@marktiderman/genesis-supabase 0.3.1 → 0.5.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.
package/README.md CHANGED
@@ -4,16 +4,30 @@ Supabase for Genesis apps on web and React Native.
4
4
 
5
5
  ```ts
6
6
  import { createGenesisSupabase, must } from "@marktiderman/genesis-supabase";
7
- import { keychainStorage, refreshWhileActive, wipeSessionOnReinstall } from "@marktiderman/genesis-supabase/native";
7
+ import {
8
+ keychainStorage,
9
+ refreshWhileActive,
10
+ wipeSessionOnReinstall,
11
+ } from "@marktiderman/genesis-supabase/native";
8
12
  import * as SecureStore from "expo-secure-store";
9
13
  import * as ExpoCrypto from "expo-crypto";
10
14
  import AsyncStorage from "@react-native-async-storage/async-storage";
11
15
  import { AppState } from "react-native";
12
16
 
13
17
  const session = keychainStorage(SecureStore);
14
- await wipeSessionOnReinstall({ session, sessionKey: "sb-<project-ref>-auth-token", installMarker: AsyncStorage });
15
-
16
- export const supabase = createGenesisSupabase<Database>({ url, publishableKey, storage: session, platform: "native", crypto: ExpoCrypto });
18
+ await wipeSessionOnReinstall({
19
+ session,
20
+ sessionKey: "sb-<project-ref>-auth-token",
21
+ installMarker: AsyncStorage,
22
+ });
23
+
24
+ export const supabase = createGenesisSupabase<Database>({
25
+ url,
26
+ publishableKey,
27
+ storage: session,
28
+ platform: "native",
29
+ crypto: ExpoCrypto,
30
+ });
17
31
  refreshWhileActive(supabase, AppState);
18
32
 
19
33
  const rows = must(await supabase.from("tasks").select("*"));
@@ -47,18 +61,25 @@ import Constants from "expo-constants";
47
61
  import { storeAppleToken } from "@marktiderman/genesis-supabase";
48
62
  import { signInWithApple } from "@marktiderman/genesis-supabase/native";
49
63
 
50
- const digest = (s: string) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, s);
64
+ const digest = (s: string) =>
65
+ Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, s);
51
66
 
52
67
  async function onApple() {
53
68
  // The nonce is 32 random bytes from crypto.getRandomValues (createGenesisSupabase installs it on native).
54
- const result = await signInWithApple(supabase, AppleAuthentication, { digest });
69
+ const result = await signInWithApple(supabase, AppleAuthentication, {
70
+ digest,
71
+ });
55
72
  if (result.canceled) return; // they closed Apple's sheet: not an error
56
73
  if (result.error) return showError(result.error.message); // result.error.code says why
57
74
  // Apple shares the name on the first sign-in only, so save it now.
58
- if (result.fullName) await supabase.auth.updateUser({ data: { full_name: result.fullName } });
75
+ if (result.fullName)
76
+ await supabase.auth.updateUser({ data: { full_name: result.fullName } });
59
77
  // For revoking on deletion: hand the single-use code to the server now (see "Revoking Apple's tokens").
60
78
  if (result.authorizationCode) {
61
- const stored = await storeAppleToken(supabase, { authorizationCode: result.authorizationCode, clientId: Constants.expoConfig?.ios?.bundleIdentifier });
79
+ const stored = await storeAppleToken(supabase, {
80
+ authorizationCode: result.authorizationCode,
81
+ clientId: Constants.expoConfig?.ios?.bundleIdentifier,
82
+ });
62
83
  if (!stored.ok) console.warn(stored.error.message); // they're signed in either way
63
84
  }
64
85
  }
@@ -84,10 +105,15 @@ async function onApple() {
84
105
  import { GoogleOneTapSignIn } from "@react-native-google-signin/google-signin";
85
106
  import { signInWithGoogle } from "@marktiderman/genesis-supabase/native";
86
107
 
87
- GoogleOneTapSignIn.configure({ webClientId: WEB_CLIENT_ID, iosClientId: IOS_CLIENT_ID }); // once, at start-up
108
+ GoogleOneTapSignIn.configure({
109
+ webClientId: WEB_CLIENT_ID,
110
+ iosClientId: IOS_CLIENT_ID,
111
+ }); // once, at start-up
88
112
 
89
113
  async function onGoogle() {
90
- const result = await signInWithGoogle(supabase, GoogleOneTapSignIn, { digest });
114
+ const result = await signInWithGoogle(supabase, GoogleOneTapSignIn, {
115
+ digest,
116
+ });
91
117
  if (result.canceled) return;
92
118
  if (result.error) return showError(result.error.message);
93
119
  }
@@ -119,11 +145,15 @@ import * as WebBrowser from "expo-web-browser";
119
145
  import { completeOAuthRedirect } from "@marktiderman/genesis-supabase/native";
120
146
 
121
147
  const redirectTo = "myapp://auth/callback";
122
- const { data } = await supabase.auth.signInWithOAuth({ provider: "google", options: { redirectTo, skipBrowserRedirect: true } });
148
+ const { data } = await supabase.auth.signInWithOAuth({
149
+ provider: "google",
150
+ options: { redirectTo, skipBrowserRedirect: true },
151
+ });
123
152
  const res = await WebBrowser.openAuthSessionAsync(data.url!, redirectTo);
124
153
  if (res.type === "success") {
125
154
  const result = await completeOAuthRedirect(supabase, res.url);
126
- if (result.error) showError(result.error.message); // never result.error.providerMessage
155
+ if (result.error)
156
+ showError(result.error.message); // never result.error.providerMessage
127
157
  else if (result.redirectType === "recovery") router.push("/set-password");
128
158
  }
129
159
  ```
@@ -163,12 +193,12 @@ test is `migrations/tests/0001_genesis_account_deletion.test.sql`: copy it to
163
193
 
164
194
  It refuses, by error code (`SupabaseQueryError.code` on the client):
165
195
 
166
- | Code | When |
167
- |---|---|
196
+ | Code | When |
197
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
168
198
  | `42501` | Nobody is signed in, or the person has a verified MFA factor and the session isn't `aal2`: verify the second factor, then retry. |
169
- | `GA001` | The app's hook refused (below). |
170
- | `GA002` | The app requires a recent sign-in and this one isn't: sign in again, then retry. |
171
- | `23503` | A row still references the login: a foreign key without `on delete cascade`, or files in Storage. |
199
+ | `GA001` | The app's hook refused (below). |
200
+ | `GA002` | The app requires a recent sign-in and this one isn't: sign in again, then retry. |
201
+ | `23503` | A row still references the login: a foreign key without `on delete cascade`, or files in Storage. |
172
202
 
173
203
  - **Your data:** deleting the login removes the rows whose foreign keys to `auth.users(id)` are
174
204
  `on delete cascade`, and is refused (`23503`) while any other row still points at it, so the
@@ -363,7 +393,6 @@ and release notes (`notes`, an array of `{ version, title?, body }`). Copy it in
363
393
  > block instead. When you move, remove 0005's pgTAP test from your app (two of its assertions
364
394
  > describe the old register and forget) and stop calling `genesis-push-send`.
365
395
 
366
-
367
396
  [`migrations/0005_genesis_push.sql`](./migrations/0005_genesis_push.sql) and the
368
397
  [`genesis-push-send`](./functions/genesis-push-send) Edge Function are the server half of
369
398
  `@marktiderman/genesis-native/push`. Copy the migration into your app's `supabase/migrations/`
@@ -477,11 +506,11 @@ into your app under a new timestamp; it stands alone.
477
506
 
478
507
  Functions (templates; copy the folder into `supabase/functions/`):
479
508
 
480
- | Function | What it does | Secrets |
481
- |---|---|---|
482
- | `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) |
483
- | `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`) |
484
- | `genesis-support-cleanup` | On a schedule: removes queued and orphaned screenshots through the Storage API | `GENESIS_SUPPORT_CLEANUP_SECRET` |
509
+ | Function | What it does | Secrets |
510
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
511
+ | `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) |
512
+ | `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`) |
513
+ | `genesis-support-cleanup` | On a schedule: removes queued and orphaned screenshots through the Storage API | `GENESIS_SUPPORT_CLEANUP_SECRET` |
485
514
 
486
515
  All three deploy with `verify_jwt = false` (each checks its caller or secret itself).
487
516
 
@@ -518,22 +547,31 @@ alter table public.tenant_feature_changes add foreign key (tenant_id) references
518
547
  Without the admin hook nobody can change a switch (`42501`, with a hint): it fails closed.
519
548
 
520
549
  ```ts
521
- import { setTenantFeature, tenantFeatures, tenantFeatureHistory } from "@marktiderman/genesis-supabase";
550
+ import {
551
+ setTenantFeature,
552
+ tenantFeatures,
553
+ tenantFeatureHistory,
554
+ } from "@marktiderman/genesis-supabase";
522
555
 
523
556
  const features = await tenantFeatures(supabase, teamId); // members and admins; 42501 for anyone else
524
557
  if (features.isOn("chat")) showChatTab();
525
558
 
526
- await setTenantFeature(supabase, { tenantId: teamId, featureId: "chat", enabled: false, reason: "Launching next month" });
559
+ await setTenantFeature(supabase, {
560
+ tenantId: teamId,
561
+ featureId: "chat",
562
+ enabled: false,
563
+ reason: "Launching next month",
564
+ });
527
565
  const history = await tenantFeatureHistory(supabase, teamId); // admins: who, when, why, newest first
528
566
  ```
529
567
 
530
- | | |
531
- | --- | --- |
568
+ | | |
569
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------ |
532
570
  | `public.set_tenant_feature(tenant, feature, enabled, reason)` | admins only (the hook); a reason of 1 to 500 characters; a FeatureId-shaped id |
533
- | `public.tenant_features_off(tenant)` | the ids that are off; members (`private.genesis_tenant_member`) and admins |
534
- | `public.tenant_feature_settings(tenant)` | every switch with its reason, who and when; admins |
535
- | `public.tenant_feature_history(tenant, max_rows)` | every change, newest first; admins |
536
- | `private.tenant_feature_on(tenant, feature)` | for SQL gates; a feature with no row is on |
571
+ | `public.tenant_features_off(tenant)` | the ids that are off; members (`private.genesis_tenant_member`) and admins |
572
+ | `public.tenant_feature_settings(tenant)` | every switch with its reason, who and when; admins |
573
+ | `public.tenant_feature_history(tenant, max_rows)` | every change, newest first; admins |
574
+ | `private.tenant_feature_on(tenant, feature)` | for SQL gates; a feature with no row is on |
537
575
 
538
576
  Every change, through the function or by the service role writing the table directly, is
539
577
  recorded by a trigger in `public.tenant_feature_changes` with the person (none for the service
@@ -551,9 +589,156 @@ A delete that cascades into `tenant_features` (from your tenant table, or a pers
551
589
  goes with them) isn't recorded, so it never refuses the tenant's or the person's deletion; saving
552
590
  the same switch and reason again changes nothing, not even who and when.
553
591
 
592
+ ## Background jobs (the jobs block)
593
+
594
+ `migrations/0007_genesis_jobs.sql` is one queue for background work and one way to schedule it, so
595
+ an app writes neither its own outbox table nor its own cron wiring. `@marktiderman/genesis-supabase/jobs`
596
+ is the server half. It is server only, since it takes the service-role client and the job secret.
597
+
598
+ **Queue.** Enqueue from server code (`enqueueJob`) or from SQL (`genesis_job_enqueue`, for example
599
+ in a trigger). Then run the handlers on a schedule:
600
+
601
+ ```ts
602
+ import { enqueueJob, runJobs, PermanentJobError } from "@marktiderman/genesis-supabase/jobs";
603
+
604
+ await enqueueJob(admin, "email.digest", { personId }, { idempotencyKey: `${personId}:${day}` });
605
+
606
+ const report = await runJobs(admin, {
607
+ handlers: {
608
+ "email.digest": async ({ payload, redelivery }) => {
609
+ if (redelivery && (await alreadySent(payload.personId))) return;
610
+ const res = await send(payload.personId);
611
+ if (res.status === 429) return { retryAfterSeconds: Number(res.headers.get("retry-after") ?? 60) };
612
+ if (res.status >= 400 && res.status < 500) throw new PermanentJobError(`refused: ${res.status}`);
613
+ },
614
+ },
615
+ });
616
+ ```
617
+
618
+ - **Claims.** `runJobs` claims due jobs of the kinds it handles (`SKIP LOCKED`), each leased for
619
+ `leaseSeconds`. A lease that runs out brings the job back as a `redelivery`. A job that keeps
620
+ dying fails as `lease_expired`.
621
+ - **Outcomes.** A handler that returns nothing is done. One that throws is retried with jittered
622
+ backoff (30 s, doubling, at most an hour) until `maxAttempts`, which defaults to 8.
623
+ `PermanentJobError` or `{ failed }` stops it at once.
624
+ - **Idempotency.** An idempotency key makes a repeated enqueue return the job already queued. It
625
+ only dedupes while that job is pending or running: once it is done, failed or cancelled, the key
626
+ is free again.
627
+ - **Clean-up.** Finished jobs are deleted after 14 days. That is scheduled only if pg_cron was
628
+ installed when the migration ran. If you enable pg_cron later, schedule
629
+ `select private.genesis_jobs_purge()` yourself.
630
+ - **Logs.** Nothing about a payload is logged, so payloads should hold ids and never secrets.
631
+
632
+ - **Settle failures.** If recording an outcome fails, the job is counted as `unsettled` and comes
633
+ back when its lease runs out. The run carries on, and only a failed claim throws.
634
+
635
+ **One job route.** `createJobsHandler` runs the job the route names, but only if the request passes
636
+ one of two constant-time checks:
637
+
638
+ - **Signed for that job.** `Authorization: GenesisJob t=<unix seconds>,sig=<hex HMAC-SHA256 of
639
+ "<t>.<job>">` is good for five minutes. This is what schedules send, and `signJobRequest` builds
640
+ it from your own code.
641
+ - **The plain secret.** `Authorization: Bearer <secret>` (any case) is for a platform cron or a
642
+ person running a job by hand.
643
+
644
+ `previousSecret` is accepted too while you rotate the secret, so remove it once every caller has the
645
+ new one.
646
+
647
+ A signed call is signed for the job name only, so the route trusts nothing else in it. The job gets
648
+ an empty `body`, and the response is `{ job, ok: true }` with no result, because pg_net keeps responses
649
+ in `net._http_response`. A bearer call gets its body, and the job's result comes back as `result`.
650
+
651
+ ```ts
652
+ const handle = createJobsHandler({
653
+ secret: process.env.GENESIS_JOBS_SECRET,
654
+ previousSecret: process.env.GENESIS_JOBS_SECRET_PREVIOUS,
655
+ jobs: {
656
+ queue: () => runJobs(admin, { handlers }),
657
+ "purge-uploads": () => purgeUploads(admin),
658
+ },
659
+ });
660
+ // /api/jobs/$job, GET and POST
661
+ export const POST = ({ request, params }) => handle(request, params.job);
662
+ ```
663
+
664
+ It answers 401 for a wrong or missing secret, and 500 when no secret is set. An unknown job gets
665
+ 404, and a failure gets 500 with no detail (the error goes to `log`).
666
+
667
+ **Schedules.** pg_cron calls the route through pg_net, which needs pgcrypto too (Supabase has it
668
+ in `extensions`). The URL and the secret are read from Vault each time, so they never sit in
669
+ `cron.job`. The request carries a five-minute signature for that one job, never the secret itself.
670
+ That matters because pg_net keeps each request's headers in `net.http_request_queue` until it sends
671
+ them, and on some images other roles can read that table.
672
+
673
+ ```sql
674
+ create extension if not exists pg_cron;
675
+ create extension if not exists pg_net;
676
+ select vault.create_secret('https://app.example.com/api/jobs', 'genesis_jobs_url');
677
+ select vault.create_secret('<the same secret as GENESIS_JOBS_SECRET>', 'genesis_jobs_secret');
678
+ select genesis_schedule_job('jobs-queue', '* * * * *', 'queue'); -- POST …/api/jobs/queue
679
+ select genesis_schedule_job('purge-uploads', '15 3 * * *', 'purge-uploads');
680
+ ```
681
+
682
+ The Vault secrets belong to each environment, so create them by hand in each project and never in
683
+ a migration.
684
+
685
+ - A schedule is the cron job `genesis-job:<name>`. Calling `genesis_schedule_job` with an existing
686
+ name replaces it.
687
+ - `genesis_unschedule_job(name)` removes one, and never touches a cron job it didn't make. A platform cron (Vercel, Cloudflare) can call the same
688
+ route instead: it sends GET with the bearer.
689
+
690
+ **Access.** The queue table has RLS on, no policies, and no grants to `anon` or `authenticated`.
691
+ Every function is granted by name to `service_role` only.
692
+
693
+ ## Testing your schema's data contract (genesis_testing)
694
+
695
+ `sql/testing/genesis_testing.sql` is a pgTAP kit that checks your own tables and functions keep the
696
+ Genesis data rules. **Test databases only:** never put it in `supabase/migrations/`. It can sign
697
+ in as anyone, and the API roles can't call it (only `sign_out` and `user_id`). Copy it to `supabase/tests/database/000_genesis_testing.test.sql` (the leading
698
+ zeros make `supabase test db` run it first; it commits only the `genesis_testing` schema and reports
699
+ one passing line of its own). Then call the helpers from your own test files, inside their usual
700
+ `begin; … rollback;`:
701
+
702
+ ```sql
703
+ select genesis_testing.create_users(); -- owner, staff, client, stranger (fixed ids)
704
+ select genesis_testing.refuses($$delete from notes where id = '…'$$,
705
+ genesis_testing.user_id('client'), 'a client can''t delete a note outright');
706
+ select genesis_testing.server_owned('public.notes', 'id', '…',
707
+ array['status', 'completed_at'], genesis_testing.user_id('client'));
708
+ select genesis_testing.allows_system_paths($$delete from notes where id = '…'$$,
709
+ array['bt.hard_delete=on'], genesis_testing.user_id('client'), 'system work can delete');
710
+ select genesis_testing.never_shows('select * from group_progress(''…'')', 'PRIVATE-ANSWER',
711
+ genesis_testing.user_id('staff'), 'a private answer never reaches the report');
712
+ select genesis_testing.sees('select * from notes', genesis_testing.user_id('client'), 2, 'own notes only');
713
+ ```
714
+
715
+ | Helper | Passes when |
716
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
717
+ | `refuses(sql, user, description)` | The write raises 42501 or changes no row. |
718
+ | `allows(sql, user, description, min_rows)` | It runs without an error (`user` null: signed out, as jobs and backfills run). |
719
+ | `allows_with_setting(sql, setting, value, user, description)` | It runs with a bypass setting such as `bt.hard_delete`. |
720
+ | `allows_system_paths(sql, settings, user, description)` | It runs signed out and under each `name=value` setting (one line each). |
721
+ | `server_owned(table, key_column, key, columns, user)` | The user can't change any of `columns` (one line each), whether the update raises, misses, or a trigger puts it back. |
722
+ | `never_shows(query, marker, user, description)` / `never_shows_anon` | `marker` appears nowhere in what the query returns, for a table, a view or a report function. |
723
+ | `sees(query, user, expected, description)` | The query returns exactly `expected` rows. |
724
+
725
+ A `user` of null means the system: no signed-in user and the test's own role, as jobs, backfills and
726
+ migrations run. Checks only pass on evidence: any error other than a refusal fails them (so a typo
727
+ can't pass), and "changed nothing" counts only when a control run can make the change (so a wrong
728
+ key can't pass). Every check runs in a savepoint that is always rolled back, so checks never affect each other. A
729
+ failing check prints why (`# changed from open to done`, `# raised 23514 …`). The `probe_*`
730
+ functions return the same verdict as jsonb for testing your own helpers. `sign_in(who)`,
731
+ `sign_in_anon()` and `sign_out()` act as someone for the rest of a test, setting both
732
+ `request.jwt.claims` and the legacy `request.jwt.claim.sub`.
733
+
734
+ What to cover, from the review history behind this kit: every guard trigger against every system
735
+ path (signed out, your bypass settings, cascades and ON DELETE SET NULL); every column only the
736
+ server sets; every security-definer function that reads a table with a privacy flag. The fixture
737
+ ids are shared with `@marktiderman/genesis-core/testing`.
738
+
554
739
  ## Blocks
555
740
 
556
- `accountBlock` and `tenantFeaturesBlock` are `defineBlock` declarations
741
+ `accountBlock`, `tenantFeaturesBlock` and `jobsBlock` are `defineBlock` declarations
557
742
  (`@marktiderman/genesis-features/blocks`) of what this package's blocks need: their migrations
558
743
  (with their tests) and, for the account, what it means for the stores' privacy forms. The
559
744
  functions above don't change; the declarations are for `resolveBlocks` and the shell.
@@ -564,5 +749,4 @@ functions above don't change; the declarations are for `resolveBlocks` and the s
564
749
  - **The URL polyfill.** Supabase's React Native guides still import `react-native-url-polyfill/auto` before creating the client. Keep it until your app has tested PKCE sign-in end to end without it: React Native's built-in `URL` has improved, but the docs don't yet say it's enough.
565
750
  - **`refreshWhileActive` on native only.** Supabase gates the AppState listener to native platforms; call it from native code (not on web, where the browser already refreshes in the background).
566
751
 
567
-
568
752
  Part of the [Genesis framework](https://github.com/marktiderman/genesis).
package/dist/blocks.d.ts CHANGED
@@ -15,5 +15,12 @@ declare const accountBlock: _marktiderman_genesis_features_blocks.BlockDef<"acco
15
15
  * @stability Beta
16
16
  */
17
17
  declare const tenantFeaturesBlock: _marktiderman_genesis_features_blocks.BlockDef<"tenant-features", never>;
18
+ /**
19
+ * The jobs block: a background-job queue and named schedules that call the
20
+ * app's job route. Server only; the route is `createJobsHandler` from `/jobs`.
21
+ *
22
+ * @stability Beta
23
+ */
24
+ declare const jobsBlock: _marktiderman_genesis_features_blocks.BlockDef<"jobs", never>;
18
25
 
19
- export { accountBlock, tenantFeaturesBlock };
26
+ export { accountBlock, jobsBlock, tenantFeaturesBlock };
package/dist/blocks.js CHANGED
@@ -1,3 +1,3 @@
1
- export { accountBlock, tenantFeaturesBlock } from './chunk-T2HGFC4C.js';
1
+ export { accountBlock, jobsBlock, tenantFeaturesBlock } from './chunk-A3H5CTYT.js';
2
2
  //# sourceMappingURL=blocks.js.map
3
3
  //# sourceMappingURL=blocks.js.map
@@ -31,7 +31,15 @@ var tenantFeaturesBlock = defineBlock({
31
31
  privacy: { notes: ["Keeps who changed a tenant's switches; a deleted person's changes stay, without the person."] }
32
32
  }
33
33
  });
34
+ var jobsBlock = defineBlock({
35
+ id: "jobs",
36
+ platforms: ["web"],
37
+ contributes: {
38
+ migrations: [{ package: PACKAGE, file: "migrations/0007_genesis_jobs.sql", test: "migrations/tests/0007_genesis_jobs.test.sql" }],
39
+ privacy: { notes: ["Job payloads hold ids, never content or secrets; finished jobs are deleted after 14 days."] }
40
+ }
41
+ });
34
42
 
35
- export { accountBlock, tenantFeaturesBlock };
36
- //# sourceMappingURL=chunk-T2HGFC4C.js.map
37
- //# sourceMappingURL=chunk-T2HGFC4C.js.map
43
+ export { accountBlock, jobsBlock, tenantFeaturesBlock };
44
+ //# sourceMappingURL=chunk-A3H5CTYT.js.map
45
+ //# sourceMappingURL=chunk-A3H5CTYT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/blocks.ts"],"names":[],"mappings":";;;AAQA,IAAM,OAAA,GAAU,gCAAA;AAST,IAAM,eAAe,WAAA,CAAY;AAAA,EACtC,EAAA,EAAI,SAAA;AAAA,EACJ,SAAA,EAAW,CAAC,KAAA,EAAO,QAAQ,CAAA;AAAA,EAC3B,WAAA,EAAa;AAAA,IACX,UAAA,EAAY;AAAA,MACV,EAAE,OAAA,EAAS,OAAA,EAAS,IAAA,EAAM,8CAAA,EAAgD,MAAM,yDAAA,EAA0D;AAAA;AAAA,MAE1I,EAAE,OAAA,EAAS,OAAA,EAAS,IAAA,EAAM,oDAAA,EAAsD,MAAM,+DAAA;AAAgE,KACxJ;AAAA,IACA,OAAA,EAAS;AAAA,MACP,QAAA,EAAU;AAAA,QACR,EAAE,IAAA,EAAM,eAAA,EAAiB,QAAA,EAAU,CAAC,mBAAmB,CAAA,EAAG,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,KAAA,EAAM;AAAA,QACxF,EAAE,IAAA,EAAM,SAAA,EAAW,QAAA,EAAU,CAAC,mBAAmB,CAAA,EAAG,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,KAAA,EAAM;AAAA,QAClF,EAAE,IAAA,EAAM,MAAA,EAAQ,QAAA,EAAU,CAAC,mBAAmB,CAAA,EAAG,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,KAAA;AAAM,OACjF;AAAA,MACA,KAAA,EAAO;AAAA,QACL,4GAAA;AAAA,QACA;AAAA;AACF;AACF;AAEJ,CAAC;AAQM,IAAM,sBAAsB,WAAA,CAAY;AAAA,EAC7C,EAAA,EAAI,iBAAA;AAAA,EACJ,WAAA,EAAa;AAAA,IACX,UAAA,EAAY,CAAC,EAAE,OAAA,EAAS,SAAS,IAAA,EAAM,6CAAA,EAA+C,IAAA,EAAM,wDAAA,EAA0D,CAAA;AAAA,IACtJ,OAAA,EAAS,EAAE,KAAA,EAAO,CAAC,6FAA6F,CAAA;AAAE;AAEtH,CAAC;AAQM,IAAM,YAAY,WAAA,CAAY;AAAA,EACnC,EAAA,EAAI,MAAA;AAAA,EACJ,SAAA,EAAW,CAAC,KAAK,CAAA;AAAA,EACjB,WAAA,EAAa;AAAA,IACX,UAAA,EAAY,CAAC,EAAE,OAAA,EAAS,SAAS,IAAA,EAAM,kCAAA,EAAoC,IAAA,EAAM,6CAAA,EAA+C,CAAA;AAAA,IAChI,OAAA,EAAS,EAAE,KAAA,EAAO,CAAC,2FAA2F,CAAA;AAAE;AAEpH,CAAC","file":"chunk-A3H5CTYT.js","sourcesContent":["/**\n * The blocks this package carries the server half of, as `defineBlock`\n * declarations: what each needs in the database, and what it means for the\n * stores' privacy forms. The functions themselves (`deleteMyAccount`,\n * `tenantFeatures`, …) don't change.\n */\nimport { defineBlock } from \"@marktiderman/genesis-features/blocks\";\n\nconst PACKAGE = \"@marktiderman/genesis-supabase\";\n\n/**\n * The account block: sign-in, profile and in-app account deletion. Its\n * screens are the app's (or genesis-ui-native's `AuthScreen`); this names its\n * migrations and its data.\n *\n * @stability Beta\n */\nexport const accountBlock = defineBlock({\n id: \"account\",\n platforms: [\"web\", \"native\"],\n contributes: {\n migrations: [\n { package: PACKAGE, file: \"migrations/0001_genesis_account_deletion.sql\", test: \"migrations/tests/0001_genesis_account_deletion.test.sql\" },\n // With Sign in with Apple, with the genesis-apple-revoke function beside it.\n { package: PACKAGE, file: \"migrations/0003_genesis_apple_token_revocation.sql\", test: \"migrations/tests/0003_genesis_apple_token_revocation.test.sql\" },\n ],\n privacy: {\n collects: [\n { type: \"email address\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"user id\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"name\", purposes: [\"app functionality\"], linked: true, tracking: false },\n ],\n notes: [\n \"People delete their account from inside the app; their login and the rows that cascade from it go with it.\",\n \"With Sign in with Apple, the person's Apple tokens are revoked when the account is deleted.\",\n ],\n },\n },\n});\n\n/**\n * The tenant features block: per-tenant on/off switches for shipped\n * features, set by the tenant's admins with a reason.\n *\n * @stability Beta\n */\nexport const tenantFeaturesBlock = defineBlock({\n id: \"tenant-features\",\n contributes: {\n migrations: [{ package: PACKAGE, file: \"migrations/0004_genesis_tenant_features.sql\", test: \"migrations/tests/0004_genesis_tenant_features.test.sql\" }],\n privacy: { notes: [\"Keeps who changed a tenant's switches; a deleted person's changes stay, without the person.\"] },\n },\n});\n\n/**\n * The jobs block: a background-job queue and named schedules that call the\n * app's job route. Server only; the route is `createJobsHandler` from `/jobs`.\n *\n * @stability Beta\n */\nexport const jobsBlock = defineBlock({\n id: \"jobs\",\n platforms: [\"web\"],\n contributes: {\n migrations: [{ package: PACKAGE, file: \"migrations/0007_genesis_jobs.sql\", test: \"migrations/tests/0007_genesis_jobs.test.sql\" }],\n privacy: { notes: [\"Job payloads hold ids, never content or secrets; finished jobs are deleted after 14 days.\"] },\n },\n});\n"]}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import '@supabase/supabase-js';
2
2
  export { A as APPLE_REVOKE_FUNCTION, D as DeleteAccountClient, c as DeleteMyAccountOptions, E as ExpoCryptoLike, F as FunctionsClient, G as GenesisSupabaseOptions, P as PostgrestErrorLike, S as SessionStorage, d as SetTenantFeatureInput, e as StoreAppleTokenOptions, f as StoreAppleTokenResult, g as SupabaseQueryError, T as TenantFeatureChange, j as TenantFeatureSetting, k as TenantFeatures, l as TenantFeaturesClient, m as createGenesisSupabase, n as deleteMyAccount, o as must, r as requestAppleRevocation, s as setTenantFeature, p as storeAppleToken, t as tenantFeatureHistory, q as tenantFeatureSettings, u as tenantFeatures } from './index-BSGz3RGk.js';
3
- export { accountBlock, tenantFeaturesBlock } from './blocks.js';
3
+ export { accountBlock, jobsBlock, tenantFeaturesBlock } from './blocks.js';
4
4
  import '@marktiderman/genesis-features/blocks';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { installWebCrypto, assertPkceCrypto, isDigestOnlyPolyfill, claimsViaServer } from './chunk-FAA2FTF4.js';
2
- export { accountBlock, tenantFeaturesBlock } from './chunk-T2HGFC4C.js';
2
+ export { accountBlock, jobsBlock, tenantFeaturesBlock } from './chunk-A3H5CTYT.js';
3
3
  import { createClient, AuthInvalidJwtError } from '@supabase/supabase-js';
4
4
 
5
5
  // src/apple-tokens.ts
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/apple-tokens.ts","../src/tenant-features.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The app's side of Sign in with Apple token revocation: hand the sign-in's\n * authorization code to the `genesis-apple-revoke` Edge Function (it keeps\n * Apple's refresh token server-side), and ask it to revoke after the account\n * is deleted. The function and its migration ship in this package; see the\n * README (\"Revoking Apple's tokens\").\n */\n\n/** The Edge Function's default name, as shipped in `functions/genesis-apple-revoke`. */\nexport const APPLE_REVOKE_FUNCTION = \"genesis-apple-revoke\";\n\n/** The store call waits on Apple's token endpoint (10 s timeout in the function). */\nconst STORE_TIMEOUT_MS = 20_000;\n/** The function answers the revoke call at once and works in the background; don't hold the delete screen for longer. */\nconst REVOKE_TIMEOUT_MS = 5_000;\n\n/** The parts of a Supabase client these helpers use. A `SupabaseClient` fits. */\nexport interface FunctionsClient {\n functions: {\n // Loose on purpose, so any supabase-js version's `invoke` fits.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n invoke: (name: string, options?: any) => PromiseLike<{ data: unknown; error: unknown }>;\n };\n}\n\n/** @stability Beta */\nexport interface StoreAppleTokenOptions {\n /** `authorizationCode` from `signInWithApple`'s result. Single-use, valid for five minutes: send it straight after sign-in. */\n authorizationCode: string;\n /** The client id the code was issued for: your bundle id on iOS. Must be in the function's APPLE_CLIENT_IDS; default its first. */\n clientId?: string;\n /** The Edge Function's name. Default `genesis-apple-revoke`. */\n functionName?: string;\n}\n\n/** @stability Beta */\nexport type StoreAppleTokenResult = { ok: true } | { ok: false; error: Error };\n\n/**\n * After a Sign in with Apple, sends the authorization code to the Edge\n * Function, which exchanges it for Apple's refresh token and keeps it where\n * only the server reads it, so deleting the account can revoke it. Call it\n * with the session from that sign-in. Never throws: the person is signed in\n * either way, so log a failure rather than showing it.\n *\n * @stability Beta\n */\nexport async function storeAppleToken(client: FunctionsClient, { authorizationCode, clientId, functionName = APPLE_REVOKE_FUNCTION }: StoreAppleTokenOptions): Promise<StoreAppleTokenResult> {\n try {\n const { error } = await client.functions.invoke(functionName, { body: { action: \"store\", code: authorizationCode, ...(clientId ? { clientId } : {}) }, timeout: STORE_TIMEOUT_MS });\n if (error) return { ok: false, error: error instanceof Error ? error : new Error(String((error as { message?: unknown }).message ?? error)) };\n return { ok: true };\n } catch (error) {\n return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };\n }\n}\n\n/**\n * Asks the Edge Function to revoke the tokens of deleted accounts now, rather\n * than at its next scheduled run. Never throws and never fails the caller:\n * the account is already gone and the queue keeps the token until a run\n * succeeds.\n */\nexport async function requestAppleRevocation(client: FunctionsClient, functionName = APPLE_REVOKE_FUNCTION): Promise<boolean> {\n try {\n const { error } = await client.functions.invoke(functionName, { body: { action: \"revoke\" }, timeout: REVOKE_TIMEOUT_MS });\n return !error;\n } catch {\n return false;\n }\n}\n","/**\n * Tenant features: the client half of `migrations/0004_genesis_tenant_features.sql`.\n * A tenant's admins turn a shipped feature off (or back on) for their tenant,\n * with a reason; its members read which ids are off and hide them.\n *\n * Exposure only: a switch hides a feature, it never grants or denies access\n * to data (that is RLS) and never sells it (that is an entitlement).\n */\n// A cycle with ./index, used only inside the functions.\nimport { must, type PostgrestErrorLike } from \"./index\";\n\n/** The parts of a Supabase client these helpers use. A `SupabaseClient` fits. */\nexport interface TenantFeaturesClient {\n // Loose on purpose: a client typed by an app's Database narrows `rpc`'s\n // function names, and the app's generated types may not list these yet.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, ...rest: any[]) => PromiseLike<{ data: unknown; error: PostgrestErrorLike | null }>;\n}\n\ntype RpcResult = { data: unknown; error: null } | { data: null; error: PostgrestErrorLike };\n\n/** Which features a tenant has turned off. A feature not in `off` is on. @stability Beta */\nexport interface TenantFeatures {\n readonly tenantId: string;\n readonly off: ReadonlySet<string>;\n isOn(featureId: string): boolean;\n}\n\n/**\n * The features `tenantId` has turned off, for one of its members (or\n * admins): `tenant_features_off`. Throws a `SupabaseQueryError` (`42501`)\n * for anyone else, so a refusal is never mistaken for \"everything on\".\n *\n * ```ts\n * const features = await tenantFeatures(supabase, teamId);\n * if (features.isOn(\"chat\")) showChatTab();\n * ```\n *\n * @stability Beta\n */\nexport async function tenantFeatures(client: TenantFeaturesClient, tenantId: string): Promise<TenantFeatures> {\n const data = must((await client.rpc(\"tenant_features_off\", { tenant: tenantId })) as RpcResult);\n const off = new Set(Array.isArray(data) ? data.filter((id): id is string => typeof id === \"string\") : []);\n return Object.freeze({ tenantId, off, isOn: (featureId: string) => !off.has(featureId) });\n}\n\n/** @stability Beta */\nexport interface SetTenantFeatureInput {\n tenantId: string;\n featureId: string;\n enabled: boolean;\n /** Why, in at most 500 characters. Required: it goes in the history. */\n reason: string;\n}\n\n/**\n * Turn a feature on or off for a tenant: `set_tenant_feature`. Only the\n * tenant's admins (the app's `private.genesis_tenant_admin` hook); throws a\n * `SupabaseQueryError` otherwise (`42501`), for a blank reason (`22023`) or\n * a malformed feature id (`23514`).\n *\n * @stability Beta\n */\nexport async function setTenantFeature(client: TenantFeaturesClient, input: SetTenantFeatureInput): Promise<void> {\n must(\n (await client.rpc(\"set_tenant_feature\", {\n tenant: input.tenantId,\n feature: input.featureId,\n enabled: input.enabled,\n reason: input.reason,\n })) as RpcResult,\n );\n}\n\n/** One switch a tenant has set. @stability Beta */\nexport interface TenantFeatureSetting {\n feature_id: string;\n enabled: boolean;\n reason: string;\n updated_by: string | null;\n updated_at: string;\n}\n\n/** Every switch a tenant has set, with its reason, for its admins: `tenant_feature_settings`. @stability Beta */\nexport async function tenantFeatureSettings(client: TenantFeaturesClient, tenantId: string): Promise<TenantFeatureSetting[]> {\n return (must((await client.rpc(\"tenant_feature_settings\", { tenant: tenantId })) as RpcResult) ?? []) as TenantFeatureSetting[];\n}\n\n/** One change to a tenant's switches. `enabled` is null when a switch row was deleted (back to on). @stability Beta */\nexport interface TenantFeatureChange {\n feature_id: string;\n enabled: boolean | null;\n reason: string | null;\n /** The person, or null for the service role, a migration, or a person since deleted. */\n changed_by: string | null;\n changed_role: string;\n changed_at: string;\n}\n\n/** A tenant's history of changes, newest first (at most `maxRows`, 1 to 1000, default 100), for its admins. @stability Beta */\nexport async function tenantFeatureHistory(\n client: TenantFeaturesClient,\n tenantId: string,\n options: { maxRows?: number } = {},\n): Promise<TenantFeatureChange[]> {\n const args = options.maxRows === undefined ? { tenant: tenantId } : { tenant: tenantId, max_rows: options.maxRows };\n return (must((await client.rpc(\"tenant_feature_history\", args)) as RpcResult) ?? []) as TenantFeatureChange[];\n}\n","/**\n * Supabase for Genesis apps. Platform-neutral: web, React Native and the\n * server import it. Native-only helpers (Keychain storage, foreground\n * refresh, reinstall wipe) are on `@marktiderman/genesis-supabase/native`.\n */\nimport { AuthInvalidJwtError, createClient, type SupabaseClient, type SupabaseClientOptions } from \"@supabase/supabase-js\";\nimport { APPLE_REVOKE_FUNCTION, requestAppleRevocation, type FunctionsClient } from \"./apple-tokens\";\nimport { assertPkceCrypto, claimsViaServer, installWebCrypto, isDigestOnlyPolyfill, type ExpoCryptoLike } from \"./web-crypto\";\n\nexport type { ExpoCryptoLike } from \"./web-crypto\";\nexport {\n APPLE_REVOKE_FUNCTION,\n requestAppleRevocation,\n storeAppleToken,\n type FunctionsClient,\n type StoreAppleTokenOptions,\n type StoreAppleTokenResult,\n} from \"./apple-tokens\";\n\n/** The async key-value store Supabase auth keeps its session in. */\nexport interface SessionStorage {\n getItem(key: string): Promise<string | null>;\n setItem(key: string, value: string): Promise<void>;\n removeItem(key: string): Promise<void>;\n}\n\n/** @stability Beta */\nexport interface GenesisSupabaseOptions {\n url: string;\n /** The publishable (anon) key. Never the secret key in an app. */\n publishableKey: string;\n /** Where the session lives. Native: `keychainStorage(SecureStore)`. Web: leave out for supabase-js's own default. */\n storage?: SessionStorage;\n /** `\"native\"` sets the React Native defaults (no session from the URL). Default `\"web\"`. */\n platform?: \"web\" | \"native\";\n /**\n * Read a session from the URL after an OAuth redirect. Default: `true` on\n * web, `false` on native, where the app hands the redirect URL to\n * `exchangeCodeForSession` itself.\n */\n detectSessionInUrl?: boolean;\n /**\n * `expo-crypto` (`import * as ExpoCrypto from \"expo-crypto\"`), on native.\n * Hermes has no `crypto.subtle`, and without it supabase-js sends the PKCE\n * challenge as \"plain\"; this installs `crypto.getRandomValues` and a SHA-256\n * `crypto.subtle.digest` from it (real WebCrypto is kept), so the challenge\n * is S256. A PKCE client made where there is neither throws in development\n * and logs an error in a release build. With the polyfill in place,\n * `auth.getClaims` always has the server check the token (`getUser`), as\n * supabase-js does where WebCrypto is missing: the polyfill can hash but\n * not verify a signature.\n */\n crypto?: ExpoCryptoLike;\n /** Anything else for supabase-js, merged last. */\n options?: SupabaseClientOptions<\"public\">;\n}\n\n/**\n * A Supabase client with Genesis's defaults: PKCE sign-in (the code flow, not\n * tokens in a redirect URL, with an S256 challenge: pass `crypto` on native),\n * a persisted session that refreshes itself, and\n * the session storage you pass.\n *\n * @stability Beta\n */\nexport function createGenesisSupabase<Database = unknown>({\n url,\n publishableKey,\n storage,\n platform = \"web\",\n detectSessionInUrl = platform === \"web\",\n crypto,\n options = {},\n}: GenesisSupabaseOptions): SupabaseClient<Database> {\n if (crypto) installWebCrypto(crypto);\n // The PKCE flow needs WebCrypto for an S256 challenge; never let it fall back to \"plain\" quietly.\n if ((options.auth?.flowType ?? \"pkce\") === \"pkce\") assertPkceCrypto();\n // supabase-js's generics over the schema don't survive being forwarded, so the\n // options are built untyped and the client is typed by the caller's Database.\n const client = createClient(url, publishableKey, {\n ...options,\n auth: {\n flowType: \"pkce\",\n persistSession: true,\n autoRefreshToken: true,\n detectSessionInUrl,\n ...(storage ? { storage } : {}),\n ...options.auth,\n },\n });\n if (isDigestOnlyPolyfill()) {\n const auth = client.auth;\n auth.getClaims = claimsViaServer(auth, (message) => new AuthInvalidJwtError(message)) as typeof auth.getClaims;\n }\n return client as unknown as SupabaseClient<Database>;\n}\n\n/** What a Supabase or PostgREST error carries. */\nexport interface PostgrestErrorLike {\n message: string;\n code?: string;\n details?: string | null;\n hint?: string | null;\n}\n\n/**\n * A failed query, with the database's `code` (`23505` unique violation,\n * `42501` permission denied, `PGRST116` no rows…), `details` and `hint`, so a\n * caller can tell an RLS refusal from a conflict. The original error is the\n * `cause`.\n *\n * @stability Beta\n */\nexport class SupabaseQueryError extends Error {\n override name = \"SupabaseQueryError\";\n readonly code?: string;\n readonly details?: string | null;\n readonly hint?: string | null;\n constructor(error: PostgrestErrorLike) {\n super(error.message, { cause: error });\n this.code = error.code;\n this.details = error.details;\n this.hint = error.hint;\n }\n}\n\n/**\n * The data, or a thrown {@link SupabaseQueryError}: for query results where an\n * error should fail the caller (a TanStack Query `queryFn`). The return type\n * keeps `null` when the query can return no row (`.maybeSingle()`).\n *\n * @stability Beta\n */\nexport function must<T>(result: { data: T; error: null } | { data: null; error: PostgrestErrorLike }): T {\n if (result.error) throw new SupabaseQueryError(result.error);\n return result.data as T;\n}\n\n/** The parts of a Supabase client {@link deleteMyAccount} uses. A `SupabaseClient` fits. */\nexport interface DeleteAccountClient {\n // Loose on purpose: a client typed by an app's Database narrows `rpc`'s\n // function names, and the app's generated types may not list this one yet.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, ...rest: any[]) => PromiseLike<{ data: unknown; error: PostgrestErrorLike | null }>;\n auth: { signOut(options?: { scope?: \"global\" | \"local\" | \"others\" }): Promise<unknown> };\n /** Needed only with `revokeAppleTokens`. */\n functions?: FunctionsClient[\"functions\"];\n}\n\n/** @stability Beta */\nexport interface DeleteMyAccountOptions {\n /**\n * After the delete, ask the `genesis-apple-revoke` Edge Function to revoke\n * the person's Sign in with Apple tokens now (`true`, or the function's\n * name). Best effort: the delete has already queued them and the\n * function's schedule retries, so a failure here never fails the call.\n */\n revokeAppleTokens?: boolean | string;\n}\n\n/**\n * In-app account deletion (App Store Review Guideline 5.1.1(v)), on web and\n * native: calls the `delete_my_account` RPC (this package's\n * `migrations/0001_genesis_account_deletion.sql`), which deletes the login\n * and, through the app's `on delete cascade` foreign keys, its data; then\n * clears the session on this device. Throws a {@link SupabaseQueryError} when\n * the database refuses (`42501`: not signed in) and keeps the session then.\n *\n * With Sign in with Apple, ship the `genesis-apple-revoke` function and\n * migration 0003 and pass `{ revokeAppleTokens: true }`: App Review requires\n * the person's Apple tokens to be revoked.\n *\n * Put it behind a confirmation; see the README.\n *\n * @stability Beta\n */\nexport async function deleteMyAccount(client: DeleteAccountClient, options: DeleteMyAccountOptions = {}): Promise<void> {\n must((await client.rpc(\"delete_my_account\")) as { data: unknown; error: null } | { data: null; error: PostgrestErrorLike });\n const revoke = options.revokeAppleTokens;\n // The function needs no login (it works through the queue the delete just filled), so this runs after it.\n if (revoke && client.functions) await requestAppleRevocation({ functions: client.functions }, typeof revoke === \"string\" ? revoke : APPLE_REVOKE_FUNCTION);\n // The login is gone and its refresh tokens with it: only this device's copy is left to clear.\n await client.auth.signOut({ scope: \"local\" });\n}\nexport {\n tenantFeatures,\n setTenantFeature,\n tenantFeatureSettings,\n tenantFeatureHistory,\n type SetTenantFeatureInput,\n type TenantFeatureChange,\n type TenantFeatureSetting,\n type TenantFeatures,\n type TenantFeaturesClient,\n} from \"./tenant-features\";\nexport { accountBlock, tenantFeaturesBlock } from \"./blocks\";\n"]}
1
+ {"version":3,"sources":["../src/apple-tokens.ts","../src/tenant-features.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The app's side of Sign in with Apple token revocation: hand the sign-in's\n * authorization code to the `genesis-apple-revoke` Edge Function (it keeps\n * Apple's refresh token server-side), and ask it to revoke after the account\n * is deleted. The function and its migration ship in this package; see the\n * README (\"Revoking Apple's tokens\").\n */\n\n/** The Edge Function's default name, as shipped in `functions/genesis-apple-revoke`. */\nexport const APPLE_REVOKE_FUNCTION = \"genesis-apple-revoke\";\n\n/** The store call waits on Apple's token endpoint (10 s timeout in the function). */\nconst STORE_TIMEOUT_MS = 20_000;\n/** The function answers the revoke call at once and works in the background; don't hold the delete screen for longer. */\nconst REVOKE_TIMEOUT_MS = 5_000;\n\n/** The parts of a Supabase client these helpers use. A `SupabaseClient` fits. */\nexport interface FunctionsClient {\n functions: {\n // Loose on purpose, so any supabase-js version's `invoke` fits.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n invoke: (name: string, options?: any) => PromiseLike<{ data: unknown; error: unknown }>;\n };\n}\n\n/** @stability Beta */\nexport interface StoreAppleTokenOptions {\n /** `authorizationCode` from `signInWithApple`'s result. Single-use, valid for five minutes: send it straight after sign-in. */\n authorizationCode: string;\n /** The client id the code was issued for: your bundle id on iOS. Must be in the function's APPLE_CLIENT_IDS; default its first. */\n clientId?: string;\n /** The Edge Function's name. Default `genesis-apple-revoke`. */\n functionName?: string;\n}\n\n/** @stability Beta */\nexport type StoreAppleTokenResult = { ok: true } | { ok: false; error: Error };\n\n/**\n * After a Sign in with Apple, sends the authorization code to the Edge\n * Function, which exchanges it for Apple's refresh token and keeps it where\n * only the server reads it, so deleting the account can revoke it. Call it\n * with the session from that sign-in. Never throws: the person is signed in\n * either way, so log a failure rather than showing it.\n *\n * @stability Beta\n */\nexport async function storeAppleToken(client: FunctionsClient, { authorizationCode, clientId, functionName = APPLE_REVOKE_FUNCTION }: StoreAppleTokenOptions): Promise<StoreAppleTokenResult> {\n try {\n const { error } = await client.functions.invoke(functionName, { body: { action: \"store\", code: authorizationCode, ...(clientId ? { clientId } : {}) }, timeout: STORE_TIMEOUT_MS });\n if (error) return { ok: false, error: error instanceof Error ? error : new Error(String((error as { message?: unknown }).message ?? error)) };\n return { ok: true };\n } catch (error) {\n return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };\n }\n}\n\n/**\n * Asks the Edge Function to revoke the tokens of deleted accounts now, rather\n * than at its next scheduled run. Never throws and never fails the caller:\n * the account is already gone and the queue keeps the token until a run\n * succeeds.\n */\nexport async function requestAppleRevocation(client: FunctionsClient, functionName = APPLE_REVOKE_FUNCTION): Promise<boolean> {\n try {\n const { error } = await client.functions.invoke(functionName, { body: { action: \"revoke\" }, timeout: REVOKE_TIMEOUT_MS });\n return !error;\n } catch {\n return false;\n }\n}\n","/**\n * Tenant features: the client half of `migrations/0004_genesis_tenant_features.sql`.\n * A tenant's admins turn a shipped feature off (or back on) for their tenant,\n * with a reason; its members read which ids are off and hide them.\n *\n * Exposure only: a switch hides a feature, it never grants or denies access\n * to data (that is RLS) and never sells it (that is an entitlement).\n */\n// A cycle with ./index, used only inside the functions.\nimport { must, type PostgrestErrorLike } from \"./index\";\n\n/** The parts of a Supabase client these helpers use. A `SupabaseClient` fits. */\nexport interface TenantFeaturesClient {\n // Loose on purpose: a client typed by an app's Database narrows `rpc`'s\n // function names, and the app's generated types may not list these yet.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, ...rest: any[]) => PromiseLike<{ data: unknown; error: PostgrestErrorLike | null }>;\n}\n\ntype RpcResult = { data: unknown; error: null } | { data: null; error: PostgrestErrorLike };\n\n/** Which features a tenant has turned off. A feature not in `off` is on. @stability Beta */\nexport interface TenantFeatures {\n readonly tenantId: string;\n readonly off: ReadonlySet<string>;\n isOn(featureId: string): boolean;\n}\n\n/**\n * The features `tenantId` has turned off, for one of its members (or\n * admins): `tenant_features_off`. Throws a `SupabaseQueryError` (`42501`)\n * for anyone else, so a refusal is never mistaken for \"everything on\".\n *\n * ```ts\n * const features = await tenantFeatures(supabase, teamId);\n * if (features.isOn(\"chat\")) showChatTab();\n * ```\n *\n * @stability Beta\n */\nexport async function tenantFeatures(client: TenantFeaturesClient, tenantId: string): Promise<TenantFeatures> {\n const data = must((await client.rpc(\"tenant_features_off\", { tenant: tenantId })) as RpcResult);\n const off = new Set(Array.isArray(data) ? data.filter((id): id is string => typeof id === \"string\") : []);\n return Object.freeze({ tenantId, off, isOn: (featureId: string) => !off.has(featureId) });\n}\n\n/** @stability Beta */\nexport interface SetTenantFeatureInput {\n tenantId: string;\n featureId: string;\n enabled: boolean;\n /** Why, in at most 500 characters. Required: it goes in the history. */\n reason: string;\n}\n\n/**\n * Turn a feature on or off for a tenant: `set_tenant_feature`. Only the\n * tenant's admins (the app's `private.genesis_tenant_admin` hook); throws a\n * `SupabaseQueryError` otherwise (`42501`), for a blank reason (`22023`) or\n * a malformed feature id (`23514`).\n *\n * @stability Beta\n */\nexport async function setTenantFeature(client: TenantFeaturesClient, input: SetTenantFeatureInput): Promise<void> {\n must(\n (await client.rpc(\"set_tenant_feature\", {\n tenant: input.tenantId,\n feature: input.featureId,\n enabled: input.enabled,\n reason: input.reason,\n })) as RpcResult,\n );\n}\n\n/** One switch a tenant has set. @stability Beta */\nexport interface TenantFeatureSetting {\n feature_id: string;\n enabled: boolean;\n reason: string;\n updated_by: string | null;\n updated_at: string;\n}\n\n/** Every switch a tenant has set, with its reason, for its admins: `tenant_feature_settings`. @stability Beta */\nexport async function tenantFeatureSettings(client: TenantFeaturesClient, tenantId: string): Promise<TenantFeatureSetting[]> {\n return (must((await client.rpc(\"tenant_feature_settings\", { tenant: tenantId })) as RpcResult) ?? []) as TenantFeatureSetting[];\n}\n\n/** One change to a tenant's switches. `enabled` is null when a switch row was deleted (back to on). @stability Beta */\nexport interface TenantFeatureChange {\n feature_id: string;\n enabled: boolean | null;\n reason: string | null;\n /** The person, or null for the service role, a migration, or a person since deleted. */\n changed_by: string | null;\n changed_role: string;\n changed_at: string;\n}\n\n/** A tenant's history of changes, newest first (at most `maxRows`, 1 to 1000, default 100), for its admins. @stability Beta */\nexport async function tenantFeatureHistory(\n client: TenantFeaturesClient,\n tenantId: string,\n options: { maxRows?: number } = {},\n): Promise<TenantFeatureChange[]> {\n const args = options.maxRows === undefined ? { tenant: tenantId } : { tenant: tenantId, max_rows: options.maxRows };\n return (must((await client.rpc(\"tenant_feature_history\", args)) as RpcResult) ?? []) as TenantFeatureChange[];\n}\n","/**\n * Supabase for Genesis apps. Platform-neutral: web, React Native and the\n * server import it. Native-only helpers (Keychain storage, foreground\n * refresh, reinstall wipe) are on `@marktiderman/genesis-supabase/native`.\n */\nimport { AuthInvalidJwtError, createClient, type SupabaseClient, type SupabaseClientOptions } from \"@supabase/supabase-js\";\nimport { APPLE_REVOKE_FUNCTION, requestAppleRevocation, type FunctionsClient } from \"./apple-tokens\";\nimport { assertPkceCrypto, claimsViaServer, installWebCrypto, isDigestOnlyPolyfill, type ExpoCryptoLike } from \"./web-crypto\";\n\nexport type { ExpoCryptoLike } from \"./web-crypto\";\nexport {\n APPLE_REVOKE_FUNCTION,\n requestAppleRevocation,\n storeAppleToken,\n type FunctionsClient,\n type StoreAppleTokenOptions,\n type StoreAppleTokenResult,\n} from \"./apple-tokens\";\n\n/** The async key-value store Supabase auth keeps its session in. */\nexport interface SessionStorage {\n getItem(key: string): Promise<string | null>;\n setItem(key: string, value: string): Promise<void>;\n removeItem(key: string): Promise<void>;\n}\n\n/** @stability Beta */\nexport interface GenesisSupabaseOptions {\n url: string;\n /** The publishable (anon) key. Never the secret key in an app. */\n publishableKey: string;\n /** Where the session lives. Native: `keychainStorage(SecureStore)`. Web: leave out for supabase-js's own default. */\n storage?: SessionStorage;\n /** `\"native\"` sets the React Native defaults (no session from the URL). Default `\"web\"`. */\n platform?: \"web\" | \"native\";\n /**\n * Read a session from the URL after an OAuth redirect. Default: `true` on\n * web, `false` on native, where the app hands the redirect URL to\n * `exchangeCodeForSession` itself.\n */\n detectSessionInUrl?: boolean;\n /**\n * `expo-crypto` (`import * as ExpoCrypto from \"expo-crypto\"`), on native.\n * Hermes has no `crypto.subtle`, and without it supabase-js sends the PKCE\n * challenge as \"plain\"; this installs `crypto.getRandomValues` and a SHA-256\n * `crypto.subtle.digest` from it (real WebCrypto is kept), so the challenge\n * is S256. A PKCE client made where there is neither throws in development\n * and logs an error in a release build. With the polyfill in place,\n * `auth.getClaims` always has the server check the token (`getUser`), as\n * supabase-js does where WebCrypto is missing: the polyfill can hash but\n * not verify a signature.\n */\n crypto?: ExpoCryptoLike;\n /** Anything else for supabase-js, merged last. */\n options?: SupabaseClientOptions<\"public\">;\n}\n\n/**\n * A Supabase client with Genesis's defaults: PKCE sign-in (the code flow, not\n * tokens in a redirect URL, with an S256 challenge: pass `crypto` on native),\n * a persisted session that refreshes itself, and\n * the session storage you pass.\n *\n * @stability Beta\n */\nexport function createGenesisSupabase<Database = unknown>({\n url,\n publishableKey,\n storage,\n platform = \"web\",\n detectSessionInUrl = platform === \"web\",\n crypto,\n options = {},\n}: GenesisSupabaseOptions): SupabaseClient<Database> {\n if (crypto) installWebCrypto(crypto);\n // The PKCE flow needs WebCrypto for an S256 challenge; never let it fall back to \"plain\" quietly.\n if ((options.auth?.flowType ?? \"pkce\") === \"pkce\") assertPkceCrypto();\n // supabase-js's generics over the schema don't survive being forwarded, so the\n // options are built untyped and the client is typed by the caller's Database.\n const client = createClient(url, publishableKey, {\n ...options,\n auth: {\n flowType: \"pkce\",\n persistSession: true,\n autoRefreshToken: true,\n detectSessionInUrl,\n ...(storage ? { storage } : {}),\n ...options.auth,\n },\n });\n if (isDigestOnlyPolyfill()) {\n const auth = client.auth;\n auth.getClaims = claimsViaServer(auth, (message) => new AuthInvalidJwtError(message)) as typeof auth.getClaims;\n }\n return client as unknown as SupabaseClient<Database>;\n}\n\n/** What a Supabase or PostgREST error carries. */\nexport interface PostgrestErrorLike {\n message: string;\n code?: string;\n details?: string | null;\n hint?: string | null;\n}\n\n/**\n * A failed query, with the database's `code` (`23505` unique violation,\n * `42501` permission denied, `PGRST116` no rows…), `details` and `hint`, so a\n * caller can tell an RLS refusal from a conflict. The original error is the\n * `cause`.\n *\n * @stability Beta\n */\nexport class SupabaseQueryError extends Error {\n override name = \"SupabaseQueryError\";\n readonly code?: string;\n readonly details?: string | null;\n readonly hint?: string | null;\n constructor(error: PostgrestErrorLike) {\n super(error.message, { cause: error });\n this.code = error.code;\n this.details = error.details;\n this.hint = error.hint;\n }\n}\n\n/**\n * The data, or a thrown {@link SupabaseQueryError}: for query results where an\n * error should fail the caller (a TanStack Query `queryFn`). The return type\n * keeps `null` when the query can return no row (`.maybeSingle()`).\n *\n * @stability Beta\n */\nexport function must<T>(result: { data: T; error: null } | { data: null; error: PostgrestErrorLike }): T {\n if (result.error) throw new SupabaseQueryError(result.error);\n return result.data as T;\n}\n\n/** The parts of a Supabase client {@link deleteMyAccount} uses. A `SupabaseClient` fits. */\nexport interface DeleteAccountClient {\n // Loose on purpose: a client typed by an app's Database narrows `rpc`'s\n // function names, and the app's generated types may not list this one yet.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, ...rest: any[]) => PromiseLike<{ data: unknown; error: PostgrestErrorLike | null }>;\n auth: { signOut(options?: { scope?: \"global\" | \"local\" | \"others\" }): Promise<unknown> };\n /** Needed only with `revokeAppleTokens`. */\n functions?: FunctionsClient[\"functions\"];\n}\n\n/** @stability Beta */\nexport interface DeleteMyAccountOptions {\n /**\n * After the delete, ask the `genesis-apple-revoke` Edge Function to revoke\n * the person's Sign in with Apple tokens now (`true`, or the function's\n * name). Best effort: the delete has already queued them and the\n * function's schedule retries, so a failure here never fails the call.\n */\n revokeAppleTokens?: boolean | string;\n}\n\n/**\n * In-app account deletion (App Store Review Guideline 5.1.1(v)), on web and\n * native: calls the `delete_my_account` RPC (this package's\n * `migrations/0001_genesis_account_deletion.sql`), which deletes the login\n * and, through the app's `on delete cascade` foreign keys, its data; then\n * clears the session on this device. Throws a {@link SupabaseQueryError} when\n * the database refuses (`42501`: not signed in) and keeps the session then.\n *\n * With Sign in with Apple, ship the `genesis-apple-revoke` function and\n * migration 0003 and pass `{ revokeAppleTokens: true }`: App Review requires\n * the person's Apple tokens to be revoked.\n *\n * Put it behind a confirmation; see the README.\n *\n * @stability Beta\n */\nexport async function deleteMyAccount(client: DeleteAccountClient, options: DeleteMyAccountOptions = {}): Promise<void> {\n must((await client.rpc(\"delete_my_account\")) as { data: unknown; error: null } | { data: null; error: PostgrestErrorLike });\n const revoke = options.revokeAppleTokens;\n // The function needs no login (it works through the queue the delete just filled), so this runs after it.\n if (revoke && client.functions) await requestAppleRevocation({ functions: client.functions }, typeof revoke === \"string\" ? revoke : APPLE_REVOKE_FUNCTION);\n // The login is gone and its refresh tokens with it: only this device's copy is left to clear.\n await client.auth.signOut({ scope: \"local\" });\n}\nexport {\n tenantFeatures,\n setTenantFeature,\n tenantFeatureSettings,\n tenantFeatureHistory,\n type SetTenantFeatureInput,\n type TenantFeatureChange,\n type TenantFeatureSetting,\n type TenantFeatures,\n type TenantFeaturesClient,\n} from \"./tenant-features\";\nexport { accountBlock, jobsBlock, tenantFeaturesBlock } from \"./blocks\";\n"]}