@synap-core/sdk 1.1.0 → 1.2.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/src/client.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * const synap = createSynapClient({
9
9
  * podUrl: "https://pod.example.com",
10
- * apiKey: "sk_live_...",
10
+ * sessionToken: "<Kratos session token>",
11
11
  * workspaceId: "ws_...",
12
12
  * });
13
13
  *
@@ -33,8 +33,8 @@ export interface SynapClientOptions {
33
33
  podUrl: string;
34
34
 
35
35
  /**
36
- * Ory Kratos session token (for example, from the Pod's
37
- * `/api/federation/exchange` endpoint).
36
+ * Ory Kratos session token (the `session_token` a Kratos API / native
37
+ * login flow returns).
38
38
  * Sent as `X-Session-Token` header — preferred for non-browser clients
39
39
  * like extensions, CLI tools, and mobile apps.
40
40
  * @example "oathkeeper_token_..."
@@ -42,10 +42,13 @@ export interface SynapClientOptions {
42
42
  sessionToken?: string;
43
43
 
44
44
  /**
45
- * API key for authentication (Bearer token).
46
- * Generate one in your workspace developer settings.
45
+ * LOCAL_MODE token only (the desktop host's `LOCAL_AUTH_TOKEN`), sent as
46
+ * `Authorization: Bearer`. `/trpc` sits behind Ory session auth, so a real
47
+ * pod refuses hub / user API keys (`synap_hub_*`, `synap_user_*`) with a 401;
48
+ * this client throws early if given one. Use `@synap-core/hub-rest-client`
49
+ * against `/api/hub/*` for hub / user API keys, or `sessionToken` / cookies
50
+ * for a signed-in user.
47
51
  * If omitted, the client falls back to cookie-based Kratos session auth.
48
- * @example "sk_live_abc123..."
49
52
  */
50
53
  apiKey?: string;
51
54
 
@@ -75,6 +78,13 @@ export interface SynapClientOptions {
75
78
  onAuthError?: (error: { status: number; message: string }) => void;
76
79
  }
77
80
 
81
+ /**
82
+ * Prefixes of hub / user API keys (SSOT: `KEY_PREFIXES` in synap-backend
83
+ * `packages/database/src/schema/api-keys.ts`; hub_inbound keys use `synap_hub_`).
84
+ * The LOCAL_MODE token is random bytes and never starts with these.
85
+ */
86
+ const HUB_KEY_PREFIXES = ["synap_hub_", "synap_user_"] as const;
87
+
78
88
  /**
79
89
  * Creates a fully-typed tRPC client connected to a Synap data pod.
80
90
  *
@@ -86,6 +96,13 @@ export function createSynapClient(
86
96
  ): CreateTRPCClient<AppRouter> {
87
97
  const { podUrl, apiKey, workspaceId, extraHeaders, onAuthError } = options;
88
98
 
99
+ if (apiKey && HUB_KEY_PREFIXES.some((p) => apiKey.startsWith(p))) {
100
+ throw new Error(
101
+ "createSynapClient: this is a hub/user API key; /trpc only accepts a session or the local-mode token. " +
102
+ "Use @synap-core/hub-rest-client against /api/hub/* instead.",
103
+ );
104
+ }
105
+
89
106
  const url = `${podUrl.replace(/\/$/, "")}/trpc`;
90
107
 
91
108
  // Base fetch: always include credentials so session cookies are sent in
@@ -177,7 +194,7 @@ export function createSynapClient(
177
194
  headers: () => ({
178
195
  // Session token mode (Kratos, preferred for non-browser clients)
179
196
  ...(options.sessionToken ? { "x-session-token": options.sessionToken } : {}),
180
- // API key mode (Bearer, for legacy SDK usage)
197
+ // LOCAL_MODE token (Bearer); hub/user API keys are rejected above
181
198
  ...(apiKey && !options.sessionToken ? { Authorization: `Bearer ${apiKey}` } : {}),
182
199
  ...(workspaceId ? { "x-workspace-id": workspaceId } : {}),
183
200
  ...extraHeaders,
package/src/index.ts CHANGED
@@ -1,2 +1,15 @@
1
1
  export { createSynapClient } from "./client";
2
2
  export type { SynapClient, SynapClientOptions, AppRouter } from "./client";
3
+ export {
4
+ ensureTemplate,
5
+ ensuredWorkspaceId,
6
+ parseEnsureTemplateResult,
7
+ } from "./templates";
8
+ export type {
9
+ EnsureTemplateClient,
10
+ EnsureTemplateInput,
11
+ EnsureTemplateMissingTemplate,
12
+ EnsureTemplateOptions,
13
+ EnsureTemplateResult,
14
+ EnsureTemplateStatus,
15
+ } from "./templates";
@@ -0,0 +1,579 @@
1
+ /**
2
+ * @synap-core/sdk/public — a thin, CREDENTIALLESS fetch client for the pod's
3
+ * public doors (Sites W3/W4, collection door C1a/C1b):
4
+ *
5
+ * GET <pod>/api/hub/public/shares/:token read a published share
6
+ * GET <pod>/api/hub/public/forms/:token a form's public definition + ticket
7
+ * POST <pod>/api/hub/public/forms/:token a guest submission (202 / 422 / 503)
8
+ * GET <pod>/api/hub/public/sites/:token a site manifest
9
+ * GET <pod>/api/hub/public/sites/:token/collections/:key a page of records
10
+ * GET <pod>/api/hub/public/sites/:token/collections/:key/:slugOrPublicId a record
11
+ *
12
+ * Invariants (pinned by src/__tests__/public.guard.test.ts):
13
+ * - every request is `credentials: 'omit'`; no Authorization / Cookie / session
14
+ * header is ever set (only `Accept`, `If-None-Match` on a site revalidation,
15
+ * and `Content-Type` on POST);
16
+ * - imports NOTHING (no tRPC, superjson, zod, authenticated client);
17
+ * - the uniform 404 is one typed `not_found` (revoked / expired / unknown are
18
+ * indistinguishable by design); the constant 202 is `{ received: true }`;
19
+ * - a submit has six typed non-success outcomes (`invalid` with the field keys,
20
+ * `retry`, `unavailable`, `rate_limited`, `network`, `server`); a 422 `retry`
21
+ * (ticket missing / too early / stale) is retried ONCE automatically: the
22
+ * form is fetched again, `minSubmitMs` is waited out (capped) and the same
23
+ * submission (same idempotency key) is resent;
24
+ * - server detail (bodies, messages) never reaches the caller.
25
+ *
26
+ * DRIFT RISK: the response types below are HAND-WRITTEN from the backend wire
27
+ * contracts (synap-backend: `_codecs/public-shares.ts` PublicShareResponseSchema,
28
+ * `services/forms/form-definition.ts` publicFormView + the public-forms route,
29
+ * `SubmissionEnvelopeSchema`, `_codecs/public-sites.ts` + the bounds in
30
+ * `services/sharing/public-site-read.ts` parseCollectionQuery).
31
+ * `src/__tests__/fixtures.ts` copies those shapes; when the backend changes,
32
+ * update both.
33
+ */
34
+
35
+ // ── Wire types (hand-written; see DRIFT RISK) ────────────────────────────────
36
+
37
+ export interface PublicShare {
38
+ resourceType: "entity";
39
+ /** Present only when the publisher allowlisted `title`. */
40
+ title?: string;
41
+ properties: Record<string, string | number | boolean>;
42
+ body: { format: "markdown"; content: string } | null;
43
+ /** Publication day, YYYY-MM-DD. */
44
+ publishedOn: string;
45
+ }
46
+
47
+ export type PublicFormFieldType =
48
+ | "text"
49
+ | "richtext"
50
+ | "email"
51
+ | "phone"
52
+ | "url"
53
+ | "number"
54
+ | "boolean"
55
+ | "date"
56
+ | "enum";
57
+
58
+ export interface PublicFormField {
59
+ key: string;
60
+ label: string;
61
+ type: PublicFormFieldType;
62
+ required?: true;
63
+ help?: string;
64
+ constraints?: {
65
+ enum?: string[];
66
+ min?: number;
67
+ max?: number;
68
+ maxLength?: number;
69
+ };
70
+ }
71
+
72
+ export interface PublicForm {
73
+ title: string;
74
+ fields: PublicFormField[];
75
+ successMessage: string;
76
+ /** The envelope key of the honeypot (always "hp" today). */
77
+ honeypotField: string;
78
+ /** Time-to-submit ticket, minted by this GET; pass it back to `submitForm`. */
79
+ ticket: string;
80
+ /**
81
+ * The POST is refused with `retry` until this many ms have passed since the
82
+ * ticket was minted. Hold the submit button until then. 0 = no wait.
83
+ */
84
+ minSubmitMs: number;
85
+ captcha: { required: true; siteKey: string | null } | { required: false };
86
+ }
87
+
88
+ /** A published value: a publish-time snapshot, scalars only (C1b). */
89
+ export type PublicValue = string | number | boolean;
90
+
91
+ export interface PublicSiteCollection {
92
+ /** The collection key (a profile slug); pass it to `listCollection`. */
93
+ key: string;
94
+ /** Live published records in it. */
95
+ count: number;
96
+ /** The default sort keys (`-` = descending). */
97
+ sort: string[];
98
+ /** The default page size. */
99
+ pageSize: number;
100
+ /** How a record of this collection is addressed in URLs. */
101
+ addressing: "slug" | "publicId";
102
+ }
103
+
104
+ /** A published site's manifest. */
105
+ export interface PublicSite {
106
+ /** Present only when the owner's policy publishes `title`. */
107
+ title?: string;
108
+ /** Present only when the owner's policy publishes `description`. */
109
+ description?: string;
110
+ /** Day the site went live, YYYY-MM-DD. */
111
+ publishedOn: string;
112
+ collections: PublicSiteCollection[];
113
+ }
114
+
115
+ /** One published record as a list item (no body). */
116
+ export interface PublicRecordSummary {
117
+ collection: string;
118
+ /** The stable public address: `p_` + 16 random base62 chars. */
119
+ publicId: string;
120
+ /** The record's slug in its collection, or null. */
121
+ slug: string | null;
122
+ /** Present only when the owner's policy publishes `title`. */
123
+ title?: string;
124
+ properties: Record<string, PublicValue>;
125
+ /** Publication day, YYYY-MM-DD. */
126
+ publishedOn: string;
127
+ }
128
+
129
+ export interface PublicCollectionPage {
130
+ items: PublicRecordSummary[];
131
+ /** Live published records in the collection. */
132
+ total: number;
133
+ offset: number;
134
+ limit: number;
135
+ }
136
+
137
+ /** One published record, with its pinned body. */
138
+ export interface PublicRecord extends PublicRecordSummary {
139
+ body: { format: "markdown"; content: string } | null;
140
+ }
141
+
142
+ /** The collection door's query bounds (backend `parseCollectionQuery`). */
143
+ export const SITE_MAX_OFFSET = 10_000;
144
+ export const SITE_MAX_LIMIT = 100;
145
+ export const SITE_MAX_SORT_KEYS = 3;
146
+ /** The sort key every collection accepts, besides its configured ones. */
147
+ export const SITE_PUBLISHED_AT_SORT = "publishedAt";
148
+
149
+ // ── Results ──────────────────────────────────────────────────────────────────
150
+
151
+ /** `not_found` is the uniform 404 (never says revoked vs expired vs unknown). */
152
+ export type PublicReadError =
153
+ | "not_found"
154
+ | "rate_limited"
155
+ /** The owner has not turned public pages/forms on for this workspace. */
156
+ | "doors_disabled"
157
+ | "network"
158
+ | "server";
159
+ /** A submit never yields `not_found`: the door answers 202 to everything. */
160
+ export type PublicSubmitError =
161
+ | "invalid"
162
+ /** The ticket was refused, even after the one automatic retry. */
163
+ | "retry"
164
+ /** 503: the pod failed to file it; resubmitting with the same key is safe. */
165
+ | "unavailable"
166
+ | "rate_limited"
167
+ /** The owner has not turned public pages/forms on for this workspace. */
168
+ | "doors_disabled"
169
+ | "network"
170
+ | "server";
171
+
172
+ /**
173
+ * A site read's failure. `not_found` is the uniform 404 (an unknown, revoked
174
+ * or unpublished site, collection or record are indistinguishable).
175
+ */
176
+ export type PublicSiteError =
177
+ | "not_found"
178
+ | "rate_limited"
179
+ /** The site is live but the owner switched its space's public pages off. */
180
+ | "doors_disabled"
181
+ /** 400: the query is out of bounds or names a sort key the collection does not offer. */
182
+ | "bad_request"
183
+ /** No answer (network, DNS, CORS) or a gateway 502/503/504. */
184
+ | "pod_unreachable"
185
+ | "server";
186
+
187
+ /**
188
+ * A failure. `rate_limited` carries `retryAfterSec` only when the response
189
+ * had a `Retry-After` header the caller can read (see README: the pod's public
190
+ * doors do not send one today, and a browser cannot read it cross-origin).
191
+ */
192
+ export type PublicFailure<E extends string> = E extends "rate_limited"
193
+ ? { ok: false; error: E; retryAfterSec?: number }
194
+ : { ok: false; error: E };
195
+
196
+ export type PublicResult<T, E extends string = PublicReadError> =
197
+ | { ok: true; data: T }
198
+ | PublicFailure<E>;
199
+
200
+ /** A site read. `etag` is the door's weak validator, for `ifNoneMatch`. */
201
+ export type PublicSiteResult<T> =
202
+ | { ok: true; data: T; etag: string | null }
203
+ | PublicFailure<PublicSiteError>;
204
+
205
+ /** 304: what the caller holds for `ifNoneMatch` is still current. */
206
+ export interface PublicNotModified {
207
+ ok: true;
208
+ notModified: true;
209
+ etag: string;
210
+ }
211
+
212
+ /**
213
+ * Revalidate with the ETag of an earlier answer. SERVER-SIDE ONLY: the public
214
+ * doors allow no request header but `Content-Type` cross-origin, so a browser
215
+ * fails the preflight (→ `pod_unreachable`). Browsers revalidate on their own
216
+ * through their HTTP cache (`Cache-Control: no-cache` + ETag).
217
+ */
218
+ export interface Revalidate {
219
+ ifNoneMatch: string;
220
+ }
221
+
222
+ export interface CollectionQuery {
223
+ /** 0..10 000 (default 0). Anything else is `bad_request`, without a request. */
224
+ offset?: number;
225
+ /** An integer, clamped to 1..100 (default: the collection's page size). */
226
+ limit?: number;
227
+ /**
228
+ * ≤ 3 keys, `-` = descending, out of the collection's `sort` keys and
229
+ * `publishedAt` (default: the collection's sort). A string is comma-split.
230
+ */
231
+ sort?: string | readonly string[];
232
+ }
233
+
234
+ export type SubmitResult =
235
+ | { ok: true; received: true }
236
+ /** 422: the answers do not satisfy the form. `fields` are field keys; empty = not a submission. */
237
+ | { ok: false; error: "invalid"; fields: string[] }
238
+ | PublicFailure<Exclude<PublicSubmitError, "invalid">>;
239
+
240
+ export interface SubmitFormInput {
241
+ /** Answers keyed by the form's field `key`s. */
242
+ fields: Record<string, unknown>;
243
+ /** The `ticket` from `getForm` (waiting out the form's min time is the caller's job). */
244
+ ticket?: string;
245
+ /** Turnstile-style token, when `captcha.required`. */
246
+ captchaToken?: string;
247
+ /** Optional replay key; a repeated key collapses onto one submission. */
248
+ idempotencyKey?: string;
249
+ }
250
+
251
+ export interface PublicClientOptions {
252
+ /** Pod origin, e.g. `https://you.synap.live` (a trailing slash is ignored). */
253
+ podUrl: string;
254
+ /** Injectable fetch; defaults to the global one. */
255
+ fetch?: typeof fetch;
256
+ /** Injectable wait (tests); defaults to `setTimeout`. */
257
+ sleep?: (ms: number) => Promise<void>;
258
+ }
259
+
260
+ export interface PublicClient {
261
+ readShare(token: string): Promise<PublicResult<PublicShare>>;
262
+ getForm(token: string): Promise<PublicResult<PublicForm>>;
263
+ submitForm(token: string, input: SubmitFormInput): Promise<SubmitResult>;
264
+
265
+ /** A published site's manifest: its metadata and configured collections. */
266
+ getSite(siteToken: string): Promise<PublicSiteResult<PublicSite>>;
267
+ getSite(
268
+ siteToken: string,
269
+ opts: Revalidate
270
+ ): Promise<PublicSiteResult<PublicSite> | PublicNotModified>;
271
+
272
+ /** One page of a collection's live published records (no bodies). */
273
+ listCollection(
274
+ siteToken: string,
275
+ key: string,
276
+ query?: CollectionQuery
277
+ ): Promise<PublicSiteResult<PublicCollectionPage>>;
278
+ listCollection(
279
+ siteToken: string,
280
+ key: string,
281
+ query: CollectionQuery & Revalidate
282
+ ): Promise<PublicSiteResult<PublicCollectionPage> | PublicNotModified>;
283
+
284
+ /** One record by its public id (`p_…`) or its slug, with its pinned body. */
285
+ getRecord(
286
+ siteToken: string,
287
+ key: string,
288
+ slugOrPublicId: string
289
+ ): Promise<PublicSiteResult<PublicRecord>>;
290
+ getRecord(
291
+ siteToken: string,
292
+ key: string,
293
+ slugOrPublicId: string,
294
+ opts: Revalidate
295
+ ): Promise<PublicSiteResult<PublicRecord> | PublicNotModified>;
296
+ }
297
+
298
+ /** The honeypot envelope key the door reads. Always sent empty. */
299
+ const HONEYPOT_KEY = "hp";
300
+ /** Longest wait the automatic ticket retry will sit out, whatever the pod says. */
301
+ const MAX_TICKET_WAIT_MS = 10_000;
302
+ const MAX_INVALID_KEYS = 100;
303
+ const MAX_KEY_LENGTH = 200;
304
+
305
+ function waitMs(form: unknown): number {
306
+ const n = (form as { minSubmitMs?: unknown } | null)?.minSubmitMs;
307
+ return typeof n === "number" && Number.isFinite(n) && n > 0
308
+ ? Math.min(n, MAX_TICKET_WAIT_MS)
309
+ : 0;
310
+ }
311
+
312
+ /** A 422 body -> outcome. Only field keys survive; anything else is dropped. */
313
+ function parse422(body: unknown): { retry: true } | { fields: string[] } | null {
314
+ if (typeof body !== "object" || body === null) return null;
315
+ const b = body as { retry?: unknown; invalid?: unknown };
316
+ if (b.retry === true) return { retry: true };
317
+ if (!Array.isArray(b.invalid)) return null;
318
+ return {
319
+ fields: b.invalid
320
+ .filter((k): k is string => typeof k === "string" && k.length <= MAX_KEY_LENGTH)
321
+ .slice(0, MAX_INVALID_KEYS),
322
+ };
323
+ }
324
+
325
+ /** The one refusal body's machine code (see `PUBLIC_DOORS_DISABLED_BODY` on the pod). */
326
+ const DOORS_DISABLED_CODE = "public_doors_disabled";
327
+
328
+ function isDoorsDisabledBody(body: unknown): boolean {
329
+ return (
330
+ typeof body === "object" &&
331
+ body !== null &&
332
+ (body as { code?: unknown }).code === DOORS_DISABLED_CODE
333
+ );
334
+ }
335
+
336
+ /** A non-200 answer of a read door (no answer at all is the caller's `network`). */
337
+ function classify(status: number, body: unknown): Exclude<PublicReadError, "network"> {
338
+ if (status === 404) return "not_found";
339
+ if (status === 429) return "rate_limited";
340
+ if (status === 403 && isDoorsDisabledBody(body)) return "doors_disabled";
341
+ return "server";
342
+ }
343
+
344
+ function classifySite(status: number, body: unknown): PublicSiteError {
345
+ if (status === 400) return "bad_request";
346
+ if (status === 502 || status === 503 || status === 504) return "pod_unreachable";
347
+ return classify(status, body);
348
+ }
349
+
350
+ /** Longest `Retry-After` passed on, whatever the response says. */
351
+ const MAX_RETRY_AFTER_SEC = 86_400;
352
+
353
+ /** `Retry-After` (delta-seconds or an HTTP date) in whole seconds, else undefined. */
354
+ function retryAfterSec(res: Response): number | undefined {
355
+ const raw = res.headers.get("retry-after")?.trim();
356
+ if (!raw) return undefined;
357
+ let sec: number;
358
+ if (/^\d+$/.test(raw)) {
359
+ sec = Number(raw);
360
+ } else {
361
+ const at = Date.parse(raw);
362
+ if (Number.isNaN(at)) return undefined;
363
+ sec = Math.ceil((at - Date.now()) / 1000);
364
+ }
365
+ return Math.min(Math.max(0, sec), MAX_RETRY_AFTER_SEC);
366
+ }
367
+
368
+ /** A failure; `rate_limited` picks up the response's `Retry-After`, if any. */
369
+ function fail<E extends string>(error: E, res: Response | null): PublicFailure<E> {
370
+ const sec = error === "rate_limited" && res ? retryAfterSec(res) : undefined;
371
+ return (sec === undefined ? { ok: false, error } : { ok: false, error, retryAfterSec: sec }) as PublicFailure<E>;
372
+ }
373
+
374
+ async function readBody(res: Response): Promise<unknown> {
375
+ try {
376
+ return await res.json();
377
+ } catch {
378
+ return null;
379
+ }
380
+ }
381
+
382
+ /**
383
+ * One URL path segment, or null when it could not stay one: empty, `.` and
384
+ * `..` would be resolved away by the URL parser and reach ANOTHER door (e.g.
385
+ * collection `..` is the manifest), typed as the wrong shape.
386
+ */
387
+ function segment(raw: string): string | null {
388
+ if (typeof raw !== "string" || raw === "" || raw === "." || raw === "..") return null;
389
+ return encodeURIComponent(raw);
390
+ }
391
+
392
+ /** A sort term as the door parses it (`-` = descending). */
393
+ const SORT_TERM = /^(-?)([A-Za-z][A-Za-z0-9_]{0,63})$/;
394
+
395
+ /**
396
+ * The collection query string, checked against the door's static bounds so a
397
+ * request that can only be a 400 is never sent. null = `bad_request`. Whether
398
+ * a sort key is one the collection offers is the door's call.
399
+ */
400
+ function collectionSearch(q: CollectionQuery | undefined): string | null {
401
+ const params: string[] = [];
402
+ if (q?.offset !== undefined) {
403
+ if (!Number.isInteger(q.offset) || q.offset < 0 || q.offset > SITE_MAX_OFFSET) return null;
404
+ params.push(`offset=${q.offset}`);
405
+ }
406
+ if (q?.limit !== undefined) {
407
+ if (!Number.isInteger(q.limit) || q.limit < 0) return null;
408
+ params.push(`limit=${Math.min(SITE_MAX_LIMIT, Math.max(1, q.limit))}`);
409
+ }
410
+ if (q?.sort !== undefined) {
411
+ const terms = typeof q.sort === "string" ? q.sort.split(",") : [...q.sort];
412
+ if (terms.length > SITE_MAX_SORT_KEYS) return null;
413
+ const keys = new Set<string>();
414
+ for (const term of terms) {
415
+ const m = SORT_TERM.exec(term);
416
+ if (!m || keys.has(m[2])) return null;
417
+ keys.add(m[2]);
418
+ }
419
+ if (terms.length > 0) params.push(`sort=${terms.join(",")}`);
420
+ }
421
+ return params.length > 0 ? `?${params.join("&")}` : "";
422
+ }
423
+
424
+ export function createPublicClient(opts: PublicClientOptions): PublicClient {
425
+ const base = `${opts.podUrl.replace(/\/+$/, "")}/api/hub/public`;
426
+ const doFetch = (): typeof fetch => {
427
+ const f = opts.fetch ?? globalThis.fetch;
428
+ if (!f) throw new Error("No fetch available: pass `fetch` to createPublicClient");
429
+ return f;
430
+ };
431
+ const url = (resource: "shares" | "forms", token: string) =>
432
+ `${base}/${resource}/${encodeURIComponent(token)}`;
433
+
434
+ async function send(
435
+ target: string,
436
+ init: { method: "GET" | "POST"; body?: string; ifNoneMatch?: string }
437
+ ): Promise<Response | null> {
438
+ try {
439
+ return await doFetch()(target, {
440
+ method: init.method,
441
+ credentials: "omit",
442
+ headers:
443
+ init.method === "POST"
444
+ ? { "Content-Type": "application/json" }
445
+ : init.ifNoneMatch
446
+ ? { Accept: "application/json", "If-None-Match": init.ifNoneMatch }
447
+ : { Accept: "application/json" },
448
+ ...(init.body !== undefined ? { body: init.body } : {}),
449
+ });
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+
455
+ async function getJson<T>(
456
+ resource: "shares" | "forms",
457
+ token: string
458
+ ): Promise<PublicResult<T>> {
459
+ const res = await send(url(resource, token), { method: "GET" });
460
+ if (!res) return { ok: false, error: "network" };
461
+ if (res.status !== 200) return fail(classify(res.status, await readBody(res)), res);
462
+ try {
463
+ return { ok: true, data: (await res.json()) as T };
464
+ } catch {
465
+ return { ok: false, error: "server" };
466
+ }
467
+ }
468
+
469
+ /** A site door read. `path` null = a segment that cannot be addressed: the uniform miss. */
470
+ async function siteRead<T>(
471
+ path: string | null,
472
+ ifNoneMatch: string | undefined
473
+ ): Promise<PublicSiteResult<T> | PublicNotModified> {
474
+ if (path === null) return { ok: false, error: "not_found" };
475
+ const revalidate = typeof ifNoneMatch === "string" && ifNoneMatch !== "" ? ifNoneMatch : undefined;
476
+ const res = await send(`${base}/sites/${path}`, { method: "GET", ifNoneMatch: revalidate });
477
+ if (!res) return { ok: false, error: "pod_unreachable" };
478
+ const etag = res.headers.get("etag");
479
+ if (res.status === 304 && revalidate !== undefined) {
480
+ return { ok: true, notModified: true, etag: etag ?? revalidate };
481
+ }
482
+ if (res.status !== 200) return fail(classifySite(res.status, await readBody(res)), res);
483
+ try {
484
+ return { ok: true, data: (await res.json()) as T, etag };
485
+ } catch {
486
+ return { ok: false, error: "server" };
487
+ }
488
+ }
489
+
490
+ function getSite(siteToken: string, opts?: Partial<Revalidate>) {
491
+ return siteRead<PublicSite>(segment(siteToken), opts?.ifNoneMatch);
492
+ }
493
+
494
+ async function listCollection(
495
+ siteToken: string,
496
+ key: string,
497
+ query?: CollectionQuery & Partial<Revalidate>
498
+ ): Promise<PublicSiteResult<PublicCollectionPage> | PublicNotModified> {
499
+ const token = segment(siteToken);
500
+ const k = segment(key);
501
+ if (token === null || k === null) return { ok: false, error: "not_found" };
502
+ const search = collectionSearch(query);
503
+ if (search === null) return { ok: false, error: "bad_request" };
504
+ return siteRead(`${token}/collections/${k}${search}`, query?.ifNoneMatch);
505
+ }
506
+
507
+ function getRecord(
508
+ siteToken: string,
509
+ key: string,
510
+ slugOrPublicId: string,
511
+ opts?: Partial<Revalidate>
512
+ ) {
513
+ const parts = [segment(siteToken), segment(key), segment(slugOrPublicId)];
514
+ const path = parts.every((p) => p !== null)
515
+ ? `${parts[0]}/collections/${parts[1]}/${parts[2]}`
516
+ : null;
517
+ return siteRead<PublicRecord>(path, opts?.ifNoneMatch);
518
+ }
519
+
520
+ const sleep =
521
+ opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
522
+
523
+ async function postOnce(
524
+ token: string,
525
+ input: SubmitFormInput,
526
+ ticket: string | undefined
527
+ ): Promise<{ retry: true } | { retry: false; result: SubmitResult }> {
528
+ const body: Record<string, unknown> = {
529
+ fields: input.fields,
530
+ [HONEYPOT_KEY]: "",
531
+ };
532
+ if (ticket !== undefined) body.ticket = ticket;
533
+ if (input.captchaToken !== undefined) body.captchaToken = input.captchaToken;
534
+ if (input.idempotencyKey !== undefined) body.idempotencyKey = input.idempotencyKey;
535
+ const res = await send(url("forms", token), {
536
+ method: "POST",
537
+ body: JSON.stringify(body),
538
+ });
539
+ const done = (result: SubmitResult) => ({ retry: false as const, result });
540
+ if (!res) return done({ ok: false, error: "network" });
541
+ if (res.status === 202) return done({ ok: true, received: true });
542
+ if (res.status === 422) {
543
+ let parsed: ReturnType<typeof parse422> = null;
544
+ try {
545
+ parsed = parse422(await res.json());
546
+ } catch {
547
+ parsed = null;
548
+ }
549
+ if (!parsed) return done({ ok: false, error: "server" });
550
+ if ("retry" in parsed) return { retry: true };
551
+ return done({ ok: false, error: "invalid", fields: parsed.fields });
552
+ }
553
+ if (res.status === 503) return done({ ok: false, error: "unavailable" });
554
+ if (res.status === 429) return done(fail("rate_limited", res));
555
+ if (res.status === 403 && isDoorsDisabledBody(await readBody(res))) {
556
+ return done({ ok: false, error: "doors_disabled" });
557
+ }
558
+ return done({ ok: false, error: "server" });
559
+ }
560
+
561
+ return {
562
+ readShare: (token) => getJson<PublicShare>("shares", token),
563
+ getForm: (token) => getJson<PublicForm>("forms", token),
564
+ async submitForm(token, input) {
565
+ const first = await postOnce(token, input, input.ticket);
566
+ if (!first.retry) return first.result;
567
+ // Ticket refused: fetch the form again (a fresh ticket + the wait), hold
568
+ // for it, resubmit once with the same idempotency key.
569
+ const fresh = await getJson<PublicForm>("forms", token);
570
+ if (!fresh.ok) return { ok: false, error: "retry" };
571
+ await sleep(waitMs(fresh.data));
572
+ const second = await postOnce(token, input, fresh.data.ticket);
573
+ return second.retry ? { ok: false, error: "retry" } : second.result;
574
+ },
575
+ getSite: getSite as PublicClient["getSite"],
576
+ listCollection: listCollection as PublicClient["listCollection"],
577
+ getRecord: getRecord as PublicClient["getRecord"],
578
+ };
579
+ }