@synap-core/sdk 1.4.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.
- package/CHANGELOG.md +133 -0
- package/README.md +136 -10
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +412 -64
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +400 -32
- package/dist/connect.d.ts +400 -32
- package/dist/connect.js +407 -65
- package/dist/connect.js.map +1 -1
- package/dist/public.cjs +9 -2
- package/dist/public.cjs.map +1 -1
- package/dist/public.d.cts +19 -4
- package/dist/public.d.ts +19 -4
- package/dist/public.js +8 -2
- package/dist/public.js.map +1 -1
- package/dist/testing.cjs +234 -12
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +122 -27
- package/dist/testing.d.ts +122 -27
- package/dist/testing.js +230 -12
- package/dist/testing.js.map +1 -1
- package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
- package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
- package/package.json +30 -6
- package/src/connect/auth.ts +156 -27
- package/src/connect/client.ts +374 -34
- package/src/connect/cloud.ts +14 -10
- package/src/connect/discovery.ts +66 -32
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +7 -0
- package/src/connect/flow.ts +42 -14
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +254 -0
- package/src/connect/index.ts +60 -42
- package/src/connect/rules.ts +18 -0
- package/src/connect/store.ts +9 -5
- package/src/connect/types.ts +28 -4
- package/src/public/client.ts +14 -2
- package/src/public/safe-markdown.ts +13 -0
- package/src/public/share-model.ts +6 -2
- package/src/public/site-model.ts +5 -3
- package/src/testing/auth-server.ts +239 -14
- package/src/testing/cloud.ts +3 -4
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +8 -6
- package/src/testing/seed.ts +120 -0
- 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, 1.4.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
|
|
@@ -171,17 +174,29 @@ export const config = {
|
|
|
171
174
|
|
|
172
175
|
// a server component / action (no Request there: pass the Cookie header)
|
|
173
176
|
const connection = await synap.readConnection((await headers()).get("cookie"));
|
|
174
|
-
|
|
177
|
+
if (!connection) redirect("/connect");
|
|
178
|
+
const pod = synap.client(connection);
|
|
175
179
|
const res = await pod.post("/entities", { profileSlug: "post", title: "Hello" });
|
|
176
180
|
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 | …
|
|
181
|
+
if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupported | rate_limited (retryAfterSec) | pod_unreachable | pod_moved | …
|
|
178
182
|
```
|
|
179
183
|
|
|
180
|
-
- **Discovery**: `discoverPod("pod.
|
|
184
|
+
- **Discovery**: `discoverPod("you.pod.synap.live")` fetches the pod's RFC 8414
|
|
181
185
|
metadata and requires `issuer` equal to the address, CIMD, `iss` on answers and
|
|
182
|
-
S256 (`not_a_pod`, `pod_unsupported`, `pod_unreachable`). A
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
185
200
|
- **Failures** redirect with `?error=<ConnectErrorCode>`: `denied`,
|
|
186
201
|
`expired_transaction`, `mismatch`, `misconfigured`, `rate_limited`,
|
|
187
202
|
`pod_unreachable`, … (`parseConnectErrorCode` reads it back). `connect` and
|
|
@@ -196,7 +211,7 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
|
|
|
196
211
|
rotation's result instead of reaching the pod. Across server instances only
|
|
197
212
|
a shared server-side `ConnectionStore` rules that replay out. A concurrent
|
|
198
213
|
loser (refused softly by the pod) keeps the still-valid access token.
|
|
199
|
-
The pod's reuse grace
|
|
214
|
+
The pod's reuse grace answers a spent token presented again within
|
|
200
215
|
30 s with the same pair the first use got; its `expires_in` may be under an
|
|
201
216
|
hour, and the SDK reads it as sent.
|
|
202
217
|
- **Your own store**: implement `ConnectionStore` (`read`, `write`, `clear`,
|
|
@@ -211,7 +226,7 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
|
|
|
211
226
|
goes to the pod's consent screen even when this browser is already connected to
|
|
212
227
|
that pod (to widen or narrow access). It is a normal sign-in: fresh PKCE verifier
|
|
213
228
|
and state, `iss` checked; a denied or forged answer leaves the live connection as it is.
|
|
214
|
-
- **Synap Cloud
|
|
229
|
+
- **Synap Cloud**: with `cloudUrl` set, `synap.cloudConnectAvailable` is
|
|
215
230
|
true and `cloudConnectAvailable({ cloudUrl })` too. POST `handlers.cloudConnect`
|
|
216
231
|
sends the browser to `<cp>/connect/pod?client_id=<client id>&state=<nonce>`, the
|
|
217
232
|
state sealed in an HttpOnly cookie of that browser (10 minutes). The CP returns to
|
|
@@ -258,12 +273,123 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
|
|
|
258
273
|
every time; ask for templates with `templates` (CIMD `synap_templates`) instead:
|
|
259
274
|
the pod's consent offers to create the space.
|
|
260
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`.
|
|
375
|
+
|
|
261
376
|
Test without a pod: `createMockAuthServer()` from `@synap-core/sdk/testing`
|
|
262
377
|
(metadata, `authorize()` as the consent screen, `/token` with PKCE, rotating
|
|
263
378
|
refresh with the 30 s reuse grace, `/revoke`, Hub REST doors: `GET /entities`
|
|
264
379
|
answers an array as the pod does, `GET /proposals` lists what proposed writes
|
|
265
380
|
filed; `synap_app` is `MOCK_APP` unless `app: null`; `writeMode`, `failures`, `refresh: false`, `entities`,
|
|
266
|
-
`refreshGraceSec`), and `createMockCloud()` for the Synap Cloud pod chooser
|
|
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
|
+
|
|
267
393
|
|
|
268
394
|
```typescript
|
|
269
395
|
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-
|
|
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
|
|
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
|
|
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 };
|