@marktiderman/genesis-supabase 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +367 -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,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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// genesis-push-send: sends pushes through Expo's push service to the devices
|
|
2
|
+
// migration 0005 says may get them now (categories, quiet hours, hidden
|
|
3
|
+
// previews), records a receipt per ticket, polls the receipts and forgets dead
|
|
4
|
+
// devices. A template from @marktiderman/genesis-supabase: copy this folder to
|
|
5
|
+
// your app's `supabase/functions/`, beside migration 0005 (see the package README).
|
|
6
|
+
//
|
|
7
|
+
// Secrets (`supabase secrets set …`): GENESIS_PUSH_SECRET (at least 32
|
|
8
|
+
// characters; every caller sends it as a bearer token) and, when your Expo
|
|
9
|
+
// project requires it (recommended), EXPO_ACCESS_TOKEN. Optional:
|
|
10
|
+
// PUSH_HIDDEN_TITLE and PUSH_HIDDEN_BODY, the neutral text a person who hides
|
|
11
|
+
// previews sees. SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set by Supabase.
|
|
12
|
+
// Deploy with `--no-verify-jwt` (or `verify_jwt = false` in config.toml): the
|
|
13
|
+
// function checks GENESIS_PUSH_SECRET itself.
|
|
14
|
+
import { createClient } from "npm:@supabase/supabase-js@2.118.0";
|
|
15
|
+
import { handle, type PushStatus, type PushStore, type PushTarget } from "./handler.ts";
|
|
16
|
+
|
|
17
|
+
const url = Deno.env.get("SUPABASE_URL") ?? "";
|
|
18
|
+
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
|
19
|
+
const admin = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
|
|
20
|
+
|
|
21
|
+
async function rpc<T>(fn: string, args: Record<string, unknown>): Promise<T> {
|
|
22
|
+
const { data, error } = await admin.rpc(fn, args);
|
|
23
|
+
// The code only: a database message can quote the row it refused.
|
|
24
|
+
if (error) throw new Error(`${fn}: ${error.code ?? "error"}`);
|
|
25
|
+
return data as T;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const store: PushStore = {
|
|
29
|
+
targets: (userId, category, defaultOn, urgent) =>
|
|
30
|
+
rpc<PushTarget[]>("genesis_push_targets", { p_user_id: userId, p_category: category, p_default_on: defaultOn, p_bypass_quiet_hours: urgent }),
|
|
31
|
+
status: async (userId, category, defaultOn, urgent) => {
|
|
32
|
+
const rows = await rpc<PushStatus[]>("genesis_push_status", { p_user_id: userId, p_category: category, p_default_on: defaultOn, p_bypass_quiet_hours: urgent });
|
|
33
|
+
return rows[0] ?? { deliver: false, reason: "error", quiet_until: null };
|
|
34
|
+
},
|
|
35
|
+
recordTickets: async (rows) => {
|
|
36
|
+
await rpc<number>("genesis_push_tickets_record", { p_tickets: rows });
|
|
37
|
+
},
|
|
38
|
+
forgetDevices: async (deviceIds) => {
|
|
39
|
+
await rpc<number>("genesis_push_devices_forget", { p_device_ids: deviceIds });
|
|
40
|
+
},
|
|
41
|
+
claimReceipts: (limit) => rpc<{ id: number; ticket_id: string }[]>("genesis_push_receipts_claim", { p_limit: limit }),
|
|
42
|
+
settleReceipts: async (results) => {
|
|
43
|
+
await rpc<number>("genesis_push_receipts_settle", { p_results: results });
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
Deno.serve((req) =>
|
|
48
|
+
handle(req, {
|
|
49
|
+
env: (name) => Deno.env.get(name),
|
|
50
|
+
fetch,
|
|
51
|
+
store,
|
|
52
|
+
log: (message) => console.error(message),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The genesis-support-cleanup Edge Function's request handling, with its
|
|
3
|
+
* dependencies passed in, so it is tested without Deno or a database.
|
|
4
|
+
*
|
|
5
|
+
* POST {} (from a schedule: pg_cron + pg_net, or any cron)
|
|
6
|
+
* x-genesis-support-secret: <GENESIS_SUPPORT_CLEANUP_SECRET>
|
|
7
|
+
*
|
|
8
|
+
* Removes, through the Storage API, the screenshots whose attachment row is
|
|
9
|
+
* gone (queued in public.support_attachment_purges when a report or an account
|
|
10
|
+
* is deleted) and the uploads no attachment ever named (older than a day,
|
|
11
|
+
* `support_orphan_objects`). Supabase refuses direct deletes from
|
|
12
|
+
* storage.objects, so this is the only way they go. A bounded batch per call.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface PurgeRow {
|
|
16
|
+
id: number;
|
|
17
|
+
bucket: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the function does with the service role. */
|
|
22
|
+
export interface CleanupStore {
|
|
23
|
+
/** The oldest queued purges. */
|
|
24
|
+
queued(limit: number): Promise<PurgeRow[]>;
|
|
25
|
+
/** Objects in the bucket no attachment names, uploaded more than a day ago. */
|
|
26
|
+
orphans(limit: number): Promise<string[]>;
|
|
27
|
+
/** Storage API remove; returns the paths it removed. */
|
|
28
|
+
remove(bucket: string, paths: string[]): Promise<string[]>;
|
|
29
|
+
/** Forget purge rows whose object is gone. */
|
|
30
|
+
forget(ids: number[]): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CleanupDeps {
|
|
34
|
+
env: (name: string) => string | undefined;
|
|
35
|
+
store: CleanupStore;
|
|
36
|
+
log: (message: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const BATCH = 100;
|
|
40
|
+
const BUCKET = "genesis-support";
|
|
41
|
+
|
|
42
|
+
function json(status: number, body: Record<string, unknown>): Response {
|
|
43
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sameSecret(a: string, b: string): boolean {
|
|
47
|
+
const x = new TextEncoder().encode(a);
|
|
48
|
+
const y = new TextEncoder().encode(b);
|
|
49
|
+
let diff = x.length ^ y.length;
|
|
50
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
51
|
+
return diff === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function handle(req: Request, deps: CleanupDeps): Promise<Response> {
|
|
55
|
+
if (req.method !== "POST") return json(405, { error: "method_not_allowed" });
|
|
56
|
+
const secret = deps.env("GENESIS_SUPPORT_CLEANUP_SECRET") ?? "";
|
|
57
|
+
if (!secret) {
|
|
58
|
+
deps.log("[genesis-support-cleanup] GENESIS_SUPPORT_CLEANUP_SECRET is not set; refusing every call");
|
|
59
|
+
return json(500, { error: "not_configured" });
|
|
60
|
+
}
|
|
61
|
+
if (!sameSecret(req.headers.get("x-genesis-support-secret") ?? "", secret)) return json(401, { error: "unauthorized" });
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const queued = (await deps.store.queued(BATCH)).filter((row) => row.bucket === BUCKET);
|
|
65
|
+
let purged = 0;
|
|
66
|
+
if (queued.length) {
|
|
67
|
+
const paths = queued.map((row) => row.path);
|
|
68
|
+
await deps.store.remove(BUCKET, paths);
|
|
69
|
+
// Storage's remove answers with what it removed; an object already gone is gone too.
|
|
70
|
+
await deps.store.forget(queued.map((row) => row.id));
|
|
71
|
+
purged = queued.length;
|
|
72
|
+
}
|
|
73
|
+
const orphans = await deps.store.orphans(BATCH);
|
|
74
|
+
const removed = orphans.length ? await deps.store.remove(BUCKET, orphans) : [];
|
|
75
|
+
return json(200, { ok: true, purged, orphans: removed.length });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
deps.log(`[genesis-support-cleanup] ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
return json(500, { error: "cleanup_failed" });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// genesis-support-cleanup: removes support screenshots whose report or account was
|
|
2
|
+
// deleted, and uploads that were never attached, through the Storage API (Supabase
|
|
3
|
+
// refuses direct deletes from storage.objects). Call it on a schedule. See handler.ts.
|
|
4
|
+
// A template from @marktiderman/genesis-supabase: copy this folder to your app's
|
|
5
|
+
// `supabase/functions/`, beside migration 0006 (see the package README).
|
|
6
|
+
//
|
|
7
|
+
// Secrets: GENESIS_SUPPORT_CLEANUP_SECRET (the schedule sends it as
|
|
8
|
+
// `x-genesis-support-secret`). SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set by
|
|
9
|
+
// Supabase. Deploy with `verify_jwt = false`: the secret is the check.
|
|
10
|
+
import { createClient } from "npm:@supabase/supabase-js@2.118.0";
|
|
11
|
+
import { handle, type CleanupStore, type PurgeRow } from "./handler.ts";
|
|
12
|
+
|
|
13
|
+
const admin = createClient(Deno.env.get("SUPABASE_URL") ?? "", Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "", {
|
|
14
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const store: CleanupStore = {
|
|
18
|
+
async queued(limit) {
|
|
19
|
+
const { data, error } = await admin.from("support_attachment_purges").select("id, bucket, path").order("id").limit(limit);
|
|
20
|
+
if (error) throw new Error(`support_attachment_purges: ${error.code ?? ""} ${error.message}`);
|
|
21
|
+
return (data ?? []) as PurgeRow[];
|
|
22
|
+
},
|
|
23
|
+
async orphans(limit) {
|
|
24
|
+
const { data, error } = await admin.rpc("support_orphan_objects", { older_than: "1 day", max_rows: limit });
|
|
25
|
+
if (error) throw new Error(`support_orphan_objects: ${error.code ?? ""} ${error.message}`);
|
|
26
|
+
return ((data ?? []) as { path: string }[]).map((row) => row.path);
|
|
27
|
+
},
|
|
28
|
+
async remove(bucket, paths) {
|
|
29
|
+
const { data, error } = await admin.storage.from(bucket).remove(paths);
|
|
30
|
+
if (error) throw new Error(`storage remove: ${error.message}`);
|
|
31
|
+
return (data ?? []).map((object) => object.name);
|
|
32
|
+
},
|
|
33
|
+
async forget(ids) {
|
|
34
|
+
const { error } = await admin.from("support_attachment_purges").delete().in("id", ids);
|
|
35
|
+
if (error) throw new Error(`support_attachment_purges: ${error.code ?? ""} ${error.message}`);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
Deno.serve((req) => handle(req, { env: (name) => Deno.env.get(name), store, log: (message) => console.error(message) }));
|