@synap-core/sdk 1.5.1 → 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.
@@ -172,7 +172,13 @@ declare function parseMockPodSeed(input: string | unknown): MockPodSeed;
172
172
  declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
173
173
  declare const MOCK_GRANT: PodGrant[];
174
174
  declare const MOCK_USER: PodUser;
175
- 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";
176
182
  interface MockAuthServerOptions {
177
183
  issuer?: string;
178
184
  /** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
@@ -209,6 +215,14 @@ interface MockAuthServerOptions {
209
215
  * not name it; the SDK reads an agent connection).
210
216
  */
211
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;
212
226
  /**
213
227
  * The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
214
228
  * before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
@@ -284,6 +298,10 @@ interface MockAuthServer {
284
298
  authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
285
299
  /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
286
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;
287
305
  /** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
288
306
  loseNextRefreshRace(): void;
289
307
  /** Refresh-token redeems that were reuses (each revoked a family). */
package/dist/testing.d.ts CHANGED
@@ -172,7 +172,13 @@ declare function parseMockPodSeed(input: string | unknown): MockPodSeed;
172
172
  declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
173
173
  declare const MOCK_GRANT: PodGrant[];
174
174
  declare const MOCK_USER: PodUser;
175
- 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";
176
182
  interface MockAuthServerOptions {
177
183
  issuer?: string;
178
184
  /** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
@@ -209,6 +215,14 @@ interface MockAuthServerOptions {
209
215
  * not name it; the SDK reads an agent connection).
210
216
  */
211
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;
212
226
  /**
213
227
  * The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
214
228
  * before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
@@ -284,6 +298,10 @@ interface MockAuthServer {
284
298
  authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
285
299
  /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
286
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;
287
305
  /** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
288
306
  loseNextRefreshRace(): void;
289
307
  /** Refresh-token redeems that were reuses (each revoked a family). */
package/dist/testing.js CHANGED
@@ -140,6 +140,7 @@ var MOCK_GRANT = [
140
140
  { permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
141
141
  { permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
142
142
  ];
143
+ var STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
143
144
  var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
144
145
  var MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
145
146
  var MOCK_APP_AGENT_ID = "agent_mock_app";
@@ -178,16 +179,32 @@ function createMockAuthServer(options = {}) {
178
179
  const seededAt = new Date(now()).toISOString();
179
180
  const rolesOf = (e) => Array.isArray(e.facets) ? e.facets.filter((f) => typeof f?.slug === "string") : [];
180
181
  function readableEntity(e, spaces, only) {
181
- if (typeof e.workspaceId === "string" && spaces.has(e.workspaceId) && (!only || e.workspaceId === only)) return true;
182
+ if (typeof e.workspaceId === "string" && kindReadable(e, spaces, e.workspaceId) && (!only || e.workspaceId === only)) return true;
182
183
  return facetsOf(e, spaces, only ?? void 0).some((f) => e.workspaceId == null || !f.sharedRole);
183
184
  }
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 facetsOf = (e, spaces, lens) => rolesOf(e).filter((f) => typeof f.workspaceId === "string" && kindReadable(e, spaces, f.workspaceId) && (lens === void 0 || f.workspaceId === lens));
185
186
  const facetRow = (f) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
186
187
  const entityWire = (e) => {
187
188
  const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
188
189
  return wire;
189
190
  };
190
- const readSpaces = (scope) => new Set(grant.filter((g) => /^entity\.(.+\.)?read$/.test(g.permission) && scope.includes(g.permission)).map((g) => g.workspaceId));
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
+ };
191
208
  for (const p of seed.proposals ?? []) {
192
209
  proposals.push({
193
210
  proposalType: "create",
@@ -202,7 +219,9 @@ function createMockAuthServer(options = {}) {
202
219
  ...p
203
220
  });
204
221
  }
205
- 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;
206
225
  const graceMs = (options.refreshGraceSec ?? 30) * 1e3;
207
226
  const app = options.app === void 0 ? MOCK_APP : options.app;
208
227
  let proposalSeq = 0;
@@ -248,7 +267,7 @@ function createMockAuthServer(options = {}) {
248
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) }),
249
268
  synap_user: user,
250
269
  ...app ? { synap_app: app } : {},
251
- ...options.actsAs ? { synap_acts_as: options.actsAs } : {}
270
+ ...actsAs ? { synap_acts_as: actsAs } : {}
252
271
  };
253
272
  if (pair) {
254
273
  body.refresh_token = pair.refreshToken;
@@ -274,6 +293,21 @@ function createMockAuthServer(options = {}) {
274
293
  if (form.resource !== void 0 && row.resource && form.resource !== row.resource) {
275
294
  return oauthError("invalid_target", "resource does not match the authorization request");
276
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
+ }
277
311
  for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
278
312
  return json(200, tokenBody(row.scope, row.clientId, randomToken()));
279
313
  }
@@ -350,6 +384,8 @@ function createMockAuthServer(options = {}) {
350
384
  const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
351
385
  const other = entities.find((e) => e.id === otherId);
352
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;
353
389
  connections.push({
354
390
  entityId: otherId,
355
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).
@@ -478,6 +514,12 @@ function createMockAuthServer(options = {}) {
478
514
  return json(403, { error: "Forbidden" });
479
515
  case "door_unsupported":
480
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" });
481
523
  case "server":
482
524
  return json(500, { error: "Internal error" });
483
525
  case "unavailable":
@@ -528,9 +570,10 @@ function createMockAuthServer(options = {}) {
528
570
  }
529
571
  const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
530
572
  if (approve) {
531
- if (options.actsAs !== "person") {
573
+ if (actsAs !== "person") {
532
574
  return json(403, { error: "An agent credential cannot approve proposals \u2014 approve is human review. Approve from a human session." });
533
575
  }
576
+ if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
534
577
  const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
535
578
  if (!p) return json(404, { error: "Proposal not found" });
536
579
  if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
@@ -541,16 +584,26 @@ function createMockAuthServer(options = {}) {
541
584
  approvals.push(p.id);
542
585
  return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
543
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
+ }
544
595
  const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
545
596
  if (patched) {
546
597
  const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
547
- if (target && target.workspaceId == null) return json(403, { error: "grant does not allow" });
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
+ }
548
601
  }
549
602
  if (method !== "GET") {
550
603
  writes.push({ method, path, body });
551
604
  const fields = body && typeof body === "object" ? body : {};
552
605
  const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
553
- if (writeMode === "proposed") {
606
+ if (writeMode() === "proposed") {
554
607
  const proposalId = `prop_mock_${++proposalSeq}`;
555
608
  proposals.push({
556
609
  id: proposalId,
@@ -667,9 +720,15 @@ function createMockAuthServer(options = {}) {
667
720
  if (scope.length === 0) return finish({ error: "invalid_scope" });
668
721
  if (decision.deny) return finish({ error: "access_denied" });
669
722
  const code = randomToken();
670
- 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" });
671
724
  return finish({ code });
672
725
  },
726
+ setActsAs(v) {
727
+ actsAs = v ?? void 0;
728
+ },
729
+ expireStepUp() {
730
+ deviceFresh = false;
731
+ },
673
732
  consumeRefreshToken(t) {
674
733
  const row = refreshTokens.get(t);
675
734
  if (row) row.used = true;