@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/dist/testing.cjs CHANGED
@@ -180,12 +180,27 @@ async function pkceChallenge(verifier) {
180
180
  return base64url(new Uint8Array(digest));
181
181
  }
182
182
 
183
+ // src/testing/roles.ts
184
+ function desugarSeedRoles(row, at) {
185
+ if (!Array.isArray(row.seedRoles)) return row;
186
+ const { seedRoles, ...rest } = row;
187
+ const facets = Array.isArray(row.facets) ? row.facets : [];
188
+ 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 }));
189
+ for (const r of shorthand) {
190
+ if (facets.some((f) => f?.slug === r.slug && f.workspaceId === r.workspaceId)) {
191
+ 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`);
192
+ }
193
+ }
194
+ return { ...rest, facets: [...facets, ...shorthand] };
195
+ }
196
+
183
197
  // src/testing/auth-server.ts
184
198
  var MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
185
199
  var MOCK_GRANT = [
186
200
  { permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
187
201
  { permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
188
202
  ];
203
+ var STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
189
204
  var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
190
205
  var MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
191
206
  var MOCK_APP_AGENT_ID = "agent_mock_app";
@@ -213,13 +228,43 @@ function createMockAuthServer(options = {}) {
213
228
  const writes = [];
214
229
  const proposals = [];
215
230
  const seed = options.seed ?? {};
216
- const entities = [...seed.entities ?? [], ...options.entities ?? []];
231
+ const entities = [...seed.entities ?? [], ...options.entities ?? []].map(
232
+ (e, i) => desugarSeedRoles(e, `entities[${i}]`)
233
+ );
217
234
  const sessions = [...seed.sessions ?? []];
218
235
  const documents = [...seed.documents ?? []];
219
236
  const relations = [...seed.relations ?? []];
220
237
  const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug]));
221
238
  const approvals = [];
222
239
  const seededAt = new Date(now()).toISOString();
240
+ const rolesOf = (e) => Array.isArray(e.facets) ? e.facets.filter((f) => typeof f?.slug === "string") : [];
241
+ function readableEntity(e, spaces, only) {
242
+ if (typeof e.workspaceId === "string" && kindReadable(e, spaces, e.workspaceId) && (!only || e.workspaceId === only)) return true;
243
+ return facetsOf(e, spaces, only ?? void 0).some((f) => e.workspaceId == null || !f.sharedRole);
244
+ }
245
+ const facetsOf = (e, spaces, lens) => rolesOf(e).filter((f) => typeof f.workspaceId === "string" && kindReadable(e, spaces, f.workspaceId) && (lens === void 0 || f.workspaceId === lens));
246
+ const facetRow = (f) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
247
+ const entityWire = (e) => {
248
+ const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
249
+ return wire;
250
+ };
251
+ const readSpaces = (scope) => {
252
+ const out = /* @__PURE__ */ new Map();
253
+ for (const g of grant) {
254
+ const m = /^entity\.(?:(.+)\.)?read$/.exec(g.permission);
255
+ if (!m || !scope.includes(g.permission)) continue;
256
+ const kind = m[1];
257
+ if (kind === void 0 || kind === "*") out.set(g.workspaceId, null);
258
+ else if (out.get(g.workspaceId) !== null) out.set(g.workspaceId, (out.get(g.workspaceId) ?? /* @__PURE__ */ new Set()).add(kind));
259
+ else continue;
260
+ }
261
+ return out;
262
+ };
263
+ const kindReadable = (e, spaces, space) => {
264
+ if (!spaces.has(space)) return false;
265
+ const kinds = spaces.get(space);
266
+ return kinds === null || kinds === void 0 ? kinds === null : kinds.has(String(e.profileSlug ?? e.type ?? ""));
267
+ };
223
268
  for (const p of seed.proposals ?? []) {
224
269
  proposals.push({
225
270
  proposalType: "create",
@@ -234,7 +279,9 @@ function createMockAuthServer(options = {}) {
234
279
  ...p
235
280
  });
236
281
  }
237
- const writeMode = options.writeMode ?? (options.actsAs === "person" ? "applied" : "proposed");
282
+ let actsAs = options.actsAs;
283
+ const writeMode = () => options.writeMode ?? (actsAs === "person" ? "applied" : "proposed");
284
+ let deviceFresh = !options.requireStepUp;
238
285
  const graceMs = (options.refreshGraceSec ?? 30) * 1e3;
239
286
  const app = options.app === void 0 ? MOCK_APP : options.app;
240
287
  let proposalSeq = 0;
@@ -280,7 +327,7 @@ function createMockAuthServer(options = {}) {
280
327
  synap_grant: grant.filter((g) => scope.includes(g.permission)).map((g) => g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId) }),
281
328
  synap_user: user,
282
329
  ...app ? { synap_app: app } : {},
283
- ...options.actsAs ? { synap_acts_as: options.actsAs } : {}
330
+ ...actsAs ? { synap_acts_as: actsAs } : {}
284
331
  };
285
332
  if (pair) {
286
333
  body.refresh_token = pair.refreshToken;
@@ -306,6 +353,21 @@ function createMockAuthServer(options = {}) {
306
353
  if (form.resource !== void 0 && row.resource && form.resource !== row.resource) {
307
354
  return oauthError("invalid_target", "resource does not match the authorization request");
308
355
  }
356
+ if (actsAs === "person" && row.stepUp !== Boolean(form.synap_device_refresh_token)) {
357
+ return oauthError(
358
+ "invalid_grant",
359
+ row.stepUp ? "This code re-confirms a device: send its refresh token as synap_device_refresh_token" : "This code signs a new device in; a re-confirmation asks with prompt=login"
360
+ );
361
+ }
362
+ if (row.stepUp) {
363
+ const device = refreshTokens.get(form.synap_device_refresh_token);
364
+ if (!device || device.clientId !== row.clientId || device.used || device.revoked) {
365
+ return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
366
+ }
367
+ device.used = true;
368
+ deviceFresh = true;
369
+ return json(200, tokenBody(row.scope, row.clientId, device.family));
370
+ }
309
371
  for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
310
372
  return json(200, tokenBody(row.scope, row.clientId, randomToken()));
311
373
  }
@@ -367,11 +429,12 @@ function createMockAuthServer(options = {}) {
367
429
  triage: { pending: false, acceptedAt: null, acceptedBy: null },
368
430
  ...s
369
431
  });
370
- function readSeeded(path, query) {
432
+ function readSeeded(path, query, scope) {
433
+ const spaces = readSpaces(scope);
371
434
  const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
372
435
  if (conn) {
373
436
  const id = decodeURIComponent(conn[1]);
374
- const ws = query.get("workspaceId");
437
+ const ws = query.get("workspaceId") ?? void 0;
375
438
  const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
376
439
  const inLens = (e) => !ws || e.workspaceId === ws || e.workspaceId == null;
377
440
  const connections = [];
@@ -380,10 +443,17 @@ function createMockAuthServer(options = {}) {
380
443
  const outgoing = r.sourceEntityId === id;
381
444
  const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
382
445
  const other = entities.find((e) => e.id === otherId);
383
- if (!other || !inLens(other)) continue;
446
+ if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
447
+ const self = entities.find((e) => e.id === id);
448
+ if (self && self.workspaceId == null && other.workspaceId == null) continue;
384
449
  connections.push({
385
450
  entityId: otherId,
386
- entity: { ...other, type: other.profileSlug ?? other.type ?? null, facetSlugs: other.facetSlugs ?? [] },
451
+ // relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
452
+ entity: {
453
+ ...entityWire(other),
454
+ type: other.profileSlug ?? other.type ?? null,
455
+ facetSlugs: rolesOf(other).length > 0 ? facetsOf(other, spaces, ws).map((f) => f.slug) : other.facetSlugs ?? []
456
+ },
387
457
  label: r.type,
388
458
  direction: outgoing ? "outgoing" : "incoming",
389
459
  source: "graph",
@@ -416,7 +486,39 @@ function createMockAuthServer(options = {}) {
416
486
  const entity = /^\/entities\/([^/]+)$/.exec(path);
417
487
  if (entity) {
418
488
  const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
419
- return found ? json(200, found) : new Response(null, { status: 404 });
489
+ return found && readableEntity(found, spaces) ? json(200, entityWire(found)) : new Response(null, { status: 404 });
490
+ }
491
+ const roles = /^\/entities\/([^/]+)\/facets$/.exec(path);
492
+ if (roles) {
493
+ const found = entities.find((e) => e.id === decodeURIComponent(roles[1]));
494
+ if (!found || !readableEntity(found, spaces)) return new Response(null, { status: 404 });
495
+ return json(200, {
496
+ facets: facetsOf(found, spaces).map((f) => ({
497
+ facet: {
498
+ id: f.facetId,
499
+ entityId: found.id,
500
+ profileId: `profile_${f.slug}`,
501
+ userId: found.userId ?? user.id,
502
+ // A stamped facet answers its space; a shared role's facet is stored pod-wide.
503
+ workspaceId: f.sharedRole ? null : f.workspaceId,
504
+ contextEntityId: null,
505
+ status: f.status ?? null,
506
+ statusCategory: null,
507
+ properties: f.properties ?? {},
508
+ metadata: {},
509
+ createdByKind: null,
510
+ createdByUserId: null,
511
+ agentUserId: null,
512
+ sourceProposalId: null,
513
+ correlationId: null,
514
+ createdAt: seededAt,
515
+ updatedAt: seededAt,
516
+ deletedAt: null
517
+ },
518
+ profile: { id: `profile_${f.slug}`, slug: f.slug, profileKind: "role" },
519
+ effectiveProperties: []
520
+ }))
521
+ });
420
522
  }
421
523
  const doc = /^\/documents\/([^/]+)$/.exec(path);
422
524
  if (doc) {
@@ -472,6 +574,12 @@ function createMockAuthServer(options = {}) {
472
574
  return json(403, { error: "Forbidden" });
473
575
  case "door_unsupported":
474
576
  return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
577
+ case "step_up_required":
578
+ return json(403, { error: STEP_UP_MESSAGE });
579
+ case "email_unverified":
580
+ return json(409, { error: "Add and verify an email on your Synap account to connect a pod.", code: "CP_ACCOUNT_EMAIL_REQUIRED", recovery: { action: "update_control_plane_profile" } });
581
+ case "origin_insecure":
582
+ return json(409, { error: "This application must be served over HTTPS to connect to a Pod.", code: "FEDERATED_APPLICATION_ORIGIN_INSECURE" });
475
583
  case "server":
476
584
  return json(500, { error: "Internal error" });
477
585
  case "unavailable":
@@ -482,12 +590,27 @@ function createMockAuthServer(options = {}) {
482
590
  }
483
591
  if (method === "GET" && path === "/users/me") return json(200, user);
484
592
  if (method === "GET" && path === "/entities") {
485
- const ws = query.get("workspaceId");
593
+ const ws = query.get("workspaceId") || void 0;
486
594
  const kind = query.get("profileSlug");
595
+ const role = query.get("facetSlug");
596
+ const podWide = query.get("includePodWide") !== "false";
597
+ const withFacets = query.get("includeFacets") === "true";
487
598
  const offset = Number(query.get("offset") ?? 0) || 0;
488
599
  const limit = Number(query.get("limit") ?? 50) || 50;
489
- const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
490
- return json(200, rows.slice(offset, offset + limit));
600
+ const spaces = readSpaces(row.scope);
601
+ const rows = entities.filter((e) => {
602
+ if (!readableEntity(e, spaces, ws)) return false;
603
+ if (ws && !podWide && e.workspaceId !== ws && !facetsOf(e, spaces, ws).some((f) => !f.sharedRole)) return false;
604
+ const slugs = facetsOf(e, spaces, ws).map((f) => f.slug);
605
+ return (!kind || e.profileSlug === kind || slugs.includes(kind)) && (!role || slugs.includes(role));
606
+ });
607
+ return json(
608
+ 200,
609
+ rows.slice(offset, offset + limit).map((e) => {
610
+ const facets = withFacets ? facetsOf(e, spaces, ws).map(facetRow) : [];
611
+ return facets.length > 0 ? { ...entityWire(e), facetSlugs: facets.map((f) => f.slug), facets } : entityWire(e);
612
+ })
613
+ );
491
614
  }
492
615
  if (method === "GET" && path === "/proposals") {
493
616
  const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
@@ -502,14 +625,15 @@ function createMockAuthServer(options = {}) {
502
625
  return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
503
626
  }
504
627
  if (method === "GET") {
505
- const read = readSeeded(path, query);
628
+ const read = readSeeded(path, query, row.scope);
506
629
  if (read) return read;
507
630
  }
508
631
  const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
509
632
  if (approve) {
510
- if (options.actsAs !== "person") {
633
+ if (actsAs !== "person") {
511
634
  return json(403, { error: "An agent credential cannot approve proposals \u2014 approve is human review. Approve from a human session." });
512
635
  }
636
+ if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
513
637
  const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
514
638
  if (!p) return json(404, { error: "Proposal not found" });
515
639
  if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
@@ -520,11 +644,26 @@ function createMockAuthServer(options = {}) {
520
644
  approvals.push(p.id);
521
645
  return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
522
646
  }
647
+ const attach = method === "POST" ? /^\/entities\/([^/]+)\/facets$/.exec(path) : null;
648
+ const facetPatch = method === "PATCH" ? /^\/facets\/([^/]+)$/.exec(path) : null;
649
+ if (attach) return json(403, { error: "grant does not allow" });
650
+ if (facetPatch) {
651
+ const fid = decodeURIComponent(facetPatch[1]);
652
+ const parent = entities.find((e) => rolesOf(e).some((f) => f.facetId === fid));
653
+ if (parent && parent.workspaceId == null) return json(403, { error: "grant does not allow" });
654
+ }
655
+ const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
656
+ if (patched) {
657
+ const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
658
+ if (target && target.workspaceId == null) {
659
+ return typeof target.userId === "string" && target.userId !== user.id ? new Response(null, { status: 404 }) : json(403, { error: "grant does not allow" });
660
+ }
661
+ }
523
662
  if (method !== "GET") {
524
663
  writes.push({ method, path, body });
525
664
  const fields = body && typeof body === "object" ? body : {};
526
665
  const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
527
- if (writeMode === "proposed") {
666
+ if (writeMode() === "proposed") {
528
667
  const proposalId = `prop_mock_${++proposalSeq}`;
529
668
  proposals.push({
530
669
  id: proposalId,
@@ -641,9 +780,15 @@ function createMockAuthServer(options = {}) {
641
780
  if (scope.length === 0) return finish({ error: "invalid_scope" });
642
781
  if (decision.deny) return finish({ error: "access_denied" });
643
782
  const code = randomToken();
644
- codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope });
783
+ codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope, stepUp: actsAs === "person" && q.get("prompt") === "login" });
645
784
  return finish({ code });
646
785
  },
786
+ setActsAs(v) {
787
+ actsAs = v ?? void 0;
788
+ },
789
+ expireStepUp() {
790
+ deviceFresh = false;
791
+ },
647
792
  consumeRefreshToken(t) {
648
793
  const row = refreshTokens.get(t);
649
794
  if (row) row.used = true;
@@ -697,9 +842,36 @@ function parseMockPodSeed(input) {
697
842
  for (const field of REQUIRED[key]) {
698
843
  if (typeof row[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
699
844
  }
845
+ if (key === "entities") checkFacets(row.facets, `seed.entities[${i}].facets`);
700
846
  });
701
847
  }
702
- return JSON.parse(JSON.stringify(obj));
848
+ obj.entities?.forEach((row, i) => {
849
+ const roles = row.seedRoles;
850
+ if (roles === void 0) return;
851
+ if (!Array.isArray(roles)) throw new TypeError(`seed.entities[${i}].seedRoles: expected an array`);
852
+ roles.forEach((r, j) => {
853
+ for (const field of ["profileSlug", "workspaceId"]) {
854
+ if (!r || typeof r !== "object" || typeof r[field] !== "string") {
855
+ throw new TypeError(`seed.entities[${i}].seedRoles[${j}].${field}: expected a string`);
856
+ }
857
+ }
858
+ });
859
+ });
860
+ const out = JSON.parse(JSON.stringify(obj));
861
+ if (out.entities) out.entities = out.entities.map((row, i) => desugarSeedRoles(row, `seed.entities[${i}]`));
862
+ return out;
863
+ }
864
+ function checkFacets(facets, at) {
865
+ if (facets === void 0) return;
866
+ if (!Array.isArray(facets)) throw new TypeError(`${at}: expected an array`);
867
+ facets.forEach((f, j) => {
868
+ if (!f || typeof f !== "object" || Array.isArray(f)) throw new TypeError(`${at}[${j}]: expected an object`);
869
+ for (const field of ["facetId", "slug", "workspaceId"]) {
870
+ if (typeof f[field] !== "string") throw new TypeError(`${at}[${j}].${field}: expected a string`);
871
+ }
872
+ const shared = f.sharedRole;
873
+ if (shared !== void 0 && typeof shared !== "boolean") throw new TypeError(`${at}[${j}].sharedRole: expected a boolean`);
874
+ });
703
875
  }
704
876
 
705
877
  // src/connect/rules.ts