Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 11 additions & 27 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,22 @@ name: Deploy web (Cloudflare Pages)
# "civfix-web". TWO environments, selected by the REF this runs on
# (issue https://github.com/civfix/issue-tracker/issues/108):
#
# push to main -> STAGING --branch=staging -> civfix.dev (api.civfix.dev, Stripe TEST)
# a published v* tag -> PRODUCTION --branch=main -> civfix.org (api.civfix.org, Stripe LIVE)
# push to main -> STAGING --branch=staging -> civfix.dev (api.civfix.dev)
# a published v* tag -> PRODUCTION --branch=main -> civfix.org (api.civfix.org)
#
# There is no `dev` branch any more: feature branches PR into main, main IS staging, and cutting a
# release promotes to production.
#
# WHY THIS IS A REBUILD AND NOT A BYTE-PROMOTION. The backend's Docker images are built once and the
# same digests are promoted to prod. A Next static export cannot work that way: every NEXT_PUBLIC_* is
# INLINED INTO THE BUNDLE at build time, so the staging artifact has api.civfix.dev and the Stripe TEST
# key compiled into it. Production is therefore rebuilt FROM THE RELEASE TAG — the same commit, with
# INLINED INTO THE BUNDLE at build time, so the staging artifact has api.civfix.dev compiled into it.
# Production is therefore rebuilt FROM THE RELEASE TAG — the same commit, with
# production values. Same source, not the same bytes; see civfix-infra/docs/DEPLOY.md §Promotion.
#
# Required repository configuration (Settings -> Secrets and variables -> Actions):
# Secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
# Variables: NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_TURNSTILE_SITEKEY,
# NEXT_PUBLIC_CARTO_API_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE (production only),
# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_TEST (staging only)
# NEXT_PUBLIC_CARTO_API_KEY
#
# On a push, only paths that feed the web build trigger a deploy; a mobile-only change never redeploys
# the site. A release always deploys, so a tag produces a complete, self-consistent production.
Expand Down Expand Up @@ -80,8 +79,8 @@ jobs:

- name: Prove the production ref (tag shape + merged into main)
# THIS IS A BRANCH-PROTECTION CONTROL, not a formality. Without it the only thing standing
# between an arbitrary commit and civfix.org — with the LIVE Stripe key — is the ability to push
# a tag. `main` requires a reviewed PR; tags do not, so a tag on an unreviewed branch would be a
# between an arbitrary commit and civfix.org is the ability to push a tag.
# `main` requires a reviewed PR; tags do not, so a tag on an unreviewed branch would be a
# complete bypass straight to production. civfix-backend's deploy.yml carries the same proof;
# keep the regex identical to that one and to CIVFIX_RELEASE_TAG_REGEX in civfix-infra.
if: env.DEPLOY_ENV == 'production'
Expand Down Expand Up @@ -135,46 +134,35 @@ jobs:
PROD_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
PROD_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}
PROD_TURNSTILE: ${{ vars.NEXT_PUBLIC_TURNSTILE_SITEKEY }}
STRIPE_LIVE: ${{ vars.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE }}
STRIPE_TEST: ${{ vars.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_TEST }}
run: |
set -euo pipefail
if [ "$DEPLOY_ENV" = "production" ]; then
api="$PROD_API_URL"; site="$PROD_SITE_URL"; turnstile="$PROD_TURNSTILE"
stripe="$STRIPE_LIVE"; pages_branch=main
pages_branch=main
else
api="https://api.civfix.dev"; site="https://civfix.dev"; turnstile=""
stripe="$STRIPE_TEST"; pages_branch=staging
pages_branch=staging
fi
# Heredoc delimiters, not `KEY=value` lines: a repo variable containing a newline would
# otherwise inject arbitrary extra KEY=VALUE pairs into the job environment.
emit() { printf '%s<<__CIVFIX_EOF__\n%s\n__CIVFIX_EOF__\n' "$1" "$2" >> "$GITHUB_ENV"; }
emit NEXT_PUBLIC_API_URL "$api"
emit NEXT_PUBLIC_SITE_URL "$site"
emit NEXT_PUBLIC_TURNSTILE_SITEKEY "$turnstile"
emit NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY "$stripe"
emit PAGES_BRANCH "$pages_branch"

- name: Assert the build targets and the keys agree
- name: Assert the build targets agree
# The one check that makes a cross-environment mix-up impossible rather than merely unlikely.
# It runs BEFORE the build, so a mismatch costs nothing, and it checks the values that were
# actually resolved — not the expressions that were supposed to resolve them.
run: |
set -euo pipefail
fail() { echo "::error::$*"; exit 1; }
case "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" in
""|pk_live_*|pk_test_*) ;;
*) fail "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is neither empty nor a pk_live_/pk_test_ key." ;;
esac
if [ "$DEPLOY_ENV" = "production" ]; then
[ "$NEXT_PUBLIC_API_URL" = "https://api.civfix.org" ] \
|| fail "production build points at '$NEXT_PUBLIC_API_URL', not https://api.civfix.org."
[ "$NEXT_PUBLIC_SITE_URL" = "https://civfix.org" ] \
|| fail "production build declares site '$NEXT_PUBLIC_SITE_URL', not https://civfix.org."
case "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" in
pk_live_*) ;;
*) fail "production build has no LIVE Stripe key (got '${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:0:8}...'). Check vars.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE." ;;
esac
[ -n "$NEXT_PUBLIC_TURNSTILE_SITEKEY" ] \
|| fail "production build has no Turnstile sitekey; signup abuse controls would be off."
[ "$PAGES_BRANCH" = "main" ] || fail "production must publish to the Pages production branch."
Expand All @@ -183,13 +171,9 @@ jobs:
https://api.civfix.dev) ;;
*) fail "staging build points at '$NEXT_PUBLIC_API_URL', not https://api.civfix.dev." ;;
esac
case "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" in
""|pk_test_*) ;;
*) fail "staging build carries a non-test Stripe key. Refusing to publish it publicly." ;;
esac
[ "$PAGES_BRANCH" != "main" ] || fail "staging must not publish to the Pages production branch."
fi
echo "$DEPLOY_ENV: api=$NEXT_PUBLIC_API_URL site=$NEXT_PUBLIC_SITE_URL stripe=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:0:8}... pages-branch=$PAGES_BRANCH"
echo "$DEPLOY_ENV: api=$NEXT_PUBLIC_API_URL site=$NEXT_PUBLIC_SITE_URL pages-branch=$PAGES_BRANCH"

- name: Build static export
# NEXT_PUBLIC_* come from $GITHUB_ENV (resolved and asserted above) and are inlined here.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,13 @@ to production only when a release is cut (issue

| Event | What happens |
| --- | --- |
| push to `main` | `deploy-web.yml` builds the web static export against `api.civfix.dev` with the Stripe TEST key and publishes it to the `staging` branch of the Cloudflare Pages project `civfix-web` (https://civfix.dev). `publish-shared.yml` publishes `@civfix/shared` if its version is ahead of the registry. |
| published `v*` release | `deploy-web.yml` rebuilds **the same commit** against `api.civfix.org` with the LIVE Stripe key and Turnstile on, and publishes it to the Pages production branch (https://civfix.org). |
| push to `main` | `deploy-web.yml` builds the web static export against `api.civfix.dev` and publishes it to the `staging` branch of the Cloudflare Pages project `civfix-web` (https://civfix.dev). `publish-shared.yml` publishes `@civfix/shared` if its version is ahead of the registry. |
| published `v*` release | `deploy-web.yml` rebuilds **the same commit** against `api.civfix.org` with Turnstile on, and publishes it to the Pages production branch (https://civfix.org). |

A static export inlines every `NEXT_PUBLIC_*` at build time, so production is a rebuild of the release
commit rather than a byte-copy of the staging artifact — unlike the backend, whose Docker images really
are promoted as-is. The workflow resolves those values once and then asserts they match the target, so a
build cannot ship a test key to civfix.org or a live key to the public preview.
build cannot ship the staging API URL to civfix.org or the production one to the public preview.

The mobile app deploys through `.github/workflows/deploy-mobile.yml` on the same lane: a push to
`main` that touches the app or the packages builds the `testflight` profile (staging API) on a
Expand Down
56 changes: 26 additions & 30 deletions apps/community-mobile/APP-REVIEW-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,37 @@ Paste the relevant section into **App Store Connect → App Review Information

---

## Donations to nonprofit event hosts

**Donations are collected outside the app, on the web, by the nonprofit — not by civfix, and not in-app.**

- Some events are hosted by independent US 501(c)(3) nonprofit organizations. On such an event
(and on that organization's page) the app shows a **Donate** button.
- Tapping it opens `https://civfix.org/donate/<organization>` in an **SFSafariViewController**
(Android: a Custom Tab) with the **address bar visible**. It is a normal web page: the reviewer can
see the URL, read it, and dismiss it. Nothing about the donation is entered, rendered or confirmed
inside the app itself.
- **No payment information is ever entered in the app.** Card entry happens on that web page, inside
Stripe's own iframe. civfix's app has no payment SDK, no Apple Pay entitlement, and declares no
payment-related privacy-manifest API.
- **The nonprofit is the merchant of record.** Funds settle directly to the organization's own Stripe
account. civfix facilitates the payment and charges a disclosed 5% platform fee; civfix never holds
the funds.
- The donation page carries the disclosures California Gov. Code §12599.9 requires (recipient, the
possibility that the charity may not receive the donation, remittance timing, fees, deductibility,
merchant of record, refund policy) before the donor can continue.
## External donation links

**civfix processes no payments anywhere in its stack. A donation link is a plain external URL a host
chose, and it opens in the browser.**

- An event host, an organization or a person may add a **donation link** to their own profile,
organization or event: an https URL to a page they run elsewhere (their own fundraiser, a
nonprofit's giving page, a payment page they own). civfix stores only the URL.
- Where such a link exists, the app shows a small **Donate** card naming who it supports and the
link's hostname. Tapping **Open donation page** opens that URL in an **SFSafariViewController**
(Android: a Custom Tab) with the **address bar visible**. It is an ordinary third-party web page:
the reviewer can see the URL, read it, and dismiss it. Nothing about a donation is entered,
rendered or confirmed inside the app.
- **No payment information is ever entered in the app, and civfix never holds or moves funds.** The
app has no payment SDK, no Apple Pay entitlement, no checkout screen and no payment-related
privacy-manifest API. civfix is not a party to whatever happens on the host's page.
- **Nothing being funded is digital content or an in-app feature.** A donation unlocks no
functionality, content, subscription or service in the app. It is a charitable contribution to a
third-party nonprofit, so **IAP does not apply** (Guidelines 3.1.1 and 3.2.2(iv); Google Play's
equivalent charitable-donation carve-out).
functionality, content, subscription or service in the app, so **IAP does not apply**
(Guidelines 3.1.1 and 3.2.2(iv); Google Play's equivalent carve-out for donations to third parties
made outside the app).

**Reviewer steps**

1. Open any event hosted by a verified nonprofit (or the organization page from that event).
2. Tap **Donate** → a Safari view opens on `civfix.org/donate/<organization>` with the address bar
showing.
3. Test card `4242 4242 4242 4242`, any future expiry, any CVC, any postcode. The donation is a real
Stripe test-mode charge on the sandbox account; nothing is charged.
4. Dismiss the Safari view with **Close** to return to the app.
1. Open an event, organization or profile that shows a **Donate** card (a host sets the link under
Settings → Account → Donation link; an organization sets it in its web settings).
2. Tap **Open donation page** → a Safari view opens on the host's own page with the address bar
showing the third-party hostname.
3. Dismiss the Safari view with **Close** to return to the app. No state in the app changes.

**If a reviewer treats the SFSafariViewController as "in-app"**, flip the app to hand donation links to
the system browser instead — no binary change and no resubmission:
**If a reviewer objects to the SFSafariViewController**, flip the app to hand donation links to the
system browser instead — no binary change and no resubmission:

```sh
EXPO_PUBLIC_DONATE_BROWSER_MODE=system eas update --branch production
Expand Down
2 changes: 1 addition & 1 deletion apps/community-mobile/APP-STORE-SUBMISSION-AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Permission↔usage parity is clean and there is **no tracking/ads/analytics SDK*
- 🟡 **M9 — Dev-only promo-notification helper still imported in prod layout.** `src/dev/promoNotification.ts` is imported + invoked in `RootLayout` (`_layout.tsx:52-54, 310-311`). Inert unless `EXPO_PUBLIC_PROMO_NOTIF==="1"`, but the file's own header says "Remove before shipping." If that env var were ever set, it fires a fake "Your report has been resolved" notification (2.3 risk). *Fix:* delete the file + import/call.
- 🟡 **M10 — Notification icon/sound are explicit placeholders.** `app.config.ts:104-110` comment: "Placeholder assets; replace with branded notification icon/sound before launch." Only a `color` is set → Android falls back to a default glyph. *Fix:* add a branded monochrome notification icon.
- 🟡 **M11 — "Donate" external links.** Two distinct surfaces now, only one of them addressed:
- **New (events overhaul, `@civfix/ui` 0.58.0):** a nonprofit event host's **Donate** CTA (`DonateBlock` on the event + organization pages) opens `https://civfix.org/donate/<orgSlug>` in an in-app Safari view (`SFSafariViewController` / Android Custom Tab, **address bar visible**, `enableBarCollapsing:false`), wired in `apps/community-mobile/app/_layout.tsx` as `openExternal.openInAppBrowser`. No payment data is entered in the app; the nonprofit is the merchant of record; the page carries the Gov. Code §12599.9 disclosures. The reviewer note (incl. the test card and the `EXPO_PUBLIC_DONATE_BROWSER_MODE=system` escape hatch) is in `apps/community-mobile/APP-REVIEW-NOTES.md`. *Nothing further to verify beyond the page being live in prod.*
- **Host donation links (`@civfix/ui` 0.60.0):** a host, organization or person can set their own external `donationUrl` (https-only, validated by the contract). It renders as a **Donate** card (`DonateBlock`) on the event page, the organization page and the profile, and opens that third-party URL in an in-app Safari view (`SFSafariViewController` / Android Custom Tab, **address bar visible**, `enableBarCollapsing:false`), wired in `apps/community-mobile/app/_layout.tsx` as `openExternal.openInAppBrowser`. civfix processes no payment anywhere in its stack: no payment SDK, no checkout page, no funds held. The reviewer note (incl. the `EXPO_PUBLIC_DONATE_BROWSER_MODE=system` escape hatch) is in `apps/community-mobile/APP-REVIEW-NOTES.md`. *Nothing further to verify.*
- **Legacy, STILL OPEN:** the About card + Settings row open `@civfix/ui` `externalUrls.ts` `DONATE_URL` = `https://reachoutla.org/help` (a third-party page; WebFetch returned HTTP 403 — bot-block, **inconclusive not confirmed-broken**). That constant lives in `@civfix/ui`, not in this repo. *Fix:* manually confirm it loads, or re-point it at civfix's own donation page. *(Terms/Privacy on the same card both returned HTTP 200 with real content — good.)*
- ⚪ **L3 — Version is `0.1.0` (pre-1.0).** Not a rejection alone, but with missing build numbers it signals a pre-release binary. *Fix:* bump to `1.0.0` + set build number.
- ⚪ **L4 — Stale comment** claims the API defaults to `localhost:8080`; the real default is `https://api.civfix.org` (`app.config.ts:14`, `src/config.ts:17`). Doc-only.
Expand Down
2 changes: 1 addition & 1 deletion apps/community-mobile/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ module.exports = ({ config }) => ({
name: "civfix",
slug: "civfix-community",
scheme: "civfix",
version: "1.2.0",
version: "1.2.1",
orientation: "portrait",
userInterfaceStyle: "automatic",
icon: "./assets/icon.png",
Expand Down
6 changes: 0 additions & 6 deletions apps/community-mobile/app/me/donations.tsx

This file was deleted.

4 changes: 2 additions & 2 deletions apps/community-mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"autoIncrement": true,
"env": {
"//api": "Dev/TestFlight testing builds bake the staging API base URL. App Store releases use the production profile, which sets no EXPO_PUBLIC_API_URL and therefore falls back to https://api.civfix.org (src/lib/apiUrl.ts).",
"//donate": "How a donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. OTA-flippable with `eas update` if App Review treats the in-app browser as in-app purchasing (see APP-REVIEW-NOTES.md).",
"//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).",
"EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app",
"EXPO_PUBLIC_API_URL": "https://api.civfix.dev",
"EXPO_PUBLIC_CARTO_API_KEY": "cb1_2800_1_9e1f147ec5d25247379fe9cf",
Expand All @@ -59,7 +59,7 @@
"autoIncrement": true,
"env": {
"//dsym": "iOS links React Native's prebuilt core frameworks (buildReactNativeFromSource: false in app.config.js), so App Store Connect's 'Upload Symbols Failed' warning for React.framework / ReactNativeDependencies.framework (alongside hermes and MapLibre) is expected and non-blocking.",
"//donate": "How a donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. OTA-flippable with `eas update` if App Review treats the in-app browser as in-app purchasing (see APP-REVIEW-NOTES.md).",
"//donate": "How a host's external donation link leaves the app: in-app = SFSafariViewController / Android Custom Tab with the address bar visible (the default when unset), system = the OS browser. The link is the host's own third-party page; no payment happens anywhere in civfix. OTA-flippable with `eas update` if App Review ever objects to the in-app browser (see APP-REVIEW-NOTES.md).",
"EXPO_PUBLIC_DONATE_BROWSER_MODE": "in-app",
"EXPO_PUBLIC_CARTO_API_KEY": "cb1_2800_1_9e1f147ec5d25247379fe9cf",
"EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID": "521996499476-d86mdmhsuopfp9gmqf7qarc2ousv6sqt.apps.googleusercontent.com",
Expand Down
2 changes: 1 addition & 1 deletion apps/community-mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "community-mobile",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"main": "expo-router/entry",
"scripts": {
Expand Down
Loading
Loading