@synap-core/sdk 1.0.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 +82 -4
- 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 +21 -9
- 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/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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
|
|
@@ -64,6 +70,78 @@ type EntityListInput = RouterInputs["entities"]["list"];
|
|
|
64
70
|
type EntityListOutput = RouterOutputs["entities"]["list"];
|
|
65
71
|
```
|
|
66
72
|
|
|
73
|
+
## Public doors (no credentials): `@synap-core/sdk/public`
|
|
74
|
+
|
|
75
|
+
A tiny, dependency-free fetch client for the pod's credentialless public doors
|
|
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`.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { createPublicClient } from "@synap-core/sdk/public";
|
|
85
|
+
|
|
86
|
+
const pub = createPublicClient({ podUrl: "https://your-pod.synap.live" });
|
|
87
|
+
|
|
88
|
+
const share = await pub.readShare(token); // { ok: true, data } | { ok: false, error }
|
|
89
|
+
const form = await pub.getForm(formToken); // fields, honeypotField, ticket, captcha
|
|
90
|
+
const sent = await pub.submitForm(formToken, {
|
|
91
|
+
fields: { email: "a@b.co" },
|
|
92
|
+
ticket: form.ok ? form.data.ticket : undefined,
|
|
93
|
+
captchaToken, // when form.data.captcha.required
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Errors are typed: `not_found` (the uniform 404: unknown, revoked and expired are
|
|
98
|
+
indistinguishable), `rate_limited`, `network`, `server`. A submit always answers
|
|
99
|
+
`{ ok: true, received: true }` on the pod's constant 202; the pod silently drops
|
|
100
|
+
too-fast, honeypot, captcha-failed and over-cap submissions, so wait out the form's
|
|
101
|
+
minimum time before submitting. Pass `fetch` to inject your own.
|
|
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
|
+
|
|
67
145
|
## React
|
|
68
146
|
|
|
69
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 };
|