@marktiderman/genesis-supabase 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +523 -1
- package/dist/blocks.d.ts +19 -0
- package/dist/blocks.js +3 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-FAA2FTF4.js +110 -0
- package/dist/chunk-FAA2FTF4.js.map +1 -0
- package/dist/chunk-T2HGFC4C.js +37 -0
- package/dist/chunk-T2HGFC4C.js.map +1 -0
- package/dist/index-BSGz3RGk.d.ts +317 -0
- package/dist/index.d.ts +4 -78
- package/dist/index.js +63 -2
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +233 -5
- package/dist/native.js +196 -1
- package/dist/native.js.map +1 -1
- package/functions/genesis-apple-revoke/apple.ts +237 -0
- package/functions/genesis-apple-revoke/handler.ts +227 -0
- package/functions/genesis-apple-revoke/index.ts +55 -0
- package/functions/genesis-push-send/expo.ts +117 -0
- package/functions/genesis-push-send/handler.ts +462 -0
- package/functions/genesis-push-send/index.ts +54 -0
- package/functions/genesis-support-cleanup/handler.ts +80 -0
- package/functions/genesis-support-cleanup/index.ts +39 -0
- package/functions/genesis-support-intake/contract.ts +193 -0
- package/functions/genesis-support-intake/handler.ts +170 -0
- package/functions/genesis-support-intake/index.ts +47 -0
- package/functions/genesis-support-notify/handler.ts +167 -0
- package/functions/genesis-support-notify/index.ts +15 -0
- package/functions/tests/genesis-apple-revoke_test.ts +81 -0
- package/functions/tests/genesis-push-send_test.ts +53 -0
- package/migrations/0001_genesis_account_deletion.sql +133 -0
- package/migrations/0002_genesis_app_release_policy.sql +82 -0
- package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
- package/migrations/0004_genesis_tenant_features.sql +333 -0
- package/migrations/0005_genesis_push.sql +524 -0
- package/migrations/0006_genesis_support.sql +846 -0
- package/migrations/tests/0001_genesis_account_deletion.test.sql +138 -0
- package/migrations/tests/0002_genesis_app_release_policy.test.sql +148 -0
- package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
- package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
- package/migrations/tests/0005_genesis_push.test.sql +407 -0
- package/migrations/tests/0006_genesis_support.test.sql +552 -0
- package/package.json +92 -4
|
@@ -0,0 +1,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) }));
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Genesis support wire contract: what a support client (the web loader, a
|
|
3
|
+
* native app, any HTTP caller) and the `genesis-support-intake` Edge Function
|
|
4
|
+
* say to each other. One file, kept byte-identical in two places:
|
|
5
|
+
* `@marktiderman/genesis-support` (`src/contract.ts`) and the function template
|
|
6
|
+
* (`functions/genesis-support-intake/contract.ts`); a test fails when they drift.
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free and additive: `CONTRACT_VERSION` changes only with a shape
|
|
9
|
+
* change, and a reader tolerates fields and widget types it doesn't know, so an
|
|
10
|
+
* old client keeps working.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Bumped only on a shape change. Old clients must keep working. */
|
|
14
|
+
export const CONTRACT_VERSION = 1 as const;
|
|
15
|
+
|
|
16
|
+
/** The widget kinds a boot can turn on. */
|
|
17
|
+
export type WidgetType = "feedback" | "roadmap" | "changelog" | "survey" | "messaging";
|
|
18
|
+
|
|
19
|
+
/** On the wire the type is open: a client switches on the known ones and ignores the rest. */
|
|
20
|
+
export type WidgetTypeWire = WidgetType | (string & {});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How the person is known to the server.
|
|
24
|
+
* - `genesis-session`: signed in to the app's own Supabase (a user access token). The default.
|
|
25
|
+
* - `signed`: the app's backend mints a short-lived token for the person (reserved).
|
|
26
|
+
* - `anonymous`: a device id only (reserved; the database refuses writes without a session).
|
|
27
|
+
*/
|
|
28
|
+
export type IdentityMode = "anonymous" | "signed" | "genesis-session";
|
|
29
|
+
|
|
30
|
+
export type LauncherPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
31
|
+
|
|
32
|
+
/** Theme hints a web launcher may use. Genesis apps use their own theme instead. */
|
|
33
|
+
export interface ThemeTokens {
|
|
34
|
+
colorBg?: string;
|
|
35
|
+
colorFg?: string;
|
|
36
|
+
colorPrimary?: string;
|
|
37
|
+
colorPrimaryFg?: string;
|
|
38
|
+
radius?: string;
|
|
39
|
+
fontFamily?: string;
|
|
40
|
+
launcherLabel?: string;
|
|
41
|
+
launcherPosition?: LauncherPosition;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One enabled widget and its public config (never a secret). */
|
|
45
|
+
export interface WidgetConfig {
|
|
46
|
+
type: WidgetTypeWire;
|
|
47
|
+
config: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The boot handshake (`GET`): world-readable, so only public values. */
|
|
51
|
+
export interface BootConfig {
|
|
52
|
+
contractVersion: number;
|
|
53
|
+
project: { id: string; name: string };
|
|
54
|
+
identityMode: IdentityMode;
|
|
55
|
+
theme: ThemeTokens;
|
|
56
|
+
widgets: WidgetConfig[];
|
|
57
|
+
flags: Record<string, boolean>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type FeedbackReportType = "bug" | "idea" | "question" | "praise";
|
|
61
|
+
export type FeedbackStatus = "open" | "submitted" | "in_progress" | "addressed" | "wont_fix";
|
|
62
|
+
export type FindingSeverity = "low" | "medium" | "high" | "critical";
|
|
63
|
+
|
|
64
|
+
export const FEEDBACK_REPORT_TYPES: readonly FeedbackReportType[] = ["bug", "idea", "question", "praise"];
|
|
65
|
+
export const FEEDBACK_STATUSES: readonly FeedbackStatus[] = ["open", "submitted", "in_progress", "addressed", "wont_fix"];
|
|
66
|
+
const SEVERITIES: readonly FindingSeverity[] = ["low", "medium", "high", "critical"];
|
|
67
|
+
|
|
68
|
+
/** Client to server: open a report. Screenshots go to storage, not in this body. */
|
|
69
|
+
export interface SubmitFeedbackInput {
|
|
70
|
+
type?: FeedbackReportType;
|
|
71
|
+
title?: string;
|
|
72
|
+
body: string;
|
|
73
|
+
route?: string;
|
|
74
|
+
appVersion?: string;
|
|
75
|
+
envType?: string;
|
|
76
|
+
severity?: FindingSeverity;
|
|
77
|
+
tags?: string[];
|
|
78
|
+
/** Small, free-form context (breadcrumbs, a pin on the page). At most 16 KB as JSON. */
|
|
79
|
+
context?: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Server to client: the report created. `findingId` is the report's id (one finding per report here). */
|
|
83
|
+
export interface SubmitFeedbackResult {
|
|
84
|
+
reportId: string;
|
|
85
|
+
findingId: string;
|
|
86
|
+
status: FeedbackStatus;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One of the caller's own reports. */
|
|
90
|
+
export interface FeedbackReportSummary {
|
|
91
|
+
id: string;
|
|
92
|
+
type: FeedbackReportType;
|
|
93
|
+
title: string;
|
|
94
|
+
status: FeedbackStatus;
|
|
95
|
+
route: string | null;
|
|
96
|
+
createdAt: string;
|
|
97
|
+
findingCount: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The operations the intake function multiplexes on (`{ op, ...args }`). */
|
|
101
|
+
export type FeedbackOp = "submit" | "list";
|
|
102
|
+
|
|
103
|
+
/** Headers a client sends. The person's access token goes in `Authorization: Bearer …`. */
|
|
104
|
+
export const WIDGET_HEADERS = {
|
|
105
|
+
/** The project's publishable key (an identifier, not a secret). */
|
|
106
|
+
key: "x-genesis-key",
|
|
107
|
+
/** `signed` mode: the minted short-lived token. */
|
|
108
|
+
identity: "x-genesis-identity",
|
|
109
|
+
/** `anonymous` mode: the device id. */
|
|
110
|
+
device: "x-genesis-device",
|
|
111
|
+
/** A proof-of-work or captcha token, for anonymous writes. */
|
|
112
|
+
challenge: "x-genesis-challenge",
|
|
113
|
+
/** The contract version the client was built against. */
|
|
114
|
+
contract: "x-genesis-contract",
|
|
115
|
+
} as const;
|
|
116
|
+
|
|
117
|
+
/** Text limits, the same as the database's checks. */
|
|
118
|
+
export const LIMITS = { title: 300, body: 20000, route: 2000, appVersion: 200, tag: 60, tags: 50, contextBytes: 16384 } as const;
|
|
119
|
+
|
|
120
|
+
/** A path segment that names someone or something private: an email, a UUID, a number, a long token. */
|
|
121
|
+
function privateSegment(segment: string): string | null {
|
|
122
|
+
let s = segment;
|
|
123
|
+
try {
|
|
124
|
+
s = decodeURIComponent(segment);
|
|
125
|
+
} catch {
|
|
126
|
+
// a malformed escape: judge it as written
|
|
127
|
+
}
|
|
128
|
+
if (s.includes("@")) return ":email";
|
|
129
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) return ":id";
|
|
130
|
+
if (/^\d+$/.test(s)) return ":id";
|
|
131
|
+
// A long run of letters and digits with a digit in it reads as a token or a code, not a word.
|
|
132
|
+
if (s.length >= 16 && /\d/.test(s) && /^[A-Za-z0-9_.~-]+$/.test(s)) return ":token";
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A route as a path only, safe to store and send: the query string and
|
|
138
|
+
* fragment (tokens, codes, emails) are dropped, and path segments that look
|
|
139
|
+
* private (an email, an id, a long token) become `:email`, `:id` or `:token`
|
|
140
|
+
* (`/users/jane@x.test/reset/abc123…` → `/users/:email/reset/:token`).
|
|
141
|
+
*/
|
|
142
|
+
export function cleanRoute(route: string | null | undefined): string | undefined {
|
|
143
|
+
if (typeof route !== "string") return undefined;
|
|
144
|
+
const path = route.replace(/[?#].*$/, "").trim();
|
|
145
|
+
if (!path) return undefined;
|
|
146
|
+
const safe = path
|
|
147
|
+
.split("/")
|
|
148
|
+
.map((segment) => (segment ? (privateSegment(segment) ?? segment) : segment))
|
|
149
|
+
.join("/");
|
|
150
|
+
return safe.slice(0, LIMITS.route);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type SubmitValidation =
|
|
154
|
+
| { ok: true; value: SubmitFeedbackInput }
|
|
155
|
+
| { ok: false; error: "invalid_args" | "payload_too_large"; field: string };
|
|
156
|
+
|
|
157
|
+
const byteLength = (s: string) => new TextEncoder().encode(s).length;
|
|
158
|
+
|
|
159
|
+
/** Checks a `submit` body the way the server does: known keys only, every limit. */
|
|
160
|
+
export function validateSubmitInput(raw: unknown): SubmitValidation {
|
|
161
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "invalid_args", field: "body" };
|
|
162
|
+
const input = raw as Record<string, unknown>;
|
|
163
|
+
const known = new Set(["type", "title", "body", "route", "appVersion", "envType", "severity", "tags", "context"]);
|
|
164
|
+
for (const key of Object.keys(input)) if (!known.has(key)) return { ok: false, error: "invalid_args", field: key };
|
|
165
|
+
const optionalString = (key: string, max: number): boolean => input[key] == null || (typeof input[key] === "string" && (input[key] as string).length <= max);
|
|
166
|
+
if (typeof input.body !== "string" || !input.body.trim()) return { ok: false, error: "invalid_args", field: "body" };
|
|
167
|
+
if (input.body.length > LIMITS.body) return { ok: false, error: "payload_too_large", field: "body" };
|
|
168
|
+
if (input.type != null && !FEEDBACK_REPORT_TYPES.includes(input.type as FeedbackReportType)) return { ok: false, error: "invalid_args", field: "type" };
|
|
169
|
+
if (input.severity != null && !SEVERITIES.includes(input.severity as FindingSeverity)) return { ok: false, error: "invalid_args", field: "severity" };
|
|
170
|
+
if (!optionalString("title", LIMITS.title)) return { ok: false, error: "invalid_args", field: "title" };
|
|
171
|
+
if (!optionalString("route", LIMITS.route)) return { ok: false, error: "invalid_args", field: "route" };
|
|
172
|
+
if (!optionalString("appVersion", LIMITS.appVersion)) return { ok: false, error: "invalid_args", field: "appVersion" };
|
|
173
|
+
if (!optionalString("envType", 60)) return { ok: false, error: "invalid_args", field: "envType" };
|
|
174
|
+
if (input.tags != null) {
|
|
175
|
+
if (!Array.isArray(input.tags) || input.tags.length > LIMITS.tags) return { ok: false, error: "invalid_args", field: "tags" };
|
|
176
|
+
if (!input.tags.every((t) => typeof t === "string" && t.length <= LIMITS.tag)) return { ok: false, error: "invalid_args", field: "tags" };
|
|
177
|
+
}
|
|
178
|
+
if (input.context != null) {
|
|
179
|
+
if (typeof input.context !== "object" || Array.isArray(input.context)) return { ok: false, error: "invalid_args", field: "context" };
|
|
180
|
+
if (byteLength(JSON.stringify(input.context)) > LIMITS.contextBytes) return { ok: false, error: "payload_too_large", field: "context" };
|
|
181
|
+
}
|
|
182
|
+
const value: SubmitFeedbackInput = { body: input.body };
|
|
183
|
+
if (input.type != null) value.type = input.type as FeedbackReportType;
|
|
184
|
+
if (input.title != null) value.title = input.title as string;
|
|
185
|
+
const route = cleanRoute(input.route as string | undefined);
|
|
186
|
+
if (route) value.route = route;
|
|
187
|
+
if (input.appVersion != null) value.appVersion = input.appVersion as string;
|
|
188
|
+
if (input.envType != null) value.envType = input.envType as string;
|
|
189
|
+
if (input.severity != null) value.severity = input.severity as FindingSeverity;
|
|
190
|
+
if (input.tags != null) value.tags = input.tags as string[];
|
|
191
|
+
if (input.context != null) value.context = input.context as Record<string, unknown>;
|
|
192
|
+
return { ok: true, value };
|
|
193
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The genesis-support-intake Edge Function's request handling, with its
|
|
3
|
+
* dependencies passed in (index.ts wires the real ones), so it is tested
|
|
4
|
+
* without Deno or a database.
|
|
5
|
+
*
|
|
6
|
+
* GET the boot handshake: a public BootConfig
|
|
7
|
+
* POST { op: "submit", … } opens a report (SubmitFeedbackInput) as the caller
|
|
8
|
+
* POST { op: "list" } the caller's own reports (FeedbackReportSummary[])
|
|
9
|
+
* Authorization: Bearer <the person's access token>
|
|
10
|
+
*
|
|
11
|
+
* Every database call runs as the caller (a client made with their token), so
|
|
12
|
+
* row level security decides, exactly as for the app's own Supabase client:
|
|
13
|
+
* this function never holds the service role. It exists for clients that
|
|
14
|
+
* don't run supabase-js (a script-tag loader, another backend).
|
|
15
|
+
*
|
|
16
|
+
* Browser origins: only those in GENESIS_SUPPORT_ORIGINS (comma-separated)
|
|
17
|
+
* get CORS headers, and a request from any other origin is refused. Native
|
|
18
|
+
* apps send no Origin. Responses carry an `error` code, never a database
|
|
19
|
+
* message.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
CONTRACT_VERSION,
|
|
23
|
+
WIDGET_HEADERS,
|
|
24
|
+
validateSubmitInput,
|
|
25
|
+
type BootConfig,
|
|
26
|
+
type FeedbackReportSummary,
|
|
27
|
+
type FeedbackStatus,
|
|
28
|
+
type FeedbackReportType,
|
|
29
|
+
type SubmitFeedbackResult,
|
|
30
|
+
} from "./contract.ts";
|
|
31
|
+
|
|
32
|
+
/** The slice of a supabase-js client (made with the caller's token) the handler uses. */
|
|
33
|
+
export interface CallerClient {
|
|
34
|
+
insertReport(row: Record<string, unknown>): Promise<{ id: string; status: string } | { error: string }>;
|
|
35
|
+
listReports(limit: number): Promise<Array<Record<string, unknown>> | { error: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IntakeDeps {
|
|
39
|
+
env: (name: string) => string | undefined;
|
|
40
|
+
/** A client acting as the person behind this access token. */
|
|
41
|
+
clientFor(accessToken: string): CallerClient;
|
|
42
|
+
log: (message: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
46
|
+
|
|
47
|
+
function allowedOrigins(env: IntakeDeps["env"]): string[] {
|
|
48
|
+
return (env("GENESIS_SUPPORT_ORIGINS") ?? "")
|
|
49
|
+
.split(",")
|
|
50
|
+
.map((o) => o.trim().replace(/\/$/, ""))
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function corsHeaders(origin: string | null, allowed: string[]): Record<string, string> {
|
|
55
|
+
const headers: Record<string, string> = { Vary: "Origin" };
|
|
56
|
+
if (origin && allowed.includes(origin)) {
|
|
57
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
58
|
+
headers["Access-Control-Allow-Headers"] = ["authorization", "content-type", "apikey", ...Object.values(WIDGET_HEADERS)].join(", ");
|
|
59
|
+
headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
|
|
60
|
+
headers["Access-Control-Max-Age"] = "600";
|
|
61
|
+
}
|
|
62
|
+
return headers;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function json(status: number, body: unknown, cors: Record<string, string>, extra: Record<string, string> = {}): Response {
|
|
66
|
+
return new Response(JSON.stringify(body), {
|
|
67
|
+
status,
|
|
68
|
+
headers: { ...cors, "content-type": "application/json", "cache-control": "no-store", ...extra },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The public boot projection. Nothing here is secret: it is readable by anyone. */
|
|
73
|
+
export function bootConfig(env: IntakeDeps["env"]): BootConfig {
|
|
74
|
+
const widgets = (env("GENESIS_SUPPORT_WIDGETS") ?? "feedback,roadmap")
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((w) => w.trim())
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.map((type) => ({ type, config: {} }));
|
|
79
|
+
return {
|
|
80
|
+
contractVersion: CONTRACT_VERSION,
|
|
81
|
+
project: { id: env("GENESIS_SUPPORT_APP_ID") ?? "app", name: env("GENESIS_SUPPORT_APP_NAME") ?? "" },
|
|
82
|
+
identityMode: "genesis-session",
|
|
83
|
+
theme: {},
|
|
84
|
+
widgets,
|
|
85
|
+
flags: {},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bearer(req: Request): string | null {
|
|
90
|
+
const header = req.headers.get("authorization") ?? "";
|
|
91
|
+
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
|
92
|
+
return match?.[1] ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function handle(req: Request, deps: IntakeDeps): Promise<Response> {
|
|
96
|
+
const origin = req.headers.get("origin");
|
|
97
|
+
const allowed = allowedOrigins(deps.env);
|
|
98
|
+
const cors = corsHeaders(origin, allowed);
|
|
99
|
+
if (origin && !allowed.includes(origin)) return json(403, { error: "origin_not_allowed" }, cors);
|
|
100
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
|
101
|
+
if (req.method === "GET") return json(200, bootConfig(deps.env), cors, { "cache-control": "public, max-age=60" });
|
|
102
|
+
if (req.method !== "POST") return json(405, { error: "method_not_allowed" }, cors);
|
|
103
|
+
|
|
104
|
+
const text = await req.text();
|
|
105
|
+
if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) return json(413, { error: "payload_too_large", field: "body" }, cors);
|
|
106
|
+
let raw: Record<string, unknown>;
|
|
107
|
+
try {
|
|
108
|
+
const parsed: unknown = JSON.parse(text);
|
|
109
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
|
110
|
+
raw = parsed as Record<string, unknown>;
|
|
111
|
+
} catch {
|
|
112
|
+
return json(400, { error: "invalid_json" }, cors);
|
|
113
|
+
}
|
|
114
|
+
const { op, ...args } = raw;
|
|
115
|
+
|
|
116
|
+
const token = bearer(req);
|
|
117
|
+
if (!token) return json(401, { error: "identity_required" }, cors);
|
|
118
|
+
const client = deps.clientFor(token);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
if (op === "submit") {
|
|
122
|
+
const checked = validateSubmitInput(args);
|
|
123
|
+
if (!checked.ok) return json(checked.error === "payload_too_large" ? 413 : 400, { error: checked.error, field: checked.field }, cors);
|
|
124
|
+
const a = checked.value;
|
|
125
|
+
const context: Record<string, unknown> = { ...(a.context ?? {}) };
|
|
126
|
+
if (a.envType) context.envType = a.envType;
|
|
127
|
+
if (a.severity) context.severity = a.severity;
|
|
128
|
+
if (a.tags?.length) context.tags = a.tags;
|
|
129
|
+
const created = await client.insertReport({
|
|
130
|
+
type: a.type ?? "bug",
|
|
131
|
+
title: a.title ?? a.body.slice(0, 120),
|
|
132
|
+
message: a.body,
|
|
133
|
+
route: a.route ?? null,
|
|
134
|
+
app_version: a.appVersion ?? null,
|
|
135
|
+
context,
|
|
136
|
+
});
|
|
137
|
+
if ("error" in created) return refusal(created.error, cors, deps);
|
|
138
|
+
const result: SubmitFeedbackResult = { reportId: created.id, findingId: created.id, status: created.status as FeedbackStatus };
|
|
139
|
+
return json(200, result, cors);
|
|
140
|
+
}
|
|
141
|
+
if (op === "list") {
|
|
142
|
+
const limit = typeof args.limit === "number" && Number.isInteger(args.limit) ? Math.min(Math.max(args.limit, 1), 100) : 30;
|
|
143
|
+
const rows = await client.listReports(limit);
|
|
144
|
+
if (!Array.isArray(rows)) return refusal(rows.error, cors, deps);
|
|
145
|
+
const summaries: FeedbackReportSummary[] = rows.map((r) => ({
|
|
146
|
+
id: String(r.id),
|
|
147
|
+
type: (r.type as FeedbackReportType) ?? "bug",
|
|
148
|
+
title: typeof r.title === "string" && r.title ? r.title : String(r.message ?? "").slice(0, 120),
|
|
149
|
+
status: (r.status as FeedbackStatus) ?? "open",
|
|
150
|
+
route: (r.route as string | null) ?? null,
|
|
151
|
+
createdAt: String(r.created_at),
|
|
152
|
+
findingCount: 1,
|
|
153
|
+
}));
|
|
154
|
+
return json(200, summaries, cors);
|
|
155
|
+
}
|
|
156
|
+
return json(400, { error: "unknown_op" }, cors);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
deps.log(`[genesis-support-intake] ${error instanceof Error ? error.message : String(error)}`);
|
|
159
|
+
return json(500, { error: "internal" }, cors);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A database refusal as a code the client can act on. The database's message is logged, never returned. */
|
|
164
|
+
function refusal(code: string, cors: Record<string, string>, deps: IntakeDeps): Response {
|
|
165
|
+
if (code === "GS429") return json(429, { error: "rate_limited" }, cors);
|
|
166
|
+
if (code === "42501" || code === "PGRST301" || code === "PGRST303") return json(401, { error: "identity_required" }, cors);
|
|
167
|
+
if (code === "23514") return json(400, { error: "invalid_args" }, cors);
|
|
168
|
+
deps.log(`[genesis-support-intake] database refused: ${code}`);
|
|
169
|
+
return json(500, { error: "submit_failed" }, cors);
|
|
170
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// genesis-support-intake: the support wire contract over HTTP (boot, submit, list)
|
|
2
|
+
// for clients that don't run supabase-js, such as a script-tag web loader.
|
|
3
|
+
// A template from @marktiderman/genesis-supabase: copy this folder to your app's
|
|
4
|
+
// `supabase/functions/`, beside migration 0006 (see the package README).
|
|
5
|
+
//
|
|
6
|
+
// It acts as the caller: every query runs with the person's access token and the
|
|
7
|
+
// publishable key, so row level security decides. It never uses the service role.
|
|
8
|
+
//
|
|
9
|
+
// Settings (`supabase secrets set …`, all optional): GENESIS_SUPPORT_ORIGINS (the web
|
|
10
|
+
// origins allowed to call it, comma-separated; none means browsers can't),
|
|
11
|
+
// GENESIS_SUPPORT_WIDGETS (default "feedback,roadmap"), GENESIS_SUPPORT_APP_ID and
|
|
12
|
+
// GENESIS_SUPPORT_APP_NAME (shown in the boot handshake). SUPABASE_URL and
|
|
13
|
+
// SUPABASE_ANON_KEY are set by Supabase. Deploy with the gateway's JWT check off
|
|
14
|
+
// (`verify_jwt = false`): the boot handshake has no caller, and submit and list
|
|
15
|
+
// check the caller's token through the database.
|
|
16
|
+
import { createClient } from "npm:@supabase/supabase-js@2.118.0";
|
|
17
|
+
import { handle, type CallerClient } from "./handler.ts";
|
|
18
|
+
|
|
19
|
+
const url = Deno.env.get("SUPABASE_URL") ?? "";
|
|
20
|
+
const publishableKey = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
|
|
21
|
+
|
|
22
|
+
function clientFor(accessToken: string): CallerClient {
|
|
23
|
+
const db = createClient(url, publishableKey, {
|
|
24
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
25
|
+
global: { headers: { Authorization: `Bearer ${accessToken}` } },
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
async insertReport(row) {
|
|
29
|
+
const { data, error } = await db.from("support_reports").insert(row).select("id, status").single();
|
|
30
|
+
return error ? { error: error.code ?? "unknown" } : (data as { id: string; status: string });
|
|
31
|
+
},
|
|
32
|
+
async listReports(limit) {
|
|
33
|
+
// The caller's own reports only, even when the caller is staff (who can read everyone's).
|
|
34
|
+
const { data: user, error: userError } = await db.auth.getUser(accessToken);
|
|
35
|
+
if (userError || !user.user) return { error: "42501" };
|
|
36
|
+
const { data, error } = await db
|
|
37
|
+
.from("support_reports")
|
|
38
|
+
.select("id, type, title, message, status, route, created_at")
|
|
39
|
+
.eq("user_id", user.user.id)
|
|
40
|
+
.order("created_at", { ascending: false })
|
|
41
|
+
.limit(limit);
|
|
42
|
+
return error ? { error: error.code ?? "unknown" } : (data ?? []);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Deno.serve((req) => handle(req, { env: (name) => Deno.env.get(name), clientFor, log: (message) => console.error(message) }));
|