@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,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) }));
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The genesis-support-notify Edge Function's request handling, with its
|
|
3
|
+
* dependencies passed in, so it is tested without Deno.
|
|
4
|
+
*
|
|
5
|
+
* A Supabase Database Webhook calls it on INSERT into public.support_reports
|
|
6
|
+
* (Dashboard → Database → Webhooks, or `supabase_functions.http_request` in a
|
|
7
|
+
* migration of your own), with the header `x-genesis-support-secret: <secret>`.
|
|
8
|
+
* It forwards a summary to the sinks that are configured:
|
|
9
|
+
*
|
|
10
|
+
* - a GitHub issue per report: GITHUB_TOKEN (fine-grained, Issues: write on
|
|
11
|
+
* one repository) and GITHUB_REPO ("owner/name"); GITHUB_LABELS optional;
|
|
12
|
+
* - a webhook: SUPPORT_WEBHOOK_URL, the body signed with
|
|
13
|
+
* SUPPORT_WEBHOOK_SIGNING_SECRET as `x-genesis-signature: sha256=<hex>`.
|
|
14
|
+
*
|
|
15
|
+
* What leaves the database is the summary only: the report id, type, title,
|
|
16
|
+
* message (cut to 2000 characters), route, app version, platform and time.
|
|
17
|
+
* Never the person's id or email, the device or context bags, or a
|
|
18
|
+
* screenshot. The message is what the person typed and may still name
|
|
19
|
+
* someone, so point the GitHub sink at a private repository.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface ReportRecord {
|
|
23
|
+
id: string;
|
|
24
|
+
type?: string;
|
|
25
|
+
title?: string | null;
|
|
26
|
+
message?: string;
|
|
27
|
+
route?: string | null;
|
|
28
|
+
app_version?: string | null;
|
|
29
|
+
platform?: string | null;
|
|
30
|
+
created_at?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReportSummary {
|
|
34
|
+
id: string;
|
|
35
|
+
type: string;
|
|
36
|
+
title: string;
|
|
37
|
+
message: string;
|
|
38
|
+
route: string | null;
|
|
39
|
+
appVersion: string | null;
|
|
40
|
+
platform: string | null;
|
|
41
|
+
createdAt: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NotifyDeps {
|
|
45
|
+
env: (name: string) => string | undefined;
|
|
46
|
+
fetch: typeof fetch;
|
|
47
|
+
log: (message: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MESSAGE_CAP = 2000;
|
|
51
|
+
|
|
52
|
+
/** The only fields a sink ever sees. */
|
|
53
|
+
export function summarize(record: ReportRecord): ReportSummary {
|
|
54
|
+
const message = String(record.message ?? "");
|
|
55
|
+
return {
|
|
56
|
+
id: String(record.id),
|
|
57
|
+
type: String(record.type ?? "bug"),
|
|
58
|
+
title: (record.title ?? "").trim() || message.slice(0, 120),
|
|
59
|
+
message: message.length > MESSAGE_CAP ? `${message.slice(0, MESSAGE_CAP)}…` : message,
|
|
60
|
+
route: record.route ?? null,
|
|
61
|
+
appVersion: record.app_version ?? null,
|
|
62
|
+
platform: record.platform ?? null,
|
|
63
|
+
createdAt: record.created_at ?? null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A constant-time comparison, so the secret can't be guessed a byte at a time. */
|
|
68
|
+
export function sameSecret(a: string, b: string): boolean {
|
|
69
|
+
const x = new TextEncoder().encode(a);
|
|
70
|
+
const y = new TextEncoder().encode(b);
|
|
71
|
+
let diff = x.length ^ y.length;
|
|
72
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
73
|
+
return diff === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function hmacHex(secret: string, body: string): Promise<string> {
|
|
77
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
78
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)));
|
|
79
|
+
return Array.from(sig, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function json(status: number, body: Record<string, unknown>): Response {
|
|
83
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Markdown for the issue body; the person's text is fenced so it can't mention or link anyone. */
|
|
87
|
+
export function issueBody(s: ReportSummary): string {
|
|
88
|
+
const fence = "````";
|
|
89
|
+
return [
|
|
90
|
+
`**${s.type}** from the app${s.platform ? ` (${s.platform})` : ""}`,
|
|
91
|
+
"",
|
|
92
|
+
fence,
|
|
93
|
+
s.message.replaceAll(fence, "``` `"),
|
|
94
|
+
fence,
|
|
95
|
+
"",
|
|
96
|
+
`- Route: ${s.route ? `\`${s.route.replaceAll("`", "")}\`` : "none"}`,
|
|
97
|
+
`- Version: ${s.appVersion ? `\`${s.appVersion.replaceAll("`", "")}\`` : "unknown"}`,
|
|
98
|
+
`- Report: \`${s.id}\``,
|
|
99
|
+
].join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function handle(req: Request, deps: NotifyDeps): Promise<Response> {
|
|
103
|
+
if (req.method !== "POST") return json(405, { error: "method_not_allowed" });
|
|
104
|
+
const secret = deps.env("GENESIS_SUPPORT_WEBHOOK_SECRET") ?? "";
|
|
105
|
+
if (!secret) {
|
|
106
|
+
deps.log("[genesis-support-notify] GENESIS_SUPPORT_WEBHOOK_SECRET is not set; refusing every call");
|
|
107
|
+
return json(500, { error: "not_configured" });
|
|
108
|
+
}
|
|
109
|
+
if (!sameSecret(req.headers.get("x-genesis-support-secret") ?? "", secret)) return json(401, { error: "unauthorized" });
|
|
110
|
+
|
|
111
|
+
let payload: { type?: string; table?: string; schema?: string; record?: ReportRecord };
|
|
112
|
+
try {
|
|
113
|
+
payload = await req.json();
|
|
114
|
+
} catch {
|
|
115
|
+
return json(400, { error: "invalid_json" });
|
|
116
|
+
}
|
|
117
|
+
if (payload.type !== "INSERT" || payload.table !== "support_reports" || !payload.record?.id) {
|
|
118
|
+
return json(200, { ok: true, skipped: true });
|
|
119
|
+
}
|
|
120
|
+
const summary = summarize(payload.record);
|
|
121
|
+
const sent: string[] = [];
|
|
122
|
+
const failed: string[] = [];
|
|
123
|
+
|
|
124
|
+
const repo = deps.env("GITHUB_REPO");
|
|
125
|
+
const token = deps.env("GITHUB_TOKEN");
|
|
126
|
+
if (repo && token) {
|
|
127
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
128
|
+
deps.log("[genesis-support-notify] GITHUB_REPO must be owner/name");
|
|
129
|
+
failed.push("github");
|
|
130
|
+
} else {
|
|
131
|
+
const labels = (deps.env("GITHUB_LABELS") ?? "feedback").split(",").map((l) => l.trim()).filter(Boolean);
|
|
132
|
+
const res = await deps.fetch(`https://api.github.com/repos/${repo}/issues`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", "user-agent": "genesis-support-notify" },
|
|
135
|
+
body: JSON.stringify({ title: `[${summary.type}] ${summary.title}`.slice(0, 256), body: issueBody(summary), labels: [...labels, summary.type] }),
|
|
136
|
+
});
|
|
137
|
+
if (res.ok) sent.push("github");
|
|
138
|
+
else {
|
|
139
|
+
deps.log(`[genesis-support-notify] GitHub answered ${res.status}`);
|
|
140
|
+
failed.push("github");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const hook = deps.env("SUPPORT_WEBHOOK_URL");
|
|
146
|
+
if (hook) {
|
|
147
|
+
if (!hook.startsWith("https://")) {
|
|
148
|
+
deps.log("[genesis-support-notify] SUPPORT_WEBHOOK_URL must be https");
|
|
149
|
+
failed.push("webhook");
|
|
150
|
+
} else {
|
|
151
|
+
const body = JSON.stringify({ event: "support.report.created", report: summary });
|
|
152
|
+
const signing = deps.env("SUPPORT_WEBHOOK_SIGNING_SECRET");
|
|
153
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
154
|
+
if (signing) headers["x-genesis-signature"] = `sha256=${await hmacHex(signing, body)}`;
|
|
155
|
+
const res = await deps.fetch(hook, { method: "POST", headers, body });
|
|
156
|
+
if (res.ok) sent.push("webhook");
|
|
157
|
+
else {
|
|
158
|
+
deps.log(`[genesis-support-notify] the webhook answered ${res.status}`);
|
|
159
|
+
failed.push("webhook");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!sent.length && !failed.length) deps.log("[genesis-support-notify] no sink is configured (GITHUB_REPO + GITHUB_TOKEN, or SUPPORT_WEBHOOK_URL)");
|
|
165
|
+
// 502 when a sink failed, so the database webhook's log shows it.
|
|
166
|
+
return json(failed.length ? 502 : 200, { ok: failed.length === 0, sent, failed });
|
|
167
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// genesis-support-notify: an optional server-side sink for new support reports.
|
|
2
|
+
// A Database Webhook on INSERT into public.support_reports calls it; it opens a
|
|
3
|
+
// GitHub issue per report and/or posts a signed webhook, with a summary only
|
|
4
|
+
// (no person id, email, device, context or screenshot). See handler.ts.
|
|
5
|
+
// A template from @marktiderman/genesis-supabase: copy this folder to your app's
|
|
6
|
+
// `supabase/functions/`, beside migration 0006 (see the package README).
|
|
7
|
+
//
|
|
8
|
+
// Secrets (`supabase secrets set …`): GENESIS_SUPPORT_WEBHOOK_SECRET (required; the
|
|
9
|
+
// database webhook sends it as `x-genesis-support-secret`), and for each sink you
|
|
10
|
+
// want: GITHUB_TOKEN + GITHUB_REPO (+ GITHUB_LABELS), SUPPORT_WEBHOOK_URL
|
|
11
|
+
// (+ SUPPORT_WEBHOOK_SIGNING_SECRET). Deploy with `verify_jwt = false`: the
|
|
12
|
+
// webhook secret is the check.
|
|
13
|
+
import { handle } from "./handler.ts";
|
|
14
|
+
|
|
15
|
+
Deno.serve((req) => handle(req, { env: (name) => Deno.env.get(name), fetch, log: (message) => console.error(message) }));
|