@synap-core/sdk 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +16 -0
- package/dist/connect.cjs +9 -2
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +17 -1
- package/dist/connect.d.ts +17 -1
- package/dist/connect.js +9 -2
- package/dist/connect.js.map +1 -1
- package/dist/testing.cjs +124 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +50 -1
- package/dist/testing.d.ts +50 -1
- package/dist/testing.js +124 -11
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
- package/src/connect/auth.ts +15 -2
- package/src/connect/client.ts +3 -0
- package/src/connect/hub-types.ts +17 -0
- package/src/connect/index.ts +1 -0
- package/src/testing/auth-server.ts +120 -12
- package/src/testing/roles.ts +27 -0
- package/src/testing/seed.ts +78 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# @synap-core/sdk changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.1
|
|
4
|
+
|
|
5
|
+
What a pod app sees of a person's pod, and the roles (Kind + Facets) on it: the
|
|
6
|
+
mock pod models the reach rule and answers roles as the pod does, so an app
|
|
7
|
+
that lists by role (a CRM's leads) shows its pages on mock data; the typed
|
|
8
|
+
client asks for roles; mock mode may ask for the review verbs. Additive, except
|
|
9
|
+
the mock behaviours under "Behaviour changes (mock)".
|
|
10
|
+
|
|
11
|
+
### `@synap-core/sdk/connect`
|
|
12
|
+
|
|
13
|
+
- **`listEntities({ facetSlug?, includeFacets? })`**: only the entities wearing
|
|
14
|
+
a role, and each row's roles on it (`PodEntity.facetSlugs`, `PodEntity.facets:
|
|
15
|
+
PodEntityFacet[]` = `{ facetId, slug, properties, status }`, present only on a
|
|
16
|
+
row that wears one). `includeFacets` is sent only when true. It needs a pod
|
|
17
|
+
with the backend release that adds it to `GET /entities` (synap-backend
|
|
18
|
+
`feat/hub-entities-include-facets`); until that pod runs, the pod ignores it
|
|
19
|
+
and the rows come back without roles.
|
|
20
|
+
- **Mock mode may ask for the review verbs**: `buildClientMetadata` (and so
|
|
21
|
+
`createPodAppAuth`) accepts `proposal.approve` / `proposal.reject` on an http
|
|
22
|
+
loopback `appUrl` (`localhost`, `127.0.0.1`, `[::1]`), so an app that requests
|
|
23
|
+
them starts mock mode, smoke and shots. A real pod refuses an http client_id
|
|
24
|
+
outright (synap-backend `client-id-url.ts`); every other origin is refused
|
|
25
|
+
as in 1.5.0.
|
|
26
|
+
|
|
27
|
+
### `@synap-core/sdk/testing`
|
|
28
|
+
|
|
29
|
+
- **The mock models the pod's reach rule**: `GET /entities`, `GET /entities/:id`
|
|
30
|
+
and connection neighbours answer rows of a granted space plus pod-wide
|
|
31
|
+
entities (`workspaceId` null) with a role in a granted space. A pod-wide
|
|
32
|
+
entity with no role in a granted space, and any row of an ungranted space, is
|
|
33
|
+
never answered (a miss is the pod's empty 404). Reads need an
|
|
34
|
+
`entity.*.read` grant in the token's scope.
|
|
35
|
+
- **Seeded roles, one field: `MockSeedEntity.facets`** (`MockSeedFacet`:
|
|
36
|
+
`facetId`, `slug`, `properties?`, `status?`, and seed-only `workspaceId`: the
|
|
37
|
+
space whose members see the role, stamped there or a pod-wide facet of a role
|
|
38
|
+
granted to it). Seed input only: no answer carries them unless `includeFacets`
|
|
39
|
+
asks. Validated by `parseMockPodSeed`.
|
|
40
|
+
- **`seedRoles`** (`[{ profileSlug, workspaceId }]`), **deprecated: use
|
|
41
|
+
`facets`; removed in 2.0.** A shorthand desugared into `facets` when the seed
|
|
42
|
+
is read (a generated `facetId`, no overlay); a role stated in both on one
|
|
43
|
+
entity is a parse error naming the entity.
|
|
44
|
+
- **`facets[].sharedRole`** (seed only, never served, validated): the role is
|
|
45
|
+
stored pod-wide and granted to `workspaceId` (default: stamped in it). It
|
|
46
|
+
differs from a stamped role in `includePodWide=false` on a space lens, and in
|
|
47
|
+
`GET /entities/:id/facets` (its facet answers `workspaceId` null).
|
|
48
|
+
- **`PATCH /entities/:id` of a pod-wide row** is refused (the mock answers 403;
|
|
49
|
+
the pod's 403 or 404 is not yet pinned): nothing written, no proposal.
|
|
50
|
+
- **A row of another space T with a role stamped in S** is visible to an app
|
|
51
|
+
granted S (list and by id), as to a member of S; a shared role does not
|
|
52
|
+
reach a row of another space.
|
|
53
|
+
- **`GET /entities`** as the pod (`rest/entities.ts`): `includePodWide=false`
|
|
54
|
+
on a workspace lens drops only the pod-wide rows reached through a SHARED role
|
|
55
|
+
(the pod keeps those with a role stamped in the space); `facetSlug` filters by a role
|
|
56
|
+
the read sees: what a member of a granted space sees (a workspace lens, that
|
|
57
|
+
space's roles only; never a role of a space outside the grant); a
|
|
58
|
+
`profileSlug` naming a role
|
|
59
|
+
matches its wearers too; `includeFacets=true` adds `facetSlugs` + `facets` to
|
|
60
|
+
the rows wearing one.
|
|
61
|
+
- **`GET /entities/:id/facets`**: `{ facets: [{ facet, profile, effectiveProperties }] }`
|
|
62
|
+
(`getEffectiveFacets` under the token's floor; the mock knows a role by its
|
|
63
|
+
slug only, so `profile` is `{ id: "profile_<slug>", slug, profileKind: "role" }`
|
|
64
|
+
and `effectiveProperties` is empty). A miss, or an entity out of reach, is the
|
|
65
|
+
pod's empty 404.
|
|
66
|
+
- **`GET /entities/:id/connections`**: the far end's `facetSlugs` come from its
|
|
67
|
+
seeded roles, under the read's `workspaceId` lens.
|
|
68
|
+
- Not mirrored: the mock knows no profile catalog (an undeclared `facetSlug`
|
|
69
|
+
answers `[]` where the pod answers 404; `facetProfileId` is not read), and it
|
|
70
|
+
serves neither `q` (the pod refuses it to a space-scoped key, and answers 400
|
|
71
|
+
to `q` with `includeFacets`) nor `scope=all`.
|
|
72
|
+
- Docs: AGENTS.md, "What a pod app can see and do". Needs a pod with the reach
|
|
73
|
+
rule (rolling out from 2026-10-23; an older pod returns no pod-wide rows to an app).
|
|
74
|
+
|
|
75
|
+
### Behaviour changes (mock)
|
|
76
|
+
|
|
77
|
+
- An entity list without `workspaceId` no longer returns rows of spaces the
|
|
78
|
+
token was not granted; a pod-wide row is listed only when it has a role in a
|
|
79
|
+
granted space.
|
|
80
|
+
- `includePodWide=false` on a space lens keeps the pod-wide rows with a role
|
|
81
|
+
stamped in the space (it dropped every pod-wide row).
|
|
82
|
+
- `GET /entities` and `GET /entities/:id` no longer echo a seed row's
|
|
83
|
+
`facets` / `facetSlugs` (the pod's `entityToWire` never sends them).
|
|
84
|
+
|
|
3
85
|
## 1.5.0
|
|
4
86
|
|
|
5
87
|
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, pod-wide people and companies, roles): see [AGENTS.md, "What a pod app can see and do"](./AGENTS.md#what-a-pod-app-can-see-and-do).
|
|
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
|
|
@@ -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
|
@@ -860,7 +860,10 @@ function createPodClient(connection, opts = {}) {
|
|
|
860
860
|
limit: clamp(opts2.limit, 1, 1e3),
|
|
861
861
|
offset: clamp(opts2.offset, 0, Number.MAX_SAFE_INTEGER),
|
|
862
862
|
sort: opts2.sort,
|
|
863
|
-
includePodWide: opts2.includePodWide
|
|
863
|
+
includePodWide: opts2.includePodWide,
|
|
864
|
+
facetSlug: opts2.facetSlug,
|
|
865
|
+
// Sent only when asked: the default request stays the 1.5.0 one.
|
|
866
|
+
includeFacets: opts2.includeFacets ? true : void 0
|
|
864
867
|
}, (data) => Array.isArray(data) ? data.filter(hasId) : null);
|
|
865
868
|
}
|
|
866
869
|
async function getEntity(id) {
|
|
@@ -1424,6 +1427,10 @@ var PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
|
1424
1427
|
function isSynapAppOrigin(origin) {
|
|
1425
1428
|
return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
|
|
1426
1429
|
}
|
|
1430
|
+
function isMockModeOrigin(origin) {
|
|
1431
|
+
const url = new URL(origin);
|
|
1432
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
1433
|
+
}
|
|
1427
1434
|
function assertAppPermission(permission, origin) {
|
|
1428
1435
|
const segs = permission.split(".");
|
|
1429
1436
|
const fail = (why) => {
|
|
@@ -1433,7 +1440,7 @@ function assertAppPermission(permission, origin) {
|
|
|
1433
1440
|
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
1434
1441
|
}
|
|
1435
1442
|
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;
|
|
1443
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && (isSynapAppOrigin(origin) || isMockModeOrigin(origin))) return;
|
|
1437
1444
|
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
1438
1445
|
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
1439
1446
|
}
|