@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.
@@ -51,14 +51,63 @@ declare const MOCK_SITE: MockSite;
51
51
  * `relations` as graph edges for `GET /entities/:id/connections`, `proposals`
52
52
  * as `GET /proposals?view=basic` rows, `spaces` name each granted space's
53
53
  * template (`synap_grant[].templateSlug` on the token response).
54
+ *
55
+ * An entity's roles ride on its row as `facets`, the shape `GET /entities?
56
+ * includeFacets=true` answers them (`{ facetId, slug, properties, status }`),
57
+ * plus the seed-only space each is stamped in, so a seed row reads like a pod
58
+ * row read with includeFacets. The mock derives `facetSlugs` and the reach rule
59
+ * from them and serves neither field unless asked, as the pod.
54
60
  */
55
61
 
62
+ /** One role an entity wears (backend `FacetRowAnnotation`, plus the space it is stamped in). */
63
+ interface MockSeedFacet {
64
+ facetId: string;
65
+ /** The role's slug, e.g. `lead`. */
66
+ slug: string;
67
+ /** The facet's overlay properties. Default `{}`. */
68
+ properties?: Record<string, unknown>;
69
+ /** Its lifecycle status. Default null. */
70
+ status?: string | null;
71
+ /**
72
+ * Seed only, never served: the space the role is in, i.e. the space whose
73
+ * members see the facet (stamped there, or a pod-wide facet of a role granted
74
+ * to it). A role in a granted space puts a pod-wide entity in the app's
75
+ * reach; a workspace lens sees the roles of that space only.
76
+ */
77
+ workspaceId: string;
78
+ /**
79
+ * Seed only, never served: the role is SHARED, i.e. stored pod-wide (null
80
+ * `workspaceId`) and granted to `workspaceId`, which then means "the space it
81
+ * is granted to". Default false = stamped in `workspaceId`. The pod treats the
82
+ * two alike for reach, except `includePodWide=false` on a space lens, which
83
+ * drops a pod-wide row reached only through a shared role.
84
+ */
85
+ sharedRole?: boolean;
86
+ }
56
87
  interface MockSeedEntity {
57
88
  id: string;
58
89
  profileSlug?: string;
59
90
  workspaceId?: string | null;
60
91
  title?: string | null;
61
92
  properties?: Record<string, unknown>;
93
+ /**
94
+ * The roles (facets) it wears, in the wire shape plus the space each is in
95
+ * (`MockSeedFacet`). SEED INPUT ONLY: the pod sends them only to
96
+ * `includeFacets`, and so does the mock. A pod-wide entity (`workspaceId:
97
+ * null`) with a role in a granted space is readable by the app, as a member
98
+ * of that space sees it. Absent = none.
99
+ */
100
+ facets?: MockSeedFacet[];
101
+ /**
102
+ * @deprecated Use `facets`; removed in 2.0. Shorthand for `facets` entries
103
+ * with no id and no overlay: `parseMockPodSeed` turns each into a facet (a
104
+ * generated `facetId`, empty properties, a null status). A role stated in
105
+ * both is refused.
106
+ */
107
+ seedRoles?: Array<{
108
+ profileSlug: string;
109
+ workspaceId: string;
110
+ }>;
62
111
  [key: string]: unknown;
63
112
  }
64
113
  interface MockSeedSession {
@@ -361,4 +410,4 @@ interface MockPod {
361
410
  }
362
411
  declare function createMockPod(options?: MockPodOptions): MockPod;
363
412
 
364
- export { type AuthorizeDecision, MOCK_APP, MOCK_APP_AGENT_ID, MOCK_AUTH_ISSUER, MOCK_CLOUD_URL, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_HTML_SHARE, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCloud, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockPodSeed, type MockProposal, type MockRequest, type MockSeedDocument, type MockSeedEntity, type MockSeedRelation, type MockSeedSession, type MockSeedSpace, type MockSite, type MockSubmission, createMockAuthServer, createMockCloud, createMockPod, parseMockPodSeed };
413
+ export { type AuthorizeDecision, MOCK_APP, MOCK_APP_AGENT_ID, MOCK_AUTH_ISSUER, MOCK_CLOUD_URL, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_HTML_SHARE, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCloud, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockPodSeed, type MockProposal, type MockRequest, type MockSeedDocument, type MockSeedEntity, type MockSeedFacet, type MockSeedRelation, type MockSeedSession, type MockSeedSpace, type MockSite, type MockSubmission, createMockAuthServer, createMockCloud, createMockPod, parseMockPodSeed };
package/dist/testing.d.ts CHANGED
@@ -51,14 +51,63 @@ declare const MOCK_SITE: MockSite;
51
51
  * `relations` as graph edges for `GET /entities/:id/connections`, `proposals`
52
52
  * as `GET /proposals?view=basic` rows, `spaces` name each granted space's
53
53
  * template (`synap_grant[].templateSlug` on the token response).
54
+ *
55
+ * An entity's roles ride on its row as `facets`, the shape `GET /entities?
56
+ * includeFacets=true` answers them (`{ facetId, slug, properties, status }`),
57
+ * plus the seed-only space each is stamped in, so a seed row reads like a pod
58
+ * row read with includeFacets. The mock derives `facetSlugs` and the reach rule
59
+ * from them and serves neither field unless asked, as the pod.
54
60
  */
55
61
 
62
+ /** One role an entity wears (backend `FacetRowAnnotation`, plus the space it is stamped in). */
63
+ interface MockSeedFacet {
64
+ facetId: string;
65
+ /** The role's slug, e.g. `lead`. */
66
+ slug: string;
67
+ /** The facet's overlay properties. Default `{}`. */
68
+ properties?: Record<string, unknown>;
69
+ /** Its lifecycle status. Default null. */
70
+ status?: string | null;
71
+ /**
72
+ * Seed only, never served: the space the role is in, i.e. the space whose
73
+ * members see the facet (stamped there, or a pod-wide facet of a role granted
74
+ * to it). A role in a granted space puts a pod-wide entity in the app's
75
+ * reach; a workspace lens sees the roles of that space only.
76
+ */
77
+ workspaceId: string;
78
+ /**
79
+ * Seed only, never served: the role is SHARED, i.e. stored pod-wide (null
80
+ * `workspaceId`) and granted to `workspaceId`, which then means "the space it
81
+ * is granted to". Default false = stamped in `workspaceId`. The pod treats the
82
+ * two alike for reach, except `includePodWide=false` on a space lens, which
83
+ * drops a pod-wide row reached only through a shared role.
84
+ */
85
+ sharedRole?: boolean;
86
+ }
56
87
  interface MockSeedEntity {
57
88
  id: string;
58
89
  profileSlug?: string;
59
90
  workspaceId?: string | null;
60
91
  title?: string | null;
61
92
  properties?: Record<string, unknown>;
93
+ /**
94
+ * The roles (facets) it wears, in the wire shape plus the space each is in
95
+ * (`MockSeedFacet`). SEED INPUT ONLY: the pod sends them only to
96
+ * `includeFacets`, and so does the mock. A pod-wide entity (`workspaceId:
97
+ * null`) with a role in a granted space is readable by the app, as a member
98
+ * of that space sees it. Absent = none.
99
+ */
100
+ facets?: MockSeedFacet[];
101
+ /**
102
+ * @deprecated Use `facets`; removed in 2.0. Shorthand for `facets` entries
103
+ * with no id and no overlay: `parseMockPodSeed` turns each into a facet (a
104
+ * generated `facetId`, empty properties, a null status). A role stated in
105
+ * both is refused.
106
+ */
107
+ seedRoles?: Array<{
108
+ profileSlug: string;
109
+ workspaceId: string;
110
+ }>;
62
111
  [key: string]: unknown;
63
112
  }
64
113
  interface MockSeedSession {
@@ -361,4 +410,4 @@ interface MockPod {
361
410
  }
362
411
  declare function createMockPod(options?: MockPodOptions): MockPod;
363
412
 
364
- export { type AuthorizeDecision, MOCK_APP, MOCK_APP_AGENT_ID, MOCK_AUTH_ISSUER, MOCK_CLOUD_URL, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_HTML_SHARE, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCloud, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockPodSeed, type MockProposal, type MockRequest, type MockSeedDocument, type MockSeedEntity, type MockSeedRelation, type MockSeedSession, type MockSeedSpace, type MockSite, type MockSubmission, createMockAuthServer, createMockCloud, createMockPod, parseMockPodSeed };
413
+ export { type AuthorizeDecision, MOCK_APP, MOCK_APP_AGENT_ID, MOCK_AUTH_ISSUER, MOCK_CLOUD_URL, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_HTML_SHARE, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCloud, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockPodSeed, type MockProposal, type MockRequest, type MockSeedDocument, type MockSeedEntity, type MockSeedFacet, type MockSeedRelation, type MockSeedSession, type MockSeedSpace, type MockSite, type MockSubmission, createMockAuthServer, createMockCloud, createMockPod, parseMockPodSeed };
package/dist/testing.js CHANGED
@@ -120,6 +120,20 @@ var MOCK_SITE = {
120
120
  }
121
121
  };
122
122
 
123
+ // src/testing/roles.ts
124
+ function desugarSeedRoles(row, at) {
125
+ if (!Array.isArray(row.seedRoles)) return row;
126
+ const { seedRoles, ...rest } = row;
127
+ const facets = Array.isArray(row.facets) ? row.facets : [];
128
+ const shorthand = seedRoles.filter((r) => typeof r?.profileSlug === "string" && typeof r.workspaceId === "string").map((r, i) => ({ facetId: `seedrole_${String(row.id)}_${i}`, slug: r.profileSlug, workspaceId: r.workspaceId }));
129
+ for (const r of shorthand) {
130
+ if (facets.some((f) => f?.slug === r.slug && f.workspaceId === r.workspaceId)) {
131
+ 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`);
132
+ }
133
+ }
134
+ return { ...rest, facets: [...facets, ...shorthand] };
135
+ }
136
+
123
137
  // src/testing/auth-server.ts
124
138
  var MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
125
139
  var MOCK_GRANT = [
@@ -153,13 +167,27 @@ function createMockAuthServer(options = {}) {
153
167
  const writes = [];
154
168
  const proposals = [];
155
169
  const seed = options.seed ?? {};
156
- const entities = [...seed.entities ?? [], ...options.entities ?? []];
170
+ const entities = [...seed.entities ?? [], ...options.entities ?? []].map(
171
+ (e, i) => desugarSeedRoles(e, `entities[${i}]`)
172
+ );
157
173
  const sessions = [...seed.sessions ?? []];
158
174
  const documents = [...seed.documents ?? []];
159
175
  const relations = [...seed.relations ?? []];
160
176
  const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug]));
161
177
  const approvals = [];
162
178
  const seededAt = new Date(now()).toISOString();
179
+ const rolesOf = (e) => Array.isArray(e.facets) ? e.facets.filter((f) => typeof f?.slug === "string") : [];
180
+ function readableEntity(e, spaces, only) {
181
+ if (typeof e.workspaceId === "string" && spaces.has(e.workspaceId) && (!only || e.workspaceId === only)) return true;
182
+ return facetsOf(e, spaces, only ?? void 0).some((f) => e.workspaceId == null || !f.sharedRole);
183
+ }
184
+ const facetsOf = (e, spaces, lens) => rolesOf(e).filter((f) => typeof f.workspaceId === "string" && spaces.has(f.workspaceId) && (lens === void 0 || f.workspaceId === lens));
185
+ const facetRow = (f) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
186
+ const entityWire = (e) => {
187
+ const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
188
+ return wire;
189
+ };
190
+ const readSpaces = (scope) => new Set(grant.filter((g) => /^entity\.(.+\.)?read$/.test(g.permission) && scope.includes(g.permission)).map((g) => g.workspaceId));
163
191
  for (const p of seed.proposals ?? []) {
164
192
  proposals.push({
165
193
  proposalType: "create",
@@ -307,11 +335,12 @@ function createMockAuthServer(options = {}) {
307
335
  triage: { pending: false, acceptedAt: null, acceptedBy: null },
308
336
  ...s
309
337
  });
310
- function readSeeded(path, query) {
338
+ function readSeeded(path, query, scope) {
339
+ const spaces = readSpaces(scope);
311
340
  const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
312
341
  if (conn) {
313
342
  const id = decodeURIComponent(conn[1]);
314
- const ws = query.get("workspaceId");
343
+ const ws = query.get("workspaceId") ?? void 0;
315
344
  const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
316
345
  const inLens = (e) => !ws || e.workspaceId === ws || e.workspaceId == null;
317
346
  const connections = [];
@@ -320,10 +349,15 @@ function createMockAuthServer(options = {}) {
320
349
  const outgoing = r.sourceEntityId === id;
321
350
  const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
322
351
  const other = entities.find((e) => e.id === otherId);
323
- if (!other || !inLens(other)) continue;
352
+ if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
324
353
  connections.push({
325
354
  entityId: otherId,
326
- entity: { ...other, type: other.profileSlug ?? other.type ?? null, facetSlugs: other.facetSlugs ?? [] },
355
+ // relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
356
+ entity: {
357
+ ...entityWire(other),
358
+ type: other.profileSlug ?? other.type ?? null,
359
+ facetSlugs: rolesOf(other).length > 0 ? facetsOf(other, spaces, ws).map((f) => f.slug) : other.facetSlugs ?? []
360
+ },
327
361
  label: r.type,
328
362
  direction: outgoing ? "outgoing" : "incoming",
329
363
  source: "graph",
@@ -356,7 +390,39 @@ function createMockAuthServer(options = {}) {
356
390
  const entity = /^\/entities\/([^/]+)$/.exec(path);
357
391
  if (entity) {
358
392
  const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
359
- return found ? json(200, found) : new Response(null, { status: 404 });
393
+ return found && readableEntity(found, spaces) ? json(200, entityWire(found)) : new Response(null, { status: 404 });
394
+ }
395
+ const roles = /^\/entities\/([^/]+)\/facets$/.exec(path);
396
+ if (roles) {
397
+ const found = entities.find((e) => e.id === decodeURIComponent(roles[1]));
398
+ if (!found || !readableEntity(found, spaces)) return new Response(null, { status: 404 });
399
+ return json(200, {
400
+ facets: facetsOf(found, spaces).map((f) => ({
401
+ facet: {
402
+ id: f.facetId,
403
+ entityId: found.id,
404
+ profileId: `profile_${f.slug}`,
405
+ userId: found.userId ?? user.id,
406
+ // A stamped facet answers its space; a shared role's facet is stored pod-wide.
407
+ workspaceId: f.sharedRole ? null : f.workspaceId,
408
+ contextEntityId: null,
409
+ status: f.status ?? null,
410
+ statusCategory: null,
411
+ properties: f.properties ?? {},
412
+ metadata: {},
413
+ createdByKind: null,
414
+ createdByUserId: null,
415
+ agentUserId: null,
416
+ sourceProposalId: null,
417
+ correlationId: null,
418
+ createdAt: seededAt,
419
+ updatedAt: seededAt,
420
+ deletedAt: null
421
+ },
422
+ profile: { id: `profile_${f.slug}`, slug: f.slug, profileKind: "role" },
423
+ effectiveProperties: []
424
+ }))
425
+ });
360
426
  }
361
427
  const doc = /^\/documents\/([^/]+)$/.exec(path);
362
428
  if (doc) {
@@ -422,12 +488,27 @@ function createMockAuthServer(options = {}) {
422
488
  }
423
489
  if (method === "GET" && path === "/users/me") return json(200, user);
424
490
  if (method === "GET" && path === "/entities") {
425
- const ws = query.get("workspaceId");
491
+ const ws = query.get("workspaceId") || void 0;
426
492
  const kind = query.get("profileSlug");
493
+ const role = query.get("facetSlug");
494
+ const podWide = query.get("includePodWide") !== "false";
495
+ const withFacets = query.get("includeFacets") === "true";
427
496
  const offset = Number(query.get("offset") ?? 0) || 0;
428
497
  const limit = Number(query.get("limit") ?? 50) || 50;
429
- const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
430
- return json(200, rows.slice(offset, offset + limit));
498
+ const spaces = readSpaces(row.scope);
499
+ const rows = entities.filter((e) => {
500
+ if (!readableEntity(e, spaces, ws)) return false;
501
+ if (ws && !podWide && e.workspaceId !== ws && !facetsOf(e, spaces, ws).some((f) => !f.sharedRole)) return false;
502
+ const slugs = facetsOf(e, spaces, ws).map((f) => f.slug);
503
+ return (!kind || e.profileSlug === kind || slugs.includes(kind)) && (!role || slugs.includes(role));
504
+ });
505
+ return json(
506
+ 200,
507
+ rows.slice(offset, offset + limit).map((e) => {
508
+ const facets = withFacets ? facetsOf(e, spaces, ws).map(facetRow) : [];
509
+ return facets.length > 0 ? { ...entityWire(e), facetSlugs: facets.map((f) => f.slug), facets } : entityWire(e);
510
+ })
511
+ );
431
512
  }
432
513
  if (method === "GET" && path === "/proposals") {
433
514
  const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
@@ -442,7 +523,7 @@ function createMockAuthServer(options = {}) {
442
523
  return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
443
524
  }
444
525
  if (method === "GET") {
445
- const read = readSeeded(path, query);
526
+ const read = readSeeded(path, query, row.scope);
446
527
  if (read) return read;
447
528
  }
448
529
  const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
@@ -460,6 +541,11 @@ function createMockAuthServer(options = {}) {
460
541
  approvals.push(p.id);
461
542
  return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
462
543
  }
544
+ const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
545
+ if (patched) {
546
+ const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
547
+ if (target && target.workspaceId == null) return json(403, { error: "grant does not allow" });
548
+ }
463
549
  if (method !== "GET") {
464
550
  writes.push({ method, path, body });
465
551
  const fields = body && typeof body === "object" ? body : {};
@@ -637,9 +723,36 @@ function parseMockPodSeed(input) {
637
723
  for (const field of REQUIRED[key]) {
638
724
  if (typeof row[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
639
725
  }
726
+ if (key === "entities") checkFacets(row.facets, `seed.entities[${i}].facets`);
640
727
  });
641
728
  }
642
- return JSON.parse(JSON.stringify(obj));
729
+ obj.entities?.forEach((row, i) => {
730
+ const roles = row.seedRoles;
731
+ if (roles === void 0) return;
732
+ if (!Array.isArray(roles)) throw new TypeError(`seed.entities[${i}].seedRoles: expected an array`);
733
+ roles.forEach((r, j) => {
734
+ for (const field of ["profileSlug", "workspaceId"]) {
735
+ if (!r || typeof r !== "object" || typeof r[field] !== "string") {
736
+ throw new TypeError(`seed.entities[${i}].seedRoles[${j}].${field}: expected a string`);
737
+ }
738
+ }
739
+ });
740
+ });
741
+ const out = JSON.parse(JSON.stringify(obj));
742
+ if (out.entities) out.entities = out.entities.map((row, i) => desugarSeedRoles(row, `seed.entities[${i}]`));
743
+ return out;
744
+ }
745
+ function checkFacets(facets, at) {
746
+ if (facets === void 0) return;
747
+ if (!Array.isArray(facets)) throw new TypeError(`${at}: expected an array`);
748
+ facets.forEach((f, j) => {
749
+ if (!f || typeof f !== "object" || Array.isArray(f)) throw new TypeError(`${at}[${j}]: expected an object`);
750
+ for (const field of ["facetId", "slug", "workspaceId"]) {
751
+ if (typeof f[field] !== "string") throw new TypeError(`${at}[${j}].${field}: expected a string`);
752
+ }
753
+ const shared = f.sharedRole;
754
+ if (shared !== void 0 && typeof shared !== "boolean") throw new TypeError(`${at}[${j}].sharedRole: expected a boolean`);
755
+ });
643
756
  }
644
757
 
645
758
  // src/testing/cloud.ts