@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/sdk",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Type-safe tRPC client SDK for Synap data pods, public-door client, and server-only pod-app sign-in",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -55,11 +55,17 @@
55
55
  "dist",
56
56
  "src",
57
57
  "!src/__tests__",
58
- "CHANGELOG.md"
58
+ "CHANGELOG.md",
59
+ "AGENTS.md"
59
60
  ],
60
61
  "engines": {
61
62
  "node": ">=20"
62
63
  },
64
+ "scripts": {
65
+ "build": "tsup",
66
+ "typecheck": "tsc --noEmit",
67
+ "test": "vitest run"
68
+ },
63
69
  "keywords": [
64
70
  "synap",
65
71
  "sdk",
@@ -91,10 +97,5 @@
91
97
  "url": "git+https://github.com/Synap-core/app-bases.git",
92
98
  "directory": "packages/sdk"
93
99
  },
94
- "license": "MIT",
95
- "scripts": {
96
- "build": "tsup",
97
- "typecheck": "tsc --noEmit",
98
- "test": "vitest run"
99
- }
100
- }
100
+ "license": "MIT"
101
+ }
@@ -18,6 +18,11 @@
18
18
  * what the app may touch); without it a person already connected to that pod
19
19
  * goes straight to `next`. Either way a sign-in is a fresh PKCE + state flow.
20
20
  *
21
+ * `prompt=login` is the step-up: a credential door refused a stale device
22
+ * (`step_up_required`), so the person re-confirms at the POD (never through
23
+ * Synap Cloud) and the callback redeems the code with the connection's refresh
24
+ * token. Then retry the refused call once.
25
+ *
21
26
  * `connect` and `disconnect` change state, so they are POST and same-origin
22
27
  * only (Origin, else Sec-Fetch-Site): a third-party page cannot start a
23
28
  * sign-in into an attacker's pod for a visitor (login CSRF) or sign them out.
@@ -230,8 +235,10 @@ function isMockModeOrigin(origin: string): boolean {
230
235
  }
231
236
 
232
237
  /**
233
- * One `synap_requests[].permission`, refused here exactly when the pod would
234
- * refuse it: `<subject>[.<kind>].<action>`, lowercase, a kind only on
238
+ * One `synap_requests[].permission`, refused here when the pod would refuse
239
+ * it (at most as strict: any `https://<name>.synap.live` passes the review-verb
240
+ * check here, while the pod admits only its own list of Synap app ids, which
241
+ * this SDK does not copy; the pod's answer is the authority): `<subject>[.<kind>].<action>`, lowercase, a kind only on
235
242
  * `entity`, an action in the pod's grant actions (the review verbs only for a
236
243
  * Synap app, or on mock mode's loopback origin). Stricter on one point: no `*`
237
244
  * at all (`can()` matches exactly).
@@ -418,7 +425,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
418
425
  };
419
426
 
420
427
  /** Off to the pod's consent: fresh PKCE + state, the transaction cookie set (extra cookies first). */
421
- async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | null }, extra: string[] = []) {
428
+ async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | "login" | null }, extra: string[] = []) {
422
429
  const started = await startConnect({
423
430
  ...flow,
424
431
  pod: opts.pod,
@@ -458,7 +465,9 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
458
465
  const pod = formField(form, "pod")?.slice(0, 512) ?? "";
459
466
  const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
460
467
  const space = formField(form, "space");
461
- const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
468
+ const asked = formField(form, "prompt");
469
+ // `login` is a step-up of the pod's own sign-in; it never goes through Synap Cloud (a Cloud sign-in never counts).
470
+ const prompt = asked === "consent" || asked === "login" ? asked : null;
462
471
  try {
463
472
  if (!prompt && (await liveConnectionTo(request, pod))) return redirect(new URL(next, origin).href);
464
473
  return await toPod({ pod, next, space, prompt });
@@ -476,6 +485,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
476
485
  ...flow,
477
486
  callbackUrl: request.url,
478
487
  transaction,
488
+ current: await store.read(request).catch(() => null),
479
489
  clientId,
480
490
  redirectUri,
481
491
  secret: config.secret,
@@ -66,8 +66,29 @@ export type PodErrorCode =
66
66
  | "pod_unreachable"
67
67
  /** The pod answered a redirect (a moved pod answers 308 from its old name): connect again at its new address. */
68
68
  | "pod_moved"
69
+ /**
70
+ * A credential door (approving a proposal that widens who or what can reach
71
+ * the pod) wants the person to re-confirm at the POD within 15 minutes; a
72
+ * Synap Cloud sign-in never counts. Recovery: `prompt_login`.
73
+ */
74
+ | "step_up_required"
75
+ /** Synap Cloud refused to vouch a person with no verified email. Recovery: `verify_email`. */
76
+ | "email_unverified"
77
+ /** Synap Cloud vouches an app origin over HTTPS only. Recovery: `use_https`. */
78
+ | "origin_insecure"
69
79
  | "server";
70
80
 
81
+ /**
82
+ * What a typed refusal asks the app to do:
83
+ * - `prompt_login`: send the person through the connect handler with
84
+ * `prompt=login` (re-confirm at the pod; the SDK redeems it with the
85
+ * device's refresh token), then retry ONCE;
86
+ * - `verify_email`: tell the person to verify their Synap email;
87
+ * - `use_https`: serve the app over HTTPS (`http://localhost` signs in to a
88
+ * local Control Plane started with `CP_ALLOW_DEV_ORIGINS=true`, never to api.synap.live).
89
+ */
90
+ export type PodRecovery = "prompt_login" | "verify_email" | "use_https";
91
+
71
92
  /**
72
93
  * Why the SDK refused before sending anything (`status: null`):
73
94
  * - `agent_cannot_approve`: an agent connection asked to approve (the pod
@@ -86,6 +107,8 @@ export interface PodError {
86
107
  detail?: string;
87
108
  /** Set when the SDK refused locally (1.5). */
88
109
  reason?: PodRefusalReason;
110
+ /** What to do next, on `step_up_required`, `email_unverified` and `origin_insecure`. */
111
+ recovery?: PodRecovery;
89
112
  /**
90
113
  * On `unauthorized` (1.5): the pod refused the access token, the connection
91
114
  * still holds a refresh token, and this client could not renew it (no
@@ -297,14 +320,44 @@ function renewalError(err: unknown): PodError {
297
320
  }
298
321
  }
299
322
 
323
+ /** The pod's step-up refusal (backend `STEP_UP_REQUIRED_MESSAGE`); the Hub's REST body carries only this text. */
324
+ const STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
325
+
326
+ /** The Control Plane's public problem codes (409, `{ error, code, recovery }`). */
327
+ const CP_PROBLEMS: Readonly<Record<string, { code: PodErrorCode; recovery: PodRecovery }>> = {
328
+ CP_ACCOUNT_EMAIL_REQUIRED: { code: "email_unverified", recovery: "verify_email" },
329
+ FEDERATED_APPLICATION_ORIGIN_INSECURE: { code: "origin_insecure", recovery: "use_https" },
330
+ };
331
+
332
+ function isStepUp(obj: Record<string, unknown>): boolean {
333
+ const data = (v: unknown) => (v && typeof v === "object" ? (v as Record<string, unknown>) : {});
334
+ // tRPC shape (`error.data.reasonCode`), a flat reasonCode, or the Hub REST body: `{ error: <message> }`.
335
+ const nested = data(data(obj.error).data).reasonCode ?? data(obj.data).reasonCode ?? obj.reasonCode;
336
+ return nested === "STEP_UP_REQUIRED" || obj.error === STEP_UP_MESSAGE || obj.message === STEP_UP_MESSAGE;
337
+ }
338
+
339
+ /**
340
+ * A 403 or 409 body → its typed code. A step-up is read on a 403 only, a Control
341
+ * Plane problem on a 409 only; everything else keeps the plain `forbidden` /
342
+ * `door_unsupported` / `conflict`, so a typed refusal is never read as a generic one.
343
+ */
344
+ export function classifyRefusal(status: 403 | 409 | number, body: unknown): { code: PodErrorCode; recovery?: PodRecovery } {
345
+ const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
346
+ if (status === 403) {
347
+ if (isStepUp(obj)) return { code: "step_up_required", recovery: "prompt_login" };
348
+ return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden" };
349
+ }
350
+ const problem = typeof obj.code === "string" ? CP_PROBLEMS[obj.code] : undefined;
351
+ return problem ?? { code: "conflict" };
352
+ }
353
+
300
354
  function errorFor(status: number, body: unknown, retryAfter: string | null, now: number): PodError {
301
355
  const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
302
356
  const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : undefined;
303
357
  const base = { status, ...(detail !== undefined ? { detail } : {}) };
304
358
  if (status === 401) return { code: "unauthorized", ...base };
305
- if (status === 403) return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden", ...base };
359
+ if (status === 403 || status === 409) return { ...classifyRefusal(status, body), ...base };
306
360
  if (status === 404) return { code: "not_found", ...base };
307
- if (status === 409) return { code: "conflict", ...base };
308
361
  if (status === 400 || status === 422) return { code: "invalid", ...base };
309
362
  if (status === 429) {
310
363
  const retryAfterSec = retryAfterSeconds(retryAfter, now);
@@ -48,6 +48,8 @@ export interface ConnectTransaction {
48
48
  resource: string | null;
49
49
  /** A same-origin path to land on afterwards. */
50
50
  next: string | null;
51
+ /** The sign-in re-confirms a device that is already connected (`prompt=login`). */
52
+ stepUp?: boolean;
51
53
  createdAt: number;
52
54
  }
53
55
 
@@ -74,8 +76,13 @@ export interface StartConnectOptions extends FlowOptions {
74
76
  * `"consent"`: the person asked to see the consent screen again (to widen or
75
77
  * narrow what the app may touch). Sent as OIDC `prompt=consent`; the sign-in
76
78
  * is otherwise the same (fresh PKCE verifier and state).
79
+ *
80
+ * `"login"`: a step-up. A credential door refused a stale device
81
+ * (`step_up_required`): the person re-confirms at the pod (OIDC
82
+ * `prompt=login`) and the callback redeems the code WITH the connection's
83
+ * refresh token (`synap_device_refresh_token`; see `HandleCallbackOptions.current`).
77
84
  */
78
- prompt?: "consent" | null;
85
+ prompt?: "consent" | "login" | null;
79
86
  allowInsecureLoopback?: boolean;
80
87
  }
81
88
 
@@ -124,7 +131,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
124
131
  url.searchParams.set("code_challenge_method", "S256");
125
132
  if (resource) url.searchParams.set("resource", resource);
126
133
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
127
- if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
134
+ if (opts.prompt === "consent" || opts.prompt === "login") url.searchParams.set("prompt", opts.prompt);
128
135
 
129
136
  const tx: ConnectTransaction = {
130
137
  v: 1,
@@ -137,6 +144,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
137
144
  verifier,
138
145
  resource,
139
146
  next: opts.next ?? null,
147
+ ...(opts.prompt === "login" ? { stepUp: true } : {}),
140
148
  createdAt: now,
141
149
  };
142
150
  const expiresAt = now + TRANSACTION_TTL_MS;
@@ -179,6 +187,13 @@ export interface HandleCallbackOptions extends FlowOptions {
179
187
  clientId: string;
180
188
  redirectUri: string;
181
189
  secret: AppSecret;
190
+ /**
191
+ * The connection this browser holds now. A step-up (`prompt=login`) of a
192
+ * Synap app's device is redeemed with ITS refresh token (the pod moves that
193
+ * device's clock instead of making a second device); without one, or for an
194
+ * app that is not a person's device, the code is redeemed as any other.
195
+ */
196
+ current?: PodConnection | null;
182
197
  }
183
198
 
184
199
  export interface CallbackResult {
@@ -219,6 +234,11 @@ export async function handleCallback(opts: HandleCallbackOptions): Promise<Callb
219
234
  code_verifier: tx.verifier,
220
235
  });
221
236
  if (tx.resource) body.set("resource", tx.resource);
237
+ const device =
238
+ tx.stepUp && opts.current && opts.current.actsAs === "person" && opts.current.podUrl === tx.issuer && opts.current.clientId === tx.clientId
239
+ ? opts.current
240
+ : null;
241
+ if (device?.refreshToken) body.set("synap_device_refresh_token", device.refreshToken);
222
242
  const json = await tokenRequest(tx.tokenEndpoint, body, "token", opts);
223
243
  const connection = parseTokenResponse(json, {
224
244
  podUrl: tx.issuer,
@@ -226,6 +246,7 @@ export async function handleCallback(opts: HandleCallbackOptions): Promise<Callb
226
246
  tokenEndpoint: tx.tokenEndpoint,
227
247
  revocationEndpoint: tx.revocationEndpoint,
228
248
  now: (opts.now ?? Date.now)(),
249
+ ...(device ? { previous: device } : {}),
229
250
  });
230
251
  return { connection, next: tx.next };
231
252
  }
@@ -30,6 +30,7 @@ export type {
30
30
  PodClientOptions,
31
31
  PodError,
32
32
  PodErrorCode,
33
+ PodRecovery,
33
34
  PodRefusalReason,
34
35
  PodResult,
35
36
  PodWriteResult,
@@ -63,14 +63,32 @@ import type { MockPodSeed, MockSeedDocument, MockSeedFacet, MockSeedRelation, Mo
63
63
  /** A placeholder pod origin; never contacted. */
64
64
  export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
65
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
+
66
69
  export const MOCK_GRANT: PodGrant[] = [
67
70
  { permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
68
71
  { permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
69
72
  ];
70
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
+
71
77
  export const MOCK_USER: PodUser = { id: "user_mock_ada", name: "Ada Lovelace" };
72
78
 
73
- 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";
74
92
 
75
93
  export interface MockAuthServerOptions {
76
94
  issuer?: string;
@@ -105,6 +123,14 @@ export interface MockAuthServerOptions {
105
123
  * not name it; the SDK reads an agent connection).
106
124
  */
107
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;
108
134
  /**
109
135
  * The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
110
136
  * before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
@@ -175,6 +201,10 @@ export interface MockAuthServer {
175
201
  authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
176
202
  /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
177
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;
178
208
  /** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
179
209
  loseNextRefreshRace(): void;
180
210
  /** Refresh-token redeems that were reuses (each revoked a family). */
@@ -198,6 +228,8 @@ interface CodeRow {
198
228
  challenge: string;
199
229
  resource: string | null;
200
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;
201
233
  }
202
234
 
203
235
  interface AccessRow {
@@ -266,8 +298,8 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
266
298
  * role in one. The pod sends no role field on an entity row, so the mock takes
267
299
  * its roles as SEED input (`facets`) and answers them only when asked.
268
300
  */
269
- function readableEntity(e: Record<string, unknown>, spaces: ReadonlySet<string>, only?: string | null): boolean {
270
- if (typeof e.workspaceId === "string" && spaces.has(e.workspaceId) && (!only || e.workspaceId === only)) return true;
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;
271
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.
272
304
  return facetsOf(e, spaces, only ?? undefined).some((f) => e.workspaceId == null || !f.sharedRole);
273
305
  }
@@ -276,8 +308,8 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
276
308
  * (backend `facetVisibilityConditions` under the token's grant), i.e. a role
277
309
  * whose seed space is granted; a workspace lens sees that space's roles only.
278
310
  */
279
- const facetsOf = (e: Record<string, unknown>, spaces: ReadonlySet<string>, lens?: string): MockSeedFacet[] =>
280
- rolesOf(e).filter((f) => typeof f.workspaceId === "string" && spaces.has(f.workspaceId) && (lens === undefined || f.workspaceId === lens));
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));
281
313
  /** backend `FacetRowAnnotation`: what `includeFacets=true` puts on a row. */
282
314
  const facetRow = (f: MockSeedFacet) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
283
315
  /** A row as the Hub answers it (`entityToWire`): roles ride on it only when asked. */
@@ -285,8 +317,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
285
317
  const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
286
318
  return wire;
287
319
  };
288
- const readSpaces = (scope: readonly string[]): Set<string> =>
289
- new Set(grant.filter((g) => /^entity\.(.+\.)?read$/.test(g.permission) && scope.includes(g.permission)).map((g) => g.workspaceId));
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
+ };
290
343
  for (const p of seed.proposals ?? []) {
291
344
  proposals.push({
292
345
  proposalType: "create",
@@ -301,7 +354,11 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
301
354
  ...p,
302
355
  });
303
356
  }
304
- 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;
305
362
  const graceMs = (options.refreshGraceSec ?? 30) * 1000;
306
363
  const app = options.app === undefined ? MOCK_APP : options.app;
307
364
  let proposalSeq = 0;
@@ -353,7 +410,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
353
410
  .map((g) => (g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId)! })),
354
411
  synap_user: user,
355
412
  ...(app ? { synap_app: app } : {}),
356
- ...(options.actsAs ? { synap_acts_as: options.actsAs } : {}),
413
+ ...(actsAs ? { synap_acts_as: actsAs } : {}),
357
414
  };
358
415
  if (pair) {
359
416
  body.refresh_token = pair.refreshToken;
@@ -380,6 +437,25 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
380
437
  if (form.resource !== undefined && row.resource && form.resource !== row.resource) {
381
438
  return oauthError("invalid_target", "resource does not match the authorization request");
382
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
+ }
383
459
  // The latest connect wins: a new code ends this client's older families.
384
460
  for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
385
461
  return json(200, tokenBody(row.scope, row.clientId, randomToken()));
@@ -467,6 +543,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
467
543
  const other = entities.find((e) => e.id === otherId);
468
544
  // As the pod: an edge whose far end is gone or out of the lens is not a neighbour.
469
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;
470
549
  connections.push({
471
550
  entityId: otherId,
472
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).
@@ -606,6 +685,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
606
685
  return json(403, { error: "Forbidden" });
607
686
  case "door_unsupported":
608
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" });
609
694
  case "server":
610
695
  return json(500, { error: "Internal error" });
611
696
  case "unavailable":
@@ -669,9 +754,10 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
669
754
  const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
670
755
  if (approve) {
671
756
  // rejectAgentReviewer: an agent credential never approves, before anything is read.
672
- if (options.actsAs !== "person") {
757
+ if (actsAs !== "person") {
673
758
  return json(403, { error: "An agent credential cannot approve proposals — approve is human review. Approve from a human session." });
674
759
  }
760
+ if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
675
761
  const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
676
762
  if (!p) return json(404, { error: "Proposal not found" });
677
763
  // Only where the person granted proposal.approve, for the proposal's own space.
@@ -683,17 +769,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
683
769
  approvals.push(p.id);
684
770
  return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
685
771
  }
686
- // A pod-wide row's core fields are outside a space-scoped grant: refused (the pod: 403 or 404, not yet pinned), nothing written, no proposal.
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).
687
783
  const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
688
784
  if (patched) {
689
785
  const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
690
- if (target && target.workspaceId == null) return json(403, { error: "grant does not allow" });
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
+ }
691
789
  }
692
790
  if (method !== "GET") {
693
791
  writes.push({ method, path, body });
694
792
  const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
695
793
  const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
696
- if (writeMode === "proposed") {
794
+ if (writeMode() === "proposed") {
697
795
  const proposalId = `prop_mock_${++proposalSeq}`;
698
796
  proposals.push({
699
797
  id: proposalId,
@@ -814,9 +912,15 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
814
912
  if (scope.length === 0) return finish({ error: "invalid_scope" });
815
913
  if (decision.deny) return finish({ error: "access_denied" });
816
914
  const code = randomToken();
817
- 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" });
818
916
  return finish({ code });
819
917
  },
918
+ setActsAs(v) {
919
+ actsAs = v ?? undefined;
920
+ },
921
+ expireStepUp() {
922
+ deviceFresh = false;
923
+ },
820
924
  consumeRefreshToken(t) {
821
925
  const row = refreshTokens.get(t);
822
926
  if (row) row.used = true;