@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.
@@ -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 {
@@ -123,7 +172,13 @@ declare function parseMockPodSeed(input: string | unknown): MockPodSeed;
123
172
  declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
124
173
  declare const MOCK_GRANT: PodGrant[];
125
174
  declare const MOCK_USER: PodUser;
126
- type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
175
+ type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
176
+ /** 403 `{ error: <the pod's step-up message> }`: a stale device at a credential door (the Hub REST body has no reasonCode). */
177
+ | "step_up_required"
178
+ /** 409 problem `CP_ACCOUNT_EMAIL_REQUIRED` (Control Plane: no verified email). */
179
+ | "email_unverified"
180
+ /** 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE` (Control Plane: an app origin that is not HTTPS). */
181
+ | "origin_insecure" | "unreachable" | "server" | "unavailable";
127
182
  interface MockAuthServerOptions {
128
183
  issuer?: string;
129
184
  /** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
@@ -160,6 +215,14 @@ interface MockAuthServerOptions {
160
215
  * not name it; the SDK reads an agent connection).
161
216
  */
162
217
  actsAs?: PodActsAs;
218
+ /**
219
+ * A Synap app's device starts STALE: `POST /proposals/:id/approve` is refused
220
+ * (403 `{ error: <step-up message> }`, the Hub REST shape) until the person
221
+ * re-confirms at the pod: `/authorize?prompt=login`, the code redeemed with the
222
+ * device's refresh token as `synap_device_refresh_token`. Needs `actsAs: "person"`.
223
+ * `expireStepUp()` makes it stale again (the 15-minute window).
224
+ */
225
+ requireStepUp?: boolean;
163
226
  /**
164
227
  * The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
165
228
  * before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
@@ -235,6 +298,10 @@ interface MockAuthServer {
235
298
  authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
236
299
  /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
237
300
  consumeRefreshToken(token: string): void;
301
+ /** The pod now says `synap_acts_as: v` (null = says nothing): the app left, or joined, the Synap list. */
302
+ setActsAs(v: PodActsAs | null): void;
303
+ /** The device's last real sign-in is older than the step-up window. */
304
+ expireStepUp(): void;
238
305
  /** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
239
306
  loseNextRefreshRace(): void;
240
307
  /** Refresh-token redeems that were reuses (each revoked a family). */
@@ -361,4 +428,4 @@ interface MockPod {
361
428
  }
362
429
  declare function createMockPod(options?: MockPodOptions): MockPod;
363
430
 
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 };
431
+ 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 {
@@ -123,7 +172,13 @@ declare function parseMockPodSeed(input: string | unknown): MockPodSeed;
123
172
  declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
124
173
  declare const MOCK_GRANT: PodGrant[];
125
174
  declare const MOCK_USER: PodUser;
126
- type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
175
+ type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
176
+ /** 403 `{ error: <the pod's step-up message> }`: a stale device at a credential door (the Hub REST body has no reasonCode). */
177
+ | "step_up_required"
178
+ /** 409 problem `CP_ACCOUNT_EMAIL_REQUIRED` (Control Plane: no verified email). */
179
+ | "email_unverified"
180
+ /** 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE` (Control Plane: an app origin that is not HTTPS). */
181
+ | "origin_insecure" | "unreachable" | "server" | "unavailable";
127
182
  interface MockAuthServerOptions {
128
183
  issuer?: string;
129
184
  /** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
@@ -160,6 +215,14 @@ interface MockAuthServerOptions {
160
215
  * not name it; the SDK reads an agent connection).
161
216
  */
162
217
  actsAs?: PodActsAs;
218
+ /**
219
+ * A Synap app's device starts STALE: `POST /proposals/:id/approve` is refused
220
+ * (403 `{ error: <step-up message> }`, the Hub REST shape) until the person
221
+ * re-confirms at the pod: `/authorize?prompt=login`, the code redeemed with the
222
+ * device's refresh token as `synap_device_refresh_token`. Needs `actsAs: "person"`.
223
+ * `expireStepUp()` makes it stale again (the 15-minute window).
224
+ */
225
+ requireStepUp?: boolean;
163
226
  /**
164
227
  * The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
165
228
  * before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
@@ -235,6 +298,10 @@ interface MockAuthServer {
235
298
  authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
236
299
  /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
237
300
  consumeRefreshToken(token: string): void;
301
+ /** The pod now says `synap_acts_as: v` (null = says nothing): the app left, or joined, the Synap list. */
302
+ setActsAs(v: PodActsAs | null): void;
303
+ /** The device's last real sign-in is older than the step-up window. */
304
+ expireStepUp(): void;
238
305
  /** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
239
306
  loseNextRefreshRace(): void;
240
307
  /** Refresh-token redeems that were reuses (each revoked a family). */
@@ -361,4 +428,4 @@ interface MockPod {
361
428
  }
362
429
  declare function createMockPod(options?: MockPodOptions): MockPod;
363
430
 
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 };
431
+ 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,12 +120,27 @@ 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 = [
126
140
  { permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
127
141
  { permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
128
142
  ];
143
+ var STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
129
144
  var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
130
145
  var MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
131
146
  var MOCK_APP_AGENT_ID = "agent_mock_app";
@@ -153,13 +168,43 @@ function createMockAuthServer(options = {}) {
153
168
  const writes = [];
154
169
  const proposals = [];
155
170
  const seed = options.seed ?? {};
156
- const entities = [...seed.entities ?? [], ...options.entities ?? []];
171
+ const entities = [...seed.entities ?? [], ...options.entities ?? []].map(
172
+ (e, i) => desugarSeedRoles(e, `entities[${i}]`)
173
+ );
157
174
  const sessions = [...seed.sessions ?? []];
158
175
  const documents = [...seed.documents ?? []];
159
176
  const relations = [...seed.relations ?? []];
160
177
  const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug]));
161
178
  const approvals = [];
162
179
  const seededAt = new Date(now()).toISOString();
180
+ const rolesOf = (e) => Array.isArray(e.facets) ? e.facets.filter((f) => typeof f?.slug === "string") : [];
181
+ function readableEntity(e, spaces, only) {
182
+ if (typeof e.workspaceId === "string" && kindReadable(e, spaces, e.workspaceId) && (!only || e.workspaceId === only)) return true;
183
+ return facetsOf(e, spaces, only ?? void 0).some((f) => e.workspaceId == null || !f.sharedRole);
184
+ }
185
+ const facetsOf = (e, spaces, lens) => rolesOf(e).filter((f) => typeof f.workspaceId === "string" && kindReadable(e, spaces, f.workspaceId) && (lens === void 0 || f.workspaceId === lens));
186
+ const facetRow = (f) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
187
+ const entityWire = (e) => {
188
+ const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
189
+ return wire;
190
+ };
191
+ const readSpaces = (scope) => {
192
+ const out = /* @__PURE__ */ new Map();
193
+ for (const g of grant) {
194
+ const m = /^entity\.(?:(.+)\.)?read$/.exec(g.permission);
195
+ if (!m || !scope.includes(g.permission)) continue;
196
+ const kind = m[1];
197
+ if (kind === void 0 || kind === "*") out.set(g.workspaceId, null);
198
+ else if (out.get(g.workspaceId) !== null) out.set(g.workspaceId, (out.get(g.workspaceId) ?? /* @__PURE__ */ new Set()).add(kind));
199
+ else continue;
200
+ }
201
+ return out;
202
+ };
203
+ const kindReadable = (e, spaces, space) => {
204
+ if (!spaces.has(space)) return false;
205
+ const kinds = spaces.get(space);
206
+ return kinds === null || kinds === void 0 ? kinds === null : kinds.has(String(e.profileSlug ?? e.type ?? ""));
207
+ };
163
208
  for (const p of seed.proposals ?? []) {
164
209
  proposals.push({
165
210
  proposalType: "create",
@@ -174,7 +219,9 @@ function createMockAuthServer(options = {}) {
174
219
  ...p
175
220
  });
176
221
  }
177
- const writeMode = options.writeMode ?? (options.actsAs === "person" ? "applied" : "proposed");
222
+ let actsAs = options.actsAs;
223
+ const writeMode = () => options.writeMode ?? (actsAs === "person" ? "applied" : "proposed");
224
+ let deviceFresh = !options.requireStepUp;
178
225
  const graceMs = (options.refreshGraceSec ?? 30) * 1e3;
179
226
  const app = options.app === void 0 ? MOCK_APP : options.app;
180
227
  let proposalSeq = 0;
@@ -220,7 +267,7 @@ function createMockAuthServer(options = {}) {
220
267
  synap_grant: grant.filter((g) => scope.includes(g.permission)).map((g) => g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId) }),
221
268
  synap_user: user,
222
269
  ...app ? { synap_app: app } : {},
223
- ...options.actsAs ? { synap_acts_as: options.actsAs } : {}
270
+ ...actsAs ? { synap_acts_as: actsAs } : {}
224
271
  };
225
272
  if (pair) {
226
273
  body.refresh_token = pair.refreshToken;
@@ -246,6 +293,21 @@ function createMockAuthServer(options = {}) {
246
293
  if (form.resource !== void 0 && row.resource && form.resource !== row.resource) {
247
294
  return oauthError("invalid_target", "resource does not match the authorization request");
248
295
  }
296
+ if (actsAs === "person" && row.stepUp !== Boolean(form.synap_device_refresh_token)) {
297
+ return oauthError(
298
+ "invalid_grant",
299
+ 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"
300
+ );
301
+ }
302
+ if (row.stepUp) {
303
+ const device = refreshTokens.get(form.synap_device_refresh_token);
304
+ if (!device || device.clientId !== row.clientId || device.used || device.revoked) {
305
+ return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
306
+ }
307
+ device.used = true;
308
+ deviceFresh = true;
309
+ return json(200, tokenBody(row.scope, row.clientId, device.family));
310
+ }
249
311
  for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
250
312
  return json(200, tokenBody(row.scope, row.clientId, randomToken()));
251
313
  }
@@ -307,11 +369,12 @@ function createMockAuthServer(options = {}) {
307
369
  triage: { pending: false, acceptedAt: null, acceptedBy: null },
308
370
  ...s
309
371
  });
310
- function readSeeded(path, query) {
372
+ function readSeeded(path, query, scope) {
373
+ const spaces = readSpaces(scope);
311
374
  const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
312
375
  if (conn) {
313
376
  const id = decodeURIComponent(conn[1]);
314
- const ws = query.get("workspaceId");
377
+ const ws = query.get("workspaceId") ?? void 0;
315
378
  const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
316
379
  const inLens = (e) => !ws || e.workspaceId === ws || e.workspaceId == null;
317
380
  const connections = [];
@@ -320,10 +383,17 @@ function createMockAuthServer(options = {}) {
320
383
  const outgoing = r.sourceEntityId === id;
321
384
  const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
322
385
  const other = entities.find((e) => e.id === otherId);
323
- if (!other || !inLens(other)) continue;
386
+ if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
387
+ const self = entities.find((e) => e.id === id);
388
+ if (self && self.workspaceId == null && other.workspaceId == null) continue;
324
389
  connections.push({
325
390
  entityId: otherId,
326
- entity: { ...other, type: other.profileSlug ?? other.type ?? null, facetSlugs: other.facetSlugs ?? [] },
391
+ // relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
392
+ entity: {
393
+ ...entityWire(other),
394
+ type: other.profileSlug ?? other.type ?? null,
395
+ facetSlugs: rolesOf(other).length > 0 ? facetsOf(other, spaces, ws).map((f) => f.slug) : other.facetSlugs ?? []
396
+ },
327
397
  label: r.type,
328
398
  direction: outgoing ? "outgoing" : "incoming",
329
399
  source: "graph",
@@ -356,7 +426,39 @@ function createMockAuthServer(options = {}) {
356
426
  const entity = /^\/entities\/([^/]+)$/.exec(path);
357
427
  if (entity) {
358
428
  const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
359
- return found ? json(200, found) : new Response(null, { status: 404 });
429
+ return found && readableEntity(found, spaces) ? json(200, entityWire(found)) : new Response(null, { status: 404 });
430
+ }
431
+ const roles = /^\/entities\/([^/]+)\/facets$/.exec(path);
432
+ if (roles) {
433
+ const found = entities.find((e) => e.id === decodeURIComponent(roles[1]));
434
+ if (!found || !readableEntity(found, spaces)) return new Response(null, { status: 404 });
435
+ return json(200, {
436
+ facets: facetsOf(found, spaces).map((f) => ({
437
+ facet: {
438
+ id: f.facetId,
439
+ entityId: found.id,
440
+ profileId: `profile_${f.slug}`,
441
+ userId: found.userId ?? user.id,
442
+ // A stamped facet answers its space; a shared role's facet is stored pod-wide.
443
+ workspaceId: f.sharedRole ? null : f.workspaceId,
444
+ contextEntityId: null,
445
+ status: f.status ?? null,
446
+ statusCategory: null,
447
+ properties: f.properties ?? {},
448
+ metadata: {},
449
+ createdByKind: null,
450
+ createdByUserId: null,
451
+ agentUserId: null,
452
+ sourceProposalId: null,
453
+ correlationId: null,
454
+ createdAt: seededAt,
455
+ updatedAt: seededAt,
456
+ deletedAt: null
457
+ },
458
+ profile: { id: `profile_${f.slug}`, slug: f.slug, profileKind: "role" },
459
+ effectiveProperties: []
460
+ }))
461
+ });
360
462
  }
361
463
  const doc = /^\/documents\/([^/]+)$/.exec(path);
362
464
  if (doc) {
@@ -412,6 +514,12 @@ function createMockAuthServer(options = {}) {
412
514
  return json(403, { error: "Forbidden" });
413
515
  case "door_unsupported":
414
516
  return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
517
+ case "step_up_required":
518
+ return json(403, { error: STEP_UP_MESSAGE });
519
+ case "email_unverified":
520
+ 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" } });
521
+ case "origin_insecure":
522
+ return json(409, { error: "This application must be served over HTTPS to connect to a Pod.", code: "FEDERATED_APPLICATION_ORIGIN_INSECURE" });
415
523
  case "server":
416
524
  return json(500, { error: "Internal error" });
417
525
  case "unavailable":
@@ -422,12 +530,27 @@ function createMockAuthServer(options = {}) {
422
530
  }
423
531
  if (method === "GET" && path === "/users/me") return json(200, user);
424
532
  if (method === "GET" && path === "/entities") {
425
- const ws = query.get("workspaceId");
533
+ const ws = query.get("workspaceId") || void 0;
426
534
  const kind = query.get("profileSlug");
535
+ const role = query.get("facetSlug");
536
+ const podWide = query.get("includePodWide") !== "false";
537
+ const withFacets = query.get("includeFacets") === "true";
427
538
  const offset = Number(query.get("offset") ?? 0) || 0;
428
539
  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));
540
+ const spaces = readSpaces(row.scope);
541
+ const rows = entities.filter((e) => {
542
+ if (!readableEntity(e, spaces, ws)) return false;
543
+ if (ws && !podWide && e.workspaceId !== ws && !facetsOf(e, spaces, ws).some((f) => !f.sharedRole)) return false;
544
+ const slugs = facetsOf(e, spaces, ws).map((f) => f.slug);
545
+ return (!kind || e.profileSlug === kind || slugs.includes(kind)) && (!role || slugs.includes(role));
546
+ });
547
+ return json(
548
+ 200,
549
+ rows.slice(offset, offset + limit).map((e) => {
550
+ const facets = withFacets ? facetsOf(e, spaces, ws).map(facetRow) : [];
551
+ return facets.length > 0 ? { ...entityWire(e), facetSlugs: facets.map((f) => f.slug), facets } : entityWire(e);
552
+ })
553
+ );
431
554
  }
432
555
  if (method === "GET" && path === "/proposals") {
433
556
  const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
@@ -442,14 +565,15 @@ function createMockAuthServer(options = {}) {
442
565
  return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
443
566
  }
444
567
  if (method === "GET") {
445
- const read = readSeeded(path, query);
568
+ const read = readSeeded(path, query, row.scope);
446
569
  if (read) return read;
447
570
  }
448
571
  const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
449
572
  if (approve) {
450
- if (options.actsAs !== "person") {
573
+ if (actsAs !== "person") {
451
574
  return json(403, { error: "An agent credential cannot approve proposals \u2014 approve is human review. Approve from a human session." });
452
575
  }
576
+ if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
453
577
  const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
454
578
  if (!p) return json(404, { error: "Proposal not found" });
455
579
  if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
@@ -460,11 +584,26 @@ function createMockAuthServer(options = {}) {
460
584
  approvals.push(p.id);
461
585
  return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
462
586
  }
587
+ const attach = method === "POST" ? /^\/entities\/([^/]+)\/facets$/.exec(path) : null;
588
+ const facetPatch = method === "PATCH" ? /^\/facets\/([^/]+)$/.exec(path) : null;
589
+ if (attach) return json(403, { error: "grant does not allow" });
590
+ if (facetPatch) {
591
+ const fid = decodeURIComponent(facetPatch[1]);
592
+ const parent = entities.find((e) => rolesOf(e).some((f) => f.facetId === fid));
593
+ if (parent && parent.workspaceId == null) return json(403, { error: "grant does not allow" });
594
+ }
595
+ const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
596
+ if (patched) {
597
+ const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
598
+ if (target && target.workspaceId == null) {
599
+ return typeof target.userId === "string" && target.userId !== user.id ? new Response(null, { status: 404 }) : json(403, { error: "grant does not allow" });
600
+ }
601
+ }
463
602
  if (method !== "GET") {
464
603
  writes.push({ method, path, body });
465
604
  const fields = body && typeof body === "object" ? body : {};
466
605
  const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
467
- if (writeMode === "proposed") {
606
+ if (writeMode() === "proposed") {
468
607
  const proposalId = `prop_mock_${++proposalSeq}`;
469
608
  proposals.push({
470
609
  id: proposalId,
@@ -581,9 +720,15 @@ function createMockAuthServer(options = {}) {
581
720
  if (scope.length === 0) return finish({ error: "invalid_scope" });
582
721
  if (decision.deny) return finish({ error: "access_denied" });
583
722
  const code = randomToken();
584
- codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope });
723
+ codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope, stepUp: actsAs === "person" && q.get("prompt") === "login" });
585
724
  return finish({ code });
586
725
  },
726
+ setActsAs(v) {
727
+ actsAs = v ?? void 0;
728
+ },
729
+ expireStepUp() {
730
+ deviceFresh = false;
731
+ },
587
732
  consumeRefreshToken(t) {
588
733
  const row = refreshTokens.get(t);
589
734
  if (row) row.used = true;
@@ -637,9 +782,36 @@ function parseMockPodSeed(input) {
637
782
  for (const field of REQUIRED[key]) {
638
783
  if (typeof row[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
639
784
  }
785
+ if (key === "entities") checkFacets(row.facets, `seed.entities[${i}].facets`);
640
786
  });
641
787
  }
642
- return JSON.parse(JSON.stringify(obj));
788
+ obj.entities?.forEach((row, i) => {
789
+ const roles = row.seedRoles;
790
+ if (roles === void 0) return;
791
+ if (!Array.isArray(roles)) throw new TypeError(`seed.entities[${i}].seedRoles: expected an array`);
792
+ roles.forEach((r, j) => {
793
+ for (const field of ["profileSlug", "workspaceId"]) {
794
+ if (!r || typeof r !== "object" || typeof r[field] !== "string") {
795
+ throw new TypeError(`seed.entities[${i}].seedRoles[${j}].${field}: expected a string`);
796
+ }
797
+ }
798
+ });
799
+ });
800
+ const out = JSON.parse(JSON.stringify(obj));
801
+ if (out.entities) out.entities = out.entities.map((row, i) => desugarSeedRoles(row, `seed.entities[${i}]`));
802
+ return out;
803
+ }
804
+ function checkFacets(facets, at) {
805
+ if (facets === void 0) return;
806
+ if (!Array.isArray(facets)) throw new TypeError(`${at}: expected an array`);
807
+ facets.forEach((f, j) => {
808
+ if (!f || typeof f !== "object" || Array.isArray(f)) throw new TypeError(`${at}[${j}]: expected an object`);
809
+ for (const field of ["facetId", "slug", "workspaceId"]) {
810
+ if (typeof f[field] !== "string") throw new TypeError(`${at}[${j}].${field}: expected a string`);
811
+ }
812
+ const shared = f.sharedRole;
813
+ if (shared !== void 0 && typeof shared !== "boolean") throw new TypeError(`${at}[${j}].sharedRole: expected a boolean`);
814
+ });
643
815
  }
644
816
 
645
817
  // src/testing/cloud.ts