@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.
Files changed (63) hide show
  1. package/README.md +138 -6
  2. package/dist/chunk-J7XHAAFJ.js +79 -0
  3. package/dist/chunk-J7XHAAFJ.js.map +1 -0
  4. package/dist/client-CVUT1Mcf.d.cts +253 -0
  5. package/dist/client-CVUT1Mcf.d.ts +253 -0
  6. package/dist/connect.browser.cjs +22 -0
  7. package/dist/connect.browser.cjs.map +1 -0
  8. package/dist/connect.browser.d.cts +2 -0
  9. package/dist/connect.browser.d.ts +2 -0
  10. package/dist/connect.browser.js +5 -0
  11. package/dist/connect.browser.js.map +1 -0
  12. package/dist/connect.cjs +1206 -0
  13. package/dist/connect.cjs.map +1 -0
  14. package/dist/connect.d.cts +442 -0
  15. package/dist/connect.d.ts +442 -0
  16. package/dist/connect.js +1092 -0
  17. package/dist/connect.js.map +1 -0
  18. package/dist/index.cjs +48 -6
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +118 -7
  21. package/dist/index.d.ts +118 -7
  22. package/dist/index.js +41 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/public.cjs +627 -0
  25. package/dist/public.cjs.map +1 -0
  26. package/dist/public.d.cts +228 -0
  27. package/dist/public.d.ts +228 -0
  28. package/dist/public.js +578 -0
  29. package/dist/public.js.map +1 -0
  30. package/dist/testing.cjs +671 -0
  31. package/dist/testing.cjs.map +1 -0
  32. package/dist/testing.d.cts +181 -0
  33. package/dist/testing.d.ts +181 -0
  34. package/dist/testing.js +614 -0
  35. package/dist/testing.js.map +1 -0
  36. package/dist/types-DpqPNaaa.d.cts +76 -0
  37. package/dist/types-DpqPNaaa.d.ts +76 -0
  38. package/package.json +16 -3
  39. package/src/client.ts +24 -7
  40. package/src/connect/auth.ts +405 -0
  41. package/src/connect/browser.ts +10 -0
  42. package/src/connect/client.ts +193 -0
  43. package/src/connect/discovery.ts +262 -0
  44. package/src/connect/encoding.ts +93 -0
  45. package/src/connect/errors.ts +115 -0
  46. package/src/connect/flow.ts +449 -0
  47. package/src/connect/guard.ts +25 -0
  48. package/src/connect/index.ts +59 -0
  49. package/src/connect/seal.ts +119 -0
  50. package/src/connect/store.ts +151 -0
  51. package/src/connect/types.ts +80 -0
  52. package/src/index.ts +13 -0
  53. package/src/public/client.ts +579 -0
  54. package/src/public/form-model.ts +210 -0
  55. package/src/public/index.ts +12 -0
  56. package/src/public/safe-markdown.ts +114 -0
  57. package/src/public/share-model.ts +47 -0
  58. package/src/public/site-model.ts +217 -0
  59. package/src/templates.ts +122 -0
  60. package/src/testing/auth-server.ts +380 -0
  61. package/src/testing/fixtures.ts +132 -0
  62. package/src/testing/index.ts +359 -0
  63. 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
- 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,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 };