glitchgrab 1.49.0 → 1.50.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 +2 -2
- package/dist/server.d.mts +3 -3
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +1 -1
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -886,12 +886,12 @@ interface GuideReadOptions {
|
|
|
886
886
|
}
|
|
887
887
|
```
|
|
888
888
|
|
|
889
|
-
Never pass the `
|
|
889
|
+
Never pass the `ggc_` server key here for a public page — it reads drafts too, and client calls.
|
|
890
890
|
|
|
891
891
|
## How do I show recorded client calls in my admin panel?
|
|
892
892
|
|
|
893
893
|
On a call page in Glitchgrab, press **share with client**, then issue a **calls
|
|
894
|
-
key** (`ggc_…`)
|
|
894
|
+
key** (`ggc_…`) on API Tokens → Server keys. Put it in your server env as `GLITCHGRAB_CALLS_KEY` — never
|
|
895
895
|
a `NEXT_PUBLIC_` variable, because it plays and deletes calls. Your admin panel
|
|
896
896
|
then reads only the calls you shared:
|
|
897
897
|
|
package/dist/server.d.mts
CHANGED
|
@@ -136,7 +136,7 @@ interface GuideSummary {
|
|
|
136
136
|
category: string | null;
|
|
137
137
|
/** The order set in Glitchgrab. The list already arrives sorted by it. */
|
|
138
138
|
orderIndex: number;
|
|
139
|
-
/** Always `"PUBLISHED"` with a `gg_` token; only
|
|
139
|
+
/** Always `"PUBLISHED"` with a `gg_` token; only the project's `ggc_` server key sees drafts. */
|
|
140
140
|
status: "DRAFT" | "PUBLISHED";
|
|
141
141
|
/** When someone last re-checked the steps against the product. */
|
|
142
142
|
lastVerifiedAt: string | null;
|
|
@@ -170,7 +170,7 @@ interface GuideReadOptions {
|
|
|
170
170
|
* then `GLITCHGRAB_TOKEN`, then `NEXT_PUBLIC_GLITCHGRAB_TOKEN` — the token is
|
|
171
171
|
* public, so the one your provider already uses is fine.
|
|
172
172
|
*
|
|
173
|
-
* Never pass the `
|
|
173
|
+
* Never pass the `ggc_` server key for a public page: it reads drafts too, and client calls.
|
|
174
174
|
*/
|
|
175
175
|
token?: string;
|
|
176
176
|
/** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */
|
|
@@ -273,7 +273,7 @@ interface Call extends CallListItem {
|
|
|
273
273
|
/** Options for {@link listCalls}, {@link getCall} and {@link deleteCall}. */
|
|
274
274
|
interface CallsOptions {
|
|
275
275
|
/**
|
|
276
|
-
* The project's
|
|
276
|
+
* The project's server key (`ggc_…`, API Tokens → Server keys). Falls back to `GLITCHGRAB_CALLS_KEY`.
|
|
277
277
|
* Server only — never a `NEXT_PUBLIC_` variable: it plays and deletes calls.
|
|
278
278
|
*/
|
|
279
279
|
key?: string;
|
package/dist/server.d.ts
CHANGED
|
@@ -136,7 +136,7 @@ interface GuideSummary {
|
|
|
136
136
|
category: string | null;
|
|
137
137
|
/** The order set in Glitchgrab. The list already arrives sorted by it. */
|
|
138
138
|
orderIndex: number;
|
|
139
|
-
/** Always `"PUBLISHED"` with a `gg_` token; only
|
|
139
|
+
/** Always `"PUBLISHED"` with a `gg_` token; only the project's `ggc_` server key sees drafts. */
|
|
140
140
|
status: "DRAFT" | "PUBLISHED";
|
|
141
141
|
/** When someone last re-checked the steps against the product. */
|
|
142
142
|
lastVerifiedAt: string | null;
|
|
@@ -170,7 +170,7 @@ interface GuideReadOptions {
|
|
|
170
170
|
* then `GLITCHGRAB_TOKEN`, then `NEXT_PUBLIC_GLITCHGRAB_TOKEN` — the token is
|
|
171
171
|
* public, so the one your provider already uses is fine.
|
|
172
172
|
*
|
|
173
|
-
* Never pass the `
|
|
173
|
+
* Never pass the `ggc_` server key for a public page: it reads drafts too, and client calls.
|
|
174
174
|
*/
|
|
175
175
|
token?: string;
|
|
176
176
|
/** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */
|
|
@@ -273,7 +273,7 @@ interface Call extends CallListItem {
|
|
|
273
273
|
/** Options for {@link listCalls}, {@link getCall} and {@link deleteCall}. */
|
|
274
274
|
interface CallsOptions {
|
|
275
275
|
/**
|
|
276
|
-
* The project's
|
|
276
|
+
* The project's server key (`ggc_…`, API Tokens → Server keys). Falls back to `GLITCHGRAB_CALLS_KEY`.
|
|
277
277
|
* Server only — never a `NEXT_PUBLIC_` variable: it plays and deletes calls.
|
|
278
278
|
*/
|
|
279
279
|
key?: string;
|
package/dist/server.js
CHANGED
|
@@ -425,7 +425,7 @@ async function deleteCall(id, options = {}) {
|
|
|
425
425
|
const wanted = typeof id === "string" ? id.trim() : "";
|
|
426
426
|
if (!wanted) return { deleted: false, error: "No call id" };
|
|
427
427
|
const key = callsKey(options);
|
|
428
|
-
if (!key) return { deleted: false, error: "No
|
|
428
|
+
if (!key) return { deleted: false, error: "No server key \u2014 set GLITCHGRAB_CALLS_KEY (ggc_\u2026)" };
|
|
429
429
|
const { error } = await callSdkApi(`/api/v1/sdk/calls?id=${encodeURIComponent(wanted)}`, key, options, {
|
|
430
430
|
method: "DELETE",
|
|
431
431
|
cache: "no-store"
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts","../src/runtime.ts","../src/utils.ts"],"sourcesContent":["/**\n * Server-side reporting — `import { reportServerError } from \"glitchgrab/server\"`.\n *\n * The rest of this SDK is `\"use client\"`: it hooks `window.onerror`, walks the\n * DOM for a screenshot, and needs a mounted provider for its token. None of\n * that exists in a cron job at 3am, an API route handler, or a queue worker —\n * which is precisely where the failures nobody is watching happen. A WhatsApp\n * send Meta rejects, an SMTP timeout, a nightly digest that throws: no tab is\n * open, so the browser SDK never sees them and no issue is ever filed.\n *\n * This entry has no `\"use client\"`, touches no DOM API, and imports no React.\n * It POSTs to the same `/api/v1/sdk/report` endpoint the browser uses, with\n * `source: \"SDK_AUTO\"` so the server-side dedup applies (24h per signature,\n * 7 days once an issue exists) — otherwise a cron failing hourly would open\n * twenty-four identical issues a day.\n *\n * Like the rest of the SDK it never throws: a reporting failure must not turn\n * a handled error into an unhandled one.\n */\nimport { createHmac } from \"node:crypto\";\nimport type { ReportPayload, ReportResult, ReportSeverity, ReportType } from \"./types\";\nimport { DEFAULT_BASE_URL, sendReport } from \"./utils\";\n\n/** Who to attribute the report to. A server has no signed-in user, so this is the job or service. */\nexport interface ServerReporterIdentity {\n /** Stable id — the cron name, the service name. Defaults to `\"server\"`. */\n id?: string;\n /** Display name shown on the report. Defaults to `\"Server\"`. */\n name?: string;\n email?: string;\n phone?: string;\n}\n\n/** Defaults applied to every server report. Set once at boot with {@link configureServerReporter}. */\nexport interface ServerReporterConfig {\n /** Repo token (`gg_…`). Falls back to `process.env.GLITCHGRAB_TOKEN`. */\n token?: string;\n /** Override the API host. Falls back to `process.env.GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Where this ran — `\"cron/attendance-prompts\"`, `\"worker/email-queue\"`.\n *\n * It is the grouping key, not a link: it is sent as the report's `pageUrl`\n * so two different jobs throwing the same `\"Timeout\"` stay two issues\n * instead of collapsing into one.\n */\n context?: string;\n /** Merged into every report's metadata. Good place for region, deployment id, service name. */\n metadata?: Record<string, string>;\n reporter?: ServerReporterIdentity;\n /**\n * Report from `NODE_ENV=development` too. Off by default, matching the\n * browser SDK — a laptop crashing its way through a refactor should not file\n * issues. Unlike the browser there is no `Origin` header for the API to\n * recognise, so nothing else stops a dev machine from filing.\n */\n enableInDevelopment?: boolean;\n}\n\n/** Per-call options. Anything set here wins over {@link configureServerReporter}. */\nexport interface ServerReportOptions extends ServerReporterConfig {\n /** Issue type. Defaults to `BUG`. */\n type?: ReportType;\n /** Rendered as a `severity:<value>` label on the GitHub issue. */\n severity?: ReportSeverity;\n /** Extra prose for the issue body — what the job was doing, which record it was on. */\n description?: string;\n /**\n * A real request URL, when there is one (an API route handler). Overrides\n * the synthetic URL built from `context`.\n */\n pageUrl?: string;\n}\n\nconst DEV_BLOCKED_MESSAGE = \"development\";\n\n/**\n * Node's `process`, as much of it as this file uses.\n *\n * `src/global.d.ts` declares a browser-shaped `process` with nothing but\n * `env`, and that is deliberate — the client bundle must not be able to reach\n * for Node APIs. So this entry reads the real one off `globalThis` behind its\n * own narrow type instead of pulling `@types/node` into the package.\n */\ninterface NodeProcessLike {\n env?: Record<string, string | undefined>;\n version?: string;\n on?(event: string, listener: (arg: never) => void): unknown;\n off?(event: string, listener: (arg: never) => void): unknown;\n listenerCount?(event: string): number;\n}\n\nfunction nodeProcess(): NodeProcessLike | undefined {\n try {\n const candidate = (globalThis as { process?: NodeProcessLike }).process;\n return candidate && typeof candidate === \"object\" ? candidate : undefined;\n } catch {\n return undefined;\n }\n}\n\nlet defaults: ServerReporterConfig = {};\n\n/**\n * Set the token and the defaults once, at module load, instead of passing them\n * to every call site. A later call's own options still win.\n */\nexport function configureServerReporter(config: ServerReporterConfig): void {\n defaults = { ...defaults, ...config };\n}\n\n/** Test-only — drop everything {@link configureServerReporter} set. */\nexport function resetServerReporter(): void {\n defaults = {};\n}\n\nfunction readEnv(name: string): string | undefined {\n try {\n return nodeProcess()?.env?.[name];\n } catch {\n return undefined;\n }\n}\n\n/**\n * Message and stack out of whatever was thrown.\n *\n * A thrown string, a rejected `{ code, message }`, an `undefined` from a\n * `Promise.reject()` — all of it reaches here, and all of it must produce a\n * non-empty message: the API's dedup signature is `null` without one, and a\n * report with no signature is a fresh issue every single time.\n */\nfunction describeError(error: unknown): { message: string; stack?: string } {\n try {\n if (error instanceof Error) {\n return {\n message: error.message || error.name || \"Unknown server error\",\n stack: error.stack,\n };\n }\n if (typeof error === \"string\") {\n return { message: error || \"Unknown server error\" };\n }\n if (error && typeof error === \"object\") {\n const maybe = error as { message?: unknown; stack?: unknown };\n const message = typeof maybe.message === \"string\" ? maybe.message : JSON.stringify(error);\n return {\n message: message || \"Unknown server error\",\n stack: typeof maybe.stack === \"string\" ? maybe.stack : undefined,\n };\n }\n return { message: String(error ?? \"\") || \"Unknown server error\" };\n } catch {\n return { message: \"Unknown server error\" };\n }\n}\n\n/**\n * `pageUrl` doubles as the dedup grouping key, so a job needs one even though\n * no page was involved. `server://` keeps it obviously synthetic — and keeps it\n * clear of the API's localhost check, which only matches http(s) and would\n * otherwise swallow the report with a 200 and create nothing.\n */\nfunction contextUrl(context: string | undefined): string | undefined {\n if (!context) return undefined;\n return `server://${context.replace(/^\\/+/, \"\")}`;\n}\n\nfunction runtimeMetadata(): Record<string, string> {\n try {\n const proc = nodeProcess();\n if (!proc) return { runtime: \"server\" };\n const region = proc.env?.VERCEL_REGION;\n const deploymentEnv = proc.env?.VERCEL_ENV;\n return {\n runtime: \"node\",\n ...(proc.version ? { nodeVersion: proc.version } : {}),\n ...(region ? { region } : {}),\n ...(deploymentEnv ? { deploymentEnv } : {}),\n };\n } catch {\n return { runtime: \"server\" };\n }\n}\n\n/**\n * File a server-side error as a GitHub issue.\n *\n * Await it. On a serverless platform the function may be frozen the moment your\n * handler returns, and a floating promise dies with it — the report never\n * leaves the machine.\n *\n * ```ts\n * try {\n * await sendDigest();\n * } catch (error) {\n * await reportServerError(error, { context: \"cron/daily-digest\" });\n * throw error;\n * }\n * ```\n *\n * Returns `null` when nothing was filed — no token, development mode, or the\n * API refused it. Never throws.\n */\nexport async function reportServerError(\n error: unknown,\n options: ServerReportOptions = {}\n): Promise<ReportResult | null> {\n try {\n const token = options.token ?? defaults.token ?? readEnv(\"GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const enableInDevelopment = options.enableInDevelopment ?? defaults.enableInDevelopment ?? false;\n if (!enableInDevelopment && readEnv(\"NODE_ENV\") === DEV_BLOCKED_MESSAGE) return null;\n\n const { message, stack } = describeError(error);\n const context = options.context ?? defaults.context;\n const reporter = { ...defaults.reporter, ...options.reporter };\n\n const payload: ReportPayload = {\n token,\n source: \"SDK_AUTO\",\n type: options.type ?? \"BUG\",\n errorMessage: message,\n ...(stack ? { errorStack: stack } : {}),\n ...(options.description ? { description: options.description } : {}),\n ...(options.pageUrl ?? contextUrl(context)\n ? { pageUrl: options.pageUrl ?? contextUrl(context) }\n : {}),\n metadata: {\n ...runtimeMetadata(),\n ...defaults.metadata,\n ...options.metadata,\n ...(context ? { serverContext: context } : {}),\n ...(options.severity ? { severity: options.severity } : {}),\n sessionUserId: reporter.id ?? context ?? \"server\",\n sessionUserName: reporter.name ?? \"Server\",\n ...(reporter.email ? { sessionUserEmail: reporter.email } : {}),\n ...(reporter.phone ? { sessionUserPhone: reporter.phone } : {}),\n },\n };\n\n return await sendReport(payload, options.baseUrl ?? defaults.baseUrl ?? readEnv(\"GLITCHGRAB_BASE_URL\"));\n } catch {\n return null;\n }\n}\n\n/** Options for {@link captureServerErrors}. */\nexport interface CaptureServerErrorsOptions extends ServerReportOptions {\n /**\n * Also report rejected promises nobody handled. Default `true`.\n *\n * Node crashes the process on an unhandled rejection — but only while no\n * `unhandledRejection` listener exists. Attaching one silently converts a\n * crash into a warning, so this re-throws afterwards to put the default\n * behaviour back, unless the host app has its own listener (then it is theirs\n * to decide).\n */\n captureUnhandledRejections?: boolean;\n}\n\n/**\n * Report every uncaught exception and unhandled rejection in this process.\n *\n * Call it once at boot — `instrumentation.ts` in a Next.js app. Returns a\n * function that removes the listeners again.\n *\n * Uncaught exceptions use `uncaughtExceptionMonitor`, which observes without\n * taking over: the process still crashes exactly as it would have. A reporter\n * that keeps a broken process alive is worse than no reporter.\n */\nexport function captureServerErrors(options: CaptureServerErrorsOptions = {}): () => void {\n try {\n const proc = nodeProcess();\n if (!proc || typeof proc.on !== \"function\") return () => {};\n\n const onException = (error: unknown) => {\n void reportServerError(error, { ...options, context: options.context ?? \"uncaughtException\" });\n };\n\n const onRejection = (reason: unknown) => {\n void reportServerError(reason, {\n ...options,\n context: options.context ?? \"unhandledRejection\",\n });\n\n // Ours is the only listener, so suppressing the crash was never the\n // host app's choice. Hand the default behaviour back.\n if (proc.listenerCount?.(\"unhandledRejection\") === 1) {\n setTimeout(() => {\n throw reason;\n }, 0);\n }\n };\n\n proc.on(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n\n const captureRejections = options.captureUnhandledRejections ?? true;\n if (captureRejections) proc.on(\"unhandledRejection\", onRejection as (arg: never) => void);\n\n return () => {\n try {\n proc.off?.(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n if (captureRejections) {\n proc.off?.(\"unhandledRejection\", onRejection as (arg: never) => void);\n }\n } catch {\n // Nothing to undo.\n }\n };\n } catch {\n return () => {};\n }\n}\n\n/** Options for {@link signSession}. */\nexport interface SignSessionOptions {\n /** Your user's id — the same value you pass as `session.userId`. */\n userId: string;\n /** The repo's signing secret (`ggs_…`). Falls back to `process.env.GLITCHGRAB_SIGNING_SECRET`. */\n secret?: string;\n /** How long the signature stays valid, in seconds. Default one day. */\n ttlSeconds?: number;\n /** Clock override in epoch ms. For tests. */\n now?: number;\n}\n\n/**\n * Vouch for the logged-in user so the SDK's AI features work when the project\n * owner turned on signed users.\n *\n * The `gg_` token ships in your JavaScript, so on its own it proves nothing\n * about who is calling. Your server holds the signing secret; call this where\n * you build the `session` prop and pass the result as `session.signature`.\n * Render it per request so it refreshes before `ttlSeconds` runs out.\n *\n * Format: `v1.<exp>.<base64url HMAC-SHA256(secret, \"v1.<exp>.<userId>\")>`.\n *\n * Unlike the rest of this SDK it throws: it runs in your own server code, where\n * a missing secret is a deploy mistake you want to see — not a user who quietly\n * loses the AI features.\n */\nexport function signSession(options: SignSessionOptions): string {\n const userId = options?.userId;\n if (typeof userId !== \"string\" || userId.length === 0) {\n throw new Error(\"glitchgrab signSession: userId is required\");\n }\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;\n const secret = options.secret || env?.GLITCHGRAB_SIGNING_SECRET;\n if (!secret) {\n throw new Error(\n \"glitchgrab signSession: no signing secret — pass `secret` or set GLITCHGRAB_SIGNING_SECRET\"\n );\n }\n const ttl = options.ttlSeconds ?? 86_400;\n if (!Number.isFinite(ttl) || ttl <= 0) {\n throw new Error(\"glitchgrab signSession: ttlSeconds must be a positive number\");\n }\n const exp = Math.floor((options.now ?? Date.now()) / 1000) + Math.floor(ttl);\n const sig = createHmac(\"sha256\", secret).update(`v1.${exp}.${userId}`).digest(\"base64url\");\n return `v1.${exp}.${sig}`;\n}\n\n// ── Guides ───────────────────────────────────────────────────────────────────\n//\n// A team writes its how-tos in Glitchgrab and renders them on its own site from\n// `GET /api/v1/sdk/guides` (#370). Every site needs the same client — token,\n// caching, never failing the page, shape checks — so it lives here instead of\n// being rewritten, slightly differently, in each product (#409).\n\n/** One guide as the list returns it — no body. Dates are ISO strings: this came over JSON. */\nexport interface GuideSummary {\n slug: string;\n title: string;\n /** One line saying what the guide answers. */\n summary: string;\n category: string | null;\n /** The order set in Glitchgrab. The list already arrives sorted by it. */\n orderIndex: number;\n /** Always `\"PUBLISHED\"` with a `gg_` token; only a `ggw_` write key sees drafts. */\n status: \"DRAFT\" | \"PUBLISHED\";\n /** When someone last re-checked the steps against the product. */\n lastVerifiedAt: string | null;\n updatedAt: string;\n /**\n * Where this guide lives on your site: its own link, else\n * `<guides base URL>/<slug>`, else null when the repo sets neither.\n */\n link: string | null;\n}\n\n/** One guide with its body. */\nexport interface Guide extends GuideSummary {\n /** The markdown as written. */\n content: string;\n /**\n * `content` rendered by Glitchgrab: raw HTML in the markdown arrives as text,\n * only allowlisted attributes survive, and links keep only http(s), mailto\n * and relative targets. Headings carry `id=\"guide-<heading text>\"`, for a\n * table of contents or a link to a single step.\n *\n * Safe to render as-is. On a domain that holds your users' sessions, a second\n * sanitize pass of your own is cheap defence in depth — this SDK ships no\n * sanitizer so it stays dependency-free.\n */\n html: string;\n}\n\n/** Options for {@link listGuides} and {@link getGuide}. */\nexport interface GuideReadOptions {\n /**\n * Repo token (`gg_…`). Falls back to {@link configureServerReporter}'s token,\n * then `GLITCHGRAB_TOKEN`, then `NEXT_PUBLIC_GLITCHGRAB_TOKEN` — the token is\n * public, so the one your provider already uses is fine.\n *\n * Never pass the `ggw_` guides write key for a public page: it reads drafts too.\n */\n token?: string;\n /** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Seconds Next.js may cache the response. Default 300; `0` reads fresh on\n * every request. The route is `force-dynamic`, so without this nothing is\n * cached at all. Ignored outside Next.js.\n */\n revalidate?: number;\n /** Give up and return empty after this many ms. Default 5000 — a slow Glitchgrab must not hang your render. */\n timeoutMs?: number;\n}\n\nconst GUIDES_REVALIDATE_SECONDS = 300;\nconst SDK_API_TIMEOUT_MS = 5000;\n\n/** `RequestInit` plus Next.js's cache option, without pulling Next's types into this entry. */\ntype NextFetchInit = RequestInit & { next?: { revalidate?: number } };\n\ninterface SdkApiResult {\n /** `data` from a successful response, else null. */\n data: unknown;\n /** Null on success; the route's own `error` when it sent one, so a caller can show why. */\n error: string | null;\n}\n\n/**\n * One request to a Glitchgrab SDK route. Never throws: a refusal, a timeout, a\n * network failure and unreadable JSON all come back as `error`.\n */\nasync function callSdkApi(\n path: string,\n token: string,\n options: { baseUrl?: string; timeoutMs?: number },\n init: { method?: \"GET\" | \"DELETE\"; cache: { revalidate: number } | \"no-store\" }\n): Promise<SdkApiResult> {\n const baseUrl = (\n options.baseUrl ??\n defaults.baseUrl ??\n readEnv(\"GLITCHGRAB_BASE_URL\") ??\n DEFAULT_BASE_URL\n ).replace(/\\/+$/, \"\");\n\n const controller = typeof AbortController === \"function\" ? new AbortController() : undefined;\n const timer = controller\n ? setTimeout(() => controller.abort(), options.timeoutMs ?? SDK_API_TIMEOUT_MS)\n : undefined;\n\n try {\n const request: NextFetchInit = {\n method: init.method ?? \"GET\",\n headers: { Authorization: `Bearer ${token}` },\n ...(init.cache === \"no-store\" ? { cache: \"no-store\" as const } : { next: { revalidate: init.cache.revalidate } }),\n ...(controller ? { signal: controller.signal } : {}),\n };\n const response = await fetch(`${baseUrl}${path}`, request);\n const body = (await response.json().catch(() => null)) as\n | { success?: unknown; data?: unknown; error?: unknown }\n | null;\n if (!response.ok || body?.success !== true) {\n return { data: null, error: typeof body?.error === \"string\" ? body.error : `HTTP ${response.status}` };\n }\n return { data: body.data ?? null, error: null };\n } catch {\n return { data: null, error: \"Could not reach Glitchgrab\" };\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\n/** `data` from the guides route, or null on any failure — no token, non-2xx, timeout, bad JSON. */\nasync function readGuidesApi(query: string, options: GuideReadOptions): Promise<unknown> {\n const token =\n options.token ??\n defaults.token ??\n readEnv(\"GLITCHGRAB_TOKEN\") ??\n readEnv(\"NEXT_PUBLIC_GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const revalidate =\n typeof options.revalidate === \"number\" && Number.isFinite(options.revalidate) && options.revalidate >= 0\n ? options.revalidate\n : GUIDES_REVALIDATE_SECONDS;\n\n const result = await callSdkApi(`/api/v1/sdk/guides${query}`, token, options, { cache: { revalidate } });\n return result.data;\n}\n\n/**\n * A guide summary out of whatever the API sent, or null when it is not one.\n * Only slug, title and summary are required — a page cannot render without\n * them. The rest default, so the returned type is honest either way.\n */\nfunction readGuideSummary(value: unknown): GuideSummary | null {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return null;\n const guide = value as Record<string, unknown>;\n if (\n typeof guide.slug !== \"string\" ||\n typeof guide.title !== \"string\" ||\n typeof guide.summary !== \"string\"\n ) {\n return null;\n }\n return {\n slug: guide.slug,\n title: guide.title,\n summary: guide.summary,\n category: typeof guide.category === \"string\" ? guide.category : null,\n orderIndex: typeof guide.orderIndex === \"number\" ? guide.orderIndex : 0,\n status: guide.status === \"DRAFT\" ? \"DRAFT\" : \"PUBLISHED\",\n lastVerifiedAt: typeof guide.lastVerifiedAt === \"string\" ? guide.lastVerifiedAt : null,\n updatedAt: typeof guide.updatedAt === \"string\" ? guide.updatedAt : \"\",\n link: typeof guide.link === \"string\" ? guide.link : null,\n };\n}\n\n/**\n * Every published guide for the token's repo, in the order set in Glitchgrab.\n * No bodies — this is the index page.\n *\n * ```ts\n * // app/guides/page.tsx\n * import { listGuides } from \"glitchgrab/server\";\n *\n * export default async function GuidesPage() {\n * const guides = await listGuides();\n * return guides.map((g) => <a key={g.slug} href={`/guides/${g.slug}`}>{g.title}</a>);\n * }\n * ```\n *\n * Returns `[]` on any failure. Never throws: a Glitchgrab outage leaves the\n * page empty instead of turning it into a 500.\n */\nexport async function listGuides(options: GuideReadOptions = {}): Promise<GuideSummary[]> {\n try {\n const data = await readGuidesApi(\"\", options);\n if (!Array.isArray(data)) return [];\n return data.map(readGuideSummary).filter((guide): guide is GuideSummary => guide !== null);\n } catch {\n return [];\n }\n}\n\n/**\n * One published guide with its markdown and rendered HTML.\n *\n * Returns `null` when there is no such guide and on any failure — call\n * `notFound()` on it. Never throws.\n */\nexport async function getGuide(slug: string, options: GuideReadOptions = {}): Promise<Guide | null> {\n try {\n const wanted = typeof slug === \"string\" ? slug.trim() : \"\";\n if (!wanted) return null;\n\n const data = await readGuidesApi(`?slug=${encodeURIComponent(wanted)}`, options);\n const summary = readGuideSummary(data);\n const body = data as { content?: unknown; html?: unknown } | null;\n if (!summary || typeof body?.html !== \"string\") return null;\n\n return {\n ...summary,\n content: typeof body.content === \"string\" ? body.content : \"\",\n html: body.html,\n };\n } catch {\n return null;\n }\n}\n\n// ── Calls ────────────────────────────────────────────────────────────────────\n//\n// Recorded client calls a project shared with its client (#416), for the team's\n// own admin panel. Unlike guides these are private: they need the project's\n// calls key (`ggc_…`), which lives on the server and never in a bundle, and\n// every response holds links that expire — so nothing here is cached.\n\n/** Someone on the call, as the recorder saw them. */\nexport interface CallParticipant {\n name: string | null;\n email: string | null;\n}\n\n/** One shared call as the list returns it — no transcript, no links. */\nexport interface CallListItem {\n id: string;\n title: string | null;\n startsAt: string | null;\n endsAt: string | null;\n durationSec: number | null;\n /** `\"bot\"` (joined the Meet) or `\"extension\"` (recorded from someone's Chrome). */\n recorder: string | null;\n /** Match a call to your customer by these emails. */\n participants: CallParticipant[];\n hasRecording: boolean;\n /** `IDLE`, `RUNNING`, `DONE` or `FAILED`. */\n transcriptStatus: string;\n /** One line on what the call was about, once Glitchgrab has read it. */\n overview: string | null;\n createdAt: string;\n}\n\nexport interface CallAudio {\n /**\n * `call` — a bot recording's single mixed track. An extension recording has\n * two instead: `client` (everyone else on the call) and `recorder` (the\n * person who recorded it).\n */\n track: \"call\" | \"client\" | \"recorder\";\n /** Plays for {@link Call.linksExpireInSeconds}. */\n url: string;\n}\n\nexport interface CallMoment {\n /** Milliseconds into the call — seek the audio here. */\n tMs: number | null;\n speaker: string | null;\n text: string;\n}\n\n/** One shared call in full. */\nexport interface Call extends CallListItem {\n meetUrl: string | null;\n audio: CallAudio[];\n /** Audio and screenshot links stop working after this many seconds. Read the call per render; never store them. */\n linksExpireInSeconds: number;\n transcript: string | null;\n summary: { overview: string; moments: CallMoment[] } | null;\n /** Screenshots grabbed during the call, a page at a time. `tMs` is on the transcript's clock. */\n frames: { total: number; offset: number; limit: number; items: { tMs: number; url: string }[] };\n}\n\n/** Options for {@link listCalls}, {@link getCall} and {@link deleteCall}. */\nexport interface CallsOptions {\n /**\n * The project's calls key (`ggc_…`). Falls back to `GLITCHGRAB_CALLS_KEY`.\n * Server only — never a `NEXT_PUBLIC_` variable: it plays and deletes calls.\n */\n key?: string;\n /** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /** Give up after this many ms. Default 5000. */\n timeoutMs?: number;\n}\n\nexport interface ListCallsOptions extends CallsOptions {\n /** Default 20, max 100. */\n limit?: number;\n /** ISO date: only calls recorded before it. Pass the last call's `createdAt` for the next page. */\n before?: string;\n}\n\nexport interface GetCallOptions extends CallsOptions {\n /** First screenshot to return. Default 0. */\n framesOffset?: number;\n /** Screenshots per page. Default 50, max 200, `0` for none. */\n framesLimit?: number;\n}\n\nexport type DeleteCallResult = { deleted: true } | { deleted: false; error: string };\n\nconst CALLS_KEY_PREFIX = \"ggc_\";\n\n/** Only a `ggc_` key. A `gg_` token here would only 401 — and means the public token sits where a secret belongs. */\nfunction callsKey(options: CallsOptions): string | null {\n const key = options.key ?? readEnv(\"GLITCHGRAB_CALLS_KEY\");\n return typeof key === \"string\" && key.startsWith(CALLS_KEY_PREFIX) ? key : null;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction stringOrNull(value: unknown): string | null {\n return typeof value === \"string\" ? value : null;\n}\n\nfunction numberOrNull(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nfunction readCallListItem(value: unknown): CallListItem | null {\n if (!isRecord(value) || typeof value.id !== \"string\") return null;\n return {\n id: value.id,\n title: stringOrNull(value.title),\n startsAt: stringOrNull(value.startsAt),\n endsAt: stringOrNull(value.endsAt),\n durationSec: numberOrNull(value.durationSec),\n recorder: stringOrNull(value.recorder),\n participants: Array.isArray(value.participants)\n ? value.participants.filter(isRecord).map((p) => ({ name: stringOrNull(p.name), email: stringOrNull(p.email) }))\n : [],\n hasRecording: value.hasRecording === true,\n transcriptStatus: typeof value.transcriptStatus === \"string\" ? value.transcriptStatus : \"IDLE\",\n overview: stringOrNull(value.overview),\n createdAt: typeof value.createdAt === \"string\" ? value.createdAt : \"\",\n };\n}\n\nconst AUDIO_TRACKS: ReadonlySet<string> = new Set([\"call\", \"client\", \"recorder\"]);\n\nfunction readCall(value: unknown): Call | null {\n const base = readCallListItem(value);\n if (!base || !isRecord(value)) return null;\n\n const rawSummary = value.summary;\n const summary =\n isRecord(rawSummary) && typeof rawSummary.overview === \"string\"\n ? {\n overview: rawSummary.overview,\n moments: Array.isArray(rawSummary.moments)\n ? rawSummary.moments\n .filter(isRecord)\n .filter((m) => typeof m.text === \"string\")\n .map((m) => ({ tMs: numberOrNull(m.tMs), speaker: stringOrNull(m.speaker), text: m.text as string }))\n : [],\n }\n : null;\n\n const frames = isRecord(value.frames) ? value.frames : {};\n\n return {\n ...base,\n meetUrl: stringOrNull(value.meetUrl),\n audio: Array.isArray(value.audio)\n ? value.audio\n .filter(isRecord)\n .filter((a) => typeof a.url === \"string\" && typeof a.track === \"string\" && AUDIO_TRACKS.has(a.track))\n .map((a) => ({ track: a.track as CallAudio[\"track\"], url: a.url as string }))\n : [],\n linksExpireInSeconds: numberOrNull(value.linksExpireInSeconds) ?? 0,\n transcript: stringOrNull(value.transcript),\n summary,\n frames: {\n total: numberOrNull(frames.total) ?? 0,\n offset: numberOrNull(frames.offset) ?? 0,\n limit: numberOrNull(frames.limit) ?? 0,\n items: Array.isArray(frames.items)\n ? frames.items\n .filter(isRecord)\n .filter((f) => typeof f.url === \"string\" && typeof f.tMs === \"number\")\n .map((f) => ({ tMs: f.tMs as number, url: f.url as string }))\n : [],\n },\n };\n}\n\nfunction setNumber(query: URLSearchParams, name: string, value: unknown): void {\n if (typeof value === \"number\" && Number.isFinite(value)) query.set(name, String(Math.floor(value)));\n}\n\n/**\n * Calls shared with the client on the key's project, newest first. No links —\n * open one with {@link getCall}.\n *\n * ```ts\n * // app/admin/calls/page.tsx — server component\n * import { listCalls } from \"glitchgrab/server\";\n *\n * export default async function CallsPage() {\n * const calls = await listCalls(); // GLITCHGRAB_CALLS_KEY=ggc_…\n * return calls.map((c) => <a key={c.id} href={`/admin/calls/${c.id}`}>{c.title}</a>);\n * }\n * ```\n *\n * Returns `[]` on any failure. Never throws.\n */\nexport async function listCalls(options: ListCallsOptions = {}): Promise<CallListItem[]> {\n try {\n const key = callsKey(options);\n if (!key) return [];\n\n const query = new URLSearchParams();\n setNumber(query, \"limit\", options.limit);\n if (typeof options.before === \"string\" && options.before) query.set(\"before\", options.before);\n const qs = query.toString();\n\n const { data } = await callSdkApi(`/api/v1/sdk/calls${qs ? `?${qs}` : \"\"}`, key, options, { cache: \"no-store\" });\n if (!Array.isArray(data)) return [];\n return data.map(readCallListItem).filter((call): call is CallListItem => call !== null);\n } catch {\n return [];\n }\n}\n\n/**\n * One shared call: audio links, transcript, summary and a page of screenshots.\n * Links expire after `linksExpireInSeconds` — read the call when the page\n * renders and never store them.\n *\n * Returns `null` when the call does not exist, is not shared, and on any\n * failure — call `notFound()` on it. Never throws.\n */\nexport async function getCall(id: string, options: GetCallOptions = {}): Promise<Call | null> {\n try {\n const wanted = typeof id === \"string\" ? id.trim() : \"\";\n const key = callsKey(options);\n if (!wanted || !key) return null;\n\n const query = new URLSearchParams({ id: wanted });\n setNumber(query, \"framesOffset\", options.framesOffset);\n setNumber(query, \"framesLimit\", options.framesLimit);\n\n const { data } = await callSdkApi(`/api/v1/sdk/calls?${query.toString()}`, key, options, { cache: \"no-store\" });\n return readCall(data);\n } catch {\n return null;\n }\n}\n\n/**\n * Permanently delete one shared call — the recording, the screenshots, the\n * transcript. Cannot be undone.\n *\n * Resolves `{ deleted: false, error }` instead of throwing: the reason is worth\n * showing (\"The bot is still in this call — delete it after the call ends\").\n */\nexport async function deleteCall(id: string, options: CallsOptions = {}): Promise<DeleteCallResult> {\n try {\n const wanted = typeof id === \"string\" ? id.trim() : \"\";\n if (!wanted) return { deleted: false, error: \"No call id\" };\n const key = callsKey(options);\n if (!key) return { deleted: false, error: \"No calls key — set GLITCHGRAB_CALLS_KEY (ggc_…)\" };\n\n const { error } = await callSdkApi(`/api/v1/sdk/calls?id=${encodeURIComponent(wanted)}`, key, options, {\n method: \"DELETE\",\n cache: \"no-store\",\n });\n return error === null ? { deleted: true } : { deleted: false, error };\n } catch {\n return { deleted: false, error: \"Could not delete the call\" };\n }\n}\n\n/**\n * `JSON.stringify` that is safe inside `<script type=\"application/ld+json\">`.\n *\n * Guide titles and summaries are written outside your codebase, and\n * `JSON.stringify` leaves `<` alone — a title containing `</script>` closes the\n * tag and whatever follows runs as HTML. The JSON unicode escape for `<` is the same\n * character to a JSON parser and inert to the HTML one. `>`, `&` and the two\n * JavaScript line separators are escaped for the same reason.\n *\n * ```tsx\n * <script\n * type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: escapeJsonForScript({ \"@type\": \"HowTo\", name: guide.title }) }}\n * />\n * ```\n *\n * Never throws: a value JSON cannot represent (circular, `undefined`) becomes `null`.\n */\nexport function escapeJsonForScript(value: unknown): string {\n try {\n const json = JSON.stringify(value);\n if (json === undefined) return \"null\";\n return json\n .replace(/</g, \"\\\\u003c\")\n .replace(/>/g, \"\\\\u003e\")\n .replace(/&/g, \"\\\\u0026\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n } catch {\n return \"null\";\n }\n}\n\nexport type { ReportResult, ReportSeverity, ReportType } from \"./types\";\n","import type { RuntimeInfo } from \"./types\";\n\n/**\n * Runtime health at the moment of the crash. `deviceInfo` says what the machine\n * is; this says what shape it was in — a leak that ran the heap to its ceiling,\n * a 2G connection that starved a fetch, a bug that only shows up after ten\n * minutes on the page or on the fifth error in a row.\n */\n\nconst pageLoadedAt = Date.now();\nlet errorCount = 0;\n\n/** Bumped by every auto-captured error so a report says which one in the run it was. */\nexport function incrementErrorCount(): number {\n errorCount += 1;\n return errorCount;\n}\n\n/** Test-only — reset the per-session counter. */\nexport function resetErrorCount(): void {\n errorCount = 0;\n}\n\ninterface MemoryCapableWindow {\n memory?: { usedJSHeapSize?: number; jsHeapSizeLimit?: number };\n}\n\ninterface NetworkInformation {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\nconst toMb = (bytes: number | undefined): number | undefined =>\n typeof bytes === \"number\" ? Math.round((bytes / 1048576) * 10) / 10 : undefined;\n\nexport function captureRuntimeInfo(): RuntimeInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n\n // Chromium-only, and absent entirely under cross-origin isolation.\n const memory = (performance as unknown as MemoryCapableWindow)?.memory;\n // Chromium + Android only.\n const connection = (navigator as unknown as { connection?: NetworkInformation })?.connection;\n\n return {\n timeOnPageMs: Date.now() - pageLoadedAt,\n errorCount,\n visibility: typeof document !== \"undefined\" ? document.visibilityState : \"unknown\",\n ...(memory\n ? {\n jsHeapUsedMb: toMb(memory.usedJSHeapSize),\n jsHeapLimitMb: toMb(memory.jsHeapSizeLimit),\n }\n : {}),\n ...(connection\n ? {\n connectionType: connection.effectiveType,\n downlinkMbps: connection.downlink,\n rttMs: connection.rtt,\n saveData: connection.saveData,\n }\n : {}),\n };\n } catch {\n return null;\n }\n}\n","import type {\n CapturedContext,\n DeviceInfo,\n FeedbackPayload,\n FeedbackResult,\n ReportPayload,\n ReportResult,\n} from \"./types\";\n// The React-free subpath: utils.ts is also bundled into `glitchgrab/server`.\nimport { assistOffline, toAssistTurnResult } from \"@glitchgrab/report-ui/assist-response\";\nimport type { AssistTurnResult } from \"@glitchgrab/report-ui\";\nimport { getBreadcrumbs } from \"./breadcrumbs\";\nimport { captureRuntimeInfo } from \"./runtime\";\nimport { getAppContext, getRelease } from \"./app-context\";\n\nconst SENSITIVE_PARAMS = [\n \"token\", \"key\", \"secret\", \"password\", \"passwd\", \"auth\", \"authorization\",\n \"session\", \"sessionid\", \"session_id\", \"api_key\", \"apikey\", \"access_token\",\n \"refresh_token\", \"client_secret\", \"code\", \"state\", \"nonce\", \"credential\", \"private\",\n];\n\nexport function sanitizeUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const params = new URLSearchParams(parsed.search);\n let modified = false;\n for (const key of Array.from(params.keys())) {\n if (SENSITIVE_PARAMS.some((s) => key.toLowerCase().includes(s))) {\n params.set(key, \"[REDACTED]\");\n modified = true;\n }\n }\n if (modified) parsed.search = params.toString();\n return parsed.toString();\n } catch {\n return url.split(\"?\")[0] ?? url;\n }\n}\n\nexport function captureDeviceInfo(): DeviceInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n return {\n screenWidth: screen.width,\n screenHeight: screen.height,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n platform: navigator.platform ?? \"unknown\",\n language: navigator.language ?? \"unknown\",\n online: navigator.onLine,\n colorScheme: window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\",\n devicePixelRatio: window.devicePixelRatio ?? 1,\n };\n } catch {\n return null;\n }\n}\n\nexport function captureContext(visitedPages: string[]): CapturedContext {\n try {\n return {\n url: sanitizeUrl(typeof window !== \"undefined\" ? window.location.href : \"\"),\n userAgent: typeof navigator !== \"undefined\" ? navigator.userAgent : \"\",\n timestamp: new Date().toISOString(),\n visitedPages: visitedPages.map(sanitizeUrl),\n breadcrumbs: getBreadcrumbs(),\n deviceInfo: captureDeviceInfo(),\n runtime: captureRuntimeInfo(),\n appContext: getAppContext(),\n release: getRelease(),\n };\n } catch {\n return {\n url: \"\",\n userAgent: \"\",\n timestamp: new Date().toISOString(),\n visitedPages: [],\n breadcrumbs: [],\n deviceInfo: null,\n runtime: null,\n appContext: {},\n };\n }\n}\n\n/**\n * Metadata every report carries regardless of how it was raised — release,\n * runtime health, and the host app's own context. One builder so a user-filed\n * report and an auto-captured crash describe the same world; the payload sites\n * only add what is specific to them.\n *\n * App context is prefixed so a key named `timestamp` or `status` can't quietly\n * overwrite a field the dashboard relies on.\n */\nexport function contextMetadata(context: CapturedContext): Record<string, string> {\n try {\n const runtime = context.runtime;\n const appContext = Object.entries(context.appContext ?? {}).reduce<Record<string, string>>(\n (acc, [key, value]) => {\n acc[`ctx_${key}`] = value;\n return acc;\n },\n {}\n );\n\n return {\n ...(context.release ? { release: context.release } : {}),\n ...(runtime\n ? {\n timeOnPageMs: String(runtime.timeOnPageMs),\n errorCount: String(runtime.errorCount),\n visibility: runtime.visibility,\n ...(runtime.jsHeapUsedMb !== undefined\n ? { jsHeapUsedMb: String(runtime.jsHeapUsedMb) }\n : {}),\n ...(runtime.jsHeapLimitMb !== undefined\n ? { jsHeapLimitMb: String(runtime.jsHeapLimitMb) }\n : {}),\n ...(runtime.connectionType ? { connectionType: runtime.connectionType } : {}),\n ...(runtime.downlinkMbps !== undefined\n ? { downlinkMbps: String(runtime.downlinkMbps) }\n : {}),\n ...(runtime.rttMs !== undefined ? { rttMs: String(runtime.rttMs) } : {}),\n ...(runtime.saveData !== undefined ? { saveData: String(runtime.saveData) } : {}),\n }\n : {}),\n ...appContext,\n };\n } catch {\n return {};\n }\n}\n\nexport const DEFAULT_BASE_URL = \"https://glitchgrab.dev\";\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 500;\n\n/**\n * Send a report and return the result.\n * Never throws — returns null on failure.\n */\nexport async function sendReport(\n payload: ReportPayload,\n baseUrl?: string\n): Promise<ReportResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/report`;\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n };\n\n // keepalive has a 64KB body limit in browsers — skip it for large payloads (e.g., screenshots)\n const useKeepalive = body.length < 60_000;\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n ...(useKeepalive ? { keepalive: true } : {}),\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<ReportResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n // Fallback: sendBeacon (fire-and-forget, no result)\n if (typeof navigator !== \"undefined\" && navigator.sendBeacon) {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: \"application/json\" }));\n } catch {\n // Silently fail\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Save a star rating your end-user left about your app.\n * Never throws — returns null on failure.\n *\n * Unlike `sendReport`, this is always a deliberate user action with a dialog\n * open in front of them, so it retries on transient failure but skips the\n * sendBeacon fallback: the caller needs a real result to show a thank-you.\n */\nexport async function sendFeedback(\n payload: FeedbackPayload,\n baseUrl?: string\n): Promise<FeedbackResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/feedback`;\n const body = JSON.stringify(payload);\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n },\n body,\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<FeedbackResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n // 4xx other than rate limiting is a caller mistake — retrying won't help.\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\nexport interface EnhanceContext {\n url?: string;\n visitedPages?: string[];\n breadcrumbs?: Array<{ type: string; message: string }>;\n}\n\n/**\n * The signed-in user as the host's server vouched for them (`signSession`).\n *\n * Rides in the request BODY, never a header: every token route sets its own\n * CORS allow-list of headers, so a new header would fail preflight on all of\n * them. Omitted entirely when there is no signature — an unsigned id proves\n * nothing, and the server reads its absence as \"unsigned\".\n */\nexport interface SignedIdentity {\n userId?: string | null;\n signature?: string | null;\n}\n\n/** `{ userId, signature }` to spread into a JSON body, or nothing. */\nexport function signedFields(identity?: SignedIdentity | null): { userId?: string; signature?: string } {\n if (!identity?.signature || !identity.userId) return {};\n return { userId: identity.userId, signature: identity.signature };\n}\n\n/**\n * Polish raw description text via the Glitchgrab AI enhance endpoint.\n * Returns the polished text on success, or the original text on any failure.\n * Never throws.\n */\nexport async function enhanceText(\n text: string,\n token: string,\n baseUrl?: string,\n screenshot?: string | null,\n context?: EnhanceContext | null,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n const trimmed = text.trim();\n if (!trimmed) return text;\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/enhance-text`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n text: trimmed,\n ...(screenshot ? { screenshot } : {}),\n ...(context ? { context } : {}),\n ...signedFields(identity),\n }),\n });\n if (!response.ok) return text;\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { text?: string };\n };\n if (envelope?.success && typeof envelope.data?.text === \"string\") {\n return envelope.data.text;\n }\n return text;\n } catch {\n return text;\n }\n}\n\n/**\n * \"Is this already filed?\" for the report dialog's plain form. Asked when Send\n * is pressed; a match lets the reporter add to the open issue instead of\n * opening a second one.\n *\n * `[]` when the project has it off, `null` on any failure — the dialog sends\n * straight away on either. Never throws.\n */\nexport async function findSimilarIssues(\n description: string,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<{ number: number; title: string; url: string; status?: string }[] | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/reports/similar`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ text: description, ...signedFields(identity) }),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n data?: { enabled?: boolean; matches?: { number: number; title: string; url: string; status?: string }[] };\n } | null;\n if (!response.ok || !envelope?.success) return null;\n if (!envelope.data?.enabled) return [];\n return Array.isArray(envelope.data.matches) ? envelope.data.matches : [];\n } catch {\n return null;\n }\n}\n\n/**\n * One turn of the AI report assistant (#330).\n *\n * Same never-throw contract as everything else in the SDK: a failure comes back\n * as `degraded` and the dialog quietly falls back to the plain form. An\n * end-user of the host app must never see a Glitchgrab error, and must never be\n * blocked from filing a bug because a model was busy.\n */\nexport async function assistTurn(\n params: {\n messages: { role: \"user\" | \"assistant\"; content: string }[];\n conversationId: string | null;\n screenshot?: string | null;\n context?: Record<string, unknown> | null;\n /**\n * Who the host app says is logged in, from the provider's `session` prop.\n * Stored against the conversation so a chat and the report it produced\n * agree on who filed — one SDK token covers every end user of the host app,\n * so without this every stranger's chat looks like the same person.\n */\n reporter?: {\n key?: string | null;\n name?: string | null;\n email?: string | null;\n /** `signSession()` output for `key`; absent when the host did not sign. */\n signature?: string | null;\n };\n },\n token: string,\n baseUrl?: string\n): Promise<AssistTurnResult> {\n const offline = assistOffline();\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/report-chat`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(params),\n });\n // One mapping for every host (packages/report-ui/src/assist-response.ts):\n // the server's degrade reason passes through, and nothing the sheet renders\n // is dropped.\n return toAssistTurnResult(response.ok, await response.json().catch(() => null));\n } catch {\n return offline;\n }\n}\n\n/**\n * Send an audio Blob to the Glitchgrab STT proxy (which calls Sarvam).\n * Returns the transcript string, or \"\" on any failure. Never throws.\n */\nexport async function transcribeAudio(\n blob: Blob,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n if (blob.size === 0) return \"\";\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/stt`;\n const form = new FormData();\n form.append(\"file\", blob, \"audio.webm\");\n const signed = signedFields(identity);\n if (signed.userId && signed.signature) {\n form.append(\"userId\", signed.userId);\n form.append(\"signature\", signed.signature);\n }\n const response = await fetch(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n });\n if (!response.ok) return \"\";\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { transcript?: string };\n };\n if (envelope?.success && typeof envelope.data?.transcript === \"string\") {\n return envelope.data.transcript;\n }\n return \"\";\n } catch {\n return \"\";\n }\n}\n"],"mappings":"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","names":["_a"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/runtime.ts","../src/utils.ts"],"sourcesContent":["/**\n * Server-side reporting — `import { reportServerError } from \"glitchgrab/server\"`.\n *\n * The rest of this SDK is `\"use client\"`: it hooks `window.onerror`, walks the\n * DOM for a screenshot, and needs a mounted provider for its token. None of\n * that exists in a cron job at 3am, an API route handler, or a queue worker —\n * which is precisely where the failures nobody is watching happen. A WhatsApp\n * send Meta rejects, an SMTP timeout, a nightly digest that throws: no tab is\n * open, so the browser SDK never sees them and no issue is ever filed.\n *\n * This entry has no `\"use client\"`, touches no DOM API, and imports no React.\n * It POSTs to the same `/api/v1/sdk/report` endpoint the browser uses, with\n * `source: \"SDK_AUTO\"` so the server-side dedup applies (24h per signature,\n * 7 days once an issue exists) — otherwise a cron failing hourly would open\n * twenty-four identical issues a day.\n *\n * Like the rest of the SDK it never throws: a reporting failure must not turn\n * a handled error into an unhandled one.\n */\nimport { createHmac } from \"node:crypto\";\nimport type { ReportPayload, ReportResult, ReportSeverity, ReportType } from \"./types\";\nimport { DEFAULT_BASE_URL, sendReport } from \"./utils\";\n\n/** Who to attribute the report to. A server has no signed-in user, so this is the job or service. */\nexport interface ServerReporterIdentity {\n /** Stable id — the cron name, the service name. Defaults to `\"server\"`. */\n id?: string;\n /** Display name shown on the report. Defaults to `\"Server\"`. */\n name?: string;\n email?: string;\n phone?: string;\n}\n\n/** Defaults applied to every server report. Set once at boot with {@link configureServerReporter}. */\nexport interface ServerReporterConfig {\n /** Repo token (`gg_…`). Falls back to `process.env.GLITCHGRAB_TOKEN`. */\n token?: string;\n /** Override the API host. Falls back to `process.env.GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Where this ran — `\"cron/attendance-prompts\"`, `\"worker/email-queue\"`.\n *\n * It is the grouping key, not a link: it is sent as the report's `pageUrl`\n * so two different jobs throwing the same `\"Timeout\"` stay two issues\n * instead of collapsing into one.\n */\n context?: string;\n /** Merged into every report's metadata. Good place for region, deployment id, service name. */\n metadata?: Record<string, string>;\n reporter?: ServerReporterIdentity;\n /**\n * Report from `NODE_ENV=development` too. Off by default, matching the\n * browser SDK — a laptop crashing its way through a refactor should not file\n * issues. Unlike the browser there is no `Origin` header for the API to\n * recognise, so nothing else stops a dev machine from filing.\n */\n enableInDevelopment?: boolean;\n}\n\n/** Per-call options. Anything set here wins over {@link configureServerReporter}. */\nexport interface ServerReportOptions extends ServerReporterConfig {\n /** Issue type. Defaults to `BUG`. */\n type?: ReportType;\n /** Rendered as a `severity:<value>` label on the GitHub issue. */\n severity?: ReportSeverity;\n /** Extra prose for the issue body — what the job was doing, which record it was on. */\n description?: string;\n /**\n * A real request URL, when there is one (an API route handler). Overrides\n * the synthetic URL built from `context`.\n */\n pageUrl?: string;\n}\n\nconst DEV_BLOCKED_MESSAGE = \"development\";\n\n/**\n * Node's `process`, as much of it as this file uses.\n *\n * `src/global.d.ts` declares a browser-shaped `process` with nothing but\n * `env`, and that is deliberate — the client bundle must not be able to reach\n * for Node APIs. So this entry reads the real one off `globalThis` behind its\n * own narrow type instead of pulling `@types/node` into the package.\n */\ninterface NodeProcessLike {\n env?: Record<string, string | undefined>;\n version?: string;\n on?(event: string, listener: (arg: never) => void): unknown;\n off?(event: string, listener: (arg: never) => void): unknown;\n listenerCount?(event: string): number;\n}\n\nfunction nodeProcess(): NodeProcessLike | undefined {\n try {\n const candidate = (globalThis as { process?: NodeProcessLike }).process;\n return candidate && typeof candidate === \"object\" ? candidate : undefined;\n } catch {\n return undefined;\n }\n}\n\nlet defaults: ServerReporterConfig = {};\n\n/**\n * Set the token and the defaults once, at module load, instead of passing them\n * to every call site. A later call's own options still win.\n */\nexport function configureServerReporter(config: ServerReporterConfig): void {\n defaults = { ...defaults, ...config };\n}\n\n/** Test-only — drop everything {@link configureServerReporter} set. */\nexport function resetServerReporter(): void {\n defaults = {};\n}\n\nfunction readEnv(name: string): string | undefined {\n try {\n return nodeProcess()?.env?.[name];\n } catch {\n return undefined;\n }\n}\n\n/**\n * Message and stack out of whatever was thrown.\n *\n * A thrown string, a rejected `{ code, message }`, an `undefined` from a\n * `Promise.reject()` — all of it reaches here, and all of it must produce a\n * non-empty message: the API's dedup signature is `null` without one, and a\n * report with no signature is a fresh issue every single time.\n */\nfunction describeError(error: unknown): { message: string; stack?: string } {\n try {\n if (error instanceof Error) {\n return {\n message: error.message || error.name || \"Unknown server error\",\n stack: error.stack,\n };\n }\n if (typeof error === \"string\") {\n return { message: error || \"Unknown server error\" };\n }\n if (error && typeof error === \"object\") {\n const maybe = error as { message?: unknown; stack?: unknown };\n const message = typeof maybe.message === \"string\" ? maybe.message : JSON.stringify(error);\n return {\n message: message || \"Unknown server error\",\n stack: typeof maybe.stack === \"string\" ? maybe.stack : undefined,\n };\n }\n return { message: String(error ?? \"\") || \"Unknown server error\" };\n } catch {\n return { message: \"Unknown server error\" };\n }\n}\n\n/**\n * `pageUrl` doubles as the dedup grouping key, so a job needs one even though\n * no page was involved. `server://` keeps it obviously synthetic — and keeps it\n * clear of the API's localhost check, which only matches http(s) and would\n * otherwise swallow the report with a 200 and create nothing.\n */\nfunction contextUrl(context: string | undefined): string | undefined {\n if (!context) return undefined;\n return `server://${context.replace(/^\\/+/, \"\")}`;\n}\n\nfunction runtimeMetadata(): Record<string, string> {\n try {\n const proc = nodeProcess();\n if (!proc) return { runtime: \"server\" };\n const region = proc.env?.VERCEL_REGION;\n const deploymentEnv = proc.env?.VERCEL_ENV;\n return {\n runtime: \"node\",\n ...(proc.version ? { nodeVersion: proc.version } : {}),\n ...(region ? { region } : {}),\n ...(deploymentEnv ? { deploymentEnv } : {}),\n };\n } catch {\n return { runtime: \"server\" };\n }\n}\n\n/**\n * File a server-side error as a GitHub issue.\n *\n * Await it. On a serverless platform the function may be frozen the moment your\n * handler returns, and a floating promise dies with it — the report never\n * leaves the machine.\n *\n * ```ts\n * try {\n * await sendDigest();\n * } catch (error) {\n * await reportServerError(error, { context: \"cron/daily-digest\" });\n * throw error;\n * }\n * ```\n *\n * Returns `null` when nothing was filed — no token, development mode, or the\n * API refused it. Never throws.\n */\nexport async function reportServerError(\n error: unknown,\n options: ServerReportOptions = {}\n): Promise<ReportResult | null> {\n try {\n const token = options.token ?? defaults.token ?? readEnv(\"GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const enableInDevelopment = options.enableInDevelopment ?? defaults.enableInDevelopment ?? false;\n if (!enableInDevelopment && readEnv(\"NODE_ENV\") === DEV_BLOCKED_MESSAGE) return null;\n\n const { message, stack } = describeError(error);\n const context = options.context ?? defaults.context;\n const reporter = { ...defaults.reporter, ...options.reporter };\n\n const payload: ReportPayload = {\n token,\n source: \"SDK_AUTO\",\n type: options.type ?? \"BUG\",\n errorMessage: message,\n ...(stack ? { errorStack: stack } : {}),\n ...(options.description ? { description: options.description } : {}),\n ...(options.pageUrl ?? contextUrl(context)\n ? { pageUrl: options.pageUrl ?? contextUrl(context) }\n : {}),\n metadata: {\n ...runtimeMetadata(),\n ...defaults.metadata,\n ...options.metadata,\n ...(context ? { serverContext: context } : {}),\n ...(options.severity ? { severity: options.severity } : {}),\n sessionUserId: reporter.id ?? context ?? \"server\",\n sessionUserName: reporter.name ?? \"Server\",\n ...(reporter.email ? { sessionUserEmail: reporter.email } : {}),\n ...(reporter.phone ? { sessionUserPhone: reporter.phone } : {}),\n },\n };\n\n return await sendReport(payload, options.baseUrl ?? defaults.baseUrl ?? readEnv(\"GLITCHGRAB_BASE_URL\"));\n } catch {\n return null;\n }\n}\n\n/** Options for {@link captureServerErrors}. */\nexport interface CaptureServerErrorsOptions extends ServerReportOptions {\n /**\n * Also report rejected promises nobody handled. Default `true`.\n *\n * Node crashes the process on an unhandled rejection — but only while no\n * `unhandledRejection` listener exists. Attaching one silently converts a\n * crash into a warning, so this re-throws afterwards to put the default\n * behaviour back, unless the host app has its own listener (then it is theirs\n * to decide).\n */\n captureUnhandledRejections?: boolean;\n}\n\n/**\n * Report every uncaught exception and unhandled rejection in this process.\n *\n * Call it once at boot — `instrumentation.ts` in a Next.js app. Returns a\n * function that removes the listeners again.\n *\n * Uncaught exceptions use `uncaughtExceptionMonitor`, which observes without\n * taking over: the process still crashes exactly as it would have. A reporter\n * that keeps a broken process alive is worse than no reporter.\n */\nexport function captureServerErrors(options: CaptureServerErrorsOptions = {}): () => void {\n try {\n const proc = nodeProcess();\n if (!proc || typeof proc.on !== \"function\") return () => {};\n\n const onException = (error: unknown) => {\n void reportServerError(error, { ...options, context: options.context ?? \"uncaughtException\" });\n };\n\n const onRejection = (reason: unknown) => {\n void reportServerError(reason, {\n ...options,\n context: options.context ?? \"unhandledRejection\",\n });\n\n // Ours is the only listener, so suppressing the crash was never the\n // host app's choice. Hand the default behaviour back.\n if (proc.listenerCount?.(\"unhandledRejection\") === 1) {\n setTimeout(() => {\n throw reason;\n }, 0);\n }\n };\n\n proc.on(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n\n const captureRejections = options.captureUnhandledRejections ?? true;\n if (captureRejections) proc.on(\"unhandledRejection\", onRejection as (arg: never) => void);\n\n return () => {\n try {\n proc.off?.(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n if (captureRejections) {\n proc.off?.(\"unhandledRejection\", onRejection as (arg: never) => void);\n }\n } catch {\n // Nothing to undo.\n }\n };\n } catch {\n return () => {};\n }\n}\n\n/** Options for {@link signSession}. */\nexport interface SignSessionOptions {\n /** Your user's id — the same value you pass as `session.userId`. */\n userId: string;\n /** The repo's signing secret (`ggs_…`). Falls back to `process.env.GLITCHGRAB_SIGNING_SECRET`. */\n secret?: string;\n /** How long the signature stays valid, in seconds. Default one day. */\n ttlSeconds?: number;\n /** Clock override in epoch ms. For tests. */\n now?: number;\n}\n\n/**\n * Vouch for the logged-in user so the SDK's AI features work when the project\n * owner turned on signed users.\n *\n * The `gg_` token ships in your JavaScript, so on its own it proves nothing\n * about who is calling. Your server holds the signing secret; call this where\n * you build the `session` prop and pass the result as `session.signature`.\n * Render it per request so it refreshes before `ttlSeconds` runs out.\n *\n * Format: `v1.<exp>.<base64url HMAC-SHA256(secret, \"v1.<exp>.<userId>\")>`.\n *\n * Unlike the rest of this SDK it throws: it runs in your own server code, where\n * a missing secret is a deploy mistake you want to see — not a user who quietly\n * loses the AI features.\n */\nexport function signSession(options: SignSessionOptions): string {\n const userId = options?.userId;\n if (typeof userId !== \"string\" || userId.length === 0) {\n throw new Error(\"glitchgrab signSession: userId is required\");\n }\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;\n const secret = options.secret || env?.GLITCHGRAB_SIGNING_SECRET;\n if (!secret) {\n throw new Error(\n \"glitchgrab signSession: no signing secret — pass `secret` or set GLITCHGRAB_SIGNING_SECRET\"\n );\n }\n const ttl = options.ttlSeconds ?? 86_400;\n if (!Number.isFinite(ttl) || ttl <= 0) {\n throw new Error(\"glitchgrab signSession: ttlSeconds must be a positive number\");\n }\n const exp = Math.floor((options.now ?? Date.now()) / 1000) + Math.floor(ttl);\n const sig = createHmac(\"sha256\", secret).update(`v1.${exp}.${userId}`).digest(\"base64url\");\n return `v1.${exp}.${sig}`;\n}\n\n// ── Guides ───────────────────────────────────────────────────────────────────\n//\n// A team writes its how-tos in Glitchgrab and renders them on its own site from\n// `GET /api/v1/sdk/guides` (#370). Every site needs the same client — token,\n// caching, never failing the page, shape checks — so it lives here instead of\n// being rewritten, slightly differently, in each product (#409).\n\n/** One guide as the list returns it — no body. Dates are ISO strings: this came over JSON. */\nexport interface GuideSummary {\n slug: string;\n title: string;\n /** One line saying what the guide answers. */\n summary: string;\n category: string | null;\n /** The order set in Glitchgrab. The list already arrives sorted by it. */\n orderIndex: number;\n /** Always `\"PUBLISHED\"` with a `gg_` token; only the project's `ggc_` server key sees drafts. */\n status: \"DRAFT\" | \"PUBLISHED\";\n /** When someone last re-checked the steps against the product. */\n lastVerifiedAt: string | null;\n updatedAt: string;\n /**\n * Where this guide lives on your site: its own link, else\n * `<guides base URL>/<slug>`, else null when the repo sets neither.\n */\n link: string | null;\n}\n\n/** One guide with its body. */\nexport interface Guide extends GuideSummary {\n /** The markdown as written. */\n content: string;\n /**\n * `content` rendered by Glitchgrab: raw HTML in the markdown arrives as text,\n * only allowlisted attributes survive, and links keep only http(s), mailto\n * and relative targets. Headings carry `id=\"guide-<heading text>\"`, for a\n * table of contents or a link to a single step.\n *\n * Safe to render as-is. On a domain that holds your users' sessions, a second\n * sanitize pass of your own is cheap defence in depth — this SDK ships no\n * sanitizer so it stays dependency-free.\n */\n html: string;\n}\n\n/** Options for {@link listGuides} and {@link getGuide}. */\nexport interface GuideReadOptions {\n /**\n * Repo token (`gg_…`). Falls back to {@link configureServerReporter}'s token,\n * then `GLITCHGRAB_TOKEN`, then `NEXT_PUBLIC_GLITCHGRAB_TOKEN` — the token is\n * public, so the one your provider already uses is fine.\n *\n * Never pass the `ggc_` server key for a public page: it reads drafts too, and client calls.\n */\n token?: string;\n /** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Seconds Next.js may cache the response. Default 300; `0` reads fresh on\n * every request. The route is `force-dynamic`, so without this nothing is\n * cached at all. Ignored outside Next.js.\n */\n revalidate?: number;\n /** Give up and return empty after this many ms. Default 5000 — a slow Glitchgrab must not hang your render. */\n timeoutMs?: number;\n}\n\nconst GUIDES_REVALIDATE_SECONDS = 300;\nconst SDK_API_TIMEOUT_MS = 5000;\n\n/** `RequestInit` plus Next.js's cache option, without pulling Next's types into this entry. */\ntype NextFetchInit = RequestInit & { next?: { revalidate?: number } };\n\ninterface SdkApiResult {\n /** `data` from a successful response, else null. */\n data: unknown;\n /** Null on success; the route's own `error` when it sent one, so a caller can show why. */\n error: string | null;\n}\n\n/**\n * One request to a Glitchgrab SDK route. Never throws: a refusal, a timeout, a\n * network failure and unreadable JSON all come back as `error`.\n */\nasync function callSdkApi(\n path: string,\n token: string,\n options: { baseUrl?: string; timeoutMs?: number },\n init: { method?: \"GET\" | \"DELETE\"; cache: { revalidate: number } | \"no-store\" }\n): Promise<SdkApiResult> {\n const baseUrl = (\n options.baseUrl ??\n defaults.baseUrl ??\n readEnv(\"GLITCHGRAB_BASE_URL\") ??\n DEFAULT_BASE_URL\n ).replace(/\\/+$/, \"\");\n\n const controller = typeof AbortController === \"function\" ? new AbortController() : undefined;\n const timer = controller\n ? setTimeout(() => controller.abort(), options.timeoutMs ?? SDK_API_TIMEOUT_MS)\n : undefined;\n\n try {\n const request: NextFetchInit = {\n method: init.method ?? \"GET\",\n headers: { Authorization: `Bearer ${token}` },\n ...(init.cache === \"no-store\" ? { cache: \"no-store\" as const } : { next: { revalidate: init.cache.revalidate } }),\n ...(controller ? { signal: controller.signal } : {}),\n };\n const response = await fetch(`${baseUrl}${path}`, request);\n const body = (await response.json().catch(() => null)) as\n | { success?: unknown; data?: unknown; error?: unknown }\n | null;\n if (!response.ok || body?.success !== true) {\n return { data: null, error: typeof body?.error === \"string\" ? body.error : `HTTP ${response.status}` };\n }\n return { data: body.data ?? null, error: null };\n } catch {\n return { data: null, error: \"Could not reach Glitchgrab\" };\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\n/** `data` from the guides route, or null on any failure — no token, non-2xx, timeout, bad JSON. */\nasync function readGuidesApi(query: string, options: GuideReadOptions): Promise<unknown> {\n const token =\n options.token ??\n defaults.token ??\n readEnv(\"GLITCHGRAB_TOKEN\") ??\n readEnv(\"NEXT_PUBLIC_GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const revalidate =\n typeof options.revalidate === \"number\" && Number.isFinite(options.revalidate) && options.revalidate >= 0\n ? options.revalidate\n : GUIDES_REVALIDATE_SECONDS;\n\n const result = await callSdkApi(`/api/v1/sdk/guides${query}`, token, options, { cache: { revalidate } });\n return result.data;\n}\n\n/**\n * A guide summary out of whatever the API sent, or null when it is not one.\n * Only slug, title and summary are required — a page cannot render without\n * them. The rest default, so the returned type is honest either way.\n */\nfunction readGuideSummary(value: unknown): GuideSummary | null {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return null;\n const guide = value as Record<string, unknown>;\n if (\n typeof guide.slug !== \"string\" ||\n typeof guide.title !== \"string\" ||\n typeof guide.summary !== \"string\"\n ) {\n return null;\n }\n return {\n slug: guide.slug,\n title: guide.title,\n summary: guide.summary,\n category: typeof guide.category === \"string\" ? guide.category : null,\n orderIndex: typeof guide.orderIndex === \"number\" ? guide.orderIndex : 0,\n status: guide.status === \"DRAFT\" ? \"DRAFT\" : \"PUBLISHED\",\n lastVerifiedAt: typeof guide.lastVerifiedAt === \"string\" ? guide.lastVerifiedAt : null,\n updatedAt: typeof guide.updatedAt === \"string\" ? guide.updatedAt : \"\",\n link: typeof guide.link === \"string\" ? guide.link : null,\n };\n}\n\n/**\n * Every published guide for the token's repo, in the order set in Glitchgrab.\n * No bodies — this is the index page.\n *\n * ```ts\n * // app/guides/page.tsx\n * import { listGuides } from \"glitchgrab/server\";\n *\n * export default async function GuidesPage() {\n * const guides = await listGuides();\n * return guides.map((g) => <a key={g.slug} href={`/guides/${g.slug}`}>{g.title}</a>);\n * }\n * ```\n *\n * Returns `[]` on any failure. Never throws: a Glitchgrab outage leaves the\n * page empty instead of turning it into a 500.\n */\nexport async function listGuides(options: GuideReadOptions = {}): Promise<GuideSummary[]> {\n try {\n const data = await readGuidesApi(\"\", options);\n if (!Array.isArray(data)) return [];\n return data.map(readGuideSummary).filter((guide): guide is GuideSummary => guide !== null);\n } catch {\n return [];\n }\n}\n\n/**\n * One published guide with its markdown and rendered HTML.\n *\n * Returns `null` when there is no such guide and on any failure — call\n * `notFound()` on it. Never throws.\n */\nexport async function getGuide(slug: string, options: GuideReadOptions = {}): Promise<Guide | null> {\n try {\n const wanted = typeof slug === \"string\" ? slug.trim() : \"\";\n if (!wanted) return null;\n\n const data = await readGuidesApi(`?slug=${encodeURIComponent(wanted)}`, options);\n const summary = readGuideSummary(data);\n const body = data as { content?: unknown; html?: unknown } | null;\n if (!summary || typeof body?.html !== \"string\") return null;\n\n return {\n ...summary,\n content: typeof body.content === \"string\" ? body.content : \"\",\n html: body.html,\n };\n } catch {\n return null;\n }\n}\n\n// ── Calls ────────────────────────────────────────────────────────────────────\n//\n// Recorded client calls a project shared with its client (#416), for the team's\n// own admin panel. Unlike guides these are private: they need the project's\n// server key (`ggc_…`, API Tokens → Server keys), which lives on the server and never in a bundle, and\n// every response holds links that expire — so nothing here is cached.\n\n/** Someone on the call, as the recorder saw them. */\nexport interface CallParticipant {\n name: string | null;\n email: string | null;\n}\n\n/** One shared call as the list returns it — no transcript, no links. */\nexport interface CallListItem {\n id: string;\n title: string | null;\n startsAt: string | null;\n endsAt: string | null;\n durationSec: number | null;\n /** `\"bot\"` (joined the Meet) or `\"extension\"` (recorded from someone's Chrome). */\n recorder: string | null;\n /** Match a call to your customer by these emails. */\n participants: CallParticipant[];\n hasRecording: boolean;\n /** `IDLE`, `RUNNING`, `DONE` or `FAILED`. */\n transcriptStatus: string;\n /** One line on what the call was about, once Glitchgrab has read it. */\n overview: string | null;\n createdAt: string;\n}\n\nexport interface CallAudio {\n /**\n * `call` — a bot recording's single mixed track. An extension recording has\n * two instead: `client` (everyone else on the call) and `recorder` (the\n * person who recorded it).\n */\n track: \"call\" | \"client\" | \"recorder\";\n /** Plays for {@link Call.linksExpireInSeconds}. */\n url: string;\n}\n\nexport interface CallMoment {\n /** Milliseconds into the call — seek the audio here. */\n tMs: number | null;\n speaker: string | null;\n text: string;\n}\n\n/** One shared call in full. */\nexport interface Call extends CallListItem {\n meetUrl: string | null;\n audio: CallAudio[];\n /** Audio and screenshot links stop working after this many seconds. Read the call per render; never store them. */\n linksExpireInSeconds: number;\n transcript: string | null;\n summary: { overview: string; moments: CallMoment[] } | null;\n /** Screenshots grabbed during the call, a page at a time. `tMs` is on the transcript's clock. */\n frames: { total: number; offset: number; limit: number; items: { tMs: number; url: string }[] };\n}\n\n/** Options for {@link listCalls}, {@link getCall} and {@link deleteCall}. */\nexport interface CallsOptions {\n /**\n * The project's server key (`ggc_…`, API Tokens → Server keys). Falls back to `GLITCHGRAB_CALLS_KEY`.\n * Server only — never a `NEXT_PUBLIC_` variable: it plays and deletes calls.\n */\n key?: string;\n /** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /** Give up after this many ms. Default 5000. */\n timeoutMs?: number;\n}\n\nexport interface ListCallsOptions extends CallsOptions {\n /** Default 20, max 100. */\n limit?: number;\n /** ISO date: only calls recorded before it. Pass the last call's `createdAt` for the next page. */\n before?: string;\n}\n\nexport interface GetCallOptions extends CallsOptions {\n /** First screenshot to return. Default 0. */\n framesOffset?: number;\n /** Screenshots per page. Default 50, max 200, `0` for none. */\n framesLimit?: number;\n}\n\nexport type DeleteCallResult = { deleted: true } | { deleted: false; error: string };\n\nconst CALLS_KEY_PREFIX = \"ggc_\";\n\n/** Only a `ggc_` key. A `gg_` token here would only 401 — and means the public token sits where a secret belongs. */\nfunction callsKey(options: CallsOptions): string | null {\n const key = options.key ?? readEnv(\"GLITCHGRAB_CALLS_KEY\");\n return typeof key === \"string\" && key.startsWith(CALLS_KEY_PREFIX) ? key : null;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction stringOrNull(value: unknown): string | null {\n return typeof value === \"string\" ? value : null;\n}\n\nfunction numberOrNull(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nfunction readCallListItem(value: unknown): CallListItem | null {\n if (!isRecord(value) || typeof value.id !== \"string\") return null;\n return {\n id: value.id,\n title: stringOrNull(value.title),\n startsAt: stringOrNull(value.startsAt),\n endsAt: stringOrNull(value.endsAt),\n durationSec: numberOrNull(value.durationSec),\n recorder: stringOrNull(value.recorder),\n participants: Array.isArray(value.participants)\n ? value.participants.filter(isRecord).map((p) => ({ name: stringOrNull(p.name), email: stringOrNull(p.email) }))\n : [],\n hasRecording: value.hasRecording === true,\n transcriptStatus: typeof value.transcriptStatus === \"string\" ? value.transcriptStatus : \"IDLE\",\n overview: stringOrNull(value.overview),\n createdAt: typeof value.createdAt === \"string\" ? value.createdAt : \"\",\n };\n}\n\nconst AUDIO_TRACKS: ReadonlySet<string> = new Set([\"call\", \"client\", \"recorder\"]);\n\nfunction readCall(value: unknown): Call | null {\n const base = readCallListItem(value);\n if (!base || !isRecord(value)) return null;\n\n const rawSummary = value.summary;\n const summary =\n isRecord(rawSummary) && typeof rawSummary.overview === \"string\"\n ? {\n overview: rawSummary.overview,\n moments: Array.isArray(rawSummary.moments)\n ? rawSummary.moments\n .filter(isRecord)\n .filter((m) => typeof m.text === \"string\")\n .map((m) => ({ tMs: numberOrNull(m.tMs), speaker: stringOrNull(m.speaker), text: m.text as string }))\n : [],\n }\n : null;\n\n const frames = isRecord(value.frames) ? value.frames : {};\n\n return {\n ...base,\n meetUrl: stringOrNull(value.meetUrl),\n audio: Array.isArray(value.audio)\n ? value.audio\n .filter(isRecord)\n .filter((a) => typeof a.url === \"string\" && typeof a.track === \"string\" && AUDIO_TRACKS.has(a.track))\n .map((a) => ({ track: a.track as CallAudio[\"track\"], url: a.url as string }))\n : [],\n linksExpireInSeconds: numberOrNull(value.linksExpireInSeconds) ?? 0,\n transcript: stringOrNull(value.transcript),\n summary,\n frames: {\n total: numberOrNull(frames.total) ?? 0,\n offset: numberOrNull(frames.offset) ?? 0,\n limit: numberOrNull(frames.limit) ?? 0,\n items: Array.isArray(frames.items)\n ? frames.items\n .filter(isRecord)\n .filter((f) => typeof f.url === \"string\" && typeof f.tMs === \"number\")\n .map((f) => ({ tMs: f.tMs as number, url: f.url as string }))\n : [],\n },\n };\n}\n\nfunction setNumber(query: URLSearchParams, name: string, value: unknown): void {\n if (typeof value === \"number\" && Number.isFinite(value)) query.set(name, String(Math.floor(value)));\n}\n\n/**\n * Calls shared with the client on the key's project, newest first. No links —\n * open one with {@link getCall}.\n *\n * ```ts\n * // app/admin/calls/page.tsx — server component\n * import { listCalls } from \"glitchgrab/server\";\n *\n * export default async function CallsPage() {\n * const calls = await listCalls(); // GLITCHGRAB_CALLS_KEY=ggc_…\n * return calls.map((c) => <a key={c.id} href={`/admin/calls/${c.id}`}>{c.title}</a>);\n * }\n * ```\n *\n * Returns `[]` on any failure. Never throws.\n */\nexport async function listCalls(options: ListCallsOptions = {}): Promise<CallListItem[]> {\n try {\n const key = callsKey(options);\n if (!key) return [];\n\n const query = new URLSearchParams();\n setNumber(query, \"limit\", options.limit);\n if (typeof options.before === \"string\" && options.before) query.set(\"before\", options.before);\n const qs = query.toString();\n\n const { data } = await callSdkApi(`/api/v1/sdk/calls${qs ? `?${qs}` : \"\"}`, key, options, { cache: \"no-store\" });\n if (!Array.isArray(data)) return [];\n return data.map(readCallListItem).filter((call): call is CallListItem => call !== null);\n } catch {\n return [];\n }\n}\n\n/**\n * One shared call: audio links, transcript, summary and a page of screenshots.\n * Links expire after `linksExpireInSeconds` — read the call when the page\n * renders and never store them.\n *\n * Returns `null` when the call does not exist, is not shared, and on any\n * failure — call `notFound()` on it. Never throws.\n */\nexport async function getCall(id: string, options: GetCallOptions = {}): Promise<Call | null> {\n try {\n const wanted = typeof id === \"string\" ? id.trim() : \"\";\n const key = callsKey(options);\n if (!wanted || !key) return null;\n\n const query = new URLSearchParams({ id: wanted });\n setNumber(query, \"framesOffset\", options.framesOffset);\n setNumber(query, \"framesLimit\", options.framesLimit);\n\n const { data } = await callSdkApi(`/api/v1/sdk/calls?${query.toString()}`, key, options, { cache: \"no-store\" });\n return readCall(data);\n } catch {\n return null;\n }\n}\n\n/**\n * Permanently delete one shared call — the recording, the screenshots, the\n * transcript. Cannot be undone.\n *\n * Resolves `{ deleted: false, error }` instead of throwing: the reason is worth\n * showing (\"The bot is still in this call — delete it after the call ends\").\n */\nexport async function deleteCall(id: string, options: CallsOptions = {}): Promise<DeleteCallResult> {\n try {\n const wanted = typeof id === \"string\" ? id.trim() : \"\";\n if (!wanted) return { deleted: false, error: \"No call id\" };\n const key = callsKey(options);\n if (!key) return { deleted: false, error: \"No server key — set GLITCHGRAB_CALLS_KEY (ggc_…)\" };\n\n const { error } = await callSdkApi(`/api/v1/sdk/calls?id=${encodeURIComponent(wanted)}`, key, options, {\n method: \"DELETE\",\n cache: \"no-store\",\n });\n return error === null ? { deleted: true } : { deleted: false, error };\n } catch {\n return { deleted: false, error: \"Could not delete the call\" };\n }\n}\n\n/**\n * `JSON.stringify` that is safe inside `<script type=\"application/ld+json\">`.\n *\n * Guide titles and summaries are written outside your codebase, and\n * `JSON.stringify` leaves `<` alone — a title containing `</script>` closes the\n * tag and whatever follows runs as HTML. The JSON unicode escape for `<` is the same\n * character to a JSON parser and inert to the HTML one. `>`, `&` and the two\n * JavaScript line separators are escaped for the same reason.\n *\n * ```tsx\n * <script\n * type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: escapeJsonForScript({ \"@type\": \"HowTo\", name: guide.title }) }}\n * />\n * ```\n *\n * Never throws: a value JSON cannot represent (circular, `undefined`) becomes `null`.\n */\nexport function escapeJsonForScript(value: unknown): string {\n try {\n const json = JSON.stringify(value);\n if (json === undefined) return \"null\";\n return json\n .replace(/</g, \"\\\\u003c\")\n .replace(/>/g, \"\\\\u003e\")\n .replace(/&/g, \"\\\\u0026\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n } catch {\n return \"null\";\n }\n}\n\nexport type { ReportResult, ReportSeverity, ReportType } from \"./types\";\n","import type { RuntimeInfo } from \"./types\";\n\n/**\n * Runtime health at the moment of the crash. `deviceInfo` says what the machine\n * is; this says what shape it was in — a leak that ran the heap to its ceiling,\n * a 2G connection that starved a fetch, a bug that only shows up after ten\n * minutes on the page or on the fifth error in a row.\n */\n\nconst pageLoadedAt = Date.now();\nlet errorCount = 0;\n\n/** Bumped by every auto-captured error so a report says which one in the run it was. */\nexport function incrementErrorCount(): number {\n errorCount += 1;\n return errorCount;\n}\n\n/** Test-only — reset the per-session counter. */\nexport function resetErrorCount(): void {\n errorCount = 0;\n}\n\ninterface MemoryCapableWindow {\n memory?: { usedJSHeapSize?: number; jsHeapSizeLimit?: number };\n}\n\ninterface NetworkInformation {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\nconst toMb = (bytes: number | undefined): number | undefined =>\n typeof bytes === \"number\" ? Math.round((bytes / 1048576) * 10) / 10 : undefined;\n\nexport function captureRuntimeInfo(): RuntimeInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n\n // Chromium-only, and absent entirely under cross-origin isolation.\n const memory = (performance as unknown as MemoryCapableWindow)?.memory;\n // Chromium + Android only.\n const connection = (navigator as unknown as { connection?: NetworkInformation })?.connection;\n\n return {\n timeOnPageMs: Date.now() - pageLoadedAt,\n errorCount,\n visibility: typeof document !== \"undefined\" ? document.visibilityState : \"unknown\",\n ...(memory\n ? {\n jsHeapUsedMb: toMb(memory.usedJSHeapSize),\n jsHeapLimitMb: toMb(memory.jsHeapSizeLimit),\n }\n : {}),\n ...(connection\n ? {\n connectionType: connection.effectiveType,\n downlinkMbps: connection.downlink,\n rttMs: connection.rtt,\n saveData: connection.saveData,\n }\n : {}),\n };\n } catch {\n return null;\n }\n}\n","import type {\n CapturedContext,\n DeviceInfo,\n FeedbackPayload,\n FeedbackResult,\n ReportPayload,\n ReportResult,\n} from \"./types\";\n// The React-free subpath: utils.ts is also bundled into `glitchgrab/server`.\nimport { assistOffline, toAssistTurnResult } from \"@glitchgrab/report-ui/assist-response\";\nimport type { AssistTurnResult } from \"@glitchgrab/report-ui\";\nimport { getBreadcrumbs } from \"./breadcrumbs\";\nimport { captureRuntimeInfo } from \"./runtime\";\nimport { getAppContext, getRelease } from \"./app-context\";\n\nconst SENSITIVE_PARAMS = [\n \"token\", \"key\", \"secret\", \"password\", \"passwd\", \"auth\", \"authorization\",\n \"session\", \"sessionid\", \"session_id\", \"api_key\", \"apikey\", \"access_token\",\n \"refresh_token\", \"client_secret\", \"code\", \"state\", \"nonce\", \"credential\", \"private\",\n];\n\nexport function sanitizeUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const params = new URLSearchParams(parsed.search);\n let modified = false;\n for (const key of Array.from(params.keys())) {\n if (SENSITIVE_PARAMS.some((s) => key.toLowerCase().includes(s))) {\n params.set(key, \"[REDACTED]\");\n modified = true;\n }\n }\n if (modified) parsed.search = params.toString();\n return parsed.toString();\n } catch {\n return url.split(\"?\")[0] ?? url;\n }\n}\n\nexport function captureDeviceInfo(): DeviceInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n return {\n screenWidth: screen.width,\n screenHeight: screen.height,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n platform: navigator.platform ?? \"unknown\",\n language: navigator.language ?? \"unknown\",\n online: navigator.onLine,\n colorScheme: window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\",\n devicePixelRatio: window.devicePixelRatio ?? 1,\n };\n } catch {\n return null;\n }\n}\n\nexport function captureContext(visitedPages: string[]): CapturedContext {\n try {\n return {\n url: sanitizeUrl(typeof window !== \"undefined\" ? window.location.href : \"\"),\n userAgent: typeof navigator !== \"undefined\" ? navigator.userAgent : \"\",\n timestamp: new Date().toISOString(),\n visitedPages: visitedPages.map(sanitizeUrl),\n breadcrumbs: getBreadcrumbs(),\n deviceInfo: captureDeviceInfo(),\n runtime: captureRuntimeInfo(),\n appContext: getAppContext(),\n release: getRelease(),\n };\n } catch {\n return {\n url: \"\",\n userAgent: \"\",\n timestamp: new Date().toISOString(),\n visitedPages: [],\n breadcrumbs: [],\n deviceInfo: null,\n runtime: null,\n appContext: {},\n };\n }\n}\n\n/**\n * Metadata every report carries regardless of how it was raised — release,\n * runtime health, and the host app's own context. One builder so a user-filed\n * report and an auto-captured crash describe the same world; the payload sites\n * only add what is specific to them.\n *\n * App context is prefixed so a key named `timestamp` or `status` can't quietly\n * overwrite a field the dashboard relies on.\n */\nexport function contextMetadata(context: CapturedContext): Record<string, string> {\n try {\n const runtime = context.runtime;\n const appContext = Object.entries(context.appContext ?? {}).reduce<Record<string, string>>(\n (acc, [key, value]) => {\n acc[`ctx_${key}`] = value;\n return acc;\n },\n {}\n );\n\n return {\n ...(context.release ? { release: context.release } : {}),\n ...(runtime\n ? {\n timeOnPageMs: String(runtime.timeOnPageMs),\n errorCount: String(runtime.errorCount),\n visibility: runtime.visibility,\n ...(runtime.jsHeapUsedMb !== undefined\n ? { jsHeapUsedMb: String(runtime.jsHeapUsedMb) }\n : {}),\n ...(runtime.jsHeapLimitMb !== undefined\n ? { jsHeapLimitMb: String(runtime.jsHeapLimitMb) }\n : {}),\n ...(runtime.connectionType ? { connectionType: runtime.connectionType } : {}),\n ...(runtime.downlinkMbps !== undefined\n ? { downlinkMbps: String(runtime.downlinkMbps) }\n : {}),\n ...(runtime.rttMs !== undefined ? { rttMs: String(runtime.rttMs) } : {}),\n ...(runtime.saveData !== undefined ? { saveData: String(runtime.saveData) } : {}),\n }\n : {}),\n ...appContext,\n };\n } catch {\n return {};\n }\n}\n\nexport const DEFAULT_BASE_URL = \"https://glitchgrab.dev\";\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 500;\n\n/**\n * Send a report and return the result.\n * Never throws — returns null on failure.\n */\nexport async function sendReport(\n payload: ReportPayload,\n baseUrl?: string\n): Promise<ReportResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/report`;\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n };\n\n // keepalive has a 64KB body limit in browsers — skip it for large payloads (e.g., screenshots)\n const useKeepalive = body.length < 60_000;\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n ...(useKeepalive ? { keepalive: true } : {}),\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<ReportResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n // Fallback: sendBeacon (fire-and-forget, no result)\n if (typeof navigator !== \"undefined\" && navigator.sendBeacon) {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: \"application/json\" }));\n } catch {\n // Silently fail\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Save a star rating your end-user left about your app.\n * Never throws — returns null on failure.\n *\n * Unlike `sendReport`, this is always a deliberate user action with a dialog\n * open in front of them, so it retries on transient failure but skips the\n * sendBeacon fallback: the caller needs a real result to show a thank-you.\n */\nexport async function sendFeedback(\n payload: FeedbackPayload,\n baseUrl?: string\n): Promise<FeedbackResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/feedback`;\n const body = JSON.stringify(payload);\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n },\n body,\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<FeedbackResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n // 4xx other than rate limiting is a caller mistake — retrying won't help.\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\nexport interface EnhanceContext {\n url?: string;\n visitedPages?: string[];\n breadcrumbs?: Array<{ type: string; message: string }>;\n}\n\n/**\n * The signed-in user as the host's server vouched for them (`signSession`).\n *\n * Rides in the request BODY, never a header: every token route sets its own\n * CORS allow-list of headers, so a new header would fail preflight on all of\n * them. Omitted entirely when there is no signature — an unsigned id proves\n * nothing, and the server reads its absence as \"unsigned\".\n */\nexport interface SignedIdentity {\n userId?: string | null;\n signature?: string | null;\n}\n\n/** `{ userId, signature }` to spread into a JSON body, or nothing. */\nexport function signedFields(identity?: SignedIdentity | null): { userId?: string; signature?: string } {\n if (!identity?.signature || !identity.userId) return {};\n return { userId: identity.userId, signature: identity.signature };\n}\n\n/**\n * Polish raw description text via the Glitchgrab AI enhance endpoint.\n * Returns the polished text on success, or the original text on any failure.\n * Never throws.\n */\nexport async function enhanceText(\n text: string,\n token: string,\n baseUrl?: string,\n screenshot?: string | null,\n context?: EnhanceContext | null,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n const trimmed = text.trim();\n if (!trimmed) return text;\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/enhance-text`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n text: trimmed,\n ...(screenshot ? { screenshot } : {}),\n ...(context ? { context } : {}),\n ...signedFields(identity),\n }),\n });\n if (!response.ok) return text;\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { text?: string };\n };\n if (envelope?.success && typeof envelope.data?.text === \"string\") {\n return envelope.data.text;\n }\n return text;\n } catch {\n return text;\n }\n}\n\n/**\n * \"Is this already filed?\" for the report dialog's plain form. Asked when Send\n * is pressed; a match lets the reporter add to the open issue instead of\n * opening a second one.\n *\n * `[]` when the project has it off, `null` on any failure — the dialog sends\n * straight away on either. Never throws.\n */\nexport async function findSimilarIssues(\n description: string,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<{ number: number; title: string; url: string; status?: string }[] | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/reports/similar`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ text: description, ...signedFields(identity) }),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n data?: { enabled?: boolean; matches?: { number: number; title: string; url: string; status?: string }[] };\n } | null;\n if (!response.ok || !envelope?.success) return null;\n if (!envelope.data?.enabled) return [];\n return Array.isArray(envelope.data.matches) ? envelope.data.matches : [];\n } catch {\n return null;\n }\n}\n\n/**\n * One turn of the AI report assistant (#330).\n *\n * Same never-throw contract as everything else in the SDK: a failure comes back\n * as `degraded` and the dialog quietly falls back to the plain form. An\n * end-user of the host app must never see a Glitchgrab error, and must never be\n * blocked from filing a bug because a model was busy.\n */\nexport async function assistTurn(\n params: {\n messages: { role: \"user\" | \"assistant\"; content: string }[];\n conversationId: string | null;\n screenshot?: string | null;\n context?: Record<string, unknown> | null;\n /**\n * Who the host app says is logged in, from the provider's `session` prop.\n * Stored against the conversation so a chat and the report it produced\n * agree on who filed — one SDK token covers every end user of the host app,\n * so without this every stranger's chat looks like the same person.\n */\n reporter?: {\n key?: string | null;\n name?: string | null;\n email?: string | null;\n /** `signSession()` output for `key`; absent when the host did not sign. */\n signature?: string | null;\n };\n },\n token: string,\n baseUrl?: string\n): Promise<AssistTurnResult> {\n const offline = assistOffline();\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/report-chat`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(params),\n });\n // One mapping for every host (packages/report-ui/src/assist-response.ts):\n // the server's degrade reason passes through, and nothing the sheet renders\n // is dropped.\n return toAssistTurnResult(response.ok, await response.json().catch(() => null));\n } catch {\n return offline;\n }\n}\n\n/**\n * Send an audio Blob to the Glitchgrab STT proxy (which calls Sarvam).\n * Returns the transcript string, or \"\" on any failure. Never throws.\n */\nexport async function transcribeAudio(\n blob: Blob,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n if (blob.size === 0) return \"\";\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/stt`;\n const form = new FormData();\n form.append(\"file\", blob, \"audio.webm\");\n const signed = signedFields(identity);\n if (signed.userId && signed.signature) {\n form.append(\"userId\", signed.userId);\n form.append(\"signature\", signed.signature);\n }\n const response = await fetch(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n });\n if (!response.ok) return \"\";\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { transcript?: string };\n };\n if (envelope?.success && typeof envelope.data?.transcript === \"string\") {\n return envelope.data.transcript;\n }\n return \"\";\n } catch {\n return \"\";\n }\n}\n"],"mappings":"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","names":["_a"]}
|
package/dist/server.mjs
CHANGED
|
@@ -391,7 +391,7 @@ async function deleteCall(id, options = {}) {
|
|
|
391
391
|
const wanted = typeof id === "string" ? id.trim() : "";
|
|
392
392
|
if (!wanted) return { deleted: false, error: "No call id" };
|
|
393
393
|
const key = callsKey(options);
|
|
394
|
-
if (!key) return { deleted: false, error: "No
|
|
394
|
+
if (!key) return { deleted: false, error: "No server key \u2014 set GLITCHGRAB_CALLS_KEY (ggc_\u2026)" };
|
|
395
395
|
const { error } = await callSdkApi(`/api/v1/sdk/calls?id=${encodeURIComponent(wanted)}`, key, options, {
|
|
396
396
|
method: "DELETE",
|
|
397
397
|
cache: "no-store"
|
package/dist/server.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts","../src/runtime.ts","../src/utils.ts"],"sourcesContent":["/**\n * Server-side reporting — `import { reportServerError } from \"glitchgrab/server\"`.\n *\n * The rest of this SDK is `\"use client\"`: it hooks `window.onerror`, walks the\n * DOM for a screenshot, and needs a mounted provider for its token. None of\n * that exists in a cron job at 3am, an API route handler, or a queue worker —\n * which is precisely where the failures nobody is watching happen. A WhatsApp\n * send Meta rejects, an SMTP timeout, a nightly digest that throws: no tab is\n * open, so the browser SDK never sees them and no issue is ever filed.\n *\n * This entry has no `\"use client\"`, touches no DOM API, and imports no React.\n * It POSTs to the same `/api/v1/sdk/report` endpoint the browser uses, with\n * `source: \"SDK_AUTO\"` so the server-side dedup applies (24h per signature,\n * 7 days once an issue exists) — otherwise a cron failing hourly would open\n * twenty-four identical issues a day.\n *\n * Like the rest of the SDK it never throws: a reporting failure must not turn\n * a handled error into an unhandled one.\n */\nimport { createHmac } from \"node:crypto\";\nimport type { ReportPayload, ReportResult, ReportSeverity, ReportType } from \"./types\";\nimport { DEFAULT_BASE_URL, sendReport } from \"./utils\";\n\n/** Who to attribute the report to. A server has no signed-in user, so this is the job or service. */\nexport interface ServerReporterIdentity {\n /** Stable id — the cron name, the service name. Defaults to `\"server\"`. */\n id?: string;\n /** Display name shown on the report. Defaults to `\"Server\"`. */\n name?: string;\n email?: string;\n phone?: string;\n}\n\n/** Defaults applied to every server report. Set once at boot with {@link configureServerReporter}. */\nexport interface ServerReporterConfig {\n /** Repo token (`gg_…`). Falls back to `process.env.GLITCHGRAB_TOKEN`. */\n token?: string;\n /** Override the API host. Falls back to `process.env.GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Where this ran — `\"cron/attendance-prompts\"`, `\"worker/email-queue\"`.\n *\n * It is the grouping key, not a link: it is sent as the report's `pageUrl`\n * so two different jobs throwing the same `\"Timeout\"` stay two issues\n * instead of collapsing into one.\n */\n context?: string;\n /** Merged into every report's metadata. Good place for region, deployment id, service name. */\n metadata?: Record<string, string>;\n reporter?: ServerReporterIdentity;\n /**\n * Report from `NODE_ENV=development` too. Off by default, matching the\n * browser SDK — a laptop crashing its way through a refactor should not file\n * issues. Unlike the browser there is no `Origin` header for the API to\n * recognise, so nothing else stops a dev machine from filing.\n */\n enableInDevelopment?: boolean;\n}\n\n/** Per-call options. Anything set here wins over {@link configureServerReporter}. */\nexport interface ServerReportOptions extends ServerReporterConfig {\n /** Issue type. Defaults to `BUG`. */\n type?: ReportType;\n /** Rendered as a `severity:<value>` label on the GitHub issue. */\n severity?: ReportSeverity;\n /** Extra prose for the issue body — what the job was doing, which record it was on. */\n description?: string;\n /**\n * A real request URL, when there is one (an API route handler). Overrides\n * the synthetic URL built from `context`.\n */\n pageUrl?: string;\n}\n\nconst DEV_BLOCKED_MESSAGE = \"development\";\n\n/**\n * Node's `process`, as much of it as this file uses.\n *\n * `src/global.d.ts` declares a browser-shaped `process` with nothing but\n * `env`, and that is deliberate — the client bundle must not be able to reach\n * for Node APIs. So this entry reads the real one off `globalThis` behind its\n * own narrow type instead of pulling `@types/node` into the package.\n */\ninterface NodeProcessLike {\n env?: Record<string, string | undefined>;\n version?: string;\n on?(event: string, listener: (arg: never) => void): unknown;\n off?(event: string, listener: (arg: never) => void): unknown;\n listenerCount?(event: string): number;\n}\n\nfunction nodeProcess(): NodeProcessLike | undefined {\n try {\n const candidate = (globalThis as { process?: NodeProcessLike }).process;\n return candidate && typeof candidate === \"object\" ? candidate : undefined;\n } catch {\n return undefined;\n }\n}\n\nlet defaults: ServerReporterConfig = {};\n\n/**\n * Set the token and the defaults once, at module load, instead of passing them\n * to every call site. A later call's own options still win.\n */\nexport function configureServerReporter(config: ServerReporterConfig): void {\n defaults = { ...defaults, ...config };\n}\n\n/** Test-only — drop everything {@link configureServerReporter} set. */\nexport function resetServerReporter(): void {\n defaults = {};\n}\n\nfunction readEnv(name: string): string | undefined {\n try {\n return nodeProcess()?.env?.[name];\n } catch {\n return undefined;\n }\n}\n\n/**\n * Message and stack out of whatever was thrown.\n *\n * A thrown string, a rejected `{ code, message }`, an `undefined` from a\n * `Promise.reject()` — all of it reaches here, and all of it must produce a\n * non-empty message: the API's dedup signature is `null` without one, and a\n * report with no signature is a fresh issue every single time.\n */\nfunction describeError(error: unknown): { message: string; stack?: string } {\n try {\n if (error instanceof Error) {\n return {\n message: error.message || error.name || \"Unknown server error\",\n stack: error.stack,\n };\n }\n if (typeof error === \"string\") {\n return { message: error || \"Unknown server error\" };\n }\n if (error && typeof error === \"object\") {\n const maybe = error as { message?: unknown; stack?: unknown };\n const message = typeof maybe.message === \"string\" ? maybe.message : JSON.stringify(error);\n return {\n message: message || \"Unknown server error\",\n stack: typeof maybe.stack === \"string\" ? maybe.stack : undefined,\n };\n }\n return { message: String(error ?? \"\") || \"Unknown server error\" };\n } catch {\n return { message: \"Unknown server error\" };\n }\n}\n\n/**\n * `pageUrl` doubles as the dedup grouping key, so a job needs one even though\n * no page was involved. `server://` keeps it obviously synthetic — and keeps it\n * clear of the API's localhost check, which only matches http(s) and would\n * otherwise swallow the report with a 200 and create nothing.\n */\nfunction contextUrl(context: string | undefined): string | undefined {\n if (!context) return undefined;\n return `server://${context.replace(/^\\/+/, \"\")}`;\n}\n\nfunction runtimeMetadata(): Record<string, string> {\n try {\n const proc = nodeProcess();\n if (!proc) return { runtime: \"server\" };\n const region = proc.env?.VERCEL_REGION;\n const deploymentEnv = proc.env?.VERCEL_ENV;\n return {\n runtime: \"node\",\n ...(proc.version ? { nodeVersion: proc.version } : {}),\n ...(region ? { region } : {}),\n ...(deploymentEnv ? { deploymentEnv } : {}),\n };\n } catch {\n return { runtime: \"server\" };\n }\n}\n\n/**\n * File a server-side error as a GitHub issue.\n *\n * Await it. On a serverless platform the function may be frozen the moment your\n * handler returns, and a floating promise dies with it — the report never\n * leaves the machine.\n *\n * ```ts\n * try {\n * await sendDigest();\n * } catch (error) {\n * await reportServerError(error, { context: \"cron/daily-digest\" });\n * throw error;\n * }\n * ```\n *\n * Returns `null` when nothing was filed — no token, development mode, or the\n * API refused it. Never throws.\n */\nexport async function reportServerError(\n error: unknown,\n options: ServerReportOptions = {}\n): Promise<ReportResult | null> {\n try {\n const token = options.token ?? defaults.token ?? readEnv(\"GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const enableInDevelopment = options.enableInDevelopment ?? defaults.enableInDevelopment ?? false;\n if (!enableInDevelopment && readEnv(\"NODE_ENV\") === DEV_BLOCKED_MESSAGE) return null;\n\n const { message, stack } = describeError(error);\n const context = options.context ?? defaults.context;\n const reporter = { ...defaults.reporter, ...options.reporter };\n\n const payload: ReportPayload = {\n token,\n source: \"SDK_AUTO\",\n type: options.type ?? \"BUG\",\n errorMessage: message,\n ...(stack ? { errorStack: stack } : {}),\n ...(options.description ? { description: options.description } : {}),\n ...(options.pageUrl ?? contextUrl(context)\n ? { pageUrl: options.pageUrl ?? contextUrl(context) }\n : {}),\n metadata: {\n ...runtimeMetadata(),\n ...defaults.metadata,\n ...options.metadata,\n ...(context ? { serverContext: context } : {}),\n ...(options.severity ? { severity: options.severity } : {}),\n sessionUserId: reporter.id ?? context ?? \"server\",\n sessionUserName: reporter.name ?? \"Server\",\n ...(reporter.email ? { sessionUserEmail: reporter.email } : {}),\n ...(reporter.phone ? { sessionUserPhone: reporter.phone } : {}),\n },\n };\n\n return await sendReport(payload, options.baseUrl ?? defaults.baseUrl ?? readEnv(\"GLITCHGRAB_BASE_URL\"));\n } catch {\n return null;\n }\n}\n\n/** Options for {@link captureServerErrors}. */\nexport interface CaptureServerErrorsOptions extends ServerReportOptions {\n /**\n * Also report rejected promises nobody handled. Default `true`.\n *\n * Node crashes the process on an unhandled rejection — but only while no\n * `unhandledRejection` listener exists. Attaching one silently converts a\n * crash into a warning, so this re-throws afterwards to put the default\n * behaviour back, unless the host app has its own listener (then it is theirs\n * to decide).\n */\n captureUnhandledRejections?: boolean;\n}\n\n/**\n * Report every uncaught exception and unhandled rejection in this process.\n *\n * Call it once at boot — `instrumentation.ts` in a Next.js app. Returns a\n * function that removes the listeners again.\n *\n * Uncaught exceptions use `uncaughtExceptionMonitor`, which observes without\n * taking over: the process still crashes exactly as it would have. A reporter\n * that keeps a broken process alive is worse than no reporter.\n */\nexport function captureServerErrors(options: CaptureServerErrorsOptions = {}): () => void {\n try {\n const proc = nodeProcess();\n if (!proc || typeof proc.on !== \"function\") return () => {};\n\n const onException = (error: unknown) => {\n void reportServerError(error, { ...options, context: options.context ?? \"uncaughtException\" });\n };\n\n const onRejection = (reason: unknown) => {\n void reportServerError(reason, {\n ...options,\n context: options.context ?? \"unhandledRejection\",\n });\n\n // Ours is the only listener, so suppressing the crash was never the\n // host app's choice. Hand the default behaviour back.\n if (proc.listenerCount?.(\"unhandledRejection\") === 1) {\n setTimeout(() => {\n throw reason;\n }, 0);\n }\n };\n\n proc.on(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n\n const captureRejections = options.captureUnhandledRejections ?? true;\n if (captureRejections) proc.on(\"unhandledRejection\", onRejection as (arg: never) => void);\n\n return () => {\n try {\n proc.off?.(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n if (captureRejections) {\n proc.off?.(\"unhandledRejection\", onRejection as (arg: never) => void);\n }\n } catch {\n // Nothing to undo.\n }\n };\n } catch {\n return () => {};\n }\n}\n\n/** Options for {@link signSession}. */\nexport interface SignSessionOptions {\n /** Your user's id — the same value you pass as `session.userId`. */\n userId: string;\n /** The repo's signing secret (`ggs_…`). Falls back to `process.env.GLITCHGRAB_SIGNING_SECRET`. */\n secret?: string;\n /** How long the signature stays valid, in seconds. Default one day. */\n ttlSeconds?: number;\n /** Clock override in epoch ms. For tests. */\n now?: number;\n}\n\n/**\n * Vouch for the logged-in user so the SDK's AI features work when the project\n * owner turned on signed users.\n *\n * The `gg_` token ships in your JavaScript, so on its own it proves nothing\n * about who is calling. Your server holds the signing secret; call this where\n * you build the `session` prop and pass the result as `session.signature`.\n * Render it per request so it refreshes before `ttlSeconds` runs out.\n *\n * Format: `v1.<exp>.<base64url HMAC-SHA256(secret, \"v1.<exp>.<userId>\")>`.\n *\n * Unlike the rest of this SDK it throws: it runs in your own server code, where\n * a missing secret is a deploy mistake you want to see — not a user who quietly\n * loses the AI features.\n */\nexport function signSession(options: SignSessionOptions): string {\n const userId = options?.userId;\n if (typeof userId !== \"string\" || userId.length === 0) {\n throw new Error(\"glitchgrab signSession: userId is required\");\n }\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;\n const secret = options.secret || env?.GLITCHGRAB_SIGNING_SECRET;\n if (!secret) {\n throw new Error(\n \"glitchgrab signSession: no signing secret — pass `secret` or set GLITCHGRAB_SIGNING_SECRET\"\n );\n }\n const ttl = options.ttlSeconds ?? 86_400;\n if (!Number.isFinite(ttl) || ttl <= 0) {\n throw new Error(\"glitchgrab signSession: ttlSeconds must be a positive number\");\n }\n const exp = Math.floor((options.now ?? Date.now()) / 1000) + Math.floor(ttl);\n const sig = createHmac(\"sha256\", secret).update(`v1.${exp}.${userId}`).digest(\"base64url\");\n return `v1.${exp}.${sig}`;\n}\n\n// ── Guides ───────────────────────────────────────────────────────────────────\n//\n// A team writes its how-tos in Glitchgrab and renders them on its own site from\n// `GET /api/v1/sdk/guides` (#370). Every site needs the same client — token,\n// caching, never failing the page, shape checks — so it lives here instead of\n// being rewritten, slightly differently, in each product (#409).\n\n/** One guide as the list returns it — no body. Dates are ISO strings: this came over JSON. */\nexport interface GuideSummary {\n slug: string;\n title: string;\n /** One line saying what the guide answers. */\n summary: string;\n category: string | null;\n /** The order set in Glitchgrab. The list already arrives sorted by it. */\n orderIndex: number;\n /** Always `\"PUBLISHED\"` with a `gg_` token; only a `ggw_` write key sees drafts. */\n status: \"DRAFT\" | \"PUBLISHED\";\n /** When someone last re-checked the steps against the product. */\n lastVerifiedAt: string | null;\n updatedAt: string;\n /**\n * Where this guide lives on your site: its own link, else\n * `<guides base URL>/<slug>`, else null when the repo sets neither.\n */\n link: string | null;\n}\n\n/** One guide with its body. */\nexport interface Guide extends GuideSummary {\n /** The markdown as written. */\n content: string;\n /**\n * `content` rendered by Glitchgrab: raw HTML in the markdown arrives as text,\n * only allowlisted attributes survive, and links keep only http(s), mailto\n * and relative targets. Headings carry `id=\"guide-<heading text>\"`, for a\n * table of contents or a link to a single step.\n *\n * Safe to render as-is. On a domain that holds your users' sessions, a second\n * sanitize pass of your own is cheap defence in depth — this SDK ships no\n * sanitizer so it stays dependency-free.\n */\n html: string;\n}\n\n/** Options for {@link listGuides} and {@link getGuide}. */\nexport interface GuideReadOptions {\n /**\n * Repo token (`gg_…`). Falls back to {@link configureServerReporter}'s token,\n * then `GLITCHGRAB_TOKEN`, then `NEXT_PUBLIC_GLITCHGRAB_TOKEN` — the token is\n * public, so the one your provider already uses is fine.\n *\n * Never pass the `ggw_` guides write key for a public page: it reads drafts too.\n */\n token?: string;\n /** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Seconds Next.js may cache the response. Default 300; `0` reads fresh on\n * every request. The route is `force-dynamic`, so without this nothing is\n * cached at all. Ignored outside Next.js.\n */\n revalidate?: number;\n /** Give up and return empty after this many ms. Default 5000 — a slow Glitchgrab must not hang your render. */\n timeoutMs?: number;\n}\n\nconst GUIDES_REVALIDATE_SECONDS = 300;\nconst SDK_API_TIMEOUT_MS = 5000;\n\n/** `RequestInit` plus Next.js's cache option, without pulling Next's types into this entry. */\ntype NextFetchInit = RequestInit & { next?: { revalidate?: number } };\n\ninterface SdkApiResult {\n /** `data` from a successful response, else null. */\n data: unknown;\n /** Null on success; the route's own `error` when it sent one, so a caller can show why. */\n error: string | null;\n}\n\n/**\n * One request to a Glitchgrab SDK route. Never throws: a refusal, a timeout, a\n * network failure and unreadable JSON all come back as `error`.\n */\nasync function callSdkApi(\n path: string,\n token: string,\n options: { baseUrl?: string; timeoutMs?: number },\n init: { method?: \"GET\" | \"DELETE\"; cache: { revalidate: number } | \"no-store\" }\n): Promise<SdkApiResult> {\n const baseUrl = (\n options.baseUrl ??\n defaults.baseUrl ??\n readEnv(\"GLITCHGRAB_BASE_URL\") ??\n DEFAULT_BASE_URL\n ).replace(/\\/+$/, \"\");\n\n const controller = typeof AbortController === \"function\" ? new AbortController() : undefined;\n const timer = controller\n ? setTimeout(() => controller.abort(), options.timeoutMs ?? SDK_API_TIMEOUT_MS)\n : undefined;\n\n try {\n const request: NextFetchInit = {\n method: init.method ?? \"GET\",\n headers: { Authorization: `Bearer ${token}` },\n ...(init.cache === \"no-store\" ? { cache: \"no-store\" as const } : { next: { revalidate: init.cache.revalidate } }),\n ...(controller ? { signal: controller.signal } : {}),\n };\n const response = await fetch(`${baseUrl}${path}`, request);\n const body = (await response.json().catch(() => null)) as\n | { success?: unknown; data?: unknown; error?: unknown }\n | null;\n if (!response.ok || body?.success !== true) {\n return { data: null, error: typeof body?.error === \"string\" ? body.error : `HTTP ${response.status}` };\n }\n return { data: body.data ?? null, error: null };\n } catch {\n return { data: null, error: \"Could not reach Glitchgrab\" };\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\n/** `data` from the guides route, or null on any failure — no token, non-2xx, timeout, bad JSON. */\nasync function readGuidesApi(query: string, options: GuideReadOptions): Promise<unknown> {\n const token =\n options.token ??\n defaults.token ??\n readEnv(\"GLITCHGRAB_TOKEN\") ??\n readEnv(\"NEXT_PUBLIC_GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const revalidate =\n typeof options.revalidate === \"number\" && Number.isFinite(options.revalidate) && options.revalidate >= 0\n ? options.revalidate\n : GUIDES_REVALIDATE_SECONDS;\n\n const result = await callSdkApi(`/api/v1/sdk/guides${query}`, token, options, { cache: { revalidate } });\n return result.data;\n}\n\n/**\n * A guide summary out of whatever the API sent, or null when it is not one.\n * Only slug, title and summary are required — a page cannot render without\n * them. The rest default, so the returned type is honest either way.\n */\nfunction readGuideSummary(value: unknown): GuideSummary | null {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return null;\n const guide = value as Record<string, unknown>;\n if (\n typeof guide.slug !== \"string\" ||\n typeof guide.title !== \"string\" ||\n typeof guide.summary !== \"string\"\n ) {\n return null;\n }\n return {\n slug: guide.slug,\n title: guide.title,\n summary: guide.summary,\n category: typeof guide.category === \"string\" ? guide.category : null,\n orderIndex: typeof guide.orderIndex === \"number\" ? guide.orderIndex : 0,\n status: guide.status === \"DRAFT\" ? \"DRAFT\" : \"PUBLISHED\",\n lastVerifiedAt: typeof guide.lastVerifiedAt === \"string\" ? guide.lastVerifiedAt : null,\n updatedAt: typeof guide.updatedAt === \"string\" ? guide.updatedAt : \"\",\n link: typeof guide.link === \"string\" ? guide.link : null,\n };\n}\n\n/**\n * Every published guide for the token's repo, in the order set in Glitchgrab.\n * No bodies — this is the index page.\n *\n * ```ts\n * // app/guides/page.tsx\n * import { listGuides } from \"glitchgrab/server\";\n *\n * export default async function GuidesPage() {\n * const guides = await listGuides();\n * return guides.map((g) => <a key={g.slug} href={`/guides/${g.slug}`}>{g.title}</a>);\n * }\n * ```\n *\n * Returns `[]` on any failure. Never throws: a Glitchgrab outage leaves the\n * page empty instead of turning it into a 500.\n */\nexport async function listGuides(options: GuideReadOptions = {}): Promise<GuideSummary[]> {\n try {\n const data = await readGuidesApi(\"\", options);\n if (!Array.isArray(data)) return [];\n return data.map(readGuideSummary).filter((guide): guide is GuideSummary => guide !== null);\n } catch {\n return [];\n }\n}\n\n/**\n * One published guide with its markdown and rendered HTML.\n *\n * Returns `null` when there is no such guide and on any failure — call\n * `notFound()` on it. Never throws.\n */\nexport async function getGuide(slug: string, options: GuideReadOptions = {}): Promise<Guide | null> {\n try {\n const wanted = typeof slug === \"string\" ? slug.trim() : \"\";\n if (!wanted) return null;\n\n const data = await readGuidesApi(`?slug=${encodeURIComponent(wanted)}`, options);\n const summary = readGuideSummary(data);\n const body = data as { content?: unknown; html?: unknown } | null;\n if (!summary || typeof body?.html !== \"string\") return null;\n\n return {\n ...summary,\n content: typeof body.content === \"string\" ? body.content : \"\",\n html: body.html,\n };\n } catch {\n return null;\n }\n}\n\n// ── Calls ────────────────────────────────────────────────────────────────────\n//\n// Recorded client calls a project shared with its client (#416), for the team's\n// own admin panel. Unlike guides these are private: they need the project's\n// calls key (`ggc_…`), which lives on the server and never in a bundle, and\n// every response holds links that expire — so nothing here is cached.\n\n/** Someone on the call, as the recorder saw them. */\nexport interface CallParticipant {\n name: string | null;\n email: string | null;\n}\n\n/** One shared call as the list returns it — no transcript, no links. */\nexport interface CallListItem {\n id: string;\n title: string | null;\n startsAt: string | null;\n endsAt: string | null;\n durationSec: number | null;\n /** `\"bot\"` (joined the Meet) or `\"extension\"` (recorded from someone's Chrome). */\n recorder: string | null;\n /** Match a call to your customer by these emails. */\n participants: CallParticipant[];\n hasRecording: boolean;\n /** `IDLE`, `RUNNING`, `DONE` or `FAILED`. */\n transcriptStatus: string;\n /** One line on what the call was about, once Glitchgrab has read it. */\n overview: string | null;\n createdAt: string;\n}\n\nexport interface CallAudio {\n /**\n * `call` — a bot recording's single mixed track. An extension recording has\n * two instead: `client` (everyone else on the call) and `recorder` (the\n * person who recorded it).\n */\n track: \"call\" | \"client\" | \"recorder\";\n /** Plays for {@link Call.linksExpireInSeconds}. */\n url: string;\n}\n\nexport interface CallMoment {\n /** Milliseconds into the call — seek the audio here. */\n tMs: number | null;\n speaker: string | null;\n text: string;\n}\n\n/** One shared call in full. */\nexport interface Call extends CallListItem {\n meetUrl: string | null;\n audio: CallAudio[];\n /** Audio and screenshot links stop working after this many seconds. Read the call per render; never store them. */\n linksExpireInSeconds: number;\n transcript: string | null;\n summary: { overview: string; moments: CallMoment[] } | null;\n /** Screenshots grabbed during the call, a page at a time. `tMs` is on the transcript's clock. */\n frames: { total: number; offset: number; limit: number; items: { tMs: number; url: string }[] };\n}\n\n/** Options for {@link listCalls}, {@link getCall} and {@link deleteCall}. */\nexport interface CallsOptions {\n /**\n * The project's calls key (`ggc_…`). Falls back to `GLITCHGRAB_CALLS_KEY`.\n * Server only — never a `NEXT_PUBLIC_` variable: it plays and deletes calls.\n */\n key?: string;\n /** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /** Give up after this many ms. Default 5000. */\n timeoutMs?: number;\n}\n\nexport interface ListCallsOptions extends CallsOptions {\n /** Default 20, max 100. */\n limit?: number;\n /** ISO date: only calls recorded before it. Pass the last call's `createdAt` for the next page. */\n before?: string;\n}\n\nexport interface GetCallOptions extends CallsOptions {\n /** First screenshot to return. Default 0. */\n framesOffset?: number;\n /** Screenshots per page. Default 50, max 200, `0` for none. */\n framesLimit?: number;\n}\n\nexport type DeleteCallResult = { deleted: true } | { deleted: false; error: string };\n\nconst CALLS_KEY_PREFIX = \"ggc_\";\n\n/** Only a `ggc_` key. A `gg_` token here would only 401 — and means the public token sits where a secret belongs. */\nfunction callsKey(options: CallsOptions): string | null {\n const key = options.key ?? readEnv(\"GLITCHGRAB_CALLS_KEY\");\n return typeof key === \"string\" && key.startsWith(CALLS_KEY_PREFIX) ? key : null;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction stringOrNull(value: unknown): string | null {\n return typeof value === \"string\" ? value : null;\n}\n\nfunction numberOrNull(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nfunction readCallListItem(value: unknown): CallListItem | null {\n if (!isRecord(value) || typeof value.id !== \"string\") return null;\n return {\n id: value.id,\n title: stringOrNull(value.title),\n startsAt: stringOrNull(value.startsAt),\n endsAt: stringOrNull(value.endsAt),\n durationSec: numberOrNull(value.durationSec),\n recorder: stringOrNull(value.recorder),\n participants: Array.isArray(value.participants)\n ? value.participants.filter(isRecord).map((p) => ({ name: stringOrNull(p.name), email: stringOrNull(p.email) }))\n : [],\n hasRecording: value.hasRecording === true,\n transcriptStatus: typeof value.transcriptStatus === \"string\" ? value.transcriptStatus : \"IDLE\",\n overview: stringOrNull(value.overview),\n createdAt: typeof value.createdAt === \"string\" ? value.createdAt : \"\",\n };\n}\n\nconst AUDIO_TRACKS: ReadonlySet<string> = new Set([\"call\", \"client\", \"recorder\"]);\n\nfunction readCall(value: unknown): Call | null {\n const base = readCallListItem(value);\n if (!base || !isRecord(value)) return null;\n\n const rawSummary = value.summary;\n const summary =\n isRecord(rawSummary) && typeof rawSummary.overview === \"string\"\n ? {\n overview: rawSummary.overview,\n moments: Array.isArray(rawSummary.moments)\n ? rawSummary.moments\n .filter(isRecord)\n .filter((m) => typeof m.text === \"string\")\n .map((m) => ({ tMs: numberOrNull(m.tMs), speaker: stringOrNull(m.speaker), text: m.text as string }))\n : [],\n }\n : null;\n\n const frames = isRecord(value.frames) ? value.frames : {};\n\n return {\n ...base,\n meetUrl: stringOrNull(value.meetUrl),\n audio: Array.isArray(value.audio)\n ? value.audio\n .filter(isRecord)\n .filter((a) => typeof a.url === \"string\" && typeof a.track === \"string\" && AUDIO_TRACKS.has(a.track))\n .map((a) => ({ track: a.track as CallAudio[\"track\"], url: a.url as string }))\n : [],\n linksExpireInSeconds: numberOrNull(value.linksExpireInSeconds) ?? 0,\n transcript: stringOrNull(value.transcript),\n summary,\n frames: {\n total: numberOrNull(frames.total) ?? 0,\n offset: numberOrNull(frames.offset) ?? 0,\n limit: numberOrNull(frames.limit) ?? 0,\n items: Array.isArray(frames.items)\n ? frames.items\n .filter(isRecord)\n .filter((f) => typeof f.url === \"string\" && typeof f.tMs === \"number\")\n .map((f) => ({ tMs: f.tMs as number, url: f.url as string }))\n : [],\n },\n };\n}\n\nfunction setNumber(query: URLSearchParams, name: string, value: unknown): void {\n if (typeof value === \"number\" && Number.isFinite(value)) query.set(name, String(Math.floor(value)));\n}\n\n/**\n * Calls shared with the client on the key's project, newest first. No links —\n * open one with {@link getCall}.\n *\n * ```ts\n * // app/admin/calls/page.tsx — server component\n * import { listCalls } from \"glitchgrab/server\";\n *\n * export default async function CallsPage() {\n * const calls = await listCalls(); // GLITCHGRAB_CALLS_KEY=ggc_…\n * return calls.map((c) => <a key={c.id} href={`/admin/calls/${c.id}`}>{c.title}</a>);\n * }\n * ```\n *\n * Returns `[]` on any failure. Never throws.\n */\nexport async function listCalls(options: ListCallsOptions = {}): Promise<CallListItem[]> {\n try {\n const key = callsKey(options);\n if (!key) return [];\n\n const query = new URLSearchParams();\n setNumber(query, \"limit\", options.limit);\n if (typeof options.before === \"string\" && options.before) query.set(\"before\", options.before);\n const qs = query.toString();\n\n const { data } = await callSdkApi(`/api/v1/sdk/calls${qs ? `?${qs}` : \"\"}`, key, options, { cache: \"no-store\" });\n if (!Array.isArray(data)) return [];\n return data.map(readCallListItem).filter((call): call is CallListItem => call !== null);\n } catch {\n return [];\n }\n}\n\n/**\n * One shared call: audio links, transcript, summary and a page of screenshots.\n * Links expire after `linksExpireInSeconds` — read the call when the page\n * renders and never store them.\n *\n * Returns `null` when the call does not exist, is not shared, and on any\n * failure — call `notFound()` on it. Never throws.\n */\nexport async function getCall(id: string, options: GetCallOptions = {}): Promise<Call | null> {\n try {\n const wanted = typeof id === \"string\" ? id.trim() : \"\";\n const key = callsKey(options);\n if (!wanted || !key) return null;\n\n const query = new URLSearchParams({ id: wanted });\n setNumber(query, \"framesOffset\", options.framesOffset);\n setNumber(query, \"framesLimit\", options.framesLimit);\n\n const { data } = await callSdkApi(`/api/v1/sdk/calls?${query.toString()}`, key, options, { cache: \"no-store\" });\n return readCall(data);\n } catch {\n return null;\n }\n}\n\n/**\n * Permanently delete one shared call — the recording, the screenshots, the\n * transcript. Cannot be undone.\n *\n * Resolves `{ deleted: false, error }` instead of throwing: the reason is worth\n * showing (\"The bot is still in this call — delete it after the call ends\").\n */\nexport async function deleteCall(id: string, options: CallsOptions = {}): Promise<DeleteCallResult> {\n try {\n const wanted = typeof id === \"string\" ? id.trim() : \"\";\n if (!wanted) return { deleted: false, error: \"No call id\" };\n const key = callsKey(options);\n if (!key) return { deleted: false, error: \"No calls key — set GLITCHGRAB_CALLS_KEY (ggc_…)\" };\n\n const { error } = await callSdkApi(`/api/v1/sdk/calls?id=${encodeURIComponent(wanted)}`, key, options, {\n method: \"DELETE\",\n cache: \"no-store\",\n });\n return error === null ? { deleted: true } : { deleted: false, error };\n } catch {\n return { deleted: false, error: \"Could not delete the call\" };\n }\n}\n\n/**\n * `JSON.stringify` that is safe inside `<script type=\"application/ld+json\">`.\n *\n * Guide titles and summaries are written outside your codebase, and\n * `JSON.stringify` leaves `<` alone — a title containing `</script>` closes the\n * tag and whatever follows runs as HTML. The JSON unicode escape for `<` is the same\n * character to a JSON parser and inert to the HTML one. `>`, `&` and the two\n * JavaScript line separators are escaped for the same reason.\n *\n * ```tsx\n * <script\n * type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: escapeJsonForScript({ \"@type\": \"HowTo\", name: guide.title }) }}\n * />\n * ```\n *\n * Never throws: a value JSON cannot represent (circular, `undefined`) becomes `null`.\n */\nexport function escapeJsonForScript(value: unknown): string {\n try {\n const json = JSON.stringify(value);\n if (json === undefined) return \"null\";\n return json\n .replace(/</g, \"\\\\u003c\")\n .replace(/>/g, \"\\\\u003e\")\n .replace(/&/g, \"\\\\u0026\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n } catch {\n return \"null\";\n }\n}\n\nexport type { ReportResult, ReportSeverity, ReportType } from \"./types\";\n","import type { RuntimeInfo } from \"./types\";\n\n/**\n * Runtime health at the moment of the crash. `deviceInfo` says what the machine\n * is; this says what shape it was in — a leak that ran the heap to its ceiling,\n * a 2G connection that starved a fetch, a bug that only shows up after ten\n * minutes on the page or on the fifth error in a row.\n */\n\nconst pageLoadedAt = Date.now();\nlet errorCount = 0;\n\n/** Bumped by every auto-captured error so a report says which one in the run it was. */\nexport function incrementErrorCount(): number {\n errorCount += 1;\n return errorCount;\n}\n\n/** Test-only — reset the per-session counter. */\nexport function resetErrorCount(): void {\n errorCount = 0;\n}\n\ninterface MemoryCapableWindow {\n memory?: { usedJSHeapSize?: number; jsHeapSizeLimit?: number };\n}\n\ninterface NetworkInformation {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\nconst toMb = (bytes: number | undefined): number | undefined =>\n typeof bytes === \"number\" ? Math.round((bytes / 1048576) * 10) / 10 : undefined;\n\nexport function captureRuntimeInfo(): RuntimeInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n\n // Chromium-only, and absent entirely under cross-origin isolation.\n const memory = (performance as unknown as MemoryCapableWindow)?.memory;\n // Chromium + Android only.\n const connection = (navigator as unknown as { connection?: NetworkInformation })?.connection;\n\n return {\n timeOnPageMs: Date.now() - pageLoadedAt,\n errorCount,\n visibility: typeof document !== \"undefined\" ? document.visibilityState : \"unknown\",\n ...(memory\n ? {\n jsHeapUsedMb: toMb(memory.usedJSHeapSize),\n jsHeapLimitMb: toMb(memory.jsHeapSizeLimit),\n }\n : {}),\n ...(connection\n ? {\n connectionType: connection.effectiveType,\n downlinkMbps: connection.downlink,\n rttMs: connection.rtt,\n saveData: connection.saveData,\n }\n : {}),\n };\n } catch {\n return null;\n }\n}\n","import type {\n CapturedContext,\n DeviceInfo,\n FeedbackPayload,\n FeedbackResult,\n ReportPayload,\n ReportResult,\n} from \"./types\";\n// The React-free subpath: utils.ts is also bundled into `glitchgrab/server`.\nimport { assistOffline, toAssistTurnResult } from \"@glitchgrab/report-ui/assist-response\";\nimport type { AssistTurnResult } from \"@glitchgrab/report-ui\";\nimport { getBreadcrumbs } from \"./breadcrumbs\";\nimport { captureRuntimeInfo } from \"./runtime\";\nimport { getAppContext, getRelease } from \"./app-context\";\n\nconst SENSITIVE_PARAMS = [\n \"token\", \"key\", \"secret\", \"password\", \"passwd\", \"auth\", \"authorization\",\n \"session\", \"sessionid\", \"session_id\", \"api_key\", \"apikey\", \"access_token\",\n \"refresh_token\", \"client_secret\", \"code\", \"state\", \"nonce\", \"credential\", \"private\",\n];\n\nexport function sanitizeUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const params = new URLSearchParams(parsed.search);\n let modified = false;\n for (const key of Array.from(params.keys())) {\n if (SENSITIVE_PARAMS.some((s) => key.toLowerCase().includes(s))) {\n params.set(key, \"[REDACTED]\");\n modified = true;\n }\n }\n if (modified) parsed.search = params.toString();\n return parsed.toString();\n } catch {\n return url.split(\"?\")[0] ?? url;\n }\n}\n\nexport function captureDeviceInfo(): DeviceInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n return {\n screenWidth: screen.width,\n screenHeight: screen.height,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n platform: navigator.platform ?? \"unknown\",\n language: navigator.language ?? \"unknown\",\n online: navigator.onLine,\n colorScheme: window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\",\n devicePixelRatio: window.devicePixelRatio ?? 1,\n };\n } catch {\n return null;\n }\n}\n\nexport function captureContext(visitedPages: string[]): CapturedContext {\n try {\n return {\n url: sanitizeUrl(typeof window !== \"undefined\" ? window.location.href : \"\"),\n userAgent: typeof navigator !== \"undefined\" ? navigator.userAgent : \"\",\n timestamp: new Date().toISOString(),\n visitedPages: visitedPages.map(sanitizeUrl),\n breadcrumbs: getBreadcrumbs(),\n deviceInfo: captureDeviceInfo(),\n runtime: captureRuntimeInfo(),\n appContext: getAppContext(),\n release: getRelease(),\n };\n } catch {\n return {\n url: \"\",\n userAgent: \"\",\n timestamp: new Date().toISOString(),\n visitedPages: [],\n breadcrumbs: [],\n deviceInfo: null,\n runtime: null,\n appContext: {},\n };\n }\n}\n\n/**\n * Metadata every report carries regardless of how it was raised — release,\n * runtime health, and the host app's own context. One builder so a user-filed\n * report and an auto-captured crash describe the same world; the payload sites\n * only add what is specific to them.\n *\n * App context is prefixed so a key named `timestamp` or `status` can't quietly\n * overwrite a field the dashboard relies on.\n */\nexport function contextMetadata(context: CapturedContext): Record<string, string> {\n try {\n const runtime = context.runtime;\n const appContext = Object.entries(context.appContext ?? {}).reduce<Record<string, string>>(\n (acc, [key, value]) => {\n acc[`ctx_${key}`] = value;\n return acc;\n },\n {}\n );\n\n return {\n ...(context.release ? { release: context.release } : {}),\n ...(runtime\n ? {\n timeOnPageMs: String(runtime.timeOnPageMs),\n errorCount: String(runtime.errorCount),\n visibility: runtime.visibility,\n ...(runtime.jsHeapUsedMb !== undefined\n ? { jsHeapUsedMb: String(runtime.jsHeapUsedMb) }\n : {}),\n ...(runtime.jsHeapLimitMb !== undefined\n ? { jsHeapLimitMb: String(runtime.jsHeapLimitMb) }\n : {}),\n ...(runtime.connectionType ? { connectionType: runtime.connectionType } : {}),\n ...(runtime.downlinkMbps !== undefined\n ? { downlinkMbps: String(runtime.downlinkMbps) }\n : {}),\n ...(runtime.rttMs !== undefined ? { rttMs: String(runtime.rttMs) } : {}),\n ...(runtime.saveData !== undefined ? { saveData: String(runtime.saveData) } : {}),\n }\n : {}),\n ...appContext,\n };\n } catch {\n return {};\n }\n}\n\nexport const DEFAULT_BASE_URL = \"https://glitchgrab.dev\";\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 500;\n\n/**\n * Send a report and return the result.\n * Never throws — returns null on failure.\n */\nexport async function sendReport(\n payload: ReportPayload,\n baseUrl?: string\n): Promise<ReportResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/report`;\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n };\n\n // keepalive has a 64KB body limit in browsers — skip it for large payloads (e.g., screenshots)\n const useKeepalive = body.length < 60_000;\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n ...(useKeepalive ? { keepalive: true } : {}),\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<ReportResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n // Fallback: sendBeacon (fire-and-forget, no result)\n if (typeof navigator !== \"undefined\" && navigator.sendBeacon) {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: \"application/json\" }));\n } catch {\n // Silently fail\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Save a star rating your end-user left about your app.\n * Never throws — returns null on failure.\n *\n * Unlike `sendReport`, this is always a deliberate user action with a dialog\n * open in front of them, so it retries on transient failure but skips the\n * sendBeacon fallback: the caller needs a real result to show a thank-you.\n */\nexport async function sendFeedback(\n payload: FeedbackPayload,\n baseUrl?: string\n): Promise<FeedbackResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/feedback`;\n const body = JSON.stringify(payload);\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n },\n body,\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<FeedbackResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n // 4xx other than rate limiting is a caller mistake — retrying won't help.\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\nexport interface EnhanceContext {\n url?: string;\n visitedPages?: string[];\n breadcrumbs?: Array<{ type: string; message: string }>;\n}\n\n/**\n * The signed-in user as the host's server vouched for them (`signSession`).\n *\n * Rides in the request BODY, never a header: every token route sets its own\n * CORS allow-list of headers, so a new header would fail preflight on all of\n * them. Omitted entirely when there is no signature — an unsigned id proves\n * nothing, and the server reads its absence as \"unsigned\".\n */\nexport interface SignedIdentity {\n userId?: string | null;\n signature?: string | null;\n}\n\n/** `{ userId, signature }` to spread into a JSON body, or nothing. */\nexport function signedFields(identity?: SignedIdentity | null): { userId?: string; signature?: string } {\n if (!identity?.signature || !identity.userId) return {};\n return { userId: identity.userId, signature: identity.signature };\n}\n\n/**\n * Polish raw description text via the Glitchgrab AI enhance endpoint.\n * Returns the polished text on success, or the original text on any failure.\n * Never throws.\n */\nexport async function enhanceText(\n text: string,\n token: string,\n baseUrl?: string,\n screenshot?: string | null,\n context?: EnhanceContext | null,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n const trimmed = text.trim();\n if (!trimmed) return text;\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/enhance-text`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n text: trimmed,\n ...(screenshot ? { screenshot } : {}),\n ...(context ? { context } : {}),\n ...signedFields(identity),\n }),\n });\n if (!response.ok) return text;\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { text?: string };\n };\n if (envelope?.success && typeof envelope.data?.text === \"string\") {\n return envelope.data.text;\n }\n return text;\n } catch {\n return text;\n }\n}\n\n/**\n * \"Is this already filed?\" for the report dialog's plain form. Asked when Send\n * is pressed; a match lets the reporter add to the open issue instead of\n * opening a second one.\n *\n * `[]` when the project has it off, `null` on any failure — the dialog sends\n * straight away on either. Never throws.\n */\nexport async function findSimilarIssues(\n description: string,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<{ number: number; title: string; url: string; status?: string }[] | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/reports/similar`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ text: description, ...signedFields(identity) }),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n data?: { enabled?: boolean; matches?: { number: number; title: string; url: string; status?: string }[] };\n } | null;\n if (!response.ok || !envelope?.success) return null;\n if (!envelope.data?.enabled) return [];\n return Array.isArray(envelope.data.matches) ? envelope.data.matches : [];\n } catch {\n return null;\n }\n}\n\n/**\n * One turn of the AI report assistant (#330).\n *\n * Same never-throw contract as everything else in the SDK: a failure comes back\n * as `degraded` and the dialog quietly falls back to the plain form. An\n * end-user of the host app must never see a Glitchgrab error, and must never be\n * blocked from filing a bug because a model was busy.\n */\nexport async function assistTurn(\n params: {\n messages: { role: \"user\" | \"assistant\"; content: string }[];\n conversationId: string | null;\n screenshot?: string | null;\n context?: Record<string, unknown> | null;\n /**\n * Who the host app says is logged in, from the provider's `session` prop.\n * Stored against the conversation so a chat and the report it produced\n * agree on who filed — one SDK token covers every end user of the host app,\n * so without this every stranger's chat looks like the same person.\n */\n reporter?: {\n key?: string | null;\n name?: string | null;\n email?: string | null;\n /** `signSession()` output for `key`; absent when the host did not sign. */\n signature?: string | null;\n };\n },\n token: string,\n baseUrl?: string\n): Promise<AssistTurnResult> {\n const offline = assistOffline();\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/report-chat`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(params),\n });\n // One mapping for every host (packages/report-ui/src/assist-response.ts):\n // the server's degrade reason passes through, and nothing the sheet renders\n // is dropped.\n return toAssistTurnResult(response.ok, await response.json().catch(() => null));\n } catch {\n return offline;\n }\n}\n\n/**\n * Send an audio Blob to the Glitchgrab STT proxy (which calls Sarvam).\n * Returns the transcript string, or \"\" on any failure. Never throws.\n */\nexport async function transcribeAudio(\n blob: Blob,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n if (blob.size === 0) return \"\";\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/stt`;\n const form = new FormData();\n form.append(\"file\", blob, \"audio.webm\");\n const signed = signedFields(identity);\n if (signed.userId && signed.signature) {\n form.append(\"userId\", signed.userId);\n form.append(\"signature\", signed.signature);\n }\n const response = await fetch(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n });\n if (!response.ok) return \"\";\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { transcript?: string };\n };\n if (envelope?.success && typeof envelope.data?.transcript === \"string\") {\n return envelope.data.transcript;\n }\n return \"\";\n } catch {\n return \"\";\n }\n}\n"],"mappings":"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","names":["_a"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/runtime.ts","../src/utils.ts"],"sourcesContent":["/**\n * Server-side reporting — `import { reportServerError } from \"glitchgrab/server\"`.\n *\n * The rest of this SDK is `\"use client\"`: it hooks `window.onerror`, walks the\n * DOM for a screenshot, and needs a mounted provider for its token. None of\n * that exists in a cron job at 3am, an API route handler, or a queue worker —\n * which is precisely where the failures nobody is watching happen. A WhatsApp\n * send Meta rejects, an SMTP timeout, a nightly digest that throws: no tab is\n * open, so the browser SDK never sees them and no issue is ever filed.\n *\n * This entry has no `\"use client\"`, touches no DOM API, and imports no React.\n * It POSTs to the same `/api/v1/sdk/report` endpoint the browser uses, with\n * `source: \"SDK_AUTO\"` so the server-side dedup applies (24h per signature,\n * 7 days once an issue exists) — otherwise a cron failing hourly would open\n * twenty-four identical issues a day.\n *\n * Like the rest of the SDK it never throws: a reporting failure must not turn\n * a handled error into an unhandled one.\n */\nimport { createHmac } from \"node:crypto\";\nimport type { ReportPayload, ReportResult, ReportSeverity, ReportType } from \"./types\";\nimport { DEFAULT_BASE_URL, sendReport } from \"./utils\";\n\n/** Who to attribute the report to. A server has no signed-in user, so this is the job or service. */\nexport interface ServerReporterIdentity {\n /** Stable id — the cron name, the service name. Defaults to `\"server\"`. */\n id?: string;\n /** Display name shown on the report. Defaults to `\"Server\"`. */\n name?: string;\n email?: string;\n phone?: string;\n}\n\n/** Defaults applied to every server report. Set once at boot with {@link configureServerReporter}. */\nexport interface ServerReporterConfig {\n /** Repo token (`gg_…`). Falls back to `process.env.GLITCHGRAB_TOKEN`. */\n token?: string;\n /** Override the API host. Falls back to `process.env.GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Where this ran — `\"cron/attendance-prompts\"`, `\"worker/email-queue\"`.\n *\n * It is the grouping key, not a link: it is sent as the report's `pageUrl`\n * so two different jobs throwing the same `\"Timeout\"` stay two issues\n * instead of collapsing into one.\n */\n context?: string;\n /** Merged into every report's metadata. Good place for region, deployment id, service name. */\n metadata?: Record<string, string>;\n reporter?: ServerReporterIdentity;\n /**\n * Report from `NODE_ENV=development` too. Off by default, matching the\n * browser SDK — a laptop crashing its way through a refactor should not file\n * issues. Unlike the browser there is no `Origin` header for the API to\n * recognise, so nothing else stops a dev machine from filing.\n */\n enableInDevelopment?: boolean;\n}\n\n/** Per-call options. Anything set here wins over {@link configureServerReporter}. */\nexport interface ServerReportOptions extends ServerReporterConfig {\n /** Issue type. Defaults to `BUG`. */\n type?: ReportType;\n /** Rendered as a `severity:<value>` label on the GitHub issue. */\n severity?: ReportSeverity;\n /** Extra prose for the issue body — what the job was doing, which record it was on. */\n description?: string;\n /**\n * A real request URL, when there is one (an API route handler). Overrides\n * the synthetic URL built from `context`.\n */\n pageUrl?: string;\n}\n\nconst DEV_BLOCKED_MESSAGE = \"development\";\n\n/**\n * Node's `process`, as much of it as this file uses.\n *\n * `src/global.d.ts` declares a browser-shaped `process` with nothing but\n * `env`, and that is deliberate — the client bundle must not be able to reach\n * for Node APIs. So this entry reads the real one off `globalThis` behind its\n * own narrow type instead of pulling `@types/node` into the package.\n */\ninterface NodeProcessLike {\n env?: Record<string, string | undefined>;\n version?: string;\n on?(event: string, listener: (arg: never) => void): unknown;\n off?(event: string, listener: (arg: never) => void): unknown;\n listenerCount?(event: string): number;\n}\n\nfunction nodeProcess(): NodeProcessLike | undefined {\n try {\n const candidate = (globalThis as { process?: NodeProcessLike }).process;\n return candidate && typeof candidate === \"object\" ? candidate : undefined;\n } catch {\n return undefined;\n }\n}\n\nlet defaults: ServerReporterConfig = {};\n\n/**\n * Set the token and the defaults once, at module load, instead of passing them\n * to every call site. A later call's own options still win.\n */\nexport function configureServerReporter(config: ServerReporterConfig): void {\n defaults = { ...defaults, ...config };\n}\n\n/** Test-only — drop everything {@link configureServerReporter} set. */\nexport function resetServerReporter(): void {\n defaults = {};\n}\n\nfunction readEnv(name: string): string | undefined {\n try {\n return nodeProcess()?.env?.[name];\n } catch {\n return undefined;\n }\n}\n\n/**\n * Message and stack out of whatever was thrown.\n *\n * A thrown string, a rejected `{ code, message }`, an `undefined` from a\n * `Promise.reject()` — all of it reaches here, and all of it must produce a\n * non-empty message: the API's dedup signature is `null` without one, and a\n * report with no signature is a fresh issue every single time.\n */\nfunction describeError(error: unknown): { message: string; stack?: string } {\n try {\n if (error instanceof Error) {\n return {\n message: error.message || error.name || \"Unknown server error\",\n stack: error.stack,\n };\n }\n if (typeof error === \"string\") {\n return { message: error || \"Unknown server error\" };\n }\n if (error && typeof error === \"object\") {\n const maybe = error as { message?: unknown; stack?: unknown };\n const message = typeof maybe.message === \"string\" ? maybe.message : JSON.stringify(error);\n return {\n message: message || \"Unknown server error\",\n stack: typeof maybe.stack === \"string\" ? maybe.stack : undefined,\n };\n }\n return { message: String(error ?? \"\") || \"Unknown server error\" };\n } catch {\n return { message: \"Unknown server error\" };\n }\n}\n\n/**\n * `pageUrl` doubles as the dedup grouping key, so a job needs one even though\n * no page was involved. `server://` keeps it obviously synthetic — and keeps it\n * clear of the API's localhost check, which only matches http(s) and would\n * otherwise swallow the report with a 200 and create nothing.\n */\nfunction contextUrl(context: string | undefined): string | undefined {\n if (!context) return undefined;\n return `server://${context.replace(/^\\/+/, \"\")}`;\n}\n\nfunction runtimeMetadata(): Record<string, string> {\n try {\n const proc = nodeProcess();\n if (!proc) return { runtime: \"server\" };\n const region = proc.env?.VERCEL_REGION;\n const deploymentEnv = proc.env?.VERCEL_ENV;\n return {\n runtime: \"node\",\n ...(proc.version ? { nodeVersion: proc.version } : {}),\n ...(region ? { region } : {}),\n ...(deploymentEnv ? { deploymentEnv } : {}),\n };\n } catch {\n return { runtime: \"server\" };\n }\n}\n\n/**\n * File a server-side error as a GitHub issue.\n *\n * Await it. On a serverless platform the function may be frozen the moment your\n * handler returns, and a floating promise dies with it — the report never\n * leaves the machine.\n *\n * ```ts\n * try {\n * await sendDigest();\n * } catch (error) {\n * await reportServerError(error, { context: \"cron/daily-digest\" });\n * throw error;\n * }\n * ```\n *\n * Returns `null` when nothing was filed — no token, development mode, or the\n * API refused it. Never throws.\n */\nexport async function reportServerError(\n error: unknown,\n options: ServerReportOptions = {}\n): Promise<ReportResult | null> {\n try {\n const token = options.token ?? defaults.token ?? readEnv(\"GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const enableInDevelopment = options.enableInDevelopment ?? defaults.enableInDevelopment ?? false;\n if (!enableInDevelopment && readEnv(\"NODE_ENV\") === DEV_BLOCKED_MESSAGE) return null;\n\n const { message, stack } = describeError(error);\n const context = options.context ?? defaults.context;\n const reporter = { ...defaults.reporter, ...options.reporter };\n\n const payload: ReportPayload = {\n token,\n source: \"SDK_AUTO\",\n type: options.type ?? \"BUG\",\n errorMessage: message,\n ...(stack ? { errorStack: stack } : {}),\n ...(options.description ? { description: options.description } : {}),\n ...(options.pageUrl ?? contextUrl(context)\n ? { pageUrl: options.pageUrl ?? contextUrl(context) }\n : {}),\n metadata: {\n ...runtimeMetadata(),\n ...defaults.metadata,\n ...options.metadata,\n ...(context ? { serverContext: context } : {}),\n ...(options.severity ? { severity: options.severity } : {}),\n sessionUserId: reporter.id ?? context ?? \"server\",\n sessionUserName: reporter.name ?? \"Server\",\n ...(reporter.email ? { sessionUserEmail: reporter.email } : {}),\n ...(reporter.phone ? { sessionUserPhone: reporter.phone } : {}),\n },\n };\n\n return await sendReport(payload, options.baseUrl ?? defaults.baseUrl ?? readEnv(\"GLITCHGRAB_BASE_URL\"));\n } catch {\n return null;\n }\n}\n\n/** Options for {@link captureServerErrors}. */\nexport interface CaptureServerErrorsOptions extends ServerReportOptions {\n /**\n * Also report rejected promises nobody handled. Default `true`.\n *\n * Node crashes the process on an unhandled rejection — but only while no\n * `unhandledRejection` listener exists. Attaching one silently converts a\n * crash into a warning, so this re-throws afterwards to put the default\n * behaviour back, unless the host app has its own listener (then it is theirs\n * to decide).\n */\n captureUnhandledRejections?: boolean;\n}\n\n/**\n * Report every uncaught exception and unhandled rejection in this process.\n *\n * Call it once at boot — `instrumentation.ts` in a Next.js app. Returns a\n * function that removes the listeners again.\n *\n * Uncaught exceptions use `uncaughtExceptionMonitor`, which observes without\n * taking over: the process still crashes exactly as it would have. A reporter\n * that keeps a broken process alive is worse than no reporter.\n */\nexport function captureServerErrors(options: CaptureServerErrorsOptions = {}): () => void {\n try {\n const proc = nodeProcess();\n if (!proc || typeof proc.on !== \"function\") return () => {};\n\n const onException = (error: unknown) => {\n void reportServerError(error, { ...options, context: options.context ?? \"uncaughtException\" });\n };\n\n const onRejection = (reason: unknown) => {\n void reportServerError(reason, {\n ...options,\n context: options.context ?? \"unhandledRejection\",\n });\n\n // Ours is the only listener, so suppressing the crash was never the\n // host app's choice. Hand the default behaviour back.\n if (proc.listenerCount?.(\"unhandledRejection\") === 1) {\n setTimeout(() => {\n throw reason;\n }, 0);\n }\n };\n\n proc.on(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n\n const captureRejections = options.captureUnhandledRejections ?? true;\n if (captureRejections) proc.on(\"unhandledRejection\", onRejection as (arg: never) => void);\n\n return () => {\n try {\n proc.off?.(\"uncaughtExceptionMonitor\", onException as (arg: never) => void);\n if (captureRejections) {\n proc.off?.(\"unhandledRejection\", onRejection as (arg: never) => void);\n }\n } catch {\n // Nothing to undo.\n }\n };\n } catch {\n return () => {};\n }\n}\n\n/** Options for {@link signSession}. */\nexport interface SignSessionOptions {\n /** Your user's id — the same value you pass as `session.userId`. */\n userId: string;\n /** The repo's signing secret (`ggs_…`). Falls back to `process.env.GLITCHGRAB_SIGNING_SECRET`. */\n secret?: string;\n /** How long the signature stays valid, in seconds. Default one day. */\n ttlSeconds?: number;\n /** Clock override in epoch ms. For tests. */\n now?: number;\n}\n\n/**\n * Vouch for the logged-in user so the SDK's AI features work when the project\n * owner turned on signed users.\n *\n * The `gg_` token ships in your JavaScript, so on its own it proves nothing\n * about who is calling. Your server holds the signing secret; call this where\n * you build the `session` prop and pass the result as `session.signature`.\n * Render it per request so it refreshes before `ttlSeconds` runs out.\n *\n * Format: `v1.<exp>.<base64url HMAC-SHA256(secret, \"v1.<exp>.<userId>\")>`.\n *\n * Unlike the rest of this SDK it throws: it runs in your own server code, where\n * a missing secret is a deploy mistake you want to see — not a user who quietly\n * loses the AI features.\n */\nexport function signSession(options: SignSessionOptions): string {\n const userId = options?.userId;\n if (typeof userId !== \"string\" || userId.length === 0) {\n throw new Error(\"glitchgrab signSession: userId is required\");\n }\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;\n const secret = options.secret || env?.GLITCHGRAB_SIGNING_SECRET;\n if (!secret) {\n throw new Error(\n \"glitchgrab signSession: no signing secret — pass `secret` or set GLITCHGRAB_SIGNING_SECRET\"\n );\n }\n const ttl = options.ttlSeconds ?? 86_400;\n if (!Number.isFinite(ttl) || ttl <= 0) {\n throw new Error(\"glitchgrab signSession: ttlSeconds must be a positive number\");\n }\n const exp = Math.floor((options.now ?? Date.now()) / 1000) + Math.floor(ttl);\n const sig = createHmac(\"sha256\", secret).update(`v1.${exp}.${userId}`).digest(\"base64url\");\n return `v1.${exp}.${sig}`;\n}\n\n// ── Guides ───────────────────────────────────────────────────────────────────\n//\n// A team writes its how-tos in Glitchgrab and renders them on its own site from\n// `GET /api/v1/sdk/guides` (#370). Every site needs the same client — token,\n// caching, never failing the page, shape checks — so it lives here instead of\n// being rewritten, slightly differently, in each product (#409).\n\n/** One guide as the list returns it — no body. Dates are ISO strings: this came over JSON. */\nexport interface GuideSummary {\n slug: string;\n title: string;\n /** One line saying what the guide answers. */\n summary: string;\n category: string | null;\n /** The order set in Glitchgrab. The list already arrives sorted by it. */\n orderIndex: number;\n /** Always `\"PUBLISHED\"` with a `gg_` token; only the project's `ggc_` server key sees drafts. */\n status: \"DRAFT\" | \"PUBLISHED\";\n /** When someone last re-checked the steps against the product. */\n lastVerifiedAt: string | null;\n updatedAt: string;\n /**\n * Where this guide lives on your site: its own link, else\n * `<guides base URL>/<slug>`, else null when the repo sets neither.\n */\n link: string | null;\n}\n\n/** One guide with its body. */\nexport interface Guide extends GuideSummary {\n /** The markdown as written. */\n content: string;\n /**\n * `content` rendered by Glitchgrab: raw HTML in the markdown arrives as text,\n * only allowlisted attributes survive, and links keep only http(s), mailto\n * and relative targets. Headings carry `id=\"guide-<heading text>\"`, for a\n * table of contents or a link to a single step.\n *\n * Safe to render as-is. On a domain that holds your users' sessions, a second\n * sanitize pass of your own is cheap defence in depth — this SDK ships no\n * sanitizer so it stays dependency-free.\n */\n html: string;\n}\n\n/** Options for {@link listGuides} and {@link getGuide}. */\nexport interface GuideReadOptions {\n /**\n * Repo token (`gg_…`). Falls back to {@link configureServerReporter}'s token,\n * then `GLITCHGRAB_TOKEN`, then `NEXT_PUBLIC_GLITCHGRAB_TOKEN` — the token is\n * public, so the one your provider already uses is fine.\n *\n * Never pass the `ggc_` server key for a public page: it reads drafts too, and client calls.\n */\n token?: string;\n /** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /**\n * Seconds Next.js may cache the response. Default 300; `0` reads fresh on\n * every request. The route is `force-dynamic`, so without this nothing is\n * cached at all. Ignored outside Next.js.\n */\n revalidate?: number;\n /** Give up and return empty after this many ms. Default 5000 — a slow Glitchgrab must not hang your render. */\n timeoutMs?: number;\n}\n\nconst GUIDES_REVALIDATE_SECONDS = 300;\nconst SDK_API_TIMEOUT_MS = 5000;\n\n/** `RequestInit` plus Next.js's cache option, without pulling Next's types into this entry. */\ntype NextFetchInit = RequestInit & { next?: { revalidate?: number } };\n\ninterface SdkApiResult {\n /** `data` from a successful response, else null. */\n data: unknown;\n /** Null on success; the route's own `error` when it sent one, so a caller can show why. */\n error: string | null;\n}\n\n/**\n * One request to a Glitchgrab SDK route. Never throws: a refusal, a timeout, a\n * network failure and unreadable JSON all come back as `error`.\n */\nasync function callSdkApi(\n path: string,\n token: string,\n options: { baseUrl?: string; timeoutMs?: number },\n init: { method?: \"GET\" | \"DELETE\"; cache: { revalidate: number } | \"no-store\" }\n): Promise<SdkApiResult> {\n const baseUrl = (\n options.baseUrl ??\n defaults.baseUrl ??\n readEnv(\"GLITCHGRAB_BASE_URL\") ??\n DEFAULT_BASE_URL\n ).replace(/\\/+$/, \"\");\n\n const controller = typeof AbortController === \"function\" ? new AbortController() : undefined;\n const timer = controller\n ? setTimeout(() => controller.abort(), options.timeoutMs ?? SDK_API_TIMEOUT_MS)\n : undefined;\n\n try {\n const request: NextFetchInit = {\n method: init.method ?? \"GET\",\n headers: { Authorization: `Bearer ${token}` },\n ...(init.cache === \"no-store\" ? { cache: \"no-store\" as const } : { next: { revalidate: init.cache.revalidate } }),\n ...(controller ? { signal: controller.signal } : {}),\n };\n const response = await fetch(`${baseUrl}${path}`, request);\n const body = (await response.json().catch(() => null)) as\n | { success?: unknown; data?: unknown; error?: unknown }\n | null;\n if (!response.ok || body?.success !== true) {\n return { data: null, error: typeof body?.error === \"string\" ? body.error : `HTTP ${response.status}` };\n }\n return { data: body.data ?? null, error: null };\n } catch {\n return { data: null, error: \"Could not reach Glitchgrab\" };\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\n/** `data` from the guides route, or null on any failure — no token, non-2xx, timeout, bad JSON. */\nasync function readGuidesApi(query: string, options: GuideReadOptions): Promise<unknown> {\n const token =\n options.token ??\n defaults.token ??\n readEnv(\"GLITCHGRAB_TOKEN\") ??\n readEnv(\"NEXT_PUBLIC_GLITCHGRAB_TOKEN\");\n if (!token) return null;\n\n const revalidate =\n typeof options.revalidate === \"number\" && Number.isFinite(options.revalidate) && options.revalidate >= 0\n ? options.revalidate\n : GUIDES_REVALIDATE_SECONDS;\n\n const result = await callSdkApi(`/api/v1/sdk/guides${query}`, token, options, { cache: { revalidate } });\n return result.data;\n}\n\n/**\n * A guide summary out of whatever the API sent, or null when it is not one.\n * Only slug, title and summary are required — a page cannot render without\n * them. The rest default, so the returned type is honest either way.\n */\nfunction readGuideSummary(value: unknown): GuideSummary | null {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return null;\n const guide = value as Record<string, unknown>;\n if (\n typeof guide.slug !== \"string\" ||\n typeof guide.title !== \"string\" ||\n typeof guide.summary !== \"string\"\n ) {\n return null;\n }\n return {\n slug: guide.slug,\n title: guide.title,\n summary: guide.summary,\n category: typeof guide.category === \"string\" ? guide.category : null,\n orderIndex: typeof guide.orderIndex === \"number\" ? guide.orderIndex : 0,\n status: guide.status === \"DRAFT\" ? \"DRAFT\" : \"PUBLISHED\",\n lastVerifiedAt: typeof guide.lastVerifiedAt === \"string\" ? guide.lastVerifiedAt : null,\n updatedAt: typeof guide.updatedAt === \"string\" ? guide.updatedAt : \"\",\n link: typeof guide.link === \"string\" ? guide.link : null,\n };\n}\n\n/**\n * Every published guide for the token's repo, in the order set in Glitchgrab.\n * No bodies — this is the index page.\n *\n * ```ts\n * // app/guides/page.tsx\n * import { listGuides } from \"glitchgrab/server\";\n *\n * export default async function GuidesPage() {\n * const guides = await listGuides();\n * return guides.map((g) => <a key={g.slug} href={`/guides/${g.slug}`}>{g.title}</a>);\n * }\n * ```\n *\n * Returns `[]` on any failure. Never throws: a Glitchgrab outage leaves the\n * page empty instead of turning it into a 500.\n */\nexport async function listGuides(options: GuideReadOptions = {}): Promise<GuideSummary[]> {\n try {\n const data = await readGuidesApi(\"\", options);\n if (!Array.isArray(data)) return [];\n return data.map(readGuideSummary).filter((guide): guide is GuideSummary => guide !== null);\n } catch {\n return [];\n }\n}\n\n/**\n * One published guide with its markdown and rendered HTML.\n *\n * Returns `null` when there is no such guide and on any failure — call\n * `notFound()` on it. Never throws.\n */\nexport async function getGuide(slug: string, options: GuideReadOptions = {}): Promise<Guide | null> {\n try {\n const wanted = typeof slug === \"string\" ? slug.trim() : \"\";\n if (!wanted) return null;\n\n const data = await readGuidesApi(`?slug=${encodeURIComponent(wanted)}`, options);\n const summary = readGuideSummary(data);\n const body = data as { content?: unknown; html?: unknown } | null;\n if (!summary || typeof body?.html !== \"string\") return null;\n\n return {\n ...summary,\n content: typeof body.content === \"string\" ? body.content : \"\",\n html: body.html,\n };\n } catch {\n return null;\n }\n}\n\n// ── Calls ────────────────────────────────────────────────────────────────────\n//\n// Recorded client calls a project shared with its client (#416), for the team's\n// own admin panel. Unlike guides these are private: they need the project's\n// server key (`ggc_…`, API Tokens → Server keys), which lives on the server and never in a bundle, and\n// every response holds links that expire — so nothing here is cached.\n\n/** Someone on the call, as the recorder saw them. */\nexport interface CallParticipant {\n name: string | null;\n email: string | null;\n}\n\n/** One shared call as the list returns it — no transcript, no links. */\nexport interface CallListItem {\n id: string;\n title: string | null;\n startsAt: string | null;\n endsAt: string | null;\n durationSec: number | null;\n /** `\"bot\"` (joined the Meet) or `\"extension\"` (recorded from someone's Chrome). */\n recorder: string | null;\n /** Match a call to your customer by these emails. */\n participants: CallParticipant[];\n hasRecording: boolean;\n /** `IDLE`, `RUNNING`, `DONE` or `FAILED`. */\n transcriptStatus: string;\n /** One line on what the call was about, once Glitchgrab has read it. */\n overview: string | null;\n createdAt: string;\n}\n\nexport interface CallAudio {\n /**\n * `call` — a bot recording's single mixed track. An extension recording has\n * two instead: `client` (everyone else on the call) and `recorder` (the\n * person who recorded it).\n */\n track: \"call\" | \"client\" | \"recorder\";\n /** Plays for {@link Call.linksExpireInSeconds}. */\n url: string;\n}\n\nexport interface CallMoment {\n /** Milliseconds into the call — seek the audio here. */\n tMs: number | null;\n speaker: string | null;\n text: string;\n}\n\n/** One shared call in full. */\nexport interface Call extends CallListItem {\n meetUrl: string | null;\n audio: CallAudio[];\n /** Audio and screenshot links stop working after this many seconds. Read the call per render; never store them. */\n linksExpireInSeconds: number;\n transcript: string | null;\n summary: { overview: string; moments: CallMoment[] } | null;\n /** Screenshots grabbed during the call, a page at a time. `tMs` is on the transcript's clock. */\n frames: { total: number; offset: number; limit: number; items: { tMs: number; url: string }[] };\n}\n\n/** Options for {@link listCalls}, {@link getCall} and {@link deleteCall}. */\nexport interface CallsOptions {\n /**\n * The project's server key (`ggc_…`, API Tokens → Server keys). Falls back to `GLITCHGRAB_CALLS_KEY`.\n * Server only — never a `NEXT_PUBLIC_` variable: it plays and deletes calls.\n */\n key?: string;\n /** Override the API host. Falls back to `GLITCHGRAB_BASE_URL`, then `https://glitchgrab.dev`. */\n baseUrl?: string;\n /** Give up after this many ms. Default 5000. */\n timeoutMs?: number;\n}\n\nexport interface ListCallsOptions extends CallsOptions {\n /** Default 20, max 100. */\n limit?: number;\n /** ISO date: only calls recorded before it. Pass the last call's `createdAt` for the next page. */\n before?: string;\n}\n\nexport interface GetCallOptions extends CallsOptions {\n /** First screenshot to return. Default 0. */\n framesOffset?: number;\n /** Screenshots per page. Default 50, max 200, `0` for none. */\n framesLimit?: number;\n}\n\nexport type DeleteCallResult = { deleted: true } | { deleted: false; error: string };\n\nconst CALLS_KEY_PREFIX = \"ggc_\";\n\n/** Only a `ggc_` key. A `gg_` token here would only 401 — and means the public token sits where a secret belongs. */\nfunction callsKey(options: CallsOptions): string | null {\n const key = options.key ?? readEnv(\"GLITCHGRAB_CALLS_KEY\");\n return typeof key === \"string\" && key.startsWith(CALLS_KEY_PREFIX) ? key : null;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction stringOrNull(value: unknown): string | null {\n return typeof value === \"string\" ? value : null;\n}\n\nfunction numberOrNull(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nfunction readCallListItem(value: unknown): CallListItem | null {\n if (!isRecord(value) || typeof value.id !== \"string\") return null;\n return {\n id: value.id,\n title: stringOrNull(value.title),\n startsAt: stringOrNull(value.startsAt),\n endsAt: stringOrNull(value.endsAt),\n durationSec: numberOrNull(value.durationSec),\n recorder: stringOrNull(value.recorder),\n participants: Array.isArray(value.participants)\n ? value.participants.filter(isRecord).map((p) => ({ name: stringOrNull(p.name), email: stringOrNull(p.email) }))\n : [],\n hasRecording: value.hasRecording === true,\n transcriptStatus: typeof value.transcriptStatus === \"string\" ? value.transcriptStatus : \"IDLE\",\n overview: stringOrNull(value.overview),\n createdAt: typeof value.createdAt === \"string\" ? value.createdAt : \"\",\n };\n}\n\nconst AUDIO_TRACKS: ReadonlySet<string> = new Set([\"call\", \"client\", \"recorder\"]);\n\nfunction readCall(value: unknown): Call | null {\n const base = readCallListItem(value);\n if (!base || !isRecord(value)) return null;\n\n const rawSummary = value.summary;\n const summary =\n isRecord(rawSummary) && typeof rawSummary.overview === \"string\"\n ? {\n overview: rawSummary.overview,\n moments: Array.isArray(rawSummary.moments)\n ? rawSummary.moments\n .filter(isRecord)\n .filter((m) => typeof m.text === \"string\")\n .map((m) => ({ tMs: numberOrNull(m.tMs), speaker: stringOrNull(m.speaker), text: m.text as string }))\n : [],\n }\n : null;\n\n const frames = isRecord(value.frames) ? value.frames : {};\n\n return {\n ...base,\n meetUrl: stringOrNull(value.meetUrl),\n audio: Array.isArray(value.audio)\n ? value.audio\n .filter(isRecord)\n .filter((a) => typeof a.url === \"string\" && typeof a.track === \"string\" && AUDIO_TRACKS.has(a.track))\n .map((a) => ({ track: a.track as CallAudio[\"track\"], url: a.url as string }))\n : [],\n linksExpireInSeconds: numberOrNull(value.linksExpireInSeconds) ?? 0,\n transcript: stringOrNull(value.transcript),\n summary,\n frames: {\n total: numberOrNull(frames.total) ?? 0,\n offset: numberOrNull(frames.offset) ?? 0,\n limit: numberOrNull(frames.limit) ?? 0,\n items: Array.isArray(frames.items)\n ? frames.items\n .filter(isRecord)\n .filter((f) => typeof f.url === \"string\" && typeof f.tMs === \"number\")\n .map((f) => ({ tMs: f.tMs as number, url: f.url as string }))\n : [],\n },\n };\n}\n\nfunction setNumber(query: URLSearchParams, name: string, value: unknown): void {\n if (typeof value === \"number\" && Number.isFinite(value)) query.set(name, String(Math.floor(value)));\n}\n\n/**\n * Calls shared with the client on the key's project, newest first. No links —\n * open one with {@link getCall}.\n *\n * ```ts\n * // app/admin/calls/page.tsx — server component\n * import { listCalls } from \"glitchgrab/server\";\n *\n * export default async function CallsPage() {\n * const calls = await listCalls(); // GLITCHGRAB_CALLS_KEY=ggc_…\n * return calls.map((c) => <a key={c.id} href={`/admin/calls/${c.id}`}>{c.title}</a>);\n * }\n * ```\n *\n * Returns `[]` on any failure. Never throws.\n */\nexport async function listCalls(options: ListCallsOptions = {}): Promise<CallListItem[]> {\n try {\n const key = callsKey(options);\n if (!key) return [];\n\n const query = new URLSearchParams();\n setNumber(query, \"limit\", options.limit);\n if (typeof options.before === \"string\" && options.before) query.set(\"before\", options.before);\n const qs = query.toString();\n\n const { data } = await callSdkApi(`/api/v1/sdk/calls${qs ? `?${qs}` : \"\"}`, key, options, { cache: \"no-store\" });\n if (!Array.isArray(data)) return [];\n return data.map(readCallListItem).filter((call): call is CallListItem => call !== null);\n } catch {\n return [];\n }\n}\n\n/**\n * One shared call: audio links, transcript, summary and a page of screenshots.\n * Links expire after `linksExpireInSeconds` — read the call when the page\n * renders and never store them.\n *\n * Returns `null` when the call does not exist, is not shared, and on any\n * failure — call `notFound()` on it. Never throws.\n */\nexport async function getCall(id: string, options: GetCallOptions = {}): Promise<Call | null> {\n try {\n const wanted = typeof id === \"string\" ? id.trim() : \"\";\n const key = callsKey(options);\n if (!wanted || !key) return null;\n\n const query = new URLSearchParams({ id: wanted });\n setNumber(query, \"framesOffset\", options.framesOffset);\n setNumber(query, \"framesLimit\", options.framesLimit);\n\n const { data } = await callSdkApi(`/api/v1/sdk/calls?${query.toString()}`, key, options, { cache: \"no-store\" });\n return readCall(data);\n } catch {\n return null;\n }\n}\n\n/**\n * Permanently delete one shared call — the recording, the screenshots, the\n * transcript. Cannot be undone.\n *\n * Resolves `{ deleted: false, error }` instead of throwing: the reason is worth\n * showing (\"The bot is still in this call — delete it after the call ends\").\n */\nexport async function deleteCall(id: string, options: CallsOptions = {}): Promise<DeleteCallResult> {\n try {\n const wanted = typeof id === \"string\" ? id.trim() : \"\";\n if (!wanted) return { deleted: false, error: \"No call id\" };\n const key = callsKey(options);\n if (!key) return { deleted: false, error: \"No server key — set GLITCHGRAB_CALLS_KEY (ggc_…)\" };\n\n const { error } = await callSdkApi(`/api/v1/sdk/calls?id=${encodeURIComponent(wanted)}`, key, options, {\n method: \"DELETE\",\n cache: \"no-store\",\n });\n return error === null ? { deleted: true } : { deleted: false, error };\n } catch {\n return { deleted: false, error: \"Could not delete the call\" };\n }\n}\n\n/**\n * `JSON.stringify` that is safe inside `<script type=\"application/ld+json\">`.\n *\n * Guide titles and summaries are written outside your codebase, and\n * `JSON.stringify` leaves `<` alone — a title containing `</script>` closes the\n * tag and whatever follows runs as HTML. The JSON unicode escape for `<` is the same\n * character to a JSON parser and inert to the HTML one. `>`, `&` and the two\n * JavaScript line separators are escaped for the same reason.\n *\n * ```tsx\n * <script\n * type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: escapeJsonForScript({ \"@type\": \"HowTo\", name: guide.title }) }}\n * />\n * ```\n *\n * Never throws: a value JSON cannot represent (circular, `undefined`) becomes `null`.\n */\nexport function escapeJsonForScript(value: unknown): string {\n try {\n const json = JSON.stringify(value);\n if (json === undefined) return \"null\";\n return json\n .replace(/</g, \"\\\\u003c\")\n .replace(/>/g, \"\\\\u003e\")\n .replace(/&/g, \"\\\\u0026\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n } catch {\n return \"null\";\n }\n}\n\nexport type { ReportResult, ReportSeverity, ReportType } from \"./types\";\n","import type { RuntimeInfo } from \"./types\";\n\n/**\n * Runtime health at the moment of the crash. `deviceInfo` says what the machine\n * is; this says what shape it was in — a leak that ran the heap to its ceiling,\n * a 2G connection that starved a fetch, a bug that only shows up after ten\n * minutes on the page or on the fifth error in a row.\n */\n\nconst pageLoadedAt = Date.now();\nlet errorCount = 0;\n\n/** Bumped by every auto-captured error so a report says which one in the run it was. */\nexport function incrementErrorCount(): number {\n errorCount += 1;\n return errorCount;\n}\n\n/** Test-only — reset the per-session counter. */\nexport function resetErrorCount(): void {\n errorCount = 0;\n}\n\ninterface MemoryCapableWindow {\n memory?: { usedJSHeapSize?: number; jsHeapSizeLimit?: number };\n}\n\ninterface NetworkInformation {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\nconst toMb = (bytes: number | undefined): number | undefined =>\n typeof bytes === \"number\" ? Math.round((bytes / 1048576) * 10) / 10 : undefined;\n\nexport function captureRuntimeInfo(): RuntimeInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n\n // Chromium-only, and absent entirely under cross-origin isolation.\n const memory = (performance as unknown as MemoryCapableWindow)?.memory;\n // Chromium + Android only.\n const connection = (navigator as unknown as { connection?: NetworkInformation })?.connection;\n\n return {\n timeOnPageMs: Date.now() - pageLoadedAt,\n errorCount,\n visibility: typeof document !== \"undefined\" ? document.visibilityState : \"unknown\",\n ...(memory\n ? {\n jsHeapUsedMb: toMb(memory.usedJSHeapSize),\n jsHeapLimitMb: toMb(memory.jsHeapSizeLimit),\n }\n : {}),\n ...(connection\n ? {\n connectionType: connection.effectiveType,\n downlinkMbps: connection.downlink,\n rttMs: connection.rtt,\n saveData: connection.saveData,\n }\n : {}),\n };\n } catch {\n return null;\n }\n}\n","import type {\n CapturedContext,\n DeviceInfo,\n FeedbackPayload,\n FeedbackResult,\n ReportPayload,\n ReportResult,\n} from \"./types\";\n// The React-free subpath: utils.ts is also bundled into `glitchgrab/server`.\nimport { assistOffline, toAssistTurnResult } from \"@glitchgrab/report-ui/assist-response\";\nimport type { AssistTurnResult } from \"@glitchgrab/report-ui\";\nimport { getBreadcrumbs } from \"./breadcrumbs\";\nimport { captureRuntimeInfo } from \"./runtime\";\nimport { getAppContext, getRelease } from \"./app-context\";\n\nconst SENSITIVE_PARAMS = [\n \"token\", \"key\", \"secret\", \"password\", \"passwd\", \"auth\", \"authorization\",\n \"session\", \"sessionid\", \"session_id\", \"api_key\", \"apikey\", \"access_token\",\n \"refresh_token\", \"client_secret\", \"code\", \"state\", \"nonce\", \"credential\", \"private\",\n];\n\nexport function sanitizeUrl(url: string): string {\n try {\n const parsed = new URL(url);\n const params = new URLSearchParams(parsed.search);\n let modified = false;\n for (const key of Array.from(params.keys())) {\n if (SENSITIVE_PARAMS.some((s) => key.toLowerCase().includes(s))) {\n params.set(key, \"[REDACTED]\");\n modified = true;\n }\n }\n if (modified) parsed.search = params.toString();\n return parsed.toString();\n } catch {\n return url.split(\"?\")[0] ?? url;\n }\n}\n\nexport function captureDeviceInfo(): DeviceInfo | null {\n try {\n if (typeof window === \"undefined\") return null;\n return {\n screenWidth: screen.width,\n screenHeight: screen.height,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n platform: navigator.platform ?? \"unknown\",\n language: navigator.language ?? \"unknown\",\n online: navigator.onLine,\n colorScheme: window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\",\n devicePixelRatio: window.devicePixelRatio ?? 1,\n };\n } catch {\n return null;\n }\n}\n\nexport function captureContext(visitedPages: string[]): CapturedContext {\n try {\n return {\n url: sanitizeUrl(typeof window !== \"undefined\" ? window.location.href : \"\"),\n userAgent: typeof navigator !== \"undefined\" ? navigator.userAgent : \"\",\n timestamp: new Date().toISOString(),\n visitedPages: visitedPages.map(sanitizeUrl),\n breadcrumbs: getBreadcrumbs(),\n deviceInfo: captureDeviceInfo(),\n runtime: captureRuntimeInfo(),\n appContext: getAppContext(),\n release: getRelease(),\n };\n } catch {\n return {\n url: \"\",\n userAgent: \"\",\n timestamp: new Date().toISOString(),\n visitedPages: [],\n breadcrumbs: [],\n deviceInfo: null,\n runtime: null,\n appContext: {},\n };\n }\n}\n\n/**\n * Metadata every report carries regardless of how it was raised — release,\n * runtime health, and the host app's own context. One builder so a user-filed\n * report and an auto-captured crash describe the same world; the payload sites\n * only add what is specific to them.\n *\n * App context is prefixed so a key named `timestamp` or `status` can't quietly\n * overwrite a field the dashboard relies on.\n */\nexport function contextMetadata(context: CapturedContext): Record<string, string> {\n try {\n const runtime = context.runtime;\n const appContext = Object.entries(context.appContext ?? {}).reduce<Record<string, string>>(\n (acc, [key, value]) => {\n acc[`ctx_${key}`] = value;\n return acc;\n },\n {}\n );\n\n return {\n ...(context.release ? { release: context.release } : {}),\n ...(runtime\n ? {\n timeOnPageMs: String(runtime.timeOnPageMs),\n errorCount: String(runtime.errorCount),\n visibility: runtime.visibility,\n ...(runtime.jsHeapUsedMb !== undefined\n ? { jsHeapUsedMb: String(runtime.jsHeapUsedMb) }\n : {}),\n ...(runtime.jsHeapLimitMb !== undefined\n ? { jsHeapLimitMb: String(runtime.jsHeapLimitMb) }\n : {}),\n ...(runtime.connectionType ? { connectionType: runtime.connectionType } : {}),\n ...(runtime.downlinkMbps !== undefined\n ? { downlinkMbps: String(runtime.downlinkMbps) }\n : {}),\n ...(runtime.rttMs !== undefined ? { rttMs: String(runtime.rttMs) } : {}),\n ...(runtime.saveData !== undefined ? { saveData: String(runtime.saveData) } : {}),\n }\n : {}),\n ...appContext,\n };\n } catch {\n return {};\n }\n}\n\nexport const DEFAULT_BASE_URL = \"https://glitchgrab.dev\";\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 500;\n\n/**\n * Send a report and return the result.\n * Never throws — returns null on failure.\n */\nexport async function sendReport(\n payload: ReportPayload,\n baseUrl?: string\n): Promise<ReportResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/report`;\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n };\n\n // keepalive has a 64KB body limit in browsers — skip it for large payloads (e.g., screenshots)\n const useKeepalive = body.length < 60_000;\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n ...(useKeepalive ? { keepalive: true } : {}),\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<ReportResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n // Fallback: sendBeacon (fire-and-forget, no result)\n if (typeof navigator !== \"undefined\" && navigator.sendBeacon) {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: \"application/json\" }));\n } catch {\n // Silently fail\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Save a star rating your end-user left about your app.\n * Never throws — returns null on failure.\n *\n * Unlike `sendReport`, this is always a deliberate user action with a dialog\n * open in front of them, so it retries on transient failure but skips the\n * sendBeacon fallback: the caller needs a real result to show a thank-you.\n */\nexport async function sendFeedback(\n payload: FeedbackPayload,\n baseUrl?: string\n): Promise<FeedbackResult | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/feedback`;\n const body = JSON.stringify(payload);\n\n for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {\n try {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${payload.token}`,\n },\n body,\n });\n\n if (response.ok) {\n const envelope = (await response.json()) as {\n success: boolean;\n data?: Omit<FeedbackResult, \"success\">;\n };\n return { success: envelope.success, ...(envelope.data ?? {}) };\n }\n\n // 4xx other than rate limiting is a caller mistake — retrying won't help.\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n return null;\n }\n } catch {\n // Will retry\n }\n\n if (attempt < MAX_RETRIES - 1) {\n await new Promise((resolve) =>\n setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))\n );\n }\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\nexport interface EnhanceContext {\n url?: string;\n visitedPages?: string[];\n breadcrumbs?: Array<{ type: string; message: string }>;\n}\n\n/**\n * The signed-in user as the host's server vouched for them (`signSession`).\n *\n * Rides in the request BODY, never a header: every token route sets its own\n * CORS allow-list of headers, so a new header would fail preflight on all of\n * them. Omitted entirely when there is no signature — an unsigned id proves\n * nothing, and the server reads its absence as \"unsigned\".\n */\nexport interface SignedIdentity {\n userId?: string | null;\n signature?: string | null;\n}\n\n/** `{ userId, signature }` to spread into a JSON body, or nothing. */\nexport function signedFields(identity?: SignedIdentity | null): { userId?: string; signature?: string } {\n if (!identity?.signature || !identity.userId) return {};\n return { userId: identity.userId, signature: identity.signature };\n}\n\n/**\n * Polish raw description text via the Glitchgrab AI enhance endpoint.\n * Returns the polished text on success, or the original text on any failure.\n * Never throws.\n */\nexport async function enhanceText(\n text: string,\n token: string,\n baseUrl?: string,\n screenshot?: string | null,\n context?: EnhanceContext | null,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n const trimmed = text.trim();\n if (!trimmed) return text;\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/enhance-text`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({\n text: trimmed,\n ...(screenshot ? { screenshot } : {}),\n ...(context ? { context } : {}),\n ...signedFields(identity),\n }),\n });\n if (!response.ok) return text;\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { text?: string };\n };\n if (envelope?.success && typeof envelope.data?.text === \"string\") {\n return envelope.data.text;\n }\n return text;\n } catch {\n return text;\n }\n}\n\n/**\n * \"Is this already filed?\" for the report dialog's plain form. Asked when Send\n * is pressed; a match lets the reporter add to the open issue instead of\n * opening a second one.\n *\n * `[]` when the project has it off, `null` on any failure — the dialog sends\n * straight away on either. Never throws.\n */\nexport async function findSimilarIssues(\n description: string,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<{ number: number; title: string; url: string; status?: string }[] | null> {\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/reports/similar`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ text: description, ...signedFields(identity) }),\n });\n const envelope = (await response.json().catch(() => null)) as {\n success?: boolean;\n data?: { enabled?: boolean; matches?: { number: number; title: string; url: string; status?: string }[] };\n } | null;\n if (!response.ok || !envelope?.success) return null;\n if (!envelope.data?.enabled) return [];\n return Array.isArray(envelope.data.matches) ? envelope.data.matches : [];\n } catch {\n return null;\n }\n}\n\n/**\n * One turn of the AI report assistant (#330).\n *\n * Same never-throw contract as everything else in the SDK: a failure comes back\n * as `degraded` and the dialog quietly falls back to the plain form. An\n * end-user of the host app must never see a Glitchgrab error, and must never be\n * blocked from filing a bug because a model was busy.\n */\nexport async function assistTurn(\n params: {\n messages: { role: \"user\" | \"assistant\"; content: string }[];\n conversationId: string | null;\n screenshot?: string | null;\n context?: Record<string, unknown> | null;\n /**\n * Who the host app says is logged in, from the provider's `session` prop.\n * Stored against the conversation so a chat and the report it produced\n * agree on who filed — one SDK token covers every end user of the host app,\n * so without this every stranger's chat looks like the same person.\n */\n reporter?: {\n key?: string | null;\n name?: string | null;\n email?: string | null;\n /** `signSession()` output for `key`; absent when the host did not sign. */\n signature?: string | null;\n };\n },\n token: string,\n baseUrl?: string\n): Promise<AssistTurnResult> {\n const offline = assistOffline();\n try {\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/ai/report-chat`;\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(params),\n });\n // One mapping for every host (packages/report-ui/src/assist-response.ts):\n // the server's degrade reason passes through, and nothing the sheet renders\n // is dropped.\n return toAssistTurnResult(response.ok, await response.json().catch(() => null));\n } catch {\n return offline;\n }\n}\n\n/**\n * Send an audio Blob to the Glitchgrab STT proxy (which calls Sarvam).\n * Returns the transcript string, or \"\" on any failure. Never throws.\n */\nexport async function transcribeAudio(\n blob: Blob,\n token: string,\n baseUrl?: string,\n identity?: SignedIdentity | null\n): Promise<string> {\n try {\n if (blob.size === 0) return \"\";\n const url = `${baseUrl ?? DEFAULT_BASE_URL}/api/v1/sdk/stt`;\n const form = new FormData();\n form.append(\"file\", blob, \"audio.webm\");\n const signed = signedFields(identity);\n if (signed.userId && signed.signature) {\n form.append(\"userId\", signed.userId);\n form.append(\"signature\", signed.signature);\n }\n const response = await fetch(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: form,\n });\n if (!response.ok) return \"\";\n const envelope = (await response.json()) as {\n success: boolean;\n data?: { transcript?: string };\n };\n if (envelope?.success && typeof envelope.data?.transcript === \"string\") {\n return envelope.data.transcript;\n }\n return \"\";\n } catch {\n return \"\";\n }\n}\n"],"mappings":"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","names":["_a"]}
|