@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 +250 -0
- package/CHANGELOG.md +83 -17
- package/README.md +3 -3
- package/dist/connect.cjs +29 -5
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +39 -3
- package/dist/connect.d.ts +39 -3
- package/dist/connect.js +29 -5
- package/dist/connect.js.map +1 -1
- package/dist/testing.cjs +68 -9
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +19 -1
- package/dist/testing.d.ts +19 -1
- package/dist/testing.js +68 -9
- package/dist/testing.js.map +1 -1
- package/package.json +10 -9
- package/src/connect/auth.ts +14 -4
- package/src/connect/client.ts +55 -2
- package/src/connect/flow.ts +23 -2
- package/src/connect/index.ts +1 -0
- package/src/testing/auth-server.ts +118 -14
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
|
-
|
|
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
|
|
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
|
|
49
|
-
the
|
|
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`):
|
|
54
|
-
on a workspace lens drops only the pod-wide rows
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
73
|
-
|
|
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
|
|
79
|
-
|
|
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,
|
|
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
|
|
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
|
|
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",
|
|
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
|
|
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
|