@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/README.md +58 -6
- package/dist/client-C5sF20f1.d.cts +253 -0
- package/dist/client-C5sF20f1.d.ts +253 -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 +386 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +110 -0
- package/dist/testing.d.ts +110 -0
- package/dist/testing.js +352 -0
- package/dist/testing.js.map +1 -0
- package/package.json +6 -1
- package/src/client.ts +24 -7
- 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/fixtures.ts +132 -0
- package/src/testing/index.ts +354 -0
- package/src/public.ts +0 -262
package/src/templates.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ensureTemplate — an app asks the pod whether the template it runs on is
|
|
3
|
+
* installed, and (optionally) asks for it to be installed.
|
|
4
|
+
*
|
|
5
|
+
* TEMPLATE-LIFECYCLE Contract B. ONE door: tRPC `workspaces.ensureTemplate`
|
|
6
|
+
* (Hub REST mirror `POST /api/hub/workspaces/ensure-template`). The pod
|
|
7
|
+
* resolves the definition from the catalog and installs through its governed
|
|
8
|
+
* install door — an app NEVER sends a definition. "Installed" means an ACTIVE
|
|
9
|
+
* workspace whose `settings.packageSlug` is the slug.
|
|
10
|
+
*
|
|
11
|
+
* The result union below is HAND-WRITTEN from the contract
|
|
12
|
+
* (`TEMPLATE-LIFECYCLE/DESIGN.md`, Contract B) because the published
|
|
13
|
+
* `@synap-core/api-types` does not carry the procedure until the next regen.
|
|
14
|
+
* Once it does, `EnsureTemplateResult` should become
|
|
15
|
+
* `RouterOutputs["workspaces"]["ensureTemplate"]`.
|
|
16
|
+
*
|
|
17
|
+
* A failed call REJECTS. It is never folded into `missing`: "the pod said the
|
|
18
|
+
* template is not installed" and "we could not ask the pod" are different
|
|
19
|
+
* facts, and only the first may offer an install.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface EnsureTemplateInput {
|
|
23
|
+
slug: string;
|
|
24
|
+
/** Ask the pod to install it when missing. */
|
|
25
|
+
install?: boolean;
|
|
26
|
+
/** One plain sentence: why the app needs it (shown on the install request). */
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EnsureTemplateMissingTemplate {
|
|
31
|
+
slug: string;
|
|
32
|
+
name: string;
|
|
33
|
+
description: string | null;
|
|
34
|
+
icon: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type EnsureTemplateResult =
|
|
38
|
+
/** Installed: an ACTIVE workspace carries the template. */
|
|
39
|
+
| { status: "installed"; workspaceId: string; version: string | null; drifted: boolean }
|
|
40
|
+
/** Not installed, and no install was asked. */
|
|
41
|
+
| { status: "missing"; template: EnsureTemplateMissingTemplate }
|
|
42
|
+
/** Install asked by an agent or app key: the governed install filed a proposal. */
|
|
43
|
+
| { status: "proposed"; proposalId: string; reviewUrl: string }
|
|
44
|
+
/** Install asked by a person: it is being installed now. */
|
|
45
|
+
| { status: "installing"; workspaceId: string }
|
|
46
|
+
/**
|
|
47
|
+
* Another live space already holds this template's domain (one space per
|
|
48
|
+
* domain), so the pod will not install a twin. The person decides what to
|
|
49
|
+
* do with that space; `guidance` is the pod's own sentence.
|
|
50
|
+
*/
|
|
51
|
+
| { status: "exists"; workspaceId: string; workspaceName: string; guidance: string }
|
|
52
|
+
/** The catalog retired the template; `replacedBy` is its successor, if any. */
|
|
53
|
+
| { status: "retired"; replacedBy: string | null }
|
|
54
|
+
/** A COMPLETE catalog sync does not list the slug. */
|
|
55
|
+
| { status: "not_in_catalog" }
|
|
56
|
+
/** The pod cannot tell yet (catalog cache cold or last sync incomplete). */
|
|
57
|
+
| { status: "unknown" };
|
|
58
|
+
|
|
59
|
+
export type EnsureTemplateStatus = EnsureTemplateResult["status"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The slice of the tRPC client this helper needs. Structural on purpose: it
|
|
63
|
+
* accepts `createSynapClient(...)` and a React `trpc.useUtils().client` alike,
|
|
64
|
+
* and keeps compiling before the procedure lands in the generated types.
|
|
65
|
+
*/
|
|
66
|
+
export interface EnsureTemplateClient {
|
|
67
|
+
workspaces: {
|
|
68
|
+
ensureTemplate: {
|
|
69
|
+
mutate(input: EnsureTemplateInput): Promise<unknown>;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EnsureTemplateOptions {
|
|
75
|
+
install?: boolean;
|
|
76
|
+
reason?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const STATUSES: ReadonlySet<string> = new Set<EnsureTemplateStatus>([
|
|
80
|
+
"installed",
|
|
81
|
+
"missing",
|
|
82
|
+
"proposed",
|
|
83
|
+
"installing",
|
|
84
|
+
"exists",
|
|
85
|
+
"retired",
|
|
86
|
+
"not_in_catalog",
|
|
87
|
+
"unknown",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Narrow the wire answer. An answer whose `status` is not one the contract
|
|
92
|
+
* names is a protocol error and throws: guessing "missing" would offer an
|
|
93
|
+
* install the pod never said was needed.
|
|
94
|
+
*/
|
|
95
|
+
export function parseEnsureTemplateResult(raw: unknown): EnsureTemplateResult {
|
|
96
|
+
const status =
|
|
97
|
+
raw && typeof raw === "object" ? (raw as { status?: unknown }).status : undefined;
|
|
98
|
+
if (typeof status !== "string" || !STATUSES.has(status)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`ensureTemplate: the pod answered with an unknown status (${String(status)}).`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return raw as EnsureTemplateResult;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function ensureTemplate(
|
|
107
|
+
client: EnsureTemplateClient,
|
|
108
|
+
slug: string,
|
|
109
|
+
opts: EnsureTemplateOptions = {},
|
|
110
|
+
): Promise<EnsureTemplateResult> {
|
|
111
|
+
const input: EnsureTemplateInput = { slug };
|
|
112
|
+
if (opts.install) input.install = true;
|
|
113
|
+
if (opts.reason) input.reason = opts.reason;
|
|
114
|
+
return parseEnsureTemplateResult(await client.workspaces.ensureTemplate.mutate(input));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The workspace the template lives in, when the answer names one. */
|
|
118
|
+
export function ensuredWorkspaceId(result: EnsureTemplateResult): string | null {
|
|
119
|
+
return result.status === "installed" || result.status === "installing"
|
|
120
|
+
? result.workspaceId
|
|
121
|
+
: null;
|
|
122
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo data for the mock pod. Shapes are the `@synap-core/sdk/public` wire
|
|
3
|
+
* types, so a fixture that drifts from the contract fails to compile.
|
|
4
|
+
*/
|
|
5
|
+
import type { PublicForm, PublicRecord, PublicShare } from "../public/client";
|
|
6
|
+
|
|
7
|
+
export const MOCK_FORM: PublicForm = {
|
|
8
|
+
title: "Get in touch",
|
|
9
|
+
fields: [
|
|
10
|
+
{ key: "name", label: "Name", type: "text", required: true, constraints: { maxLength: 120 } },
|
|
11
|
+
{ key: "email", label: "Email", type: "email", required: true },
|
|
12
|
+
{
|
|
13
|
+
key: "topic",
|
|
14
|
+
label: "Topic",
|
|
15
|
+
type: "enum",
|
|
16
|
+
constraints: { enum: ["Sales", "Support", "Partnership"] },
|
|
17
|
+
},
|
|
18
|
+
{ key: "message", label: "Message", type: "richtext", help: "A few lines is plenty." },
|
|
19
|
+
],
|
|
20
|
+
successMessage: "Thanks — we received your message.",
|
|
21
|
+
honeypotField: "hp",
|
|
22
|
+
ticket: "mock-ticket",
|
|
23
|
+
minSubmitMs: 0,
|
|
24
|
+
captcha: { required: false },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MOCK_SHARE: PublicShare = {
|
|
28
|
+
resourceType: "entity",
|
|
29
|
+
title: "Launch notes",
|
|
30
|
+
properties: { status: "open", owner: "Ada", featured: true },
|
|
31
|
+
body: {
|
|
32
|
+
format: "markdown",
|
|
33
|
+
content: [
|
|
34
|
+
"# Launch notes",
|
|
35
|
+
"",
|
|
36
|
+
"This page is a **published snapshot** from a Synap pod.",
|
|
37
|
+
"",
|
|
38
|
+
"- Read the [Synap docs](https://synap.live)",
|
|
39
|
+
"- Links other than http, https and mailto render as plain text: [bad](javascript:alert(1))",
|
|
40
|
+
"",
|
|
41
|
+
"<script>raw HTML is shown as text, never run</script>",
|
|
42
|
+
].join("\n"),
|
|
43
|
+
},
|
|
44
|
+
publishedOn: "2026-09-27",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** One collection of a mock site: its door config and its live records. */
|
|
48
|
+
export interface MockCollection {
|
|
49
|
+
/** Default sort keys (`-` = descending); also the sort keys the door accepts. */
|
|
50
|
+
sort: string[];
|
|
51
|
+
pageSize: number;
|
|
52
|
+
addressing: "slug" | "publicId";
|
|
53
|
+
records: PublicRecord[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A mock published site. The manifest is derived from it (counts included). */
|
|
57
|
+
export interface MockSite {
|
|
58
|
+
title?: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
publishedOn: string;
|
|
61
|
+
collections: Record<string, MockCollection>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A public id of the door's shape (`p_` + 16 base62), deterministic. */
|
|
65
|
+
function publicId(prefix: string, n: number): string {
|
|
66
|
+
return `p_${prefix}${String(n).padStart(16 - prefix.length, "0")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function slugify(s: string): string {
|
|
70
|
+
return s
|
|
71
|
+
.normalize("NFKD")
|
|
72
|
+
.replace(/[̀-ͯ]/g, "")
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
75
|
+
.replace(/^-+|-+$/g, "")
|
|
76
|
+
.slice(0, 96);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const PAPER_TITLES = [
|
|
80
|
+
"Sparse graphs at scale",
|
|
81
|
+
"Learning to rank, revisited",
|
|
82
|
+
"A note on causal tracing",
|
|
83
|
+
"Small models, long contexts",
|
|
84
|
+
"Evaluating retrieval honestly",
|
|
85
|
+
"Robust tokenisation",
|
|
86
|
+
"Graphs that forget",
|
|
87
|
+
"Calibration under shift",
|
|
88
|
+
"Latent space cartography",
|
|
89
|
+
"Proofs as programs, again",
|
|
90
|
+
"Cheap uncertainty",
|
|
91
|
+
"Résumé of a decade",
|
|
92
|
+
];
|
|
93
|
+
const VENUES = ["NeurIPS", "ICML", "ACL", "ICLR"];
|
|
94
|
+
|
|
95
|
+
const PAPERS: PublicRecord[] = PAPER_TITLES.map((title, i) => ({
|
|
96
|
+
collection: "paper",
|
|
97
|
+
publicId: publicId("paper", i + 1),
|
|
98
|
+
slug: slugify(title),
|
|
99
|
+
title,
|
|
100
|
+
properties: {
|
|
101
|
+
year: 2026 - (i % 5),
|
|
102
|
+
venue: VENUES[i % VENUES.length],
|
|
103
|
+
open: i % 3 !== 0,
|
|
104
|
+
},
|
|
105
|
+
publishedOn: `2026-09-${String(28 - i).padStart(2, "0")}`,
|
|
106
|
+
body: i % 4 === 3 ? null : { format: "markdown", content: `# ${title}\n\nAbstract of **${title}**.` },
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const PEOPLE: PublicRecord[] = ["Ada Lovelace", "Alan Turing", "Grace Hopper"].map((name, i) => ({
|
|
110
|
+
collection: "person",
|
|
111
|
+
publicId: publicId("person", i + 1),
|
|
112
|
+
slug: null,
|
|
113
|
+
title: name,
|
|
114
|
+
properties: { name, role: i === 0 ? "Lead" : "Researcher" },
|
|
115
|
+
publishedOn: "2026-09-01",
|
|
116
|
+
body: null,
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The demo site: `paper` (12 records, 5 per page, by slug: pagination),
|
|
121
|
+
* `person` (by public id), `event` (nothing published: the empty state).
|
|
122
|
+
*/
|
|
123
|
+
export const MOCK_SITE: MockSite = {
|
|
124
|
+
title: "Demo lab",
|
|
125
|
+
description: "What the lab publishes",
|
|
126
|
+
publishedOn: "2026-09-01",
|
|
127
|
+
collections: {
|
|
128
|
+
paper: { sort: ["-year"], pageSize: 5, addressing: "slug", records: PAPERS },
|
|
129
|
+
person: { sort: ["name"], pageSize: 20, addressing: "publicId", records: PEOPLE },
|
|
130
|
+
event: { sort: [], pageSize: 10, addressing: "slug", records: [] },
|
|
131
|
+
},
|
|
132
|
+
};
|
|
@@ -0,0 +1,354 @@
|
|
|
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
|
+
import type {
|
|
20
|
+
PublicCollectionPage,
|
|
21
|
+
PublicForm,
|
|
22
|
+
PublicRecord,
|
|
23
|
+
PublicRecordSummary,
|
|
24
|
+
PublicShare,
|
|
25
|
+
PublicSite,
|
|
26
|
+
} from "../public/client";
|
|
27
|
+
import { MOCK_FORM, MOCK_SHARE, MOCK_SITE, type MockSite } from "./fixtures";
|
|
28
|
+
|
|
29
|
+
export { MOCK_FORM, MOCK_SHARE, MOCK_SITE };
|
|
30
|
+
export type { MockCollection, MockSite } from "./fixtures";
|
|
31
|
+
|
|
32
|
+
/** A placeholder origin; never contacted. */
|
|
33
|
+
export const MOCK_POD_URL = "https://mock.synap.invalid";
|
|
34
|
+
|
|
35
|
+
export const MOCK_TOKENS = { form: "demo-form", share: "demo", site: "demo-site" } as const;
|
|
36
|
+
|
|
37
|
+
/** The `Retry-After` the `rateLimitedRetryAfter` token sends. */
|
|
38
|
+
export const MOCK_RETRY_AFTER_SEC = 30;
|
|
39
|
+
|
|
40
|
+
/** Tokens that make every door answer one failure, so each UI state can be rendered. */
|
|
41
|
+
export const MOCK_FAILURE_TOKENS = {
|
|
42
|
+
/** 429 with no `Retry-After` (what the pod's public doors send today). */
|
|
43
|
+
rateLimited: "mock-rate-limited",
|
|
44
|
+
/** 429 with `Retry-After: 30`. */
|
|
45
|
+
rateLimitedRetryAfter: "mock-rate-limited-retry-after",
|
|
46
|
+
/** 403 `public_doors_disabled`: the owner switched public pages off. */
|
|
47
|
+
doorsDisabled: "mock-doors-disabled",
|
|
48
|
+
/** 500. */
|
|
49
|
+
serverError: "mock-server-error",
|
|
50
|
+
/** 503 (a read: `pod_unreachable` / `server`; a submit: `unavailable`). */
|
|
51
|
+
unavailable: "mock-unavailable",
|
|
52
|
+
/** No answer: the fetch rejects (`network` / `pod_unreachable`). */
|
|
53
|
+
unreachable: "mock-unreachable",
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
export interface MockPodOptions {
|
|
57
|
+
/** Forms by token (default: the demo form under `MOCK_TOKENS.form`). */
|
|
58
|
+
forms?: Record<string, PublicForm>;
|
|
59
|
+
/** Shares by token (default: the demo share under `MOCK_TOKENS.share`). */
|
|
60
|
+
shares?: Record<string, PublicShare>;
|
|
61
|
+
/** Sites by token (default: the demo site under `MOCK_TOKENS.site`). */
|
|
62
|
+
sites?: Record<string, MockSite>;
|
|
63
|
+
/** Refuse non-safelisted request headers, as a browser's CORS preflight would. */
|
|
64
|
+
browser?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MockRequest {
|
|
68
|
+
method: string;
|
|
69
|
+
url: string;
|
|
70
|
+
/** Lower-cased header names. */
|
|
71
|
+
headers: Record<string, string>;
|
|
72
|
+
credentials: RequestCredentials | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MockSubmission {
|
|
76
|
+
token: string;
|
|
77
|
+
/** The parsed envelope (`fields`, `ticket`, `hp`, …). */
|
|
78
|
+
body: unknown;
|
|
79
|
+
/** The answer the mock gave. */
|
|
80
|
+
status: 202 | 422;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface MockPod {
|
|
84
|
+
podUrl: string;
|
|
85
|
+
fetch: typeof fetch;
|
|
86
|
+
/** Every request seen, in order (assert credentials and headers on it). */
|
|
87
|
+
requests: MockRequest[];
|
|
88
|
+
/** Every submit seen, in order. */
|
|
89
|
+
submissions: MockSubmission[];
|
|
90
|
+
/** Forget `requests` and `submissions`. */
|
|
91
|
+
reset(): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const NOT_FOUND = { error: "Not found" };
|
|
95
|
+
const DOORS_DISABLED = {
|
|
96
|
+
error: "The owner has not turned on public pages and forms for this workspace.",
|
|
97
|
+
code: "public_doors_disabled",
|
|
98
|
+
};
|
|
99
|
+
/** Request headers a browser sends cross-origin without a preflight. */
|
|
100
|
+
const SAFELISTED = new Set(["accept", "accept-language", "content-language", "content-type"]);
|
|
101
|
+
/** The only request header the real doors allow on a preflight. */
|
|
102
|
+
const PREFLIGHT_ALLOWED = new Set(["content-type"]);
|
|
103
|
+
|
|
104
|
+
const PUBLIC_ID_RE = /^p_[A-Za-z0-9]{16}$/;
|
|
105
|
+
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
106
|
+
const SLUG_MAX_LENGTH = 96;
|
|
107
|
+
const DIGITS = /^\d{1,9}$/;
|
|
108
|
+
const SORT_TERM = /^(-?)([A-Za-z][A-Za-z0-9_]{0,63})$/;
|
|
109
|
+
const MAX_OFFSET = 10_000;
|
|
110
|
+
const MAX_LIMIT = 100;
|
|
111
|
+
const MAX_SORT_KEYS = 3;
|
|
112
|
+
const PUBLISHED_AT = "publishedAt";
|
|
113
|
+
|
|
114
|
+
function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
115
|
+
return new Response(JSON.stringify(body), {
|
|
116
|
+
status,
|
|
117
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-cache", ...headers },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** FNV-1a, 32 bits: a stable weak validator, no crypto needed. */
|
|
122
|
+
function weakEtag(value: unknown): string {
|
|
123
|
+
let h = 0x811c9dc5;
|
|
124
|
+
for (const ch of JSON.stringify(value)) {
|
|
125
|
+
h ^= ch.charCodeAt(0);
|
|
126
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
127
|
+
}
|
|
128
|
+
return `W/"${h.toString(16).padStart(8, "0")}"`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function etagMatches(header: string | null, etag: string): boolean {
|
|
132
|
+
if (!header) return false;
|
|
133
|
+
if (header.trim() === "*") return true;
|
|
134
|
+
const strip = (t: string) => t.trim().replace(/^W\//, "");
|
|
135
|
+
return header.split(",").some((t) => strip(t) === strip(etag));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function failure(token: string): Response | "reject" | null {
|
|
139
|
+
switch (token) {
|
|
140
|
+
case MOCK_FAILURE_TOKENS.rateLimited:
|
|
141
|
+
return json(429, { error: "Too many requests", retryAfter: "5 minutes" });
|
|
142
|
+
case MOCK_FAILURE_TOKENS.rateLimitedRetryAfter:
|
|
143
|
+
return json(429, { error: "Too many requests" }, { "Retry-After": String(MOCK_RETRY_AFTER_SEC) });
|
|
144
|
+
case MOCK_FAILURE_TOKENS.doorsDisabled:
|
|
145
|
+
return json(403, DOORS_DISABLED);
|
|
146
|
+
case MOCK_FAILURE_TOKENS.serverError:
|
|
147
|
+
return json(500, { error: "Internal error" });
|
|
148
|
+
case MOCK_FAILURE_TOKENS.unavailable:
|
|
149
|
+
return json(503, { received: false });
|
|
150
|
+
case MOCK_FAILURE_TOKENS.unreachable:
|
|
151
|
+
return "reject";
|
|
152
|
+
default:
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function summary(record: PublicRecord): PublicRecordSummary {
|
|
158
|
+
const { body: _body, ...rest } = record;
|
|
159
|
+
return rest;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function manifest(site: MockSite): PublicSite {
|
|
163
|
+
return {
|
|
164
|
+
...(site.title !== undefined ? { title: site.title } : {}),
|
|
165
|
+
...(site.description !== undefined ? { description: site.description } : {}),
|
|
166
|
+
publishedOn: site.publishedOn,
|
|
167
|
+
collections: Object.keys(site.collections)
|
|
168
|
+
.sort()
|
|
169
|
+
.map((key) => {
|
|
170
|
+
const c = site.collections[key];
|
|
171
|
+
return { key, count: c.records.length, sort: [...c.sort], pageSize: c.pageSize, addressing: c.addressing };
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type Sort = Array<{ key: string; desc: boolean }>;
|
|
177
|
+
|
|
178
|
+
/** The door's `parseCollectionQuery`: the same bounds, the same 400s. */
|
|
179
|
+
function parseQuery(params: URLSearchParams, c: { sort: string[]; pageSize: number }): { offset: number; limit: number; sort: Sort } | string {
|
|
180
|
+
for (const name of params.keys()) {
|
|
181
|
+
if (name === "embed") return "embed is not available";
|
|
182
|
+
if (name === "ref" || name.startsWith("ref[")) return "ref filter is not available";
|
|
183
|
+
}
|
|
184
|
+
const one = (name: string): string | undefined | null => {
|
|
185
|
+
const all = params.getAll(name);
|
|
186
|
+
return all.length === 0 ? undefined : all.length === 1 ? all[0] : null;
|
|
187
|
+
};
|
|
188
|
+
const rawOffset = one("offset");
|
|
189
|
+
if (rawOffset === null || (rawOffset !== undefined && !DIGITS.test(rawOffset))) return "invalid offset";
|
|
190
|
+
const offset = rawOffset === undefined ? 0 : Number(rawOffset);
|
|
191
|
+
if (offset > MAX_OFFSET) return "offset too large";
|
|
192
|
+
const rawLimit = one("limit");
|
|
193
|
+
if (rawLimit === null || (rawLimit !== undefined && !DIGITS.test(rawLimit))) return "invalid limit";
|
|
194
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, rawLimit === undefined ? c.pageSize : Number(rawLimit)));
|
|
195
|
+
const rawSort = one("sort");
|
|
196
|
+
if (rawSort === null) return "invalid sort";
|
|
197
|
+
const terms =
|
|
198
|
+
rawSort !== undefined ? rawSort.split(",") : c.sort.length > 0 ? [...c.sort] : [`-${PUBLISHED_AT}`];
|
|
199
|
+
if (terms.length > MAX_SORT_KEYS) return "invalid sort";
|
|
200
|
+
const allowed = new Set([PUBLISHED_AT, ...c.sort.map((s) => s.replace(/^-/, ""))]);
|
|
201
|
+
const sort: Sort = [];
|
|
202
|
+
for (const term of terms) {
|
|
203
|
+
const m = SORT_TERM.exec(term);
|
|
204
|
+
if (!m || !allowed.has(m[2]) || sort.some((s) => s.key === m[2])) return "invalid sort";
|
|
205
|
+
sort.push({ key: m[2], desc: m[1] === "-" });
|
|
206
|
+
}
|
|
207
|
+
return { offset, limit, sort };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** jsonb-like order within a type (strings < numbers < booleans); a missing value sorts last. */
|
|
211
|
+
function compareRecords(sort: Sort) {
|
|
212
|
+
const rank = (v: unknown) => (typeof v === "string" ? 0 : typeof v === "number" ? 1 : 2);
|
|
213
|
+
const valueOf = (r: PublicRecord, key: string) => (key === PUBLISHED_AT ? r.publishedOn : r.properties[key]);
|
|
214
|
+
return (a: PublicRecord, b: PublicRecord): number => {
|
|
215
|
+
for (const { key, desc } of sort) {
|
|
216
|
+
const va = valueOf(a, key);
|
|
217
|
+
const vb = valueOf(b, key);
|
|
218
|
+
if (va === undefined || vb === undefined) {
|
|
219
|
+
if (va !== vb) return va === undefined ? 1 : -1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
let d = rank(va) - rank(vb);
|
|
223
|
+
if (d === 0) d = va < vb ? -1 : va > vb ? 1 : 0;
|
|
224
|
+
if (d !== 0) return desc ? -d : d;
|
|
225
|
+
}
|
|
226
|
+
if (a.publishedOn !== b.publishedOn) return a.publishedOn < b.publishedOn ? 1 : -1;
|
|
227
|
+
return a.publicId < b.publicId ? -1 : a.publicId > b.publicId ? 1 : 0;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 200 with a weak ETag, or 304 when If-None-Match already holds it. */
|
|
232
|
+
function cached(view: unknown, ifNoneMatch: string | null): Response {
|
|
233
|
+
const etag = weakEtag(view);
|
|
234
|
+
if (etagMatches(ifNoneMatch, etag)) {
|
|
235
|
+
return new Response(null, { status: 304, headers: { ETag: etag, "Cache-Control": "no-cache" } });
|
|
236
|
+
}
|
|
237
|
+
return json(200, view, { ETag: etag });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function siteDoor(site: MockSite, rest: string[], params: URLSearchParams, ifNoneMatch: string | null): Response {
|
|
241
|
+
if (rest.length === 0) return cached(manifest(site), ifNoneMatch);
|
|
242
|
+
if (rest[0] !== "collections" || rest.length > 3 || !Object.hasOwn(site.collections, rest[1])) {
|
|
243
|
+
return json(404, NOT_FOUND);
|
|
244
|
+
}
|
|
245
|
+
const c = site.collections[rest[1]];
|
|
246
|
+
if (rest.length === 2) {
|
|
247
|
+
const q = parseQuery(params, c);
|
|
248
|
+
if (typeof q === "string") return json(400, { error: q });
|
|
249
|
+
const sorted = [...c.records].sort(compareRecords(q.sort));
|
|
250
|
+
const page: PublicCollectionPage = {
|
|
251
|
+
items: sorted.slice(q.offset, q.offset + q.limit).map(summary),
|
|
252
|
+
total: sorted.length,
|
|
253
|
+
offset: q.offset,
|
|
254
|
+
limit: q.limit,
|
|
255
|
+
};
|
|
256
|
+
return cached(page, ifNoneMatch);
|
|
257
|
+
}
|
|
258
|
+
const address = rest[2];
|
|
259
|
+
const byPublicId = PUBLIC_ID_RE.test(address);
|
|
260
|
+
if (!byPublicId && !(address.length <= SLUG_MAX_LENGTH && SLUG_RE.test(address))) return json(404, NOT_FOUND);
|
|
261
|
+
const record = c.records.find((r) => (byPublicId ? r.publicId === address : r.slug === address));
|
|
262
|
+
if (!record) return json(404, NOT_FOUND);
|
|
263
|
+
if (params.has("embed")) return json(400, { error: "embed is not available" });
|
|
264
|
+
return cached(record, ifNoneMatch);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The door's 422 for answers that do not satisfy the form (required, enum). */
|
|
268
|
+
function invalidKeys(form: PublicForm, body: unknown): string[] {
|
|
269
|
+
const fields = (body as { fields?: unknown } | null)?.fields;
|
|
270
|
+
if (typeof fields !== "object" || fields === null) return [];
|
|
271
|
+
const answers = fields as Record<string, unknown>;
|
|
272
|
+
return form.fields
|
|
273
|
+
.filter((f) => {
|
|
274
|
+
const v = answers[f.key];
|
|
275
|
+
if (v === undefined || v === "" || v === null) return f.required === true;
|
|
276
|
+
if (f.type === "boolean") return typeof v !== "boolean" || (f.required === true && v !== true);
|
|
277
|
+
const allowed = f.constraints?.enum;
|
|
278
|
+
return allowed !== undefined && !allowed.includes(String(v));
|
|
279
|
+
})
|
|
280
|
+
.map((f) => f.key);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function createMockPod(options: MockPodOptions = {}): MockPod {
|
|
284
|
+
const forms = options.forms ?? { [MOCK_TOKENS.form]: MOCK_FORM };
|
|
285
|
+
const shares = options.shares ?? { [MOCK_TOKENS.share]: MOCK_SHARE };
|
|
286
|
+
const sites = options.sites ?? { [MOCK_TOKENS.site]: MOCK_SITE };
|
|
287
|
+
const requests: MockRequest[] = [];
|
|
288
|
+
const submissions: MockSubmission[] = [];
|
|
289
|
+
|
|
290
|
+
const mockFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
291
|
+
const request = typeof input === "object" && "method" in input ? input : null;
|
|
292
|
+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
293
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
294
|
+
const headers: Record<string, string> = {};
|
|
295
|
+
new Headers(init?.headers ?? request?.headers).forEach((value, name) => {
|
|
296
|
+
headers[name.toLowerCase()] = value;
|
|
297
|
+
});
|
|
298
|
+
const credentials = init?.credentials ?? request?.credentials;
|
|
299
|
+
requests.push({ method, url: url.href, headers, credentials });
|
|
300
|
+
|
|
301
|
+
// What a browser does against the real doors (`Access-Control-Allow-Origin: *`).
|
|
302
|
+
if (credentials === "include") throw new TypeError("Failed to fetch: credentialed request to a public door");
|
|
303
|
+
if (options.browser) {
|
|
304
|
+
const preflighted = Object.keys(headers).filter((h) => !SAFELISTED.has(h) && !PREFLIGHT_ALLOWED.has(h));
|
|
305
|
+
if (preflighted.length > 0) throw new TypeError(`Failed to fetch: preflight refuses ${preflighted.join(", ")}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const m = url.pathname.match(/^\/api\/hub\/public\/(forms|shares|sites)\/(.+)$/);
|
|
309
|
+
if (!m) return json(404, NOT_FOUND);
|
|
310
|
+
let segments: string[];
|
|
311
|
+
try {
|
|
312
|
+
segments = m[2].split("/").map(decodeURIComponent);
|
|
313
|
+
} catch {
|
|
314
|
+
return json(404, NOT_FOUND);
|
|
315
|
+
}
|
|
316
|
+
const [token, ...rest] = segments;
|
|
317
|
+
const failed = failure(token);
|
|
318
|
+
if (failed === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
319
|
+
if (failed) return failed;
|
|
320
|
+
|
|
321
|
+
const door = m[1];
|
|
322
|
+
if (door === "forms" && method === "POST" && rest.length === 0) {
|
|
323
|
+
let body: unknown = null;
|
|
324
|
+
try {
|
|
325
|
+
body = JSON.parse(typeof init?.body === "string" ? init.body : "null");
|
|
326
|
+
} catch {
|
|
327
|
+
body = null;
|
|
328
|
+
}
|
|
329
|
+
const form = Object.hasOwn(forms, token) ? forms[token] : undefined;
|
|
330
|
+
const invalid = form ? invalidKeys(form, body) : [];
|
|
331
|
+
const status = invalid.length > 0 ? 422 : 202;
|
|
332
|
+
submissions.push({ token, body, status });
|
|
333
|
+
return status === 422 ? json(422, { received: false, invalid }) : json(202, { received: true });
|
|
334
|
+
}
|
|
335
|
+
if (method !== "GET") return json(404, NOT_FOUND);
|
|
336
|
+
if (door === "forms" && rest.length === 0 && Object.hasOwn(forms, token)) return json(200, forms[token]);
|
|
337
|
+
if (door === "shares" && rest.length === 0 && Object.hasOwn(shares, token)) return json(200, shares[token]);
|
|
338
|
+
if (door === "sites" && Object.hasOwn(sites, token)) {
|
|
339
|
+
return siteDoor(sites[token], rest, url.searchParams, headers["if-none-match"] ?? null);
|
|
340
|
+
}
|
|
341
|
+
return json(404, NOT_FOUND);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
podUrl: MOCK_POD_URL,
|
|
346
|
+
fetch: mockFetch as typeof fetch,
|
|
347
|
+
requests,
|
|
348
|
+
submissions,
|
|
349
|
+
reset() {
|
|
350
|
+
requests.length = 0;
|
|
351
|
+
submissions.length = 0;
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|