@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.
Files changed (39) hide show
  1. package/README.md +377 -23
  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 -103
  11. package/dist/index.js +60 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/native.d.ts +90 -6
  14. package/dist/native.js +70 -2
  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/0003_genesis_apple_token_revocation.sql +205 -0
  32. package/migrations/0004_genesis_tenant_features.sql +333 -0
  33. package/migrations/0005_genesis_push.sql +524 -0
  34. package/migrations/0006_genesis_support.sql +846 -0
  35. package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
  36. package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
  37. package/migrations/tests/0005_genesis_push.test.sql +407 -0
  38. package/migrations/tests/0006_genesis_support.test.sql +552 -0
  39. 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;