@synap-core/sdk 1.5.1 → 1.5.2

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/AGENTS.md ADDED
@@ -0,0 +1,250 @@
1
+ # AGENTS.md — @synap-core/sdk
2
+
3
+ Typed tRPC client for a Synap data pod. This is how an app reads and writes a pod.
4
+
5
+ ## Create a client
6
+
7
+ ```ts
8
+ import { createSynapClient } from "@synap-core/sdk";
9
+
10
+ const synap = createSynapClient({
11
+ podUrl: "https://pod.example.com", // NO trailing slash, NO /trpc suffix
12
+ sessionToken: process.env.SYNAP_SESSION_TOKEN, // OR apiKey (local-mode token only), OR omit for cookie auth
13
+ workspaceId: process.env.SYNAP_WORKSPACE_ID ?? "",
14
+ });
15
+
16
+ const me = await synap.users.me.query();
17
+ const list = await synap.entities.list.query({});
18
+ const res = await synap.entities.create.mutate({ /* ... */ });
19
+ ```
20
+
21
+ The returned object is a fully-typed tRPC client — every pod router is available
22
+ with autocomplete. Types come from `@synap-core/api-types`.
23
+
24
+ ## Auth (pick one)
25
+ - `apiKey` — the LOCAL_MODE token only. Hub / user API keys (`synap_hub_*`, `synap_user_*`) are refused by `/trpc` and throw here; use `@synap-core/hub-rest-client` on `/api/hub/*`.
26
+ - `sessionToken` — Ory Kratos token, sent as `X-Session-Token`. Non-browser clients.
27
+ - omit both — ambient Kratos cookie (browser, same-origin only).
28
+
29
+ Bootstrap a machine credential with `@synap-core/auth-bootstrap`
30
+ (`setupAgent` → API key, `handshake` → session token). Browser sign-in:
31
+ `@synap-core/auth`. React bindings: `@synap-core/react`. Realtime:
32
+ `@synap-core/sdk-realtime`.
33
+
34
+ ## Start your app's user from a template
35
+
36
+ To let a user begin from a ready-made workspace (a CRM, a research desk, …)
37
+ instead of a blank pod, use the `@synap-core/react` hooks — **discovery reads the
38
+ pod's ONE marketplace catalog, apply happens on the pod**:
39
+
40
+ ```tsx
41
+ import { useTemplateCatalog, useInstallTemplate } from "@synap-core/react";
42
+
43
+ function TemplatePicker() {
44
+ const { data } = useTemplateCatalog({ query: "crm" }); // browse installable templates
45
+ const install = useInstallTemplate();
46
+
47
+ return data?.templates.map((t) => (
48
+ <button
49
+ key={t.slug}
50
+ onClick={() =>
51
+ install.mutate(
52
+ { slug: t.slug },
53
+ { onSuccess: (r) => console.log("workspaceId", r.workspaceId) },
54
+ )
55
+ }
56
+ >
57
+ {t.name}
58
+ </button>
59
+ ));
60
+ }
61
+ ```
62
+
63
+ - **Discovery** — `useTemplateCatalog()` lists the pod's installable WORKSPACE
64
+ templates (slug + name + description + tier), read from the pod-local catalog
65
+ cache. Metadata only; no definition is downloaded to the client.
66
+ - **Install by slug** — `useInstallTemplate().mutate({ slug })` resolves the
67
+ freshest definition on the pod and provisions through the SAME shared engine
68
+ the in-app installer uses (idempotent, tier-gated, governed). It returns the
69
+ created/reconciled `workspaceId`.
70
+
71
+ Prefer this over hand-building a `definition` for `workspaces.createFromDefinition`
72
+ — you only need a catalog slug. Server door: `trpc.workspaces.installFromCatalog`.
73
+
74
+ ## Public sites (no credentials): `@synap-core/sdk/public`
75
+
76
+ For a public website use the credentialless entry, never the root client:
77
+ `createPublicClient({ podUrl })` → `readShare`, `getForm`, `submitForm`,
78
+ `getSite`, `listCollection`, `getRecord`. Results are `{ ok: true, data }` or a
79
+ typed `{ ok: false, error }` (`not_found` is one uniform miss). Render with the
80
+ same entry's models (`toFieldModels`, `submitFailure`, `toSharePageModel`,
81
+ `parseSafeMarkdown`, `formatPublicValue`, `toCollectionPageModel`,
82
+ `toRecordViewModel`); never render pod content as HTML in your page. The one
83
+ exception is a body the pod serves as `format: "html"` (a shared HTML document:
84
+ the models give it as `html`, with no blocks): render it ONLY as
85
+ `<iframe sandbox="" srcdoc={html}>` (an empty `sandbox`, never `src`), so its
86
+ scripts never run and it cannot reach your page. Test and preview with
87
+ `createMockPod()` from `@synap-core/sdk/testing` (no network; `MOCK_TOKENS.htmlShare`
88
+ is an html share).
89
+
90
+ ## Pod-app sign-in (server-only): `@synap-core/sdk/connect`
91
+
92
+ For an app that acts in a person's OWN pod (not a public site): `createPodAppAuth`
93
+ gives the connect / callback / disconnect / client-metadata route handlers, a
94
+ sealed session cookie, `refreshIfNeeded` for `proxy.ts`, and `client(connection)`
95
+ for Hub REST reads and governed writes. Rules:
96
+ - import it ONLY in server files (route handlers, `proxy.ts`, server-only
97
+ modules); it throws in a browser. Never put the token or
98
+ `SYNAP_APP_SESSION_SECRET` in a `NEXT_PUBLIC_*` variable;
99
+ - never hand-roll PKCE, `state`, `iss` checks or cookie sealing: the SDK owns them;
100
+ - a write's `proposed` is success: link `reviewUrl`, never retry (an agent
101
+ connection's writes are proposals; a person connection's, `client.actsAs ===
102
+ "person"`, apply directly, as the person: say "saved", not "sent for review");
103
+ - a Hub path is pod-relative with each segment encoded (`encodeURIComponent`);
104
+ a bad one answers `invalid`, never throws;
105
+ - `unauthorized` with `refreshable` (a server component's client): send the
106
+ visitor through `proxy.ts` once with `refreshIfNeeded(request, { force: true })`;
107
+ a route handler or action passes `persist` to `client()` and the SDK renews
108
+ once itself. `unauthorized` without it = the connection is gone: connect again;
109
+ - `pod_moved`: connect again with the pod's new address (never follow the redirect);
110
+ - `space` / `spaceHint` is a hint the pod may ignore: never promise a preselected space;
111
+ - `http://localhost` signs in to the mock pod only; a real pod needs the app on https;
112
+ - read with the typed methods (`listEntities`, `getEntity`, `getEntityConnections`,
113
+ `getDocument`, `listFocusSessions` + `humanSlots`, `getFileUrl`,
114
+ `listPendingProposals({ proposalIds })`) rather than raw `get`; approve with
115
+ `approveProposal` (refused locally for an agent connection);
116
+ - the space made from a template: `client.spaces().find((s) => s.templateSlug === slug)`
117
+ (absent on pods that do not send it: keep a fallback);
118
+ - "Ask again" = the connect form with `prompt=consent`; "Waiting for review: N" =
119
+ `client.listPendingProposals()` (needs `proposal.read` in `requests`); the
120
+ space switcher = `client.spaces()`; "Manage access in your pod" =
121
+ `connection.manageUrl`, only when present (never build a pod URL);
122
+ - Synap Cloud: only when `synap.cloudConnectAvailable`; the connect page forwards
123
+ a launch with `cloudState` to GET `paths.cloud` and never acts on it itself;
124
+ - "expired" vs "connect your pod": `synap.readLapsed()` / `RefreshOutcome.lapsed`;
125
+ - Next 16 `proxy.ts`: skip prefetches in the matcher (`missing` the
126
+ `next-router-prefetch` header); the proxy itself cannot see them;
127
+ - test with `createMockAuthServer()` (and `createMockCloud()`) from `@synap-core/sdk/testing`;
128
+ seed it from one JSON file (`parseMockPodSeed`), never fixtures in app code.
129
+
130
+ ## What a pod app can see and do
131
+
132
+ Needs a pod with the space-reach release. On an
133
+ older pod an app sees only rows whose own space is granted, so the pod-wide
134
+ records roled in S (usually people and companies) come back empty; the mock already models the rule below.
135
+
136
+ - **Spaces are the person's choice.** The consent screen lets the person pick
137
+ one or more spaces, per permission. The app gets exactly those, nothing else.
138
+ A read grant is per kind too: `entity.person.read` shows people (own and
139
+ roled pod-wide), not companies; request every kind the app lists.
140
+ - **Where an entity lives.** In a space, or pod-wide (`workspaceId` null): people
141
+ and companies are ONE identity across the pod. A **role** (facet) puts a
142
+ pod-wide entity INTO a space: a lead, contact or client in a CRM.
143
+ - **What read on space S shows.** What a member of S sees: S's own records, plus
144
+ any other record of the pod (usually people and companies) that has a live role
145
+ in S, read-only: a pod-wide one, or one of another space with a role stamped in
146
+ S. A record with no role in a granted space is never returned (a list omits it,
147
+ `getEntity` is a 404). By default an entity row carries no list of its roles: ask for the role
148
+ you want with the kind and the space (`listEntities({ workspaceId: S,
149
+ facetSlug: "lead" })`), and add `includeFacets: true` to get each row's roles
150
+ (`facetSlugs`, `facets` with their status and properties; pod with that release).
151
+ - **Related records.** A related record shows only when it has a role in a
152
+ granted space itself. An edge between two pod-wide records is not readable by
153
+ an app (`getEntityConnections` omits it); an edge to a record of the space is.
154
+ - **List them.** The people roled in S come with S's own:
155
+
156
+ ```ts
157
+ const client = auth.client(connection);
158
+ const res = await client.listEntities({ workspaceId: S, profileSlug: "person" }); // GET /entities?workspaceId=S&profileSlug=person
159
+ if (res.ok) res.data; // PodEntity[]: own rows and roled ones (pod-wide: workspaceId null)
160
+ ```
161
+
162
+ Without `workspaceId` you get every granted space plus the records
163
+ roled in any of them. List people and companies with `listEntities({
164
+ workspaceId, facetSlug })` or `profileSlug`; never pass `includePodWide: false`
165
+ for them (it can hide roled pod-wide records).
166
+ - **Writes.** Every create passes `workspaceId` (a granted space): without it a
167
+ record forks into the space or is refused. Creating an entity in S is a
168
+ proposal, and its `facets` attach when it is approved (`"proposed"` is
169
+ success; see "Writes are governed"). An app cannot add a role to an existing
170
+ entity (`POST /entities/:id/facets`) nor change a role on a pod-wide record
171
+ (`PATCH /facets/:id`): the pod refuses both with 403, writes nothing and files
172
+ no proposal. Editing the core fields of a pod-wide person is also outside an
173
+ S-only grant: the pod refuses it (403 "grant does not allow" on the connected
174
+ person's own record, 404 on a teammate's), writes nothing and files no
175
+ proposal. Do not build any of these edits.
176
+ Creating a person whose email matches a pod-wide person that has a role in S
177
+ is refused (403). If the match has no role in S, a separate person is created
178
+ in S (the pod does not merge them yet). Before creating, check
179
+ `listEntities({ workspaceId: S, profileSlug: "person" })` so the app does not
180
+ make a duplicate. (The mock does not model identity by email: it never refuses
181
+ this create.)
182
+ - **Aggregate doors** (`/knowledge/*`, `/memory/search`, `/entities/retrieve`,
183
+ i.e. search and ask) answer `GRANT_DOOR_UNSUPPORTED` to a space-scoped app.
184
+ List by kind and space instead.
185
+ - **Ask for the spaces you need, never hard-code an id.** Offer templates with
186
+ `createPodAppAuth({ templates: ["crm"] })`, then find the space the person
187
+ granted with `client.spaces().find((s) => s.templateSlug === slug)?.workspaceId`
188
+ (the kit's `podApp.templates` / `spaceFor(slug)` are this). Keep a fallback to
189
+ the first granted space: the pod may not send `templateSlug`.
190
+ - **Test it.** In the mock, a seed entity with `workspaceId: null` and
191
+ `facets: [{ facetId, slug, properties?, status?, workspaceId }]` models a role
192
+ in the space `workspaceId` (with the role's own fields, e.g. `leadStage`, in
193
+ `properties`). Seed input only: the mock answers them only when
194
+ `includeFacets` asks, as the pod. (Mock seed detail, never branch on it:
195
+ `sharedRole: true` stores the role pod-wide and grants it to `workspaceId`.)
196
+ `seedRoles: [{ profileSlug, workspaceId }]` is a deprecated shorthand (no id,
197
+ no fields; removed in 2.0): never state a role in both.
198
+
199
+ ## Sign-in errors and recovery
200
+
201
+ A refusal is typed, never a generic 403 or failure. `PodError.recovery` names the next step:
202
+
203
+ | `error.code` | When | `recovery` |
204
+ | --- | --- | --- |
205
+ | `step_up_required` | 403 at a credential door (approving a proposal that widens who or what can reach the pod) for a Synap app whose device last re-confirmed at the pod more than 15 minutes ago | `prompt_login` |
206
+ | `email_unverified` | 409 problem `CP_ACCOUNT_EMAIL_REQUIRED`: Synap Cloud has no verified email for the person | `verify_email` |
207
+ | `origin_insecure` | 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE`: Synap Cloud vouches an app origin over HTTPS only | `use_https` |
208
+
209
+ - **Step-up.** Send the person through the connect handler with `prompt=login`
210
+ (`form { pod, next, prompt: "login" }`; `next` = the page that retries). They
211
+ re-confirm AT THE POD (a Synap Cloud sign-in never counts; consent stays a
212
+ browser act), the callback redeems the code with the connection's refresh token
213
+ and stores the re-confirmed connection, then retry the refused call ONCE. The
214
+ pod's Hub REST answer carries only the message, no `reasonCode`; the SDK reads
215
+ both. Only a Synap app (`actsAs: "person"`) can hit this: the review door is
216
+ the one door of the SDK that reaches a credential check.
217
+ - **The two Control Plane problems** are JSON the Control Plane sends to the pod
218
+ and to its own API. This SDK never calls that API (Synap Cloud is browser
219
+ redirects), so they reach an app only if a door relays them; the SDK types
220
+ them if one does.
221
+ - **`synap_acts_as`** is read from every token response and never carried over:
222
+ a refresh that says `agent`, or nothing, makes an agent connection.
223
+ - **Moved pods.** A redirect is never followed. An old name that answers 308
224
+ (`you.synap.live`) is retried as `you.pod.synap.live`, and the connection stores
225
+ the issuer that pod's metadata names. A pod renamed to anything else is `pod_moved`:
226
+ connect again with its new address.
227
+ - **Dev.** `http://localhost` signs in to the SDK's mock pod, or to a local
228
+ Control Plane started with `CP_ALLOW_DEV_ORIGINS=true`; never to api.synap.live
229
+ (`origin_insecure`). If an app sends a person to the synap.live sign-in, pass
230
+ only the Control Plane's `reason`: `expired`, `signed_out` or `refused`.
231
+ - **Test it.** The mock models each refusal as an opt-in failure
232
+ (`failures: { "/users/me": "step_up_required" | "email_unverified" |
233
+ "origin_insecure" }`), a stale device (`requireStepUp: true`, `expireStepUp()`),
234
+ and `setActsAs(...)`.
235
+
236
+ ## Writes are governed — `"proposed"` is SUCCESS
237
+
238
+ A mutation resolves to `approved | proposed | denied`. **`proposed` = queued for
239
+ the pod owner's review, not an error.** Never treat it as a failure, never retry
240
+ it (duplicates), surface the review. `denied` = no permission; don't retry.
241
+
242
+ ## Install (pin the major)
243
+
244
+ ```bash
245
+ npm install @synap-core/sdk@^1.0.0
246
+ ```
247
+
248
+ Do not install unpinned `@synap-core/sdk` — old `0.x` on the registry is a
249
+ different, abandoned package. Verify with a clean-dir install:
250
+ `node -e "console.log(typeof require('@synap-core/sdk').createSynapClient)"` → `function`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # @synap-core/sdk changelog
2
2
 
3
+ ## 1.5.3 (unreleased)
4
+
5
+ Sign-in hardening after the 2026-10-23 pod and Control Plane changes. Not published.
6
+ Additive; `PodErrorCode` gains three members, so an exhaustive `switch` over it
7
+ needs a case (semver would call that a minor).
8
+
9
+ - **Typed refusals with a recovery** (`PodError.recovery`: `PodRecovery`):
10
+ `step_up_required` (a stale Synap-app device at a credential door; the Hub
11
+ REST body is `{ error: <message> }`, read by message or by `reasonCode`),
12
+ `email_unverified` (409 `CP_ACCOUNT_EMAIL_REQUIRED`), `origin_insecure`
13
+ (409 `FEDERATED_APPLICATION_ORIGIN_INSECURE`). They were `forbidden` / `conflict`.
14
+ - **Step-up**: the connect handler takes `prompt=login`; the callback redeems the
15
+ code with the connection's refresh token (`synap_device_refresh_token`), as the
16
+ pod requires of a Synap app's re-confirmation (without it the pod answers
17
+ `invalid_grant`). `startConnect({ prompt: "login" })`; `handleCallback({ current })`.
18
+ It is asked of the pod only, never through Synap Cloud.
19
+ - **Behaviour change**: `prompt=login` on the connect form used to be ignored
20
+ (a live connection went straight to `next`); it now starts a step-up.
21
+ - Mock: `failures` modes `step_up_required`, `email_unverified`, `origin_insecure`;
22
+ `requireStepUp` / `expireStepUp()` (a stale device refused at approve, unlocked by
23
+ a step-up code); `setActsAs()`; a code from `prompt=login` needs the device's
24
+ refresh token and one without it must not carry one, as the pod.
25
+ - Docs: AGENTS.md, "Sign-in errors and recovery" (dev origins, `reason`, moved pods).
26
+ - Checked, no change needed: `synap_acts_as` is read from every response and never
27
+ carried over (now tested end to end); the SDK verifies no Control Plane token (no
28
+ JWKS code to add); it keeps no copy of the Synap app ids (its review-verb check is a
29
+ looser `*.synap.live` pre-check; the pod decides).
30
+
31
+ ## 1.5.2 (unreleased)
32
+
33
+ Mock pod and docs only; no runtime change. Not published.
34
+
35
+ - **AGENTS.md now ships in the package** (`files`), so `node_modules/@synap-core/sdk/AGENTS.md`
36
+ exists; 1.5.1 left it out. A test (`npm pack --dry-run`) keeps AGENTS.md,
37
+ README.md and CHANGELOG.md in the tarball. The README links the file by path.
38
+
39
+ Mock behaviour changes (as the pod; they can move a consumer's tests):
40
+
41
+ - **`GET /entities` and `GET /entities/:id` narrow by kind, as the pod's
42
+ kindClause**: `entity.<kind>.read` reads that kind only (rows of the space and
43
+ roled pod-wide rows alike); `entity.read` reads every kind. 1.5.1 treated any
44
+ `entity.<kind>.read` as every kind, so a page could list people on the mock
45
+ that an `entity.post.read`-only app never sees on the pod. A mock test that
46
+ seeds people or companies must request and be granted their kind.
47
+ - **`POST /entities/:id/facets` answers 403** (an app cannot add a role to an
48
+ existing entity), and **`PATCH /facets/:id` of a pod-wide record's role
49
+ answers 403**: nothing written, no proposal. Both used to be proposed.
50
+ - **`GET /entities/:id/connections` omits an edge between two pod-wide
51
+ records** (a pod-wide edge is owner-only; not readable by an app granted S).
52
+ - **`PATCH /entities/:id` of a pod-wide row, as the pod**: 403 "grant does not
53
+ allow" when the row's `userId` is the token's user, 404 (empty body) when it is
54
+ another user's (a teammate's record, outside the write floor). A seed row
55
+ with no `userId` keeps 403. Neither writes nor files a proposal. AGENTS.md,
56
+ "What a pod app can see and do", states it.
57
+ - Docs: that section now also says every create passes `workspaceId`, creating
58
+ in S files a proposal whose `facets` attach on approval, and a related record
59
+ shows only when it has a role in a granted space itself.
60
+
3
61
  ## 1.5.1
4
62
 
5
63
  What a pod app sees of a person's pod, and the roles (Kind + Facets) on it: the
@@ -14,8 +72,7 @@ the mock behaviours under "Behaviour changes (mock)".
14
72
  a role, and each row's roles on it (`PodEntity.facetSlugs`, `PodEntity.facets:
15
73
  PodEntityFacet[]` = `{ facetId, slug, properties, status }`, present only on a
16
74
  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
75
+ whose `GET /entities` supports `includeFacets`; on an older pod it is ignored
19
76
  and the rows come back without roles.
20
77
  - **Mock mode may ask for the review verbs**: `buildClientMetadata` (and so
21
78
  `createPodAppAuth`) accepts `proposal.approve` / `proposal.reject` on an http
@@ -31,7 +88,7 @@ the mock behaviours under "Behaviour changes (mock)".
31
88
  entities (`workspaceId` null) with a role in a granted space. A pod-wide
32
89
  entity with no role in a granted space, and any row of an ungranted space, is
33
90
  never answered (a miss is the pod's empty 404). Reads need an
34
- `entity.*.read` grant in the token's scope.
91
+ `entity.<kind>.read` (or `entity.read`) grant in the token's scope.
35
92
  - **Seeded roles, one field: `MockSeedEntity.facets`** (`MockSeedFacet`:
36
93
  `facetId`, `slug`, `properties?`, `status?`, and seed-only `workspaceId`: the
37
94
  space whose members see the role, stamped there or a pod-wide facet of a role
@@ -45,19 +102,20 @@ the mock behaviours under "Behaviour changes (mock)".
45
102
  stored pod-wide and granted to `workspaceId` (default: stamped in it). It
46
103
  differs from a stamped role in `includePodWide=false` on a space lens, and in
47
104
  `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.
105
+ - **`PATCH /entities/:id` of a pod-wide row** is refused under a space grant
106
+ (the mock answers 403; 1.5.2 pins 403 vs 404): nothing written, no proposal.
50
107
  - **A row of another space T with a role stamped in S** is visible to an app
51
108
  granted S (list and by id), as to a member of S; a shared role does not
52
109
  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.
110
+ - **`GET /entities`** as the pod (`rest/entities.ts`):
111
+ - `includePodWide=false` on a workspace lens drops only the pod-wide rows
112
+ reached through a SHARED role (the pod keeps those with a role stamped in
113
+ the space);
114
+ - `facetSlug` filters by a role the read sees: what a member of a granted
115
+ space sees (a workspace lens, that space's roles only; never a role of a
116
+ space outside the grant);
117
+ - a `profileSlug` naming a role matches its wearers too;
118
+ - `includeFacets=true` adds `facetSlugs` + `facets` to the rows wearing one.
61
119
  - **`GET /entities/:id/facets`**: `{ facets: [{ facet, profile, effectiveProperties }] }`
62
120
  (`getEffectiveFacets` under the token's floor; the mock knows a role by its
63
121
  slug only, so `profile` is `{ id: "profile_<slug>", slug, profileKind: "role" }`
@@ -69,18 +127,26 @@ the mock behaviours under "Behaviour changes (mock)".
69
127
  answers `[]` where the pod answers 404; `facetProfileId` is not read), and it
70
128
  serves neither `q` (the pod refuses it to a space-scoped key, and answers 400
71
129
  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).
130
+ - Docs: AGENTS.md, "What a pod app can see and do". Needs a pod with the
131
+ space-reach release (an older pod returns no pod-wide rows to an app).
74
132
 
75
133
  ### Behaviour changes (mock)
76
134
 
135
+ These move test results for an existing 1.5.0 consumer; check your mock tests
136
+ after upgrading.
137
+
77
138
  - 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.
139
+ token was not granted.
140
+ - A pod-wide seed row (`workspaceId: null`) with no role in a granted space is no
141
+ longer listed, and `GET /entities/:id` answers the empty 404 (a 1.5.0 seed that
142
+ expected such rows must give them a role: `facets`).
143
+ - A mock token with no `entity.<kind>.read` / `entity.read` grant reads no entity.
80
144
  - `includePodWide=false` on a space lens keeps the pod-wide rows with a role
81
145
  stamped in the space (it dropped every pod-wide row).
82
146
  - `GET /entities` and `GET /entities/:id` no longer echo a seed row's
83
147
  `facets` / `facetSlugs` (the pod's `entityToWire` never sends them).
148
+ - `PATCH /entities/:id` of a `workspaceId: null` row answers 403 and writes
149
+ nothing (it was applied or proposed).
84
150
 
85
151
  ## 1.5.0
86
152
 
package/README.md CHANGED
@@ -122,7 +122,7 @@ SDK refuses out-of-bounds `offset`/`limit`/`sort` locally, without a request),
122
122
  pass `ifNoneMatch` from a browser: the doors allow no cross-origin request header
123
123
  but `Content-Type`, so the preflight fails (browsers revalidate on their own).
124
124
 
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).
125
+ What a pod app can see and do (spaces, roled records, writes): read the section "What a pod app can see and do" of `AGENTS.md`, shipped in this package (`node_modules/@synap-core/sdk/AGENTS.md`).
126
126
 
127
127
  ## Pod-app sign-in (server-only): `@synap-core/sdk/connect` (1.3.0, 1.4.0, 1.5.0)
128
128
 
@@ -178,7 +178,7 @@ export const config = {
178
178
  const connection = await synap.readConnection((await headers()).get("cookie"));
179
179
  if (!connection) redirect("/connect");
180
180
  const pod = synap.client(connection);
181
- const res = await pod.post("/entities", { profileSlug: "post", title: "Hello" });
181
+ const res = await pod.post("/entities", { profileSlug: "post", workspaceId: pod.spaces()[0].workspaceId, title: "Hello" }); // always a granted space
182
182
  if (res.ok && res.status === "proposed") showPending(res.reviewUrl); // success: queued for review, never retry
183
183
  if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupported | rate_limited (retryAfterSec) | pod_unreachable | pod_moved | …
184
184
  ```
@@ -396,7 +396,7 @@ const pod = createMockAuthServer({ seed, actsAs: "person" });
396
396
  1.5.1: an entity's roles ride on its seed row as `facets`, the shape
397
397
  `includeFacets=true` answers, plus the space whose members see it
398
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
399
+ (`AGENTS.md`, "What a pod app can see and do"). `GET /entities` then filters by
400
400
  `facetSlug`, drops a lens's pod-wide rows on `includePodWide: false`, and puts
401
401
  `facetSlugs` + `facets` on a row only when asked; `GET /entities/:id/facets` is
402
402
  served:
package/dist/connect.cjs CHANGED
@@ -682,14 +682,32 @@ function renewalError(err) {
682
682
  return { code: "server", status: null, detail };
683
683
  }
684
684
  }
685
+ var STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
686
+ var CP_PROBLEMS = {
687
+ CP_ACCOUNT_EMAIL_REQUIRED: { code: "email_unverified", recovery: "verify_email" },
688
+ FEDERATED_APPLICATION_ORIGIN_INSECURE: { code: "origin_insecure", recovery: "use_https" }
689
+ };
690
+ function isStepUp(obj) {
691
+ const data = (v) => v && typeof v === "object" ? v : {};
692
+ const nested = data(data(obj.error).data).reasonCode ?? data(obj.data).reasonCode ?? obj.reasonCode;
693
+ return nested === "STEP_UP_REQUIRED" || obj.error === STEP_UP_MESSAGE || obj.message === STEP_UP_MESSAGE;
694
+ }
695
+ function classifyRefusal(status, body) {
696
+ const obj = body && typeof body === "object" ? body : {};
697
+ if (status === 403) {
698
+ if (isStepUp(obj)) return { code: "step_up_required", recovery: "prompt_login" };
699
+ return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden" };
700
+ }
701
+ const problem = typeof obj.code === "string" ? CP_PROBLEMS[obj.code] : void 0;
702
+ return problem ?? { code: "conflict" };
703
+ }
685
704
  function errorFor(status, body, retryAfter, now) {
686
705
  const obj = body && typeof body === "object" ? body : {};
687
706
  const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : void 0;
688
707
  const base = { status, ...detail !== void 0 ? { detail } : {} };
689
708
  if (status === 401) return { code: "unauthorized", ...base };
690
- if (status === 403) return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden", ...base };
709
+ if (status === 403 || status === 409) return { ...classifyRefusal(status, body), ...base };
691
710
  if (status === 404) return { code: "not_found", ...base };
692
- if (status === 409) return { code: "conflict", ...base };
693
711
  if (status === 400 || status === 422) return { code: "invalid", ...base };
694
712
  if (status === 429) {
695
713
  const retryAfterSec = retryAfterSeconds(retryAfter, now);
@@ -1025,7 +1043,7 @@ async function startConnect(opts) {
1025
1043
  url.searchParams.set("code_challenge_method", "S256");
1026
1044
  if (resource) url.searchParams.set("resource", resource);
1027
1045
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
1028
- if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
1046
+ if (opts.prompt === "consent" || opts.prompt === "login") url.searchParams.set("prompt", opts.prompt);
1029
1047
  const tx = {
1030
1048
  v: 1,
1031
1049
  issuer: issuer.issuer,
@@ -1037,6 +1055,7 @@ async function startConnect(opts) {
1037
1055
  verifier,
1038
1056
  resource,
1039
1057
  next: opts.next ?? null,
1058
+ ...opts.prompt === "login" ? { stepUp: true } : {},
1040
1059
  createdAt: now
1041
1060
  };
1042
1061
  const expiresAt = now + TRANSACTION_TTL_MS;
@@ -1094,13 +1113,16 @@ async function handleCallback(opts) {
1094
1113
  code_verifier: tx.verifier
1095
1114
  });
1096
1115
  if (tx.resource) body.set("resource", tx.resource);
1116
+ const device = tx.stepUp && opts.current && opts.current.actsAs === "person" && opts.current.podUrl === tx.issuer && opts.current.clientId === tx.clientId ? opts.current : null;
1117
+ if (device?.refreshToken) body.set("synap_device_refresh_token", device.refreshToken);
1097
1118
  const json = await tokenRequest(tx.tokenEndpoint, body, "token", opts);
1098
1119
  const connection = parseTokenResponse(json, {
1099
1120
  podUrl: tx.issuer,
1100
1121
  clientId: tx.clientId,
1101
1122
  tokenEndpoint: tx.tokenEndpoint,
1102
1123
  revocationEndpoint: tx.revocationEndpoint,
1103
- now: (opts.now ?? Date.now)()
1124
+ now: (opts.now ?? Date.now)(),
1125
+ ...device ? { previous: device } : {}
1104
1126
  });
1105
1127
  return { connection, next: tx.next };
1106
1128
  }
@@ -1606,7 +1628,8 @@ function createPodAppAuth(config) {
1606
1628
  const pod = formField(form, "pod")?.slice(0, 512) ?? "";
1607
1629
  const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
1608
1630
  const space = formField(form, "space");
1609
- const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
1631
+ const asked = formField(form, "prompt");
1632
+ const prompt = asked === "consent" || asked === "login" ? asked : null;
1610
1633
  try {
1611
1634
  if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
1612
1635
  return await toPod({ pod, next, space, prompt });
@@ -1623,6 +1646,7 @@ function createPodAppAuth(config) {
1623
1646
  ...flow,
1624
1647
  callbackUrl: request.url,
1625
1648
  transaction,
1649
+ current: await store.read(request).catch(() => null),
1626
1650
  clientId,
1627
1651
  redirectUri,
1628
1652
  secret: config.secret