@marktiderman/genesis-supabase 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +523 -1
  2. package/dist/blocks.d.ts +19 -0
  3. package/dist/blocks.js +3 -0
  4. package/dist/blocks.js.map +1 -0
  5. package/dist/chunk-FAA2FTF4.js +110 -0
  6. package/dist/chunk-FAA2FTF4.js.map +1 -0
  7. package/dist/chunk-T2HGFC4C.js +37 -0
  8. package/dist/chunk-T2HGFC4C.js.map +1 -0
  9. package/dist/index-BSGz3RGk.d.ts +317 -0
  10. package/dist/index.d.ts +4 -78
  11. package/dist/index.js +63 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/native.d.ts +233 -5
  14. package/dist/native.js +196 -1
  15. package/dist/native.js.map +1 -1
  16. package/functions/genesis-apple-revoke/apple.ts +237 -0
  17. package/functions/genesis-apple-revoke/handler.ts +227 -0
  18. package/functions/genesis-apple-revoke/index.ts +55 -0
  19. package/functions/genesis-push-send/expo.ts +117 -0
  20. package/functions/genesis-push-send/handler.ts +462 -0
  21. package/functions/genesis-push-send/index.ts +54 -0
  22. package/functions/genesis-support-cleanup/handler.ts +80 -0
  23. package/functions/genesis-support-cleanup/index.ts +39 -0
  24. package/functions/genesis-support-intake/contract.ts +193 -0
  25. package/functions/genesis-support-intake/handler.ts +170 -0
  26. package/functions/genesis-support-intake/index.ts +47 -0
  27. package/functions/genesis-support-notify/handler.ts +167 -0
  28. package/functions/genesis-support-notify/index.ts +15 -0
  29. package/functions/tests/genesis-apple-revoke_test.ts +81 -0
  30. package/functions/tests/genesis-push-send_test.ts +53 -0
  31. package/migrations/0001_genesis_account_deletion.sql +133 -0
  32. package/migrations/0002_genesis_app_release_policy.sql +82 -0
  33. package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
  34. package/migrations/0004_genesis_tenant_features.sql +333 -0
  35. package/migrations/0005_genesis_push.sql +524 -0
  36. package/migrations/0006_genesis_support.sql +846 -0
  37. package/migrations/tests/0001_genesis_account_deletion.test.sql +138 -0
  38. package/migrations/tests/0002_genesis_app_release_policy.test.sql +148 -0
  39. package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
  40. package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
  41. package/migrations/tests/0005_genesis_push.test.sql +407 -0
  42. package/migrations/tests/0006_genesis_support.test.sql +552 -0
  43. package/package.json +92 -4
@@ -0,0 +1,55 @@
1
+ // genesis-apple-revoke: keeps Sign in with Apple refresh tokens server-side and
2
+ // revokes them when the account is deleted (App Store Review Guideline 5.1.1(v)).
3
+ // A template from @marktiderman/genesis-supabase: copy this folder to your app's
4
+ // `supabase/functions/`, beside migration 0003 (see the package README).
5
+ //
6
+ // Secrets (`supabase secrets set …`): APPLE_TEAM_ID, APPLE_KEY_ID,
7
+ // APPLE_PRIVATE_KEY (the .p8 file's text) and APPLE_CLIENT_IDS (your bundle ids,
8
+ // comma-separated). SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set by
9
+ // Supabase. Deploy with `--no-verify-jwt` (or `verify_jwt = false` in
10
+ // config.toml): the store action checks the caller itself, and the revoke
11
+ // action must run after the caller's login is gone.
12
+ import { createClient } from "npm:@supabase/supabase-js@2.118.0";
13
+ import { handle, type CallerUser, type DueRevocation, type TokenStore } from "./handler.ts";
14
+
15
+ const url = Deno.env.get("SUPABASE_URL") ?? "";
16
+ const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
17
+ const admin = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
18
+
19
+ async function rpc<T>(fn: string, args: Record<string, unknown>): Promise<T> {
20
+ const { data, error } = await admin.rpc(fn, args);
21
+ if (error) throw new Error(`${fn}: ${error.code ?? ""} ${error.message}`);
22
+ return data as T;
23
+ }
24
+
25
+ const store: TokenStore = {
26
+ save: (userId, clientId, refreshToken) =>
27
+ rpc<"stored" | "queued">("genesis_apple_token_save", { p_user_id: userId, p_client_id: clientId, p_refresh_token: refreshToken }),
28
+ claim: (limit) => rpc<DueRevocation[]>("genesis_apple_revocations_claim", { p_limit: limit }),
29
+ done: (id) => rpc<void>("genesis_apple_revocation_done", { p_id: id }),
30
+ failed: (id, error) => rpc<"retry" | "gave_up">("genesis_apple_revocation_failed", { p_id: id, p_error: error }),
31
+ };
32
+
33
+ async function getUser(accessToken: string): Promise<CallerUser | null> {
34
+ const { data, error } = await admin.auth.getUser(accessToken);
35
+ if (error || !data.user) return null;
36
+ const appleUserIds = (data.user.identities ?? [])
37
+ .filter((i) => i.provider === "apple")
38
+ .map((i) => (typeof i.identity_data?.sub === "string" ? i.identity_data.sub : i.id))
39
+ .filter((sub): sub is string => typeof sub === "string" && sub.length > 0);
40
+ return { id: data.user.id, appleUserIds };
41
+ }
42
+
43
+ const runtime = (globalThis as { EdgeRuntime?: { waitUntil(work: Promise<unknown>): void } }).EdgeRuntime;
44
+
45
+ Deno.serve((req) =>
46
+ handle(req, {
47
+ env: (name) => Deno.env.get(name),
48
+ fetch,
49
+ getUser,
50
+ store,
51
+ log: (message) => console.error(message),
52
+ // Supabase's edge runtime keeps the worker alive for this after the response.
53
+ background: runtime?.waitUntil ? (work) => runtime.waitUntil(work) : undefined,
54
+ }),
55
+ );
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Expo's push service, as the genesis-push-send function uses it:
3
+ * https://docs.expo.dev/push-notifications/sending-notifications/
4
+ *
5
+ * POST /--/api/v2/push/send up to 100 messages; one ticket each, in order
6
+ * POST /--/api/v2/push/getReceipts up to 1000 ticket ids; a receipt for each one ready
7
+ *
8
+ * Nothing here logs: a caller that logs uses `describeExpoError`, which
9
+ * names a status and Expo's error code, never a token or a message body.
10
+ */
11
+
12
+ export const EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
13
+ export const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
14
+ /** Expo takes up to 100 messages per send request. */
15
+ export const EXPO_SEND_BATCH = 100;
16
+ /** And up to 1000 ticket ids per receipts request. */
17
+ export const EXPO_RECEIPTS_BATCH = 1000;
18
+ const CALL_TIMEOUT_MS = 15_000;
19
+
20
+ /** One message for Expo. `data` reaches the app (`notification.request.content.data`), not the screen. */
21
+ export interface ExpoMessage {
22
+ to: string;
23
+ title?: string;
24
+ subtitle?: string;
25
+ body?: string;
26
+ data?: Record<string, unknown>;
27
+ sound?: "default" | null;
28
+ badge?: number;
29
+ /** Android: the notification channel (the app creates one per category). */
30
+ channelId?: string;
31
+ /** Seconds Apple and Google keep trying an offline phone. */
32
+ ttl?: number;
33
+ priority?: "default" | "normal" | "high";
34
+ }
35
+
36
+ /** Expo's answer for one message. */
37
+ export type ExpoTicket =
38
+ | { status: "ok"; id: string }
39
+ | { status: "error"; message?: string; details?: { error?: string } };
40
+
41
+ /** Expo's later word on one ticket. */
42
+ export type ExpoReceipt = { status: "ok" } | { status: "error"; message?: string; details?: { error?: string } };
43
+
44
+ /** Expo answered a whole request with an error status. */
45
+ export class ExpoHttpError extends Error {
46
+ constructor(readonly status: number) {
47
+ super(`Expo push answered HTTP ${status}`);
48
+ this.name = "ExpoHttpError";
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Expo refused the request itself (a 4xx other than 429), as it does for a
54
+ * batch that mixes projects. A timeout, a 429 or a 5xx may have been accepted
55
+ * or may recur, so those are never resent one by one.
56
+ */
57
+ export function refusedOutright(error: unknown): boolean {
58
+ return error instanceof ExpoHttpError && error.status >= 400 && error.status < 500 && error.status !== 429;
59
+ }
60
+
61
+ /** An Expo error code (`DeviceNotRegistered`), safe to store and log; anything else becomes `Unknown`. */
62
+ export function errorCode(value: { details?: { error?: string } } | undefined): string {
63
+ const code = value?.details?.error;
64
+ return typeof code === "string" && /^[A-Za-z_]{1,64}$/.test(code) ? code : "Unknown";
65
+ }
66
+
67
+ function headers(accessToken: string | undefined): Record<string, string> {
68
+ return {
69
+ accept: "application/json",
70
+ "content-type": "application/json",
71
+ ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
72
+ };
73
+ }
74
+
75
+ async function post(fetchFn: typeof fetch, url: string, body: unknown, accessToken: string | undefined): Promise<unknown> {
76
+ const response = await fetchFn(url, {
77
+ method: "POST",
78
+ headers: headers(accessToken),
79
+ body: JSON.stringify(body),
80
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
81
+ });
82
+ // The body of an error is never read: it can echo the request back.
83
+ if (!response.ok) {
84
+ await response.body?.cancel().catch(() => {});
85
+ throw new ExpoHttpError(response.status);
86
+ }
87
+ return response.json();
88
+ }
89
+
90
+ /** Sends one batch (at most 100) and returns a ticket per message, in order. */
91
+ export async function sendBatch(fetchFn: typeof fetch, messages: ExpoMessage[], accessToken?: string): Promise<ExpoTicket[]> {
92
+ if (messages.length > EXPO_SEND_BATCH) throw new Error(`sendBatch: at most ${EXPO_SEND_BATCH} messages`);
93
+ const json = (await post(fetchFn, EXPO_SEND_URL, messages, accessToken)) as { data?: unknown };
94
+ const tickets = Array.isArray(json?.data) ? (json.data as ExpoTicket[]) : [];
95
+ // A short or malformed answer: the messages without a ticket count as failed, never as sent.
96
+ return messages.map((_, i) => {
97
+ const ticket = tickets[i];
98
+ if (ticket && ticket.status === "ok" && typeof ticket.id === "string" && ticket.id.length > 0 && ticket.id.length <= 128) return ticket;
99
+ if (ticket && ticket.status === "error") return ticket;
100
+ return { status: "error", details: { error: "NoTicket" } };
101
+ });
102
+ }
103
+
104
+ /** Receipts for up to 1000 ticket ids; ids Expo has no receipt for yet are left out. */
105
+ export async function getReceipts(fetchFn: typeof fetch, ids: string[], accessToken?: string): Promise<Record<string, ExpoReceipt>> {
106
+ if (ids.length > EXPO_RECEIPTS_BATCH) throw new Error(`getReceipts: at most ${EXPO_RECEIPTS_BATCH} ids`);
107
+ const json = (await post(fetchFn, EXPO_RECEIPTS_URL, { ids }, accessToken)) as { data?: unknown };
108
+ const data = json?.data;
109
+ return data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, ExpoReceipt>) : {};
110
+ }
111
+
112
+ /** A failed call, for a log line: the status or the error's name, never the request. */
113
+ export function describeExpoError(error: unknown): string {
114
+ if (error instanceof ExpoHttpError) return `HTTP ${error.status}`;
115
+ if (error instanceof Error) return error.name === "TimeoutError" || error.name === "AbortError" ? "timeout" : error.name;
116
+ return "unknown error";
117
+ }
@@ -0,0 +1,462 @@
1
+ /**
2
+ * The genesis-push-send Edge Function's request handling, with its
3
+ * dependencies passed in (index.ts wires the real ones), so it is tested
4
+ * without Deno, a database or Expo.
5
+ *
6
+ * Every request carries `Authorization: Bearer <GENESIS_PUSH_SECRET>`: the
7
+ * function is for your server, a database trigger (pg_net) or a schedule,
8
+ * never for the app, which must not be able to push to anyone.
9
+ *
10
+ * POST { "action": "send", "messages": [PushRequest, …] } (at most 100)
11
+ * For each message, asks migration 0005's genesis_push_targets for the
12
+ * person's devices (none when they turned the category off or are in
13
+ * quiet hours), sends through Expo in batches of 100, records a receipt
14
+ * row per ticket (category and status, never content) and forgets the
15
+ * devices Expo calls DeviceNotRegistered. A person who hides previews
16
+ * gets neutral text instead of the title and body.
17
+ * Answers `{ ok, sent, failed, forgotten, results: [{ deliver, reason, quietUntil, devices, sent, failed }] }`,
18
+ * one result per message in order, so a caller can retry after `quietUntil`.
19
+ *
20
+ * POST { "action": "receipts" }
21
+ * Polls Expo for the receipts of tickets that are due (about 15 minutes
22
+ * after sending), records delivered or failed, and forgets dead devices.
23
+ * Run it on a schedule (every 15 minutes).
24
+ *
25
+ * Logs and responses never carry a token, a title, a body or `data`: only
26
+ * counts, statuses and Expo's error codes.
27
+ */
28
+ import {
29
+ EXPO_RECEIPTS_BATCH,
30
+ EXPO_SEND_BATCH,
31
+ describeExpoError,
32
+ errorCode,
33
+ getReceipts,
34
+ refusedOutright,
35
+ sendBatch,
36
+ type ExpoMessage,
37
+ type ExpoTicket,
38
+ } from "./expo.ts";
39
+
40
+ /** One push, as your server asks for it. */
41
+ export interface PushRequest {
42
+ userId: string;
43
+ /** A category id the app defines (`messages`); also the Android channel unless `channelId` is given. */
44
+ category: string;
45
+ title: string;
46
+ body?: string;
47
+ subtitle?: string;
48
+ /** The app path to open on tap (`/messages/42`); the app normalizes it again, so an unknown path opens `/`. */
49
+ url?: string;
50
+ /** Extra values for the app: ids, never content (it rides in the payload, and is dropped when previews are hidden). */
51
+ data?: Record<string, string | number | boolean | null>;
52
+ badge?: number;
53
+ /** `"default"` (the default) or `null` for silent. */
54
+ sound?: "default" | null;
55
+ /** Is this category on for someone who never chose? Default true. */
56
+ defaultOn?: boolean;
57
+ /** Goes through quiet hours (a security alert, say). Default false. */
58
+ urgent?: boolean;
59
+ channelId?: string;
60
+ ttlSeconds?: number;
61
+ }
62
+
63
+ /** A device to push to, as genesis_push_targets returns it. */
64
+ export interface PushTarget {
65
+ device_id: string;
66
+ token: string;
67
+ platform: string;
68
+ hide_previews: boolean;
69
+ }
70
+
71
+ export interface PushStatus {
72
+ deliver: boolean;
73
+ reason: string;
74
+ quiet_until: string | null;
75
+ }
76
+
77
+ export interface TicketRow {
78
+ user_id: string;
79
+ device_id: string | null;
80
+ category: string;
81
+ ticket_id?: string;
82
+ status: "sent" | "failed";
83
+ error?: string;
84
+ }
85
+
86
+ export interface ReceiptResult {
87
+ id: number;
88
+ status: "delivered" | "failed";
89
+ error?: string;
90
+ }
91
+
92
+ /** The SQL functions from migration 0005, called with the service role. */
93
+ export interface PushStore {
94
+ targets(userId: string, category: string, defaultOn: boolean, urgent: boolean): Promise<PushTarget[]>;
95
+ status(userId: string, category: string, defaultOn: boolean, urgent: boolean): Promise<PushStatus>;
96
+ recordTickets(rows: TicketRow[]): Promise<void>;
97
+ forgetDevices(deviceIds: string[]): Promise<void>;
98
+ claimReceipts(limit: number): Promise<{ id: number; ticket_id: string }[]>;
99
+ settleReceipts(results: ReceiptResult[]): Promise<void>;
100
+ }
101
+
102
+ export interface HandlerDeps {
103
+ env: (name: string) => string | undefined;
104
+ fetch: typeof fetch;
105
+ store: PushStore;
106
+ log: (message: string) => void;
107
+ /** Milliseconds now. Default `Date.now`. */
108
+ now?: () => number;
109
+ }
110
+
111
+ /** Stop starting new sends after this long, so the run ends (and records what it sent) inside an Edge Function's wall clock. */
112
+ export const SEND_BUDGET_MS = 100_000;
113
+ /** How many single sends run at once when a refused batch is resent one by one. */
114
+ const SINGLE_CONCURRENCY = 10;
115
+ /** How many people's targets are looked up at once. */
116
+ const LOOKUP_CONCURRENCY = 10;
117
+
118
+ export interface MessageResult {
119
+ deliver: boolean;
120
+ reason: string;
121
+ quietUntil: string | null;
122
+ devices: number;
123
+ sent: number;
124
+ failed: number;
125
+ }
126
+
127
+ export const MAX_MESSAGES = 100;
128
+ /** The request body, in characters. */
129
+ const MAX_BODY_CHARS = 256 * 1024;
130
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
131
+ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
132
+ const DEFAULT_HIDDEN_TITLE = "New notification";
133
+
134
+ const CORS = {
135
+ "access-control-allow-origin": "*",
136
+ "access-control-allow-headers": "authorization, x-client-info, apikey, content-type",
137
+ "access-control-allow-methods": "POST, OPTIONS",
138
+ };
139
+
140
+ function json(status: number, body: Record<string, unknown>): Response {
141
+ return new Response(JSON.stringify(body), { status, headers: { ...CORS, "content-type": "application/json", "cache-control": "no-store" } });
142
+ }
143
+
144
+ const describe = (error: unknown) => (error instanceof Error ? error.name : "unknown error");
145
+
146
+ async function readBody(req: Request): Promise<Record<string, unknown> | null> {
147
+ if (Number(req.headers.get("content-length") ?? 0) > MAX_BODY_CHARS) return null;
148
+ const text = await req.text().catch(() => null);
149
+ if (text === null || text.length > MAX_BODY_CHARS) return null;
150
+ try {
151
+ const body: unknown = JSON.parse(text);
152
+ return body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ async function digest(value: string): Promise<Uint8Array> {
159
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)));
160
+ }
161
+
162
+ /** Compares the bearer with the secret in constant time (over their digests, so the length doesn't leak either). */
163
+ export async function authorized(req: Request, secret: string): Promise<boolean> {
164
+ const match = /^Bearer\s+(\S+)$/i.exec(req.headers.get("authorization") ?? "");
165
+ if (!match) return false;
166
+ const [a, b] = await Promise.all([digest(match[1]!), digest(secret)]);
167
+ let diff = 0;
168
+ for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
169
+ return diff === 0;
170
+ }
171
+
172
+ const isText = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
173
+ const optionalText = (v: unknown, max: number) => v === undefined || v === null || isText(v, max);
174
+
175
+ const bytes = (value: unknown) => new TextEncoder().encode(typeof value === "string" ? value : JSON.stringify(value ?? null)).length;
176
+ /** Title, subtitle, body, url and data together, in UTF-8 bytes: leaves room in Expo's 4096 for the rest. */
177
+ export const MAX_CONTENT_BYTES = 3500;
178
+
179
+ /** A valid request, or null. The content limit is in bytes, so a message stays under Expo's 4 KB payload. */
180
+ export function readPushRequest(value: unknown): PushRequest | null {
181
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
182
+ const m = value as Record<string, unknown>;
183
+ if (typeof m.userId !== "string" || !UUID.test(m.userId)) return null;
184
+ if (typeof m.category !== "string" || !ID.test(m.category)) return null;
185
+ if (!isText(m.title, 200) || !optionalText(m.body, 1000) || !optionalText(m.subtitle, 200)) return null;
186
+ if (m.url !== undefined && m.url !== null && !(isText(m.url, 512) && (m.url as string).startsWith("/") && !(m.url as string).startsWith("//"))) return null;
187
+ if (m.data !== undefined && m.data !== null) {
188
+ if (typeof m.data !== "object" || Array.isArray(m.data)) return null;
189
+ const entries = Object.entries(m.data as Record<string, unknown>);
190
+ if (entries.length > 20 || JSON.stringify(m.data).length > 1024) return null;
191
+ if (!entries.every(([, v]) => v === null || ["string", "number", "boolean"].includes(typeof v))) return null;
192
+ }
193
+ if (m.badge !== undefined && m.badge !== null && !(Number.isInteger(m.badge) && (m.badge as number) >= 0 && (m.badge as number) <= 99_999)) return null;
194
+ if (m.sound !== undefined && m.sound !== null && m.sound !== "default") return null;
195
+ if (m.defaultOn !== undefined && typeof m.defaultOn !== "boolean") return null;
196
+ if (m.urgent !== undefined && typeof m.urgent !== "boolean") return null;
197
+ if (m.channelId !== undefined && m.channelId !== null && !(typeof m.channelId === "string" && ID.test(m.channelId))) return null;
198
+ if (m.ttlSeconds !== undefined && m.ttlSeconds !== null && !(Number.isInteger(m.ttlSeconds) && (m.ttlSeconds as number) >= 0 && (m.ttlSeconds as number) <= 2_419_200)) return null;
199
+ if (bytes(m.title) + bytes(m.subtitle ?? "") + bytes(m.body ?? "") + bytes(m.url ?? "") + (m.data ? bytes(m.data) : 0) > MAX_CONTENT_BYTES) return null;
200
+ return m as unknown as PushRequest;
201
+ }
202
+
203
+ /**
204
+ * What goes to Expo for one device. With previews hidden, the title becomes
205
+ * neutral text and the body, subtitle and extra data are dropped: the words
206
+ * never leave the server, so no lock screen, watch or notification log can
207
+ * show them. The tap still opens `url`, and `category` still travels: keep
208
+ * both opaque (ids, not words; no search text in a query string).
209
+ */
210
+ export function toExpoMessage(request: PushRequest, target: PushTarget, hidden: { title: string; body?: string }): ExpoMessage {
211
+ const hide = target.hide_previews;
212
+ const data: Record<string, unknown> = hide ? {} : { ...(request.data ?? {}) };
213
+ data.category = request.category;
214
+ if (request.url) data.url = request.url;
215
+ return {
216
+ to: target.token,
217
+ ...(hide ? { title: hidden.title, ...(hidden.body ? { body: hidden.body } : {}) } : { title: request.title, ...(request.body ? { body: request.body } : {}), ...(request.subtitle ? { subtitle: request.subtitle } : {}) }),
218
+ data,
219
+ sound: request.sound === null ? null : "default",
220
+ ...(request.badge !== undefined && request.badge !== null ? { badge: request.badge } : {}),
221
+ channelId: request.channelId ?? request.category,
222
+ ...(request.ttlSeconds !== undefined && request.ttlSeconds !== null ? { ttl: request.ttlSeconds } : {}),
223
+ priority: "high",
224
+ };
225
+ }
226
+
227
+ export async function handle(req: Request, deps: HandlerDeps): Promise<Response> {
228
+ try {
229
+ return await route(req, deps);
230
+ } catch (error) {
231
+ deps.log(`genesis-push-send: failed: ${describe(error)}`);
232
+ return json(500, { error: "internal" });
233
+ }
234
+ }
235
+
236
+ async function route(req: Request, deps: HandlerDeps): Promise<Response> {
237
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
238
+ if (req.method !== "POST") return json(405, { error: "method_not_allowed" });
239
+ const secret = deps.env("GENESIS_PUSH_SECRET") ?? "";
240
+ if (secret.length < 32) {
241
+ // Loud, by name only: without it nothing can call the function.
242
+ deps.log("genesis-push-send: not configured; set GENESIS_PUSH_SECRET (at least 32 characters)");
243
+ return json(500, { error: "not_configured" });
244
+ }
245
+ if (!(await authorized(req, secret))) return json(401, { error: "unauthorized" });
246
+ const body = await readBody(req);
247
+ if (!body) return json(400, { error: "bad_request" });
248
+
249
+ if (body.action === "send") {
250
+ const raw = body.messages;
251
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_MESSAGES) return json(400, { error: "bad_request" });
252
+ const requests = raw.map(readPushRequest);
253
+ const bad = requests.findIndex((r) => r === null);
254
+ if (bad >= 0) return json(400, { error: "bad_message", index: bad });
255
+ const report = await send(requests as PushRequest[], deps);
256
+ return json(200, { ok: true, ...report });
257
+ }
258
+ if (body.action === "receipts") {
259
+ const report = await pollReceipts(deps);
260
+ return json(200, { ok: true, ...report });
261
+ }
262
+ return json(400, { error: "bad_request" });
263
+ }
264
+
265
+ interface Outgoing {
266
+ message: ExpoMessage;
267
+ index: number;
268
+ userId: string;
269
+ deviceId: string;
270
+ category: string;
271
+ }
272
+
273
+ /** Sends the requests; returns the totals and one result per request. */
274
+ export async function send(requests: PushRequest[], deps: HandlerDeps) {
275
+ // The budget counts from the start: the lookups below take time too.
276
+ const now = deps.now ?? Date.now;
277
+ const started = now();
278
+ const overBudget = () => now() - started > SEND_BUDGET_MS;
279
+ const hidden = { title: deps.env("PUSH_HIDDEN_TITLE") || DEFAULT_HIDDEN_TITLE, body: deps.env("PUSH_HIDDEN_BODY") || undefined };
280
+ const accessToken = deps.env("EXPO_ACCESS_TOKEN") || undefined;
281
+ const results: MessageResult[] = requests.map(() => ({ deliver: false, reason: "ok", quietUntil: null, devices: 0, sent: 0, failed: 0 }));
282
+ const outgoing: Outgoing[] = [];
283
+
284
+ const lookup = async (request: PushRequest, index: number): Promise<Outgoing[]> => {
285
+ const defaultOn = request.defaultOn ?? true;
286
+ const urgent = request.urgent ?? false;
287
+ const result = results[index]!;
288
+ if (overBudget()) {
289
+ result.reason = "not_sent";
290
+ return [];
291
+ }
292
+ try {
293
+ const targets = await deps.store.targets(request.userId, request.category, defaultOn, urgent);
294
+ result.devices = targets.length;
295
+ if (targets.length === 0) {
296
+ const status = await deps.store.status(request.userId, request.category, defaultOn, urgent);
297
+ result.reason = status.deliver ? "no_devices" : status.reason;
298
+ result.quietUntil = status.quiet_until;
299
+ return [];
300
+ }
301
+ result.deliver = true;
302
+ return targets.map((target) => ({ message: toExpoMessage(request, target, hidden), index, userId: request.userId, deviceId: target.device_id, category: request.category }));
303
+ } catch (error) {
304
+ deps.log(`genesis-push-send: couldn't read targets: ${describe(error)}`);
305
+ result.reason = "error";
306
+ return [];
307
+ }
308
+ };
309
+ // A few lookups at a time, kept in request order.
310
+ for (let i = 0; i < requests.length; i += LOOKUP_CONCURRENCY) {
311
+ const found = await Promise.all(requests.slice(i, i + LOOKUP_CONCURRENCY).map((request, j) => lookup(request, i + j)));
312
+ for (const items of found) outgoing.push(...items);
313
+ }
314
+
315
+ let tickets: TicketRow[] = [];
316
+ let dead: string[] = [];
317
+ let forgotten = 0;
318
+ // Recorded after every batch, so a run cut short still has receipts for what it sent.
319
+ const record = async () => {
320
+ const [t, d] = [tickets, dead];
321
+ tickets = [];
322
+ dead = [];
323
+ if (t.length) await deps.store.recordTickets(t).catch((error) => deps.log(`genesis-push-send: couldn't record tickets: ${describe(error)}`));
324
+ if (d.length) {
325
+ forgotten += d.length;
326
+ await deps.store.forgetDevices(d).catch((error) => deps.log(`genesis-push-send: couldn't forget dead devices: ${describe(error)}`));
327
+ }
328
+ };
329
+ const settle = (batch: Outgoing[], answers: ExpoTicket[]) => {
330
+ batch.forEach((item, i) => {
331
+ const ticket = answers[i]!;
332
+ const result = results[item.index]!;
333
+ if (ticket.status === "ok") {
334
+ result.sent++;
335
+ tickets.push({ user_id: item.userId, device_id: item.deviceId, category: item.category, ticket_id: ticket.id, status: "sent" });
336
+ } else {
337
+ result.failed++;
338
+ const code = errorCode(ticket);
339
+ tickets.push({ user_id: item.userId, device_id: item.deviceId, category: item.category, status: "failed", error: code });
340
+ if (code === "DeviceNotRegistered") dead.push(item.deviceId);
341
+ }
342
+ });
343
+ };
344
+ const fail = (batch: Outgoing[], code: string) => settle(batch, batch.map(() => ({ status: "error", details: { error: code } }) as ExpoTicket));
345
+
346
+ let unsent = 0;
347
+ for (let i = 0; i < outgoing.length; i += EXPO_SEND_BATCH) {
348
+ const batch = outgoing.slice(i, i + EXPO_SEND_BATCH);
349
+ if (overBudget()) {
350
+ // Out of time: these were never sent, so they're failed (and may be sent again by the caller).
351
+ unsent += batch.length;
352
+ fail(batch, "NotSent");
353
+ continue;
354
+ }
355
+ try {
356
+ settle(batch, await sendBatch(deps.fetch, batch.map((o) => o.message), accessToken));
357
+ } catch (error) {
358
+ if (!refusedOutright(error) || batch.length === 1) {
359
+ // Down, slow or rate limited: this batch fails and the next still goes. Not resent: it may have been accepted.
360
+ deps.log(`genesis-push-send: a batch of ${batch.length} failed: ${describeExpoError(error)}`);
361
+ fail(batch, "SendFailed");
362
+ continue;
363
+ }
364
+ // Expo refuses a whole request for one bad message (a token from another project, say):
365
+ // send that batch one by one, so one device can't silence everyone else's.
366
+ deps.log(`genesis-push-send: a batch of ${batch.length} was refused (${describeExpoError(error)}); sending it one by one`);
367
+ for (let j = 0; j < batch.length; j += SINGLE_CONCURRENCY) {
368
+ const group = batch.slice(j, j + SINGLE_CONCURRENCY);
369
+ if (overBudget()) {
370
+ unsent += group.length;
371
+ fail(group, "NotSent");
372
+ continue;
373
+ }
374
+ await Promise.all(
375
+ group.map(async (item) => {
376
+ try {
377
+ settle([item], await sendBatch(deps.fetch, [item.message], accessToken));
378
+ } catch (single) {
379
+ fail([item], refusedOutright(single) ? "Refused" : "SendFailed");
380
+ }
381
+ }),
382
+ );
383
+ }
384
+ }
385
+ await record();
386
+ }
387
+ await record();
388
+
389
+ const sent = results.reduce((n, r) => n + r.sent, 0);
390
+ const failed = results.reduce((n, r) => n + r.failed, 0);
391
+ deps.log(`genesis-push-send: ${requests.length} messages, ${outgoing.length} devices, ${sent} sent, ${failed} failed, ${forgotten} forgotten${unsent ? `, ${unsent} not sent (out of time)` : ""}`);
392
+ return { sent, failed, forgotten, unsent, results };
393
+ }
394
+
395
+ /** Polls Expo for due receipts; returns the counts, with Expo's error codes. */
396
+ export async function pollReceipts(deps: HandlerDeps) {
397
+ const now = deps.now ?? Date.now;
398
+ const started = now();
399
+ const counts = { checked: 0, delivered: 0, failed: 0, pending: 0, errors: {} as Record<string, number> };
400
+ // A full claim means more may be due: keep going, within the time budget.
401
+ for (let round = 0; round < 20; round++) {
402
+ const claimed = await pollReceiptsOnce(deps, counts);
403
+ if (claimed < EXPO_RECEIPTS_BATCH || now() - started > SEND_BUDGET_MS) break;
404
+ }
405
+ const codes = Object.entries(counts.errors).map(([code, n]) => `${code} ${n}`).join(", ");
406
+ if (counts.checked || counts.pending) deps.log(`genesis-push-send: receipts ${counts.delivered} delivered, ${counts.failed} failed${codes ? ` (${codes})` : ""}, ${counts.pending} pending`);
407
+ // Credentials are a setup problem, not a phone's: say so loudly.
408
+ if (counts.errors.InvalidCredentials) deps.log("genesis-push-send: Expo reports InvalidCredentials: upload the APNs key / FCM credentials to your Expo project (eas credentials)");
409
+ return counts;
410
+ }
411
+
412
+ type ReceiptCounts = { checked: number; delivered: number; failed: number; pending: number; errors: Record<string, number> };
413
+
414
+ /** One claim's worth; returns how many were claimed. */
415
+ async function pollReceiptsOnce(deps: HandlerDeps, counts: ReceiptCounts): Promise<number> {
416
+ const accessToken = deps.env("EXPO_ACCESS_TOKEN") || undefined;
417
+ let due: { id: number; ticket_id: string }[];
418
+ try {
419
+ due = await deps.store.claimReceipts(EXPO_RECEIPTS_BATCH);
420
+ } catch (error) {
421
+ deps.log(`genesis-push-send: couldn't claim receipts: ${describe(error)}`);
422
+ return 0;
423
+ }
424
+ if (due.length === 0) return 0;
425
+ let receipts: Awaited<ReturnType<typeof getReceipts>>;
426
+ try {
427
+ receipts = await getReceipts(deps.fetch, due.map((d) => d.ticket_id), accessToken);
428
+ } catch (error) {
429
+ // The lease runs out and they come round again.
430
+ deps.log(`genesis-push-send: couldn't fetch receipts: ${describeExpoError(error)}`);
431
+ counts.pending += due.length;
432
+ return 0;
433
+ }
434
+ const results: ReceiptResult[] = [];
435
+ for (const row of due) {
436
+ const receipt = Object.prototype.hasOwnProperty.call(receipts, row.ticket_id) ? receipts[row.ticket_id] : undefined;
437
+ if (!receipt || (receipt.status !== "ok" && receipt.status !== "error")) {
438
+ counts.pending++;
439
+ continue;
440
+ }
441
+ counts.checked++;
442
+ if (receipt.status === "ok") {
443
+ counts.delivered++;
444
+ results.push({ id: row.id, status: "delivered" });
445
+ } else {
446
+ counts.failed++;
447
+ const code = errorCode(receipt);
448
+ counts.errors[code] = (counts.errors[code] ?? 0) + 1;
449
+ results.push({ id: row.id, status: "failed", error: code });
450
+ }
451
+ }
452
+ if (results.length) {
453
+ try {
454
+ await deps.store.settleReceipts(results);
455
+ } catch (error) {
456
+ // Their leases run out and they come round again.
457
+ deps.log(`genesis-push-send: couldn't record receipts: ${describe(error)}`);
458
+ return 0;
459
+ }
460
+ }
461
+ return due.length;
462
+ }