@synap-core/sdk 1.3.0 → 1.4.0

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.
@@ -15,9 +15,7 @@
15
15
  * resolves to a private address cannot be refused without a resolver; a
16
16
  * deployment that cares adds egress rules.
17
17
  *
18
- * Connecting through Synap Cloud (the CP pod chooser, wave C1) is not
19
- * available yet: `cloudConnectAvailable()` is false and `startCloudConnect`
20
- * throws `cp_unavailable`.
18
+ * Connecting through Synap Cloud (the CP pod chooser, wave C1) is ./cloud.ts.
21
19
  */
22
20
  import { ConnectError, retryAfterSeconds } from "./errors";
23
21
  import type { PodIssuer } from "./types";
@@ -195,16 +193,6 @@ export async function discoverPod(input: string, opts: DiscoverOptions = {}): Pr
195
193
  }
196
194
  }
197
195
 
198
- /** Whether "Continue with Synap Cloud" can be offered. False until the CP pod chooser (C1) ships. */
199
- export function cloudConnectAvailable(): boolean {
200
- return false;
201
- }
202
-
203
- /** The Synap Cloud pod chooser (C1). Not available yet: always throws `cp_unavailable`. */
204
- export function startCloudConnect(_opts: { clientId: string; state?: string }): never {
205
- throw new ConnectError("cp_unavailable", "cp: not available — connecting through Synap Cloud is not built yet; use a pod address");
206
- }
207
-
208
196
  /**
209
197
  * A same-origin path to return to after connecting, or null. Accepts a
210
198
  * relative path (never `//host` or `/\host`) or an absolute URL on the app's
@@ -235,12 +223,21 @@ export interface LaunchHint {
235
223
  next: string | null;
236
224
  /** A space to preselect on consent (`synap_space_hint`). */
237
225
  spaceHint: string | null;
226
+ /**
227
+ * The `state` of a Synap Cloud return (the CP pod chooser answers
228
+ * `initiate_login_uri?iss=…&state=…`, or `?state=…&error=access_denied` when
229
+ * the person cancelled), shape-checked only. NOT verified here:
230
+ * send the request's query to the cloud handler (GET `paths.cloud`), which
231
+ * checks it against this browser's sealed cookie. Never act on `pod` because
232
+ * a state is present.
233
+ */
234
+ cloudState: string | null;
238
235
  }
239
236
 
240
237
  /**
241
238
  * Read a third-party-initiated login (OIDC Core §4, spec 17 §7.2): the browser
242
239
  * opens the app's `initiate_login_uri?iss=…&target_link_uri=…`. One parser for
243
- * the browser launch and, later, the Synap Cloud chooser's return.
240
+ * the browser launch and the Synap Cloud chooser's return (`cloudState`).
244
241
  */
245
242
  export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allowInsecureLoopback?: boolean } = {}): LaunchHint {
246
243
  const params = new URL(url).searchParams;
@@ -254,9 +251,13 @@ export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allow
254
251
  }
255
252
  }
256
253
  const hint = params.get("synap_space_hint");
254
+ const state = params.get("state");
257
255
  return {
258
256
  pod,
259
257
  next: safeNextPath(params.get("target_link_uri"), appOrigin),
260
258
  spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
259
+ // A browser launch never carries a state: any well-formed one is a Synap Cloud return
260
+ // (a pick: iss + state; a cancel: state + error=access_denied, no iss).
261
+ cloudState: state && /^[A-Za-z0-9._~-]{16,256}$/.test(state) ? state : null,
261
262
  };
262
263
  }
@@ -13,7 +13,7 @@ export type ConnectErrorCode =
13
13
  | "pod_unreachable"
14
14
  /** A Synap pod, but too old for app sign-in (no CIMD, no `iss`, no S256). */
15
15
  | "pod_unsupported"
16
- /** Connecting through Synap Cloud is not available yet. */
16
+ /** Connecting through Synap Cloud is not configured for this app (no Control Plane URL). */
17
17
  | "cp_unavailable"
18
18
  /** The person said no on the consent screen. */
19
19
  | "denied"
@@ -66,6 +66,12 @@ export interface StartConnectOptions extends FlowOptions {
66
66
  spaceHint?: string | null;
67
67
  /** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
68
68
  next?: string | null;
69
+ /**
70
+ * `"consent"`: the person asked to see the consent screen again (to widen or
71
+ * narrow what the app may touch). Sent as OIDC `prompt=consent`; the sign-in
72
+ * is otherwise the same (fresh PKCE verifier and state).
73
+ */
74
+ prompt?: "consent" | null;
69
75
  allowInsecureLoopback?: boolean;
70
76
  }
71
77
 
@@ -114,6 +120,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
114
120
  url.searchParams.set("code_challenge_method", "S256");
115
121
  if (resource) url.searchParams.set("resource", resource);
116
122
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
123
+ if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
117
124
 
118
125
  const tx: ConnectTransaction = {
119
126
  v: 1,
@@ -270,6 +277,32 @@ function parseGrant(value: unknown): PodGrant[] | null {
270
277
  return out;
271
278
  }
272
279
 
280
+ const APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
281
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
282
+
283
+ /**
284
+ * `synap_app` (Synap extension): the pod's id for this app connection and its
285
+ * page for it. The page is on the pod's console, which may be another host
286
+ * than the issuer, so it is kept when it is an absolute https URL (http only on
287
+ * loopback) without credentials; anything else is dropped.
288
+ */
289
+ function parseApp(value: unknown): { appId?: string; manageUrl?: string } {
290
+ if (!value || typeof value !== "object") return {};
291
+ const { id, manageUrl } = value as Record<string, unknown>;
292
+ const out: { appId?: string; manageUrl?: string } = {};
293
+ if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
294
+ if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
295
+ try {
296
+ const url = new URL(manageUrl);
297
+ const secure = url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
298
+ if (secure && !url.username && !url.password) out.manageUrl = url.href;
299
+ } catch {
300
+ // not a URL: no link
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+
273
306
  function parseUser(value: unknown): PodUser | null {
274
307
  if (!value || typeof value !== "object") return null;
275
308
  const { id, name } = value as Record<string, unknown>;
@@ -278,7 +311,9 @@ function parseUser(value: unknown): PodUser | null {
278
311
 
279
312
  /**
280
313
  * A token response → a connection. On a refresh, `previous` supplies what the
281
- * response may omit (grant, user). A refresh response WITHOUT a new
314
+ * response may omit (grant, user, app). `expires_in` is read as sent: a refresh
315
+ * answered within the pod's reuse grace (PB3b) hands back the live key the
316
+ * first redeem got, with less than an hour left. A refresh response WITHOUT a new
282
317
  * refresh_token drops the old one: with single-use rotation, resending a
283
318
  * consumed token reads as theft and revokes the whole family.
284
319
  */
@@ -322,6 +357,11 @@ export function parseTokenResponse(
322
357
  user,
323
358
  connectedAt: ctx.previous?.connectedAt ?? ctx.now,
324
359
  };
360
+ const app = parseApp(body.synap_app);
361
+ const appId = app.appId ?? ctx.previous?.appId;
362
+ const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
363
+ if (appId !== undefined) connection.appId = appId;
364
+ if (manageUrl !== undefined) connection.manageUrl = manageUrl;
325
365
  if (typeof refresh_token === "string" && refresh_token !== "") {
326
366
  connection.refreshToken = refresh_token;
327
367
  const idle =
@@ -15,17 +15,26 @@ import { assertServerRuntime } from "./guard";
15
15
 
16
16
  assertServerRuntime();
17
17
 
18
- export { createPodAppAuth, buildClientMetadata, DEFAULT_PATHS, REFRESH_WINDOW_MS } from "./auth";
18
+ export { createPodAppAuth, buildClientMetadata, isPrefetchOrAsset, DEFAULT_PATHS, REFRESH_WINDOW_MS } from "./auth";
19
19
  export type { PodAppAuth, PodAppAuthConfig, PodAppPaths, RefreshOutcome, RefreshStatus } from "./auth";
20
+ export { discoverPod, canonicalPodUrl, acceptLaunch, safeNextPath } from "./discovery";
21
+ export type { DiscoverOptions, LaunchHint } from "./discovery";
20
22
  export {
21
- discoverPod,
22
- canonicalPodUrl,
23
- acceptLaunch,
24
- safeNextPath,
25
23
  cloudConnectAvailable,
26
24
  startCloudConnect,
27
- } from "./discovery";
28
- export type { DiscoverOptions, LaunchHint } from "./discovery";
25
+ acceptCloudReturn,
26
+ cloudBaseUrl,
27
+ CLOUD_TRANSACTION_TTL_MS,
28
+ } from "./cloud";
29
+ export type {
30
+ AcceptCloudReturnOptions,
31
+ CloudConnectConfig,
32
+ CloudReturn,
33
+ CloudTransaction,
34
+ SealedCloudTransaction,
35
+ StartCloudConnectOptions,
36
+ StartedCloudConnect,
37
+ } from "./cloud";
29
38
  export {
30
39
  startConnect,
31
40
  openTransaction,
@@ -46,9 +55,28 @@ export type {
46
55
  StartConnectOptions,
47
56
  StartedConnect,
48
57
  } from "./flow";
49
- export { createPodClient, can, reviewUrlFor } from "./client";
50
- export type { PodClient, PodClientOptions, PodError, PodErrorCode, PodResult, PodWriteResult, QueryValue } from "./client";
51
- export { createSealedCookieStore, cookieNames, applySetCookies, connectionExpiresAt } from "./store";
58
+ export { createPodClient, can, spaces, reviewUrlFor } from "./client";
59
+ export type {
60
+ ListPendingProposalsOptions,
61
+ PendingProposal,
62
+ PendingProposals,
63
+ PodClient,
64
+ PodClientOptions,
65
+ PodError,
66
+ PodErrorCode,
67
+ PodResult,
68
+ PodWriteResult,
69
+ QueryValue,
70
+ } from "./client";
71
+ export {
72
+ createSealedCookieStore,
73
+ cookieNames,
74
+ applySetCookies,
75
+ connectionExpiresAt,
76
+ markerCookie,
77
+ readMarker,
78
+ MARKER_TTL_SEC,
79
+ } from "./store";
52
80
  export type { ConnectionStore, CookieNames, SealedCookieStoreOptions } from "./store";
53
81
  export { seal, unseal, MIN_SECRET_LENGTH } from "./seal";
54
82
  export type { AppSecret, Opened, SealPurpose } from "./seal";
@@ -56,4 +84,4 @@ export { pkceChallenge } from "./encoding";
56
84
  export { ConnectError, isConnectError, parseConnectErrorCode, CONNECT_ERROR_CODES } from "./errors";
57
85
  export type { ConnectErrorCode } from "./errors";
58
86
  export { isBrowserRuntime } from "./guard";
59
- export type { AppRequest, ClientMetadataDocument, PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
87
+ export type { AppRequest, ClientMetadataDocument, PodConnection, PodGrant, PodIssuer, PodSpace, PodUser } from "./types";
@@ -13,7 +13,7 @@
13
13
  import { ConnectError } from "./errors";
14
14
  import { base64url, fromBase64url, fromUtf8, randomBytes, utf8 } from "./encoding";
15
15
 
16
- export type SealPurpose = "synap-connect/transaction" | "synap-connect/session";
16
+ export type SealPurpose = "synap-connect/transaction" | "synap-connect/session" | "synap-connect/cloud";
17
17
 
18
18
  /** The app secret: one value, or several to rotate (the first seals, all open). */
19
19
  export type AppSecret = string | readonly string[];
@@ -7,7 +7,8 @@
7
7
  * Bring your own (a database, a KV) by implementing `ConnectionStore`; it
8
8
  * answers with the Set-Cookie values to send (e.g. its own session id).
9
9
  */
10
- import { parseCookies, serializeCookie } from "./encoding";
10
+ import { canonicalPodUrl } from "./discovery";
11
+ import { base64url, fromBase64url, fromUtf8, parseCookies, serializeCookie, utf8 } from "./encoding";
11
12
  import { ConnectError } from "./errors";
12
13
  import { seal, unseal, type AppSecret } from "./seal";
13
14
  import type { PodConnection, PodGrant } from "./types";
@@ -24,13 +25,50 @@ export interface ConnectionStore {
24
25
  export interface CookieNames {
25
26
  session: string;
26
27
  transaction: string;
28
+ /** A Synap Cloud sign-in in progress (sealed state, 10 minutes). */
29
+ cloud: string;
30
+ /** "This browser was connected to <pod>": the pod's URL only, no secret (`markerCookie`). */
31
+ marker: string;
27
32
  }
28
33
 
29
34
  /** `__Host-` names on https (Secure, Path=/, no Domain: bound to this exact host). */
30
35
  export function cookieNames(secure: boolean): CookieNames {
31
36
  return secure
32
- ? { session: "__Host-synap_session", transaction: "__Host-synap_tx" }
33
- : { session: "synap_session", transaction: "synap_tx" };
37
+ ? { session: "__Host-synap_session", transaction: "__Host-synap_tx", cloud: "__Host-synap_cloud", marker: "__Host-synap_seen" }
38
+ : { session: "synap_session", transaction: "synap_tx", cloud: "synap_cloud", marker: "synap_seen" };
39
+ }
40
+
41
+ /** The marker outlives the connection: as long as browsers keep a cookie (400 days). */
42
+ export const MARKER_TTL_SEC = 400 * 24 * 60 * 60;
43
+
44
+ /**
45
+ * The marker cookie: which pod this browser was connected to, so a connection
46
+ * that lapsed (idle past the refresh token's life, its sealed cookie gone)
47
+ * reads as "expired, connect again to <pod>" rather than "never connected".
48
+ * It holds the pod's public URL and NOTHING else: no token, no user, nothing
49
+ * sealed. A forged value can only pre-fill the address the person confirms.
50
+ */
51
+ export function markerCookie(podUrl: string, opts: { secure: boolean; name?: string }): string {
52
+ return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
53
+ maxAgeSec: MARKER_TTL_SEC,
54
+ secure: opts.secure,
55
+ });
56
+ }
57
+
58
+ /** The marker's pod URL, or null (absent, or not a canonical pod address). */
59
+ export function readMarker(
60
+ cookieHeader: string | null,
61
+ opts: { secure: boolean; name?: string; allowInsecureLoopback?: boolean },
62
+ ): string | null {
63
+ const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
64
+ const bytes = raw ? fromBase64url(raw) : null;
65
+ if (!bytes || bytes.length > 512) return null;
66
+ const podUrl = fromUtf8(bytes);
67
+ try {
68
+ return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
69
+ } catch {
70
+ return null;
71
+ }
34
72
  }
35
73
 
36
74
  /** Browsers cap a cookie near 4096 bytes; a big grant spans several. */
@@ -63,6 +63,26 @@ export interface PodConnection {
63
63
  user: PodUser;
64
64
  /** ms since epoch. */
65
65
  connectedAt: number;
66
+ /**
67
+ * The pod's id for this app connection (`app_…`, the grant's client id), when
68
+ * the token response names it (`synap_app.id`). Absent from pods that do not.
69
+ */
70
+ appId?: string;
71
+ /**
72
+ * The pod's own page for this app connection (pod-admin `/apps/<appId>`:
73
+ * what it may touch, narrow it, apply automatically, revoke), when the token
74
+ * response names it (`synap_app.manageUrl`). Absent otherwise: hide the link,
75
+ * never guess one.
76
+ */
77
+ manageUrl?: string;
78
+ }
79
+
80
+ /** One space of the grant, its permissions together (`spaces(connection)`). */
81
+ export interface PodSpace {
82
+ workspaceId: string;
83
+ /** "" when the pod sent no name. */
84
+ workspaceName: string;
85
+ permissions: string[];
66
86
  }
67
87
 
68
88
  /** The CIMD document an app serves at its client_id URL (spec 17 §6.1). */
@@ -13,13 +13,23 @@
13
13
  * a token presented AGAIN after its use is a reuse that revokes its whole
14
14
  * family AND the live access key; a new code ends the client's older
15
15
  * families; /revoke with a refresh token kills its family and access key.
16
+ * The reuse grace (PB3b): within 30 s of its use, a spent token presented
17
+ * again with its own client_id gets the SAME pair its first use got (the
18
+ * successor refresh token and the live key, `expires_in` = what that key has
19
+ * left), as long as the successor is unused; past it, a reuse.
16
20
  * `refresh: false` is a pod before PB3 (no refresh_token at all).
17
21
  *
22
+ * Hub REST: `GET /users/me`, `GET /entities` (an ARRAY of entities, as the pod
23
+ * answers; the seeds plus what applied writes created), `GET /proposals`
24
+ * (`{ proposals, limit, offset, total, hasMore }`, rows only in spaces where
25
+ * the token holds `proposal.read`, as the pod's grant clause), governed writes.
26
+ *
18
27
  * mock.authorize(authorizeUrl) → the callback URL (approved)
19
28
  * mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
20
29
  * mock.authorize(authorizeUrl, { iss: "https://evil.example" }) → a mix-up attempt
21
- * mock.loseNextRefreshRace() → the next redeem is a concurrent loser (refused, no revoke)
22
- * mock.consumeRefreshToken(t) → the token was used elsewhere (presenting it is a reuse)
30
+ * mock.loseNextRefreshRace() → the next redeem is a concurrent loser that found no successor (refused, no revoke)
31
+ * mock.redeemElsewhere(t) → another app instance rotated t (presenting it within 30 s gets that pair)
32
+ * mock.consumeRefreshToken(t) → the token was used elsewhere with no successor (presenting it is a reuse)
23
33
  * mock.revokeAll() → the person removed the app in their pod
24
34
  */
25
35
  import { pkceChallenge, randomToken } from "../connect/encoding";
@@ -47,6 +57,15 @@ export interface MockAuthServerOptions {
47
57
  refreshIdleSec?: number;
48
58
  /** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
49
59
  advertiseRefreshExpiry?: boolean;
60
+ /** The reuse grace, seconds (PB3b). Default 30 (the pod's); 0 = off (a pod before PB3b). */
61
+ refreshGraceSec?: number;
62
+ /**
63
+ * `synap_app` on token responses: the connection's id and its page on the
64
+ * pod console. Default `MOCK_APP`; null = a pod that does not send it.
65
+ */
66
+ app?: { id: string; manageUrl: string | null } | null;
67
+ /** Entities `GET /entities` answers (with `workspaceId` / `profileSlug` filters). */
68
+ entities?: Array<Record<string, unknown>>;
50
69
  grant?: PodGrant[];
51
70
  user?: PodUser;
52
71
  /** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
@@ -76,12 +95,41 @@ export interface AuthorizeDecision {
76
95
  state?: string;
77
96
  }
78
97
 
98
+ /** A proposal a governed write filed (`GET /proposals` lists the pending ones). */
99
+ export interface MockProposal {
100
+ id: string;
101
+ proposalType: string;
102
+ targetType: string;
103
+ targetId: string | null;
104
+ status: "pending" | "approved" | "rejected";
105
+ workspaceId: string | null;
106
+ createdAt: string;
107
+ correlationId: string | null;
108
+ sessionId: string | null;
109
+ agentUserId: string | null;
110
+ summary?: string;
111
+ }
112
+
113
+ /** The connection's `synap_app` the mock pod answers by default (backend `appTokenResponse`). */
114
+ export const MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
115
+
116
+ /** The app's agent on the mock pod: who files its proposals. */
117
+ export const MOCK_APP_AGENT_ID = "agent_mock_app";
118
+
79
119
  export interface MockAuthServer {
80
120
  issuer: string;
81
121
  fetch: typeof fetch;
82
122
  requests: MockAuthRequest[];
83
123
  /** Hub writes seen, in order. */
84
124
  writes: Array<{ method: string; path: string; body: unknown }>;
125
+ /** Proposals filed by proposed writes, in order. */
126
+ proposals: MockProposal[];
127
+ /** The person approved or rejected one: it leaves the pending list. */
128
+ settleProposal(id: string, status: "approved" | "rejected"): void;
129
+ /** Another instance of the app rotated `token` (its own refresh): returns the pair the pod answered it. */
130
+ redeemElsewhere(token: string): Promise<{ access_token: string; refresh_token: string; expires_in: number }>;
131
+ /** Refresh redeems answered within the reuse grace (the same pair again). */
132
+ graceAnswers: number;
85
133
  /** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
86
134
  authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
87
135
  /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
@@ -109,6 +157,7 @@ interface AccessRow {
109
157
  expiresAt: number;
110
158
  revoked: boolean;
111
159
  family: string | null;
160
+ scope: string[];
112
161
  }
113
162
 
114
163
  interface RefreshRow {
@@ -118,6 +167,10 @@ interface RefreshRow {
118
167
  used: boolean;
119
168
  revoked: boolean;
120
169
  scope: string[];
170
+ /** When it was spent, and the pair that use was answered (the grace re-answers it). */
171
+ usedAt?: number;
172
+ successor?: string;
173
+ pairAccess?: string;
121
174
  }
122
175
 
123
176
  function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
@@ -144,7 +197,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
144
197
  const refreshTokens = new Map<string, RefreshRow>();
145
198
  const requests: MockAuthRequest[] = [];
146
199
  const writes: Array<{ method: string; path: string; body: unknown }> = [];
200
+ const proposals: MockProposal[] = [];
201
+ const entities: Array<Record<string, unknown>> = [...(options.entities ?? [])];
202
+ const graceMs = (options.refreshGraceSec ?? 30) * 1000;
203
+ const app = options.app === undefined ? MOCK_APP : options.app;
147
204
  let proposalSeq = 0;
205
+ let graceAnswers = 0;
148
206
 
149
207
  const metadata = (): Record<string, unknown> => {
150
208
  const base: Record<string, unknown> = {
@@ -175,18 +233,25 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
175
233
  for (const a of access.values()) if (a.family === family) a.revoked = true;
176
234
  };
177
235
 
178
- const tokenBody = (scope: string[], clientId: string, family: string) => {
179
- const accessToken = `synap_hub_mock_${randomToken()}`;
180
- access.set(accessToken, { expiresAt: now() + accessTtl * 1000, revoked: false, family: refresh ? family : null });
236
+ /** The token response; `pair` re-answers an existing access key + refresh token (the grace). */
237
+ const tokenBody = (scope: string[], clientId: string, family: string, pair?: { accessToken: string; refreshToken: string }) => {
238
+ const accessToken = pair?.accessToken ?? `synap_hub_mock_${randomToken()}`;
239
+ if (!pair) access.set(accessToken, { expiresAt: now() + accessTtl * 1000, revoked: false, family: refresh ? family : null, scope });
240
+ const row = access.get(accessToken)!;
181
241
  const body: Record<string, unknown> = {
182
242
  access_token: accessToken,
183
243
  token_type: "Bearer",
184
- expires_in: accessTtl,
244
+ // As the pod: what the key has left, never an assumed hour.
245
+ expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1000)),
185
246
  scope: scope.join(" "),
186
247
  synap_grant: grant.filter((g) => scope.includes(g.permission)),
187
248
  synap_user: user,
249
+ ...(app ? { synap_app: app } : {}),
188
250
  };
189
- if (refresh) {
251
+ if (pair) {
252
+ body.refresh_token = pair.refreshToken;
253
+ if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = Math.floor((refreshTokens.get(pair.refreshToken)!.idleUntil - now()) / 1000);
254
+ } else if (refresh) {
190
255
  const refreshToken = randomToken();
191
256
  refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1000, used: false, revoked: false, scope });
192
257
  body.refresh_token = refreshToken;
@@ -220,6 +285,14 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
220
285
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
221
286
  }
222
287
  if (row.used) {
288
+ // PB3b: the same pair again, inside the grace, while the successor is unused and its key lives.
289
+ const successor = row.successor ? refreshTokens.get(row.successor) : undefined;
290
+ const key = row.pairAccess ? access.get(row.pairAccess) : undefined;
291
+ const inGrace = graceMs > 0 && row.usedAt !== undefined && now() - row.usedAt < graceMs;
292
+ if (inGrace && successor && !successor.used && !successor.revoked && key && !key.revoked && key.expiresAt > now()) {
293
+ graceAnswers++;
294
+ return json(200, tokenBody(row.scope, row.clientId, row.family, { accessToken: row.pairAccess!, refreshToken: row.successor! }));
295
+ }
223
296
  reuses++;
224
297
  revokeFamily(row.family);
225
298
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
@@ -230,12 +303,18 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
230
303
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
231
304
  }
232
305
  row.used = true;
233
- return json(200, tokenBody(row.scope, row.clientId, row.family));
306
+ row.usedAt = now();
307
+ // One live key per connection: the rotation retires the family's older keys.
308
+ for (const a of access.values()) if (a.family === row.family) a.revoked = true;
309
+ const body = tokenBody(row.scope, row.clientId, row.family);
310
+ row.successor = body.refresh_token as string;
311
+ row.pairAccess = body.access_token as string;
312
+ return json(200, body);
234
313
  }
235
314
  return oauthError("unsupported_grant_type", "Unsupported grant_type");
236
315
  }
237
316
 
238
- function hub(method: string, path: string, headers: Record<string, string>, body: unknown): Response | "reject" {
317
+ function hub(method: string, path: string, query: URLSearchParams, headers: Record<string, string>, body: unknown): Response | "reject" {
239
318
  const failure = options.failures?.[path];
240
319
  if (failure === "unreachable") return "reject";
241
320
  const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
@@ -258,11 +337,48 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
258
337
  return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
259
338
  }
260
339
  if (method === "GET" && path === "/users/me") return json(200, user);
261
- if (method === "GET" && path === "/entities") return json(200, { entities: [], total: 0 });
340
+ if (method === "GET" && path === "/entities") {
341
+ // The pod answers an ARRAY (rest/entities.ts), not a page object.
342
+ const ws = query.get("workspaceId");
343
+ const kind = query.get("profileSlug");
344
+ const offset = Number(query.get("offset") ?? 0) || 0;
345
+ const limit = Number(query.get("limit") ?? 50) || 50;
346
+ const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
347
+ return json(200, rows.slice(offset, offset + limit));
348
+ }
349
+ if (method === "GET" && path === "/proposals") {
350
+ // The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
351
+ const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
352
+ const status = query.get("status") ?? "pending";
353
+ const ws = query.get("workspaceId");
354
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
355
+ const offset = Number(query.get("offset") ?? 0) || 0;
356
+ const rows = proposals
357
+ .filter((p) => p.workspaceId !== null && readable.has(p.workspaceId))
358
+ .filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws))
359
+ .reverse(); // newest first
360
+ const page = rows.slice(offset, offset + limit);
361
+ return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
362
+ }
262
363
  if (method !== "GET") {
263
364
  writes.push({ method, path, body });
365
+ const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
366
+ const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
264
367
  if ((options.writeMode ?? "proposed") === "proposed") {
265
368
  const proposalId = `prop_mock_${++proposalSeq}`;
369
+ proposals.push({
370
+ id: proposalId,
371
+ proposalType: method === "POST" ? "create" : method === "DELETE" ? "delete" : "update",
372
+ targetType: path.split("/")[1]?.replace(/s$/, "") ?? "entity",
373
+ targetId: null,
374
+ status: "pending",
375
+ workspaceId,
376
+ createdAt: new Date(now()).toISOString(),
377
+ correlationId: null,
378
+ sessionId: null,
379
+ agentUserId: MOCK_APP_AGENT_ID,
380
+ ...(typeof fields.title === "string" ? { summary: `Create "${fields.title}"` } : {}),
381
+ });
266
382
  return json(200, {
267
383
  status: "proposed",
268
384
  message: "Sent for review",
@@ -270,7 +386,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
270
386
  reviewUrl: `${issuer}/open/${proposalId}`,
271
387
  });
272
388
  }
273
- return json(200, { status: "created", id: `ent_mock_${writes.length}` });
389
+ const id = `ent_mock_${writes.length}`;
390
+ if (method === "POST" && path === "/entities") entities.push({ id, ...fields });
391
+ return json(200, { status: "created", id });
274
392
  }
275
393
  return json(404, { error: "Not found" });
276
394
  }
@@ -311,7 +429,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
311
429
  } catch {
312
430
  body = rawBody;
313
431
  }
314
- const res = hub(method, hubPath, headers, body);
432
+ const res = hub(method, hubPath, url.searchParams, headers, body);
315
433
  if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
316
434
  return res;
317
435
  }
@@ -323,6 +441,20 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
323
441
  fetch: mockFetch as typeof fetch,
324
442
  requests,
325
443
  writes,
444
+ proposals,
445
+ settleProposal(id, status) {
446
+ const p = proposals.find((x) => x.id === id);
447
+ if (p) p.status = status;
448
+ },
449
+ async redeemElsewhere(t) {
450
+ const row = refreshTokens.get(t);
451
+ const res = await token({ grant_type: "refresh_token", refresh_token: t, client_id: row?.clientId ?? "" });
452
+ if (res.status !== 200) throw new Error(`redeemElsewhere: the pod answered ${res.status}`);
453
+ return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number };
454
+ },
455
+ get graceAnswers() {
456
+ return graceAnswers;
457
+ },
326
458
  authorize(authorizeUrl, decision = {}) {
327
459
  const url = new URL(authorizeUrl);
328
460
  if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);