@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.
@@ -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,19 +57,38 @@
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";
55
65
 
66
+ /** Space -> readable kinds (null = all), what a token's `entity.*.read` grant rows add up to. */
67
+ type ReadSpaces = ReadonlyMap<string, ReadonlySet<string> | null>;
68
+
56
69
  export const MOCK_GRANT: PodGrant[] = [
57
70
  { permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
58
71
  { permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
59
72
  ];
60
73
 
74
+ /** The pod's step-up refusal text (backend `STEP_UP_REQUIRED_MESSAGE`). */
75
+ const STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
76
+
61
77
  export const MOCK_USER: PodUser = { id: "user_mock_ada", name: "Ada Lovelace" };
62
78
 
63
- export type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
79
+ export type MockHubFailure =
80
+ | "rate_limited"
81
+ | "forbidden"
82
+ | "door_unsupported"
83
+ /** 403 `{ error: <the pod's step-up message> }`: a stale device at a credential door (the Hub REST body has no reasonCode). */
84
+ | "step_up_required"
85
+ /** 409 problem `CP_ACCOUNT_EMAIL_REQUIRED` (Control Plane: no verified email). */
86
+ | "email_unverified"
87
+ /** 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE` (Control Plane: an app origin that is not HTTPS). */
88
+ | "origin_insecure"
89
+ | "unreachable"
90
+ | "server"
91
+ | "unavailable";
64
92
 
65
93
  export interface MockAuthServerOptions {
66
94
  issuer?: string;
@@ -95,6 +123,14 @@ export interface MockAuthServerOptions {
95
123
  * not name it; the SDK reads an agent connection).
96
124
  */
97
125
  actsAs?: PodActsAs;
126
+ /**
127
+ * A Synap app's device starts STALE: `POST /proposals/:id/approve` is refused
128
+ * (403 `{ error: <step-up message> }`, the Hub REST shape) until the person
129
+ * re-confirms at the pod: `/authorize?prompt=login`, the code redeemed with the
130
+ * device's refresh token as `synap_device_refresh_token`. Needs `actsAs: "person"`.
131
+ * `expireStepUp()` makes it stale again (the 15-minute window).
132
+ */
133
+ requireStepUp?: boolean;
98
134
  /**
99
135
  * The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
100
136
  * before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
@@ -165,6 +201,10 @@ export interface MockAuthServer {
165
201
  authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
166
202
  /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
167
203
  consumeRefreshToken(token: string): void;
204
+ /** The pod now says `synap_acts_as: v` (null = says nothing): the app left, or joined, the Synap list. */
205
+ setActsAs(v: PodActsAs | null): void;
206
+ /** The device's last real sign-in is older than the step-up window. */
207
+ expireStepUp(): void;
168
208
  /** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
169
209
  loseNextRefreshRace(): void;
170
210
  /** Refresh-token redeems that were reuses (each revoked a family). */
@@ -188,6 +228,8 @@ interface CodeRow {
188
228
  challenge: string;
189
229
  resource: string | null;
190
230
  scope: string[];
231
+ /** `prompt=login` from a Synap app: its code re-confirms a device (and must come with its refresh token). */
232
+ stepUp: boolean;
191
233
  }
192
234
 
193
235
  interface AccessRow {
@@ -236,13 +278,68 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
236
278
  const writes: Array<{ method: string; path: string; body: unknown }> = [];
237
279
  const proposals: MockProposal[] = [];
238
280
  const seed = options.seed ?? {};
239
- const entities: Array<Record<string, unknown>> = [...(seed.entities ?? []), ...(options.entities ?? [])];
281
+ // One role model: a seed is desugared by parseMockPodSeed; `entities` (and a seed built in code) here.
282
+ const entities: Array<Record<string, unknown>> = [...(seed.entities ?? []), ...(options.entities ?? [])].map((e, i) =>
283
+ desugarSeedRoles(e, `entities[${i}]`),
284
+ );
240
285
  const sessions: MockSeedSession[] = [...(seed.sessions ?? [])];
241
286
  const documents: MockSeedDocument[] = [...(seed.documents ?? [])];
242
287
  const relations: MockSeedRelation[] = [...(seed.relations ?? [])];
243
288
  const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug!]));
244
289
  const approvals: string[] = [];
245
290
  const seededAt = new Date(now()).toISOString();
291
+
292
+ /** The roles (facets) an entity wears: the seed's `facets` (`seedRoles` was desugared into them). */
293
+ const rolesOf = (e: Record<string, unknown>): MockSeedFacet[] =>
294
+ Array.isArray(e.facets) ? (e.facets as MockSeedFacet[]).filter((f) => typeof f?.slug === "string") : [];
295
+ /**
296
+ * What the token's grant lets it read of an entity (the pod's grant clause):
297
+ * a row of a granted space, or a pod-wide row (`workspaceId` null) with a live
298
+ * role in one. The pod sends no role field on an entity row, so the mock takes
299
+ * its roles as SEED input (`facets`) and answers them only when asked.
300
+ */
301
+ function readableEntity(e: Record<string, unknown>, spaces: ReadSpaces, only?: string | null): boolean {
302
+ if (typeof e.workspaceId === "string" && kindReadable(e, spaces, e.workspaceId) && (!only || e.workspaceId === only)) return true;
303
+ // 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.
304
+ return facetsOf(e, spaces, only ?? undefined).some((f) => e.workspaceId == null || !f.sharedRole);
305
+ }
306
+ /**
307
+ * The roles of `e` a read sees: what a member of a granted space sees
308
+ * (backend `facetVisibilityConditions` under the token's grant), i.e. a role
309
+ * whose seed space is granted; a workspace lens sees that space's roles only.
310
+ */
311
+ const facetsOf = (e: Record<string, unknown>, spaces: ReadSpaces, lens?: string): MockSeedFacet[] =>
312
+ rolesOf(e).filter((f) => typeof f.workspaceId === "string" && kindReadable(e, spaces, f.workspaceId) && (lens === undefined || f.workspaceId === lens));
313
+ /** backend `FacetRowAnnotation`: what `includeFacets=true` puts on a row. */
314
+ const facetRow = (f: MockSeedFacet) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
315
+ /** A row as the Hub answers it (`entityToWire`): roles ride on it only when asked. */
316
+ const entityWire = (e: Record<string, unknown>): Record<string, unknown> => {
317
+ const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
318
+ return wire;
319
+ };
320
+ /**
321
+ * Space -> the kinds the token reads there (null = every kind), as the pod's
322
+ * kindClause: `entity.<kind>.read` narrows to that kind; `entity.read` and
323
+ * `entity.*.read` read all.
324
+ */
325
+ const readSpaces = (scope: readonly string[]): ReadSpaces => {
326
+ const out = new Map<string, Set<string> | null>();
327
+ for (const g of grant) {
328
+ const m = /^entity\.(?:(.+)\.)?read$/.exec(g.permission);
329
+ if (!m || !scope.includes(g.permission)) continue;
330
+ const kind = m[1];
331
+ if (kind === undefined || kind === "*") out.set(g.workspaceId, null);
332
+ else if (out.get(g.workspaceId) !== null) out.set(g.workspaceId, (out.get(g.workspaceId) ?? new Set<string>()).add(kind));
333
+ else continue;
334
+ }
335
+ return out;
336
+ };
337
+ /** Whether the token reads rows of `e`'s kind in `space`. */
338
+ const kindReadable = (e: Record<string, unknown>, spaces: ReadSpaces, space: string): boolean => {
339
+ if (!spaces.has(space)) return false;
340
+ const kinds = spaces.get(space);
341
+ return kinds === null || kinds === undefined ? kinds === null : kinds.has(String(e.profileSlug ?? e.type ?? ""));
342
+ };
246
343
  for (const p of seed.proposals ?? []) {
247
344
  proposals.push({
248
345
  proposalType: "create",
@@ -257,7 +354,11 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
257
354
  ...p,
258
355
  });
259
356
  }
260
- const writeMode = options.writeMode ?? (options.actsAs === "person" ? "applied" : "proposed");
357
+ // What the pod says about the app, re-judged on every token response (`setActsAs` moves it).
358
+ let actsAs = options.actsAs;
359
+ const writeMode = () => options.writeMode ?? (actsAs === "person" ? "applied" : "proposed");
360
+ // A Synap app's device clock: stale until the person re-confirms at the pod (`requireStepUp`).
361
+ let deviceFresh = !options.requireStepUp;
261
362
  const graceMs = (options.refreshGraceSec ?? 30) * 1000;
262
363
  const app = options.app === undefined ? MOCK_APP : options.app;
263
364
  let proposalSeq = 0;
@@ -309,7 +410,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
309
410
  .map((g) => (g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId)! })),
310
411
  synap_user: user,
311
412
  ...(app ? { synap_app: app } : {}),
312
- ...(options.actsAs ? { synap_acts_as: options.actsAs } : {}),
413
+ ...(actsAs ? { synap_acts_as: actsAs } : {}),
313
414
  };
314
415
  if (pair) {
315
416
  body.refresh_token = pair.refreshToken;
@@ -336,6 +437,25 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
336
437
  if (form.resource !== undefined && row.resource && form.resource !== row.resource) {
337
438
  return oauthError("invalid_target", "resource does not match the authorization request");
338
439
  }
440
+ // A Synap app's step-up (`routes.ts`): the code and the device's refresh token must agree.
441
+ if (actsAs === "person" && row.stepUp !== Boolean(form.synap_device_refresh_token)) {
442
+ return oauthError(
443
+ "invalid_grant",
444
+ row.stepUp
445
+ ? "This code re-confirms a device: send its refresh token as synap_device_refresh_token"
446
+ : "This code signs a new device in; a re-confirmation asks with prompt=login",
447
+ );
448
+ }
449
+ if (row.stepUp) {
450
+ const device = refreshTokens.get(form.synap_device_refresh_token!);
451
+ if (!device || device.clientId !== row.clientId || device.used || device.revoked) {
452
+ return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
453
+ }
454
+ // The device keeps its family: its clock moves, nothing else is made or ended.
455
+ device.used = true;
456
+ deviceFresh = true;
457
+ return json(200, tokenBody(row.scope, row.clientId, device.family));
458
+ }
339
459
  // The latest connect wins: a new code ends this client's older families.
340
460
  for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
341
461
  return json(200, tokenBody(row.scope, row.clientId, randomToken()));
@@ -404,11 +524,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
404
524
  });
405
525
 
406
526
  /** The seeded read doors (1.5); null when `path` is none of them. */
407
- function readSeeded(path: string, query: URLSearchParams): Response | null {
527
+ function readSeeded(path: string, query: URLSearchParams, scope: readonly string[]): Response | null {
528
+ const spaces = readSpaces(scope);
408
529
  const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
409
530
  if (conn) {
410
531
  const id = decodeURIComponent(conn[1]);
411
- const ws = query.get("workspaceId");
532
+ const ws = query.get("workspaceId") ?? undefined;
412
533
  const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
413
534
  const inLens = (e: Record<string, unknown>) => !ws || e.workspaceId === ws || e.workspaceId == null;
414
535
  const connections: Array<Record<string, unknown>> = [];
@@ -421,10 +542,18 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
421
542
  const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
422
543
  const other = entities.find((e) => e.id === otherId);
423
544
  // 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;
545
+ if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
546
+ // An edge between two pod-wide records is pod-wide (owner-only): not readable by a space-scoped app.
547
+ const self = entities.find((e) => e.id === id);
548
+ if (self && self.workspaceId == null && other.workspaceId == null) continue;
425
549
  connections.push({
426
550
  entityId: otherId,
427
- entity: { ...other, type: other.profileSlug ?? other.type ?? null, facetSlugs: other.facetSlugs ?? [] },
551
+ // relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
552
+ entity: {
553
+ ...entityWire(other),
554
+ type: other.profileSlug ?? other.type ?? null,
555
+ facetSlugs: rolesOf(other).length > 0 ? facetsOf(other, spaces, ws).map((f) => f.slug) : (other.facetSlugs ?? []),
556
+ },
428
557
  label: r.type,
429
558
  direction: outgoing ? "outgoing" : "incoming",
430
559
  source: "graph",
@@ -457,8 +586,41 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
457
586
  const entity = /^\/entities\/([^/]+)$/.exec(path);
458
587
  if (entity) {
459
588
  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 });
589
+ // 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.
590
+ return found && readableEntity(found, spaces) ? json(200, entityWire(found)) : new Response(null, { status: 404 });
591
+ }
592
+ const roles = /^\/entities\/([^/]+)\/facets$/.exec(path);
593
+ if (roles) {
594
+ const found = entities.find((e) => e.id === decodeURIComponent(roles[1]));
595
+ if (!found || !readableEntity(found, spaces)) return new Response(null, { status: 404 });
596
+ // getEffectiveFacets under the whole floor: each live facet row, its role-profile, that profile's effective properties.
597
+ return json(200, {
598
+ facets: facetsOf(found, spaces).map((f) => ({
599
+ facet: {
600
+ id: f.facetId,
601
+ entityId: found.id,
602
+ profileId: `profile_${f.slug}`,
603
+ userId: found.userId ?? user.id,
604
+ // A stamped facet answers its space; a shared role's facet is stored pod-wide.
605
+ workspaceId: f.sharedRole ? null : f.workspaceId,
606
+ contextEntityId: null,
607
+ status: f.status ?? null,
608
+ statusCategory: null,
609
+ properties: f.properties ?? {},
610
+ metadata: {},
611
+ createdByKind: null,
612
+ createdByUserId: null,
613
+ agentUserId: null,
614
+ sourceProposalId: null,
615
+ correlationId: null,
616
+ createdAt: seededAt,
617
+ updatedAt: seededAt,
618
+ deletedAt: null,
619
+ },
620
+ profile: { id: `profile_${f.slug}`, slug: f.slug, profileKind: "role" },
621
+ effectiveProperties: [],
622
+ })),
623
+ });
462
624
  }
463
625
  const doc = /^\/documents\/([^/]+)$/.exec(path);
464
626
  if (doc) {
@@ -523,6 +685,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
523
685
  return json(403, { error: "Forbidden" });
524
686
  case "door_unsupported":
525
687
  return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
688
+ case "step_up_required":
689
+ return json(403, { error: STEP_UP_MESSAGE });
690
+ case "email_unverified":
691
+ 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" } });
692
+ case "origin_insecure":
693
+ return json(409, { error: "This application must be served over HTTPS to connect to a Pod.", code: "FEDERATED_APPLICATION_ORIGIN_INSECURE" });
526
694
  case "server":
527
695
  return json(500, { error: "Internal error" });
528
696
  case "unavailable":
@@ -535,12 +703,31 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
535
703
  if (method === "GET" && path === "/users/me") return json(200, user);
536
704
  if (method === "GET" && path === "/entities") {
537
705
  // The pod answers an ARRAY (rest/entities.ts), not a page object.
538
- const ws = query.get("workspaceId");
706
+ const ws = query.get("workspaceId") || undefined;
539
707
  const kind = query.get("profileSlug");
708
+ const role = query.get("facetSlug");
709
+ // A workspace lens keeps the pod-wide rows unless told not to (rest/entities.ts: `!== "false"`).
710
+ const podWide = query.get("includePodWide") !== "false";
711
+ const withFacets = query.get("includeFacets") === "true";
540
712
  const offset = Number(query.get("offset") ?? 0) || 0;
541
713
  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));
714
+ // Granted spaces' rows, plus the pod-wide rows roled in them (read-only, as a member of the space).
715
+ const spaces = readSpaces(row.scope);
716
+ const rows = entities.filter((e) => {
717
+ if (!readableEntity(e, spaces, ws)) return false;
718
+ // includePodWide=false keeps the rows with a role STAMPED in the space; it drops those reached only through a shared role.
719
+ if (ws && !podWide && e.workspaceId !== ws && !facetsOf(e, spaces, ws).some((f) => !f.sharedRole)) return false;
720
+ const slugs = facetsOf(e, spaces, ws).map((f) => f.slug);
721
+ // A profileSlug naming a role matches its wearers too (entities.list's facet branch).
722
+ return (!kind || e.profileSlug === kind || slugs.includes(kind)) && (!role || slugs.includes(role));
723
+ });
724
+ return json(
725
+ 200,
726
+ rows.slice(offset, offset + limit).map((e) => {
727
+ const facets = withFacets ? facetsOf(e, spaces, ws).map(facetRow) : [];
728
+ return facets.length > 0 ? { ...entityWire(e), facetSlugs: facets.map((f) => f.slug), facets } : entityWire(e);
729
+ }),
730
+ );
544
731
  }
545
732
  if (method === "GET" && path === "/proposals") {
546
733
  // The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
@@ -561,15 +748,16 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
561
748
  return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
562
749
  }
563
750
  if (method === "GET") {
564
- const read = readSeeded(path, query);
751
+ const read = readSeeded(path, query, row.scope);
565
752
  if (read) return read;
566
753
  }
567
754
  const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
568
755
  if (approve) {
569
756
  // rejectAgentReviewer: an agent credential never approves, before anything is read.
570
- if (options.actsAs !== "person") {
757
+ if (actsAs !== "person") {
571
758
  return json(403, { error: "An agent credential cannot approve proposals — approve is human review. Approve from a human session." });
572
759
  }
760
+ if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
573
761
  const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
574
762
  if (!p) return json(404, { error: "Proposal not found" });
575
763
  // Only where the person granted proposal.approve, for the proposal's own space.
@@ -581,11 +769,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
581
769
  approvals.push(p.id);
582
770
  return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
583
771
  }
772
+ // Roles: an app cannot add one to an existing entity (no grantable `facet.attach`), nor change one on a pod-wide record (`permits()` refuses null).
773
+ const attach = method === "POST" ? /^\/entities\/([^/]+)\/facets$/.exec(path) : null;
774
+ const facetPatch = method === "PATCH" ? /^\/facets\/([^/]+)$/.exec(path) : null;
775
+ if (attach) return json(403, { error: "grant does not allow" });
776
+ if (facetPatch) {
777
+ const fid = decodeURIComponent(facetPatch[1]);
778
+ const parent = entities.find((e) => rolesOf(e).some((f) => f.facetId === fid));
779
+ if (parent && parent.workspaceId == null) return json(403, { error: "grant does not allow" });
780
+ }
781
+ // A pod-wide row's core fields are outside a space-scoped grant: refused, nothing written, no proposal.
782
+ // The pod: 403 "grant does not allow" on the connected person's own record, 404 on a teammate's (outside the write floor).
783
+ const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
784
+ if (patched) {
785
+ const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
786
+ if (target && target.workspaceId == null) {
787
+ return typeof target.userId === "string" && target.userId !== user.id ? new Response(null, { status: 404 }) : json(403, { error: "grant does not allow" });
788
+ }
789
+ }
584
790
  if (method !== "GET") {
585
791
  writes.push({ method, path, body });
586
792
  const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
587
793
  const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
588
- if (writeMode === "proposed") {
794
+ if (writeMode() === "proposed") {
589
795
  const proposalId = `prop_mock_${++proposalSeq}`;
590
796
  proposals.push({
591
797
  id: proposalId,
@@ -706,9 +912,15 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
706
912
  if (scope.length === 0) return finish({ error: "invalid_scope" });
707
913
  if (decision.deny) return finish({ error: "access_denied" });
708
914
  const code = randomToken();
709
- codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge")!, resource, scope });
915
+ codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge")!, resource, scope, stepUp: actsAs === "person" && q.get("prompt") === "login" });
710
916
  return finish({ code });
711
917
  },
918
+ setActsAs(v) {
919
+ actsAs = v ?? undefined;
920
+ },
921
+ expireStepUp() {
922
+ deviceFresh = false;
923
+ },
712
924
  consumeRefreshToken(t) {
713
925
  const row = refreshTokens.get(t);
714
926
  if (row) row.used = true;
@@ -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
  }