@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 +250 -0
- package/CHANGELOG.md +148 -0
- package/README.md +17 -1
- package/dist/connect.cjs +38 -7
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +55 -3
- package/dist/connect.d.ts +55 -3
- package/dist/connect.js +38 -7
- package/dist/connect.js.map +1 -1
- package/dist/testing.cjs +188 -16
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +69 -2
- package/dist/testing.d.ts +69 -2
- package/dist/testing.js +188 -16
- package/dist/testing.js.map +1 -1
- package/package.json +10 -9
- package/src/connect/auth.ts +29 -6
- package/src/connect/client.ts +58 -2
- package/src/connect/flow.ts +23 -2
- package/src/connect/hub-types.ts +17 -0
- package/src/connect/index.ts +2 -0
- package/src/testing/auth-server.ts +230 -18
- package/src/testing/roles.ts +27 -0
- package/src/testing/seed.ts +78 -2
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
|
|
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",
|
|
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
|
|
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
|