@marktiderman/genesis-supabase 0.2.0 → 0.3.1
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 +377 -23
- 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 -103
- package/dist/index.js +60 -3
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +90 -6
- package/dist/native.js +70 -2
- 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/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/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 +91 -4
|
@@ -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,205 @@
|
|
|
1
|
+
-- Genesis Sign in with Apple token revocation: where Apple's refresh tokens live,
|
|
2
|
+
-- and the queue that revokes them when an account is deleted.
|
|
3
|
+
--
|
|
4
|
+
-- App Store Review Guideline 5.1.1(v): an app that offers Sign in with Apple
|
|
5
|
+
-- revokes the person's Apple tokens (https://appleid.apple.com/auth/revoke)
|
|
6
|
+
-- when they delete their account. Supabase doesn't: a native id-token sign-in
|
|
7
|
+
-- never hands Supabase an Apple refresh token, and Auth doesn't keep the
|
|
8
|
+
-- provider tokens it does see. So the app sends the sign-in's authorization
|
|
9
|
+
-- code to the `genesis-apple-revoke` Edge Function (shipped beside this file
|
|
10
|
+
-- in @marktiderman/genesis-supabase), which exchanges it for a refresh token
|
|
11
|
+
-- and keeps it here; deleting the login queues it, and the function revokes it.
|
|
12
|
+
--
|
|
13
|
+
-- Use: copy this file into your app's `supabase/migrations/` under a new
|
|
14
|
+
-- timestamped name, and the function folder into `supabase/functions/`.
|
|
15
|
+
-- Never edit it once applied; change it with a new migration. It stands
|
|
16
|
+
-- alone (it doesn't need 0001), and covers every way a login is deleted:
|
|
17
|
+
-- delete_my_account, the dashboard, and the admin API.
|
|
18
|
+
--
|
|
19
|
+
-- Who can see a token: nobody but this file's functions. The tables are in
|
|
20
|
+
-- `private` (not exposed through the Data API) with row level security on, no
|
|
21
|
+
-- policies and every table privilege revoked from anon and authenticated. The
|
|
22
|
+
-- Edge Function reaches them only through the four `public` functions below,
|
|
23
|
+
-- executable by service_role alone (granted and revoked by name).
|
|
24
|
+
--
|
|
25
|
+
-- Not encrypted in the column: an Apple refresh token is useless without a
|
|
26
|
+
-- client secret signed with your .p8 key, which lives in the function's
|
|
27
|
+
-- secrets, not the database; encrypting it with a key from those same secrets
|
|
28
|
+
-- would add a secret without closing a gap.
|
|
29
|
+
--
|
|
30
|
+
-- Failure policy: deletion never waits for Apple. The delete trigger moves
|
|
31
|
+
-- the person's tokens into `private.genesis_apple_revocations` in the same
|
|
32
|
+
-- transaction as the delete (so a refused delete queues nothing, and a
|
|
33
|
+
-- committed one can't lose its token), and the function revokes them after:
|
|
34
|
+
-- straight away when the app asks, then on a schedule, with backoff (five
|
|
35
|
+
-- minutes, doubling, at most a day apart). A revoked token's row is deleted.
|
|
36
|
+
-- After 30 failed attempts (about four weeks) the token is dropped and the
|
|
37
|
+
-- row kept, with `last_error` starting `gave_up`, as the record that it
|
|
38
|
+
-- wasn't revoked. A deleted login that signed in with Apple but has no stored
|
|
39
|
+
-- token (the store step failed, or it predates this) leaves a row with no
|
|
40
|
+
-- token and `last_error` starting `no_token`. Any row still here is one to
|
|
41
|
+
-- look at.
|
|
42
|
+
--
|
|
43
|
+
-- Verify: the pgTAP test beside this file (`tests/0003_genesis_apple_token_revocation.test.sql`).
|
|
44
|
+
|
|
45
|
+
create schema if not exists private;
|
|
46
|
+
|
|
47
|
+
-- One refresh token per login and client id (bundle id or Services ID): each
|
|
48
|
+
-- sign-in replaces the last.
|
|
49
|
+
create table private.genesis_apple_tokens (
|
|
50
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
51
|
+
client_id text not null check (client_id ~ '^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$'),
|
|
52
|
+
refresh_token text not null check (length(refresh_token) between 1 and 4096),
|
|
53
|
+
updated_at timestamptz not null default now(),
|
|
54
|
+
primary key (user_id, client_id)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
-- Tokens of deleted logins, until Apple confirms the revocation. `user_id` has
|
|
58
|
+
-- no foreign key: the login is gone. A row without a token is a record (no
|
|
59
|
+
-- token was ever stored, or the retries gave up), never handed out.
|
|
60
|
+
create table private.genesis_apple_revocations (
|
|
61
|
+
id bigint generated always as identity primary key,
|
|
62
|
+
user_id uuid not null,
|
|
63
|
+
client_id text,
|
|
64
|
+
refresh_token text,
|
|
65
|
+
queued_at timestamptz not null default now(),
|
|
66
|
+
attempts integer not null default 0,
|
|
67
|
+
next_attempt_at timestamptz not null default now(),
|
|
68
|
+
last_error text,
|
|
69
|
+
check (refresh_token is null or client_id is not null)
|
|
70
|
+
);
|
|
71
|
+
create index genesis_apple_revocations_due on private.genesis_apple_revocations (next_attempt_at) where refresh_token is not null;
|
|
72
|
+
|
|
73
|
+
alter table private.genesis_apple_tokens enable row level security;
|
|
74
|
+
alter table private.genesis_apple_revocations enable row level security;
|
|
75
|
+
revoke all on table private.genesis_apple_tokens from public, anon, authenticated;
|
|
76
|
+
revoke all on table private.genesis_apple_revocations from public, anon, authenticated;
|
|
77
|
+
|
|
78
|
+
comment on table private.genesis_apple_tokens is
|
|
79
|
+
'Sign in with Apple refresh tokens, server-only; written by genesis-apple-revoke. Genesis account block.';
|
|
80
|
+
comment on table private.genesis_apple_revocations is
|
|
81
|
+
'Apple refresh tokens of deleted logins, until /auth/revoke succeeds; rows without a token are records to look at. Genesis account block.';
|
|
82
|
+
|
|
83
|
+
-- Deleting a login (any way) queues its Apple tokens before the cascade
|
|
84
|
+
-- removes them, or records that an Apple login had none to queue.
|
|
85
|
+
create function private.genesis_apple_queue_revocation()
|
|
86
|
+
returns trigger
|
|
87
|
+
language plpgsql
|
|
88
|
+
security definer
|
|
89
|
+
set search_path = ''
|
|
90
|
+
as $$
|
|
91
|
+
begin
|
|
92
|
+
insert into private.genesis_apple_revocations (user_id, client_id, refresh_token)
|
|
93
|
+
select t.user_id, t.client_id, t.refresh_token
|
|
94
|
+
from private.genesis_apple_tokens t
|
|
95
|
+
where t.user_id = old.id;
|
|
96
|
+
if not found and exists (select 1 from auth.identities i where i.user_id = old.id and i.provider = 'apple') then
|
|
97
|
+
insert into private.genesis_apple_revocations (user_id, last_error)
|
|
98
|
+
values (old.id, 'no_token: signed in with Apple, but no refresh token was stored, so none was revoked');
|
|
99
|
+
end if;
|
|
100
|
+
return old;
|
|
101
|
+
end;
|
|
102
|
+
$$;
|
|
103
|
+
|
|
104
|
+
create trigger genesis_apple_queue_revocation
|
|
105
|
+
before delete on auth.users
|
|
106
|
+
for each row execute function private.genesis_apple_queue_revocation();
|
|
107
|
+
|
|
108
|
+
-- Keeps the refresh token from a sign-in. If the login is already gone (it was
|
|
109
|
+
-- deleted while the code was being exchanged), the token goes straight to the
|
|
110
|
+
-- queue instead, so it is still revoked. Returns 'stored' or 'queued'.
|
|
111
|
+
create function public.genesis_apple_token_save(p_user_id uuid, p_client_id text, p_refresh_token text)
|
|
112
|
+
returns text
|
|
113
|
+
language plpgsql
|
|
114
|
+
security definer
|
|
115
|
+
set search_path = ''
|
|
116
|
+
as $$
|
|
117
|
+
begin
|
|
118
|
+
insert into private.genesis_apple_tokens as t (user_id, client_id, refresh_token)
|
|
119
|
+
values (p_user_id, p_client_id, p_refresh_token)
|
|
120
|
+
on conflict (user_id, client_id) do update
|
|
121
|
+
set refresh_token = excluded.refresh_token, updated_at = now();
|
|
122
|
+
return 'stored';
|
|
123
|
+
exception
|
|
124
|
+
when foreign_key_violation then
|
|
125
|
+
insert into private.genesis_apple_revocations (user_id, client_id, refresh_token)
|
|
126
|
+
values (p_user_id, p_client_id, p_refresh_token);
|
|
127
|
+
return 'queued';
|
|
128
|
+
when check_violation then
|
|
129
|
+
-- Raised afresh so the error (and the server log) never carries the failing row, token and all.
|
|
130
|
+
raise exception 'genesis_apple_token_save: malformed client id or token' using errcode = '23514';
|
|
131
|
+
end;
|
|
132
|
+
$$;
|
|
133
|
+
|
|
134
|
+
-- Hands out up to p_limit due revocations and pushes each one's next attempt
|
|
135
|
+
-- back (5 minutes, doubling per attempt, at most a day): the lease outlasts a
|
|
136
|
+
-- batch, so a function that dies mid-batch retries later and two runs never
|
|
137
|
+
-- take the same row.
|
|
138
|
+
create function public.genesis_apple_revocations_claim(p_limit integer default 20)
|
|
139
|
+
returns table (id bigint, client_id text, refresh_token text, attempts integer)
|
|
140
|
+
language sql
|
|
141
|
+
security definer
|
|
142
|
+
set search_path = ''
|
|
143
|
+
as $$
|
|
144
|
+
update private.genesis_apple_revocations r
|
|
145
|
+
set attempts = r.attempts + 1,
|
|
146
|
+
next_attempt_at = now() + least(interval '5 minutes' * power(2, least(r.attempts, 20)), interval '1 day')
|
|
147
|
+
where r.id in (
|
|
148
|
+
select q.id from private.genesis_apple_revocations q
|
|
149
|
+
where q.refresh_token is not null and q.next_attempt_at <= now()
|
|
150
|
+
order by q.next_attempt_at
|
|
151
|
+
limit greatest(1, least(coalesce(p_limit, 20), 100))
|
|
152
|
+
for update skip locked
|
|
153
|
+
)
|
|
154
|
+
returning r.id, r.client_id, r.refresh_token, r.attempts;
|
|
155
|
+
$$;
|
|
156
|
+
|
|
157
|
+
-- Apple confirmed (or the token was already dead): forget the token.
|
|
158
|
+
create function public.genesis_apple_revocation_done(p_id bigint)
|
|
159
|
+
returns void
|
|
160
|
+
language sql
|
|
161
|
+
security definer
|
|
162
|
+
set search_path = ''
|
|
163
|
+
as $$
|
|
164
|
+
delete from private.genesis_apple_revocations where id = p_id and refresh_token is not null;
|
|
165
|
+
$$;
|
|
166
|
+
|
|
167
|
+
-- Apple refused or didn't answer: keep the row for its next attempt, with why.
|
|
168
|
+
-- After the 30th attempt, drop the token and keep the row as the record.
|
|
169
|
+
-- Returns 'retry' or 'gave_up'.
|
|
170
|
+
create function public.genesis_apple_revocation_failed(p_id bigint, p_error text)
|
|
171
|
+
returns text
|
|
172
|
+
language plpgsql
|
|
173
|
+
security definer
|
|
174
|
+
set search_path = ''
|
|
175
|
+
as $$
|
|
176
|
+
declare
|
|
177
|
+
tries integer;
|
|
178
|
+
begin
|
|
179
|
+
update private.genesis_apple_revocations
|
|
180
|
+
set last_error = left(coalesce(p_error, ''), 200)
|
|
181
|
+
where id = p_id and refresh_token is not null
|
|
182
|
+
returning attempts into tries;
|
|
183
|
+
if tries >= 30 then
|
|
184
|
+
update private.genesis_apple_revocations
|
|
185
|
+
set refresh_token = null, last_error = left('gave_up after ' || tries || ' attempts: ' || coalesce(p_error, ''), 200)
|
|
186
|
+
where id = p_id;
|
|
187
|
+
return 'gave_up';
|
|
188
|
+
end if;
|
|
189
|
+
return 'retry';
|
|
190
|
+
end;
|
|
191
|
+
$$;
|
|
192
|
+
|
|
193
|
+
-- Privileges by name. Supabase's default privileges grant execute on a new
|
|
194
|
+
-- public function to anon and authenticated, so each is revoked here; a later
|
|
195
|
+
-- blanket `grant … on all functions` would hand them back, so such a grant
|
|
196
|
+
-- must exclude them.
|
|
197
|
+
revoke execute on function private.genesis_apple_queue_revocation() from public, anon, authenticated;
|
|
198
|
+
revoke execute on function public.genesis_apple_token_save(uuid, text, text) from public, anon, authenticated;
|
|
199
|
+
revoke execute on function public.genesis_apple_revocations_claim(integer) from public, anon, authenticated;
|
|
200
|
+
revoke execute on function public.genesis_apple_revocation_done(bigint) from public, anon, authenticated;
|
|
201
|
+
revoke execute on function public.genesis_apple_revocation_failed(bigint, text) from public, anon, authenticated;
|
|
202
|
+
grant execute on function public.genesis_apple_token_save(uuid, text, text) to service_role;
|
|
203
|
+
grant execute on function public.genesis_apple_revocations_claim(integer) to service_role;
|
|
204
|
+
grant execute on function public.genesis_apple_revocation_done(bigint) to service_role;
|
|
205
|
+
grant execute on function public.genesis_apple_revocation_failed(bigint, text) to service_role;
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
-- Genesis tenant features: public.tenant_features and public.set_tenant_feature()
|
|
2
|
+
--
|
|
3
|
+
-- Per-tenant on/off switches for shipped features: a tenant's admins turn a
|
|
4
|
+
-- feature off (or back on) for their tenant, with a reason, and every change
|
|
5
|
+
-- is kept (who, when, why) in public.tenant_feature_changes. A feature with no
|
|
6
|
+
-- row is on. The client half is `tenantFeatures(client, tenantId)` and
|
|
7
|
+
-- `setTenantFeature(client, …)` in @marktiderman/genesis-supabase.
|
|
8
|
+
--
|
|
9
|
+
-- What a switch is for: exposure (hiding a tab, a nav entry, a screen), never
|
|
10
|
+
-- authorization. Paid access is an entitlement, and who may see a row is RLS;
|
|
11
|
+
-- a gate in SQL can read `private.tenant_feature_on(tenant, feature)`.
|
|
12
|
+
--
|
|
13
|
+
-- "Tenant" is whatever your app groups people by (a team, an organization, a
|
|
14
|
+
-- workspace) and is keyed by a uuid. This file names no table of yours:
|
|
15
|
+
--
|
|
16
|
+
-- The app hook: `private.genesis_tenant_admin(tenant uuid, person uuid) returns boolean`
|
|
17
|
+
-- decides who may change a tenant's features. Without it nobody can (set_tenant_feature
|
|
18
|
+
-- raises 42501 with a hint), so a fresh install fails closed. Optionally,
|
|
19
|
+
-- `private.genesis_tenant_member(tenant uuid, person uuid) returns boolean` decides who
|
|
20
|
+
-- may read which features are off (admins always may). Both run with this
|
|
21
|
+
-- file's (the owner's) rights, so they read your membership table whatever its RLS:
|
|
22
|
+
--
|
|
23
|
+
-- create function private.genesis_tenant_admin(tenant uuid, person uuid) returns boolean
|
|
24
|
+
-- language sql stable set search_path = '' as $$
|
|
25
|
+
-- select exists (select 1 from public.memberships m
|
|
26
|
+
-- where m.team_id = tenant and m.user_id = person and m.role in ('owner', 'admin'))
|
|
27
|
+
-- $$;
|
|
28
|
+
-- revoke execute on function private.genesis_tenant_admin(uuid, uuid) from public, anon, authenticated;
|
|
29
|
+
--
|
|
30
|
+
-- Your tenant table: add the foreign key yourself in a later migration, so a
|
|
31
|
+
-- deleted tenant takes its switches and their history with it (a delete that
|
|
32
|
+
-- cascades from elsewhere isn't recorded, so it never refuses the tenant's deletion):
|
|
33
|
+
--
|
|
34
|
+
-- alter table public.tenant_features add foreign key (tenant_id) references public.teams (id) on delete cascade;
|
|
35
|
+
-- alter table public.tenant_feature_changes add foreign key (tenant_id) references public.teams (id) on delete cascade;
|
|
36
|
+
--
|
|
37
|
+
-- Feature ids are the registry's FeatureIds (`features.ts`), checked here for
|
|
38
|
+
-- their shape only. If you mirror the registry into a table, add a foreign key.
|
|
39
|
+
--
|
|
40
|
+
-- Access: everything goes through the functions below (security definer,
|
|
41
|
+
-- each asking the hook). The tables have row level security on, no policies
|
|
42
|
+
-- and no grants to anon or authenticated. The service role (a server job, a
|
|
43
|
+
-- platform admin's tool) may write the switches directly, and its writes are
|
|
44
|
+
-- recorded too, with no person.
|
|
45
|
+
--
|
|
46
|
+
-- Use: copy this file into your app's `supabase/migrations/` under a new
|
|
47
|
+
-- timestamped name. Never edit it once applied; change it with a new migration.
|
|
48
|
+
-- It stands alone (it doesn't need 0001 to 0003).
|
|
49
|
+
--
|
|
50
|
+
-- Verify: the pgTAP test beside this file (`tests/0004_genesis_tenant_features.test.sql`).
|
|
51
|
+
|
|
52
|
+
create schema if not exists private;
|
|
53
|
+
|
|
54
|
+
create table public.tenant_features (
|
|
55
|
+
tenant_id uuid not null,
|
|
56
|
+
feature_id text not null
|
|
57
|
+
constraint tenant_features_feature_id_format check (feature_id ~ '^[a-z][a-z0-9]*([-_.][a-z0-9]+)*$' and char_length(feature_id) <= 100),
|
|
58
|
+
enabled boolean not null,
|
|
59
|
+
reason text not null
|
|
60
|
+
constraint tenant_features_reason_length check (char_length(btrim(reason)) between 1 and 500),
|
|
61
|
+
updated_by uuid references auth.users (id) on delete set null,
|
|
62
|
+
updated_at timestamptz not null default now(),
|
|
63
|
+
primary key (tenant_id, feature_id)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
comment on table public.tenant_features is
|
|
67
|
+
'Per-tenant feature switches (a missing row is on), with the latest reason. Written through set_tenant_feature or by the service role. Genesis tenant features.';
|
|
68
|
+
|
|
69
|
+
-- Every change, kept: who (the signed-in person, or none for the service role
|
|
70
|
+
-- or a migration), with which role, when, and why. A row is never edited.
|
|
71
|
+
create table public.tenant_feature_changes (
|
|
72
|
+
id bigint generated always as identity primary key,
|
|
73
|
+
tenant_id uuid not null,
|
|
74
|
+
feature_id text not null,
|
|
75
|
+
-- null when the switch row was deleted (back to the default: on).
|
|
76
|
+
enabled boolean,
|
|
77
|
+
reason text,
|
|
78
|
+
changed_by uuid references auth.users (id) on delete set null,
|
|
79
|
+
changed_role text not null,
|
|
80
|
+
changed_at timestamptz not null default now()
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
create index tenant_feature_changes_tenant on public.tenant_feature_changes (tenant_id, changed_at desc);
|
|
84
|
+
|
|
85
|
+
comment on table public.tenant_feature_changes is
|
|
86
|
+
'History of public.tenant_features: who changed which switch, when and why. Written only by its trigger. Genesis tenant features.';
|
|
87
|
+
|
|
88
|
+
-- The hooks, asked with the signed-in person. Missing hook, nobody signed in,
|
|
89
|
+
-- or a null answer: false.
|
|
90
|
+
create or replace function private.genesis_is_tenant_admin(tenant uuid)
|
|
91
|
+
returns boolean
|
|
92
|
+
language plpgsql
|
|
93
|
+
stable
|
|
94
|
+
security definer
|
|
95
|
+
set search_path = ''
|
|
96
|
+
as $$
|
|
97
|
+
declare
|
|
98
|
+
me uuid := auth.uid();
|
|
99
|
+
begin
|
|
100
|
+
if me is null or tenant is null or to_regprocedure('private.genesis_tenant_admin(uuid,uuid)') is null then
|
|
101
|
+
return false;
|
|
102
|
+
end if;
|
|
103
|
+
return coalesce(private.genesis_tenant_admin(tenant, me), false);
|
|
104
|
+
end;
|
|
105
|
+
$$;
|
|
106
|
+
|
|
107
|
+
create or replace function private.genesis_is_tenant_member(tenant uuid)
|
|
108
|
+
returns boolean
|
|
109
|
+
language plpgsql
|
|
110
|
+
stable
|
|
111
|
+
security definer
|
|
112
|
+
set search_path = ''
|
|
113
|
+
as $$
|
|
114
|
+
declare
|
|
115
|
+
me uuid := auth.uid();
|
|
116
|
+
begin
|
|
117
|
+
if me is null or tenant is null then
|
|
118
|
+
return false;
|
|
119
|
+
end if;
|
|
120
|
+
-- Two statements: an expression naming a missing function fails even behind `and`.
|
|
121
|
+
if to_regprocedure('private.genesis_tenant_member(uuid,uuid)') is not null then
|
|
122
|
+
if coalesce(private.genesis_tenant_member(tenant, me), false) then
|
|
123
|
+
return true;
|
|
124
|
+
end if;
|
|
125
|
+
end if;
|
|
126
|
+
return private.genesis_is_tenant_admin(tenant);
|
|
127
|
+
end;
|
|
128
|
+
$$;
|
|
129
|
+
|
|
130
|
+
-- For SQL gates (a policy, a function): is this feature on for this tenant?
|
|
131
|
+
-- Reads every row whatever the caller's RLS. A feature with no row is on.
|
|
132
|
+
create or replace function private.tenant_feature_on(tenant uuid, feature text)
|
|
133
|
+
returns boolean
|
|
134
|
+
language sql
|
|
135
|
+
stable
|
|
136
|
+
security definer
|
|
137
|
+
set search_path = ''
|
|
138
|
+
as $$
|
|
139
|
+
select not exists (
|
|
140
|
+
select 1 from public.tenant_features f
|
|
141
|
+
where f.tenant_id = tenant and f.feature_id = feature and not f.enabled
|
|
142
|
+
)
|
|
143
|
+
$$;
|
|
144
|
+
|
|
145
|
+
create or replace function public.set_tenant_feature(tenant uuid, feature text, enabled boolean, reason text)
|
|
146
|
+
returns void
|
|
147
|
+
language plpgsql
|
|
148
|
+
security definer
|
|
149
|
+
set search_path = ''
|
|
150
|
+
as $$
|
|
151
|
+
declare
|
|
152
|
+
me uuid := auth.uid();
|
|
153
|
+
why text := btrim(coalesce(reason, ''));
|
|
154
|
+
begin
|
|
155
|
+
if me is null then
|
|
156
|
+
raise exception 'set_tenant_feature: not signed in' using errcode = '42501';
|
|
157
|
+
end if;
|
|
158
|
+
if tenant is null or feature is null or enabled is null then
|
|
159
|
+
raise exception 'set_tenant_feature: tenant, feature and enabled are required' using errcode = '22004';
|
|
160
|
+
end if;
|
|
161
|
+
if to_regprocedure('private.genesis_tenant_admin(uuid,uuid)') is null then
|
|
162
|
+
raise exception 'set_tenant_feature: nobody may change tenant features yet'
|
|
163
|
+
using errcode = '42501', hint = 'Create private.genesis_tenant_admin(uuid, uuid) (see migration 0004_genesis_tenant_features).';
|
|
164
|
+
end if;
|
|
165
|
+
if not private.genesis_is_tenant_admin(tenant) then
|
|
166
|
+
raise exception 'set_tenant_feature: only the tenant''s admins can change its features' using errcode = '42501';
|
|
167
|
+
end if;
|
|
168
|
+
if why = '' or char_length(why) > 500 then
|
|
169
|
+
raise exception 'set_tenant_feature: say why, in at most 500 characters' using errcode = '22023';
|
|
170
|
+
end if;
|
|
171
|
+
insert into public.tenant_features as f (tenant_id, feature_id, enabled, reason, updated_by, updated_at)
|
|
172
|
+
values (tenant, feature, enabled, why, me, now())
|
|
173
|
+
on conflict (tenant_id, feature_id) do update
|
|
174
|
+
set enabled = excluded.enabled, reason = excluded.reason, updated_by = excluded.updated_by, updated_at = excluded.updated_at
|
|
175
|
+
-- Saving the same switch and reason again changes nothing, not even who and when.
|
|
176
|
+
where (f.enabled, f.reason) is distinct from (excluded.enabled, excluded.reason);
|
|
177
|
+
end;
|
|
178
|
+
$$;
|
|
179
|
+
|
|
180
|
+
comment on function public.set_tenant_feature(uuid, text, boolean, text) is
|
|
181
|
+
'Turns a feature on or off for a tenant, with a reason. Only the tenant''s admins (private.genesis_tenant_admin). Genesis tenant features.';
|
|
182
|
+
|
|
183
|
+
-- The ids turned off for a tenant, for its members (and admins) to hide them.
|
|
184
|
+
create or replace function public.tenant_features_off(tenant uuid)
|
|
185
|
+
returns setof text
|
|
186
|
+
language plpgsql
|
|
187
|
+
stable
|
|
188
|
+
security definer
|
|
189
|
+
set search_path = ''
|
|
190
|
+
as $$
|
|
191
|
+
begin
|
|
192
|
+
if not private.genesis_is_tenant_member(tenant) then
|
|
193
|
+
raise exception 'tenant_features_off: only the tenant''s members can read its features' using errcode = '42501';
|
|
194
|
+
end if;
|
|
195
|
+
return query
|
|
196
|
+
select f.feature_id from public.tenant_features f
|
|
197
|
+
where f.tenant_id = tenant and not f.enabled
|
|
198
|
+
order by f.feature_id;
|
|
199
|
+
end;
|
|
200
|
+
$$;
|
|
201
|
+
|
|
202
|
+
comment on function public.tenant_features_off(uuid) is
|
|
203
|
+
'The feature ids a tenant has turned off. For its members. Genesis tenant features.';
|
|
204
|
+
|
|
205
|
+
-- Every switch a tenant has set, with the latest reason, who and when: for its admins' Settings screen.
|
|
206
|
+
create or replace function public.tenant_feature_settings(tenant uuid)
|
|
207
|
+
returns table (feature_id text, enabled boolean, reason text, updated_by uuid, updated_at timestamptz)
|
|
208
|
+
language plpgsql
|
|
209
|
+
stable
|
|
210
|
+
security definer
|
|
211
|
+
set search_path = ''
|
|
212
|
+
as $$
|
|
213
|
+
begin
|
|
214
|
+
if not private.genesis_is_tenant_admin(tenant) then
|
|
215
|
+
raise exception 'tenant_feature_settings: only the tenant''s admins can read its switches' using errcode = '42501';
|
|
216
|
+
end if;
|
|
217
|
+
return query
|
|
218
|
+
select f.feature_id, f.enabled, f.reason, f.updated_by, f.updated_at from public.tenant_features f
|
|
219
|
+
where f.tenant_id = tenant
|
|
220
|
+
order by f.feature_id;
|
|
221
|
+
end;
|
|
222
|
+
$$;
|
|
223
|
+
|
|
224
|
+
comment on function public.tenant_feature_settings(uuid) is
|
|
225
|
+
'A tenant''s feature switches with their reasons. For its admins. Genesis tenant features.';
|
|
226
|
+
|
|
227
|
+
-- A tenant's history of changes, newest first, at most `max_rows` (1 to 1000).
|
|
228
|
+
create or replace function public.tenant_feature_history(tenant uuid, max_rows integer default 100)
|
|
229
|
+
returns table (feature_id text, enabled boolean, reason text, changed_by uuid, changed_role text, changed_at timestamptz)
|
|
230
|
+
language plpgsql
|
|
231
|
+
stable
|
|
232
|
+
security definer
|
|
233
|
+
set search_path = ''
|
|
234
|
+
as $$
|
|
235
|
+
begin
|
|
236
|
+
if not private.genesis_is_tenant_admin(tenant) then
|
|
237
|
+
raise exception 'tenant_feature_history: only the tenant''s admins can read its history' using errcode = '42501';
|
|
238
|
+
end if;
|
|
239
|
+
return query
|
|
240
|
+
select c.feature_id, c.enabled, c.reason, c.changed_by, c.changed_role, c.changed_at from public.tenant_feature_changes c
|
|
241
|
+
where c.tenant_id = tenant
|
|
242
|
+
order by c.changed_at desc, c.id desc
|
|
243
|
+
limit greatest(1, least(coalesce(max_rows, 100), 1000));
|
|
244
|
+
end;
|
|
245
|
+
$$;
|
|
246
|
+
|
|
247
|
+
comment on function public.tenant_feature_history(uuid, integer) is
|
|
248
|
+
'Who changed a tenant''s feature switches, when and why, newest first. For its admins. Genesis tenant features.';
|
|
249
|
+
|
|
250
|
+
create or replace function private.genesis_record_tenant_feature_change()
|
|
251
|
+
returns trigger
|
|
252
|
+
language plpgsql
|
|
253
|
+
security definer
|
|
254
|
+
set search_path = ''
|
|
255
|
+
as $$
|
|
256
|
+
declare
|
|
257
|
+
claims jsonb := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb);
|
|
258
|
+
begin
|
|
259
|
+
-- Only a change of the switch or its reason is history. This skips the
|
|
260
|
+
-- `on delete set null` of updated_by when a person is deleted, which would
|
|
261
|
+
-- otherwise record the deleted person as the actor and refuse their deletion.
|
|
262
|
+
if tg_op = 'UPDATE' and new.enabled is not distinct from old.enabled and new.reason is not distinct from old.reason
|
|
263
|
+
and new.tenant_id = old.tenant_id and new.feature_id = old.feature_id then
|
|
264
|
+
return null;
|
|
265
|
+
end if;
|
|
266
|
+
if tg_op = 'DELETE' then
|
|
267
|
+
begin
|
|
268
|
+
insert into public.tenant_feature_changes (tenant_id, feature_id, enabled, reason, changed_by, changed_role)
|
|
269
|
+
values (old.tenant_id, old.feature_id, null, null, (select u.id from auth.users u where u.id = auth.uid()),
|
|
270
|
+
coalesce(nullif(claims ->> 'role', ''), 'database'));
|
|
271
|
+
exception when foreign_key_violation then
|
|
272
|
+
-- The delete cascades from the tenant's own (your foreign key, or a person whose
|
|
273
|
+
-- tenant goes with them): the tenant is gone, so there is nothing to record, and
|
|
274
|
+
-- recording it would refuse the tenant's or the person's deletion.
|
|
275
|
+
null;
|
|
276
|
+
end;
|
|
277
|
+
return null;
|
|
278
|
+
end if;
|
|
279
|
+
insert into public.tenant_feature_changes (tenant_id, feature_id, enabled, reason, changed_by, changed_role)
|
|
280
|
+
values (
|
|
281
|
+
new.tenant_id,
|
|
282
|
+
new.feature_id,
|
|
283
|
+
new.enabled,
|
|
284
|
+
new.reason,
|
|
285
|
+
-- Only a login that still exists: one being deleted in this statement is no one.
|
|
286
|
+
(select u.id from auth.users u where u.id = auth.uid()),
|
|
287
|
+
coalesce(nullif(claims ->> 'role', ''), 'database')
|
|
288
|
+
);
|
|
289
|
+
return null;
|
|
290
|
+
end;
|
|
291
|
+
$$;
|
|
292
|
+
|
|
293
|
+
create trigger genesis_record_change
|
|
294
|
+
after insert or update or delete on public.tenant_features
|
|
295
|
+
for each row execute function private.genesis_record_tenant_feature_change();
|
|
296
|
+
|
|
297
|
+
-- Row level security on, with no policies: anon and authenticated reach the
|
|
298
|
+
-- tables only through the functions above, even if a later grant lets them try.
|
|
299
|
+
alter table public.tenant_features enable row level security;
|
|
300
|
+
alter table public.tenant_feature_changes enable row level security;
|
|
301
|
+
|
|
302
|
+
-- Privileges by name. Supabase's default privileges give anon and
|
|
303
|
+
-- authenticated everything on a new public table and function, and Postgres
|
|
304
|
+
-- gives every function to public: take them back, then grant what's needed.
|
|
305
|
+
revoke all on table public.tenant_features from public, anon, authenticated;
|
|
306
|
+
revoke all on table public.tenant_feature_changes from public, anon, authenticated;
|
|
307
|
+
-- No truncate (it would skip the history trigger), references or trigger for the service role.
|
|
308
|
+
revoke all on table public.tenant_features from service_role;
|
|
309
|
+
grant select, insert, update, delete on table public.tenant_features to service_role;
|
|
310
|
+
revoke all on sequence public.tenant_feature_changes_id_seq from public, anon, authenticated;
|
|
311
|
+
-- The history is the trigger's: the service role may read and purge it, never write it.
|
|
312
|
+
revoke all on table public.tenant_feature_changes from service_role;
|
|
313
|
+
grant select, delete on table public.tenant_feature_changes to service_role;
|
|
314
|
+
|
|
315
|
+
revoke execute on function public.set_tenant_feature(uuid, text, boolean, text) from public, anon;
|
|
316
|
+
grant execute on function public.set_tenant_feature(uuid, text, boolean, text) to authenticated;
|
|
317
|
+
revoke execute on function public.tenant_features_off(uuid) from public, anon;
|
|
318
|
+
grant execute on function public.tenant_features_off(uuid) to authenticated;
|
|
319
|
+
revoke execute on function public.tenant_feature_settings(uuid) from public, anon;
|
|
320
|
+
grant execute on function public.tenant_feature_settings(uuid) to authenticated;
|
|
321
|
+
revoke execute on function public.tenant_feature_history(uuid, integer) from public, anon;
|
|
322
|
+
grant execute on function public.tenant_feature_history(uuid, integer) to authenticated;
|
|
323
|
+
|
|
324
|
+
-- Only the functions above call these.
|
|
325
|
+
revoke execute on function private.genesis_is_tenant_admin(uuid) from public, anon, authenticated;
|
|
326
|
+
revoke execute on function private.genesis_is_tenant_member(uuid) from public, anon, authenticated;
|
|
327
|
+
revoke execute on function private.genesis_record_tenant_feature_change() from public, anon, authenticated;
|
|
328
|
+
-- For gates in your own SQL, called from security definer functions (owned
|
|
329
|
+
-- by the migration's role, so they need no grant). It answers for any tenant,
|
|
330
|
+
-- without the member check: grant it to authenticated (with usage on schema
|
|
331
|
+
-- private) only if you mean every signed-in person to be able to ask.
|
|
332
|
+
revoke execute on function private.tenant_feature_on(uuid, text) from public, anon, authenticated;
|
|
333
|
+
grant execute on function private.tenant_feature_on(uuid, text) to service_role;
|