@synap-core/sdk 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -6
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/client-CVUT1Mcf.d.cts +253 -0
- package/dist/client-CVUT1Mcf.d.ts +253 -0
- package/dist/connect.browser.cjs +22 -0
- package/dist/connect.browser.cjs.map +1 -0
- package/dist/connect.browser.d.cts +2 -0
- package/dist/connect.browser.d.ts +2 -0
- package/dist/connect.browser.js +5 -0
- package/dist/connect.browser.js.map +1 -0
- package/dist/connect.cjs +1206 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.cts +442 -0
- package/dist/connect.d.ts +442 -0
- package/dist/connect.js +1092 -0
- package/dist/connect.js.map +1 -0
- package/dist/index.cjs +48 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +118 -7
- package/dist/index.d.ts +118 -7
- package/dist/index.js +41 -2
- package/dist/index.js.map +1 -1
- package/dist/public.cjs +627 -0
- package/dist/public.cjs.map +1 -0
- package/dist/public.d.cts +228 -0
- package/dist/public.d.ts +228 -0
- package/dist/public.js +578 -0
- package/dist/public.js.map +1 -0
- package/dist/testing.cjs +671 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +181 -0
- package/dist/testing.d.ts +181 -0
- package/dist/testing.js +614 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-DpqPNaaa.d.cts +76 -0
- package/dist/types-DpqPNaaa.d.ts +76 -0
- package/package.json +16 -3
- package/src/client.ts +24 -7
- package/src/connect/auth.ts +405 -0
- package/src/connect/browser.ts +10 -0
- package/src/connect/client.ts +193 -0
- package/src/connect/discovery.ts +262 -0
- package/src/connect/encoding.ts +93 -0
- package/src/connect/errors.ts +115 -0
- package/src/connect/flow.ts +449 -0
- package/src/connect/guard.ts +25 -0
- package/src/connect/index.ts +59 -0
- package/src/connect/seal.ts +119 -0
- package/src/connect/store.ts +151 -0
- package/src/connect/types.ts +80 -0
- package/src/index.ts +13 -0
- package/src/public/client.ts +579 -0
- package/src/public/form-model.ts +210 -0
- package/src/public/index.ts +12 -0
- package/src/public/safe-markdown.ts +114 -0
- package/src/public/share-model.ts +47 -0
- package/src/public/site-model.ts +217 -0
- package/src/templates.ts +122 -0
- package/src/testing/auth-server.ts +380 -0
- package/src/testing/fixtures.ts +132 -0
- package/src/testing/index.ts +359 -0
- package/src/public.ts +0 -262
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
|
|
|
@@ -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
|
|
71
|
-
no Authorization/Cookie) and works from any origin, so it
|
|
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,128 @@ 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
|
+
## Pod-app sign-in (server-only): `@synap-core/sdk/connect` (1.3.0)
|
|
123
|
+
|
|
124
|
+
An app that reads and writes a person's OWN pod connects as an OAuth 2.1 public
|
|
125
|
+
client of that pod: its `client_id` is the https URL of its client metadata
|
|
126
|
+
document (CIMD), PKCE S256, every answer checked for the pod's `iss`. The pod
|
|
127
|
+
token stays on the app's server, sealed (AES-GCM) in an HttpOnly cookie; the
|
|
128
|
+
browser never sees it. Framework-free and Web-standard (`Request` → `Response`,
|
|
129
|
+
WebCrypto): Next route handlers and `proxy.ts`, Hono, Remix, edge runtimes.
|
|
130
|
+
**Server-only**: it throws when loaded in a browser, and a browser bundle
|
|
131
|
+
resolves it to a stub that throws.
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// lib/synap/pod-app/auth.ts (server-only)
|
|
135
|
+
import { createPodAppAuth } from "@synap-core/sdk/connect";
|
|
136
|
+
|
|
137
|
+
export const synap = createPodAppAuth({
|
|
138
|
+
appUrl: process.env.SITE_URL!, // https://studio.example.com
|
|
139
|
+
clientName: "Studio",
|
|
140
|
+
requests: [
|
|
141
|
+
{ permission: "entity.post.read", reason: "Show your posts" },
|
|
142
|
+
{ permission: "entity.post.create", reason: "Save drafts to your pod" },
|
|
143
|
+
],
|
|
144
|
+
secret: process.env.SYNAP_APP_SESSION_SECRET!, // ≥ 32 chars, never NEXT_PUBLIC_
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// app/.well-known/synap-client.json/route.ts export const GET = synap.handlers.clientMetadata;
|
|
148
|
+
// app/api/synap/connect/route.ts export const POST = synap.handlers.connect; // form { pod, next?, space? }
|
|
149
|
+
// app/api/synap/callback/route.ts export const GET = synap.handlers.callback;
|
|
150
|
+
// app/api/synap/disconnect/route.ts export const POST = synap.handlers.disconnect; // form { next? }
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// proxy.ts: refresh before render (server components cannot set cookies)
|
|
155
|
+
const out = await synap.refreshIfNeeded(request);
|
|
156
|
+
// out.status: none | skipped | fresh | refreshed | kept | expired | revoked | unavailable
|
|
157
|
+
// send out.setCookies on the response; forward out.cookieHeader (when set) as the request's Cookie
|
|
158
|
+
|
|
159
|
+
// a server component / action (no Request there: pass the Cookie header)
|
|
160
|
+
const connection = await synap.readConnection((await headers()).get("cookie"));
|
|
161
|
+
const pod = synap.client(connection!);
|
|
162
|
+
const res = await pod.post("/entities", { profileSlug: "post", title: "Hello" });
|
|
163
|
+
if (res.ok && res.status === "proposed") showPending(res.reviewUrl); // success: queued for review, never retry
|
|
164
|
+
if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupported | rate_limited (retryAfterSec) | pod_unreachable | …
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- **Discovery**: `discoverPod("pod.example.com")` fetches the pod's RFC 8414
|
|
168
|
+
metadata and requires `issuer` equal to the address, CIMD, `iss` on answers and
|
|
169
|
+
S256 (`not_a_pod`, `pod_unsupported`, `pod_unreachable`). A browser launch
|
|
170
|
+
(`?iss=…&target_link_uri=…`) is read with `acceptLaunch` and only pre-fills the
|
|
171
|
+
connect page. Synap Cloud discovery is not available yet:
|
|
172
|
+
`cloudConnectAvailable()` is `false`.
|
|
173
|
+
- **Failures** redirect with `?error=<ConnectErrorCode>`: `denied`,
|
|
174
|
+
`expired_transaction`, `mismatch`, `misconfigured`, `rate_limited`,
|
|
175
|
+
`pod_unreachable`, … (`parseConnectErrorCode` reads it back). `connect` and
|
|
176
|
+
`disconnect` are POST and same-origin only.
|
|
177
|
+
- **Refresh** (rotating, single-use, 30 days from issue) is feature-detected: a
|
|
178
|
+
pod that issues no `refresh_token` gives a connection that ends with its
|
|
179
|
+
1-hour access token. `refreshIfNeeded` rotates only inside the last 5
|
|
180
|
+
minutes, is single-flight per connection in the process, and persists the
|
|
181
|
+
rotated connection BEFORE returning it. The pod revokes the whole connection
|
|
182
|
+
when a spent refresh token is presented again, so for 2 minutes a request
|
|
183
|
+
still carrying the spent token (a parallel request, a second tab) gets the
|
|
184
|
+
rotation's result instead of reaching the pod. Across server instances only
|
|
185
|
+
a shared server-side `ConnectionStore` rules that replay out. A concurrent
|
|
186
|
+
loser (refused softly by the pod) keeps the still-valid access token.
|
|
187
|
+
- **Your own store**: implement `ConnectionStore` (`read`, `write`, `clear`,
|
|
188
|
+
returning Set-Cookie values) and pass it as `store`.
|
|
189
|
+
- Lower level: `startConnect`, `handleCallback`, `refreshConnection`,
|
|
190
|
+
`revokeConnection`, `buildClientMetadata`, `seal`/`unseal`.
|
|
191
|
+
|
|
192
|
+
Test without a pod: `createMockAuthServer()` from `@synap-core/sdk/testing`
|
|
193
|
+
(metadata, `authorize()` as the consent screen, `/token` with PKCE, rotating
|
|
194
|
+
refresh, `/revoke`, Hub REST doors; `writeMode`, `failures`, `refresh: false`):
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const pod = createMockAuthServer();
|
|
198
|
+
const synap = createPodAppAuth({ ...config, fetch: pod.fetch });
|
|
199
|
+
// POST connect → Location → pod.authorize(location) → GET callback with that URL
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Mock pod: `@synap-core/sdk/testing`
|
|
203
|
+
|
|
204
|
+
A fake `fetch` that answers the public doors (forms, shares, sites) from fixtures,
|
|
205
|
+
with no network, for tests, a site's mock mode and a states gallery. Inject it
|
|
206
|
+
into the real client so everything above the wire is the production path:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import { createPublicClient } from "@synap-core/sdk/public";
|
|
210
|
+
import { createMockPod, MOCK_TOKENS, MOCK_FAILURE_TOKENS } from "@synap-core/sdk/testing";
|
|
211
|
+
|
|
212
|
+
const pod = createMockPod(); // or createMockPod({ sites: { mine: { … } } })
|
|
213
|
+
const pub = createPublicClient({ podUrl: pod.podUrl, fetch: pod.fetch });
|
|
214
|
+
|
|
215
|
+
await pub.listCollection(MOCK_TOKENS.site, "paper", { offset: 5 }); // demo site, 12 papers
|
|
216
|
+
await pub.getSite(MOCK_FAILURE_TOKENS.rateLimited); // { ok: false, error: "rate_limited" }
|
|
217
|
+
pod.requests; // every request (method, url, headers, credentials)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
It mirrors the doors: the uniform 404, the constant 202 (422 for missing
|
|
221
|
+
required or off-list answers), the collection query bounds, weak ETags and 304.
|
|
222
|
+
A `credentials: "include"` request rejects, as it would in a browser;
|
|
223
|
+
`createMockPod({ browser: true })` also rejects headers a CORS preflight refuses.
|
|
224
|
+
|
|
93
225
|
## React
|
|
94
226
|
|
|
95
227
|
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,79 @@
|
|
|
1
|
+
// src/connect/encoding.ts
|
|
2
|
+
var encoder = new TextEncoder();
|
|
3
|
+
var decoder = new TextDecoder();
|
|
4
|
+
function utf8(text) {
|
|
5
|
+
return encoder.encode(text);
|
|
6
|
+
}
|
|
7
|
+
function fromUtf8(bytes) {
|
|
8
|
+
return decoder.decode(bytes);
|
|
9
|
+
}
|
|
10
|
+
function base64url(bytes) {
|
|
11
|
+
let binary = "";
|
|
12
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
13
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
14
|
+
}
|
|
15
|
+
function fromBase64url(text) {
|
|
16
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
|
|
17
|
+
const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4);
|
|
18
|
+
let binary;
|
|
19
|
+
try {
|
|
20
|
+
binary = atob(padded);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const out = new Uint8Array(binary.length);
|
|
25
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function randomBytes(length) {
|
|
29
|
+
const out = new Uint8Array(length);
|
|
30
|
+
crypto.getRandomValues(out);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function randomToken() {
|
|
34
|
+
return base64url(randomBytes(32));
|
|
35
|
+
}
|
|
36
|
+
async function pkceChallenge(verifier) {
|
|
37
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8(verifier));
|
|
38
|
+
return base64url(new Uint8Array(digest));
|
|
39
|
+
}
|
|
40
|
+
function constantTimeEqual(a, b) {
|
|
41
|
+
const x = utf8(a);
|
|
42
|
+
const y = utf8(b);
|
|
43
|
+
let diff = x.length ^ y.length;
|
|
44
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
45
|
+
return diff === 0;
|
|
46
|
+
}
|
|
47
|
+
function parseCookies(header) {
|
|
48
|
+
const out = /* @__PURE__ */ new Map();
|
|
49
|
+
if (!header) return out;
|
|
50
|
+
for (const part of header.split(";")) {
|
|
51
|
+
const eq = part.indexOf("=");
|
|
52
|
+
if (eq < 0) continue;
|
|
53
|
+
const name = part.slice(0, eq).trim();
|
|
54
|
+
if (!name || out.has(name)) continue;
|
|
55
|
+
let value = part.slice(eq + 1).trim();
|
|
56
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
|
57
|
+
out.set(name, value);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function serializeCookie(name, value, attrs) {
|
|
62
|
+
const parts = [`${name}=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${Math.max(0, Math.floor(attrs.maxAgeSec))}`];
|
|
63
|
+
if (attrs.secure) parts.push("Secure");
|
|
64
|
+
return parts.join("; ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
utf8,
|
|
69
|
+
fromUtf8,
|
|
70
|
+
base64url,
|
|
71
|
+
fromBase64url,
|
|
72
|
+
randomBytes,
|
|
73
|
+
randomToken,
|
|
74
|
+
pkceChallenge,
|
|
75
|
+
constantTimeEqual,
|
|
76
|
+
parseCookies,
|
|
77
|
+
serializeCookie
|
|
78
|
+
};
|
|
79
|
+
//# sourceMappingURL=chunk-J7XHAAFJ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/connect/encoding.ts"],"sourcesContent":["/**\n * Web-standard primitives (WebCrypto, TextEncoder, btoa/atob): the same code\n * runs on Node 18+, edge runtimes and workers. No Node import.\n */\n\nconst encoder = new TextEncoder();\nconst decoder = new TextDecoder();\n\n/** `Uint8Array<ArrayBuffer>`: what WebCrypto's `BufferSource` takes under TS 5.7+ lib types. */\nexport type Bytes = Uint8Array<ArrayBuffer>;\n\nexport function utf8(text: string): Bytes {\n return encoder.encode(text);\n}\n\nexport function fromUtf8(bytes: Uint8Array): string {\n return decoder.decode(bytes);\n}\n\nexport function base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** Null when `text` is not base64url. */\nexport function fromBase64url(text: string): Bytes | null {\n if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;\n const padded = text.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"===\".slice((text.length + 3) % 4);\n let binary: string;\n try {\n binary = atob(padded);\n } catch {\n return null;\n }\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n}\n\nexport function randomBytes(length: number): Bytes {\n const out = new Uint8Array(length);\n crypto.getRandomValues(out);\n return out;\n}\n\n/** 32 random bytes, base64url (43 chars): a state value or a PKCE verifier. */\nexport function randomToken(): string {\n return base64url(randomBytes(32));\n}\n\n/** RFC 7636 §4.2: `BASE64URL(SHA256(ASCII(code_verifier)))`. */\nexport async function pkceChallenge(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", utf8(verifier));\n return base64url(new Uint8Array(digest));\n}\n\n/** Compare two strings without an early exit on the first differing byte. */\nexport function constantTimeEqual(a: string, b: string): boolean {\n const x = utf8(a);\n const y = utf8(b);\n let diff = x.length ^ y.length;\n for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);\n return diff === 0;\n}\n\n/** The request's cookies by name (the first value wins, as browsers order the most specific first). */\nexport function parseCookies(header: string | null): Map<string, string> {\n const out = new Map<string, string>();\n if (!header) return out;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq < 0) continue;\n const name = part.slice(0, eq).trim();\n if (!name || out.has(name)) continue;\n let value = part.slice(eq + 1).trim();\n if (value.startsWith('\"') && value.endsWith('\"')) value = value.slice(1, -1);\n out.set(name, value);\n }\n return out;\n}\n\nexport interface CookieAttributes {\n maxAgeSec: number;\n secure: boolean;\n}\n\n/** `HttpOnly; SameSite=Lax; Path=/` always; `Secure` on https. Never a `Domain`. */\nexport function serializeCookie(name: string, value: string, attrs: CookieAttributes): string {\n const parts = [`${name}=${value}`, \"Path=/\", \"HttpOnly\", \"SameSite=Lax\", `Max-Age=${Math.max(0, Math.floor(attrs.maxAgeSec))}`];\n if (attrs.secure) parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n"],"mappings":";AAKA,IAAM,UAAU,IAAI,YAAY;AAChC,IAAM,UAAU,IAAI,YAAY;AAKzB,SAAS,KAAK,MAAqB;AACxC,SAAO,QAAQ,OAAO,IAAI;AAC5B;AAEO,SAAS,SAAS,OAA2B;AAClD,SAAO,QAAQ,OAAO,KAAK;AAC7B;AAEO,SAAS,UAAU,OAA2B;AACnD,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,IAAK,WAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAC7E,SAAO,KAAK,MAAM,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AAC/E;AAGO,SAAS,cAAc,MAA4B;AACxD,MAAI,CAAC,mBAAmB,KAAK,IAAI,EAAG,QAAO;AAC3C,QAAM,SAAS,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,IAAI,MAAM,OAAO,KAAK,SAAS,KAAK,CAAC;AAC7F,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM;AAAA,EACtB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,MAAM,IAAI,WAAW,OAAO,MAAM;AACxC,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,IAAK,KAAI,CAAC,IAAI,OAAO,WAAW,CAAC;AACpE,SAAO;AACT;AAEO,SAAS,YAAY,QAAuB;AACjD,QAAM,MAAM,IAAI,WAAW,MAAM;AACjC,SAAO,gBAAgB,GAAG;AAC1B,SAAO;AACT;AAGO,SAAS,cAAsB;AACpC,SAAO,UAAU,YAAY,EAAE,CAAC;AAClC;AAGA,eAAsB,cAAc,UAAmC;AACrE,QAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,KAAK,QAAQ,CAAC;AACnE,SAAO,UAAU,IAAI,WAAW,MAAM,CAAC;AACzC;AAGO,SAAS,kBAAkB,GAAW,GAAoB;AAC/D,QAAM,IAAI,KAAK,CAAC;AAChB,QAAM,IAAI,KAAK,CAAC;AAChB,MAAI,OAAO,EAAE,SAAS,EAAE;AACxB,WAAS,IAAI,GAAG,IAAI,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAK,UAAS,EAAE,CAAC,KAAK,MAAM,EAAE,CAAC,KAAK;AACtF,SAAO,SAAS;AAClB;AAGO,SAAS,aAAa,QAA4C;AACvE,QAAM,MAAM,oBAAI,IAAoB;AACpC,MAAI,CAAC,OAAQ,QAAO;AACpB,aAAW,QAAQ,OAAO,MAAM,GAAG,GAAG;AACpC,UAAM,KAAK,KAAK,QAAQ,GAAG;AAC3B,QAAI,KAAK,EAAG;AACZ,UAAM,OAAO,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK;AACpC,QAAI,CAAC,QAAQ,IAAI,IAAI,IAAI,EAAG;AAC5B,QAAI,QAAQ,KAAK,MAAM,KAAK,CAAC,EAAE,KAAK;AACpC,QAAI,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,EAAG,SAAQ,MAAM,MAAM,GAAG,EAAE;AAC3E,QAAI,IAAI,MAAM,KAAK;AAAA,EACrB;AACA,SAAO;AACT;AAQO,SAAS,gBAAgB,MAAc,OAAe,OAAiC;AAC5F,QAAM,QAAQ,CAAC,GAAG,IAAI,IAAI,KAAK,IAAI,UAAU,YAAY,gBAAgB,WAAW,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,SAAS,CAAC,CAAC,EAAE;AAC9H,MAAI,MAAM,OAAQ,OAAM,KAAK,QAAQ;AACrC,SAAO,MAAM,KAAK,IAAI;AACxB;","names":[]}
|
|
@@ -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 PublicShare as a, type PublicRecord as b, type PublicReadError as c, type PublicRecordSummary as d, type PublicValue as e, type PublicCollectionPage as f, type PublicResult 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 };
|