@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.
- package/README.md +523 -1
- package/dist/blocks.d.ts +19 -0
- package/dist/blocks.js +3 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-FAA2FTF4.js +110 -0
- package/dist/chunk-FAA2FTF4.js.map +1 -0
- package/dist/chunk-T2HGFC4C.js +37 -0
- package/dist/chunk-T2HGFC4C.js.map +1 -0
- package/dist/index-BSGz3RGk.d.ts +317 -0
- package/dist/index.d.ts +4 -78
- package/dist/index.js +63 -2
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +233 -5
- package/dist/native.js +196 -1
- package/dist/native.js.map +1 -1
- package/functions/genesis-apple-revoke/apple.ts +237 -0
- package/functions/genesis-apple-revoke/handler.ts +227 -0
- package/functions/genesis-apple-revoke/index.ts +55 -0
- package/functions/genesis-push-send/expo.ts +117 -0
- package/functions/genesis-push-send/handler.ts +462 -0
- package/functions/genesis-push-send/index.ts +54 -0
- package/functions/genesis-support-cleanup/handler.ts +80 -0
- package/functions/genesis-support-cleanup/index.ts +39 -0
- package/functions/genesis-support-intake/contract.ts +193 -0
- package/functions/genesis-support-intake/handler.ts +170 -0
- package/functions/genesis-support-intake/index.ts +47 -0
- package/functions/genesis-support-notify/handler.ts +167 -0
- package/functions/genesis-support-notify/index.ts +15 -0
- package/functions/tests/genesis-apple-revoke_test.ts +81 -0
- package/functions/tests/genesis-push-send_test.ts +53 -0
- package/migrations/0001_genesis_account_deletion.sql +133 -0
- package/migrations/0002_genesis_app_release_policy.sql +82 -0
- package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
- package/migrations/0004_genesis_tenant_features.sql +333 -0
- package/migrations/0005_genesis_push.sql +524 -0
- package/migrations/0006_genesis_support.sql +846 -0
- package/migrations/tests/0001_genesis_account_deletion.test.sql +138 -0
- package/migrations/tests/0002_genesis_app_release_policy.test.sql +148 -0
- package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
- package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
- package/migrations/tests/0005_genesis_push.test.sql +407 -0
- package/migrations/tests/0006_genesis_support.test.sql +552 -0
- 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;
|