@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 CHANGED
@@ -17,7 +17,7 @@ import { createSynapClient } from "@synap-core/sdk";
17
17
 
18
18
  const synap = createSynapClient({
19
19
  podUrl: "https://your-pod.synap.live", // your data pod URL
20
- apiKey: "sk_live_...", // from Workspace → Settings → API Keys
20
+ sessionToken: "...", // Kratos session token (see Authentication)
21
21
  workspaceId: "ws_...", // your workspace ID
22
22
  });
23
23
 
@@ -35,23 +35,29 @@ await synap.entities.create.mutate({
35
35
 
36
36
  ## Authentication
37
37
 
38
- **API key (server-side / external apps)**
38
+ **Session token (non-browser clients: extensions, CLIs, mobile)**
39
39
 
40
40
  ```typescript
41
41
  const synap = createSynapClient({
42
42
  podUrl: "https://your-pod.synap.live",
43
- apiKey: "sk_live_...", // Bearer token — generate in workspace settings
43
+ sessionToken: "...", // Kratos session token from an API (native) login flow
44
44
  workspaceId: "ws_...",
45
45
  });
46
46
  ```
47
47
 
48
+ **`apiKey` is for the local-mode token only.** It sends `Authorization: Bearer`
49
+ to `/trpc`, which on a real pod is behind Ory session auth and refuses hub / user
50
+ API keys (`synap_hub_*`, `synap_user_*`). `createSynapClient` throws early if you
51
+ pass one. For hub / user API keys use
52
+ `@synap-core/hub-rest-client` against `/api/hub/*`.
53
+
48
54
  **Session cookie (browser — after Kratos login)**
49
55
 
50
56
  ```typescript
51
57
  const synap = createSynapClient({
52
58
  podUrl: "https://your-pod.synap.live",
53
59
  workspaceId: "ws_...",
54
- // omit apiKey — credentials: "include" is set automatically
60
+ // omit apiKey/sessionToken — credentials: "include" is set automatically
55
61
  });
56
62
  ```
57
63
 
@@ -67,8 +73,12 @@ type EntityListOutput = RouterOutputs["entities"]["list"];
67
73
  ## Public doors (no credentials): `@synap-core/sdk/public`
68
74
 
69
75
  A tiny, dependency-free fetch client for the pod's credentialless public doors
70
- (published shares, guest forms). It never sends credentials (`credentials: 'omit'`,
71
- no Authorization/Cookie) and works from any origin, so it is safe in a public site.
76
+ (published shares, guest forms, published sites). It never sends credentials
77
+ (`credentials: 'omit'`, no Authorization/Cookie) and works from any origin, so it
78
+ is safe in a public site. The same entry ships the framework-free view-models a
79
+ site renders (no React): `toFieldModels` / `validate` / `submitFailure` (forms),
80
+ `toSharePageModel`, `parseSafeMarkdown` (data, never HTML), `formatPublicValue`,
81
+ `toCollectionPageModel` / `pageQuery` / `parsePageParam`, `toRecordViewModel`.
72
82
 
73
83
  ```typescript
74
84
  import { createPublicClient } from "@synap-core/sdk/public";
@@ -90,6 +100,48 @@ indistinguishable), `rate_limited`, `network`, `server`. A submit always answers
90
100
  too-fast, honeypot, captcha-failed and over-cap submissions, so wait out the form's
91
101
  minimum time before submitting. Pass `fetch` to inject your own.
92
102
 
103
+ ### Published sites (1.2.0)
104
+
105
+ ```typescript
106
+ const site = await pub.getSite(siteToken); // manifest: title?, collections[{ key, count, sort, pageSize, addressing }]
107
+ const page = await pub.listCollection(siteToken, "paper", { offset: 20, limit: 10, sort: ["-year"] });
108
+ const rec = await pub.getRecord(siteToken, "paper", "on-graphs"); // slug or "p_…" public id
109
+ ```
110
+
111
+ Site reads fail with `not_found` (one uniform miss for token, collection and
112
+ record), `doors_disabled`, `bad_request` (sent only when the door says 400: the
113
+ SDK refuses out-of-bounds `offset`/`limit`/`sort` locally, without a request),
114
+ `rate_limited`, `pod_unreachable` (no answer, or a 502/503/504) or `server`.
115
+ `rate_limited` carries `retryAfterSec` only when the response has a readable
116
+ `Retry-After`; the pod's public doors do not send one today, so expect
117
+ `undefined`. Successes carry the door's weak `etag`; a server may revalidate with
118
+ `{ ifNoneMatch: etag }` and get `{ ok: true, notModified: true }` on a 304. Do not
119
+ pass `ifNoneMatch` from a browser: the doors allow no cross-origin request header
120
+ but `Content-Type`, so the preflight fails (browsers revalidate on their own).
121
+
122
+ ## Mock pod: `@synap-core/sdk/testing`
123
+
124
+ A fake `fetch` that answers the public doors (forms, shares, sites) from fixtures,
125
+ with no network, for tests, a site's mock mode and a states gallery. Inject it
126
+ into the real client so everything above the wire is the production path:
127
+
128
+ ```typescript
129
+ import { createPublicClient } from "@synap-core/sdk/public";
130
+ import { createMockPod, MOCK_TOKENS, MOCK_FAILURE_TOKENS } from "@synap-core/sdk/testing";
131
+
132
+ const pod = createMockPod(); // or createMockPod({ sites: { mine: { … } } })
133
+ const pub = createPublicClient({ podUrl: pod.podUrl, fetch: pod.fetch });
134
+
135
+ await pub.listCollection(MOCK_TOKENS.site, "paper", { offset: 5 }); // demo site, 12 papers
136
+ await pub.getSite(MOCK_FAILURE_TOKENS.rateLimited); // { ok: false, error: "rate_limited" }
137
+ pod.requests; // every request (method, url, headers, credentials)
138
+ ```
139
+
140
+ It mirrors the doors: the uniform 404, the constant 202 (422 for missing
141
+ required or off-list answers), the collection query bounds, weak ETags and 304.
142
+ A `credentials: "include"` request rejects, as it would in a browser;
143
+ `createMockPod({ browser: true })` also rejects headers a CORS preflight refuses.
144
+
93
145
  ## React
94
146
 
95
147
  For React apps use [`@synap-core/react`](https://www.npmjs.com/package/@synap-core/react) which wraps this SDK with TanStack Query hooks.
@@ -0,0 +1,253 @@
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
+ interface PublicShare {
35
+ resourceType: "entity";
36
+ /** Present only when the publisher allowlisted `title`. */
37
+ title?: string;
38
+ properties: Record<string, string | number | boolean>;
39
+ body: {
40
+ format: "markdown";
41
+ content: string;
42
+ } | null;
43
+ /** Publication day, YYYY-MM-DD. */
44
+ publishedOn: string;
45
+ }
46
+ type PublicFormFieldType = "text" | "richtext" | "email" | "phone" | "url" | "number" | "boolean" | "date" | "enum";
47
+ interface PublicFormField {
48
+ key: string;
49
+ label: string;
50
+ type: PublicFormFieldType;
51
+ required?: true;
52
+ help?: string;
53
+ constraints?: {
54
+ enum?: string[];
55
+ min?: number;
56
+ max?: number;
57
+ maxLength?: number;
58
+ };
59
+ }
60
+ interface PublicForm {
61
+ title: string;
62
+ fields: PublicFormField[];
63
+ successMessage: string;
64
+ /** The envelope key of the honeypot (always "hp" today). */
65
+ honeypotField: string;
66
+ /** Time-to-submit ticket, minted by this GET; pass it back to `submitForm`. */
67
+ ticket: string;
68
+ /**
69
+ * The POST is refused with `retry` until this many ms have passed since the
70
+ * ticket was minted. Hold the submit button until then. 0 = no wait.
71
+ */
72
+ minSubmitMs: number;
73
+ captcha: {
74
+ required: true;
75
+ siteKey: string | null;
76
+ } | {
77
+ required: false;
78
+ };
79
+ }
80
+ /** A published value: a publish-time snapshot, scalars only (C1b). */
81
+ type PublicValue = string | number | boolean;
82
+ interface PublicSiteCollection {
83
+ /** The collection key (a profile slug); pass it to `listCollection`. */
84
+ key: string;
85
+ /** Live published records in it. */
86
+ count: number;
87
+ /** The default sort keys (`-` = descending). */
88
+ sort: string[];
89
+ /** The default page size. */
90
+ pageSize: number;
91
+ /** How a record of this collection is addressed in URLs. */
92
+ addressing: "slug" | "publicId";
93
+ }
94
+ /** A published site's manifest. */
95
+ interface PublicSite {
96
+ /** Present only when the owner's policy publishes `title`. */
97
+ title?: string;
98
+ /** Present only when the owner's policy publishes `description`. */
99
+ description?: string;
100
+ /** Day the site went live, YYYY-MM-DD. */
101
+ publishedOn: string;
102
+ collections: PublicSiteCollection[];
103
+ }
104
+ /** One published record as a list item (no body). */
105
+ interface PublicRecordSummary {
106
+ collection: string;
107
+ /** The stable public address: `p_` + 16 random base62 chars. */
108
+ publicId: string;
109
+ /** The record's slug in its collection, or null. */
110
+ slug: string | null;
111
+ /** Present only when the owner's policy publishes `title`. */
112
+ title?: string;
113
+ properties: Record<string, PublicValue>;
114
+ /** Publication day, YYYY-MM-DD. */
115
+ publishedOn: string;
116
+ }
117
+ interface PublicCollectionPage {
118
+ items: PublicRecordSummary[];
119
+ /** Live published records in the collection. */
120
+ total: number;
121
+ offset: number;
122
+ limit: number;
123
+ }
124
+ /** One published record, with its pinned body. */
125
+ interface PublicRecord extends PublicRecordSummary {
126
+ body: {
127
+ format: "markdown";
128
+ content: string;
129
+ } | null;
130
+ }
131
+ /** The collection door's query bounds (backend `parseCollectionQuery`). */
132
+ declare const SITE_MAX_OFFSET = 10000;
133
+ declare const SITE_MAX_LIMIT = 100;
134
+ declare const SITE_MAX_SORT_KEYS = 3;
135
+ /** The sort key every collection accepts, besides its configured ones. */
136
+ declare const SITE_PUBLISHED_AT_SORT = "publishedAt";
137
+ /** `not_found` is the uniform 404 (never says revoked vs expired vs unknown). */
138
+ type PublicReadError = "not_found" | "rate_limited"
139
+ /** The owner has not turned public pages/forms on for this workspace. */
140
+ | "doors_disabled" | "network" | "server";
141
+ /** A submit never yields `not_found`: the door answers 202 to everything. */
142
+ type PublicSubmitError = "invalid"
143
+ /** The ticket was refused, even after the one automatic retry. */
144
+ | "retry"
145
+ /** 503: the pod failed to file it; resubmitting with the same key is safe. */
146
+ | "unavailable" | "rate_limited"
147
+ /** The owner has not turned public pages/forms on for this workspace. */
148
+ | "doors_disabled" | "network" | "server";
149
+ /**
150
+ * A site read's failure. `not_found` is the uniform 404 (an unknown, revoked
151
+ * or unpublished site, collection or record are indistinguishable).
152
+ */
153
+ type PublicSiteError = "not_found" | "rate_limited"
154
+ /** The site is live but the owner switched its space's public pages off. */
155
+ | "doors_disabled"
156
+ /** 400: the query is out of bounds or names a sort key the collection does not offer. */
157
+ | "bad_request"
158
+ /** No answer (network, DNS, CORS) or a gateway 502/503/504. */
159
+ | "pod_unreachable" | "server";
160
+ /**
161
+ * A failure. `rate_limited` carries `retryAfterSec` only when the response
162
+ * had a `Retry-After` header the caller can read (see README: the pod's public
163
+ * doors do not send one today, and a browser cannot read it cross-origin).
164
+ */
165
+ type PublicFailure<E extends string> = E extends "rate_limited" ? {
166
+ ok: false;
167
+ error: E;
168
+ retryAfterSec?: number;
169
+ } : {
170
+ ok: false;
171
+ error: E;
172
+ };
173
+ type PublicResult<T, E extends string = PublicReadError> = {
174
+ ok: true;
175
+ data: T;
176
+ } | PublicFailure<E>;
177
+ /** A site read. `etag` is the door's weak validator, for `ifNoneMatch`. */
178
+ type PublicSiteResult<T> = {
179
+ ok: true;
180
+ data: T;
181
+ etag: string | null;
182
+ } | PublicFailure<PublicSiteError>;
183
+ /** 304: what the caller holds for `ifNoneMatch` is still current. */
184
+ interface PublicNotModified {
185
+ ok: true;
186
+ notModified: true;
187
+ etag: string;
188
+ }
189
+ /**
190
+ * Revalidate with the ETag of an earlier answer. SERVER-SIDE ONLY: the public
191
+ * doors allow no request header but `Content-Type` cross-origin, so a browser
192
+ * fails the preflight (→ `pod_unreachable`). Browsers revalidate on their own
193
+ * through their HTTP cache (`Cache-Control: no-cache` + ETag).
194
+ */
195
+ interface Revalidate {
196
+ ifNoneMatch: string;
197
+ }
198
+ interface CollectionQuery {
199
+ /** 0..10 000 (default 0). Anything else is `bad_request`, without a request. */
200
+ offset?: number;
201
+ /** An integer, clamped to 1..100 (default: the collection's page size). */
202
+ limit?: number;
203
+ /**
204
+ * ≤ 3 keys, `-` = descending, out of the collection's `sort` keys and
205
+ * `publishedAt` (default: the collection's sort). A string is comma-split.
206
+ */
207
+ sort?: string | readonly string[];
208
+ }
209
+ type SubmitResult = {
210
+ ok: true;
211
+ received: true;
212
+ }
213
+ /** 422: the answers do not satisfy the form. `fields` are field keys; empty = not a submission. */
214
+ | {
215
+ ok: false;
216
+ error: "invalid";
217
+ fields: string[];
218
+ } | PublicFailure<Exclude<PublicSubmitError, "invalid">>;
219
+ interface SubmitFormInput {
220
+ /** Answers keyed by the form's field `key`s. */
221
+ fields: Record<string, unknown>;
222
+ /** The `ticket` from `getForm` (waiting out the form's min time is the caller's job). */
223
+ ticket?: string;
224
+ /** Turnstile-style token, when `captcha.required`. */
225
+ captchaToken?: string;
226
+ /** Optional replay key; a repeated key collapses onto one submission. */
227
+ idempotencyKey?: string;
228
+ }
229
+ interface PublicClientOptions {
230
+ /** Pod origin, e.g. `https://you.synap.live` (a trailing slash is ignored). */
231
+ podUrl: string;
232
+ /** Injectable fetch; defaults to the global one. */
233
+ fetch?: typeof fetch;
234
+ /** Injectable wait (tests); defaults to `setTimeout`. */
235
+ sleep?: (ms: number) => Promise<void>;
236
+ }
237
+ interface PublicClient {
238
+ readShare(token: string): Promise<PublicResult<PublicShare>>;
239
+ getForm(token: string): Promise<PublicResult<PublicForm>>;
240
+ submitForm(token: string, input: SubmitFormInput): Promise<SubmitResult>;
241
+ /** A published site's manifest: its metadata and configured collections. */
242
+ getSite(siteToken: string): Promise<PublicSiteResult<PublicSite>>;
243
+ getSite(siteToken: string, opts: Revalidate): Promise<PublicSiteResult<PublicSite> | PublicNotModified>;
244
+ /** One page of a collection's live published records (no bodies). */
245
+ listCollection(siteToken: string, key: string, query?: CollectionQuery): Promise<PublicSiteResult<PublicCollectionPage>>;
246
+ listCollection(siteToken: string, key: string, query: CollectionQuery & Revalidate): Promise<PublicSiteResult<PublicCollectionPage> | PublicNotModified>;
247
+ /** One record by its public id (`p_…`) or its slug, with its pinned body. */
248
+ getRecord(siteToken: string, key: string, slugOrPublicId: string): Promise<PublicSiteResult<PublicRecord>>;
249
+ getRecord(siteToken: string, key: string, slugOrPublicId: string, opts: Revalidate): Promise<PublicSiteResult<PublicRecord> | PublicNotModified>;
250
+ }
251
+ declare function createPublicClient(opts: PublicClientOptions): PublicClient;
252
+
253
+ export { type CollectionQuery as C, type PublicForm as P, type Revalidate as R, type SubmitResult as S, type PublicReadError as a, type PublicRecordSummary as b, type PublicValue as c, type PublicCollectionPage as d, type PublicRecord as e, type PublicResult as f, type PublicShare as g, type PublicClient as h, type PublicClientOptions as i, type PublicFailure as j, type PublicFormField as k, type PublicFormFieldType as l, type PublicNotModified as m, type PublicSite as n, type PublicSiteCollection as o, type PublicSiteError as p, type PublicSiteResult as q, type PublicSubmitError as r, SITE_MAX_LIMIT as s, SITE_MAX_OFFSET as t, SITE_MAX_SORT_KEYS as u, SITE_PUBLISHED_AT_SORT as v, type SubmitFormInput as w, createPublicClient as x };
@@ -0,0 +1,253 @@
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
+ interface PublicShare {
35
+ resourceType: "entity";
36
+ /** Present only when the publisher allowlisted `title`. */
37
+ title?: string;
38
+ properties: Record<string, string | number | boolean>;
39
+ body: {
40
+ format: "markdown";
41
+ content: string;
42
+ } | null;
43
+ /** Publication day, YYYY-MM-DD. */
44
+ publishedOn: string;
45
+ }
46
+ type PublicFormFieldType = "text" | "richtext" | "email" | "phone" | "url" | "number" | "boolean" | "date" | "enum";
47
+ interface PublicFormField {
48
+ key: string;
49
+ label: string;
50
+ type: PublicFormFieldType;
51
+ required?: true;
52
+ help?: string;
53
+ constraints?: {
54
+ enum?: string[];
55
+ min?: number;
56
+ max?: number;
57
+ maxLength?: number;
58
+ };
59
+ }
60
+ interface PublicForm {
61
+ title: string;
62
+ fields: PublicFormField[];
63
+ successMessage: string;
64
+ /** The envelope key of the honeypot (always "hp" today). */
65
+ honeypotField: string;
66
+ /** Time-to-submit ticket, minted by this GET; pass it back to `submitForm`. */
67
+ ticket: string;
68
+ /**
69
+ * The POST is refused with `retry` until this many ms have passed since the
70
+ * ticket was minted. Hold the submit button until then. 0 = no wait.
71
+ */
72
+ minSubmitMs: number;
73
+ captcha: {
74
+ required: true;
75
+ siteKey: string | null;
76
+ } | {
77
+ required: false;
78
+ };
79
+ }
80
+ /** A published value: a publish-time snapshot, scalars only (C1b). */
81
+ type PublicValue = string | number | boolean;
82
+ interface PublicSiteCollection {
83
+ /** The collection key (a profile slug); pass it to `listCollection`. */
84
+ key: string;
85
+ /** Live published records in it. */
86
+ count: number;
87
+ /** The default sort keys (`-` = descending). */
88
+ sort: string[];
89
+ /** The default page size. */
90
+ pageSize: number;
91
+ /** How a record of this collection is addressed in URLs. */
92
+ addressing: "slug" | "publicId";
93
+ }
94
+ /** A published site's manifest. */
95
+ interface PublicSite {
96
+ /** Present only when the owner's policy publishes `title`. */
97
+ title?: string;
98
+ /** Present only when the owner's policy publishes `description`. */
99
+ description?: string;
100
+ /** Day the site went live, YYYY-MM-DD. */
101
+ publishedOn: string;
102
+ collections: PublicSiteCollection[];
103
+ }
104
+ /** One published record as a list item (no body). */
105
+ interface PublicRecordSummary {
106
+ collection: string;
107
+ /** The stable public address: `p_` + 16 random base62 chars. */
108
+ publicId: string;
109
+ /** The record's slug in its collection, or null. */
110
+ slug: string | null;
111
+ /** Present only when the owner's policy publishes `title`. */
112
+ title?: string;
113
+ properties: Record<string, PublicValue>;
114
+ /** Publication day, YYYY-MM-DD. */
115
+ publishedOn: string;
116
+ }
117
+ interface PublicCollectionPage {
118
+ items: PublicRecordSummary[];
119
+ /** Live published records in the collection. */
120
+ total: number;
121
+ offset: number;
122
+ limit: number;
123
+ }
124
+ /** One published record, with its pinned body. */
125
+ interface PublicRecord extends PublicRecordSummary {
126
+ body: {
127
+ format: "markdown";
128
+ content: string;
129
+ } | null;
130
+ }
131
+ /** The collection door's query bounds (backend `parseCollectionQuery`). */
132
+ declare const SITE_MAX_OFFSET = 10000;
133
+ declare const SITE_MAX_LIMIT = 100;
134
+ declare const SITE_MAX_SORT_KEYS = 3;
135
+ /** The sort key every collection accepts, besides its configured ones. */
136
+ declare const SITE_PUBLISHED_AT_SORT = "publishedAt";
137
+ /** `not_found` is the uniform 404 (never says revoked vs expired vs unknown). */
138
+ type PublicReadError = "not_found" | "rate_limited"
139
+ /** The owner has not turned public pages/forms on for this workspace. */
140
+ | "doors_disabled" | "network" | "server";
141
+ /** A submit never yields `not_found`: the door answers 202 to everything. */
142
+ type PublicSubmitError = "invalid"
143
+ /** The ticket was refused, even after the one automatic retry. */
144
+ | "retry"
145
+ /** 503: the pod failed to file it; resubmitting with the same key is safe. */
146
+ | "unavailable" | "rate_limited"
147
+ /** The owner has not turned public pages/forms on for this workspace. */
148
+ | "doors_disabled" | "network" | "server";
149
+ /**
150
+ * A site read's failure. `not_found` is the uniform 404 (an unknown, revoked
151
+ * or unpublished site, collection or record are indistinguishable).
152
+ */
153
+ type PublicSiteError = "not_found" | "rate_limited"
154
+ /** The site is live but the owner switched its space's public pages off. */
155
+ | "doors_disabled"
156
+ /** 400: the query is out of bounds or names a sort key the collection does not offer. */
157
+ | "bad_request"
158
+ /** No answer (network, DNS, CORS) or a gateway 502/503/504. */
159
+ | "pod_unreachable" | "server";
160
+ /**
161
+ * A failure. `rate_limited` carries `retryAfterSec` only when the response
162
+ * had a `Retry-After` header the caller can read (see README: the pod's public
163
+ * doors do not send one today, and a browser cannot read it cross-origin).
164
+ */
165
+ type PublicFailure<E extends string> = E extends "rate_limited" ? {
166
+ ok: false;
167
+ error: E;
168
+ retryAfterSec?: number;
169
+ } : {
170
+ ok: false;
171
+ error: E;
172
+ };
173
+ type PublicResult<T, E extends string = PublicReadError> = {
174
+ ok: true;
175
+ data: T;
176
+ } | PublicFailure<E>;
177
+ /** A site read. `etag` is the door's weak validator, for `ifNoneMatch`. */
178
+ type PublicSiteResult<T> = {
179
+ ok: true;
180
+ data: T;
181
+ etag: string | null;
182
+ } | PublicFailure<PublicSiteError>;
183
+ /** 304: what the caller holds for `ifNoneMatch` is still current. */
184
+ interface PublicNotModified {
185
+ ok: true;
186
+ notModified: true;
187
+ etag: string;
188
+ }
189
+ /**
190
+ * Revalidate with the ETag of an earlier answer. SERVER-SIDE ONLY: the public
191
+ * doors allow no request header but `Content-Type` cross-origin, so a browser
192
+ * fails the preflight (→ `pod_unreachable`). Browsers revalidate on their own
193
+ * through their HTTP cache (`Cache-Control: no-cache` + ETag).
194
+ */
195
+ interface Revalidate {
196
+ ifNoneMatch: string;
197
+ }
198
+ interface CollectionQuery {
199
+ /** 0..10 000 (default 0). Anything else is `bad_request`, without a request. */
200
+ offset?: number;
201
+ /** An integer, clamped to 1..100 (default: the collection's page size). */
202
+ limit?: number;
203
+ /**
204
+ * ≤ 3 keys, `-` = descending, out of the collection's `sort` keys and
205
+ * `publishedAt` (default: the collection's sort). A string is comma-split.
206
+ */
207
+ sort?: string | readonly string[];
208
+ }
209
+ type SubmitResult = {
210
+ ok: true;
211
+ received: true;
212
+ }
213
+ /** 422: the answers do not satisfy the form. `fields` are field keys; empty = not a submission. */
214
+ | {
215
+ ok: false;
216
+ error: "invalid";
217
+ fields: string[];
218
+ } | PublicFailure<Exclude<PublicSubmitError, "invalid">>;
219
+ interface SubmitFormInput {
220
+ /** Answers keyed by the form's field `key`s. */
221
+ fields: Record<string, unknown>;
222
+ /** The `ticket` from `getForm` (waiting out the form's min time is the caller's job). */
223
+ ticket?: string;
224
+ /** Turnstile-style token, when `captcha.required`. */
225
+ captchaToken?: string;
226
+ /** Optional replay key; a repeated key collapses onto one submission. */
227
+ idempotencyKey?: string;
228
+ }
229
+ interface PublicClientOptions {
230
+ /** Pod origin, e.g. `https://you.synap.live` (a trailing slash is ignored). */
231
+ podUrl: string;
232
+ /** Injectable fetch; defaults to the global one. */
233
+ fetch?: typeof fetch;
234
+ /** Injectable wait (tests); defaults to `setTimeout`. */
235
+ sleep?: (ms: number) => Promise<void>;
236
+ }
237
+ interface PublicClient {
238
+ readShare(token: string): Promise<PublicResult<PublicShare>>;
239
+ getForm(token: string): Promise<PublicResult<PublicForm>>;
240
+ submitForm(token: string, input: SubmitFormInput): Promise<SubmitResult>;
241
+ /** A published site's manifest: its metadata and configured collections. */
242
+ getSite(siteToken: string): Promise<PublicSiteResult<PublicSite>>;
243
+ getSite(siteToken: string, opts: Revalidate): Promise<PublicSiteResult<PublicSite> | PublicNotModified>;
244
+ /** One page of a collection's live published records (no bodies). */
245
+ listCollection(siteToken: string, key: string, query?: CollectionQuery): Promise<PublicSiteResult<PublicCollectionPage>>;
246
+ listCollection(siteToken: string, key: string, query: CollectionQuery & Revalidate): Promise<PublicSiteResult<PublicCollectionPage> | PublicNotModified>;
247
+ /** One record by its public id (`p_…`) or its slug, with its pinned body. */
248
+ getRecord(siteToken: string, key: string, slugOrPublicId: string): Promise<PublicSiteResult<PublicRecord>>;
249
+ getRecord(siteToken: string, key: string, slugOrPublicId: string, opts: Revalidate): Promise<PublicSiteResult<PublicRecord> | PublicNotModified>;
250
+ }
251
+ declare function createPublicClient(opts: PublicClientOptions): PublicClient;
252
+
253
+ export { type CollectionQuery as C, type PublicForm as P, type Revalidate as R, type SubmitResult as S, type PublicReadError as a, type PublicRecordSummary as b, type PublicValue as c, type PublicCollectionPage as d, type PublicRecord as e, type PublicResult as f, type PublicShare as g, type PublicClient as h, type PublicClientOptions as i, type PublicFailure as j, type PublicFormField as k, type PublicFormFieldType as l, type PublicNotModified as m, type PublicSite as n, type PublicSiteCollection as o, type PublicSiteError as p, type PublicSiteResult as q, type PublicSubmitError as r, SITE_MAX_LIMIT as s, SITE_MAX_OFFSET as t, SITE_MAX_SORT_KEYS as u, SITE_PUBLISHED_AT_SORT as v, type SubmitFormInput as w, createPublicClient as x };