@synap-core/sdk 1.4.0 → 1.5.1

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 +215 -0
  2. package/README.md +152 -10
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +419 -64
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +416 -32
  10. package/dist/connect.d.ts +416 -32
  11. package/dist/connect.js +414 -65
  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 +350 -15
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +171 -27
  22. package/dist/testing.d.ts +171 -27
  23. package/dist/testing.js +346 -15
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
  26. package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
  27. package/package.json +30 -6
  28. package/src/connect/auth.ts +169 -27
  29. package/src/connect/client.ts +377 -34
  30. package/src/connect/cloud.ts +14 -10
  31. package/src/connect/discovery.ts +66 -32
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +7 -0
  34. package/src/connect/flow.ts +42 -14
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +271 -0
  37. package/src/connect/index.ts +61 -42
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/store.ts +9 -5
  40. package/src/connect/types.ts +28 -4
  41. package/src/public/client.ts +14 -2
  42. package/src/public/safe-markdown.ts +13 -0
  43. package/src/public/share-model.ts +6 -2
  44. package/src/public/site-model.ts +5 -3
  45. package/src/testing/auth-server.ts +350 -17
  46. package/src/testing/cloud.ts +3 -4
  47. package/src/testing/fixtures.ts +26 -0
  48. package/src/testing/index.ts +8 -6
  49. package/src/testing/roles.ts +27 -0
  50. package/src/testing/seed.ts +196 -0
  51. package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,215 @@
1
+ # @synap-core/sdk changelog
2
+
3
+ ## 1.5.1
4
+
5
+ What a pod app sees of a person's pod, and the roles (Kind + Facets) on it: the
6
+ mock pod models the reach rule and answers roles as the pod does, so an app
7
+ that lists by role (a CRM's leads) shows its pages on mock data; the typed
8
+ client asks for roles; mock mode may ask for the review verbs. Additive, except
9
+ the mock behaviours under "Behaviour changes (mock)".
10
+
11
+ ### `@synap-core/sdk/connect`
12
+
13
+ - **`listEntities({ facetSlug?, includeFacets? })`**: only the entities wearing
14
+ a role, and each row's roles on it (`PodEntity.facetSlugs`, `PodEntity.facets:
15
+ PodEntityFacet[]` = `{ facetId, slug, properties, status }`, present only on a
16
+ row that wears one). `includeFacets` is sent only when true. It needs a pod
17
+ with the backend release that adds it to `GET /entities` (synap-backend
18
+ `feat/hub-entities-include-facets`); until that pod runs, the pod ignores it
19
+ and the rows come back without roles.
20
+ - **Mock mode may ask for the review verbs**: `buildClientMetadata` (and so
21
+ `createPodAppAuth`) accepts `proposal.approve` / `proposal.reject` on an http
22
+ loopback `appUrl` (`localhost`, `127.0.0.1`, `[::1]`), so an app that requests
23
+ them starts mock mode, smoke and shots. A real pod refuses an http client_id
24
+ outright (synap-backend `client-id-url.ts`); every other origin is refused
25
+ as in 1.5.0.
26
+
27
+ ### `@synap-core/sdk/testing`
28
+
29
+ - **The mock models the pod's reach rule**: `GET /entities`, `GET /entities/:id`
30
+ and connection neighbours answer rows of a granted space plus pod-wide
31
+ entities (`workspaceId` null) with a role in a granted space. A pod-wide
32
+ entity with no role in a granted space, and any row of an ungranted space, is
33
+ never answered (a miss is the pod's empty 404). Reads need an
34
+ `entity.*.read` grant in the token's scope.
35
+ - **Seeded roles, one field: `MockSeedEntity.facets`** (`MockSeedFacet`:
36
+ `facetId`, `slug`, `properties?`, `status?`, and seed-only `workspaceId`: the
37
+ space whose members see the role, stamped there or a pod-wide facet of a role
38
+ granted to it). Seed input only: no answer carries them unless `includeFacets`
39
+ asks. Validated by `parseMockPodSeed`.
40
+ - **`seedRoles`** (`[{ profileSlug, workspaceId }]`), **deprecated: use
41
+ `facets`; removed in 2.0.** A shorthand desugared into `facets` when the seed
42
+ is read (a generated `facetId`, no overlay); a role stated in both on one
43
+ entity is a parse error naming the entity.
44
+ - **`facets[].sharedRole`** (seed only, never served, validated): the role is
45
+ stored pod-wide and granted to `workspaceId` (default: stamped in it). It
46
+ differs from a stamped role in `includePodWide=false` on a space lens, and in
47
+ `GET /entities/:id/facets` (its facet answers `workspaceId` null).
48
+ - **`PATCH /entities/:id` of a pod-wide row** is refused (the mock answers 403;
49
+ the pod's 403 or 404 is not yet pinned): nothing written, no proposal.
50
+ - **A row of another space T with a role stamped in S** is visible to an app
51
+ granted S (list and by id), as to a member of S; a shared role does not
52
+ reach a row of another space.
53
+ - **`GET /entities`** as the pod (`rest/entities.ts`): `includePodWide=false`
54
+ on a workspace lens drops only the pod-wide rows reached through a SHARED role
55
+ (the pod keeps those with a role stamped in the space); `facetSlug` filters by a role
56
+ the read sees: what a member of a granted space sees (a workspace lens, that
57
+ space's roles only; never a role of a space outside the grant); a
58
+ `profileSlug` naming a role
59
+ matches its wearers too; `includeFacets=true` adds `facetSlugs` + `facets` to
60
+ the rows wearing one.
61
+ - **`GET /entities/:id/facets`**: `{ facets: [{ facet, profile, effectiveProperties }] }`
62
+ (`getEffectiveFacets` under the token's floor; the mock knows a role by its
63
+ slug only, so `profile` is `{ id: "profile_<slug>", slug, profileKind: "role" }`
64
+ and `effectiveProperties` is empty). A miss, or an entity out of reach, is the
65
+ pod's empty 404.
66
+ - **`GET /entities/:id/connections`**: the far end's `facetSlugs` come from its
67
+ seeded roles, under the read's `workspaceId` lens.
68
+ - Not mirrored: the mock knows no profile catalog (an undeclared `facetSlug`
69
+ answers `[]` where the pod answers 404; `facetProfileId` is not read), and it
70
+ serves neither `q` (the pod refuses it to a space-scoped key, and answers 400
71
+ to `q` with `includeFacets`) nor `scope=all`.
72
+ - Docs: AGENTS.md, "What a pod app can see and do". Needs a pod with the reach
73
+ rule (rolling out from 2026-10-23; an older pod returns no pod-wide rows to an app).
74
+
75
+ ### Behaviour changes (mock)
76
+
77
+ - An entity list without `workspaceId` no longer returns rows of spaces the
78
+ token was not granted; a pod-wide row is listed only when it has a role in a
79
+ granted space.
80
+ - `includePodWide=false` on a space lens keeps the pod-wide rows with a role
81
+ stamped in the space (it dropped every pod-wide row).
82
+ - `GET /entities` and `GET /entities/:id` no longer echo a seed row's
83
+ `facets` / `facetSlugs` (the pod's `entityToWire` never sends them).
84
+
85
+ ## 1.5.0
86
+
87
+ For Content Studio, the first pod app on `@synap-core/sdk/connect`, plus a
88
+ hardening pass. Additive, except three behaviours that were bugs (see
89
+ "Behaviour changes").
90
+
91
+ ### `@synap-core/sdk/connect`
92
+
93
+ - **Typed Hub reads on `createPodClient(connection)`**, each a `PodResult` with
94
+ the 1.4 error codes: `listEntities({ workspaceId?, profileSlug?, limit? (≤ 1000),
95
+ offset?, sort?, includePodWide? })`, `getEntity(id)`,
96
+ `getEntityConnections(id, { workspaceId?, limit? })`, `getDocument(id, { format? })`,
97
+ `listFocusSessions({ workspaceId | projectId, status?, limit? (≤ 50), lens?, kind?,
98
+ subjectEntityId? })`, `getFileUrl(entityId)`. Each shape is read from the
99
+ backend route (see `src/connect/hub-types.ts`); a 2xx answer of another shape
100
+ is `server`, never empty data.
101
+ - **`humanSlots(session)`**: the slots of a focus session that wait on the person
102
+ (`owner: "human"`, not done, not retired), for a "needs you" list.
103
+ - **`listPendingProposals({ proposalIds })`**: only those proposals (≤ 100; the
104
+ pod's filter from backend #28). An empty list answers without a request. A pod
105
+ that ignores the filter never hands back a proposal that was not asked for.
106
+ - **Who the connection acts as**: `connection.actsAs` / `client.actsAs` /
107
+ `connectionActsAs(connection)` read the token response's `synap_acts_as`. `"person"`: an official Synap app, the token is the person's,
108
+ writes apply directly. `"agent"`: every other app, writes are proposals. A pod
109
+ that does not send it is `"agent"`; the field is read from every token
110
+ response, never carried over from the previous one.
111
+ - **`approveProposal(id, { workspaceId? })`**: `POST /proposals/:id/approve` as
112
+ the person. An agent connection gets `forbidden` with `reason:
113
+ "agent_cannot_approve"` and nothing is sent; without `proposal.approve`,
114
+ `forbidden` / `not_granted`, also without a request.
115
+ - **`PodError.reason`** (`agent_cannot_approve | not_granted | invalid_input`):
116
+ set when the SDK refused locally (`status: null`). `listPendingProposals`
117
+ without `proposal.read` now carries `reason: "not_granted"`. A typed method
118
+ given an id that could leave its path segment answers `invalid`, never throws.
119
+ - **Spaces carry their template**: `PodGrant.templateSlug` and
120
+ `PodSpace.templateSlug` (from `synap_grant[].templateSlug`), so
121
+ `client.spaces().find((s) => s.templateSlug === "content-os")` finds the space.
122
+ Absent when the space came from no template or the pod does not send it. The
123
+ sealed cookie keeps it (cookies sealed by 1.4 still open).
124
+ - **A 401 renews once**: `synap.client(connection, { persist, request })` (a
125
+ route handler or a server action) renews a connection whose access key the
126
+ pod refused (a narrow, a pod-side "Revoke key") with ONE single-flight
127
+ rotation, persisted before use, and retries the request once. Lower level:
128
+ `createPodClient(connection, { renew })`. Without `persist` (a server
129
+ component) nothing is rotated and the 401 carries `PodError.refreshable`;
130
+ `refreshIfNeeded(request, { force: true })` rotates regardless of expiry.
131
+ - **`pod_moved`** (`ConnectErrorCode`, `PodErrorCode`): `/token`, the metadata
132
+ or the Hub answered a redirect (a moved pod answers 308 from its old name).
133
+ - **Discovery finds Synap Cloud pods by their current name**: `you.synap.live`
134
+ also tries `you.pod.synap.live`, then `pod.you.synap.live` (at most three
135
+ addresses).
136
+ - **Bounded answers**: discovery, `/token` (64 KB each) and the Hub client
137
+ (`maxResponseBytes`, 8 MiB) count bytes on the stream and stop past the cap;
138
+ Hub requests time out (`timeoutMs`, 10 s) as `pod_unreachable`.
139
+ - **`buildClientMetadata` refuses what the pod refuses**: actions other than
140
+ read/create/update/delete (the review verbs `proposal.approve` /
141
+ `proposal.reject` only for an official Synap app origin), a kind on a subject
142
+ other than `entity`, uppercase, more than 32 requests counted with
143
+ duplicates, an IP address `appUrl`.
144
+ - **Packaging**: `engines.node >=20`, `types` per `import` / `require`
145
+ (`.d.ts` / `.d.cts`), `sideEffects`, this changelog. Shipped source no longer
146
+ cites internal spec, wave or PR tags.
147
+
148
+ ### `@synap-core/sdk/public`
149
+
150
+ - **An HTML body arrives as HTML** (backend `body.format: "markdown" | "html"`):
151
+ `PublicShare` and `PublicRecord` carry `body: { format, content }`, and
152
+ `SharePageModel` / `RecordViewModel` gain `html: string | null`. Render it
153
+ ONLY inside `<iframe sandbox="" srcdoc>`, never in the page's own DOM;
154
+ markdown still goes through `parseSafeMarkdown`. `@synap-core/sdk/testing`
155
+ adds `MOCK_HTML_SHARE` (`MOCK_TOKENS.htmlShare`) for the mock-mode path.
156
+
157
+ ### Known issue
158
+
159
+ - A **CommonJS** consumer that type-checks declaration files
160
+ (`skipLibCheck: false`) gets `TS1479` on the root entry `@synap-core/sdk`:
161
+ its `.d.cts` imports `@synap-core/api-types`, which ships ESM types only.
162
+ `skipLibCheck: true` (the default for Next.js and most apps), every ESM or
163
+ bundler consumer, and the `/public`, `/connect` and `/testing` entries are
164
+ unaffected. Fixed when api-types ships CJS declarations.
165
+
166
+ ### Behaviour changes
167
+
168
+ - A Hub path that could leave `/api/hub` answers `{ ok: false, error: { code:
169
+ "invalid", reason: "invalid_input" } }` instead of throwing a `TypeError`, and
170
+ percent-encoded dot segments (`%2e%2e`) are refused too (WHATWG URL resolves
171
+ them like `..`, which sent the Bearer token to other pod routes).
172
+ - `refreshIfNeeded` answering `revoked` or `expired` with the access token
173
+ spent now clears the session cookie (the marker stays), instead of leaving it
174
+ for 30 days and posting the dead token to `/token` on every page view.
175
+ - A Hub answer with a 3xx status is `pod_moved` (was `server`); a `/token`
176
+ redirect is `pod_moved` (was `server_error`).
177
+
178
+ ### Kept for 1.x, changing in 2.0
179
+
180
+ - The low-level building blocks of `@synap-core/sdk/connect` (`seal`/`unseal`,
181
+ `startConnect`/`handleCallback`/`refreshConnection`/`revokeConnection`,
182
+ `openTransaction`, `parseTokenResponse`, `startCloudConnect`/`acceptCloudReturn`,
183
+ the cookie helpers, `pkceChallenge`, the `*_MS` constants) are internal: an app on
184
+ `createPodAppAuth` needs none of them and nothing new is added to them. 2.0 moves
185
+ them to their own entry. `ConnectionStore` / `createSealedCookieStore` stay the
186
+ supported custom-store seam.
187
+ - `startCloudConnect`: `secret` becomes required, `state` (ignored since 1.4) is
188
+ removed, and the call becomes a plain async function in 2.0.
189
+
190
+ ### `@synap-core/sdk/testing`
191
+
192
+ - **`parseMockPodSeed(jsonOrValue)`** and `createMockAuthServer({ seed })`: the
193
+ mock pod's data as ONE JSON shape, `{ entities, sessions, documents, relations,
194
+ proposals, spaces }`, so a mock mode, a smoke run and screenshots replay one
195
+ recorded file.
196
+ - New mock doors, answered from the seed in the backend's shapes:
197
+ `GET /entities/:id`, `GET /entities/:id/connections`, `GET /documents/:id`,
198
+ `GET /focus-sessions`, `POST /proposals/:id/approve`; `GET /proposals` honours
199
+ `proposalIds`.
200
+ - `createMockAuthServer({ actsAs: "person" | "agent" })` answers
201
+ `synap_acts_as`; a person token's writes apply (unless `writeMode`) and it may
202
+ approve where it holds `proposal.approve`; an agent's approve is refused 403.
203
+ `mock.approvals` lists what was approved. Seeded `spaces` put `templateSlug`
204
+ on the token response's grant rows.
205
+
206
+ ## 1.4.0
207
+
208
+ Re-consent (`prompt=consent`), Synap Cloud connect, `connection.manageUrl` /
209
+ `appId`, `listPendingProposals`, `spaces()`, the lapse marker (`readLapsed`),
210
+ `isPrefetchOrAsset`, `createMockCloud`. See the README.
211
+
212
+ ## 1.3.0
213
+
214
+ `@synap-core/sdk/connect`: pod-app sign-in (CIMD, PKCE S256, `iss` checks,
215
+ 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,9 @@ 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, 1.4.0)
125
+ What a pod app can see and do (spaces, pod-wide people and companies, roles): see [AGENTS.md, "What a pod app can see and do"](./AGENTS.md#what-a-pod-app-can-see-and-do).
126
+
127
+ ## Pod-app sign-in (server-only): `@synap-core/sdk/connect` (1.3.0, 1.4.0, 1.5.0)
123
128
 
124
129
  An app that reads and writes a person's OWN pod connects as an OAuth 2.1 public
125
130
  client of that pod: its `client_id` is the https URL of its client metadata
@@ -171,17 +176,29 @@ export const config = {
171
176
 
172
177
  // a server component / action (no Request there: pass the Cookie header)
173
178
  const connection = await synap.readConnection((await headers()).get("cookie"));
174
- const pod = synap.client(connection!);
179
+ if (!connection) redirect("/connect");
180
+ const pod = synap.client(connection);
175
181
  const res = await pod.post("/entities", { profileSlug: "post", title: "Hello" });
176
182
  if (res.ok && res.status === "proposed") showPending(res.reviewUrl); // success: queued for review, never retry
177
- if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupported | rate_limited (retryAfterSec) | pod_unreachable | …
183
+ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupported | rate_limited (retryAfterSec) | pod_unreachable | pod_moved | …
178
184
  ```
179
185
 
180
- - **Discovery**: `discoverPod("pod.example.com")` fetches the pod's RFC 8414
186
+ - **Discovery**: `discoverPod("you.pod.synap.live")` fetches the pod's RFC 8414
181
187
  metadata and requires `issuer` equal to the address, CIMD, `iss` on answers and
182
- S256 (`not_a_pod`, `pod_unsupported`, `pod_unreachable`). A browser launch
183
- (`?iss=…&target_link_uri=…`) is read with `acceptLaunch` and only pre-fills the
184
- connect page.
188
+ S256 (`not_a_pod`, `pod_unsupported`, `pod_unreachable`). A Synap Cloud name
189
+ typed the old way (`you.synap.live`) also tries `you.pod.synap.live`, then
190
+ `pod.you.synap.live`; another address also tries `pod.<address>`. A redirect is
191
+ never followed (`pod_moved` when only an old, moved name answers). A browser
192
+ launch (`?iss=…&target_link_uri=…`) is read with `acceptLaunch` and only
193
+ pre-fills the connect page. The connect route fetches an address a visitor
194
+ typed: rate-limit it per visitor.
195
+ - **Local development**: an app on `http://localhost` can sign in to the SDK's
196
+ mock pod only (`@synap-core/sdk/testing`, `allowInsecureLoopback`). Every real
197
+ pod, a local one included, refuses an http `client_id` and `redirect_uri`: run
198
+ the app on https (a tunnel) to sign in to a real pod.
199
+ - **`space`** (the connect form, `startConnect({ spaceHint })`) is sent to the pod
200
+ as `synap_space_hint`, a hint its consent screen may use. A pod may ignore it:
201
+ never rely on a space being preselected.
185
202
  - **Failures** redirect with `?error=<ConnectErrorCode>`: `denied`,
186
203
  `expired_transaction`, `mismatch`, `misconfigured`, `rate_limited`,
187
204
  `pod_unreachable`, … (`parseConnectErrorCode` reads it back). `connect` and
@@ -196,7 +213,7 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
196
213
  rotation's result instead of reaching the pod. Across server instances only
197
214
  a shared server-side `ConnectionStore` rules that replay out. A concurrent
198
215
  loser (refused softly by the pod) keeps the still-valid access token.
199
- The pod's reuse grace (PB3b) answers a spent token presented again within
216
+ The pod's reuse grace answers a spent token presented again within
200
217
  30 s with the same pair the first use got; its `expires_in` may be under an
201
218
  hour, and the SDK reads it as sent.
202
219
  - **Your own store**: implement `ConnectionStore` (`read`, `write`, `clear`,
@@ -211,7 +228,7 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
211
228
  goes to the pod's consent screen even when this browser is already connected to
212
229
  that pod (to widen or narrow access). It is a normal sign-in: fresh PKCE verifier
213
230
  and state, `iss` checked; a denied or forged answer leaves the live connection as it is.
214
- - **Synap Cloud** (wave C1): with `cloudUrl` set, `synap.cloudConnectAvailable` is
231
+ - **Synap Cloud**: with `cloudUrl` set, `synap.cloudConnectAvailable` is
215
232
  true and `cloudConnectAvailable({ cloudUrl })` too. POST `handlers.cloudConnect`
216
233
  sends the browser to `<cp>/connect/pod?client_id=<client id>&state=<nonce>`, the
217
234
  state sealed in an HttpOnly cookie of that browser (10 minutes). The CP returns to
@@ -258,12 +275,137 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
258
275
  every time; ask for templates with `templates` (CIMD `synap_templates`) instead:
259
276
  the pod's consent offers to create the space.
260
277
 
278
+ ### 1.5.0 (additive): typed reads, who the app acts as, approvals
279
+
280
+ **Which client for what.** A pod app (a website or service a person connects to
281
+ their OWN pod, with a token from this sign-in) reads and writes through
282
+ `synap.client(connection)` / `createPodClient(connection)`: it carries the
283
+ connection's token, refuses expired tokens locally, and answers the typed codes
284
+ below. `@synap-core/hub-rest-client` stays the client for scripts, CLIs and
285
+ agents holding a static pod key (`apiKey`): it retries, throws `HubApiError` and
286
+ knows far more doors. The two are separate on purpose; this SDK does not depend
287
+ on it (CLIENTS.md).
288
+
289
+ ```typescript
290
+ const pod = synap.client(connection);
291
+
292
+ // Typed reads: PodResult<T>, the same codes as get/post (expired | unauthorized | forbidden |
293
+ // door_unsupported | not_found | invalid | rate_limited | pod_unreachable | server | …)
294
+ await pod.listEntities({ workspaceId, profileSlug: "post", limit: 1000 }); // PodEntity[]
295
+ await pod.listEntities({ workspaceId, facetSlug: "lead", includeFacets: true }); // the wearers of a role, each with facets
296
+ await pod.getEntity(id); // PodEntity, not_found on a miss
297
+ await pod.getEntityConnections(id); // { connections, counts }
298
+ await pod.getDocument(documentId); // { content, revision, sections, … }
299
+ const sessions = await pod.listFocusSessions({ workspaceId, status: "active" });
300
+ if (sessions.ok) sessions.data.flatMap(humanSlots); // what waits on the person (owner: "human")
301
+ await pod.getFileUrl(fileEntityId); // { url, expiresAt, mimeType }
302
+ await pod.listPendingProposals({ proposalIds: [id1, id2] }); // only those (≤ 100)
303
+
304
+ // Who the token acts as: "person" for an official Synap app, else "agent".
305
+ pod.actsAs; // also connection.actsAs (absent = agent) and connectionActsAs(connection)
306
+ const space = pod.spaces().find((s) => s.templateSlug === "content-os"); // the space made from that template
307
+
308
+ // Approve, as the person (needs proposal.approve in requests, which only an official Synap app
309
+ // on https://<id>.synap.live may ask for; any other appUrl is refused at setup). An agent connection is refused
310
+ // before any request: { ok: false, error: { code: "forbidden", status: null, reason: "agent_cannot_approve" } }
311
+ const res = await pod.approveProposal(proposalId, { workspaceId });
312
+ ```
313
+
314
+ - **Writes**: an agent connection's writes are proposals (`proposed` is
315
+ success: show `reviewUrl`, never retry). A person connection's writes apply
316
+ directly, as the person, with no review step (`applied`). The result reports what the pod did either way, so a
317
+ `proposed` answer to a person (a space that reviews its members' changes) is
318
+ still success.
319
+ - **`actsAs`** is read from every token response (code exchange, refresh, the
320
+ reuse grace), never carried over: a pod that stops saying `"person"`, or never
321
+ did, gives an agent connection. It drives the SDK's own
322
+ refusals and your UI's words; the pod enforces the rule itself.
323
+ - **Local refusals** answer `status: null` with a `reason`
324
+ (`agent_cannot_approve`, `not_granted`, `invalid_input`) and send nothing: an
325
+ agent approving, a missing `proposal.read` / `proposal.approve`, an id that
326
+ could leave its path segment, `listFocusSessions` without a space or project.
327
+ - **Shapes** are read from the backend routes (`src/connect/hub-types.ts` names
328
+ each one). A 2xx answer of another shape is `server` (status 200), never empty
329
+ data. Fields the SDK does not type stay on the objects.
330
+ - **`templateSlug`** (`PodGrant`, `PodSpace`): the template the space was made
331
+ from, when the pod names one; absent for a space made by hand and from pods
332
+ that do not send it (keep a fallback, such as `ensure-template`, for those).
333
+ It survives the sealed cookie (`readConnection`).
334
+
335
+ ### 1.5.0: hardening
336
+
337
+ - **A 401 renews once.** A narrow, or a pod-side "Revoke key", ends the access
338
+ key, not the connection. A client that can set cookies renews it ONCE (one
339
+ single-flight rotation, persisted before use) and retries the request once:
340
+
341
+ ```typescript
342
+ // a route handler: send the renewed cookie on its own response, as with refreshIfNeeded
343
+ const setCookies: string[] = [];
344
+ const pod = synap.client(connection, { request, persist: (c) => void setCookies.push(...c) });
345
+ const entities = await pod.listEntities({ workspaceId });
346
+ const response = Response.json(entities);
347
+ for (const c of setCookies) response.headers.append("Set-Cookie", c);
348
+ return response;
349
+ ```
350
+
351
+ `unauthorized` then means the pod refused the connection itself. A server
352
+ component cannot set cookies, so it passes no `persist`: nothing is rotated
353
+ there (the old refresh token would be spent and lost), and a 401 answers
354
+ `unauthorized` with `refreshable: true`. Send that visitor through the proxy
355
+ once with `refreshIfNeeded(request, { force: true })` (it rotates whatever the
356
+ expiry says; refused, it is `revoked`, never `kept`).
357
+ - **A revoked connection's cookie is cleared** (`revoked` / `expired` with the
358
+ access token spent; the marker stays, so the visitor reads "expired"), so each
359
+ page view no longer posts the dead token to `/token`. A refresh that fails
360
+ while the access token still works (`kept`) is not asked again for a minute.
361
+ - **A moved pod is `pod_moved`** (`ConnectErrorCode` and `PodErrorCode`): a
362
+ redirect from `/token`, the metadata or the Hub. Connect again with the pod's
363
+ new address; the SDK never takes it from the redirect.
364
+ - **Bounded answers**: discovery (64 KB), the token endpoint (64 KB) and the Hub
365
+ (`maxResponseBytes`, 8 MiB) count bytes on the stream and stop there; Hub
366
+ requests time out (`timeoutMs`, 10 s) as `pod_unreachable`.
367
+ - **Paths**: a Hub path that could leave `/api/hub` (a dot segment, plain or
368
+ percent-encoded, `//`, `\`, a query) answers `invalid` / `invalid_input`
369
+ instead of throwing. Encode each path segment (`encodeURIComponent`).
370
+ - **`buildClientMetadata` refuses what the pod refuses**, at setup: an action
371
+ other than read/create/update/delete (`proposal.approve` and
372
+ `proposal.reject` only for an official Synap app on `https://<id>.synap.live`),
373
+ a kind on a subject other than `entity`, more than 32 requests counted with
374
+ duplicates, an IP address `appUrl`.
375
+ - **Packaging**: `engines.node >=20` (WebCrypto global), `types` per
376
+ `import`/`require` (`.d.ts` / `.d.cts`), `sideEffects` (only the `connect`
377
+ entry runs code on load), this package's `CHANGELOG.md`.
378
+
261
379
  Test without a pod: `createMockAuthServer()` from `@synap-core/sdk/testing`
262
380
  (metadata, `authorize()` as the consent screen, `/token` with PKCE, rotating
263
381
  refresh with the 30 s reuse grace, `/revoke`, Hub REST doors: `GET /entities`
264
382
  answers an array as the pod does, `GET /proposals` lists what proposed writes
265
383
  filed; `synap_app` is `MOCK_APP` unless `app: null`; `writeMode`, `failures`, `refresh: false`, `entities`,
266
- `refreshGraceSec`), and `createMockCloud()` for the Synap Cloud pod chooser:
384
+ `refreshGraceSec`), and `createMockCloud()` for the Synap Cloud pod chooser.
385
+ 1.5: `seed` (one JSON shape, `parseMockPodSeed`) feeds `GET /entities/:id`,
386
+ `GET /entities/:id/connections`, `GET /documents/:id`, `GET /focus-sessions` and
387
+ `proposalIds`; `actsAs: "person" | "agent"` answers `synap_acts_as` (a person's
388
+ writes apply, it may approve where granted; an agent's approve is refused 403):
389
+
390
+ ```typescript
391
+ import { readFileSync } from "node:fs";
392
+ const seed = parseMockPodSeed(readFileSync("mock/pod-seed.json", "utf8")); // { entities, sessions, documents, relations, proposals, spaces }
393
+ const pod = createMockAuthServer({ seed, actsAs: "person" });
394
+ ```
395
+
396
+ 1.5.1: an entity's roles ride on its seed row as `facets`, the shape
397
+ `includeFacets=true` answers, plus the space whose members see it
398
+ (`workspaceId`, seed only). A role in a granted space puts a pod-wide row in the app's reach
399
+ (AGENTS.md, "What a pod app can see and do"). `GET /entities` then filters by
400
+ `facetSlug`, drops a lens's pod-wide rows on `includePodWide: false`, and puts
401
+ `facetSlugs` + `facets` on a row only when asked; `GET /entities/:id/facets` is
402
+ served:
403
+
404
+ ```json
405
+ { "id": "ent_ada", "profileSlug": "person", "title": "Ada", "workspaceId": null,
406
+ "facets": [{ "facetId": "fct_1", "slug": "lead", "properties": { "leadStage": "prospect" }, "status": "new", "workspaceId": "ws_crm" }] }
407
+ ```
408
+
267
409
 
268
410
  ```typescript
269
411
  const pod = createMockAuthServer();
@@ -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 PublicReadError as a, type PublicRecordSummary as b, type PublicValue as c, type PublicCollectionPage as d, type PublicRecord as e, type PublicResult as f, type PublicShare as g, type PublicClient as h, type PublicClientOptions as i, type PublicFailure as j, type PublicFormField as k, type PublicFormFieldType as l, type PublicNotModified as m, type PublicSite as n, type PublicSiteCollection as o, type PublicSiteError as p, type PublicSiteResult as q, type PublicSubmitError as r, SITE_MAX_LIMIT as s, SITE_MAX_OFFSET as t, SITE_MAX_SORT_KEYS as u, SITE_PUBLISHED_AT_SORT as v, type SubmitFormInput as w, createPublicClient as x };
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 PublicReadError as a, type PublicRecordSummary as b, type PublicValue as c, type PublicCollectionPage as d, type PublicRecord as e, type PublicResult as f, type PublicShare as g, type PublicClient as h, type PublicClientOptions as i, type PublicFailure as j, type PublicFormField as k, type PublicFormFieldType as l, type PublicNotModified as m, type PublicSite as n, type PublicSiteCollection as o, type PublicSiteError as p, type PublicSiteResult as q, type PublicSubmitError as r, SITE_MAX_LIMIT as s, SITE_MAX_OFFSET as t, SITE_MAX_SORT_KEYS as u, SITE_PUBLISHED_AT_SORT as v, type SubmitFormInput as w, createPublicClient as x };
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 };