@marktiderman/genesis-supabase 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +523 -1
  2. package/dist/blocks.d.ts +19 -0
  3. package/dist/blocks.js +3 -0
  4. package/dist/blocks.js.map +1 -0
  5. package/dist/chunk-FAA2FTF4.js +110 -0
  6. package/dist/chunk-FAA2FTF4.js.map +1 -0
  7. package/dist/chunk-T2HGFC4C.js +37 -0
  8. package/dist/chunk-T2HGFC4C.js.map +1 -0
  9. package/dist/index-BSGz3RGk.d.ts +317 -0
  10. package/dist/index.d.ts +4 -78
  11. package/dist/index.js +63 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/native.d.ts +233 -5
  14. package/dist/native.js +196 -1
  15. package/dist/native.js.map +1 -1
  16. package/functions/genesis-apple-revoke/apple.ts +237 -0
  17. package/functions/genesis-apple-revoke/handler.ts +227 -0
  18. package/functions/genesis-apple-revoke/index.ts +55 -0
  19. package/functions/genesis-push-send/expo.ts +117 -0
  20. package/functions/genesis-push-send/handler.ts +462 -0
  21. package/functions/genesis-push-send/index.ts +54 -0
  22. package/functions/genesis-support-cleanup/handler.ts +80 -0
  23. package/functions/genesis-support-cleanup/index.ts +39 -0
  24. package/functions/genesis-support-intake/contract.ts +193 -0
  25. package/functions/genesis-support-intake/handler.ts +170 -0
  26. package/functions/genesis-support-intake/index.ts +47 -0
  27. package/functions/genesis-support-notify/handler.ts +167 -0
  28. package/functions/genesis-support-notify/index.ts +15 -0
  29. package/functions/tests/genesis-apple-revoke_test.ts +81 -0
  30. package/functions/tests/genesis-push-send_test.ts +53 -0
  31. package/migrations/0001_genesis_account_deletion.sql +133 -0
  32. package/migrations/0002_genesis_app_release_policy.sql +82 -0
  33. package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
  34. package/migrations/0004_genesis_tenant_features.sql +333 -0
  35. package/migrations/0005_genesis_push.sql +524 -0
  36. package/migrations/0006_genesis_support.sql +846 -0
  37. package/migrations/tests/0001_genesis_account_deletion.test.sql +138 -0
  38. package/migrations/tests/0002_genesis_app_release_policy.test.sql +148 -0
  39. package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
  40. package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
  41. package/migrations/tests/0005_genesis_push.test.sql +407 -0
  42. package/migrations/tests/0006_genesis_support.test.sql +552 -0
  43. package/package.json +92 -4
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The genesis-support-notify Edge Function's request handling, with its
3
+ * dependencies passed in, so it is tested without Deno.
4
+ *
5
+ * A Supabase Database Webhook calls it on INSERT into public.support_reports
6
+ * (Dashboard → Database → Webhooks, or `supabase_functions.http_request` in a
7
+ * migration of your own), with the header `x-genesis-support-secret: <secret>`.
8
+ * It forwards a summary to the sinks that are configured:
9
+ *
10
+ * - a GitHub issue per report: GITHUB_TOKEN (fine-grained, Issues: write on
11
+ * one repository) and GITHUB_REPO ("owner/name"); GITHUB_LABELS optional;
12
+ * - a webhook: SUPPORT_WEBHOOK_URL, the body signed with
13
+ * SUPPORT_WEBHOOK_SIGNING_SECRET as `x-genesis-signature: sha256=<hex>`.
14
+ *
15
+ * What leaves the database is the summary only: the report id, type, title,
16
+ * message (cut to 2000 characters), route, app version, platform and time.
17
+ * Never the person's id or email, the device or context bags, or a
18
+ * screenshot. The message is what the person typed and may still name
19
+ * someone, so point the GitHub sink at a private repository.
20
+ */
21
+
22
+ export interface ReportRecord {
23
+ id: string;
24
+ type?: string;
25
+ title?: string | null;
26
+ message?: string;
27
+ route?: string | null;
28
+ app_version?: string | null;
29
+ platform?: string | null;
30
+ created_at?: string;
31
+ }
32
+
33
+ export interface ReportSummary {
34
+ id: string;
35
+ type: string;
36
+ title: string;
37
+ message: string;
38
+ route: string | null;
39
+ appVersion: string | null;
40
+ platform: string | null;
41
+ createdAt: string | null;
42
+ }
43
+
44
+ export interface NotifyDeps {
45
+ env: (name: string) => string | undefined;
46
+ fetch: typeof fetch;
47
+ log: (message: string) => void;
48
+ }
49
+
50
+ const MESSAGE_CAP = 2000;
51
+
52
+ /** The only fields a sink ever sees. */
53
+ export function summarize(record: ReportRecord): ReportSummary {
54
+ const message = String(record.message ?? "");
55
+ return {
56
+ id: String(record.id),
57
+ type: String(record.type ?? "bug"),
58
+ title: (record.title ?? "").trim() || message.slice(0, 120),
59
+ message: message.length > MESSAGE_CAP ? `${message.slice(0, MESSAGE_CAP)}…` : message,
60
+ route: record.route ?? null,
61
+ appVersion: record.app_version ?? null,
62
+ platform: record.platform ?? null,
63
+ createdAt: record.created_at ?? null,
64
+ };
65
+ }
66
+
67
+ /** A constant-time comparison, so the secret can't be guessed a byte at a time. */
68
+ export function sameSecret(a: string, b: string): boolean {
69
+ const x = new TextEncoder().encode(a);
70
+ const y = new TextEncoder().encode(b);
71
+ let diff = x.length ^ y.length;
72
+ for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
73
+ return diff === 0;
74
+ }
75
+
76
+ export async function hmacHex(secret: string, body: string): Promise<string> {
77
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
78
+ const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)));
79
+ return Array.from(sig, (b) => b.toString(16).padStart(2, "0")).join("");
80
+ }
81
+
82
+ function json(status: number, body: Record<string, unknown>): Response {
83
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
84
+ }
85
+
86
+ /** Markdown for the issue body; the person's text is fenced so it can't mention or link anyone. */
87
+ export function issueBody(s: ReportSummary): string {
88
+ const fence = "````";
89
+ return [
90
+ `**${s.type}** from the app${s.platform ? ` (${s.platform})` : ""}`,
91
+ "",
92
+ fence,
93
+ s.message.replaceAll(fence, "``` `"),
94
+ fence,
95
+ "",
96
+ `- Route: ${s.route ? `\`${s.route.replaceAll("`", "")}\`` : "none"}`,
97
+ `- Version: ${s.appVersion ? `\`${s.appVersion.replaceAll("`", "")}\`` : "unknown"}`,
98
+ `- Report: \`${s.id}\``,
99
+ ].join("\n");
100
+ }
101
+
102
+ export async function handle(req: Request, deps: NotifyDeps): Promise<Response> {
103
+ if (req.method !== "POST") return json(405, { error: "method_not_allowed" });
104
+ const secret = deps.env("GENESIS_SUPPORT_WEBHOOK_SECRET") ?? "";
105
+ if (!secret) {
106
+ deps.log("[genesis-support-notify] GENESIS_SUPPORT_WEBHOOK_SECRET is not set; refusing every call");
107
+ return json(500, { error: "not_configured" });
108
+ }
109
+ if (!sameSecret(req.headers.get("x-genesis-support-secret") ?? "", secret)) return json(401, { error: "unauthorized" });
110
+
111
+ let payload: { type?: string; table?: string; schema?: string; record?: ReportRecord };
112
+ try {
113
+ payload = await req.json();
114
+ } catch {
115
+ return json(400, { error: "invalid_json" });
116
+ }
117
+ if (payload.type !== "INSERT" || payload.table !== "support_reports" || !payload.record?.id) {
118
+ return json(200, { ok: true, skipped: true });
119
+ }
120
+ const summary = summarize(payload.record);
121
+ const sent: string[] = [];
122
+ const failed: string[] = [];
123
+
124
+ const repo = deps.env("GITHUB_REPO");
125
+ const token = deps.env("GITHUB_TOKEN");
126
+ if (repo && token) {
127
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
128
+ deps.log("[genesis-support-notify] GITHUB_REPO must be owner/name");
129
+ failed.push("github");
130
+ } else {
131
+ const labels = (deps.env("GITHUB_LABELS") ?? "feedback").split(",").map((l) => l.trim()).filter(Boolean);
132
+ const res = await deps.fetch(`https://api.github.com/repos/${repo}/issues`, {
133
+ method: "POST",
134
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", "user-agent": "genesis-support-notify" },
135
+ body: JSON.stringify({ title: `[${summary.type}] ${summary.title}`.slice(0, 256), body: issueBody(summary), labels: [...labels, summary.type] }),
136
+ });
137
+ if (res.ok) sent.push("github");
138
+ else {
139
+ deps.log(`[genesis-support-notify] GitHub answered ${res.status}`);
140
+ failed.push("github");
141
+ }
142
+ }
143
+ }
144
+
145
+ const hook = deps.env("SUPPORT_WEBHOOK_URL");
146
+ if (hook) {
147
+ if (!hook.startsWith("https://")) {
148
+ deps.log("[genesis-support-notify] SUPPORT_WEBHOOK_URL must be https");
149
+ failed.push("webhook");
150
+ } else {
151
+ const body = JSON.stringify({ event: "support.report.created", report: summary });
152
+ const signing = deps.env("SUPPORT_WEBHOOK_SIGNING_SECRET");
153
+ const headers: Record<string, string> = { "content-type": "application/json" };
154
+ if (signing) headers["x-genesis-signature"] = `sha256=${await hmacHex(signing, body)}`;
155
+ const res = await deps.fetch(hook, { method: "POST", headers, body });
156
+ if (res.ok) sent.push("webhook");
157
+ else {
158
+ deps.log(`[genesis-support-notify] the webhook answered ${res.status}`);
159
+ failed.push("webhook");
160
+ }
161
+ }
162
+ }
163
+
164
+ if (!sent.length && !failed.length) deps.log("[genesis-support-notify] no sink is configured (GITHUB_REPO + GITHUB_TOKEN, or SUPPORT_WEBHOOK_URL)");
165
+ // 502 when a sink failed, so the database webhook's log shows it.
166
+ return json(failed.length ? 502 : 200, { ok: failed.length === 0, sent, failed });
167
+ }
@@ -0,0 +1,15 @@
1
+ // genesis-support-notify: an optional server-side sink for new support reports.
2
+ // A Database Webhook on INSERT into public.support_reports calls it; it opens a
3
+ // GitHub issue per report and/or posts a signed webhook, with a summary only
4
+ // (no person id, email, device, context or screenshot). See handler.ts.
5
+ // A template from @marktiderman/genesis-supabase: copy this folder to your app's
6
+ // `supabase/functions/`, beside migration 0006 (see the package README).
7
+ //
8
+ // Secrets (`supabase secrets set …`): GENESIS_SUPPORT_WEBHOOK_SECRET (required; the
9
+ // database webhook sends it as `x-genesis-support-secret`), and for each sink you
10
+ // want: GITHUB_TOKEN + GITHUB_REPO (+ GITHUB_LABELS), SUPPORT_WEBHOOK_URL
11
+ // (+ SUPPORT_WEBHOOK_SIGNING_SECRET). Deploy with `verify_jwt = false`: the
12
+ // webhook secret is the check.
13
+ import { handle } from "./handler.ts";
14
+
15
+ Deno.serve((req) => handle(req, { env: (name) => Deno.env.get(name), fetch, log: (message) => console.error(message) }));
@@ -0,0 +1,81 @@
1
+ // Deno test for the genesis-apple-revoke function's handler, in the runtime it
2
+ // deploys to (WebCrypto, fetch, Request). Apple and the database are faked.
3
+ // deno test functions/tests/genesis-apple-revoke_test.ts
4
+ import { handle, type DueRevocation, type HandlerDeps } from "../genesis-apple-revoke/handler.ts";
5
+ import { decodeJwtPayload } from "../genesis-apple-revoke/apple.ts";
6
+
7
+ function assertEquals(actual: unknown, expected: unknown, message = "") {
8
+ const a = JSON.stringify(actual);
9
+ const e = JSON.stringify(expected);
10
+ if (a !== e) throw new Error(`${message} expected ${e}, got ${a}`);
11
+ }
12
+
13
+ const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
14
+ const der = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey));
15
+ const pem = `-----BEGIN PRIVATE KEY-----\n${btoa(String.fromCharCode(...der))}\n-----END PRIVATE KEY-----`;
16
+ const env: Record<string, string> = { APPLE_TEAM_ID: "TEAM123456", APPLE_KEY_ID: "KEY1234567", APPLE_PRIVATE_KEY: pem, APPLE_CLIENT_IDS: "com.example.app" };
17
+ const b64 = (o: unknown) => btoa(JSON.stringify(o)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
18
+
19
+ function deps(queue: DueRevocation[], forms: Record<string, string>[]): HandlerDeps & { saved: unknown[][]; done: number[] } {
20
+ const saved: unknown[][] = [];
21
+ const done: number[] = [];
22
+ return {
23
+ saved,
24
+ done,
25
+ env: (n) => env[n],
26
+ fetch: (async (url: string | URL | Request, init?: RequestInit) => {
27
+ const form = Object.fromEntries(new URLSearchParams(String(init?.body)));
28
+ forms.push({ url: String(url), ...form });
29
+ if (String(url).endsWith("/auth/token")) {
30
+ const id_token = `${b64({ alg: "RS256" })}.${b64({ iss: "https://appleid.apple.com", aud: "com.example.app", sub: "apple-sub" })}.sig`;
31
+ return new Response(JSON.stringify({ refresh_token: "rt", id_token }), { status: 200 });
32
+ }
33
+ return new Response(null, { status: 200 });
34
+ }) as typeof fetch,
35
+ getUser: async (t) => (t === "access" ? { id: "user-1", appleUserIds: ["apple-sub"] } : null),
36
+ store: {
37
+ save: async (...args) => {
38
+ saved.push(args);
39
+ return "stored";
40
+ },
41
+ claim: async () => queue.splice(0),
42
+ done: async (id) => void done.push(id),
43
+ failed: async () => "retry" as const,
44
+ },
45
+ log: () => {},
46
+ };
47
+ }
48
+
49
+ const post = (body: unknown, token?: string) =>
50
+ new Request("http://localhost/functions/v1/genesis-apple-revoke", { method: "POST", headers: token ? { authorization: `Bearer ${token}` } : {}, body: JSON.stringify(body) });
51
+
52
+ Deno.test("store exchanges the code with a signed client secret and saves the token", async () => {
53
+ const forms: Record<string, string>[] = [];
54
+ const d = deps([], forms);
55
+ const res = await handle(post({ action: "store", code: "code-1" }, "access"), d);
56
+ assertEquals(res.status, 200);
57
+ assertEquals(await res.json(), { ok: true });
58
+ assertEquals(d.saved, [["user-1", "com.example.app", "rt"]]);
59
+ const secret = forms[0]!.client_secret!;
60
+ const [h, p, s] = secret.split(".");
61
+ const ok = await crypto.subtle.verify(
62
+ { name: "ECDSA", hash: "SHA-256" },
63
+ pair.publicKey,
64
+ Uint8Array.from(atob(s!.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)),
65
+ new TextEncoder().encode(`${h}.${p}`),
66
+ );
67
+ assertEquals(ok, true, "client secret signature");
68
+ assertEquals(decodeJwtPayload(secret)?.sub, "com.example.app");
69
+ });
70
+
71
+ Deno.test("revoke works through the queue with no caller", async () => {
72
+ const forms: Record<string, string>[] = [];
73
+ const d = deps([{ id: 9, client_id: "com.example.app", refresh_token: "queued-rt", attempts: 1 }], forms);
74
+ const res = await handle(post({ action: "revoke" }), d);
75
+ assertEquals(res.status, 200);
76
+ // First the proof (the token is live with these secrets), then the revocation.
77
+ assertEquals(forms[0]!.grant_type, "refresh_token");
78
+ assertEquals(forms[1]!.url, "https://appleid.apple.com/auth/revoke");
79
+ assertEquals(forms[1]!.token_type_hint, "refresh_token");
80
+ assertEquals(d.done, [9]);
81
+ });
@@ -0,0 +1,53 @@
1
+ // Deno test for the genesis-push-send function's handler, in the runtime it
2
+ // deploys to (WebCrypto, fetch, Request, AbortSignal.timeout). Expo and the
3
+ // database are faked.
4
+ // deno test functions/tests/genesis-push-send_test.ts
5
+ import { handle, type HandlerDeps, type TicketRow } from "../genesis-push-send/handler.ts";
6
+
7
+ function assertEquals(actual: unknown, expected: unknown, message = "") {
8
+ const a = JSON.stringify(actual);
9
+ const e = JSON.stringify(expected);
10
+ if (a !== e) throw new Error(`${message} expected ${e}, got ${a}`);
11
+ }
12
+
13
+ const secret = "x".repeat(32);
14
+ const user = "00000000-0000-4000-8000-000000000001";
15
+
16
+ function deps(sentBodies: unknown[], tickets: TicketRow[]): HandlerDeps {
17
+ return {
18
+ env: (n) => ({ GENESIS_PUSH_SECRET: secret })[n],
19
+ fetch: (async (_url: string | URL | Request, init?: RequestInit) => {
20
+ const body = JSON.parse(String(init?.body)) as unknown[];
21
+ sentBodies.push(body);
22
+ return new Response(JSON.stringify({ data: body.map((_, i) => ({ status: "ok", id: `t${i}` })) }), { status: 200 });
23
+ }) as typeof fetch,
24
+ store: {
25
+ targets: async () => [{ device_id: "d1", token: "ExponentPushToken[abc]", platform: "ios", hide_previews: true }],
26
+ status: async () => ({ deliver: true, reason: "ok", quiet_until: null }),
27
+ recordTickets: async (rows) => void tickets.push(...rows),
28
+ forgetDevices: async () => {},
29
+ claimReceipts: async () => [],
30
+ settleReceipts: async () => {},
31
+ },
32
+ log: () => {},
33
+ };
34
+ }
35
+
36
+ Deno.test("sends with hidden previews and records the ticket", async () => {
37
+ const sent: unknown[] = [];
38
+ const tickets: TicketRow[] = [];
39
+ const req = new Request("http://localhost", {
40
+ method: "POST",
41
+ headers: { authorization: `Bearer ${secret}` },
42
+ body: JSON.stringify({ action: "send", messages: [{ userId: user, category: "messages", title: "Private title", body: "Private body", url: "/messages/1" }] }),
43
+ });
44
+ const res = await handle(req, deps(sent, tickets));
45
+ assertEquals(res.status, 200);
46
+ assertEquals(sent, [[{ to: "ExponentPushToken[abc]", title: "New notification", data: { category: "messages", url: "/messages/1" }, sound: "default", channelId: "messages", priority: "high" }]]);
47
+ assertEquals(tickets, [{ user_id: user, device_id: "d1", category: "messages", ticket_id: "t0", status: "sent" }]);
48
+ });
49
+
50
+ Deno.test("refuses a caller without the secret", async () => {
51
+ const res = await handle(new Request("http://localhost", { method: "POST", headers: { authorization: "Bearer nope" }, body: "{}" }), deps([], []));
52
+ assertEquals(res.status, 401);
53
+ });
@@ -0,0 +1,133 @@
1
+ -- Genesis account deletion: public.delete_my_account()
2
+ --
3
+ -- App Store Review Guideline 5.1.1(v) requires an app that lets people make
4
+ -- an account to let them delete it from inside the app. This function deletes
5
+ -- the signed-in person's login; the client half is `deleteMyAccount(client)`
6
+ -- in @marktiderman/genesis-supabase.
7
+ --
8
+ -- Use: copy this file into your app's `supabase/migrations/` under a new
9
+ -- timestamped name (e.g. `20261024000000_genesis_account_deletion.sql`).
10
+ -- Never edit it once applied; change it with a new migration.
11
+ --
12
+ -- What it refuses, by SQLSTATE (the client sees it as `SupabaseQueryError.code`):
13
+ -- 42501 nobody is signed in, or the person has a verified MFA factor and
14
+ -- this session isn't aal2 (verify the second factor first)
15
+ -- GA001 refused by the app's hook (below), e.g. the sole owner of something
16
+ -- GA002 the app requires a recent sign-in and this one isn't (below)
17
+ -- 23503 a row still references the login (see "Your data")
18
+ --
19
+ -- Your data: deleting from auth.users removes rows whose foreign keys to
20
+ -- auth.users(id) say `on delete cascade`, and fails (23503) on any that
21
+ -- don't, so the person keeps their whole account rather than half of it. Give
22
+ -- every user-owned table a foreign key to auth.users with `on delete cascade`
23
+ -- (or `set null` for rows that must outlive the person): a table keyed on
24
+ -- auth.uid() with no foreign key keeps orphans, and accepts new ones while an
25
+ -- old access token is still valid (below).
26
+ --
27
+ -- Storage: Supabase documents that a user who owns objects in Storage can't
28
+ -- be deleted, so this fails (23503) while they have files. Remove a person's
29
+ -- files through the Storage API (not SQL) before calling it. (Documented by
30
+ -- Supabase; not verified against a full stack in Genesis's own tests.)
31
+ --
32
+ -- Sign in with Apple: Apple requires apps to revoke the person's Apple tokens
33
+ -- (https://appleid.apple.com/auth/revoke) when the account is deleted. That
34
+ -- needs your Apple client secret, so it is a server step this file can't do;
35
+ -- see the package README ("Not covered yet").
36
+ --
37
+ -- Sessions: the person's refresh tokens go with the login. An access token
38
+ -- already issued stays valid until it expires (the JWT expiry, one hour by
39
+ -- default); `deleteMyAccount` signs this device out straight after.
40
+ --
41
+ -- The app hook: if `private.genesis_before_account_delete(uuid)` exists, it
42
+ -- runs first, in the same transaction, with this function's (the owner's)
43
+ -- rights and the person's id. Use it to scrub data, set flags, or refuse. Any
44
+ -- error it raises refuses the delete and nothing is deleted; it reaches the
45
+ -- client as GA001 (or as the hook's own code when that starts with GA), e.g.
46
+ --
47
+ -- create function private.genesis_before_account_delete(person uuid) returns void
48
+ -- language plpgsql set search_path = '' as $$
49
+ -- begin
50
+ -- if exists (select 1 from public.teams t where t.sole_owner = person) then
51
+ -- raise exception 'Hand over your teams before deleting your account';
52
+ -- end if;
53
+ -- -- Optional: require a sign-in in the last 10 minutes (GA002 otherwise).
54
+ -- perform private.genesis_require_recent_sign_in(interval '10 minutes');
55
+ -- end $$;
56
+ -- revoke execute on function private.genesis_before_account_delete(uuid) from public, anon, authenticated;
57
+ --
58
+ -- Verify: the pgTAP test beside this file (`tests/0001_genesis_account_deletion.test.sql`).
59
+
60
+ create schema if not exists private;
61
+
62
+ -- Raises GA002 unless the person authenticated (password, OTP, OAuth, MFA…)
63
+ -- within `max_age`. Reads the newest `amr` timestamp in the JWT, which records
64
+ -- when they actually signed in and survives token refreshes; falls back to
65
+ -- `iat` (when this token was issued, which a refresh renews) without one.
66
+ -- Not called by default: an app turns it on from its hook.
67
+ create or replace function private.genesis_require_recent_sign_in(max_age interval)
68
+ returns void
69
+ language plpgsql
70
+ stable
71
+ set search_path = ''
72
+ as $$
73
+ declare
74
+ claims jsonb := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb);
75
+ signed_in bigint;
76
+ begin
77
+ select max((m ->> 'timestamp')::bigint) into signed_in
78
+ from jsonb_array_elements(case when jsonb_typeof(claims -> 'amr') = 'array' then claims -> 'amr' else '[]'::jsonb end) m
79
+ where m ->> 'timestamp' ~ '^\d+$';
80
+ signed_in := coalesce(signed_in, nullif(claims ->> 'iat', '')::bigint);
81
+ if signed_in is null or to_timestamp(signed_in) < now() - max_age then
82
+ raise exception 'delete_my_account: sign in again to delete your account'
83
+ using errcode = 'GA002', hint = 'Ask the person to sign in again, then retry.';
84
+ end if;
85
+ end;
86
+ $$;
87
+
88
+ create or replace function public.delete_my_account()
89
+ returns void
90
+ language plpgsql
91
+ security definer
92
+ set search_path = ''
93
+ as $$
94
+ declare
95
+ me uuid := auth.uid();
96
+ claims jsonb := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb);
97
+ begin
98
+ if me is null then
99
+ raise exception 'delete_my_account: not signed in' using errcode = '42501';
100
+ end if;
101
+
102
+ -- MFA step-up: with a verified second factor, only an aal2 session may delete.
103
+ if exists (select 1 from auth.mfa_factors f where f.user_id = me and f.status = 'verified')
104
+ and coalesce(claims ->> 'aal', '') <> 'aal2' then
105
+ raise exception 'delete_my_account: verify your second factor first'
106
+ using errcode = '42501', hint = 'Complete MFA (aal2), then retry.';
107
+ end if;
108
+
109
+ if to_regprocedure('private.genesis_before_account_delete(uuid)') is not null then
110
+ begin
111
+ perform private.genesis_before_account_delete(me);
112
+ exception when others then
113
+ if left(sqlstate, 2) = 'GA' then
114
+ raise;
115
+ end if;
116
+ raise exception '%', sqlerrm
117
+ using errcode = 'GA001', detail = format('Refused by private.genesis_before_account_delete (SQLSTATE %s).', sqlstate);
118
+ end;
119
+ end if;
120
+
121
+ delete from auth.users where id = me;
122
+ end;
123
+ $$;
124
+
125
+ comment on function public.delete_my_account() is
126
+ 'Deletes the signed-in person''s login (auth.users) and, through on delete cascade, their data. Genesis account block.';
127
+
128
+ -- Privileges by name. Revoking here doesn't survive a later blanket grant
129
+ -- (`grant execute on all functions in schema public to anon` hands this back
130
+ -- to anon), so a blanket grant must exclude these functions.
131
+ revoke execute on function public.delete_my_account() from public, anon;
132
+ grant execute on function public.delete_my_account() to authenticated;
133
+ revoke execute on function private.genesis_require_recent_sign_in(interval) from public, anon, authenticated;
@@ -0,0 +1,82 @@
1
+ -- Genesis app release policy: public.app_release_policy
2
+ --
3
+ -- What a native app reads at launch to decide whether this install must
4
+ -- update from the store, whether the app is down for maintenance, and which
5
+ -- release notes to show. The client half is `supabasePolicySource(client)` in
6
+ -- @marktiderman/genesis-native/updates.
7
+ --
8
+ -- Use: copy this file into your app's `supabase/migrations/` under a new
9
+ -- timestamped name (e.g. `20261024000000_genesis_app_release_policy.sql`).
10
+ -- Never edit it once applied; change it with a new migration.
11
+ --
12
+ -- Access: anyone may read (anon included: the app reads it before sign-in);
13
+ -- nobody but the service role (or a migration) may write. Edit the row from
14
+ -- the SQL editor, a migration, or a server job with the secret key.
15
+ --
16
+ -- Failing open: the client never blocks on a missing row, a malformed value
17
+ -- or a failed read. The checks below keep malformed values out in the first
18
+ -- place: a minimum version is plain dotted numbers (`1`, `1.4`, `1.4.0`),
19
+ -- store links are https (or the stores' own schemes), a minimum version
20
+ -- comes with its platform's store link, and notes are an array of
21
+ -- `{ "version": "1.4.0", "title"?: "...", "body": "..." }`.
22
+ --
23
+ -- One row per app (`app_id`, default 'default'). For example:
24
+ --
25
+ -- insert into public.app_release_policy (app_id, min_version_ios, store_url_ios)
26
+ -- values ('default', '1.4.0', 'https://apps.apple.com/app/id0000000000')
27
+ -- on conflict (app_id) do update set min_version_ios = excluded.min_version_ios,
28
+ -- store_url_ios = excluded.store_url_ios, updated_at = now();
29
+ --
30
+ -- Maintenance: set `maintenance = true` (and a `maintenance_message`). The
31
+ -- client honours it only from a live read, never from its cache, so a phone
32
+ -- offline when maintenance ends is never stuck behind it.
33
+ --
34
+ -- Verify: the pgTAP test beside this file (`tests/0002_genesis_app_release_policy.test.sql`).
35
+
36
+ create table if not exists public.app_release_policy (
37
+ app_id text primary key default 'default'
38
+ constraint app_release_policy_app_id_format check (app_id ~ '^[a-z0-9][a-z0-9._-]{0,63}$'),
39
+ min_version_ios text
40
+ constraint app_release_policy_min_version_ios_format check (min_version_ios ~ '^[0-9]{1,9}(\.[0-9]{1,9}){0,3}$'),
41
+ min_version_android text
42
+ constraint app_release_policy_min_version_android_format check (min_version_android ~ '^[0-9]{1,9}(\.[0-9]{1,9}){0,3}$'),
43
+ store_url_ios text
44
+ constraint app_release_policy_store_url_ios_format check (store_url_ios ~ '^(https|itms-apps)://'),
45
+ store_url_android text
46
+ constraint app_release_policy_store_url_android_format check (store_url_android ~ '^(https|market)://'),
47
+ maintenance boolean not null default false,
48
+ maintenance_message text
49
+ constraint app_release_policy_maintenance_message_length check (char_length(maintenance_message) <= 500),
50
+ -- An array of objects, each with a string version and body (and a string title, if any).
51
+ -- `strict` stops lax mode unwrapping nested arrays; `.type()` rejects arrays where strings belong.
52
+ notes jsonb not null default '[]'::jsonb
53
+ constraint app_release_policy_notes_shape check (
54
+ jsonb_typeof(notes) = 'array'
55
+ and not jsonb_path_exists(notes, 'strict $[*] ? (@.type() != "object")')
56
+ and not jsonb_path_exists(notes, '$[*] ? (!(@.version.type() == "string") || !(@.body.type() == "string") || (exists(@.title) && !(@.title.type() == "string")))')
57
+ ),
58
+ updated_at timestamptz not null default now(),
59
+ -- A minimum version needs a store link: the force-upgrade screen's only way out is the store.
60
+ -- (The client also forces nothing without one.)
61
+ constraint app_release_policy_ios_store_link check (min_version_ios is null or store_url_ios is not null),
62
+ constraint app_release_policy_android_store_link check (min_version_android is null or store_url_android is not null)
63
+ );
64
+
65
+ comment on table public.app_release_policy is
66
+ 'Minimum store versions, maintenance switch and release notes for native apps. Readable by everyone, written only by the service role. Genesis updates block.';
67
+
68
+ alter table public.app_release_policy enable row level security;
69
+
70
+ -- Reading: everyone, every row. No insert, update or delete policy exists, so
71
+ -- under RLS anon and authenticated can't write even if a later grant lets them try.
72
+ drop policy if exists app_release_policy_read on public.app_release_policy;
73
+ create policy app_release_policy_read on public.app_release_policy
74
+ for select to anon, authenticated
75
+ using (true);
76
+
77
+ -- Privileges by name. Supabase's default privileges give anon and
78
+ -- authenticated every privilege on a new public table; take them back and
79
+ -- grant only select. The service role bypasses RLS and keeps write access.
80
+ revoke all on table public.app_release_policy from public, anon, authenticated;
81
+ grant select on table public.app_release_policy to anon, authenticated;
82
+ grant select, insert, update, delete on table public.app_release_policy to service_role;