@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,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synap-core/sdk/testing — a mock pod: a fake `fetch` that answers the pod's
|
|
3
|
+
* public doors (forms, shares, sites) from fixtures, with no network. Inject
|
|
4
|
+
* it into the REAL client (`createPublicClient({ podUrl, fetch: pod.fetch })`)
|
|
5
|
+
* so everything above the wire — status handling, typed errors, the models —
|
|
6
|
+
* is the production path. For unit tests, a site's mock mode and a kit's
|
|
7
|
+
* states gallery.
|
|
8
|
+
*
|
|
9
|
+
* Wire behaviour mirrors the backend doors: the uniform 404 for every miss,
|
|
10
|
+
* the constant 202 on a submit, the site doors' query bounds (400), weak
|
|
11
|
+
* ETags with If-None-Match → 304. Like a browser against the real doors, a
|
|
12
|
+
* `credentials: "include"` request is refused (it rejects, as a CORS failure
|
|
13
|
+
* does); with `{ browser: true }` a non-safelisted request header is too.
|
|
14
|
+
*
|
|
15
|
+
* tokens MOCK_TOKENS.form / .share / .site the demo form, share and site
|
|
16
|
+
* tokens MOCK_FAILURE_TOKENS.* one failure on every door
|
|
17
|
+
* anything else the uniform 404
|
|
18
|
+
*
|
|
19
|
+
* Pod-app sign-in (`@synap-core/sdk/connect`): `createMockAuthServer()`, see
|
|
20
|
+
* ./auth-server.ts.
|
|
21
|
+
*/
|
|
22
|
+
import type {
|
|
23
|
+
PublicCollectionPage,
|
|
24
|
+
PublicForm,
|
|
25
|
+
PublicRecord,
|
|
26
|
+
PublicRecordSummary,
|
|
27
|
+
PublicShare,
|
|
28
|
+
PublicSite,
|
|
29
|
+
} from "../public/client";
|
|
30
|
+
import { MOCK_FORM, MOCK_SHARE, MOCK_SITE, type MockSite } from "./fixtures";
|
|
31
|
+
|
|
32
|
+
export { MOCK_FORM, MOCK_SHARE, MOCK_SITE };
|
|
33
|
+
export type { MockCollection, MockSite } from "./fixtures";
|
|
34
|
+
// Pod-app sign-in: a mock authorization server + Hub REST doors.
|
|
35
|
+
export * from "./auth-server";
|
|
36
|
+
|
|
37
|
+
/** A placeholder origin; never contacted. */
|
|
38
|
+
export const MOCK_POD_URL = "https://mock.synap.invalid";
|
|
39
|
+
|
|
40
|
+
export const MOCK_TOKENS = { form: "demo-form", share: "demo", site: "demo-site" } as const;
|
|
41
|
+
|
|
42
|
+
/** The `Retry-After` the `rateLimitedRetryAfter` token sends. */
|
|
43
|
+
export const MOCK_RETRY_AFTER_SEC = 30;
|
|
44
|
+
|
|
45
|
+
/** Tokens that make every door answer one failure, so each UI state can be rendered. */
|
|
46
|
+
export const MOCK_FAILURE_TOKENS = {
|
|
47
|
+
/** 429 with no `Retry-After` (what the pod's public doors send today). */
|
|
48
|
+
rateLimited: "mock-rate-limited",
|
|
49
|
+
/** 429 with `Retry-After: 30`. */
|
|
50
|
+
rateLimitedRetryAfter: "mock-rate-limited-retry-after",
|
|
51
|
+
/** 403 `public_doors_disabled`: the owner switched public pages off. */
|
|
52
|
+
doorsDisabled: "mock-doors-disabled",
|
|
53
|
+
/** 500. */
|
|
54
|
+
serverError: "mock-server-error",
|
|
55
|
+
/** 503 (a read: `pod_unreachable` / `server`; a submit: `unavailable`). */
|
|
56
|
+
unavailable: "mock-unavailable",
|
|
57
|
+
/** No answer: the fetch rejects (`network` / `pod_unreachable`). */
|
|
58
|
+
unreachable: "mock-unreachable",
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
export interface MockPodOptions {
|
|
62
|
+
/** Forms by token (default: the demo form under `MOCK_TOKENS.form`). */
|
|
63
|
+
forms?: Record<string, PublicForm>;
|
|
64
|
+
/** Shares by token (default: the demo share under `MOCK_TOKENS.share`). */
|
|
65
|
+
shares?: Record<string, PublicShare>;
|
|
66
|
+
/** Sites by token (default: the demo site under `MOCK_TOKENS.site`). */
|
|
67
|
+
sites?: Record<string, MockSite>;
|
|
68
|
+
/** Refuse non-safelisted request headers, as a browser's CORS preflight would. */
|
|
69
|
+
browser?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MockRequest {
|
|
73
|
+
method: string;
|
|
74
|
+
url: string;
|
|
75
|
+
/** Lower-cased header names. */
|
|
76
|
+
headers: Record<string, string>;
|
|
77
|
+
credentials: RequestCredentials | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MockSubmission {
|
|
81
|
+
token: string;
|
|
82
|
+
/** The parsed envelope (`fields`, `ticket`, `hp`, …). */
|
|
83
|
+
body: unknown;
|
|
84
|
+
/** The answer the mock gave. */
|
|
85
|
+
status: 202 | 422;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface MockPod {
|
|
89
|
+
podUrl: string;
|
|
90
|
+
fetch: typeof fetch;
|
|
91
|
+
/** Every request seen, in order (assert credentials and headers on it). */
|
|
92
|
+
requests: MockRequest[];
|
|
93
|
+
/** Every submit seen, in order. */
|
|
94
|
+
submissions: MockSubmission[];
|
|
95
|
+
/** Forget `requests` and `submissions`. */
|
|
96
|
+
reset(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const NOT_FOUND = { error: "Not found" };
|
|
100
|
+
const DOORS_DISABLED = {
|
|
101
|
+
error: "The owner has not turned on public pages and forms for this workspace.",
|
|
102
|
+
code: "public_doors_disabled",
|
|
103
|
+
};
|
|
104
|
+
/** Request headers a browser sends cross-origin without a preflight. */
|
|
105
|
+
const SAFELISTED = new Set(["accept", "accept-language", "content-language", "content-type"]);
|
|
106
|
+
/** The only request header the real doors allow on a preflight. */
|
|
107
|
+
const PREFLIGHT_ALLOWED = new Set(["content-type"]);
|
|
108
|
+
|
|
109
|
+
const PUBLIC_ID_RE = /^p_[A-Za-z0-9]{16}$/;
|
|
110
|
+
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
111
|
+
const SLUG_MAX_LENGTH = 96;
|
|
112
|
+
const DIGITS = /^\d{1,9}$/;
|
|
113
|
+
const SORT_TERM = /^(-?)([A-Za-z][A-Za-z0-9_]{0,63})$/;
|
|
114
|
+
const MAX_OFFSET = 10_000;
|
|
115
|
+
const MAX_LIMIT = 100;
|
|
116
|
+
const MAX_SORT_KEYS = 3;
|
|
117
|
+
const PUBLISHED_AT = "publishedAt";
|
|
118
|
+
|
|
119
|
+
function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
120
|
+
return new Response(JSON.stringify(body), {
|
|
121
|
+
status,
|
|
122
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-cache", ...headers },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** FNV-1a, 32 bits: a stable weak validator, no crypto needed. */
|
|
127
|
+
function weakEtag(value: unknown): string {
|
|
128
|
+
let h = 0x811c9dc5;
|
|
129
|
+
for (const ch of JSON.stringify(value)) {
|
|
130
|
+
h ^= ch.charCodeAt(0);
|
|
131
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
132
|
+
}
|
|
133
|
+
return `W/"${h.toString(16).padStart(8, "0")}"`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function etagMatches(header: string | null, etag: string): boolean {
|
|
137
|
+
if (!header) return false;
|
|
138
|
+
if (header.trim() === "*") return true;
|
|
139
|
+
const strip = (t: string) => t.trim().replace(/^W\//, "");
|
|
140
|
+
return header.split(",").some((t) => strip(t) === strip(etag));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function failure(token: string): Response | "reject" | null {
|
|
144
|
+
switch (token) {
|
|
145
|
+
case MOCK_FAILURE_TOKENS.rateLimited:
|
|
146
|
+
return json(429, { error: "Too many requests", retryAfter: "5 minutes" });
|
|
147
|
+
case MOCK_FAILURE_TOKENS.rateLimitedRetryAfter:
|
|
148
|
+
return json(429, { error: "Too many requests" }, { "Retry-After": String(MOCK_RETRY_AFTER_SEC) });
|
|
149
|
+
case MOCK_FAILURE_TOKENS.doorsDisabled:
|
|
150
|
+
return json(403, DOORS_DISABLED);
|
|
151
|
+
case MOCK_FAILURE_TOKENS.serverError:
|
|
152
|
+
return json(500, { error: "Internal error" });
|
|
153
|
+
case MOCK_FAILURE_TOKENS.unavailable:
|
|
154
|
+
return json(503, { received: false });
|
|
155
|
+
case MOCK_FAILURE_TOKENS.unreachable:
|
|
156
|
+
return "reject";
|
|
157
|
+
default:
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function summary(record: PublicRecord): PublicRecordSummary {
|
|
163
|
+
const { body: _body, ...rest } = record;
|
|
164
|
+
return rest;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function manifest(site: MockSite): PublicSite {
|
|
168
|
+
return {
|
|
169
|
+
...(site.title !== undefined ? { title: site.title } : {}),
|
|
170
|
+
...(site.description !== undefined ? { description: site.description } : {}),
|
|
171
|
+
publishedOn: site.publishedOn,
|
|
172
|
+
collections: Object.keys(site.collections)
|
|
173
|
+
.sort()
|
|
174
|
+
.map((key) => {
|
|
175
|
+
const c = site.collections[key];
|
|
176
|
+
return { key, count: c.records.length, sort: [...c.sort], pageSize: c.pageSize, addressing: c.addressing };
|
|
177
|
+
}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type Sort = Array<{ key: string; desc: boolean }>;
|
|
182
|
+
|
|
183
|
+
/** The door's `parseCollectionQuery`: the same bounds, the same 400s. */
|
|
184
|
+
function parseQuery(params: URLSearchParams, c: { sort: string[]; pageSize: number }): { offset: number; limit: number; sort: Sort } | string {
|
|
185
|
+
for (const name of params.keys()) {
|
|
186
|
+
if (name === "embed") return "embed is not available";
|
|
187
|
+
if (name === "ref" || name.startsWith("ref[")) return "ref filter is not available";
|
|
188
|
+
}
|
|
189
|
+
const one = (name: string): string | undefined | null => {
|
|
190
|
+
const all = params.getAll(name);
|
|
191
|
+
return all.length === 0 ? undefined : all.length === 1 ? all[0] : null;
|
|
192
|
+
};
|
|
193
|
+
const rawOffset = one("offset");
|
|
194
|
+
if (rawOffset === null || (rawOffset !== undefined && !DIGITS.test(rawOffset))) return "invalid offset";
|
|
195
|
+
const offset = rawOffset === undefined ? 0 : Number(rawOffset);
|
|
196
|
+
if (offset > MAX_OFFSET) return "offset too large";
|
|
197
|
+
const rawLimit = one("limit");
|
|
198
|
+
if (rawLimit === null || (rawLimit !== undefined && !DIGITS.test(rawLimit))) return "invalid limit";
|
|
199
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, rawLimit === undefined ? c.pageSize : Number(rawLimit)));
|
|
200
|
+
const rawSort = one("sort");
|
|
201
|
+
if (rawSort === null) return "invalid sort";
|
|
202
|
+
const terms =
|
|
203
|
+
rawSort !== undefined ? rawSort.split(",") : c.sort.length > 0 ? [...c.sort] : [`-${PUBLISHED_AT}`];
|
|
204
|
+
if (terms.length > MAX_SORT_KEYS) return "invalid sort";
|
|
205
|
+
const allowed = new Set([PUBLISHED_AT, ...c.sort.map((s) => s.replace(/^-/, ""))]);
|
|
206
|
+
const sort: Sort = [];
|
|
207
|
+
for (const term of terms) {
|
|
208
|
+
const m = SORT_TERM.exec(term);
|
|
209
|
+
if (!m || !allowed.has(m[2]) || sort.some((s) => s.key === m[2])) return "invalid sort";
|
|
210
|
+
sort.push({ key: m[2], desc: m[1] === "-" });
|
|
211
|
+
}
|
|
212
|
+
return { offset, limit, sort };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** jsonb-like order within a type (strings < numbers < booleans); a missing value sorts last. */
|
|
216
|
+
function compareRecords(sort: Sort) {
|
|
217
|
+
const rank = (v: unknown) => (typeof v === "string" ? 0 : typeof v === "number" ? 1 : 2);
|
|
218
|
+
const valueOf = (r: PublicRecord, key: string) => (key === PUBLISHED_AT ? r.publishedOn : r.properties[key]);
|
|
219
|
+
return (a: PublicRecord, b: PublicRecord): number => {
|
|
220
|
+
for (const { key, desc } of sort) {
|
|
221
|
+
const va = valueOf(a, key);
|
|
222
|
+
const vb = valueOf(b, key);
|
|
223
|
+
if (va === undefined || vb === undefined) {
|
|
224
|
+
if (va !== vb) return va === undefined ? 1 : -1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
let d = rank(va) - rank(vb);
|
|
228
|
+
if (d === 0) d = va < vb ? -1 : va > vb ? 1 : 0;
|
|
229
|
+
if (d !== 0) return desc ? -d : d;
|
|
230
|
+
}
|
|
231
|
+
if (a.publishedOn !== b.publishedOn) return a.publishedOn < b.publishedOn ? 1 : -1;
|
|
232
|
+
return a.publicId < b.publicId ? -1 : a.publicId > b.publicId ? 1 : 0;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 200 with a weak ETag, or 304 when If-None-Match already holds it. */
|
|
237
|
+
function cached(view: unknown, ifNoneMatch: string | null): Response {
|
|
238
|
+
const etag = weakEtag(view);
|
|
239
|
+
if (etagMatches(ifNoneMatch, etag)) {
|
|
240
|
+
return new Response(null, { status: 304, headers: { ETag: etag, "Cache-Control": "no-cache" } });
|
|
241
|
+
}
|
|
242
|
+
return json(200, view, { ETag: etag });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function siteDoor(site: MockSite, rest: string[], params: URLSearchParams, ifNoneMatch: string | null): Response {
|
|
246
|
+
if (rest.length === 0) return cached(manifest(site), ifNoneMatch);
|
|
247
|
+
if (rest[0] !== "collections" || rest.length > 3 || !Object.hasOwn(site.collections, rest[1])) {
|
|
248
|
+
return json(404, NOT_FOUND);
|
|
249
|
+
}
|
|
250
|
+
const c = site.collections[rest[1]];
|
|
251
|
+
if (rest.length === 2) {
|
|
252
|
+
const q = parseQuery(params, c);
|
|
253
|
+
if (typeof q === "string") return json(400, { error: q });
|
|
254
|
+
const sorted = [...c.records].sort(compareRecords(q.sort));
|
|
255
|
+
const page: PublicCollectionPage = {
|
|
256
|
+
items: sorted.slice(q.offset, q.offset + q.limit).map(summary),
|
|
257
|
+
total: sorted.length,
|
|
258
|
+
offset: q.offset,
|
|
259
|
+
limit: q.limit,
|
|
260
|
+
};
|
|
261
|
+
return cached(page, ifNoneMatch);
|
|
262
|
+
}
|
|
263
|
+
const address = rest[2];
|
|
264
|
+
const byPublicId = PUBLIC_ID_RE.test(address);
|
|
265
|
+
if (!byPublicId && !(address.length <= SLUG_MAX_LENGTH && SLUG_RE.test(address))) return json(404, NOT_FOUND);
|
|
266
|
+
const record = c.records.find((r) => (byPublicId ? r.publicId === address : r.slug === address));
|
|
267
|
+
if (!record) return json(404, NOT_FOUND);
|
|
268
|
+
if (params.has("embed")) return json(400, { error: "embed is not available" });
|
|
269
|
+
return cached(record, ifNoneMatch);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The door's 422 for answers that do not satisfy the form (required, enum). */
|
|
273
|
+
function invalidKeys(form: PublicForm, body: unknown): string[] {
|
|
274
|
+
const fields = (body as { fields?: unknown } | null)?.fields;
|
|
275
|
+
if (typeof fields !== "object" || fields === null) return [];
|
|
276
|
+
const answers = fields as Record<string, unknown>;
|
|
277
|
+
return form.fields
|
|
278
|
+
.filter((f) => {
|
|
279
|
+
const v = answers[f.key];
|
|
280
|
+
if (v === undefined || v === "" || v === null) return f.required === true;
|
|
281
|
+
if (f.type === "boolean") return typeof v !== "boolean" || (f.required === true && v !== true);
|
|
282
|
+
const allowed = f.constraints?.enum;
|
|
283
|
+
return allowed !== undefined && !allowed.includes(String(v));
|
|
284
|
+
})
|
|
285
|
+
.map((f) => f.key);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function createMockPod(options: MockPodOptions = {}): MockPod {
|
|
289
|
+
const forms = options.forms ?? { [MOCK_TOKENS.form]: MOCK_FORM };
|
|
290
|
+
const shares = options.shares ?? { [MOCK_TOKENS.share]: MOCK_SHARE };
|
|
291
|
+
const sites = options.sites ?? { [MOCK_TOKENS.site]: MOCK_SITE };
|
|
292
|
+
const requests: MockRequest[] = [];
|
|
293
|
+
const submissions: MockSubmission[] = [];
|
|
294
|
+
|
|
295
|
+
const mockFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
296
|
+
const request = typeof input === "object" && "method" in input ? input : null;
|
|
297
|
+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
298
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
299
|
+
const headers: Record<string, string> = {};
|
|
300
|
+
new Headers(init?.headers ?? request?.headers).forEach((value, name) => {
|
|
301
|
+
headers[name.toLowerCase()] = value;
|
|
302
|
+
});
|
|
303
|
+
const credentials = init?.credentials ?? request?.credentials;
|
|
304
|
+
requests.push({ method, url: url.href, headers, credentials });
|
|
305
|
+
|
|
306
|
+
// What a browser does against the real doors (`Access-Control-Allow-Origin: *`).
|
|
307
|
+
if (credentials === "include") throw new TypeError("Failed to fetch: credentialed request to a public door");
|
|
308
|
+
if (options.browser) {
|
|
309
|
+
const preflighted = Object.keys(headers).filter((h) => !SAFELISTED.has(h) && !PREFLIGHT_ALLOWED.has(h));
|
|
310
|
+
if (preflighted.length > 0) throw new TypeError(`Failed to fetch: preflight refuses ${preflighted.join(", ")}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const m = url.pathname.match(/^\/api\/hub\/public\/(forms|shares|sites)\/(.+)$/);
|
|
314
|
+
if (!m) return json(404, NOT_FOUND);
|
|
315
|
+
let segments: string[];
|
|
316
|
+
try {
|
|
317
|
+
segments = m[2].split("/").map(decodeURIComponent);
|
|
318
|
+
} catch {
|
|
319
|
+
return json(404, NOT_FOUND);
|
|
320
|
+
}
|
|
321
|
+
const [token, ...rest] = segments;
|
|
322
|
+
const failed = failure(token);
|
|
323
|
+
if (failed === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
324
|
+
if (failed) return failed;
|
|
325
|
+
|
|
326
|
+
const door = m[1];
|
|
327
|
+
if (door === "forms" && method === "POST" && rest.length === 0) {
|
|
328
|
+
let body: unknown = null;
|
|
329
|
+
try {
|
|
330
|
+
body = JSON.parse(typeof init?.body === "string" ? init.body : "null");
|
|
331
|
+
} catch {
|
|
332
|
+
body = null;
|
|
333
|
+
}
|
|
334
|
+
const form = Object.hasOwn(forms, token) ? forms[token] : undefined;
|
|
335
|
+
const invalid = form ? invalidKeys(form, body) : [];
|
|
336
|
+
const status = invalid.length > 0 ? 422 : 202;
|
|
337
|
+
submissions.push({ token, body, status });
|
|
338
|
+
return status === 422 ? json(422, { received: false, invalid }) : json(202, { received: true });
|
|
339
|
+
}
|
|
340
|
+
if (method !== "GET") return json(404, NOT_FOUND);
|
|
341
|
+
if (door === "forms" && rest.length === 0 && Object.hasOwn(forms, token)) return json(200, forms[token]);
|
|
342
|
+
if (door === "shares" && rest.length === 0 && Object.hasOwn(shares, token)) return json(200, shares[token]);
|
|
343
|
+
if (door === "sites" && Object.hasOwn(sites, token)) {
|
|
344
|
+
return siteDoor(sites[token], rest, url.searchParams, headers["if-none-match"] ?? null);
|
|
345
|
+
}
|
|
346
|
+
return json(404, NOT_FOUND);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
podUrl: MOCK_POD_URL,
|
|
351
|
+
fetch: mockFetch as typeof fetch,
|
|
352
|
+
requests,
|
|
353
|
+
submissions,
|
|
354
|
+
reset() {
|
|
355
|
+
requests.length = 0;
|
|
356
|
+
submissions.length = 0;
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
package/src/public.ts
DELETED
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @synap-core/sdk/public — a thin, CREDENTIALLESS fetch client for the pod's
|
|
3
|
-
* public doors (Sites W3/W4):
|
|
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
|
-
*
|
|
9
|
-
* Invariants (pinned by src/__tests__/public.guard.test.ts):
|
|
10
|
-
* - every request is `credentials: 'omit'`; no Authorization / Cookie / session
|
|
11
|
-
* header is ever set (only `Accept`, and `Content-Type` on POST);
|
|
12
|
-
* - imports NOTHING (no tRPC, superjson, zod, authenticated client);
|
|
13
|
-
* - the uniform 404 is one typed `not_found` (revoked / expired / unknown are
|
|
14
|
-
* indistinguishable by design); the constant 202 is `{ received: true }`;
|
|
15
|
-
* - a submit has six typed non-success outcomes (`invalid` with the field keys,
|
|
16
|
-
* `retry`, `unavailable`, `rate_limited`, `network`, `server`); a 422 `retry`
|
|
17
|
-
* (ticket missing / too early / stale) is retried ONCE automatically: the
|
|
18
|
-
* form is fetched again, `minSubmitMs` is waited out (capped) and the same
|
|
19
|
-
* submission (same idempotency key) is resent;
|
|
20
|
-
* - server detail (bodies, messages) never reaches the caller.
|
|
21
|
-
*
|
|
22
|
-
* DRIFT RISK: the response types below are HAND-WRITTEN from the backend wire
|
|
23
|
-
* contracts (synap-backend: `_codecs/public-shares.ts` PublicShareResponseSchema,
|
|
24
|
-
* `services/forms/form-definition.ts` publicFormView + the public-forms route,
|
|
25
|
-
* `SubmissionEnvelopeSchema`). `src/__tests__/fixtures.ts` copies those shapes;
|
|
26
|
-
* when the backend changes, update both.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
// ── Wire types (hand-written; see DRIFT RISK) ────────────────────────────────
|
|
30
|
-
|
|
31
|
-
export interface PublicShare {
|
|
32
|
-
resourceType: "entity";
|
|
33
|
-
/** Present only when the publisher allowlisted `title`. */
|
|
34
|
-
title?: string;
|
|
35
|
-
properties: Record<string, string | number | boolean>;
|
|
36
|
-
body: { format: "markdown"; content: string } | null;
|
|
37
|
-
/** Publication day, YYYY-MM-DD. */
|
|
38
|
-
publishedOn: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type PublicFormFieldType =
|
|
42
|
-
| "text"
|
|
43
|
-
| "richtext"
|
|
44
|
-
| "email"
|
|
45
|
-
| "phone"
|
|
46
|
-
| "url"
|
|
47
|
-
| "number"
|
|
48
|
-
| "boolean"
|
|
49
|
-
| "date"
|
|
50
|
-
| "enum";
|
|
51
|
-
|
|
52
|
-
export interface PublicFormField {
|
|
53
|
-
key: string;
|
|
54
|
-
label: string;
|
|
55
|
-
type: PublicFormFieldType;
|
|
56
|
-
required?: true;
|
|
57
|
-
help?: string;
|
|
58
|
-
constraints?: {
|
|
59
|
-
enum?: string[];
|
|
60
|
-
min?: number;
|
|
61
|
-
max?: number;
|
|
62
|
-
maxLength?: number;
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface PublicForm {
|
|
67
|
-
title: string;
|
|
68
|
-
fields: PublicFormField[];
|
|
69
|
-
successMessage: string;
|
|
70
|
-
/** The envelope key of the honeypot (always "hp" today). */
|
|
71
|
-
honeypotField: string;
|
|
72
|
-
/** Time-to-submit ticket, minted by this GET; pass it back to `submitForm`. */
|
|
73
|
-
ticket: string;
|
|
74
|
-
/**
|
|
75
|
-
* The POST is refused with `retry` until this many ms have passed since the
|
|
76
|
-
* ticket was minted. Hold the submit button until then. 0 = no wait.
|
|
77
|
-
*/
|
|
78
|
-
minSubmitMs: number;
|
|
79
|
-
captcha: { required: true; siteKey: string | null } | { required: false };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ── Results ──────────────────────────────────────────────────────────────────
|
|
83
|
-
|
|
84
|
-
/** `not_found` is the uniform 404 (never says revoked vs expired vs unknown). */
|
|
85
|
-
export type PublicReadError = "not_found" | "rate_limited" | "network" | "server";
|
|
86
|
-
/** A submit never yields `not_found`: the door answers 202 to everything. */
|
|
87
|
-
export type PublicSubmitError =
|
|
88
|
-
| "invalid"
|
|
89
|
-
/** The ticket was refused, even after the one automatic retry. */
|
|
90
|
-
| "retry"
|
|
91
|
-
/** 503: the pod failed to file it; resubmitting with the same key is safe. */
|
|
92
|
-
| "unavailable"
|
|
93
|
-
| "rate_limited"
|
|
94
|
-
| "network"
|
|
95
|
-
| "server";
|
|
96
|
-
|
|
97
|
-
export type PublicResult<T, E extends string = PublicReadError> =
|
|
98
|
-
| { ok: true; data: T }
|
|
99
|
-
| { ok: false; error: E };
|
|
100
|
-
|
|
101
|
-
export type SubmitResult =
|
|
102
|
-
| { ok: true; received: true }
|
|
103
|
-
/** 422: the answers do not satisfy the form. `fields` are field keys; empty = not a submission. */
|
|
104
|
-
| { ok: false; error: "invalid"; fields: string[] }
|
|
105
|
-
| { ok: false; error: Exclude<PublicSubmitError, "invalid"> };
|
|
106
|
-
|
|
107
|
-
export interface SubmitFormInput {
|
|
108
|
-
/** Answers keyed by the form's field `key`s. */
|
|
109
|
-
fields: Record<string, unknown>;
|
|
110
|
-
/** The `ticket` from `getForm` (waiting out the form's min time is the caller's job). */
|
|
111
|
-
ticket?: string;
|
|
112
|
-
/** Turnstile-style token, when `captcha.required`. */
|
|
113
|
-
captchaToken?: string;
|
|
114
|
-
/** Optional replay key; a repeated key collapses onto one submission. */
|
|
115
|
-
idempotencyKey?: string;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export interface PublicClientOptions {
|
|
119
|
-
/** Pod origin, e.g. `https://you.synap.live` (a trailing slash is ignored). */
|
|
120
|
-
podUrl: string;
|
|
121
|
-
/** Injectable fetch; defaults to the global one. */
|
|
122
|
-
fetch?: typeof fetch;
|
|
123
|
-
/** Injectable wait (tests); defaults to `setTimeout`. */
|
|
124
|
-
sleep?: (ms: number) => Promise<void>;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export interface PublicClient {
|
|
128
|
-
readShare(token: string): Promise<PublicResult<PublicShare>>;
|
|
129
|
-
getForm(token: string): Promise<PublicResult<PublicForm>>;
|
|
130
|
-
submitForm(token: string, input: SubmitFormInput): Promise<SubmitResult>;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** The honeypot envelope key the door reads. Always sent empty. */
|
|
134
|
-
const HONEYPOT_KEY = "hp";
|
|
135
|
-
/** Longest wait the automatic ticket retry will sit out, whatever the pod says. */
|
|
136
|
-
const MAX_TICKET_WAIT_MS = 10_000;
|
|
137
|
-
const MAX_INVALID_KEYS = 100;
|
|
138
|
-
const MAX_KEY_LENGTH = 200;
|
|
139
|
-
|
|
140
|
-
function waitMs(form: unknown): number {
|
|
141
|
-
const n = (form as { minSubmitMs?: unknown } | null)?.minSubmitMs;
|
|
142
|
-
return typeof n === "number" && Number.isFinite(n) && n > 0
|
|
143
|
-
? Math.min(n, MAX_TICKET_WAIT_MS)
|
|
144
|
-
: 0;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** A 422 body -> outcome. Only field keys survive; anything else is dropped. */
|
|
148
|
-
function parse422(body: unknown): { retry: true } | { fields: string[] } | null {
|
|
149
|
-
if (typeof body !== "object" || body === null) return null;
|
|
150
|
-
const b = body as { retry?: unknown; invalid?: unknown };
|
|
151
|
-
if (b.retry === true) return { retry: true };
|
|
152
|
-
if (!Array.isArray(b.invalid)) return null;
|
|
153
|
-
return {
|
|
154
|
-
fields: b.invalid
|
|
155
|
-
.filter((k): k is string => typeof k === "string" && k.length <= MAX_KEY_LENGTH)
|
|
156
|
-
.slice(0, MAX_INVALID_KEYS),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function classify(status: number): PublicReadError {
|
|
161
|
-
if (status === 404) return "not_found";
|
|
162
|
-
if (status === 429) return "rate_limited";
|
|
163
|
-
return "server";
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function createPublicClient(opts: PublicClientOptions): PublicClient {
|
|
167
|
-
const base = `${opts.podUrl.replace(/\/+$/, "")}/api/hub/public`;
|
|
168
|
-
const doFetch = (): typeof fetch => {
|
|
169
|
-
const f = opts.fetch ?? globalThis.fetch;
|
|
170
|
-
if (!f) throw new Error("No fetch available: pass `fetch` to createPublicClient");
|
|
171
|
-
return f;
|
|
172
|
-
};
|
|
173
|
-
const url = (resource: "shares" | "forms", token: string) =>
|
|
174
|
-
`${base}/${resource}/${encodeURIComponent(token)}`;
|
|
175
|
-
|
|
176
|
-
async function send(
|
|
177
|
-
target: string,
|
|
178
|
-
init: { method: "GET" | "POST"; body?: string }
|
|
179
|
-
): Promise<Response | null> {
|
|
180
|
-
try {
|
|
181
|
-
return await doFetch()(target, {
|
|
182
|
-
method: init.method,
|
|
183
|
-
credentials: "omit",
|
|
184
|
-
headers:
|
|
185
|
-
init.method === "POST"
|
|
186
|
-
? { "Content-Type": "application/json" }
|
|
187
|
-
: { Accept: "application/json" },
|
|
188
|
-
...(init.body !== undefined ? { body: init.body } : {}),
|
|
189
|
-
});
|
|
190
|
-
} catch {
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async function getJson<T>(
|
|
196
|
-
resource: "shares" | "forms",
|
|
197
|
-
token: string
|
|
198
|
-
): Promise<PublicResult<T>> {
|
|
199
|
-
const res = await send(url(resource, token), { method: "GET" });
|
|
200
|
-
if (!res) return { ok: false, error: "network" };
|
|
201
|
-
if (res.status !== 200) return { ok: false, error: classify(res.status) };
|
|
202
|
-
try {
|
|
203
|
-
return { ok: true, data: (await res.json()) as T };
|
|
204
|
-
} catch {
|
|
205
|
-
return { ok: false, error: "server" };
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const sleep =
|
|
210
|
-
opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
211
|
-
|
|
212
|
-
async function postOnce(
|
|
213
|
-
token: string,
|
|
214
|
-
input: SubmitFormInput,
|
|
215
|
-
ticket: string | undefined
|
|
216
|
-
): Promise<{ retry: true } | { retry: false; result: SubmitResult }> {
|
|
217
|
-
const body: Record<string, unknown> = {
|
|
218
|
-
fields: input.fields,
|
|
219
|
-
[HONEYPOT_KEY]: "",
|
|
220
|
-
};
|
|
221
|
-
if (ticket !== undefined) body.ticket = ticket;
|
|
222
|
-
if (input.captchaToken !== undefined) body.captchaToken = input.captchaToken;
|
|
223
|
-
if (input.idempotencyKey !== undefined) body.idempotencyKey = input.idempotencyKey;
|
|
224
|
-
const res = await send(url("forms", token), {
|
|
225
|
-
method: "POST",
|
|
226
|
-
body: JSON.stringify(body),
|
|
227
|
-
});
|
|
228
|
-
const done = (result: SubmitResult) => ({ retry: false as const, result });
|
|
229
|
-
if (!res) return done({ ok: false, error: "network" });
|
|
230
|
-
if (res.status === 202) return done({ ok: true, received: true });
|
|
231
|
-
if (res.status === 422) {
|
|
232
|
-
let parsed: ReturnType<typeof parse422> = null;
|
|
233
|
-
try {
|
|
234
|
-
parsed = parse422(await res.json());
|
|
235
|
-
} catch {
|
|
236
|
-
parsed = null;
|
|
237
|
-
}
|
|
238
|
-
if (!parsed) return done({ ok: false, error: "server" });
|
|
239
|
-
if ("retry" in parsed) return { retry: true };
|
|
240
|
-
return done({ ok: false, error: "invalid", fields: parsed.fields });
|
|
241
|
-
}
|
|
242
|
-
if (res.status === 503) return done({ ok: false, error: "unavailable" });
|
|
243
|
-
if (res.status === 429) return done({ ok: false, error: "rate_limited" });
|
|
244
|
-
return done({ ok: false, error: "server" });
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return {
|
|
248
|
-
readShare: (token) => getJson<PublicShare>("shares", token),
|
|
249
|
-
getForm: (token) => getJson<PublicForm>("forms", token),
|
|
250
|
-
async submitForm(token, input) {
|
|
251
|
-
const first = await postOnce(token, input, input.ticket);
|
|
252
|
-
if (!first.retry) return first.result;
|
|
253
|
-
// Ticket refused: fetch the form again (a fresh ticket + the wait), hold
|
|
254
|
-
// for it, resubmit once with the same idempotency key.
|
|
255
|
-
const fresh = await getJson<PublicForm>("forms", token);
|
|
256
|
-
if (!fresh.ok) return { ok: false, error: "retry" };
|
|
257
|
-
await sleep(waitMs(fresh.data));
|
|
258
|
-
const second = await postOnce(token, input, fresh.data.ticket);
|
|
259
|
-
return second.retry ? { ok: false, error: "retry" } : second.result;
|
|
260
|
-
},
|
|
261
|
-
};
|
|
262
|
-
}
|