@synap-core/sdk 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -6
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/client-CVUT1Mcf.d.cts +253 -0
- package/dist/client-CVUT1Mcf.d.ts +253 -0
- package/dist/connect.browser.cjs +22 -0
- package/dist/connect.browser.cjs.map +1 -0
- package/dist/connect.browser.d.cts +2 -0
- package/dist/connect.browser.d.ts +2 -0
- package/dist/connect.browser.js +5 -0
- package/dist/connect.browser.js.map +1 -0
- package/dist/connect.cjs +1206 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.cts +442 -0
- package/dist/connect.d.ts +442 -0
- package/dist/connect.js +1092 -0
- package/dist/connect.js.map +1 -0
- package/dist/index.cjs +48 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +118 -7
- package/dist/index.d.ts +118 -7
- package/dist/index.js +41 -2
- package/dist/index.js.map +1 -1
- package/dist/public.cjs +627 -0
- package/dist/public.cjs.map +1 -0
- package/dist/public.d.cts +228 -0
- package/dist/public.d.ts +228 -0
- package/dist/public.js +578 -0
- package/dist/public.js.map +1 -0
- package/dist/testing.cjs +671 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +181 -0
- package/dist/testing.d.ts +181 -0
- package/dist/testing.js +614 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-DpqPNaaa.d.cts +76 -0
- package/dist/types-DpqPNaaa.d.ts +76 -0
- package/package.json +16 -3
- package/src/client.ts +24 -7
- package/src/connect/auth.ts +405 -0
- package/src/connect/browser.ts +10 -0
- package/src/connect/client.ts +193 -0
- package/src/connect/discovery.ts +262 -0
- package/src/connect/encoding.ts +93 -0
- package/src/connect/errors.ts +115 -0
- package/src/connect/flow.ts +449 -0
- package/src/connect/guard.ts +25 -0
- package/src/connect/index.ts +59 -0
- package/src/connect/seal.ts +119 -0
- package/src/connect/store.ts +151 -0
- package/src/connect/types.ts +80 -0
- package/src/index.ts +13 -0
- package/src/public/client.ts +579 -0
- package/src/public/form-model.ts +210 -0
- package/src/public/index.ts +12 -0
- package/src/public/safe-markdown.ts +114 -0
- package/src/public/share-model.ts +47 -0
- package/src/public/site-model.ts +217 -0
- package/src/templates.ts +122 -0
- package/src/testing/auth-server.ts +380 -0
- package/src/testing/fixtures.ts +132 -0
- package/src/testing/index.ts +359 -0
- package/src/public.ts +0 -262
|
@@ -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
|
+
}
|