@synap-core/sdk 1.3.0 → 1.5.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 (51) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +212 -12
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +790 -161
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +610 -37
  10. package/dist/connect.d.ts +610 -37
  11. package/dist/connect.js +777 -162
  12. package/dist/connect.js.map +1 -1
  13. package/dist/public.cjs +9 -2
  14. package/dist/public.cjs.map +1 -1
  15. package/dist/public.d.cts +19 -4
  16. package/dist/public.d.ts +19 -4
  17. package/dist/public.js +8 -2
  18. package/dist/public.js.map +1 -1
  19. package/dist/testing.cjs +366 -17
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +190 -7
  22. package/dist/testing.d.ts +190 -7
  23. package/dist/testing.js +358 -17
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
  26. package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
  27. package/package.json +36 -12
  28. package/src/connect/auth.ts +314 -55
  29. package/src/connect/client.ts +472 -26
  30. package/src/connect/cloud.ts +210 -0
  31. package/src/connect/discovery.ts +79 -44
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +8 -1
  34. package/src/connect/flow.ts +80 -12
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +254 -0
  37. package/src/connect/index.ts +69 -23
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/seal.ts +1 -1
  40. package/src/connect/store.ts +50 -8
  41. package/src/connect/types.ts +48 -4
  42. package/src/public/client.ts +14 -2
  43. package/src/public/safe-markdown.ts +13 -0
  44. package/src/public/share-model.ts +6 -2
  45. package/src/public/site-model.ts +5 -3
  46. package/src/testing/auth-server.ts +377 -20
  47. package/src/testing/cloud.ts +83 -0
  48. package/src/testing/fixtures.ts +26 -0
  49. package/src/testing/index.ts +10 -6
  50. package/src/testing/seed.ts +120 -0
  51. package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,133 @@
1
+ # @synap-core/sdk changelog
2
+
3
+ ## 1.5.0
4
+
5
+ For Content Studio, the first pod app on `@synap-core/sdk/connect`, plus a
6
+ hardening pass. Additive, except three behaviours that were bugs (see
7
+ "Behaviour changes").
8
+
9
+ ### `@synap-core/sdk/connect`
10
+
11
+ - **Typed Hub reads on `createPodClient(connection)`**, each a `PodResult` with
12
+ the 1.4 error codes: `listEntities({ workspaceId?, profileSlug?, limit? (≤ 1000),
13
+ offset?, sort?, includePodWide? })`, `getEntity(id)`,
14
+ `getEntityConnections(id, { workspaceId?, limit? })`, `getDocument(id, { format? })`,
15
+ `listFocusSessions({ workspaceId | projectId, status?, limit? (≤ 50), lens?, kind?,
16
+ subjectEntityId? })`, `getFileUrl(entityId)`. Each shape is read from the
17
+ backend route (see `src/connect/hub-types.ts`); a 2xx answer of another shape
18
+ is `server`, never empty data.
19
+ - **`humanSlots(session)`**: the slots of a focus session that wait on the person
20
+ (`owner: "human"`, not done, not retired), for a "needs you" list.
21
+ - **`listPendingProposals({ proposalIds })`**: only those proposals (≤ 100; the
22
+ pod's filter from backend #28). An empty list answers without a request. A pod
23
+ that ignores the filter never hands back a proposal that was not asked for.
24
+ - **Who the connection acts as**: `connection.actsAs` / `client.actsAs` /
25
+ `connectionActsAs(connection)` read the token response's `synap_acts_as`. `"person"`: an official Synap app, the token is the person's,
26
+ writes apply directly. `"agent"`: every other app, writes are proposals. A pod
27
+ that does not send it is `"agent"`; the field is read from every token
28
+ response, never carried over from the previous one.
29
+ - **`approveProposal(id, { workspaceId? })`**: `POST /proposals/:id/approve` as
30
+ the person. An agent connection gets `forbidden` with `reason:
31
+ "agent_cannot_approve"` and nothing is sent; without `proposal.approve`,
32
+ `forbidden` / `not_granted`, also without a request.
33
+ - **`PodError.reason`** (`agent_cannot_approve | not_granted | invalid_input`):
34
+ set when the SDK refused locally (`status: null`). `listPendingProposals`
35
+ without `proposal.read` now carries `reason: "not_granted"`. A typed method
36
+ given an id that could leave its path segment answers `invalid`, never throws.
37
+ - **Spaces carry their template**: `PodGrant.templateSlug` and
38
+ `PodSpace.templateSlug` (from `synap_grant[].templateSlug`), so
39
+ `client.spaces().find((s) => s.templateSlug === "content-os")` finds the space.
40
+ Absent when the space came from no template or the pod does not send it. The
41
+ sealed cookie keeps it (cookies sealed by 1.4 still open).
42
+ - **A 401 renews once**: `synap.client(connection, { persist, request })` (a
43
+ route handler or a server action) renews a connection whose access key the
44
+ pod refused (a narrow, a pod-side "Revoke key") with ONE single-flight
45
+ rotation, persisted before use, and retries the request once. Lower level:
46
+ `createPodClient(connection, { renew })`. Without `persist` (a server
47
+ component) nothing is rotated and the 401 carries `PodError.refreshable`;
48
+ `refreshIfNeeded(request, { force: true })` rotates regardless of expiry.
49
+ - **`pod_moved`** (`ConnectErrorCode`, `PodErrorCode`): `/token`, the metadata
50
+ or the Hub answered a redirect (a moved pod answers 308 from its old name).
51
+ - **Discovery finds Synap Cloud pods by their current name**: `you.synap.live`
52
+ also tries `you.pod.synap.live`, then `pod.you.synap.live` (at most three
53
+ addresses).
54
+ - **Bounded answers**: discovery, `/token` (64 KB each) and the Hub client
55
+ (`maxResponseBytes`, 8 MiB) count bytes on the stream and stop past the cap;
56
+ Hub requests time out (`timeoutMs`, 10 s) as `pod_unreachable`.
57
+ - **`buildClientMetadata` refuses what the pod refuses**: actions other than
58
+ read/create/update/delete (the review verbs `proposal.approve` /
59
+ `proposal.reject` only for an official Synap app origin), a kind on a subject
60
+ other than `entity`, uppercase, more than 32 requests counted with
61
+ duplicates, an IP address `appUrl`.
62
+ - **Packaging**: `engines.node >=20`, `types` per `import` / `require`
63
+ (`.d.ts` / `.d.cts`), `sideEffects`, this changelog. Shipped source no longer
64
+ cites internal spec, wave or PR tags.
65
+
66
+ ### `@synap-core/sdk/public`
67
+
68
+ - **An HTML body arrives as HTML** (backend `body.format: "markdown" | "html"`):
69
+ `PublicShare` and `PublicRecord` carry `body: { format, content }`, and
70
+ `SharePageModel` / `RecordViewModel` gain `html: string | null`. Render it
71
+ ONLY inside `<iframe sandbox="" srcdoc>`, never in the page's own DOM;
72
+ markdown still goes through `parseSafeMarkdown`. `@synap-core/sdk/testing`
73
+ adds `MOCK_HTML_SHARE` (`MOCK_TOKENS.htmlShare`) for the mock-mode path.
74
+
75
+ ### Known issue
76
+
77
+ - A **CommonJS** consumer that type-checks declaration files
78
+ (`skipLibCheck: false`) gets `TS1479` on the root entry `@synap-core/sdk`:
79
+ its `.d.cts` imports `@synap-core/api-types`, which ships ESM types only.
80
+ `skipLibCheck: true` (the default for Next.js and most apps), every ESM or
81
+ bundler consumer, and the `/public`, `/connect` and `/testing` entries are
82
+ unaffected. Fixed when api-types ships CJS declarations.
83
+
84
+ ### Behaviour changes
85
+
86
+ - A Hub path that could leave `/api/hub` answers `{ ok: false, error: { code:
87
+ "invalid", reason: "invalid_input" } }` instead of throwing a `TypeError`, and
88
+ percent-encoded dot segments (`%2e%2e`) are refused too (WHATWG URL resolves
89
+ them like `..`, which sent the Bearer token to other pod routes).
90
+ - `refreshIfNeeded` answering `revoked` or `expired` with the access token
91
+ spent now clears the session cookie (the marker stays), instead of leaving it
92
+ for 30 days and posting the dead token to `/token` on every page view.
93
+ - A Hub answer with a 3xx status is `pod_moved` (was `server`); a `/token`
94
+ redirect is `pod_moved` (was `server_error`).
95
+
96
+ ### Kept for 1.x, changing in 2.0
97
+
98
+ - The low-level building blocks of `@synap-core/sdk/connect` (`seal`/`unseal`,
99
+ `startConnect`/`handleCallback`/`refreshConnection`/`revokeConnection`,
100
+ `openTransaction`, `parseTokenResponse`, `startCloudConnect`/`acceptCloudReturn`,
101
+ the cookie helpers, `pkceChallenge`, the `*_MS` constants) are internal: an app on
102
+ `createPodAppAuth` needs none of them and nothing new is added to them. 2.0 moves
103
+ them to their own entry. `ConnectionStore` / `createSealedCookieStore` stay the
104
+ supported custom-store seam.
105
+ - `startCloudConnect`: `secret` becomes required, `state` (ignored since 1.4) is
106
+ removed, and the call becomes a plain async function in 2.0.
107
+
108
+ ### `@synap-core/sdk/testing`
109
+
110
+ - **`parseMockPodSeed(jsonOrValue)`** and `createMockAuthServer({ seed })`: the
111
+ mock pod's data as ONE JSON shape, `{ entities, sessions, documents, relations,
112
+ proposals, spaces }`, so a mock mode, a smoke run and screenshots replay one
113
+ recorded file.
114
+ - New mock doors, answered from the seed in the backend's shapes:
115
+ `GET /entities/:id`, `GET /entities/:id/connections`, `GET /documents/:id`,
116
+ `GET /focus-sessions`, `POST /proposals/:id/approve`; `GET /proposals` honours
117
+ `proposalIds`.
118
+ - `createMockAuthServer({ actsAs: "person" | "agent" })` answers
119
+ `synap_acts_as`; a person token's writes apply (unless `writeMode`) and it may
120
+ approve where it holds `proposal.approve`; an agent's approve is refused 403.
121
+ `mock.approvals` lists what was approved. Seeded `spaces` put `templateSlug`
122
+ on the token response's grant rows.
123
+
124
+ ## 1.4.0
125
+
126
+ Re-consent (`prompt=consent`), Synap Cloud connect, `connection.manageUrl` /
127
+ `appId`, `listPendingProposals`, `spaces()`, the lapse marker (`readLapsed`),
128
+ `isPrefetchOrAsset`, `createMockCloud`. See the README.
129
+
130
+ ## 1.3.0
131
+
132
+ `@synap-core/sdk/connect`: pod-app sign-in (CIMD, PKCE S256, `iss` checks,
133
+ sealed cookie, rotating refresh) and `createMockAuthServer`.
package/README.md CHANGED
@@ -79,6 +79,9 @@ is safe in a public site. The same entry ships the framework-free view-models a
79
79
  site renders (no React): `toFieldModels` / `validate` / `submitFailure` (forms),
80
80
  `toSharePageModel`, `parseSafeMarkdown` (data, never HTML), `formatPublicValue`,
81
81
  `toCollectionPageModel` / `pageQuery` / `parsePageParam`, `toRecordViewModel`.
82
+ A published body is `format: "markdown"` (the models parse it into safe blocks) or
83
+ `format: "html"` (a whole HTML document such as an estimate: the models hand it
84
+ over as `html`; render it only in `<iframe sandbox="" srcdoc={html}>`).
82
85
 
83
86
  ```typescript
84
87
  import { createPublicClient } from "@synap-core/sdk/public";
@@ -119,7 +122,7 @@ SDK refuses out-of-bounds `offset`/`limit`/`sort` locally, without a request),
119
122
  pass `ifNoneMatch` from a browser: the doors allow no cross-origin request header
120
123
  but `Content-Type`, so the preflight fails (browsers revalidate on their own).
121
124
 
122
- ## Pod-app sign-in (server-only): `@synap-core/sdk/connect` (1.3.0)
125
+ ## Pod-app sign-in (server-only): `@synap-core/sdk/connect` (1.3.0, 1.4.0, 1.5.0)
123
126
 
124
127
  An app that reads and writes a person's OWN pod connects as an OAuth 2.1 public
125
128
  client of that pod: its `client_id` is the https URL of its client metadata
@@ -142,34 +145,58 @@ export const synap = createPodAppAuth({
142
145
  { permission: "entity.post.create", reason: "Save drafts to your pod" },
143
146
  ],
144
147
  secret: process.env.SYNAP_APP_SESSION_SECRET!, // ≥ 32 chars, never NEXT_PUBLIC_
148
+ cloudUrl: process.env.SYNAP_CLOUD_URL, // optional (1.4): https://api.synap.live offers "Continue with Synap Cloud"
145
149
  });
146
150
 
147
151
  // 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? }
152
+ // app/api/synap/connect/route.ts export const POST = synap.handlers.connect; // form { pod, next?, space?, prompt? }
149
153
  // app/api/synap/callback/route.ts export const GET = synap.handlers.callback;
150
154
  // app/api/synap/disconnect/route.ts export const POST = synap.handlers.disconnect; // form { next? }
155
+ // app/api/synap/cloud/route.ts (1.4) export const POST = synap.handlers.cloudConnect; // form { next?, space?, prompt? }
156
+ // export const GET = synap.handlers.cloudReturn;
151
157
  ```
152
158
 
153
159
  ```typescript
154
160
  // proxy.ts: refresh before render (server components cannot set cookies)
155
161
  const out = await synap.refreshIfNeeded(request);
156
162
  // out.status: none | skipped | fresh | refreshed | kept | expired | revoked | unavailable
163
+ // out.lapsed (with none, 1.4): { podUrl } when this browser WAS connected and the connection lapsed
157
164
  // send out.setCookies on the response; forward out.cookieHeader (when set) as the request's Cookie
158
165
 
166
+ // Next 16 strips its router headers before proxy code runs, so skip prefetches in the MATCHER
167
+ // (it still sees them; matcher values must be literals):
168
+ export const config = {
169
+ matcher: [{
170
+ source: "/((?!_next/static|_next/image|api/synap/|\\.well-known/|.*\\.\\w+$).*)",
171
+ missing: [{ type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }],
172
+ }],
173
+ };
174
+
159
175
  // a server component / action (no Request there: pass the Cookie header)
160
176
  const connection = await synap.readConnection((await headers()).get("cookie"));
161
- const pod = synap.client(connection!);
177
+ if (!connection) redirect("/connect");
178
+ const pod = synap.client(connection);
162
179
  const res = await pod.post("/entities", { profileSlug: "post", title: "Hello" });
163
180
  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 | …
181
+ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupported | rate_limited (retryAfterSec) | pod_unreachable | pod_moved | …
165
182
  ```
166
183
 
167
- - **Discovery**: `discoverPod("pod.example.com")` fetches the pod's RFC 8414
184
+ - **Discovery**: `discoverPod("you.pod.synap.live")` fetches the pod's RFC 8414
168
185
  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`.
186
+ S256 (`not_a_pod`, `pod_unsupported`, `pod_unreachable`). A Synap Cloud name
187
+ typed the old way (`you.synap.live`) also tries `you.pod.synap.live`, then
188
+ `pod.you.synap.live`; another address also tries `pod.<address>`. A redirect is
189
+ never followed (`pod_moved` when only an old, moved name answers). A browser
190
+ launch (`?iss=…&target_link_uri=…`) is read with `acceptLaunch` and only
191
+ pre-fills the connect page. The connect route fetches an address a visitor
192
+ typed: rate-limit it per visitor.
193
+ - **Local development**: an app on `http://localhost` can sign in to the SDK's
194
+ mock pod only (`@synap-core/sdk/testing`, `allowInsecureLoopback`). Every real
195
+ pod, a local one included, refuses an http `client_id` and `redirect_uri`: run
196
+ the app on https (a tunnel) to sign in to a real pod.
197
+ - **`space`** (the connect form, `startConnect({ spaceHint })`) is sent to the pod
198
+ as `synap_space_hint`, a hint its consent screen may use. A pod may ignore it:
199
+ never rely on a space being preselected.
173
200
  - **Failures** redirect with `?error=<ConnectErrorCode>`: `denied`,
174
201
  `expired_transaction`, `mismatch`, `misconfigured`, `rate_limited`,
175
202
  `pod_unreachable`, … (`parseConnectErrorCode` reads it back). `connect` and
@@ -184,19 +211,192 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
184
211
  rotation's result instead of reaching the pod. Across server instances only
185
212
  a shared server-side `ConnectionStore` rules that replay out. A concurrent
186
213
  loser (refused softly by the pod) keeps the still-valid access token.
214
+ The pod's reuse grace answers a spent token presented again within
215
+ 30 s with the same pair the first use got; its `expires_in` may be under an
216
+ hour, and the SDK reads it as sent.
187
217
  - **Your own store**: implement `ConnectionStore` (`read`, `write`, `clear`,
188
218
  returning Set-Cookie values) and pass it as `store`.
189
219
  - Lower level: `startConnect`, `handleCallback`, `refreshConnection`,
190
- `revokeConnection`, `buildClientMetadata`, `seal`/`unseal`.
220
+ `revokeConnection`, `buildClientMetadata`, `seal`/`unseal`,
221
+ `startCloudConnect`, `acceptCloudReturn`.
222
+
223
+ ### 1.4.0 (additive)
224
+
225
+ - **Re-consent**: the connect form's `prompt=consent` (or `startConnect({ prompt: "consent" })`)
226
+ goes to the pod's consent screen even when this browser is already connected to
227
+ that pod (to widen or narrow access). It is a normal sign-in: fresh PKCE verifier
228
+ and state, `iss` checked; a denied or forged answer leaves the live connection as it is.
229
+ - **Synap Cloud**: with `cloudUrl` set, `synap.cloudConnectAvailable` is
230
+ true and `cloudConnectAvailable({ cloudUrl })` too. POST `handlers.cloudConnect`
231
+ sends the browser to `<cp>/connect/pod?client_id=<client id>&state=<nonce>`, the
232
+ state sealed in an HttpOnly cookie of that browser (10 minutes). The CP returns to
233
+ the CIMD `initiate_login_uri?iss=<pod>&state=<nonce>` (your connect page):
234
+ `acceptLaunch(url).cloudState` is set, so forward the request's query to GET
235
+ `handlers.cloudReturn`, which checks the state against the cookie (constant time,
236
+ single use) BEFORE the pod is used, then runs the same sign-in as a typed address.
237
+ `acceptLaunch` itself never verifies anything; a return without a matching state
238
+ starts no sign-in (`mismatch`, `expired_transaction`). A cancel on the chooser
239
+ (`?state=…&error=access_denied`, no `iss`) also carries `cloudState`: forwarded the
240
+ same way, it lands on the connect page with `?error=denied` once the state matched.
241
+ Security notes: after the state matches, the named pod goes through the same
242
+ discovery as a typed address (metadata, `issuer` equality, CIMD, `iss`, S256)
243
+ before the browser is sent anywhere. The cloud cookie is single-use and cleared on
244
+ every outcome (success, cancel, mismatch). Accepted trade-off: a third-party page
245
+ that sends a visitor's browser to a forged return can cancel that visitor's Cloud
246
+ sign-in in progress (they click again); nothing leaks and nothing is bound, the
247
+ same nuisance any OAuth `state` check allows.
248
+
249
+ ```typescript
250
+ // app/connect/page.tsx (server component)
251
+ const launch = acceptLaunch(url, origin);
252
+ if (launch.cloudState) redirect(`${synap.paths.cloud}${new URL(url).search}`);
253
+ ```
254
+ - **`connection.manageUrl` / `connection.appId`**: the pod's page for this app
255
+ connection (pod-admin `/apps/<appId>`), read from the token response's
256
+ `synap_app: { id, manageUrl }` (https only). Absent when the pod does not send it:
257
+ hide the link, never build one.
258
+ - **`client.listPendingProposals({ workspaceId?, limit?, offset? })`**: "Waiting for
259
+ review: N" (`data.total`, plus summary rows with `reviewUrl`) from the pod's seamed
260
+ `GET /proposals` door. Ask for `proposal.read` in `requests`: without it the answer
261
+ is `forbidden` and nothing is sent (the pod would answer an empty list). It counts
262
+ the person's pending proposals in those spaces, from any agent.
263
+ - **`spaces(connection)` / `client.spaces()`**: the grant by space
264
+ (`{ workspaceId, workspaceName, permissions }`), each permission once.
265
+ - **Expired vs never connected**: each sign-in and refresh also sets a marker cookie
266
+ (`__Host-synap_seen`, 400 days) holding the pod's URL and nothing else.
267
+ `synap.readLapsed(cookieHeader)` and `RefreshOutcome.lapsed` say "this browser was
268
+ connected to <pod>" once the sealed session is gone; a disconnect clears it.
269
+ - **`isPrefetchOrAsset(request)`** is exported, and its limit documented: inside
270
+ Next 16's proxy a router prefetch is invisible (see the matcher above).
271
+ - Not added: `ensureTemplate` on the pod-app client. A pod-app key's grant is pinned
272
+ to the approved spaces, so creating a space is refused by the pod's grant check
273
+ every time; ask for templates with `templates` (CIMD `synap_templates`) instead:
274
+ the pod's consent offers to create the space.
275
+
276
+ ### 1.5.0 (additive): typed reads, who the app acts as, approvals
277
+
278
+ **Which client for what.** A pod app (a website or service a person connects to
279
+ their OWN pod, with a token from this sign-in) reads and writes through
280
+ `synap.client(connection)` / `createPodClient(connection)`: it carries the
281
+ connection's token, refuses expired tokens locally, and answers the typed codes
282
+ below. `@synap-core/hub-rest-client` stays the client for scripts, CLIs and
283
+ agents holding a static pod key (`apiKey`): it retries, throws `HubApiError` and
284
+ knows far more doors. The two are separate on purpose; this SDK does not depend
285
+ on it (CLIENTS.md).
286
+
287
+ ```typescript
288
+ const pod = synap.client(connection);
289
+
290
+ // Typed reads: PodResult<T>, the same codes as get/post (expired | unauthorized | forbidden |
291
+ // door_unsupported | not_found | invalid | rate_limited | pod_unreachable | server | …)
292
+ await pod.listEntities({ workspaceId, profileSlug: "post", limit: 1000 }); // PodEntity[]
293
+ await pod.getEntity(id); // PodEntity, not_found on a miss
294
+ await pod.getEntityConnections(id); // { connections, counts }
295
+ await pod.getDocument(documentId); // { content, revision, sections, … }
296
+ const sessions = await pod.listFocusSessions({ workspaceId, status: "active" });
297
+ if (sessions.ok) sessions.data.flatMap(humanSlots); // what waits on the person (owner: "human")
298
+ await pod.getFileUrl(fileEntityId); // { url, expiresAt, mimeType }
299
+ await pod.listPendingProposals({ proposalIds: [id1, id2] }); // only those (≤ 100)
300
+
301
+ // Who the token acts as: "person" for an official Synap app, else "agent".
302
+ pod.actsAs; // also connection.actsAs (absent = agent) and connectionActsAs(connection)
303
+ const space = pod.spaces().find((s) => s.templateSlug === "content-os"); // the space made from that template
304
+
305
+ // Approve, as the person (needs proposal.approve in requests, which only an official Synap app
306
+ // on https://<id>.synap.live may ask for; any other appUrl is refused at setup). An agent connection is refused
307
+ // before any request: { ok: false, error: { code: "forbidden", status: null, reason: "agent_cannot_approve" } }
308
+ const res = await pod.approveProposal(proposalId, { workspaceId });
309
+ ```
310
+
311
+ - **Writes**: an agent connection's writes are proposals (`proposed` is
312
+ success: show `reviewUrl`, never retry). A person connection's writes apply
313
+ directly, as the person, with no review step (`applied`). The result reports what the pod did either way, so a
314
+ `proposed` answer to a person (a space that reviews its members' changes) is
315
+ still success.
316
+ - **`actsAs`** is read from every token response (code exchange, refresh, the
317
+ reuse grace), never carried over: a pod that stops saying `"person"`, or never
318
+ did, gives an agent connection. It drives the SDK's own
319
+ refusals and your UI's words; the pod enforces the rule itself.
320
+ - **Local refusals** answer `status: null` with a `reason`
321
+ (`agent_cannot_approve`, `not_granted`, `invalid_input`) and send nothing: an
322
+ agent approving, a missing `proposal.read` / `proposal.approve`, an id that
323
+ could leave its path segment, `listFocusSessions` without a space or project.
324
+ - **Shapes** are read from the backend routes (`src/connect/hub-types.ts` names
325
+ each one). A 2xx answer of another shape is `server` (status 200), never empty
326
+ data. Fields the SDK does not type stay on the objects.
327
+ - **`templateSlug`** (`PodGrant`, `PodSpace`): the template the space was made
328
+ from, when the pod names one; absent for a space made by hand and from pods
329
+ that do not send it (keep a fallback, such as `ensure-template`, for those).
330
+ It survives the sealed cookie (`readConnection`).
331
+
332
+ ### 1.5.0: hardening
333
+
334
+ - **A 401 renews once.** A narrow, or a pod-side "Revoke key", ends the access
335
+ key, not the connection. A client that can set cookies renews it ONCE (one
336
+ single-flight rotation, persisted before use) and retries the request once:
337
+
338
+ ```typescript
339
+ // a route handler: send the renewed cookie on its own response, as with refreshIfNeeded
340
+ const setCookies: string[] = [];
341
+ const pod = synap.client(connection, { request, persist: (c) => void setCookies.push(...c) });
342
+ const entities = await pod.listEntities({ workspaceId });
343
+ const response = Response.json(entities);
344
+ for (const c of setCookies) response.headers.append("Set-Cookie", c);
345
+ return response;
346
+ ```
347
+
348
+ `unauthorized` then means the pod refused the connection itself. A server
349
+ component cannot set cookies, so it passes no `persist`: nothing is rotated
350
+ there (the old refresh token would be spent and lost), and a 401 answers
351
+ `unauthorized` with `refreshable: true`. Send that visitor through the proxy
352
+ once with `refreshIfNeeded(request, { force: true })` (it rotates whatever the
353
+ expiry says; refused, it is `revoked`, never `kept`).
354
+ - **A revoked connection's cookie is cleared** (`revoked` / `expired` with the
355
+ access token spent; the marker stays, so the visitor reads "expired"), so each
356
+ page view no longer posts the dead token to `/token`. A refresh that fails
357
+ while the access token still works (`kept`) is not asked again for a minute.
358
+ - **A moved pod is `pod_moved`** (`ConnectErrorCode` and `PodErrorCode`): a
359
+ redirect from `/token`, the metadata or the Hub. Connect again with the pod's
360
+ new address; the SDK never takes it from the redirect.
361
+ - **Bounded answers**: discovery (64 KB), the token endpoint (64 KB) and the Hub
362
+ (`maxResponseBytes`, 8 MiB) count bytes on the stream and stop there; Hub
363
+ requests time out (`timeoutMs`, 10 s) as `pod_unreachable`.
364
+ - **Paths**: a Hub path that could leave `/api/hub` (a dot segment, plain or
365
+ percent-encoded, `//`, `\`, a query) answers `invalid` / `invalid_input`
366
+ instead of throwing. Encode each path segment (`encodeURIComponent`).
367
+ - **`buildClientMetadata` refuses what the pod refuses**, at setup: an action
368
+ other than read/create/update/delete (`proposal.approve` and
369
+ `proposal.reject` only for an official Synap app on `https://<id>.synap.live`),
370
+ a kind on a subject other than `entity`, more than 32 requests counted with
371
+ duplicates, an IP address `appUrl`.
372
+ - **Packaging**: `engines.node >=20` (WebCrypto global), `types` per
373
+ `import`/`require` (`.d.ts` / `.d.cts`), `sideEffects` (only the `connect`
374
+ entry runs code on load), this package's `CHANGELOG.md`.
191
375
 
192
376
  Test without a pod: `createMockAuthServer()` from `@synap-core/sdk/testing`
193
377
  (metadata, `authorize()` as the consent screen, `/token` with PKCE, rotating
194
- refresh, `/revoke`, Hub REST doors; `writeMode`, `failures`, `refresh: false`):
378
+ refresh with the 30 s reuse grace, `/revoke`, Hub REST doors: `GET /entities`
379
+ answers an array as the pod does, `GET /proposals` lists what proposed writes
380
+ filed; `synap_app` is `MOCK_APP` unless `app: null`; `writeMode`, `failures`, `refresh: false`, `entities`,
381
+ `refreshGraceSec`), and `createMockCloud()` for the Synap Cloud pod chooser.
382
+ 1.5: `seed` (one JSON shape, `parseMockPodSeed`) feeds `GET /entities/:id`,
383
+ `GET /entities/:id/connections`, `GET /documents/:id`, `GET /focus-sessions` and
384
+ `proposalIds`; `actsAs: "person" | "agent"` answers `synap_acts_as` (a person's
385
+ writes apply, it may approve where granted; an agent's approve is refused 403):
386
+
387
+ ```typescript
388
+ import { readFileSync } from "node:fs";
389
+ const seed = parseMockPodSeed(readFileSync("mock/pod-seed.json", "utf8")); // { entities, sessions, documents, relations, proposals, spaces }
390
+ const pod = createMockAuthServer({ seed, actsAs: "person" });
391
+ ```
392
+
195
393
 
196
394
  ```typescript
197
395
  const pod = createMockAuthServer();
198
- const synap = createPodAppAuth({ ...config, fetch: pod.fetch });
396
+ const cloud = createMockCloud();
397
+ const synap = createPodAppAuth({ ...config, fetch: pod.fetch, cloudUrl: cloud.url });
199
398
  // POST connect → Location → pod.authorize(location) → GET callback with that URL
399
+ // POST cloud → Location → cloud.choose(location, { pod: pod.issuer, metadata: synap.metadata }) → forward to GET cloud
200
400
  ```
201
401
 
202
402
  ## Mock pod: `@synap-core/sdk/testing`
@@ -64,6 +64,14 @@ function serializeCookie(name, value, attrs) {
64
64
  return parts.join("; ");
65
65
  }
66
66
 
67
+ // src/connect/rules.ts
68
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
69
+ var SPACE_HINT_RE = /^[A-Za-z0-9_-]{1,64}$/;
70
+ var CLOUD_STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
71
+ function isIpLiteral(hostname) {
72
+ return hostname.startsWith("[") || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
73
+ }
74
+
67
75
  export {
68
76
  utf8,
69
77
  fromUtf8,
@@ -74,6 +82,10 @@ export {
74
82
  pkceChallenge,
75
83
  constantTimeEqual,
76
84
  parseCookies,
77
- serializeCookie
85
+ serializeCookie,
86
+ LOOPBACK_HOSTS,
87
+ SPACE_HINT_RE,
88
+ CLOUD_STATE_RE,
89
+ isIpLiteral
78
90
  };
79
- //# sourceMappingURL=chunk-J7XHAAFJ.js.map
91
+ //# sourceMappingURL=chunk-UK2U6SMA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/connect/encoding.ts","../src/connect/rules.ts"],"sourcesContent":["/**\n * Web-standard primitives (WebCrypto, TextEncoder, btoa/atob): the same code\n * runs on Node 20+, 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","/**\n * The shape rules several files of this entry (and the mock pod) apply, in\n * one place, so a hint or a state accepted in one door is accepted in all.\n */\n\n/** Hosts that count as this machine (WHATWG keeps IPv6 brackets: \"[::1]\"). */\nexport const LOOPBACK_HOSTS: ReadonlySet<string> = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\"]);\n\n/** A `synap_space_hint`: a space id (uuid, short id), never a path or a query. */\nexport const SPACE_HINT_RE = /^[A-Za-z0-9_-]{1,64}$/;\n\n/** The `state` the Synap Cloud pod chooser accepts and echoes; ours is 43 base64url characters. */\nexport const CLOUD_STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;\n\n/** An IP literal host (dotted IPv4 or bracketed IPv6), as WHATWG URL normalises them. */\nexport function isIpLiteral(hostname: string): boolean {\n return hostname.startsWith(\"[\") || /^\\d{1,3}(\\.\\d{1,3}){3}$/.test(hostname);\n}\n"],"mappings":"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","names":[]}
@@ -31,15 +31,23 @@
31
31
  * `src/__tests__/fixtures.ts` copies those shapes; when the backend changes,
32
32
  * update both.
33
33
  */
34
+ /**
35
+ * A published body, in the format the pod serves it (backend
36
+ * `PublicBodyFormatSchema`): `markdown` is text to parse with
37
+ * `parseSafeMarkdown`; `html` is a whole HTML document (an estimate, a report)
38
+ * that a page renders ONLY inside `<iframe sandbox="" srcdoc>`, never in its
39
+ * own DOM. `null`: nothing pinned, or a body that is not text (pdf, docx).
40
+ */
41
+ interface PublicBody {
42
+ format: "markdown" | "html";
43
+ content: string;
44
+ }
34
45
  interface PublicShare {
35
46
  resourceType: "entity";
36
47
  /** Present only when the publisher allowlisted `title`. */
37
48
  title?: string;
38
49
  properties: Record<string, string | number | boolean>;
39
- body: {
40
- format: "markdown";
41
- content: string;
42
- } | null;
50
+ body: PublicBody | null;
43
51
  /** Publication day, YYYY-MM-DD. */
44
52
  publishedOn: string;
45
53
  }
@@ -123,10 +131,7 @@ interface PublicCollectionPage {
123
131
  }
124
132
  /** One published record, with its pinned body. */
125
133
  interface PublicRecord extends PublicRecordSummary {
126
- body: {
127
- format: "markdown";
128
- content: string;
129
- } | null;
134
+ body: PublicBody | null;
130
135
  }
131
136
  /** The collection door's query bounds (backend `parseCollectionQuery`). */
132
137
  declare const SITE_MAX_OFFSET = 10000;
@@ -250,4 +255,4 @@ interface PublicClient {
250
255
  }
251
256
  declare function createPublicClient(opts: PublicClientOptions): PublicClient;
252
257
 
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 };
258
+ export { type CollectionQuery as C, type PublicForm as P, type Revalidate as R, type SubmitResult as S, type PublicReadError as a, type PublicBody as b, type PublicRecordSummary as c, type PublicValue as d, type PublicCollectionPage as e, type PublicRecord as f, type PublicResult as g, type PublicShare as h, type PublicClient as i, type PublicClientOptions as j, type PublicFailure as k, type PublicFormField as l, type PublicFormFieldType as m, type PublicNotModified as n, type PublicSite as o, type PublicSiteCollection as p, type PublicSiteError as q, type PublicSiteResult as r, type PublicSubmitError as s, SITE_MAX_LIMIT as t, SITE_MAX_OFFSET as u, SITE_MAX_SORT_KEYS as v, SITE_PUBLISHED_AT_SORT as w, type SubmitFormInput as x, createPublicClient as y };
@@ -31,15 +31,23 @@
31
31
  * `src/__tests__/fixtures.ts` copies those shapes; when the backend changes,
32
32
  * update both.
33
33
  */
34
+ /**
35
+ * A published body, in the format the pod serves it (backend
36
+ * `PublicBodyFormatSchema`): `markdown` is text to parse with
37
+ * `parseSafeMarkdown`; `html` is a whole HTML document (an estimate, a report)
38
+ * that a page renders ONLY inside `<iframe sandbox="" srcdoc>`, never in its
39
+ * own DOM. `null`: nothing pinned, or a body that is not text (pdf, docx).
40
+ */
41
+ interface PublicBody {
42
+ format: "markdown" | "html";
43
+ content: string;
44
+ }
34
45
  interface PublicShare {
35
46
  resourceType: "entity";
36
47
  /** Present only when the publisher allowlisted `title`. */
37
48
  title?: string;
38
49
  properties: Record<string, string | number | boolean>;
39
- body: {
40
- format: "markdown";
41
- content: string;
42
- } | null;
50
+ body: PublicBody | null;
43
51
  /** Publication day, YYYY-MM-DD. */
44
52
  publishedOn: string;
45
53
  }
@@ -123,10 +131,7 @@ interface PublicCollectionPage {
123
131
  }
124
132
  /** One published record, with its pinned body. */
125
133
  interface PublicRecord extends PublicRecordSummary {
126
- body: {
127
- format: "markdown";
128
- content: string;
129
- } | null;
134
+ body: PublicBody | null;
130
135
  }
131
136
  /** The collection door's query bounds (backend `parseCollectionQuery`). */
132
137
  declare const SITE_MAX_OFFSET = 10000;
@@ -250,4 +255,4 @@ interface PublicClient {
250
255
  }
251
256
  declare function createPublicClient(opts: PublicClientOptions): PublicClient;
252
257
 
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 };
258
+ export { type CollectionQuery as C, type PublicForm as P, type Revalidate as R, type SubmitResult as S, type PublicReadError as a, type PublicBody as b, type PublicRecordSummary as c, type PublicValue as d, type PublicCollectionPage as e, type PublicRecord as f, type PublicResult as g, type PublicShare as h, type PublicClient as i, type PublicClientOptions as j, type PublicFailure as k, type PublicFormField as l, type PublicFormFieldType as m, type PublicNotModified as n, type PublicSite as o, type PublicSiteCollection as p, type PublicSiteError as q, type PublicSiteResult as r, type PublicSubmitError as s, SITE_MAX_LIMIT as t, SITE_MAX_OFFSET as u, SITE_MAX_SORT_KEYS as v, SITE_PUBLISHED_AT_SORT as w, type SubmitFormInput as x, createPublicClient as y };