@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/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
|
-
}
|