@marktiderman/genesis-supabase 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +367 -23
  2. package/dist/blocks.d.ts +19 -0
  3. package/dist/blocks.js +3 -0
  4. package/dist/blocks.js.map +1 -0
  5. package/dist/chunk-FAA2FTF4.js +110 -0
  6. package/dist/chunk-FAA2FTF4.js.map +1 -0
  7. package/dist/chunk-T2HGFC4C.js +37 -0
  8. package/dist/chunk-T2HGFC4C.js.map +1 -0
  9. package/dist/index-BSGz3RGk.d.ts +317 -0
  10. package/dist/index.d.ts +4 -103
  11. package/dist/index.js +60 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/native.d.ts +90 -6
  14. package/dist/native.js +70 -2
  15. package/dist/native.js.map +1 -1
  16. package/functions/genesis-apple-revoke/apple.ts +237 -0
  17. package/functions/genesis-apple-revoke/handler.ts +227 -0
  18. package/functions/genesis-apple-revoke/index.ts +55 -0
  19. package/functions/genesis-push-send/expo.ts +117 -0
  20. package/functions/genesis-push-send/handler.ts +462 -0
  21. package/functions/genesis-push-send/index.ts +54 -0
  22. package/functions/genesis-support-cleanup/handler.ts +80 -0
  23. package/functions/genesis-support-cleanup/index.ts +39 -0
  24. package/functions/genesis-support-intake/contract.ts +193 -0
  25. package/functions/genesis-support-intake/handler.ts +170 -0
  26. package/functions/genesis-support-intake/index.ts +47 -0
  27. package/functions/genesis-support-notify/handler.ts +167 -0
  28. package/functions/genesis-support-notify/index.ts +15 -0
  29. package/functions/tests/genesis-apple-revoke_test.ts +81 -0
  30. package/functions/tests/genesis-push-send_test.ts +53 -0
  31. package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
  32. package/migrations/0004_genesis_tenant_features.sql +333 -0
  33. package/migrations/0005_genesis_push.sql +524 -0
  34. package/migrations/0006_genesis_support.sql +846 -0
  35. package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
  36. package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
  37. package/migrations/tests/0005_genesis_push.test.sql +407 -0
  38. package/migrations/tests/0006_genesis_support.test.sql +552 -0
  39. package/package.json +91 -4
package/README.md CHANGED
@@ -6,24 +6,26 @@ Supabase for Genesis apps on web and React Native.
6
6
  import { createGenesisSupabase, must } from "@marktiderman/genesis-supabase";
7
7
  import { keychainStorage, refreshWhileActive, wipeSessionOnReinstall } from "@marktiderman/genesis-supabase/native";
8
8
  import * as SecureStore from "expo-secure-store";
9
+ import * as ExpoCrypto from "expo-crypto";
9
10
  import AsyncStorage from "@react-native-async-storage/async-storage";
10
11
  import { AppState } from "react-native";
11
12
 
12
13
  const session = keychainStorage(SecureStore);
13
14
  await wipeSessionOnReinstall({ session, sessionKey: "sb-<project-ref>-auth-token", installMarker: AsyncStorage });
14
15
 
15
- export const supabase = createGenesisSupabase<Database>({ url, publishableKey, storage: session, platform: "native" });
16
+ export const supabase = createGenesisSupabase<Database>({ url, publishableKey, storage: session, platform: "native", crypto: ExpoCrypto });
16
17
  refreshWhileActive(supabase, AppState);
17
18
 
18
19
  const rows = must(await supabase.from("tasks").select("*"));
19
20
  ```
20
21
 
21
22
  - **`createGenesisSupabase`**: a client with PKCE sign-in, a persisted session that refreshes itself, and the storage you pass. `platform: "native"` turns off reading a session from the URL; exchange the OAuth redirect's code yourself.
23
+ - **`crypto: ExpoCrypto`** (native): Hermes has no `crypto.subtle`, and without it supabase-js sends the PKCE challenge as `plain` (the verifier itself) and, with no `crypto` at all, makes the verifier with `Math.random`. Passing `expo-crypto` installs `crypto.getRandomValues` and a SHA-256-only `crypto.subtle.digest` on `globalThis` (real WebCrypto is never replaced), so the challenge is S256. A PKCE client made with neither (on any platform: a browser page served over plain HTTP from a LAN address has no `crypto.subtle` either) throws in development and logs an error in a release build. With the polyfill in place, `auth.getClaims()` always asks the server to check the token (`getUser`), the path supabase-js itself takes without WebCrypto: the polyfill hashes, it can't verify a signature. `installWebCrypto` and `hasPkceCrypto` on `/native` do the same by hand.
22
24
  - **`keychainStorage(SecureStore)`**: session storage in the Keychain or Keystore. Values are split between characters into chunks under the Keychain's byte limit; a write switches to its new chunks only once they are all written, so a write cut short keeps the previous session, and its leftover chunks are swept on the next write; writes to one key run one at a time.
23
25
  - **`refreshWhileActive(client, AppState)`**: refreshes the session only while the app is in front.
24
26
  - **`wipeSessionOnReinstall`**: the iOS Keychain outlives an uninstall; this removes a leftover session (and its PKCE verifier) on the first launch after an install.
25
27
  - **`must(result)`**: the data, or a thrown `SupabaseQueryError` carrying the database's `code`, `details` and `hint`.
26
- - **`signInWithApple`, `completeOAuthRedirect`, `deleteMyAccount`**: see [Account](#account-sign-in-and-deletion) below.
28
+ - **`signInWithApple`, `signInWithGoogle`, `completeOAuthRedirect`, `deleteMyAccount`, `storeAppleToken`**: see [Account](#account-sign-in-and-deletion) below.
27
29
  - **`app_release_policy`**: the table the native updates block reads; see [Release policy](#release-policy-the-updates-block).
28
30
 
29
31
  Nothing here imports React Native or Expo: you pass those modules in.
@@ -32,26 +34,33 @@ Nothing here imports React Native or Expo: you pass those modules in.
32
34
 
33
35
  What the App Store asks of an app with accounts: an equivalent privacy-preserving login beside any
34
36
  other third-party sign-in (Guideline 4.8; Sign in with Apple satisfies it), Apple's own button, and
35
- deleting the account from inside the app (5.1.1(v)), including revoking the person's Apple tokens.
36
- Everything below is covered except that last server step; see [Not covered yet](#not-covered-yet).
37
+ deleting the account from inside the app (5.1.1(v)), including revoking the person's Apple tokens
38
+ ([Revoking Apple's tokens](#revoking-apples-tokens)). What isn't covered is under
39
+ [Not covered yet](#not-covered-yet).
37
40
 
38
41
  ### Sign in with Apple
39
42
 
40
43
  ```ts
41
44
  import * as AppleAuthentication from "expo-apple-authentication";
42
45
  import * as Crypto from "expo-crypto";
46
+ import Constants from "expo-constants";
47
+ import { storeAppleToken } from "@marktiderman/genesis-supabase";
43
48
  import { signInWithApple } from "@marktiderman/genesis-supabase/native";
44
49
 
45
50
  const digest = (s: string) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, s);
46
51
 
47
52
  async function onApple() {
48
- const result = await signInWithApple(supabase, AppleAuthentication, { digest, randomNonce: Crypto.randomUUID });
53
+ // The nonce is 32 random bytes from crypto.getRandomValues (createGenesisSupabase installs it on native).
54
+ const result = await signInWithApple(supabase, AppleAuthentication, { digest });
49
55
  if (result.canceled) return; // they closed Apple's sheet: not an error
50
56
  if (result.error) return showError(result.error.message); // result.error.code says why
51
57
  // Apple shares the name on the first sign-in only, so save it now.
52
58
  if (result.fullName) await supabase.auth.updateUser({ data: { full_name: result.fullName } });
53
- // For revoking on deletion: hand the single-use code to your server now (see below).
54
- if (result.authorizationCode) await supabase.functions.invoke("apple-store-token", { body: { code: result.authorizationCode } });
59
+ // For revoking on deletion: hand the single-use code to the server now (see "Revoking Apple's tokens").
60
+ if (result.authorizationCode) {
61
+ const stored = await storeAppleToken(supabase, { authorizationCode: result.authorizationCode, clientId: Constants.expoConfig?.ios?.bundleIdentifier });
62
+ if (!stored.ok) console.warn(stored.error.message); // they're signed in either way
63
+ }
55
64
  }
56
65
  ```
57
66
 
@@ -69,6 +78,40 @@ async function onApple() {
69
78
  turn on the Sign in with Apple capability (`ios.usesAppleSignIn: true` in `app.json`), and add
70
79
  `expo-apple-authentication` and `expo-crypto`.
71
80
 
81
+ ### Google, natively
82
+
83
+ ```ts
84
+ import { GoogleOneTapSignIn } from "@react-native-google-signin/google-signin";
85
+ import { signInWithGoogle } from "@marktiderman/genesis-supabase/native";
86
+
87
+ GoogleOneTapSignIn.configure({ webClientId: WEB_CLIENT_ID, iosClientId: IOS_CLIENT_ID }); // once, at start-up
88
+
89
+ async function onGoogle() {
90
+ const result = await signInWithGoogle(supabase, GoogleOneTapSignIn, { digest });
91
+ if (result.canceled) return;
92
+ if (result.error) return showError(result.error.message);
93
+ }
94
+ ```
95
+
96
+ - **`signInWithGoogle(client, GoogleOneTapSignIn, { digest, randomNonce? })`**: makes a fresh
97
+ nonce (32 random bytes), gives Google its SHA-256 (hex) through
98
+ `authenticate({ nonce })`, checks the returned ID token's `nonce` claim is that hash
99
+ (`google_nonce_missing` otherwise, and nothing is sent), then signs in with
100
+ `signInWithIdToken({ provider: "google", token, nonce })` with the raw nonce, which Supabase checks
101
+ against the token: the flow in Supabase's Google guide. Returns `{ session, user, fullName, email }`,
102
+ `{ canceled: true }` or `{ error }`; it never throws.
103
+ - **It needs the Universal sign-in module** (`GoogleOneTapSignIn`, with `authenticate`) of
104
+ `@react-native-google-signin/google-signin`, which is in the library's paid build. The free npm
105
+ build only has the original `GoogleSignin`, which can't pass a nonce on Android or iOS, so its
106
+ tokens fail the nonce check. Genesis doesn't offer a way around that: Supabase's "Skip nonce
107
+ check" would let a stolen ID token sign in, so keep it off. Without the paid build, use the
108
+ redirect below.
109
+ - Draw it with `googleProvider({ onPress: onGoogle })` from `@marktiderman/genesis-ui-native`. On
110
+ iOS, Guideline 4.8 wants Sign in with Apple beside it.
111
+ - Setup is yours: a Web client id (Supabase's Google provider takes it, plus the iOS and Android
112
+ client ids under "Authorized Client IDs"), an iOS client id, an Android client id with your
113
+ signing certificate's SHA-1, and the module's config plugin with the iOS URL scheme.
114
+
72
115
  ### Other OAuth providers (Google…): the redirect
73
116
 
74
117
  ```ts
@@ -164,25 +207,130 @@ import { deleteMyAccount } from "@marktiderman/genesis-supabase";
164
207
  />;
165
208
  ```
166
209
 
167
- - **`deleteMyAccount(client)`**: calls the `delete_my_account` RPC through `must`, then signs this
168
- device out (`scope: "local"`; the login's refresh tokens went with it). A refusal throws a
169
- `SupabaseQueryError` and keeps the session. An access token already issued stays valid until it
170
- expires.
210
+ - **`deleteMyAccount(client, { revokeAppleTokens? })`**: calls the `delete_my_account` RPC through
211
+ `must`, then signs this device out (`scope: "local"`; the login's refresh tokens went with it). A
212
+ refusal throws a `SupabaseQueryError` and keeps the session. An access token already issued stays
213
+ valid until it expires. With `revokeAppleTokens: true` (or the function's name) it asks the
214
+ `genesis-apple-revoke` function to revoke the person's Apple tokens between the two; that never
215
+ fails the call (below).
216
+
217
+ ### Revoking Apple's tokens
218
+
219
+ Apple requires an app that offers Sign in with Apple to revoke the person's tokens through its REST
220
+ API (`https://appleid.apple.com/auth/revoke`) when they delete their account, and App Review rejects
221
+ apps that don't. Supabase doesn't: the native id-token sign-in never gives Supabase an Apple refresh
222
+ token, and Auth doesn't keep the provider tokens it does see. So this package ships the server step:
223
+
224
+ - [`migrations/0003_genesis_apple_token_revocation.sql`](./migrations/0003_genesis_apple_token_revocation.sql):
225
+ `private.genesis_apple_tokens` (one refresh token per login and client id),
226
+ `private.genesis_apple_revocations` (the queue), a `before delete` trigger on `auth.users` that
227
+ moves a login's tokens into the queue, and four `public` functions only `service_role` can execute.
228
+ Its pgTAP test is `migrations/tests/0003_genesis_apple_token_revocation.test.sql`.
229
+ - [`functions/genesis-apple-revoke/`](./functions/genesis-apple-revoke): a Supabase Edge Function
230
+ (Deno). `{ action: "store", code, clientId? }` with the person's access token exchanges Apple's
231
+ authorization code at `/auth/token` and keeps the refresh token; `{ action: "revoke" }` revokes
232
+ whatever is due in the queue. `apple.ts` (the client secret, the exchange, the revocation) is plain
233
+ WebCrypto and fetch; `handler.ts` takes its dependencies, so both are unit-tested here.
234
+
235
+ **How it runs.** At sign-in the app calls `storeAppleToken(supabase, { authorizationCode, clientId })`
236
+ (the code is single-use and valid for five minutes). The function checks the access token with the
237
+ auth server, exchanges the code with a client secret it signs for that client id, refuses a code
238
+ whose Apple account (`sub`) isn't one linked to the caller (and revokes the token that exchange
239
+ minted), and stores the refresh token. Deleting the login, by `delete_my_account`, the dashboard or
240
+ the admin API, fires the trigger, which queues its tokens in the same transaction. The app's
241
+ `deleteMyAccount(supabase, { revokeAppleTokens: true })` then asks the function to work through the
242
+ queue at once (it answers 202 straight away and finishes in the background), and a schedule retries
243
+ what's left.
244
+
245
+ **Each revocation is proved first.** Apple answers 200 to `/auth/revoke` for a token it doesn't
246
+ know even when the client secret is garbage (seen 2026-10-24), so that 200 alone proves nothing.
247
+ The function first checks the token at `/auth/token` (`grant_type=refresh_token`): a 200 means the
248
+ secrets are right and the token is live, so it revokes it and forgets the row only on a 200 from
249
+ `/auth/revoke`; `invalid_grant` means the token is already dead (the person revoked it, or it
250
+ expired), so there's nothing left to revoke and the row goes; anything else (`invalid_client` for
251
+ wrong secrets, Apple down) is retried.
252
+
253
+ **When Apple fails, the account is deleted anyway.** The person asked for their data to go, and the
254
+ GDPR's right to erasure doesn't wait on a third party's API; Apple asks for revocation, not for
255
+ deletion to block on it. So deletion never calls Apple: a refused delete queues nothing (the
256
+ trigger runs in its transaction), a committed one can't lose its token, and the function retries
257
+ each queued token with backoff (five minutes, doubling, at most a day apart). After 30 failed
258
+ attempts (about four weeks) it gives up: the token is dropped, so a deleted person's token isn't
259
+ kept for ever, and the row stays with `last_error` starting `gave_up`. A deleted login that signed in
260
+ with Apple but has no stored token (the store step failed, or the account predates this) leaves a
261
+ row with `last_error` starting `no_token`. Every failure is logged with Apple's error code; any row
262
+ still in `private.genesis_apple_revocations` is one to look at.
263
+
264
+ For a login with no stored token, the only fix is Apple's own: have the person sign in with Apple
265
+ again at deletion, store the fresh code, then delete. This package doesn't do that for you.
266
+
267
+ **Who can see a token.** Nobody but the migration's functions: the tables are in `private` (not in
268
+ the Data API), with row level security on, no policies, and every table privilege revoked from anon
269
+ and authenticated. The four `public` functions are granted to `service_role` by name and revoked
270
+ from `public`, `anon` and `authenticated` by name; a later blanket `grant … on all functions` would
271
+ hand them back, so such a grant must exclude them. The column isn't encrypted: an Apple refresh
272
+ token is useless without a client secret signed with your .p8 key, which lives in the function's
273
+ secrets, not the database. The function never returns or logs a token, a key or Apple's response
274
+ body.
275
+
276
+ **Setup:**
277
+
278
+ 1. In Apple Developer, under Keys, create a key with Sign in with Apple enabled for your App ID, and
279
+ download the .p8 (once). Note its Key ID and your Team ID.
280
+ 2. Copy the migration into `supabase/migrations/` under a new timestamped name, its test into
281
+ `supabase/tests/database/`, and `functions/genesis-apple-revoke/` into `supabase/functions/`.
282
+ 3. Set the secrets (for a local stack, put the same names in `supabase/functions/.env`):
283
+ ```bash
284
+ supabase secrets set APPLE_TEAM_ID=ABCDE12345 APPLE_KEY_ID=FGHIJ67890 \
285
+ APPLE_CLIENT_IDS=com.example.app,com.example.app.dev APPLE_PRIVATE_KEY="$(cat AuthKey_FGHIJ67890.p8)"
286
+ ```
287
+ `APPLE_CLIENT_IDS` lists every bundle id (or Services ID) the app signs in with, each build
288
+ variant too (`.dev`, `.preview`); a store request names one of them, or gets the first, and one
289
+ outside the list is refused (`unknown_client`, logged). Keep the .p8 out of the repo.
290
+ 4. Deploy without the gateway's JWT check: the store action checks the caller itself, and the
291
+ revoke action runs after the login is gone and needs no caller. Anyone can call it, but all it
292
+ does is work through rows already due, 20 at a time, each at most once per backoff step, and
293
+ answer `{ ok: true }`; repeat calls find nothing due.
294
+ ```toml
295
+ # supabase/config.toml
296
+ [functions.genesis-apple-revoke]
297
+ verify_jwt = false
298
+ ```
299
+ then `supabase functions deploy genesis-apple-revoke`.
300
+ 5. Schedule the retries (Supabase's pg_cron and pg_net; once per project, not in a migration
301
+ because the URL differs per project):
302
+ ```sql
303
+ select cron.schedule('genesis-apple-revoke', '*/10 * * * *', $$
304
+ select net.http_post(
305
+ url := 'https://<project-ref>.supabase.co/functions/v1/genesis-apple-revoke',
306
+ headers := '{"content-type": "application/json"}'::jsonb,
307
+ body := '{"action": "revoke"}'::jsonb)
308
+ $$);
309
+ ```
310
+ 6. Wire the app: `storeAppleToken` after `signInWithApple` (above) and
311
+ `deleteMyAccount(supabase, { revokeAppleTokens: true })`.
312
+ 7. Before release, sign in with Apple on a device build and check a row appears in
313
+ `private.genesis_apple_tokens`; then delete the account and check the queue empties and the app
314
+ is gone from Settings → Apple ID → Sign in with Apple on the device.
315
+
316
+ To watch it: `select id, client_id, attempts, last_error, queued_at from private.genesis_apple_revocations order by queued_at;`
317
+ (no tokens in that select). `check: invalid_client` means the secrets don't match the client id;
318
+ `gave_up` and `no_token` rows are tokens that were never revoked.
319
+
320
+ Each sign-in replaces the stored token for that client id without revoking the old one, which would
321
+ end the authorization the new one belongs to (if Apple's revocation covers the whole authorization,
322
+ which isn't verified here).
171
323
 
172
324
  ### Not covered yet
173
325
 
174
- - **Revoking Sign in with Apple tokens on deletion.** Apple requires an app that offers Sign in
175
- with Apple to revoke the person's tokens through its REST API when they delete their account, and
176
- App Review rejects apps that don't. Supabase doesn't do it, and the native id-token sign-in never
177
- gives Supabase an Apple refresh token, so it takes a server step this package doesn't ship yet:
178
- 1. At sign-in, the app sends `result.authorizationCode` to an Edge Function. It is single-use and
179
- valid for about five minutes.
180
- 2. The function exchanges it at `https://appleid.apple.com/auth/token`
181
- (`grant_type=authorization_code`, with your client id and a client secret JWT signed with your
182
- Sign in with Apple key) and stores the returned `refresh_token` against the user, somewhere
183
- only the service role reads.
184
- 3. Before deletion, a function calls `https://appleid.apple.com/auth/revoke` with that refresh
185
- token (`token_type_hint=refresh_token`), then calls `delete_my_account` (or the app does).
326
+ - **Tried against Apple with a real key.** The client secret, the request shapes and the failure
327
+ paths are unit-tested with a throwaway P-256 key and a mocked Apple, and the SQL with pgTAP, but
328
+ no real Sign in with Apple key has been through the exchange and revocation yet (setup step 7).
329
+ That Apple answers `invalid_client` (not `invalid_grant`) to a live token with wrong secrets is
330
+ expected, not observed.
331
+ - **Sign in with Apple on the web** (Services ID and redirect) isn't wired: the function accepts a
332
+ Services ID in `APPLE_CLIENT_IDS`, but exchanges codes without a `redirect_uri`, as a native
333
+ sign-in needs.
186
334
 
187
335
  ## Release policy (the updates block)
188
336
 
@@ -204,6 +352,202 @@ and release notes (`notes`, an array of `{ version, title?, body }`). Copy it in
204
352
  - **Read it** with `supabasePolicySource(supabase)` from `@marktiderman/genesis-native/updates`,
205
353
  which fails open: no row, a failed read or a malformed value never blocks anyone.
206
354
 
355
+ ## Push (the push block)
356
+
357
+ [`migrations/0005_genesis_push.sql`](./migrations/0005_genesis_push.sql) and the
358
+ [`genesis-push-send`](./functions/genesis-push-send) Edge Function are the server half of
359
+ `@marktiderman/genesis-native/push`. Copy the migration into your app's `supabase/migrations/`
360
+ under a new timestamped name, its pgTAP test (`migrations/tests/0005_genesis_push.test.sql`) into
361
+ `supabase/tests/database/`, and the function folder into `supabase/functions/`.
362
+
363
+ - **Tables**, all with row level security on and owner-only policies:
364
+ `push_devices` (user, Expo token, platform, app version, last seen; read and deleted by the
365
+ owner, written only through `genesis_push_register`), `notification_preferences` (category
366
+ switches, quiet hours with an IANA time zone checked on write, `hide_previews`; the owner reads
367
+ and writes their own row) and `push_receipts` (category, Expo ticket id, status and an Expo
368
+ error code, never content; the owner reads, the sender writes; kept 30 days). Deleting a login
369
+ deletes all three.
370
+ - **Who can call what**, granted and revoked by name: `genesis_push_register` (authenticated, its
371
+ own token; a token another login held moves, and ten devices per person are kept),
372
+ `genesis_push_forget(token)` (anon and authenticated: the phone may already be signed out), and
373
+ for the service role only `genesis_push_status`, `genesis_push_targets`,
374
+ `genesis_push_tickets_record`, `genesis_push_receipts_claim`, `genesis_push_receipts_settle` and
375
+ `genesis_push_devices_forget`.
376
+ - **Who to push to:** `genesis_push_targets(user, category, default_on, bypass_quiet_hours, at)`
377
+ returns the person's devices, or none when they turned the category off or are in quiet hours.
378
+ Quiet hours are read in their time zone: each night's window runs from the first moment the
379
+ local clock reads the start to the first moment after it that it reads the end (past midnight
380
+ when the start is later), so a repeated fall-back hour doesn't restart it and a skipped
381
+ spring-forward end time ends it at the change. A start equal to the end is no quiet hours, and a
382
+ zone the server no longer knows never silences anyone.
383
+ `genesis_push_status` says why and until when (`quiet_until`), so a caller can send later.
384
+ - **An Expo push token is a credential.** Only its owner and the service role can read one. Turn
385
+ on enhanced push security in your Expo project and give the function `EXPO_ACCESS_TOKEN`, so a
386
+ leaked token alone can't push to a phone.
387
+
388
+ ### Sending
389
+
390
+ Deploy `genesis-push-send` with `--no-verify-jwt` (the starter's `config.toml` sets
391
+ `verify_jwt = false`) and set `GENESIS_PUSH_SECRET` (32 characters or more), plus
392
+ `EXPO_ACCESS_TOKEN`, and optionally `PUSH_HIDDEN_TITLE` / `PUSH_HIDDEN_BODY` (what someone who hides
393
+ previews sees; default "New notification"). Call it from your server, a database trigger
394
+ (`pg_net`) or a schedule, never from the app:
395
+
396
+ ```http
397
+ POST /functions/v1/genesis-push-send
398
+ Authorization: Bearer <GENESIS_PUSH_SECRET>
399
+
400
+ { "action": "send", "messages": [
401
+ { "userId": "…", "category": "messages", "title": "Sam", "body": "See you at six",
402
+ "url": "/messages/42", "badge": 3 } ] }
403
+ ```
404
+
405
+ Up to 100 messages a request, each at most 3500 bytes of title, subtitle, body, url and data
406
+ (UTF-8, so it fits Expo's 4 KB); each may set `subtitle`, `data` (ids, not content), `sound: null`,
407
+ `defaultOn`, `urgent` (through quiet hours), `channelId` and `ttlSeconds`. The answer has one result
408
+ per message (`deliver`, `reason`: `ok`, `category_off`, `quiet_hours`, `no_devices`; `quietUntil`;
409
+ `sent`, `failed`). It sends through Expo in batches of 100, resends a batch Expo refused outright
410
+ one by one (so one bad token can't silence the rest), never resends one that timed out or got a
411
+ 429 or 5xx (it may have been delivered), records the receipt rows of each batch as it goes and
412
+ forgets the devices Expo calls `DeviceNotRegistered`. It starts no send after 100 seconds, so a
413
+ large or refused run ends inside the function's wall clock; what it didn't send is answered as
414
+ `unsent` (send those again). For someone who hides previews, the title and body are replaced and
415
+ the subtitle and `data` dropped on the server; the tap still opens `url`, and `category` still
416
+ travels, so keep both opaque (ids, never words or search text).
417
+
418
+ Schedule `{ "action": "receipts" }` every 15 minutes (pg_cron with pg_net, or any scheduler): it
419
+ claims the tickets that are due (1000 at a time, again while full claims come back, within the
420
+ same time budget), asks Expo for their receipts, records delivered or failed, forgets dead devices
421
+ (not one registered again since the push; the same run marks tickets with no receipt after a day
422
+ `unknown` and deletes receipts older than 30 days, so without the schedule `push_receipts` keeps
423
+ growing), and logs `InvalidCredentials` loudly (your APNs or FCM credentials in EAS are wrong).
424
+ Logs and answers carry counts, statuses and Expo's error codes only: never a token, a title, a
425
+ body or `data`.
426
+
427
+ ### Not covered yet
428
+
429
+ - **Real delivery.** The SQL is pgTAP-tested and the function unit-tested against a faked Expo;
430
+ nothing has been delivered through APNs or FCM (that needs an EAS project with push credentials).
431
+ - **Scheduling for later.** A message held by quiet hours is answered with `quietUntil`, not queued:
432
+ resend it then if it still matters.
433
+
434
+ ## Support (the support block)
435
+
436
+ `migrations/0006_genesis_support.sql` is the database half of `@marktiderman/genesis-support`:
437
+ reports (`support_reports`), their screenshots (`support_attachments`, in the private
438
+ `genesis-support` bucket under `<user id>/<report id>/`), the conversation on each
439
+ (`support_replies`), the idea board (`support_ideas`) and one vote per person and idea
440
+ (`support_idea_votes`). Copy it and its test (`migrations/tests/0006_genesis_support.test.sql`)
441
+ into your app under a new timestamp; it stands alone.
442
+
443
+ - **Who sees what.** A person sees, edits while open and deletes their own reports, replies on
444
+ them and sees their own screenshots. Every signed-in person reads the idea board; a person sees
445
+ only their own votes, and the count is kept on the idea by a trigger. anon reaches nothing.
446
+ - **Staff** are whoever `private.genesis_support_staff(person uuid) returns boolean` says, a hook
447
+ you add (like the tenant-admin hook of 0004). Without it nobody is staff. Staff read every report
448
+ and screenshot, reply, and set statuses through `support_set_report_status` and
449
+ `support_set_idea_status` (shipped stamps `shipped_at` and takes the FeatureId it shipped as).
450
+ - **The server's columns.** Status, owner, feature id, read receipts and vote counts can't be
451
+ written by a person (column privileges); a reply's author and staff flag come from the session.
452
+ - **Vote stuffing.** One vote per person and idea (the primary key); a Supabase anonymous sign-in
453
+ (`is_anonymous`) can report but never vote or suggest; votes only on open, planned and in
454
+ progress ideas; at most 10 ideas per person a day.
455
+ - **Screenshots.** Uploads only into the person's own open report's folder, no overwrite and no
456
+ delete by anyone but the service role; images only, 5 MB each, at most 5 per report. Deleting a
457
+ report or an account queues its objects in `support_attachment_purges` (Supabase refuses direct
458
+ deletes from `storage.objects`); the cleanup function removes them.
459
+ - **Limits** raise SQLSTATE `GS429`: 20 reports and 60 replies per person an hour, counted in a
460
+ log that deleting doesn't touch, under a per-person lock.
461
+ - **Whatever else the app allows on storage**, restrictive policies hold the bucket to these rules
462
+ for every role but the service role. A deleted report's screenshots can't be opened again, by
463
+ staff or the person, before the cleanup removes them.
464
+ - **The notify sinks keep copies.** An issue or webhook made for a report isn't withdrawn when the
465
+ report or the account is deleted: point the GitHub sink at a private repository and delete issues
466
+ yourself when someone asks to be forgotten.
467
+
468
+ Functions (templates; copy the folder into `supabase/functions/`):
469
+
470
+ | Function | What it does | Secrets |
471
+ |---|---|---|
472
+ | `genesis-support-intake` | The wire contract over HTTP (boot, submit, list) for clients without supabase-js; acts as the caller, never the service role | `GENESIS_SUPPORT_ORIGINS` (web origins) |
473
+ | `genesis-support-notify` | A Database Webhook on new reports: a GitHub issue per report and/or a signed webhook, with a summary only (no person, device, context or screenshot) | `GENESIS_SUPPORT_WEBHOOK_SECRET`, `GITHUB_TOKEN` + `GITHUB_REPO`, `SUPPORT_WEBHOOK_URL` (+ `SUPPORT_WEBHOOK_SIGNING_SECRET`) |
474
+ | `genesis-support-cleanup` | On a schedule: removes queued and orphaned screenshots through the Storage API | `GENESIS_SUPPORT_CLEANUP_SECRET` |
475
+
476
+ All three deploy with `verify_jwt = false` (each checks its caller or secret itself).
477
+
478
+ ### Not covered yet
479
+
480
+ - Not applied to a hosted project yet: the SQL is pgTAP-tested on a local database and the
481
+ functions unit-tested with fakes.
482
+ - Staff screens, Realtime, and an email when staff reply.
483
+
484
+ ## Tenant features
485
+
486
+ Per-tenant switches for shipped features: a tenant's admins turn a feature off (or back on) for
487
+ their tenant with a reason, and its members hide what is off. "Tenant" is whatever your app groups
488
+ people by (a team, an organization, a workspace), keyed by a uuid. A switch controls exposure,
489
+ never authorization (that is RLS) and never paid access (that is an entitlement).
490
+
491
+ Copy `migrations/0004_genesis_tenant_features.sql` into `supabase/migrations/` under a timestamped
492
+ name, and its pgTAP test into `supabase/tests/database/`. Then give it your membership rule as a
493
+ hook, in your own migration:
494
+
495
+ ```sql
496
+ create function private.genesis_tenant_admin(tenant uuid, person uuid) returns boolean
497
+ language sql stable set search_path = '' as $$
498
+ select exists (select 1 from public.memberships m
499
+ where m.team_id = tenant and m.user_id = person and m.role in ('owner', 'admin'))
500
+ $$;
501
+ revoke execute on function private.genesis_tenant_admin(uuid, uuid) from public, anon, authenticated;
502
+ -- Optional: who may read which features are off (admins always may).
503
+ -- create function private.genesis_tenant_member(tenant uuid, person uuid) returns boolean …
504
+ alter table public.tenant_features add foreign key (tenant_id) references public.teams (id) on delete cascade;
505
+ alter table public.tenant_feature_changes add foreign key (tenant_id) references public.teams (id) on delete cascade;
506
+ ```
507
+
508
+ Without the admin hook nobody can change a switch (`42501`, with a hint): it fails closed.
509
+
510
+ ```ts
511
+ import { setTenantFeature, tenantFeatures, tenantFeatureHistory } from "@marktiderman/genesis-supabase";
512
+
513
+ const features = await tenantFeatures(supabase, teamId); // members and admins; 42501 for anyone else
514
+ if (features.isOn("chat")) showChatTab();
515
+
516
+ await setTenantFeature(supabase, { tenantId: teamId, featureId: "chat", enabled: false, reason: "Launching next month" });
517
+ const history = await tenantFeatureHistory(supabase, teamId); // admins: who, when, why, newest first
518
+ ```
519
+
520
+ | | |
521
+ | --- | --- |
522
+ | `public.set_tenant_feature(tenant, feature, enabled, reason)` | admins only (the hook); a reason of 1 to 500 characters; a FeatureId-shaped id |
523
+ | `public.tenant_features_off(tenant)` | the ids that are off; members (`private.genesis_tenant_member`) and admins |
524
+ | `public.tenant_feature_settings(tenant)` | every switch with its reason, who and when; admins |
525
+ | `public.tenant_feature_history(tenant, max_rows)` | every change, newest first; admins |
526
+ | `private.tenant_feature_on(tenant, feature)` | for SQL gates; a feature with no row is on |
527
+
528
+ Every change, through the function or by the service role writing the table directly, is
529
+ recorded by a trigger in `public.tenant_feature_changes` with the person (none for the service
530
+ role or a migration), the JWT role and the time. The tables have row level security on with no
531
+ policies and no grants to anon or authenticated: they are reached only through the functions,
532
+ each security definer with an empty `search_path`, granted and revoked by name. Deleting a
533
+ person never fails on these tables; their changes stay, without them.
534
+
535
+ `private.tenant_feature_on` answers for any tenant, without the member check, so it is granted to
536
+ nobody but the service role: call it from your own security definer functions (owned by the
537
+ migration's role, they need no grant). Granting it to authenticated, with usage on `private`, lets
538
+ every signed-in person ask about any tenant.
539
+
540
+ A delete that cascades into `tenant_features` (from your tenant table, or a person whose tenant
541
+ goes with them) isn't recorded, so it never refuses the tenant's or the person's deletion; saving
542
+ the same switch and reason again changes nothing, not even who and when.
543
+
544
+ ## Blocks
545
+
546
+ `accountBlock` and `tenantFeaturesBlock` are `defineBlock` declarations
547
+ (`@marktiderman/genesis-features/blocks`) of what this package's blocks need: their migrations
548
+ (with their tests) and, for the account, what it means for the stores' privacy forms. The
549
+ functions above don't change; the declarations are for `resolveBlocks` and the shell.
550
+
207
551
  ## Choices, and why
208
552
 
209
553
  - **Keychain chunks rather than encrypted AsyncStorage.** Supabase's Expo guide shows two options: an AES-encrypted value in AsyncStorage with the key in SecureStore ("LargeSecureStore"), or `expo-sqlite/localStorage`. `keychainStorage` keeps the whole session in the Keychain/Keystore instead, split into chunks under the item size limit, with an atomic switch between generations. That means one native dependency (`expo-secure-store`) rather than three, no key to manage, and a session that can't be read off the device's file system. If you prefer LargeSecureStore, pass it as `storage`; everything else here works the same.
@@ -0,0 +1,19 @@
1
+ import * as _marktiderman_genesis_features_blocks from '@marktiderman/genesis-features/blocks';
2
+
3
+ /**
4
+ * The account block: sign-in, profile and in-app account deletion. Its
5
+ * screens are the app's (or genesis-ui-native's `AuthScreen`); this names its
6
+ * migrations and its data.
7
+ *
8
+ * @stability Beta
9
+ */
10
+ declare const accountBlock: _marktiderman_genesis_features_blocks.BlockDef<"account", never>;
11
+ /**
12
+ * The tenant features block: per-tenant on/off switches for shipped
13
+ * features, set by the tenant's admins with a reason.
14
+ *
15
+ * @stability Beta
16
+ */
17
+ declare const tenantFeaturesBlock: _marktiderman_genesis_features_blocks.BlockDef<"tenant-features", never>;
18
+
19
+ export { accountBlock, tenantFeaturesBlock };
package/dist/blocks.js ADDED
@@ -0,0 +1,3 @@
1
+ export { accountBlock, tenantFeaturesBlock } from './chunk-T2HGFC4C.js';
2
+ //# sourceMappingURL=blocks.js.map
3
+ //# sourceMappingURL=blocks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"blocks.js"}
@@ -0,0 +1,110 @@
1
+ // src/web-crypto.ts
2
+ var POLYFILL = /* @__PURE__ */ Symbol.for("genesis.webCryptoPolyfill");
3
+ var MAX_RANDOM_BYTES = 65536;
4
+ function algorithmName(algorithm) {
5
+ const name = typeof algorithm === "string" ? algorithm : algorithm?.name;
6
+ return typeof name === "string" ? name.toUpperCase() : "";
7
+ }
8
+ var INTEGER_ARRAYS = ["Int8Array", "Uint8Array", "Uint8ClampedArray", "Int16Array", "Uint16Array", "Int32Array", "Uint32Array", "BigInt64Array", "BigUint64Array"];
9
+ function isIntegerArray(value) {
10
+ return ArrayBuffer.isView(value) && INTEGER_ARRAYS.includes(Object.prototype.toString.call(value).slice(8, -1));
11
+ }
12
+ function cryptoOf(target) {
13
+ return target.crypto;
14
+ }
15
+ function hasPkceCrypto(target = globalThis) {
16
+ const g = target;
17
+ const crypto = cryptoOf(target);
18
+ return typeof crypto?.getRandomValues === "function" && typeof crypto?.subtle?.digest === "function" && typeof g.TextEncoder === "function" && typeof g.btoa === "function";
19
+ }
20
+ function installWebCrypto(expoCrypto, target = globalThis) {
21
+ if (typeof expoCrypto?.getRandomValues !== "function" || typeof expoCrypto?.digest !== "function") {
22
+ throw new TypeError('installWebCrypto needs expo-crypto: pass `import * as ExpoCrypto from "expo-crypto"`.');
23
+ }
24
+ const existing = cryptoOf(target);
25
+ const realRandom = existing?.getRandomValues;
26
+ const realSubtle = existing?.subtle;
27
+ if (typeof realRandom === "function" && typeof realSubtle?.digest === "function") {
28
+ return existing[POLYFILL] ? "polyfill" : "native";
29
+ }
30
+ const getRandomValues = typeof realRandom === "function" ? realRandom.bind(existing) : (array) => {
31
+ if (!isIntegerArray(array)) throw new TypeError("crypto.getRandomValues: expected an integer TypedArray.");
32
+ if (array.byteLength > MAX_RANDOM_BYTES) {
33
+ throw new RangeError(`crypto.getRandomValues: ${array.byteLength} bytes is over the ${MAX_RANDOM_BYTES}-byte quota.`);
34
+ }
35
+ const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
36
+ const filled = expoCrypto.getRandomValues(bytes);
37
+ if (filled !== bytes) {
38
+ if (!(filled instanceof Uint8Array) || filled.byteLength !== bytes.byteLength) {
39
+ throw new Error("crypto.getRandomValues: expo-crypto returned no random bytes.");
40
+ }
41
+ bytes.set(filled);
42
+ }
43
+ return array;
44
+ };
45
+ const digest = async (algorithm, data) => {
46
+ const name = algorithmName(algorithm);
47
+ if (name !== "SHA-256") throw new Error(`crypto.subtle.digest: only SHA-256 is polyfilled, not ${name || String(algorithm)}.`);
48
+ let bytes;
49
+ if (ArrayBuffer.isView(data)) bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
50
+ else if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
51
+ else throw new TypeError("crypto.subtle.digest: expected an ArrayBuffer or a TypedArray.");
52
+ return expoCrypto.digest("SHA-256", bytes.slice());
53
+ };
54
+ const subtle = typeof realSubtle?.digest === "function" ? realSubtle : Object.assign(Object.create(realSubtle ?? Object.prototype), { digest });
55
+ const crypto = Object.assign(Object.create(existing ?? Object.prototype), { getRandomValues, subtle, [POLYFILL]: true });
56
+ Object.defineProperty(target, "crypto", { value: crypto, configurable: true, enumerable: false, writable: true });
57
+ return "polyfill";
58
+ }
59
+ function isDigestOnlyPolyfill(target = globalThis) {
60
+ const crypto = cryptoOf(target);
61
+ return Boolean(crypto?.[POLYFILL]) && typeof crypto?.subtle?.importKey !== "function";
62
+ }
63
+ function decodeJwt(token) {
64
+ const parts = token.split(".");
65
+ if (parts.length !== 3 || parts.some((p) => !/^[A-Za-z0-9_-]*$/.test(p))) return null;
66
+ const bytes = (part) => {
67
+ const binary = atob(part.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((part.length + 3) % 4));
68
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
69
+ };
70
+ try {
71
+ const json = (part) => JSON.parse(new TextDecoder().decode(bytes(part)));
72
+ return { header: json(parts[0]), payload: json(parts[1]), signature: bytes(parts[2]) };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ function claimsViaServer(auth, invalidJwt) {
78
+ return async (jwt, options = {}) => {
79
+ let token = jwt;
80
+ if (!token) {
81
+ const { data, error: error2 } = await auth.getSession();
82
+ if (error2 || !data.session) return { data: null, error: error2 ?? null };
83
+ token = data.session.access_token;
84
+ }
85
+ const decoded = decodeJwt(token);
86
+ if (!decoded) return { data: null, error: invalidJwt("Invalid JWT structure") };
87
+ if (!options.allowExpired) {
88
+ const exp = decoded.payload.exp;
89
+ if (typeof exp !== "number") return { data: null, error: invalidJwt("Missing exp claim") };
90
+ if (exp <= Math.floor(Date.now() / 1e3)) return { data: null, error: invalidJwt("JWT has expired") };
91
+ }
92
+ const { error } = await auth.getUser(token);
93
+ if (error) return { data: null, error };
94
+ return { data: { claims: decoded.payload, header: decoded.header, signature: decoded.signature }, error: null };
95
+ };
96
+ }
97
+ function isDev() {
98
+ if (typeof __DEV__ !== "undefined") return Boolean(__DEV__);
99
+ return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
100
+ }
101
+ function assertPkceCrypto(target = globalThis) {
102
+ if (hasPkceCrypto(target)) return;
103
+ const message = 'Genesis Supabase: this runtime has no WebCrypto (crypto.getRandomValues and crypto.subtle.digest), so supabase-js would send PKCE challenges as "plain". Pass expo-crypto: createGenesisSupabase({ platform: "native", crypto: ExpoCrypto, \u2026 }) with `import * as ExpoCrypto from "expo-crypto"`.';
104
+ if (isDev()) throw new Error(message);
105
+ console.error(message);
106
+ }
107
+
108
+ export { assertPkceCrypto, claimsViaServer, hasPkceCrypto, installWebCrypto, isDigestOnlyPolyfill };
109
+ //# sourceMappingURL=chunk-FAA2FTF4.js.map
110
+ //# sourceMappingURL=chunk-FAA2FTF4.js.map