@marktiderman/genesis-supabase 0.4.0 → 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
@@ -589,6 +589,107 @@ A delete that cascades into `tenant_features` (from your tenant table, or a pers
589
589
  goes with them) isn't recorded, so it never refuses the tenant's or the person's deletion; saving
590
590
  the same switch and reason again changes nothing, not even who and when.
591
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
+
592
693
  ## Testing your schema's data contract (genesis_testing)
593
694
 
594
695
  `sql/testing/genesis_testing.sql` is a pgTAP kit that checks your own tables and functions keep the
@@ -637,7 +738,7 @@ ids are shared with `@marktiderman/genesis-core/testing`.
637
738
 
638
739
  ## Blocks
639
740
 
640
- `accountBlock` and `tenantFeaturesBlock` are `defineBlock` declarations
741
+ `accountBlock`, `tenantFeaturesBlock` and `jobsBlock` are `defineBlock` declarations
641
742
  (`@marktiderman/genesis-features/blocks`) of what this package's blocks need: their migrations
642
743
  (with their tests) and, for the account, what it means for the stores' privacy forms. The
643
744
  functions above don't change; the declarations are for `resolveBlocks` and the shell.
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"]}
package/dist/jobs.d.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `@marktiderman/genesis-supabase/jobs`: the server half of
3
+ * `migrations/0007_genesis_jobs.sql`. Server only (it takes the service-role
4
+ * client and the job secret); never import it into browser or app code.
5
+ *
6
+ * - `enqueueJob`: queue work from server code (triggers enqueue in SQL).
7
+ * - `runJobs`: claim due jobs, run each kind's handler, settle each one, with
8
+ * backoff on failure. Never throws for one job's failure.
9
+ * - `createJobsHandler`: the app's one job route. It checks the request is
10
+ * signed for the job (`genesis_schedule_job` signs; the secret never
11
+ * travels) or carries the bearer secret (a platform cron, a person), with a
12
+ * previous secret accepted while you rotate it, and runs the named job.
13
+ *
14
+ * ```ts
15
+ * // src/routes/api/jobs/$job.ts (any server route that hands over a Request)
16
+ * const handle = createJobsHandler({
17
+ * secret: process.env.GENESIS_JOBS_SECRET,
18
+ * previousSecret: process.env.GENESIS_JOBS_SECRET_PREVIOUS,
19
+ * jobs: {
20
+ * queue: () => runJobs(admin, { handlers: { "email.digest": sendDigest } }),
21
+ * cleanup: () => removeQueuedFiles(admin),
22
+ * },
23
+ * });
24
+ * export const POST = ({ request, params }) => handle(request, params.job);
25
+ * ```
26
+ *
27
+ * @packageDocumentation
28
+ */
29
+ /** The parts of a Supabase client these helpers use: a service-role `SupabaseClient` fits. */
30
+ interface JobsClient {
31
+ rpc: (fn: any, ...rest: any[]) => PromiseLike<{
32
+ data: unknown;
33
+ error: {
34
+ message: string;
35
+ code?: string;
36
+ } | null;
37
+ }>;
38
+ }
39
+ /** A failure from the queue's own functions (not from a handler). */
40
+ declare class JobsError extends Error {
41
+ readonly code: string | undefined;
42
+ constructor(message: string, code?: string);
43
+ }
44
+ /** @stability Beta */
45
+ interface EnqueueOptions {
46
+ /** Run no earlier than this. Default now. */
47
+ runAfter?: Date;
48
+ /** One job per kind and key while it is queued or running: a repeat returns the queued job's id. */
49
+ idempotencyKey?: string;
50
+ /** Attempts before it fails for good, 1 to 100. Default 8. */
51
+ maxAttempts?: number;
52
+ }
53
+ /**
54
+ * Queue one job of `kind` (`"email.digest"`, lowercase) with a JSON object
55
+ * payload. Returns the job's id. Payloads are readable by the service role:
56
+ * put ids in them, never secrets.
57
+ *
58
+ * @stability Beta
59
+ */
60
+ declare function enqueueJob(client: JobsClient, kind: string, payload?: Record<string, unknown>, options?: EnqueueOptions): Promise<string>;
61
+ /** One claimed job, as a handler sees it. @stability Beta */
62
+ interface Job<Payload = Record<string, unknown>> {
63
+ id: string;
64
+ kind: string;
65
+ payload: Payload;
66
+ /** This attempt's number, from 1. */
67
+ attempt: number;
68
+ /** An earlier attempt's lease ran out: it may have done the work. Handlers that can look first, do. */
69
+ redelivery: boolean;
70
+ }
71
+ /**
72
+ * What a handler can return. Nothing (or `{ done: true }`) is done. Throwing
73
+ * retries with backoff; throw a `PermanentJobError` (or return
74
+ * `{ failed }`) to stop.
75
+ *
76
+ * @stability Beta
77
+ */
78
+ type JobOutcome = void | {
79
+ done: true;
80
+ } | {
81
+ retryAfterSeconds: number;
82
+ error?: string;
83
+ } | {
84
+ failed: string;
85
+ };
86
+ /** Thrown by a handler for a failure that won't get better with a retry (a bad payload, a 4xx). @stability Beta */
87
+ declare class PermanentJobError extends Error {
88
+ constructor(message: string);
89
+ }
90
+ type JobHandler = (job: Job<any>) => Promise<JobOutcome> | JobOutcome;
91
+ /** @stability Beta */
92
+ interface RunJobsOptions {
93
+ /** Kind → handler. Only these kinds are claimed. */
94
+ handlers: Readonly<Record<string, JobHandler>>;
95
+ /** Jobs claimed in this run. Default 25. */
96
+ limit?: number;
97
+ /** How long a claim holds a job; a handler must finish inside it. Default 300. */
98
+ leaseSeconds?: number;
99
+ /** Jobs run at once. Default 4. */
100
+ concurrency?: number;
101
+ /** Seconds before retry `attempt` (from 1). Default 30 s doubling, jittered, at most an hour. */
102
+ backoff?: (attempt: number) => number;
103
+ /** Where one line per failed job goes (kind, id, attempt, the error's message). Default console.error. */
104
+ log?: (line: string) => void;
105
+ }
106
+ /** @stability Beta */
107
+ interface RunJobsReport {
108
+ claimed: number;
109
+ done: number;
110
+ retried: number;
111
+ failed: number;
112
+ /** Settles refused because the lease ran out first (another run has the job). */
113
+ stale: number;
114
+ /** Settles that failed (the database unreachable): those jobs come back when their lease runs out. */
115
+ unsettled: number;
116
+ }
117
+ /** 30 s, doubling per attempt, ±20% jitter, at most an hour. */
118
+ declare function defaultBackoff(attempt: number): number;
119
+ /**
120
+ * Claim due jobs of the handled kinds, run them, and settle each: done, a
121
+ * retry after backoff, or failed. One job's failure, or a failure to record
122
+ * it, never stops the others; only a failed claim throws. Nothing about a
123
+ * payload is logged.
124
+ *
125
+ * @stability Beta
126
+ */
127
+ declare function runJobs(client: JobsClient, options: RunJobsOptions): Promise<RunJobsReport>;
128
+ /** @stability Beta */
129
+ interface VerifyJobRequestOptions {
130
+ /** The job secret. Unset refuses every request (500). */
131
+ secret: string | undefined;
132
+ /** The secret before a rotation, accepted too. Remove it once every caller has the new one. */
133
+ previousSecret?: string | undefined;
134
+ /** How far a signed request's time may be from now, in seconds. Default 300. */
135
+ maxSkewSeconds?: number;
136
+ /** Now, in seconds (for tests). */
137
+ now?: () => number;
138
+ }
139
+ /**
140
+ * The `Authorization` value a scheduler sends for `job`: `GenesisJob
141
+ * t=<unix seconds>,sig=<hex HMAC-SHA256 of "<t>.<job>">`. What
142
+ * `genesis_schedule_job` sends; for calling a job route from your own code.
143
+ *
144
+ * @stability Beta
145
+ */
146
+ declare function signJobRequest(secret: string, job: string, now?: number): Promise<string>;
147
+ /**
148
+ * Checks a request's `Authorization` in constant time against the secret and
149
+ * the previous one. Two forms: `GenesisJob t=…,sig=…`, signed for `job` within
150
+ * `maxSkewSeconds` (what schedules send: the secret never travels), or
151
+ * `Bearer <secret>` (a platform cron, a person). The scheme's case and extra
152
+ * spaces don't matter. Returns `null` when it passes, else the response to
153
+ * send: 401, or 500 when no secret is configured.
154
+ *
155
+ * @stability Beta
156
+ */
157
+ declare function verifyJobRequest(request: Request, options: VerifyJobRequestOptions, job?: string): Promise<Response | null>;
158
+ /**
159
+ * One named job a route can run. Its return value (JSON) is the response's `result` for a
160
+ * bearer call; a signed call (a schedule) gets `{ job, ok: true }` and its `body` is always `{}`.
161
+ *
162
+ * @stability Beta
163
+ */
164
+ type NamedJob = (context: {
165
+ request: Request;
166
+ body: Record<string, unknown>;
167
+ }) => Promise<unknown> | unknown;
168
+ /** @stability Beta */
169
+ interface JobsHandlerOptions extends VerifyJobRequestOptions {
170
+ /** Job name (`"digests"`, as `genesis_schedule_job` names it) → what it runs. */
171
+ jobs: Readonly<Record<string, NamedJob>>;
172
+ /** Where a job's failure goes (name and the error's message). Default console.error. */
173
+ log?: (line: string) => void;
174
+ }
175
+ /**
176
+ * The app's job route: checks the request is signed for (or carries the
177
+ * secret of) the job named by the route (`name`, e.g. a path param), the
178
+ * body's `job`, or the URL's last segment, then runs it. GET and POST (platform crons send GET). Answers 401, 404 for an
179
+ * unknown job, 405, 200 with the job's result, or 500 with no detail.
180
+ *
181
+ * @stability Beta
182
+ */
183
+ declare function createJobsHandler(options: JobsHandlerOptions): (request: Request, name?: string) => Promise<Response>;
184
+
185
+ export { type EnqueueOptions, type Job, type JobHandler, type JobOutcome, type JobsClient, JobsError, type JobsHandlerOptions, type NamedJob, PermanentJobError, type RunJobsOptions, type RunJobsReport, type VerifyJobRequestOptions, createJobsHandler, defaultBackoff, enqueueJob, runJobs, signJobRequest, verifyJobRequest };
package/dist/jobs.js ADDED
@@ -0,0 +1,163 @@
1
+ // src/jobs.ts
2
+ var JobsError = class extends Error {
3
+ code;
4
+ constructor(message, code) {
5
+ super(message);
6
+ this.name = "JobsError";
7
+ this.code = code;
8
+ }
9
+ };
10
+ async function call(client, fn, args) {
11
+ const { data, error } = await client.rpc(fn, args);
12
+ if (error) throw new JobsError(`${fn}: ${error.message}`, error.code);
13
+ return data;
14
+ }
15
+ function enqueueJob(client, kind, payload = {}, options = {}) {
16
+ return call(client, "genesis_job_enqueue", {
17
+ p_kind: kind,
18
+ p_payload: payload,
19
+ p_run_after: options.runAfter?.toISOString() ?? null,
20
+ p_idempotency_key: options.idempotencyKey ?? null,
21
+ p_max_attempts: options.maxAttempts ?? 8
22
+ });
23
+ }
24
+ var PermanentJobError = class extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = "PermanentJobError";
28
+ }
29
+ };
30
+ function defaultBackoff(attempt) {
31
+ const base = Math.min(3600, 30 * 2 ** Math.max(0, attempt - 1));
32
+ return Math.round(base * (0.8 + Math.random() * 0.4));
33
+ }
34
+ function messageOf(error) {
35
+ return (error instanceof Error ? `${error.name}: ${error.message}` : String(error)).slice(0, 1e3);
36
+ }
37
+ async function runJobs(client, options) {
38
+ const kinds = Object.keys(options.handlers);
39
+ const report = { claimed: 0, done: 0, retried: 0, failed: 0, stale: 0, unsettled: 0 };
40
+ if (kinds.length === 0) return report;
41
+ const backoff = options.backoff ?? defaultBackoff;
42
+ const log = options.log ?? ((line) => console.error(line));
43
+ const claimed = await call(client, "genesis_jobs_claim", {
44
+ p_kinds: kinds,
45
+ p_limit: options.limit ?? 25,
46
+ p_lease_seconds: options.leaseSeconds ?? 300
47
+ }) ?? [];
48
+ report.claimed = claimed.length;
49
+ const settle = async (job, outcome, retryAfter, error) => {
50
+ const ok = await call(client, "genesis_jobs_settle", {
51
+ p_id: job.id,
52
+ p_claim_id: job.claim_id,
53
+ p_outcome: outcome,
54
+ p_retry_after_seconds: retryAfter,
55
+ p_error: error
56
+ });
57
+ if (!ok) report.stale++;
58
+ else if (outcome === "done") report.done++;
59
+ else if (outcome === "retry") report.retried++;
60
+ else report.failed++;
61
+ };
62
+ const outcomeOf = async (job) => {
63
+ const handler = options.handlers[job.kind];
64
+ const { claim_id: _claim, ...seen } = job;
65
+ try {
66
+ const outcome = handler ? await handler(seen) : { failed: `no handler for ${job.kind}` };
67
+ if (outcome && "failed" in outcome) {
68
+ log(`[jobs] ${job.kind} ${job.id} attempt ${job.attempt} failed: ${outcome.failed}`);
69
+ return ["failed", null, outcome.failed.slice(0, 1e3)];
70
+ }
71
+ if (outcome && "retryAfterSeconds" in outcome) return ["retry", Math.max(0, Math.round(outcome.retryAfterSeconds)), outcome.error ?? null];
72
+ return ["done", null, null];
73
+ } catch (error) {
74
+ const message = messageOf(error);
75
+ log(`[jobs] ${job.kind} ${job.id} attempt ${job.attempt}: ${message}`);
76
+ return error instanceof PermanentJobError ? ["failed", null, message] : ["retry", backoff(job.attempt), message];
77
+ }
78
+ };
79
+ const runOne = async (job) => {
80
+ const [outcome, retryAfter, error] = await outcomeOf(job);
81
+ try {
82
+ await settle(job, outcome, retryAfter, error);
83
+ } catch (failure) {
84
+ report.unsettled++;
85
+ log(`[jobs] ${job.kind} ${job.id}: could not record ${outcome}: ${messageOf(failure)}`);
86
+ }
87
+ };
88
+ const queue = [...claimed];
89
+ const workers = Array.from({ length: Math.max(1, Math.min(options.concurrency ?? 4, queue.length)) }, async () => {
90
+ for (let job = queue.shift(); job; job = queue.shift()) await runOne(job);
91
+ });
92
+ await Promise.all(workers);
93
+ return report;
94
+ }
95
+ var encoder = new TextEncoder();
96
+ async function digest(value) {
97
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
98
+ }
99
+ function sameBytes(a, b) {
100
+ let diff = a.length ^ b.length;
101
+ for (let i = 0; i < a.length && i < b.length; i++) diff |= a[i] ^ b[i];
102
+ return diff === 0;
103
+ }
104
+ var hex = (bytes) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
105
+ async function hmacHex(secret, message) {
106
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
107
+ return hex(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
108
+ }
109
+ async function signJobRequest(secret, job, now = Math.floor(Date.now() / 1e3)) {
110
+ return `GenesisJob t=${now},sig=${await hmacHex(secret, `${now}.${job}`)}`;
111
+ }
112
+ async function verifyJobRequest(request, options, job) {
113
+ if (!options.secret) return Response.json({ error: "The job secret is not configured." }, { status: 500 });
114
+ const secrets = [options.secret, ...options.previousSecret ? [options.previousSecret] : []];
115
+ const header = (request.headers.get("authorization") ?? "").trim();
116
+ const unauthorized = () => Response.json({ error: "Unauthorized." }, { status: 401 });
117
+ const bearer = /^bearer\s+([^\s,]+)$/i.exec(header)?.[1];
118
+ if (bearer) {
119
+ const given = await digest(bearer);
120
+ let ok = false;
121
+ for (const secret of secrets) ok = sameBytes(given, await digest(secret)) || ok;
122
+ return ok ? null : unauthorized();
123
+ }
124
+ const signed = /^genesisjob\s+t=(\d{1,12})\s*,\s*sig=([0-9a-f]{64})$/i.exec(header);
125
+ if (signed && job) {
126
+ const t = Number(signed[1]);
127
+ const now = options.now?.() ?? Math.floor(Date.now() / 1e3);
128
+ if (Math.abs(now - t) > (options.maxSkewSeconds ?? 300)) return unauthorized();
129
+ const given = await digest(signed[2].toLowerCase());
130
+ let ok = false;
131
+ for (const secret of secrets) ok = sameBytes(given, await digest(await hmacHex(secret, `${t}.${job}`))) || ok;
132
+ return ok ? null : unauthorized();
133
+ }
134
+ return unauthorized();
135
+ }
136
+ var JOB_NAME = /^[a-z][a-z0-9]*([-_][a-z0-9]+)*$/;
137
+ function createJobsHandler(options) {
138
+ const log = options.log ?? ((line) => console.error(line));
139
+ return async (request, name) => {
140
+ if (request.method !== "GET" && request.method !== "POST") {
141
+ return Response.json({ error: "Use GET or POST." }, { status: 405, headers: { Allow: "GET, POST" } });
142
+ }
143
+ const signed = /^genesisjob\s/i.test((request.headers.get("authorization") ?? "").trim());
144
+ const raw = request.method === "POST" && !signed ? await request.clone().json().catch(() => null) : null;
145
+ const body = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
146
+ const job = name ?? (typeof body["job"] === "string" ? body["job"] : new URL(request.url).pathname.split("/").filter(Boolean).at(-1));
147
+ const refused = await verifyJobRequest(request, options, job);
148
+ if (refused) return refused;
149
+ const run = job && JOB_NAME.test(job) && Object.hasOwn(options.jobs, job) ? options.jobs[job] : void 0;
150
+ if (!run) return Response.json({ error: "No such job." }, { status: 404 });
151
+ try {
152
+ const result = await run({ request, body }) ?? null;
153
+ return Response.json(signed ? { job, ok: true } : { job, result });
154
+ } catch (error) {
155
+ log(`[jobs] ${job}: ${messageOf(error)}`);
156
+ return Response.json({ job, error: "The job failed; see the server log." }, { status: 500 });
157
+ }
158
+ };
159
+ }
160
+
161
+ export { JobsError, PermanentJobError, createJobsHandler, defaultBackoff, enqueueJob, runJobs, signJobRequest, verifyJobRequest };
162
+ //# sourceMappingURL=jobs.js.map
163
+ //# sourceMappingURL=jobs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/jobs.ts"],"names":[],"mappings":"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","file":"jobs.js","sourcesContent":["/**\n * `@marktiderman/genesis-supabase/jobs`: the server half of\n * `migrations/0007_genesis_jobs.sql`. Server only (it takes the service-role\n * client and the job secret); never import it into browser or app code.\n *\n * - `enqueueJob`: queue work from server code (triggers enqueue in SQL).\n * - `runJobs`: claim due jobs, run each kind's handler, settle each one, with\n * backoff on failure. Never throws for one job's failure.\n * - `createJobsHandler`: the app's one job route. It checks the request is\n * signed for the job (`genesis_schedule_job` signs; the secret never\n * travels) or carries the bearer secret (a platform cron, a person), with a\n * previous secret accepted while you rotate it, and runs the named job.\n *\n * ```ts\n * // src/routes/api/jobs/$job.ts (any server route that hands over a Request)\n * const handle = createJobsHandler({\n * secret: process.env.GENESIS_JOBS_SECRET,\n * previousSecret: process.env.GENESIS_JOBS_SECRET_PREVIOUS,\n * jobs: {\n * queue: () => runJobs(admin, { handlers: { \"email.digest\": sendDigest } }),\n * cleanup: () => removeQueuedFiles(admin),\n * },\n * });\n * export const POST = ({ request, params }) => handle(request, params.job);\n * ```\n *\n * @packageDocumentation\n */\n\n/** The parts of a Supabase client these helpers use: a service-role `SupabaseClient` fits. */\nexport interface JobsClient {\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: { message: string; code?: string } | null }>;\n}\n\n/** A failure from the queue's own functions (not from a handler). */\nexport class JobsError extends Error {\n readonly code: string | undefined;\n constructor(message: string, code?: string) {\n super(message);\n this.name = \"JobsError\";\n this.code = code;\n }\n}\n\nasync function call<T>(client: JobsClient, fn: string, args: Record<string, unknown>): Promise<T> {\n const { data, error } = await client.rpc(fn, args);\n if (error) throw new JobsError(`${fn}: ${error.message}`, error.code);\n return data as T;\n}\n\n/** @stability Beta */\nexport interface EnqueueOptions {\n /** Run no earlier than this. Default now. */\n runAfter?: Date;\n /** One job per kind and key while it is queued or running: a repeat returns the queued job's id. */\n idempotencyKey?: string;\n /** Attempts before it fails for good, 1 to 100. Default 8. */\n maxAttempts?: number;\n}\n\n/**\n * Queue one job of `kind` (`\"email.digest\"`, lowercase) with a JSON object\n * payload. Returns the job's id. Payloads are readable by the service role:\n * put ids in them, never secrets.\n *\n * @stability Beta\n */\nexport function enqueueJob(client: JobsClient, kind: string, payload: Record<string, unknown> = {}, options: EnqueueOptions = {}): Promise<string> {\n return call<string>(client, \"genesis_job_enqueue\", {\n p_kind: kind,\n p_payload: payload,\n p_run_after: options.runAfter?.toISOString() ?? null,\n p_idempotency_key: options.idempotencyKey ?? null,\n p_max_attempts: options.maxAttempts ?? 8,\n });\n}\n\n/** One claimed job, as a handler sees it. @stability Beta */\nexport interface Job<Payload = Record<string, unknown>> {\n id: string;\n kind: string;\n payload: Payload;\n /** This attempt's number, from 1. */\n attempt: number;\n /** An earlier attempt's lease ran out: it may have done the work. Handlers that can look first, do. */\n redelivery: boolean;\n}\n\n/**\n * What a handler can return. Nothing (or `{ done: true }`) is done. Throwing\n * retries with backoff; throw a `PermanentJobError` (or return\n * `{ failed }`) to stop.\n *\n * @stability Beta\n */\nexport type JobOutcome =\n | void\n | { done: true }\n | { retryAfterSeconds: number; error?: string }\n | { failed: string };\n\n/** Thrown by a handler for a failure that won't get better with a retry (a bad payload, a 4xx). @stability Beta */\nexport class PermanentJobError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"PermanentJobError\";\n }\n}\n\n// Handlers are keyed by kind, so each takes its own payload type.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type JobHandler = (job: Job<any>) => Promise<JobOutcome> | JobOutcome;\n\n/** @stability Beta */\nexport interface RunJobsOptions {\n /** Kind → handler. Only these kinds are claimed. */\n handlers: Readonly<Record<string, JobHandler>>;\n /** Jobs claimed in this run. Default 25. */\n limit?: number;\n /** How long a claim holds a job; a handler must finish inside it. Default 300. */\n leaseSeconds?: number;\n /** Jobs run at once. Default 4. */\n concurrency?: number;\n /** Seconds before retry `attempt` (from 1). Default 30 s doubling, jittered, at most an hour. */\n backoff?: (attempt: number) => number;\n /** Where one line per failed job goes (kind, id, attempt, the error's message). Default console.error. */\n log?: (line: string) => void;\n}\n\n/** @stability Beta */\nexport interface RunJobsReport {\n claimed: number;\n done: number;\n retried: number;\n failed: number;\n /** Settles refused because the lease ran out first (another run has the job). */\n stale: number;\n /** Settles that failed (the database unreachable): those jobs come back when their lease runs out. */\n unsettled: number;\n}\n\n/** 30 s, doubling per attempt, ±20% jitter, at most an hour. */\nexport function defaultBackoff(attempt: number): number {\n const base = Math.min(3600, 30 * 2 ** Math.max(0, attempt - 1));\n return Math.round(base * (0.8 + Math.random() * 0.4));\n}\n\nfunction messageOf(error: unknown): string {\n return (error instanceof Error ? `${error.name}: ${error.message}` : String(error)).slice(0, 1000);\n}\n\n/**\n * Claim due jobs of the handled kinds, run them, and settle each: done, a\n * retry after backoff, or failed. One job's failure, or a failure to record\n * it, never stops the others; only a failed claim throws. Nothing about a\n * payload is logged.\n *\n * @stability Beta\n */\nexport async function runJobs(client: JobsClient, options: RunJobsOptions): Promise<RunJobsReport> {\n const kinds = Object.keys(options.handlers);\n const report: RunJobsReport = { claimed: 0, done: 0, retried: 0, failed: 0, stale: 0, unsettled: 0 };\n if (kinds.length === 0) return report;\n const backoff = options.backoff ?? defaultBackoff;\n const log = options.log ?? ((line: string) => console.error(line));\n\n type Claimed = Job & { claim_id: string };\n const claimed =\n (await call<Claimed[] | null>(client, \"genesis_jobs_claim\", {\n p_kinds: kinds,\n p_limit: options.limit ?? 25,\n p_lease_seconds: options.leaseSeconds ?? 300,\n })) ?? [];\n report.claimed = claimed.length;\n\n const settle = async (job: Claimed, outcome: \"done\" | \"retry\" | \"failed\", retryAfter: number | null, error: string | null) => {\n const ok = await call<boolean>(client, \"genesis_jobs_settle\", {\n p_id: job.id,\n p_claim_id: job.claim_id,\n p_outcome: outcome,\n p_retry_after_seconds: retryAfter,\n p_error: error,\n });\n if (!ok) report.stale++;\n else if (outcome === \"done\") report.done++;\n else if (outcome === \"retry\") report.retried++;\n else report.failed++;\n };\n\n type Settle = [\"done\" | \"retry\" | \"failed\", number | null, string | null];\n\n // What the handler says, as a settle. Only the handler runs inside this try.\n const outcomeOf = async (job: Claimed): Promise<Settle> => {\n const handler = options.handlers[job.kind];\n const { claim_id: _claim, ...seen } = job;\n try {\n const outcome = handler ? await handler(seen) : { failed: `no handler for ${job.kind}` };\n if (outcome && \"failed\" in outcome) {\n log(`[jobs] ${job.kind} ${job.id} attempt ${job.attempt} failed: ${outcome.failed}`);\n return [\"failed\", null, outcome.failed.slice(0, 1000)];\n }\n if (outcome && \"retryAfterSeconds\" in outcome) return [\"retry\", Math.max(0, Math.round(outcome.retryAfterSeconds)), outcome.error ?? null];\n return [\"done\", null, null];\n } catch (error) {\n const message = messageOf(error);\n log(`[jobs] ${job.kind} ${job.id} attempt ${job.attempt}: ${message}`);\n return error instanceof PermanentJobError ? [\"failed\", null, message] : [\"retry\", backoff(job.attempt), message];\n }\n };\n\n const runOne = async (job: Claimed) => {\n const [outcome, retryAfter, error] = await outcomeOf(job);\n try {\n await settle(job, outcome, retryAfter, error);\n } catch (failure) {\n // The queue didn't take the outcome: the lease runs out and the job comes back as a\n // redelivery. The other jobs in this run carry on.\n report.unsettled++;\n log(`[jobs] ${job.kind} ${job.id}: could not record ${outcome}: ${messageOf(failure)}`);\n }\n };\n\n const queue = [...claimed];\n const workers = Array.from({ length: Math.max(1, Math.min(options.concurrency ?? 4, queue.length)) }, async () => {\n for (let job = queue.shift(); job; job = queue.shift()) await runOne(job);\n });\n await Promise.all(workers);\n return report;\n}\n\nconst encoder = new TextEncoder();\n\nasync function digest(value: string): Promise<Uint8Array> {\n return new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value)));\n}\n\nfunction sameBytes(a: Uint8Array, b: Uint8Array): boolean {\n let diff = a.length ^ b.length;\n for (let i = 0; i < a.length && i < b.length; i++) diff |= a[i]! ^ b[i]!;\n return diff === 0;\n}\n\n/** @stability Beta */\nexport interface VerifyJobRequestOptions {\n /** The job secret. Unset refuses every request (500). */\n secret: string | undefined;\n /** The secret before a rotation, accepted too. Remove it once every caller has the new one. */\n previousSecret?: string | undefined;\n /** How far a signed request's time may be from now, in seconds. Default 300. */\n maxSkewSeconds?: number;\n /** Now, in seconds (for tests). */\n now?: () => number;\n}\n\nconst hex = (bytes: ArrayBuffer) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n\nasync function hmacHex(secret: string, message: string): Promise<string> {\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return hex(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(message)));\n}\n\n/**\n * The `Authorization` value a scheduler sends for `job`: `GenesisJob\n * t=<unix seconds>,sig=<hex HMAC-SHA256 of \"<t>.<job>\">`. What\n * `genesis_schedule_job` sends; for calling a job route from your own code.\n *\n * @stability Beta\n */\nexport async function signJobRequest(secret: string, job: string, now: number = Math.floor(Date.now() / 1000)): Promise<string> {\n return `GenesisJob t=${now},sig=${await hmacHex(secret, `${now}.${job}`)}`;\n}\n\n/**\n * Checks a request's `Authorization` in constant time against the secret and\n * the previous one. Two forms: `GenesisJob t=…,sig=…`, signed for `job` within\n * `maxSkewSeconds` (what schedules send: the secret never travels), or\n * `Bearer <secret>` (a platform cron, a person). The scheme's case and extra\n * spaces don't matter. Returns `null` when it passes, else the response to\n * send: 401, or 500 when no secret is configured.\n *\n * @stability Beta\n */\nexport async function verifyJobRequest(request: Request, options: VerifyJobRequestOptions, job?: string): Promise<Response | null> {\n if (!options.secret) return Response.json({ error: \"The job secret is not configured.\" }, { status: 500 });\n const secrets = [options.secret, ...(options.previousSecret ? [options.previousSecret] : [])];\n const header = (request.headers.get(\"authorization\") ?? \"\").trim();\n const unauthorized = () => Response.json({ error: \"Unauthorized.\" }, { status: 401 });\n\n const bearer = /^bearer\\s+([^\\s,]+)$/i.exec(header)?.[1];\n if (bearer) {\n const given = await digest(bearer);\n let ok = false;\n for (const secret of secrets) ok = sameBytes(given, await digest(secret)) || ok;\n return ok ? null : unauthorized();\n }\n\n const signed = /^genesisjob\\s+t=(\\d{1,12})\\s*,\\s*sig=([0-9a-f]{64})$/i.exec(header);\n if (signed && job) {\n const t = Number(signed[1]);\n const now = options.now?.() ?? Math.floor(Date.now() / 1000);\n if (Math.abs(now - t) > (options.maxSkewSeconds ?? 300)) return unauthorized();\n const given = await digest(signed[2]!.toLowerCase());\n let ok = false;\n for (const secret of secrets) ok = sameBytes(given, await digest(await hmacHex(secret, `${t}.${job}`))) || ok;\n return ok ? null : unauthorized();\n }\n return unauthorized();\n}\n\n/**\n * One named job a route can run. Its return value (JSON) is the response's `result` for a\n * bearer call; a signed call (a schedule) gets `{ job, ok: true }` and its `body` is always `{}`.\n *\n * @stability Beta\n */\nexport type NamedJob = (context: { request: Request; body: Record<string, unknown> }) => Promise<unknown> | unknown;\n\n/** @stability Beta */\nexport interface JobsHandlerOptions extends VerifyJobRequestOptions {\n /** Job name (`\"digests\"`, as `genesis_schedule_job` names it) → what it runs. */\n jobs: Readonly<Record<string, NamedJob>>;\n /** Where a job's failure goes (name and the error's message). Default console.error. */\n log?: (line: string) => void;\n}\n\nconst JOB_NAME = /^[a-z][a-z0-9]*([-_][a-z0-9]+)*$/;\n\n/**\n * The app's job route: checks the request is signed for (or carries the\n * secret of) the job named by the route (`name`, e.g. a path param), the\n * body's `job`, or the URL's last segment, then runs it. GET and POST (platform crons send GET). Answers 401, 404 for an\n * unknown job, 405, 200 with the job's result, or 500 with no detail.\n *\n * @stability Beta\n */\nexport function createJobsHandler(options: JobsHandlerOptions): (request: Request, name?: string) => Promise<Response> {\n const log = options.log ?? ((line: string) => console.error(line));\n return async (request, name) => {\n if (request.method !== \"GET\" && request.method !== \"POST\") {\n return Response.json({ error: \"Use GET or POST.\" }, { status: 405, headers: { Allow: \"GET, POST\" } });\n }\n // A signed call (a schedule) is signed for the job name only, so nothing else it carries is\n // trusted: its body is ignored, and its response carries no result (pg_net keeps responses\n // in net._http_response, which other roles may read).\n const signed = /^genesisjob\\s/i.test((request.headers.get(\"authorization\") ?? \"\").trim());\n const raw = request.method === \"POST\" && !signed ? await request.clone().json().catch(() => null) : null;\n const body = raw && typeof raw === \"object\" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};\n const job = name ?? (typeof body[\"job\"] === \"string\" ? body[\"job\"] : new URL(request.url).pathname.split(\"/\").filter(Boolean).at(-1));\n\n // The signature is for one job name, so check it against the name the route will run.\n const refused = await verifyJobRequest(request, options, job);\n if (refused) return refused;\n\n const run = job && JOB_NAME.test(job) && Object.hasOwn(options.jobs, job) ? options.jobs[job] : undefined;\n if (!run) return Response.json({ error: \"No such job.\" }, { status: 404 });\n\n try {\n const result = (await run({ request, body })) ?? null;\n return Response.json(signed ? { job, ok: true } : { job, result });\n } catch (error) {\n log(`[jobs] ${job}: ${messageOf(error)}`);\n return Response.json({ job, error: \"The job failed; see the server log.\" }, { status: 500 });\n }\n };\n}\n"]}
@@ -0,0 +1,352 @@
1
+ -- Genesis jobs: one background-job queue and one way to schedule work.
2
+ --
3
+ -- Two halves:
4
+ --
5
+ -- 1. A queue, private.genesis_jobs. Code (a trigger, a server function, the app) enqueues a job
6
+ -- of a kind with a JSON payload; a runner claims due jobs with SKIP LOCKED, each leased to a
7
+ -- claim id, does the work and settles each one: done, retry later, or failed. A job whose
8
+ -- lease runs out comes back as a redelivery; one that keeps dying fails as lease_expired.
9
+ -- An idempotency key makes an enqueue that repeats (a retried trigger, a double click) one job.
10
+ -- The runner half is `runJobs` in @marktiderman/genesis-supabase/jobs.
11
+ --
12
+ -- 2. Schedules. genesis_schedule_job(name, cron, job) asks pg_cron to POST to the app's job route
13
+ -- (`<url>/<job>`) through pg_net. The request carries no secret: it is signed,
14
+ -- `Authorization: GenesisJob t=<unix seconds>,sig=<hex HMAC-SHA256 of "<t>.<job>">`, with the
15
+ -- secret the route checks. A signature is good for that one job for five minutes, so the copy
16
+ -- pg_net keeps in its queue until it sends (net.http_request_queue, readable by more roles than
17
+ -- you'd think) is worth little. The URL and the secret are read from Vault by name at call
18
+ -- time and never stored in cron's table:
19
+ --
20
+ -- select vault.create_secret('https://app.example.com/api/jobs', 'genesis_jobs_url');
21
+ -- select vault.create_secret('<a long random secret>', 'genesis_jobs_secret');
22
+ -- select genesis_schedule_job('send-digests', '*/5 * * * *', 'digests');
23
+ --
24
+ -- The app's route is `createJobsHandler` in @marktiderman/genesis-supabase/jobs, which
25
+ -- checks the signature (or a plain bearer secret, for a platform cron or a person). pg_cron,
26
+ -- pg_net and pgcrypto are only needed for schedules: the queue works without them. Schedules
27
+ -- are cron jobs named `genesis-job:<name>`, so these functions never touch another cron job.
28
+ -- The 14-day purge is scheduled only if pg_cron is there when this runs; enable pg_cron later
29
+ -- and schedule it yourself: select cron.schedule('genesis-jobs-purge', '20 4 * * *',
30
+ -- 'select private.genesis_jobs_purge()');
31
+ --
32
+ -- Access: the queue has row level security on, no policies and no grants to anon or
33
+ -- authenticated. Everything goes through the functions below, granted by name to service_role
34
+ -- only (a server job, a trigger running as its owner). Payloads never hold secrets: they are
35
+ -- readable by anyone with the service role.
36
+ --
37
+ -- Use: copy this file into your app's `supabase/migrations/` under a new timestamped name
38
+ -- (`genesis migrate` does it). Never edit it once applied; change it with a new migration.
39
+ -- It stands alone.
40
+ --
41
+ -- Verify: the pgTAP test beside this file (`tests/0007_genesis_jobs.test.sql`).
42
+
43
+ create schema if not exists private;
44
+
45
+ create table private.genesis_jobs (
46
+ id uuid primary key default gen_random_uuid(),
47
+ kind text not null
48
+ constraint genesis_jobs_kind_format check (kind ~ '^[a-z][a-z0-9]*([-_.:][a-z0-9]+)*$' and char_length(kind) <= 100),
49
+ payload jsonb not null default '{}'::jsonb,
50
+ -- One job per (kind, key) while it is queued or running: a second enqueue returns the first.
51
+ -- Once the job is done, failed or cancelled the key is free again.
52
+ idempotency_key text
53
+ constraint genesis_jobs_idempotency_key_length check (char_length(idempotency_key) between 1 and 200),
54
+ status text not null default 'pending'
55
+ constraint genesis_jobs_status check (status in ('pending', 'running', 'done', 'failed', 'cancelled')),
56
+ run_after timestamptz not null default now(),
57
+ -- Attempts started (claims), including redeliveries.
58
+ attempts integer not null default 0,
59
+ max_attempts integer not null default 8
60
+ constraint genesis_jobs_max_attempts check (max_attempts between 1 and 100),
61
+ lease_until timestamptz,
62
+ -- The claim this job is leased to; settling with another one is stale.
63
+ claim_id uuid,
64
+ last_error text
65
+ constraint genesis_jobs_last_error_length check (char_length(last_error) <= 1000),
66
+ created_at timestamptz not null default now(),
67
+ updated_at timestamptz not null default now(),
68
+ finished_at timestamptz
69
+ );
70
+
71
+ create unique index genesis_jobs_idempotency on private.genesis_jobs (kind, idempotency_key)
72
+ where idempotency_key is not null and status in ('pending', 'running');
73
+ create index genesis_jobs_due on private.genesis_jobs (run_after) where status = 'pending';
74
+ create index genesis_jobs_leased on private.genesis_jobs (lease_until) where status = 'running';
75
+ create index genesis_jobs_finished on private.genesis_jobs (finished_at) where finished_at is not null;
76
+
77
+ alter table private.genesis_jobs enable row level security;
78
+ revoke all on table private.genesis_jobs from public, anon, authenticated;
79
+
80
+ -- Queues one job and returns its id. With an idempotency key, a job of the same kind and key
81
+ -- that is still pending or running is returned instead of a new one.
82
+ create or replace function public.genesis_job_enqueue(
83
+ p_kind text,
84
+ p_payload jsonb default '{}'::jsonb,
85
+ p_run_after timestamptz default null,
86
+ p_idempotency_key text default null,
87
+ p_max_attempts integer default 8
88
+ ) returns uuid
89
+ language plpgsql
90
+ security definer
91
+ set search_path = ''
92
+ as $$
93
+ declare
94
+ job uuid;
95
+ begin
96
+ if p_kind is null then
97
+ raise exception 'genesis_job_enqueue: kind is required' using errcode = '22023';
98
+ end if;
99
+ if p_payload is null or jsonb_typeof(p_payload) <> 'object' then
100
+ raise exception 'genesis_job_enqueue: payload is a JSON object' using errcode = '22023';
101
+ end if;
102
+ if p_idempotency_key is not null then
103
+ select j.id into job from private.genesis_jobs j
104
+ where j.kind = p_kind and j.idempotency_key = p_idempotency_key and j.status in ('pending', 'running');
105
+ if found then
106
+ return job;
107
+ end if;
108
+ end if;
109
+ begin
110
+ insert into private.genesis_jobs (kind, payload, run_after, idempotency_key, max_attempts)
111
+ values (p_kind, p_payload, coalesce(p_run_after, now()), p_idempotency_key, coalesce(p_max_attempts, 8))
112
+ returning id into job;
113
+ exception when unique_violation then
114
+ -- Two enqueues with one key raced; the other one won.
115
+ select j.id into job from private.genesis_jobs j
116
+ where j.kind = p_kind and j.idempotency_key = p_idempotency_key and j.status in ('pending', 'running');
117
+ end;
118
+ return job;
119
+ end $$;
120
+
121
+ -- Hands out up to p_limit due jobs of the given kinds (all kinds when null), oldest first, each
122
+ -- leased for p_lease_seconds to one new claim id. Due: pending and past run_after, or running
123
+ -- with a lease that ran out (`redelivery`: the last attempt may have done the work). A job whose
124
+ -- lease ran out on its last attempt fails as lease_expired instead of coming back for ever.
125
+ create or replace function public.genesis_jobs_claim(
126
+ p_kinds text[] default null,
127
+ p_limit integer default 25,
128
+ p_lease_seconds integer default 300
129
+ ) returns table (id uuid, kind text, payload jsonb, attempt integer, redelivery boolean, claim_id uuid)
130
+ language plpgsql
131
+ security definer
132
+ set search_path = ''
133
+ as $$
134
+ #variable_conflict use_column
135
+ declare
136
+ claim uuid := gen_random_uuid();
137
+ begin
138
+ if p_limit is null or p_limit not between 1 and 1000 then
139
+ raise exception 'genesis_jobs_claim: limit is 1 to 1000' using errcode = '22023';
140
+ end if;
141
+ if p_lease_seconds is null or p_lease_seconds not between 1 and 3600 then
142
+ raise exception 'genesis_jobs_claim: lease is 1 to 3600 seconds' using errcode = '22023';
143
+ end if;
144
+
145
+ -- Out of attempts with the lease gone: the runner died with it every time.
146
+ update private.genesis_jobs j
147
+ set status = 'failed', last_error = 'lease_expired', lease_until = null, claim_id = null,
148
+ finished_at = now(), updated_at = now()
149
+ where j.status = 'running' and j.lease_until < now() and j.attempts >= j.max_attempts
150
+ and (p_kinds is null or j.kind = any (p_kinds));
151
+
152
+ return query
153
+ with due as (
154
+ select j.id, j.status = 'running' as redelivery
155
+ from private.genesis_jobs j
156
+ where ((j.status = 'pending' and j.run_after <= now()) or (j.status = 'running' and j.lease_until < now()))
157
+ and (p_kinds is null or j.kind = any (p_kinds))
158
+ order by j.run_after, j.created_at
159
+ limit p_limit
160
+ for update skip locked
161
+ )
162
+ update private.genesis_jobs j
163
+ set status = 'running', attempts = j.attempts + 1, lease_until = now() + make_interval(secs => p_lease_seconds),
164
+ claim_id = claim, updated_at = now()
165
+ from due
166
+ where j.id = due.id
167
+ returning j.id, j.kind, j.payload, j.attempts, due.redelivery, j.claim_id;
168
+ end $$;
169
+
170
+ -- Records what happened to a claimed job. p_outcome: 'done'; 'retry' (back to pending after
171
+ -- p_retry_after_seconds, or failed when out of attempts); 'failed' (permanent). Returns false,
172
+ -- changing nothing, when the claim is stale (the lease ran out and the job was claimed again).
173
+ create or replace function public.genesis_jobs_settle(
174
+ p_id uuid,
175
+ p_claim_id uuid,
176
+ p_outcome text,
177
+ p_retry_after_seconds integer default null,
178
+ p_error text default null
179
+ ) returns boolean
180
+ language plpgsql
181
+ security definer
182
+ set search_path = ''
183
+ as $$
184
+ declare
185
+ j private.genesis_jobs;
186
+ begin
187
+ if p_outcome is null or p_outcome not in ('done', 'retry', 'failed') then
188
+ raise exception 'genesis_jobs_settle: outcome is done, retry or failed' using errcode = '22023';
189
+ end if;
190
+ if p_outcome = 'retry' and (p_retry_after_seconds is null or p_retry_after_seconds not between 0 and 604800) then
191
+ raise exception 'genesis_jobs_settle: a retry needs retry_after_seconds, 0 to 604800' using errcode = '22023';
192
+ end if;
193
+
194
+ select * into j from private.genesis_jobs x where x.id = p_id for update;
195
+ if not found or j.status <> 'running' or j.claim_id is distinct from p_claim_id then
196
+ return false;
197
+ end if;
198
+
199
+ if p_outcome = 'done' then
200
+ update private.genesis_jobs x set status = 'done', last_error = null, lease_until = null, claim_id = null,
201
+ finished_at = now(), updated_at = now() where x.id = p_id;
202
+ elsif p_outcome = 'retry' and j.attempts < j.max_attempts then
203
+ update private.genesis_jobs x set status = 'pending', last_error = left(p_error, 1000),
204
+ run_after = now() + make_interval(secs => p_retry_after_seconds), lease_until = null, claim_id = null,
205
+ updated_at = now() where x.id = p_id;
206
+ else
207
+ update private.genesis_jobs x set status = 'failed', last_error = left(coalesce(p_error, 'failed'), 1000),
208
+ lease_until = null, claim_id = null, finished_at = now(), updated_at = now() where x.id = p_id;
209
+ end if;
210
+ return true;
211
+ end $$;
212
+
213
+ -- Cancels a job that hasn't run yet. A running job finishes its attempt; true when cancelled.
214
+ create or replace function public.genesis_job_cancel(p_id uuid) returns boolean
215
+ language sql
216
+ security definer
217
+ set search_path = ''
218
+ as $$
219
+ with cancelled as (
220
+ update private.genesis_jobs j set status = 'cancelled', finished_at = now(), updated_at = now()
221
+ where j.id = p_id and j.status = 'pending'
222
+ returning 1
223
+ )
224
+ select exists (select 1 from cancelled)
225
+ $$;
226
+
227
+ -- Deletes finished jobs (done, failed, cancelled) older than p_keep. Scheduled below when pg_cron
228
+ -- is there; returns how many went.
229
+ create or replace function private.genesis_jobs_purge(p_keep interval default interval '14 days') returns integer
230
+ language sql
231
+ security definer
232
+ set search_path = ''
233
+ as $$
234
+ with gone as (
235
+ delete from private.genesis_jobs j
236
+ where j.status in ('done', 'failed', 'cancelled') and j.finished_at < now() - p_keep
237
+ returning 1
238
+ )
239
+ select count(*)::integer from gone
240
+ $$;
241
+
242
+ -- POSTs {"job": p_job} to <genesis_jobs_url>/<p_job>, signed with genesis_jobs_secret (both read
243
+ -- from Vault now; the secret itself is never sent). Returns pg_net's request id. What cron runs for
244
+ -- each schedule.
245
+ create or replace function private.genesis_jobs_call(p_job text) returns bigint
246
+ language plpgsql
247
+ security definer
248
+ set search_path = ''
249
+ as $$
250
+ declare
251
+ base text;
252
+ secret text;
253
+ stamp text;
254
+ signature text;
255
+ request bigint;
256
+ begin
257
+ if p_job is null or p_job !~ '^[a-z][a-z0-9]*([-_][a-z0-9]+)*$' or char_length(p_job) > 100 then
258
+ raise exception 'genesis_jobs_call: job is a lowercase name like "send-digests"' using errcode = '22023';
259
+ end if;
260
+ if to_regclass('vault.decrypted_secrets') is null then
261
+ raise exception 'genesis_jobs_call: Vault is not installed' using errcode = '55000';
262
+ end if;
263
+ if to_regnamespace('net') is null then
264
+ raise exception 'genesis_jobs_call: pg_net is not installed (create extension pg_net)' using errcode = '55000';
265
+ end if;
266
+ execute 'select decrypted_secret from vault.decrypted_secrets where name = $1' into base using 'genesis_jobs_url';
267
+ execute 'select decrypted_secret from vault.decrypted_secrets where name = $1' into secret using 'genesis_jobs_secret';
268
+ if base is null or secret is null then
269
+ raise exception 'genesis_jobs_call: set the Vault secrets genesis_jobs_url and genesis_jobs_secret' using errcode = '55000';
270
+ end if;
271
+ if to_regprocedure('extensions.hmac(text, text, text)') is null then
272
+ raise exception 'genesis_jobs_call: pgcrypto is not installed (create extension pgcrypto with schema extensions)' using errcode = '55000';
273
+ end if;
274
+ stamp := floor(extract(epoch from clock_timestamp()))::bigint::text;
275
+ execute 'select encode(extensions.hmac($1, $2, ''sha256''), ''hex'')' into signature using stamp || '.' || p_job, secret;
276
+ execute 'select net.http_post(url := $1, body := $2, headers := $3, timeout_milliseconds := 10000)'
277
+ into request
278
+ using rtrim(base, '/') || '/' || p_job,
279
+ jsonb_build_object('job', p_job),
280
+ jsonb_build_object('Content-Type', 'application/json', 'Authorization', 'GenesisJob t=' || stamp || ',sig=' || signature);
281
+ return request;
282
+ end $$;
283
+
284
+ -- Schedules (or reschedules) p_job on a cron schedule, as the cron job `genesis-job:<p_name>`.
285
+ -- Returns cron's job id.
286
+ create or replace function public.genesis_schedule_job(p_name text, p_schedule text, p_job text) returns bigint
287
+ language plpgsql
288
+ security definer
289
+ set search_path = ''
290
+ as $$
291
+ declare
292
+ scheduled bigint;
293
+ begin
294
+ if p_name is null or p_name !~ '^[a-z][a-z0-9]*([-_][a-z0-9]+)*$' or char_length(p_name) > 100 then
295
+ raise exception 'genesis_schedule_job: name is a lowercase name like "send-digests"' using errcode = '22023';
296
+ end if;
297
+ if p_job is null or p_job !~ '^[a-z][a-z0-9]*([-_][a-z0-9]+)*$' or char_length(p_job) > 100 then
298
+ raise exception 'genesis_schedule_job: job is a lowercase name like "digests"' using errcode = '22023';
299
+ end if;
300
+ if to_regnamespace('cron') is null then
301
+ raise exception 'genesis_schedule_job: pg_cron is not installed (create extension pg_cron)' using errcode = '55000';
302
+ end if;
303
+ execute 'select cron.schedule($1, $2, $3)' into scheduled
304
+ using 'genesis-job:' || p_name, p_schedule, format('select private.genesis_jobs_call(%L)', p_job);
305
+ return scheduled;
306
+ end $$;
307
+
308
+ -- Removes a schedule made by genesis_schedule_job (only `genesis-job:` cron jobs). True when one was there.
309
+ create or replace function public.genesis_unschedule_job(p_name text) returns boolean
310
+ language plpgsql
311
+ security definer
312
+ set search_path = ''
313
+ as $$
314
+ declare
315
+ removed boolean := false;
316
+ begin
317
+ if to_regnamespace('cron') is null then
318
+ return false;
319
+ end if;
320
+ if p_name is null or p_name !~ '^[a-z][a-z0-9]*([-_][a-z0-9]+)*$' or char_length(p_name) > 100 then
321
+ return false;
322
+ end if;
323
+ execute 'select exists (select 1 from cron.job where jobname = $1)' into removed using 'genesis-job:' || p_name;
324
+ if removed then
325
+ execute 'select cron.unschedule($1)' using 'genesis-job:' || p_name;
326
+ end if;
327
+ return removed;
328
+ end $$;
329
+
330
+ revoke execute on function public.genesis_job_enqueue(text, jsonb, timestamptz, text, integer) from public, anon, authenticated;
331
+ revoke execute on function public.genesis_jobs_claim(text[], integer, integer) from public, anon, authenticated;
332
+ revoke execute on function public.genesis_jobs_settle(uuid, uuid, text, integer, text) from public, anon, authenticated;
333
+ revoke execute on function public.genesis_job_cancel(uuid) from public, anon, authenticated;
334
+ revoke execute on function public.genesis_schedule_job(text, text, text) from public, anon, authenticated;
335
+ revoke execute on function public.genesis_unschedule_job(text) from public, anon, authenticated;
336
+ revoke execute on function private.genesis_jobs_purge(interval) from public, anon, authenticated;
337
+ revoke execute on function private.genesis_jobs_call(text) from public, anon, authenticated;
338
+
339
+ grant execute on function public.genesis_job_enqueue(text, jsonb, timestamptz, text, integer) to service_role;
340
+ grant execute on function public.genesis_jobs_claim(text[], integer, integer) to service_role;
341
+ grant execute on function public.genesis_jobs_settle(uuid, uuid, text, integer, text) to service_role;
342
+ grant execute on function public.genesis_job_cancel(uuid) to service_role;
343
+ grant execute on function public.genesis_schedule_job(text, text, text) to service_role;
344
+ grant execute on function public.genesis_unschedule_job(text) to service_role;
345
+
346
+ -- Finished jobs go after 14 days, when pg_cron is there.
347
+ do $$
348
+ begin
349
+ if to_regnamespace('cron') is not null then
350
+ perform cron.schedule('genesis-jobs-purge', '20 4 * * *', 'select private.genesis_jobs_purge()');
351
+ end if;
352
+ end $$;
@@ -0,0 +1,129 @@
1
+ -- @feat:jobs
2
+ -- pgTAP test for 0007_genesis_jobs.sql.
3
+ -- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
4
+ -- It writes only its own jobs and rolls everything back. The schedule checks run when pg_cron
5
+ -- is installed and pass as skipped when it isn't.
6
+ begin;
7
+ create extension if not exists pgtap with schema extensions;
8
+ select plan(46);
9
+
10
+ -- Access: only the service role reaches the queue, and only through the functions.
11
+ select ok(not has_function_privilege('anon', 'public.genesis_job_enqueue(text, jsonb, timestamptz, text, integer)', 'execute'), 'anon cannot enqueue');
12
+ select ok(not has_function_privilege('authenticated', 'public.genesis_job_enqueue(text, jsonb, timestamptz, text, integer)', 'execute'), 'authenticated cannot enqueue');
13
+ select ok(not has_function_privilege('authenticated', 'public.genesis_jobs_claim(text[], integer, integer)', 'execute'), 'authenticated cannot claim');
14
+ select ok(not has_function_privilege('authenticated', 'public.genesis_jobs_settle(uuid, uuid, text, integer, text)', 'execute'), 'authenticated cannot settle');
15
+ select ok(not has_function_privilege('authenticated', 'public.genesis_schedule_job(text, text, text)', 'execute'), 'authenticated cannot schedule');
16
+ select ok(not has_function_privilege('authenticated', 'private.genesis_jobs_call(text)', 'execute'), 'authenticated cannot call a job route');
17
+ select ok(has_function_privilege('service_role', 'public.genesis_jobs_claim(text[], integer, integer)', 'execute'), 'service_role claims');
18
+ select ok(not has_table_privilege('authenticated', 'private.genesis_jobs', 'select'), 'authenticated cannot read the queue');
19
+ select ok((select relrowsecurity from pg_class where oid = 'private.genesis_jobs'::regclass), 'the queue has row level security on');
20
+
21
+ -- Start from an empty queue so other jobs in the app's database don't change the counts.
22
+ create temp table saved_jobs as select * from private.genesis_jobs;
23
+ delete from private.genesis_jobs;
24
+
25
+ -- Enqueue.
26
+ select throws_ok($$ select public.genesis_job_enqueue('Bad Kind') $$, '23514', null, 'a kind is lowercase');
27
+ select throws_ok($$ select public.genesis_job_enqueue('test.a', '[1]'::jsonb) $$, '22023', null, 'a payload is an object');
28
+
29
+ create temp table ids (name text primary key, id uuid);
30
+ insert into ids values ('a1', public.genesis_job_enqueue('test.a', '{"n": 1}'));
31
+ insert into ids values ('a2', public.genesis_job_enqueue('test.a', '{"n": 2}', p_idempotency_key => 'k'));
32
+ select is(public.genesis_job_enqueue('test.a', '{"n": 3}', p_idempotency_key => 'k'), (select id from ids where name = 'a2'),
33
+ 'an enqueue with a queued key returns the queued job');
34
+ select isnt(public.genesis_job_enqueue('test.b', '{}', p_idempotency_key => 'k'), (select id from ids where name = 'a2'),
35
+ 'the same key on another kind is another job');
36
+ insert into ids values ('later', public.genesis_job_enqueue('test.a', '{}', now() + interval '1 hour'));
37
+ select is((select count(*)::int from private.genesis_jobs), 4, 'four jobs queued');
38
+
39
+ -- Claim.
40
+ select throws_ok($$ select * from public.genesis_jobs_claim(null, 0) $$, '22023', null, 'a claim takes 1 or more');
41
+ create temp table claim1 as select * from public.genesis_jobs_claim(array['test.a'], 10, 60);
42
+ select is((select count(*)::int from claim1), 2, 'a claim hands out the due jobs of its kinds, not the future one');
43
+ select ok((select bool_and(attempt = 1 and not redelivery) from claim1), 'first attempts, not redeliveries');
44
+ select is((select count(distinct claim_id)::int from claim1), 1, 'one claim id for the batch');
45
+ select is((select count(*)::int from public.genesis_jobs_claim(array['test.a'], 10, 60)), 0, 'leased jobs are not handed out again');
46
+ select is((select count(*)::int from public.genesis_jobs_claim(null, 10, 60)), 1, 'no kinds claims every kind (test.b)');
47
+
48
+ -- Settle.
49
+ select ok(public.genesis_jobs_settle((select id from ids where name = 'a1'), (select claim_id from claim1 limit 1), 'done'), 'done settles');
50
+ select is((select status from private.genesis_jobs where id = (select id from ids where name = 'a1')), 'done', 'the job is done');
51
+ select ok(not public.genesis_jobs_settle((select id from ids where name = 'a1'), (select claim_id from claim1 limit 1), 'done'), 'settling twice is stale');
52
+ select ok(not public.genesis_jobs_settle((select id from ids where name = 'a2'), gen_random_uuid(), 'done'), 'another claim id is stale');
53
+ select throws_ok(format('select public.genesis_jobs_settle(%L, %L, %L)', (select id from ids where name = 'a2'), (select claim_id from claim1 limit 1), 'retry'),
54
+ '22023', null, 'a retry needs a delay');
55
+ select ok(public.genesis_jobs_settle((select id from ids where name = 'a2'), (select claim_id from claim1 limit 1), 'retry', 0, 'timeout'), 'retry settles');
56
+ select is((select status || ':' || last_error from private.genesis_jobs where id = (select id from ids where name = 'a2')), 'pending:timeout', 'a retry is pending again with its error');
57
+ select ok(public.genesis_job_enqueue('test.a', '{}', p_idempotency_key => 'k') = (select id from ids where name = 'a2'), 'a retrying job still holds its key');
58
+
59
+ -- Out of attempts: a retry on the last attempt fails.
60
+ update private.genesis_jobs set max_attempts = 2 where id = (select id from ids where name = 'a2');
61
+ create temp table claim2 as select * from public.genesis_jobs_claim(array['test.a'], 10, 60);
62
+ select is((select attempt from claim2 where id = (select id from ids where name = 'a2')), 2, 'the second attempt');
63
+ select ok(public.genesis_jobs_settle((select id from ids where name = 'a2'), (select claim_id from claim2 limit 1), 'retry', 0, 'timeout again'), 'the last retry settles');
64
+ select is((select status from private.genesis_jobs where id = (select id from ids where name = 'a2')), 'failed', 'out of attempts, it fails');
65
+ select isnt(public.genesis_job_enqueue('test.a', '{}', p_idempotency_key => 'k'), (select id from ids where name = 'a2'), 'a failed job frees its key');
66
+
67
+ -- Lease expiry: redelivered, then failed as lease_expired when out of attempts.
68
+ delete from private.genesis_jobs;
69
+ insert into ids values ('lease', public.genesis_job_enqueue('test.lease', '{}', p_max_attempts => 2));
70
+ select is((select count(*)::int from public.genesis_jobs_claim(array['test.lease'], 1, 60)), 1, 'the first attempt is claimed');
71
+ update private.genesis_jobs set lease_until = now() - interval '1 second' where id = (select id from ids where name = 'lease');
72
+ select is((select redelivery from public.genesis_jobs_claim(array['test.lease'], 1, 60)), true, 'a job whose lease ran out comes back as a redelivery');
73
+ update private.genesis_jobs set lease_until = now() - interval '1 second' where id = (select id from ids where name = 'lease');
74
+ select is((select count(*)::int from public.genesis_jobs_claim(array['test.lease'], 1, 60)), 0, 'out of attempts with the lease gone, it is not handed out');
75
+ select is((select status || ':' || last_error from private.genesis_jobs where id = (select id from ids where name = 'lease')), 'failed:lease_expired', 'it fails as lease_expired');
76
+
77
+ -- Cancel and purge.
78
+ insert into ids values ('cancel', public.genesis_job_enqueue('test.c', '{}'));
79
+ select ok(public.genesis_job_cancel((select id from ids where name = 'cancel')), 'a pending job cancels');
80
+ update private.genesis_jobs set finished_at = now() - interval '30 days';
81
+ select is(private.genesis_jobs_purge(), 2, 'finished jobs older than 14 days are purged');
82
+
83
+ -- Schedules. cron.job is read through a function so this file parses without pg_cron.
84
+ create function pg_temp.cron_has(p_name text) returns boolean language plpgsql as $$
85
+ declare found boolean;
86
+ begin
87
+ execute 'select exists (select 1 from cron.job where jobname = $1)' into found using p_name;
88
+ return found;
89
+ end $$;
90
+ select throws_ok($$ select public.genesis_schedule_job('Bad Name', '* * * * *', 'x') $$, '22023', null, 'a schedule name is lowercase');
91
+ select throws_ok($$ select private.genesis_jobs_call('../admin') $$, '22023', null, 'a job name is never a path');
92
+ select case when to_regnamespace('cron') is null
93
+ then skip('pg_cron is not installed', 1)
94
+ else ok(public.genesis_schedule_job('genesis-jobs-test', '*/5 * * * *', 'digests') > 0, 'schedules through pg_cron') end;
95
+ select case when to_regnamespace('cron') is null
96
+ then skip('pg_cron is not installed', 1)
97
+ else ok(pg_temp.cron_has('genesis-job:genesis-jobs-test'), 'a schedule is the cron job genesis-job:<name>') end;
98
+ create temp table unscheduled as select case when to_regnamespace('cron') is null then null else public.genesis_unschedule_job('genesis-jobs-test') end as removed;
99
+ select case when to_regnamespace('cron') is null
100
+ then skip('pg_cron is not installed', 1)
101
+ else ok((select removed from unscheduled)
102
+ and not pg_temp.cron_has('genesis-job:genesis-jobs-test'), 'unschedules it') end;
103
+ select case when to_regnamespace('cron') is null
104
+ then skip('pg_cron is not installed', 1)
105
+ else ok(not public.genesis_unschedule_job('genesis-jobs-purge'), 'never touches a cron job it did not make') end;
106
+
107
+ -- A call is signed, never carrying the secret. Needs Vault, pg_net and pgcrypto; skipped without.
108
+ -- pg_net's queue, read through a function so this file parses without pg_net.
109
+ create function pg_temp.last_call_signed() returns boolean language plpgsql as $$
110
+ declare ok boolean;
111
+ begin
112
+ execute $q$select url = 'http://127.0.0.1:9/api/jobs/digests'
113
+ and headers->>'Authorization' ~ '^GenesisJob t=[0-9]+,sig=[0-9a-f]{64}$'
114
+ and headers::text !~ 'genesis-test-secret'
115
+ from net.http_request_queue order by id desc limit 1$q$ into ok;
116
+ return ok;
117
+ end $$;
118
+ create temp table call_ready as select to_regclass('vault.secrets') is not null and to_regnamespace('net') is not null
119
+ and to_regprocedure('extensions.hmac(text, text, text)') is not null as ready;
120
+ select case when not (select ready from call_ready)
121
+ then skip('Vault, pg_net or pgcrypto is not installed', 1)
122
+ else lives_ok($$ select vault.create_secret('http://127.0.0.1:9/api/jobs/', 'genesis_jobs_url'), vault.create_secret('genesis-test-secret', 'genesis_jobs_secret'), private.genesis_jobs_call('digests') $$, 'a call queues a request') end;
123
+ select case when not (select ready from call_ready)
124
+ then skip('Vault, pg_net or pgcrypto is not installed', 1)
125
+ else ok(pg_temp.last_call_signed(), 'posts to <url>/<job>, signed, with no secret in the queue') end;
126
+
127
+ insert into private.genesis_jobs select * from saved_jobs;
128
+ select * from finish();
129
+ rollback;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@marktiderman/genesis-supabase",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
- "description": "Supabase for Genesis apps on web and React Native: a client with PKCE and safe defaults, Keychain session storage that survives interrupted writes, refresh only while the app is in front, native Sign in with Apple and Google, OAuth redirects, in-app account deletion, per-tenant feature switches (tenant_features), and a result helper. Part of the Genesis framework.",
7
+ "description": "Supabase for Genesis apps on web and React Native: a client with PKCE and safe defaults, Keychain session storage that survives interrupted writes, refresh only while the app is in front, native Sign in with Apple and Google, OAuth redirects, in-app account deletion, per-tenant feature switches (tenant_features), a background-job queue with schedules (genesis_jobs), and a result helper. Part of the Genesis framework.",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/marktiderman/genesis.git",
@@ -31,6 +31,10 @@
31
31
  "./blocks": {
32
32
  "types": "./dist/blocks.d.ts",
33
33
  "import": "./dist/blocks.js"
34
+ },
35
+ "./jobs": {
36
+ "types": "./dist/jobs.d.ts",
37
+ "import": "./dist/jobs.js"
34
38
  }
35
39
  },
36
40
  "files": [
@@ -41,7 +45,7 @@
41
45
  "README.md"
42
46
  ],
43
47
  "dependencies": {
44
- "@marktiderman/genesis-features": "^0.3.0"
48
+ "@marktiderman/genesis-features": "^0.4.0"
45
49
  },
46
50
  "peerDependencies": {
47
51
  "@supabase/supabase-js": "^2.118.0",
@@ -123,6 +127,37 @@
123
127
  "steps": [
124
128
  "Give tenant_features your membership rule in a migration of your own: create private.genesis_tenant_admin(tenant uuid, person uuid) returns boolean, and revoke execute on it from public, anon, authenticated (genesis-supabase README, Tenant feature switches). Without it nobody can change a switch."
125
129
  ]
130
+ },
131
+ "jobs": {
132
+ "feature": {
133
+ "name": "Background jobs",
134
+ "summary": "A job queue (enqueue, claim with leases, retry with backoff) and named schedules that call the app's job route with a secret.",
135
+ "keywords": [
136
+ "background jobs",
137
+ "cron",
138
+ "queue",
139
+ "scheduled tasks"
140
+ ],
141
+ "audience": [
142
+ "developer"
143
+ ]
144
+ },
145
+ "platforms": [
146
+ "web"
147
+ ],
148
+ "migrations": [
149
+ {
150
+ "file": "migrations/0007_genesis_jobs.sql",
151
+ "test": "migrations/tests/0007_genesis_jobs.test.sql"
152
+ }
153
+ ],
154
+ "install": [
155
+ "@supabase/supabase-js@^2.118.0"
156
+ ],
157
+ "steps": [
158
+ "Add one job route with createJobsHandler from @marktiderman/genesis-supabase/jobs, and set GENESIS_JOBS_SECRET on the server.",
159
+ "For schedules: enable pg_cron and pg_net, add the Vault secrets genesis_jobs_url (the route's base URL) and genesis_jobs_secret (the same secret), then call genesis_schedule_job(name, cron, job) in a migration."
160
+ ]
126
161
  }
127
162
  }
128
163
  },
@@ -1 +0,0 @@
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","file":"chunk-T2HGFC4C.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"]}