@synap-core/sdk 1.5.0 → 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,153 @@
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
+
61
+ ## 1.5.1
62
+
63
+ What a pod app sees of a person's pod, and the roles (Kind + Facets) on it: the
64
+ mock pod models the reach rule and answers roles as the pod does, so an app
65
+ that lists by role (a CRM's leads) shows its pages on mock data; the typed
66
+ client asks for roles; mock mode may ask for the review verbs. Additive, except
67
+ the mock behaviours under "Behaviour changes (mock)".
68
+
69
+ ### `@synap-core/sdk/connect`
70
+
71
+ - **`listEntities({ facetSlug?, includeFacets? })`**: only the entities wearing
72
+ a role, and each row's roles on it (`PodEntity.facetSlugs`, `PodEntity.facets:
73
+ PodEntityFacet[]` = `{ facetId, slug, properties, status }`, present only on a
74
+ row that wears one). `includeFacets` is sent only when true. It needs a pod
75
+ whose `GET /entities` supports `includeFacets`; on an older pod it is ignored
76
+ and the rows come back without roles.
77
+ - **Mock mode may ask for the review verbs**: `buildClientMetadata` (and so
78
+ `createPodAppAuth`) accepts `proposal.approve` / `proposal.reject` on an http
79
+ loopback `appUrl` (`localhost`, `127.0.0.1`, `[::1]`), so an app that requests
80
+ them starts mock mode, smoke and shots. A real pod refuses an http client_id
81
+ outright (synap-backend `client-id-url.ts`); every other origin is refused
82
+ as in 1.5.0.
83
+
84
+ ### `@synap-core/sdk/testing`
85
+
86
+ - **The mock models the pod's reach rule**: `GET /entities`, `GET /entities/:id`
87
+ and connection neighbours answer rows of a granted space plus pod-wide
88
+ entities (`workspaceId` null) with a role in a granted space. A pod-wide
89
+ entity with no role in a granted space, and any row of an ungranted space, is
90
+ never answered (a miss is the pod's empty 404). Reads need an
91
+ `entity.<kind>.read` (or `entity.read`) grant in the token's scope.
92
+ - **Seeded roles, one field: `MockSeedEntity.facets`** (`MockSeedFacet`:
93
+ `facetId`, `slug`, `properties?`, `status?`, and seed-only `workspaceId`: the
94
+ space whose members see the role, stamped there or a pod-wide facet of a role
95
+ granted to it). Seed input only: no answer carries them unless `includeFacets`
96
+ asks. Validated by `parseMockPodSeed`.
97
+ - **`seedRoles`** (`[{ profileSlug, workspaceId }]`), **deprecated: use
98
+ `facets`; removed in 2.0.** A shorthand desugared into `facets` when the seed
99
+ is read (a generated `facetId`, no overlay); a role stated in both on one
100
+ entity is a parse error naming the entity.
101
+ - **`facets[].sharedRole`** (seed only, never served, validated): the role is
102
+ stored pod-wide and granted to `workspaceId` (default: stamped in it). It
103
+ differs from a stamped role in `includePodWide=false` on a space lens, and in
104
+ `GET /entities/:id/facets` (its facet answers `workspaceId` null).
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.
107
+ - **A row of another space T with a role stamped in S** is visible to an app
108
+ granted S (list and by id), as to a member of S; a shared role does not
109
+ reach a row of another space.
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.
119
+ - **`GET /entities/:id/facets`**: `{ facets: [{ facet, profile, effectiveProperties }] }`
120
+ (`getEffectiveFacets` under the token's floor; the mock knows a role by its
121
+ slug only, so `profile` is `{ id: "profile_<slug>", slug, profileKind: "role" }`
122
+ and `effectiveProperties` is empty). A miss, or an entity out of reach, is the
123
+ pod's empty 404.
124
+ - **`GET /entities/:id/connections`**: the far end's `facetSlugs` come from its
125
+ seeded roles, under the read's `workspaceId` lens.
126
+ - Not mirrored: the mock knows no profile catalog (an undeclared `facetSlug`
127
+ answers `[]` where the pod answers 404; `facetProfileId` is not read), and it
128
+ serves neither `q` (the pod refuses it to a space-scoped key, and answers 400
129
+ to `q` with `includeFacets`) nor `scope=all`.
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).
132
+
133
+ ### Behaviour changes (mock)
134
+
135
+ These move test results for an existing 1.5.0 consumer; check your mock tests
136
+ after upgrading.
137
+
138
+ - An entity list without `workspaceId` no longer returns rows of spaces the
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.
144
+ - `includePodWide=false` on a space lens keeps the pod-wide rows with a role
145
+ stamped in the space (it dropped every pod-wide row).
146
+ - `GET /entities` and `GET /entities/:id` no longer echo a seed row's
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).
150
+
3
151
  ## 1.5.0
4
152
 
5
153
  For Content Studio, the first pod app on `@synap-core/sdk/connect`, plus a
package/README.md CHANGED
@@ -122,6 +122,8 @@ 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, 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
+
125
127
  ## Pod-app sign-in (server-only): `@synap-core/sdk/connect` (1.3.0, 1.4.0, 1.5.0)
126
128
 
127
129
  An app that reads and writes a person's OWN pod connects as an OAuth 2.1 public
@@ -176,7 +178,7 @@ export const config = {
176
178
  const connection = await synap.readConnection((await headers()).get("cookie"));
177
179
  if (!connection) redirect("/connect");
178
180
  const pod = synap.client(connection);
179
- 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
180
182
  if (res.ok && res.status === "proposed") showPending(res.reviewUrl); // success: queued for review, never retry
181
183
  if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupported | rate_limited (retryAfterSec) | pod_unreachable | pod_moved | …
182
184
  ```
@@ -290,6 +292,7 @@ const pod = synap.client(connection);
290
292
  // Typed reads: PodResult<T>, the same codes as get/post (expired | unauthorized | forbidden |
291
293
  // door_unsupported | not_found | invalid | rate_limited | pod_unreachable | server | …)
292
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
293
296
  await pod.getEntity(id); // PodEntity, not_found on a miss
294
297
  await pod.getEntityConnections(id); // { connections, counts }
295
298
  await pod.getDocument(documentId); // { content, revision, sections, … }
@@ -390,6 +393,19 @@ const seed = parseMockPodSeed(readFileSync("mock/pod-seed.json", "utf8")); // {
390
393
  const pod = createMockAuthServer({ seed, actsAs: "person" });
391
394
  ```
392
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
+
393
409
 
394
410
  ```typescript
395
411
  const pod = createMockAuthServer();
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);
@@ -860,7 +878,10 @@ function createPodClient(connection, opts = {}) {
860
878
  limit: clamp(opts2.limit, 1, 1e3),
861
879
  offset: clamp(opts2.offset, 0, Number.MAX_SAFE_INTEGER),
862
880
  sort: opts2.sort,
863
- includePodWide: opts2.includePodWide
881
+ includePodWide: opts2.includePodWide,
882
+ facetSlug: opts2.facetSlug,
883
+ // Sent only when asked: the default request stays the 1.5.0 one.
884
+ includeFacets: opts2.includeFacets ? true : void 0
864
885
  }, (data) => Array.isArray(data) ? data.filter(hasId) : null);
865
886
  }
866
887
  async function getEntity(id) {
@@ -1022,7 +1043,7 @@ async function startConnect(opts) {
1022
1043
  url.searchParams.set("code_challenge_method", "S256");
1023
1044
  if (resource) url.searchParams.set("resource", resource);
1024
1045
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
1025
- if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
1046
+ if (opts.prompt === "consent" || opts.prompt === "login") url.searchParams.set("prompt", opts.prompt);
1026
1047
  const tx = {
1027
1048
  v: 1,
1028
1049
  issuer: issuer.issuer,
@@ -1034,6 +1055,7 @@ async function startConnect(opts) {
1034
1055
  verifier,
1035
1056
  resource,
1036
1057
  next: opts.next ?? null,
1058
+ ...opts.prompt === "login" ? { stepUp: true } : {},
1037
1059
  createdAt: now
1038
1060
  };
1039
1061
  const expiresAt = now + TRANSACTION_TTL_MS;
@@ -1091,13 +1113,16 @@ async function handleCallback(opts) {
1091
1113
  code_verifier: tx.verifier
1092
1114
  });
1093
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);
1094
1118
  const json = await tokenRequest(tx.tokenEndpoint, body, "token", opts);
1095
1119
  const connection = parseTokenResponse(json, {
1096
1120
  podUrl: tx.issuer,
1097
1121
  clientId: tx.clientId,
1098
1122
  tokenEndpoint: tx.tokenEndpoint,
1099
1123
  revocationEndpoint: tx.revocationEndpoint,
1100
- now: (opts.now ?? Date.now)()
1124
+ now: (opts.now ?? Date.now)(),
1125
+ ...device ? { previous: device } : {}
1101
1126
  });
1102
1127
  return { connection, next: tx.next };
1103
1128
  }
@@ -1424,6 +1449,10 @@ var PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
1424
1449
  function isSynapAppOrigin(origin) {
1425
1450
  return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
1426
1451
  }
1452
+ function isMockModeOrigin(origin) {
1453
+ const url = new URL(origin);
1454
+ return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
1455
+ }
1427
1456
  function assertAppPermission(permission, origin) {
1428
1457
  const segs = permission.split(".");
1429
1458
  const fail = (why) => {
@@ -1433,7 +1462,7 @@ function assertAppPermission(permission, origin) {
1433
1462
  fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
1434
1463
  }
1435
1464
  if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
1436
- if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && isSynapAppOrigin(origin)) return;
1465
+ if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && (isSynapAppOrigin(origin) || isMockModeOrigin(origin))) return;
1437
1466
  if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
1438
1467
  fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
1439
1468
  }
@@ -1599,7 +1628,8 @@ function createPodAppAuth(config) {
1599
1628
  const pod = formField(form, "pod")?.slice(0, 512) ?? "";
1600
1629
  const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
1601
1630
  const space = formField(form, "space");
1602
- const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
1631
+ const asked = formField(form, "prompt");
1632
+ const prompt = asked === "consent" || asked === "login" ? asked : null;
1603
1633
  try {
1604
1634
  if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
1605
1635
  return await toPod({ pod, next, space, prompt });
@@ -1616,6 +1646,7 @@ function createPodAppAuth(config) {
1616
1646
  ...flow,
1617
1647
  callbackUrl: request.url,
1618
1648
  transaction,
1649
+ current: await store.read(request).catch(() => null),
1619
1650
  clientId,
1620
1651
  redirectUri,
1621
1652
  secret: config.secret