@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.
@@ -38,9 +38,22 @@ export interface PodEntity {
38
38
  /** ISO 8601. */
39
39
  createdAt?: string;
40
40
  updatedAt?: string;
41
+ /** The roles it wears, with `includeFacets`; absent when it wears none. */
42
+ facetSlugs?: string[];
43
+ /** Each role's overlay, with `includeFacets`; absent when it wears none. */
44
+ facets?: PodEntityFacet[];
41
45
  [key: string]: unknown;
42
46
  }
43
47
 
48
+ /** One role an entity wears (`FacetRowAnnotation`). */
49
+ export interface PodEntityFacet {
50
+ facetId: string;
51
+ /** The role, e.g. `lead`. */
52
+ slug: string;
53
+ properties: Record<string, unknown>;
54
+ status: string | null;
55
+ }
56
+
44
57
  export interface ListEntitiesOptions {
45
58
  /** One granted space. Omitted: every space the connection can read. */
46
59
  workspaceId?: string;
@@ -53,6 +66,10 @@ export interface ListEntitiesOptions {
53
66
  sort?: string;
54
67
  /** With `workspaceId`: also the person's pod-wide rows (the pod's default: true). */
55
68
  includePodWide?: boolean;
69
+ /** Only the entities wearing this role, e.g. `lead`. */
70
+ facetSlug?: string;
71
+ /** Put `facetSlugs` and `facets` on each row that wears a role. */
72
+ includeFacets?: boolean;
56
73
  }
57
74
 
58
75
  export type PodConnectionSource = "graph" | "property" | "thread" | "context_channel" | "focus_session";
@@ -48,6 +48,7 @@ export type {
48
48
  PodEntity,
49
49
  PodEntityConnection,
50
50
  PodEntityConnections,
51
+ PodEntityFacet,
51
52
  PodFileUrl,
52
53
  PodFocusSession,
53
54
  PodSessionSlot,
@@ -29,6 +29,15 @@
29
29
  * the sessions about it), `GET /documents/:id` (`{ document }`), `GET
30
30
  * /focus-sessions` (an ARRAY; `workspaceId` or `projectId` required; status,
31
31
  * limit 1-50, lens, kind, subjectEntityId), `POST /proposals/:id/approve`.
32
+ * 1.5.1, roles (seed `entities[].facets`; `seedRoles` is desugared into them): the
33
+ * reach rule (a granted space's rows, plus the pod-wide rows with a role in
34
+ * one); `GET /entities` filters by `facetSlug` (and a `profileSlug` naming a
35
+ * role), drops the pod-wide rows of a workspace lens on `includePodWide=false`,
36
+ * and with `includeFacets=true` puts `facetSlugs` + `facets` on the rows that
37
+ * wear a visible one; `GET /entities/:id/facets` (`{ facets: [{ facet,
38
+ * profile, effectiveProperties }] }`). The token sees a facet as a member of
39
+ * a granted space does (its seed `workspaceId`), never another. A role is known by its slug only: no 404 for an
40
+ * undeclared `facetSlug`, a synthetic profile id, `effectiveProperties` empty.
32
41
  *
33
42
  * Who the token acts as: `actsAs: "person"` answers
34
43
  * `synap_acts_as: "person"`, applies writes directly (unless `writeMode`
@@ -48,7 +57,8 @@
48
57
  */
49
58
  import { pkceChallenge, randomToken } from "../connect/encoding";
50
59
  import type { PodActsAs, PodGrant, PodUser } from "../connect/types";
51
- import type { MockPodSeed, MockSeedDocument, MockSeedRelation, MockSeedSession } from "./seed";
60
+ import { desugarSeedRoles } from "./roles";
61
+ import type { MockPodSeed, MockSeedDocument, MockSeedFacet, MockSeedRelation, MockSeedSession } from "./seed";
52
62
 
53
63
  /** A placeholder pod origin; never contacted. */
54
64
  export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
@@ -236,13 +246,47 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
236
246
  const writes: Array<{ method: string; path: string; body: unknown }> = [];
237
247
  const proposals: MockProposal[] = [];
238
248
  const seed = options.seed ?? {};
239
- const entities: Array<Record<string, unknown>> = [...(seed.entities ?? []), ...(options.entities ?? [])];
249
+ // One role model: a seed is desugared by parseMockPodSeed; `entities` (and a seed built in code) here.
250
+ const entities: Array<Record<string, unknown>> = [...(seed.entities ?? []), ...(options.entities ?? [])].map((e, i) =>
251
+ desugarSeedRoles(e, `entities[${i}]`),
252
+ );
240
253
  const sessions: MockSeedSession[] = [...(seed.sessions ?? [])];
241
254
  const documents: MockSeedDocument[] = [...(seed.documents ?? [])];
242
255
  const relations: MockSeedRelation[] = [...(seed.relations ?? [])];
243
256
  const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug!]));
244
257
  const approvals: string[] = [];
245
258
  const seededAt = new Date(now()).toISOString();
259
+
260
+ /** The roles (facets) an entity wears: the seed's `facets` (`seedRoles` was desugared into them). */
261
+ const rolesOf = (e: Record<string, unknown>): MockSeedFacet[] =>
262
+ Array.isArray(e.facets) ? (e.facets as MockSeedFacet[]).filter((f) => typeof f?.slug === "string") : [];
263
+ /**
264
+ * What the token's grant lets it read of an entity (the pod's grant clause):
265
+ * a row of a granted space, or a pod-wide row (`workspaceId` null) with a live
266
+ * role in one. The pod sends no role field on an entity row, so the mock takes
267
+ * its roles as SEED input (`facets`) and answers them only when asked.
268
+ */
269
+ function readableEntity(e: Record<string, unknown>, spaces: ReadonlySet<string>, only?: string | null): boolean {
270
+ if (typeof e.workspaceId === "string" && spaces.has(e.workspaceId) && (!only || e.workspaceId === only)) return true;
271
+ // A pod-wide row is reached by any role in the space; a row of ANOTHER space only by a role stamped in this one.
272
+ return facetsOf(e, spaces, only ?? undefined).some((f) => e.workspaceId == null || !f.sharedRole);
273
+ }
274
+ /**
275
+ * The roles of `e` a read sees: what a member of a granted space sees
276
+ * (backend `facetVisibilityConditions` under the token's grant), i.e. a role
277
+ * whose seed space is granted; a workspace lens sees that space's roles only.
278
+ */
279
+ const facetsOf = (e: Record<string, unknown>, spaces: ReadonlySet<string>, lens?: string): MockSeedFacet[] =>
280
+ rolesOf(e).filter((f) => typeof f.workspaceId === "string" && spaces.has(f.workspaceId) && (lens === undefined || f.workspaceId === lens));
281
+ /** backend `FacetRowAnnotation`: what `includeFacets=true` puts on a row. */
282
+ const facetRow = (f: MockSeedFacet) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
283
+ /** A row as the Hub answers it (`entityToWire`): roles ride on it only when asked. */
284
+ const entityWire = (e: Record<string, unknown>): Record<string, unknown> => {
285
+ const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
286
+ return wire;
287
+ };
288
+ const readSpaces = (scope: readonly string[]): Set<string> =>
289
+ new Set(grant.filter((g) => /^entity\.(.+\.)?read$/.test(g.permission) && scope.includes(g.permission)).map((g) => g.workspaceId));
246
290
  for (const p of seed.proposals ?? []) {
247
291
  proposals.push({
248
292
  proposalType: "create",
@@ -404,11 +448,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
404
448
  });
405
449
 
406
450
  /** The seeded read doors (1.5); null when `path` is none of them. */
407
- function readSeeded(path: string, query: URLSearchParams): Response | null {
451
+ function readSeeded(path: string, query: URLSearchParams, scope: readonly string[]): Response | null {
452
+ const spaces = readSpaces(scope);
408
453
  const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
409
454
  if (conn) {
410
455
  const id = decodeURIComponent(conn[1]);
411
- const ws = query.get("workspaceId");
456
+ const ws = query.get("workspaceId") ?? undefined;
412
457
  const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
413
458
  const inLens = (e: Record<string, unknown>) => !ws || e.workspaceId === ws || e.workspaceId == null;
414
459
  const connections: Array<Record<string, unknown>> = [];
@@ -421,10 +466,15 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
421
466
  const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
422
467
  const other = entities.find((e) => e.id === otherId);
423
468
  // As the pod: an edge whose far end is gone or out of the lens is not a neighbour.
424
- if (!other || !inLens(other)) continue;
469
+ if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
425
470
  connections.push({
426
471
  entityId: otherId,
427
- entity: { ...other, type: other.profileSlug ?? other.type ?? null, facetSlugs: other.facetSlugs ?? [] },
472
+ // relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
473
+ entity: {
474
+ ...entityWire(other),
475
+ type: other.profileSlug ?? other.type ?? null,
476
+ facetSlugs: rolesOf(other).length > 0 ? facetsOf(other, spaces, ws).map((f) => f.slug) : (other.facetSlugs ?? []),
477
+ },
428
478
  label: r.type,
429
479
  direction: outgoing ? "outgoing" : "incoming",
430
480
  source: "graph",
@@ -457,8 +507,41 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
457
507
  const entity = /^\/entities\/([^/]+)$/.exec(path);
458
508
  if (entity) {
459
509
  const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
460
- // The pod answers a miss with an EMPTY 404 body (rest/entities.ts `c.body(null, 404)`).
461
- return found ? json(200, found) : new Response(null, { status: 404 });
510
+ // The pod answers a miss with an EMPTY 404 body (rest/entities.ts `c.body(null, 404)`); so does a row the grant cannot read.
511
+ return found && readableEntity(found, spaces) ? json(200, entityWire(found)) : new Response(null, { status: 404 });
512
+ }
513
+ const roles = /^\/entities\/([^/]+)\/facets$/.exec(path);
514
+ if (roles) {
515
+ const found = entities.find((e) => e.id === decodeURIComponent(roles[1]));
516
+ if (!found || !readableEntity(found, spaces)) return new Response(null, { status: 404 });
517
+ // getEffectiveFacets under the whole floor: each live facet row, its role-profile, that profile's effective properties.
518
+ return json(200, {
519
+ facets: facetsOf(found, spaces).map((f) => ({
520
+ facet: {
521
+ id: f.facetId,
522
+ entityId: found.id,
523
+ profileId: `profile_${f.slug}`,
524
+ userId: found.userId ?? user.id,
525
+ // A stamped facet answers its space; a shared role's facet is stored pod-wide.
526
+ workspaceId: f.sharedRole ? null : f.workspaceId,
527
+ contextEntityId: null,
528
+ status: f.status ?? null,
529
+ statusCategory: null,
530
+ properties: f.properties ?? {},
531
+ metadata: {},
532
+ createdByKind: null,
533
+ createdByUserId: null,
534
+ agentUserId: null,
535
+ sourceProposalId: null,
536
+ correlationId: null,
537
+ createdAt: seededAt,
538
+ updatedAt: seededAt,
539
+ deletedAt: null,
540
+ },
541
+ profile: { id: `profile_${f.slug}`, slug: f.slug, profileKind: "role" },
542
+ effectiveProperties: [],
543
+ })),
544
+ });
462
545
  }
463
546
  const doc = /^\/documents\/([^/]+)$/.exec(path);
464
547
  if (doc) {
@@ -535,12 +618,31 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
535
618
  if (method === "GET" && path === "/users/me") return json(200, user);
536
619
  if (method === "GET" && path === "/entities") {
537
620
  // The pod answers an ARRAY (rest/entities.ts), not a page object.
538
- const ws = query.get("workspaceId");
621
+ const ws = query.get("workspaceId") || undefined;
539
622
  const kind = query.get("profileSlug");
623
+ const role = query.get("facetSlug");
624
+ // A workspace lens keeps the pod-wide rows unless told not to (rest/entities.ts: `!== "false"`).
625
+ const podWide = query.get("includePodWide") !== "false";
626
+ const withFacets = query.get("includeFacets") === "true";
540
627
  const offset = Number(query.get("offset") ?? 0) || 0;
541
628
  const limit = Number(query.get("limit") ?? 50) || 50;
542
- const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
543
- return json(200, rows.slice(offset, offset + limit));
629
+ // Granted spaces' rows, plus the pod-wide rows roled in them (read-only, as a member of the space).
630
+ const spaces = readSpaces(row.scope);
631
+ const rows = entities.filter((e) => {
632
+ if (!readableEntity(e, spaces, ws)) return false;
633
+ // includePodWide=false keeps the rows with a role STAMPED in the space; it drops those reached only through a shared role.
634
+ if (ws && !podWide && e.workspaceId !== ws && !facetsOf(e, spaces, ws).some((f) => !f.sharedRole)) return false;
635
+ const slugs = facetsOf(e, spaces, ws).map((f) => f.slug);
636
+ // A profileSlug naming a role matches its wearers too (entities.list's facet branch).
637
+ return (!kind || e.profileSlug === kind || slugs.includes(kind)) && (!role || slugs.includes(role));
638
+ });
639
+ return json(
640
+ 200,
641
+ rows.slice(offset, offset + limit).map((e) => {
642
+ const facets = withFacets ? facetsOf(e, spaces, ws).map(facetRow) : [];
643
+ return facets.length > 0 ? { ...entityWire(e), facetSlugs: facets.map((f) => f.slug), facets } : entityWire(e);
644
+ }),
645
+ );
544
646
  }
545
647
  if (method === "GET" && path === "/proposals") {
546
648
  // The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
@@ -561,7 +663,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
561
663
  return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
562
664
  }
563
665
  if (method === "GET") {
564
- const read = readSeeded(path, query);
666
+ const read = readSeeded(path, query, row.scope);
565
667
  if (read) return read;
566
668
  }
567
669
  const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
@@ -581,6 +683,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
581
683
  approvals.push(p.id);
582
684
  return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
583
685
  }
686
+ // A pod-wide row's core fields are outside a space-scoped grant: refused (the pod: 403 or 404, not yet pinned), nothing written, no proposal.
687
+ const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
688
+ if (patched) {
689
+ const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
690
+ if (target && target.workspaceId == null) return json(403, { error: "grant does not allow" });
691
+ }
584
692
  if (method !== "GET") {
585
693
  writes.push({ method, path, body });
586
694
  const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The mock's ONE role model is `facets` (./seed.ts `MockSeedFacet`). The
3
+ * deprecated `seedRoles` shorthand is desugared into it here, once, by
4
+ * `parseMockPodSeed` and by `createMockAuthServer` for `options.entities`, so
5
+ * nothing downstream reads `seedRoles`. Not exported from `@synap-core/sdk/testing`.
6
+ */
7
+ import type { MockSeedFacet } from "./seed";
8
+
9
+ /**
10
+ * `row` with its `seedRoles` turned into `facets` (a generated `facetId`, no
11
+ * overlay). A role stated in both (same slug, same space) is a TypeError
12
+ * naming the entity: one fact, one place, never a silent merge.
13
+ */
14
+ export function desugarSeedRoles(row: Record<string, unknown>, at: string): Record<string, unknown> {
15
+ if (!Array.isArray(row.seedRoles)) return row;
16
+ const { seedRoles, ...rest } = row;
17
+ const facets = Array.isArray(row.facets) ? (row.facets as MockSeedFacet[]) : [];
18
+ const shorthand = (seedRoles as Array<{ profileSlug?: unknown; workspaceId?: unknown }>)
19
+ .filter((r) => typeof r?.profileSlug === "string" && typeof r.workspaceId === "string")
20
+ .map((r, i): MockSeedFacet => ({ facetId: `seedrole_${String(row.id)}_${i}`, slug: r.profileSlug as string, workspaceId: r.workspaceId as string }));
21
+ for (const r of shorthand) {
22
+ if (facets.some((f) => f?.slug === r.slug && f.workspaceId === r.workspaceId)) {
23
+ throw new TypeError(`${at} ("${String(row.id)}"): the role "${r.slug}" in ${r.workspaceId} is in both facets and seedRoles; keep it in facets only`);
24
+ }
25
+ }
26
+ return { ...rest, facets: [...facets, ...shorthand] };
27
+ }
@@ -14,8 +14,41 @@
14
14
  * `relations` as graph edges for `GET /entities/:id/connections`, `proposals`
15
15
  * as `GET /proposals?view=basic` rows, `spaces` name each granted space's
16
16
  * template (`synap_grant[].templateSlug` on the token response).
17
+ *
18
+ * An entity's roles ride on its row as `facets`, the shape `GET /entities?
19
+ * includeFacets=true` answers them (`{ facetId, slug, properties, status }`),
20
+ * plus the seed-only space each is stamped in, so a seed row reads like a pod
21
+ * row read with includeFacets. The mock derives `facetSlugs` and the reach rule
22
+ * from them and serves neither field unless asked, as the pod.
17
23
  */
18
24
  import type { MockProposal } from "./auth-server";
25
+ import { desugarSeedRoles } from "./roles";
26
+
27
+ /** One role an entity wears (backend `FacetRowAnnotation`, plus the space it is stamped in). */
28
+ export interface MockSeedFacet {
29
+ facetId: string;
30
+ /** The role's slug, e.g. `lead`. */
31
+ slug: string;
32
+ /** The facet's overlay properties. Default `{}`. */
33
+ properties?: Record<string, unknown>;
34
+ /** Its lifecycle status. Default null. */
35
+ status?: string | null;
36
+ /**
37
+ * Seed only, never served: the space the role is in, i.e. the space whose
38
+ * members see the facet (stamped there, or a pod-wide facet of a role granted
39
+ * to it). A role in a granted space puts a pod-wide entity in the app's
40
+ * reach; a workspace lens sees the roles of that space only.
41
+ */
42
+ workspaceId: string;
43
+ /**
44
+ * Seed only, never served: the role is SHARED, i.e. stored pod-wide (null
45
+ * `workspaceId`) and granted to `workspaceId`, which then means "the space it
46
+ * is granted to". Default false = stamped in `workspaceId`. The pod treats the
47
+ * two alike for reach, except `includePodWide=false` on a space lens, which
48
+ * drops a pod-wide row reached only through a shared role.
49
+ */
50
+ sharedRole?: boolean;
51
+ }
19
52
 
20
53
  export interface MockSeedEntity {
21
54
  id: string;
@@ -23,6 +56,21 @@ export interface MockSeedEntity {
23
56
  workspaceId?: string | null;
24
57
  title?: string | null;
25
58
  properties?: Record<string, unknown>;
59
+ /**
60
+ * The roles (facets) it wears, in the wire shape plus the space each is in
61
+ * (`MockSeedFacet`). SEED INPUT ONLY: the pod sends them only to
62
+ * `includeFacets`, and so does the mock. A pod-wide entity (`workspaceId:
63
+ * null`) with a role in a granted space is readable by the app, as a member
64
+ * of that space sees it. Absent = none.
65
+ */
66
+ facets?: MockSeedFacet[];
67
+ /**
68
+ * @deprecated Use `facets`; removed in 2.0. Shorthand for `facets` entries
69
+ * with no id and no overlay: `parseMockPodSeed` turns each into a facet (a
70
+ * generated `facetId`, empty properties, a null status). A role stated in
71
+ * both is refused.
72
+ */
73
+ seedRoles?: Array<{ profileSlug: string; workspaceId: string }>;
26
74
  [key: string]: unknown;
27
75
  }
28
76
 
@@ -113,8 +161,36 @@ export function parseMockPodSeed(input: string | unknown): MockPodSeed {
113
161
  for (const field of REQUIRED[key]) {
114
162
  if (typeof (row as Record<string, unknown>)[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
115
163
  }
164
+ if (key === "entities") checkFacets((row as Record<string, unknown>).facets, `seed.entities[${i}].facets`);
116
165
  });
117
166
  }
118
- // A JSON round trip: what the mock serves is what a file could hold.
119
- return JSON.parse(JSON.stringify(obj)) as MockPodSeed;
167
+ (obj.entities as Array<Record<string, unknown>> | undefined)?.forEach((row, i) => {
168
+ const roles = row.seedRoles;
169
+ if (roles === undefined) return;
170
+ if (!Array.isArray(roles)) throw new TypeError(`seed.entities[${i}].seedRoles: expected an array`);
171
+ roles.forEach((r, j) => {
172
+ for (const field of ["profileSlug", "workspaceId"]) {
173
+ if (!r || typeof r !== "object" || typeof (r as Record<string, unknown>)[field] !== "string") {
174
+ throw new TypeError(`seed.entities[${i}].seedRoles[${j}].${field}: expected a string`);
175
+ }
176
+ }
177
+ });
178
+ });
179
+ // A JSON round trip: what the mock serves is what a file could hold. Then ONE role model: seedRoles → facets.
180
+ const out = JSON.parse(JSON.stringify(obj)) as MockPodSeed;
181
+ if (out.entities) out.entities = out.entities.map((row, i) => desugarSeedRoles(row, `seed.entities[${i}]`) as MockSeedEntity);
182
+ return out;
183
+ }
184
+
185
+ function checkFacets(facets: unknown, at: string): void {
186
+ if (facets === undefined) return;
187
+ if (!Array.isArray(facets)) throw new TypeError(`${at}: expected an array`);
188
+ facets.forEach((f, j) => {
189
+ if (!f || typeof f !== "object" || Array.isArray(f)) throw new TypeError(`${at}[${j}]: expected an object`);
190
+ for (const field of ["facetId", "slug", "workspaceId"]) {
191
+ if (typeof (f as Record<string, unknown>)[field] !== "string") throw new TypeError(`${at}[${j}].${field}: expected a string`);
192
+ }
193
+ const shared = (f as Record<string, unknown>).sharedRole;
194
+ if (shared !== undefined && typeof shared !== "boolean") throw new TypeError(`${at}[${j}].sharedRole: expected a boolean`);
195
+ });
120
196
  }