@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 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
  }