@synap-core/sdk 1.3.0 → 1.5.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +212 -12
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +790 -161
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +610 -37
  10. package/dist/connect.d.ts +610 -37
  11. package/dist/connect.js +777 -162
  12. package/dist/connect.js.map +1 -1
  13. package/dist/public.cjs +9 -2
  14. package/dist/public.cjs.map +1 -1
  15. package/dist/public.d.cts +19 -4
  16. package/dist/public.d.ts +19 -4
  17. package/dist/public.js +8 -2
  18. package/dist/public.js.map +1 -1
  19. package/dist/testing.cjs +366 -17
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +190 -7
  22. package/dist/testing.d.ts +190 -7
  23. package/dist/testing.js +358 -17
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
  26. package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
  27. package/package.json +36 -12
  28. package/src/connect/auth.ts +314 -55
  29. package/src/connect/client.ts +472 -26
  30. package/src/connect/cloud.ts +210 -0
  31. package/src/connect/discovery.ts +79 -44
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +8 -1
  34. package/src/connect/flow.ts +80 -12
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +254 -0
  37. package/src/connect/index.ts +69 -23
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/seal.ts +1 -1
  40. package/src/connect/store.ts +50 -8
  41. package/src/connect/types.ts +48 -4
  42. package/src/public/client.ts +14 -2
  43. package/src/public/safe-markdown.ts +13 -0
  44. package/src/public/share-model.ts +6 -2
  45. package/src/public/site-model.ts +5 -3
  46. package/src/testing/auth-server.ts +377 -20
  47. package/src/testing/cloud.ts +83 -0
  48. package/src/testing/fixtures.ts +26 -0
  49. package/src/testing/index.ts +10 -6
  50. package/src/testing/seed.ts +120 -0
  51. package/dist/chunk-J7XHAAFJ.js.map +0 -1
@@ -8,7 +8,7 @@
8
8
  * is honest (a network blip) — it never says anything about the token.
9
9
  */
10
10
  import type { PublicReadError, PublicResult, PublicShare } from "./client";
11
- import { parseSafeMarkdown, type Block } from "./safe-markdown";
11
+ import { bodyOf, type Block } from "./safe-markdown";
12
12
  import { formatPublicValue, type FormatOptions } from "./site-model";
13
13
 
14
14
  export const UNAVAILABLE_TITLE = "This link isn't available";
@@ -20,7 +20,10 @@ export type SharePageModel =
20
20
  title: string | null;
21
21
  /** Property rows exactly as the publisher allowlisted them. */
22
22
  properties: Array<{ key: string; value: string }>;
23
+ /** A markdown body as safe blocks (`[]` for an html body or none). */
23
24
  blocks: Block[];
25
+ /** An html body: render ONLY as `<iframe sandbox="" srcdoc>`. `null` otherwise. */
26
+ html: string | null;
24
27
  publishedOn: string;
25
28
  };
26
29
 
@@ -41,7 +44,8 @@ export function toSharePageModel(
41
44
  key,
42
45
  value: formatPublicValue(value, opts),
43
46
  })),
44
- blocks: share.body ? parseSafeMarkdown(share.body.content) : [],
47
+ ...bodyOf(share.body),
45
48
  publishedOn: share.publishedOn,
46
49
  };
47
50
  }
51
+
@@ -14,7 +14,7 @@ import {
14
14
  type PublicRecordSummary,
15
15
  type PublicValue,
16
16
  } from "./client";
17
- import { parseSafeMarkdown, type Block } from "./safe-markdown";
17
+ import { bodyOf, type Block } from "./safe-markdown";
18
18
 
19
19
  export interface FormatOptions {
20
20
  /** BCP 47 locale (default `en`). */
@@ -197,8 +197,10 @@ export interface RecordViewModel {
197
197
  slug: string | null;
198
198
  /** Rows exactly as the publisher allowlisted them, formatted. */
199
199
  properties: Array<{ key: string; value: string }>;
200
- /** The body as safe blocks (never HTML). */
200
+ /** A markdown body as safe blocks (`[]` for an html body or none). */
201
201
  blocks: Block[];
202
+ /** An html body: render ONLY as `<iframe sandbox="" srcdoc>`. `null` otherwise. */
203
+ html: string | null;
202
204
  publishedOn: string;
203
205
  }
204
206
 
@@ -211,7 +213,7 @@ export function toRecordViewModel(record: PublicRecord, opts: FormatOptions = {}
211
213
  key,
212
214
  value: formatPublicValue(value, opts),
213
215
  })),
214
- blocks: record.body ? parseSafeMarkdown(record.body.content) : [],
216
+ ...bodyOf(record.body),
215
217
  publishedOn: record.publishedOn,
216
218
  };
217
219
  }
@@ -8,22 +8,47 @@
8
8
  * Wire behaviour mirrors synap-backend `routers/oauth/routes.ts` for a CIMD
9
9
  * client: `iss` on every authorization answer (RFC 9207), PKCE S256 checked at
10
10
  * /token, single-use codes bound to client_id + redirect_uri + resource, the
11
- * token response's `synap_grant` / `synap_user`. Refresh mirrors PB3
11
+ * token response's `synap_grant` / `synap_user`. Refresh mirrors the pod's
12
12
  * (`routers/oauth/refresh.ts`): rotating and single-use, 30 days from issue;
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
- * `refresh: false` is a pod before PB3 (no refresh_token at all).
16
+ * The reuse grace: 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.
20
+ * `refresh: false` is a pod without refresh tokens (no refresh_token at all).
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; `proposalIds`),
26
+ * governed writes. 1.5, from `seed` (one JSON shape, ./seed.ts):
27
+ * `GET /entities/:id` (404 with an empty body, as the pod), `GET
28
+ * /entities/:id/connections` (`{ connections, counts }`: seeded relations and
29
+ * the sessions about it), `GET /documents/:id` (`{ document }`), `GET
30
+ * /focus-sessions` (an ARRAY; `workspaceId` or `projectId` required; status,
31
+ * limit 1-50, lens, kind, subjectEntityId), `POST /proposals/:id/approve`.
32
+ *
33
+ * Who the token acts as: `actsAs: "person"` answers
34
+ * `synap_acts_as: "person"`, applies writes directly (unless `writeMode`
35
+ * says otherwise) and lets the token approve where it holds
36
+ * `proposal.approve`; `"agent"` answers `"agent"`; omitted, the field is not
37
+ * sent (a pod that does not name it) and the token is an agent's. An agent's approve is
38
+ * refused 403, as the pod's `rejectAgentReviewer`.
17
39
  *
18
40
  * mock.authorize(authorizeUrl) → the callback URL (approved)
19
41
  * mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
20
42
  * 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)
43
+ * mock.loseNextRefreshRace() → the next redeem is a concurrent loser that found no successor (refused, no revoke)
44
+ * mock.redeemElsewhere(t) → another app instance rotated t (presenting it within 30 s gets that pair)
45
+ * mock.consumeRefreshToken(t) → the token was used elsewhere with no successor (presenting it is a reuse)
23
46
  * mock.revokeAll() → the person removed the app in their pod
47
+ * mock.revokeAccessKeys() → a narrow or a pod-side "Revoke key": the live keys stop, the connection (its refresh token) lives
24
48
  */
25
49
  import { pkceChallenge, randomToken } from "../connect/encoding";
26
- import type { PodGrant, PodUser } from "../connect/types";
50
+ import type { PodActsAs, PodGrant, PodUser } from "../connect/types";
51
+ import type { MockPodSeed, MockSeedDocument, MockSeedRelation, MockSeedSession } from "./seed";
27
52
 
28
53
  /** A placeholder pod origin; never contacted. */
29
54
  export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
@@ -39,7 +64,7 @@ export type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" |
39
64
 
40
65
  export interface MockAuthServerOptions {
41
66
  issuer?: string;
42
- /** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
67
+ /** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
43
68
  refresh?: boolean;
44
69
  /** Default 3600 (the pod's 1 h). */
45
70
  accessTtlSec?: number;
@@ -47,12 +72,35 @@ export interface MockAuthServerOptions {
47
72
  refreshIdleSec?: number;
48
73
  /** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
49
74
  advertiseRefreshExpiry?: boolean;
75
+ /** The reuse grace, seconds. Default 30 (the pod's); 0 = off (a pod without the grace). */
76
+ refreshGraceSec?: number;
77
+ /**
78
+ * `synap_app` on token responses: the connection's id and its page on the
79
+ * pod console. Default `MOCK_APP`; null = a pod that does not send it.
80
+ */
81
+ app?: { id: string; manageUrl: string | null } | null;
82
+ /** Entities `GET /entities` answers (with `workspaceId` / `profileSlug` filters). */
83
+ entities?: Array<Record<string, unknown>>;
50
84
  grant?: PodGrant[];
51
85
  user?: PodUser;
52
86
  /** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
53
87
  metadata?: Record<string, unknown>;
54
- /** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
88
+ /**
89
+ * Hub writes: "proposed" (ask-first, the default for an agent token) or
90
+ * "applied" (the default for `actsAs: "person"`).
91
+ */
55
92
  writeMode?: "proposed" | "applied";
93
+ /**
94
+ * `synap_acts_as` on token responses. Omitted = not sent (a pod that does
95
+ * not name it; the SDK reads an agent connection).
96
+ */
97
+ actsAs?: PodActsAs;
98
+ /**
99
+ * The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
100
+ * before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
101
+ * those spaces.
102
+ */
103
+ seed?: MockPodSeed;
56
104
  /** Hub paths (after /api/hub) that answer one failure. */
57
105
  failures?: Record<string, MockHubFailure>;
58
106
  now?: () => number;
@@ -76,12 +124,43 @@ export interface AuthorizeDecision {
76
124
  state?: string;
77
125
  }
78
126
 
127
+ /** A proposal a governed write filed (`GET /proposals` lists the pending ones). */
128
+ export interface MockProposal {
129
+ id: string;
130
+ proposalType: string;
131
+ targetType: string;
132
+ targetId: string | null;
133
+ status: "pending" | "approved" | "rejected";
134
+ workspaceId: string | null;
135
+ createdAt: string;
136
+ correlationId: string | null;
137
+ sessionId: string | null;
138
+ agentUserId: string | null;
139
+ summary?: string;
140
+ }
141
+
142
+ /** The connection's `synap_app` the mock pod answers by default (backend `appTokenResponse`). */
143
+ export const MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
144
+
145
+ /** The app's agent on the mock pod: who files its proposals. */
146
+ export const MOCK_APP_AGENT_ID = "agent_mock_app";
147
+
79
148
  export interface MockAuthServer {
80
149
  issuer: string;
81
150
  fetch: typeof fetch;
82
151
  requests: MockAuthRequest[];
83
152
  /** Hub writes seen, in order. */
84
153
  writes: Array<{ method: string; path: string; body: unknown }>;
154
+ /** Proposals: the seeded ones, then those filed by proposed writes, in order. */
155
+ proposals: MockProposal[];
156
+ /** Proposals approved through `POST /proposals/:id/approve`, in order. */
157
+ approvals: string[];
158
+ /** The person approved or rejected one: it leaves the pending list. */
159
+ settleProposal(id: string, status: "approved" | "rejected"): void;
160
+ /** Another instance of the app rotated `token` (its own refresh): returns the pair the pod answered it. */
161
+ redeemElsewhere(token: string): Promise<{ access_token: string; refresh_token: string; expires_in: number }>;
162
+ /** Refresh redeems answered within the reuse grace (the same pair again). */
163
+ graceAnswers: number;
85
164
  /** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
86
165
  authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
87
166
  /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
@@ -92,6 +171,12 @@ export interface MockAuthServer {
92
171
  reuses: number;
93
172
  /** The person revoked the app: every token stops working. */
94
173
  revokeAll(): void;
174
+ /**
175
+ * The live access keys stop working, the refresh tokens do not: what a
176
+ * narrow (the slot's key is revoked at once) or a pod-side "Revoke key"
177
+ * does. The next refresh mints a new key.
178
+ */
179
+ revokeAccessKeys(): void;
95
180
  /** Live (unrevoked) refresh tokens. */
96
181
  liveRefreshTokens(): string[];
97
182
  reset(): void;
@@ -109,6 +194,7 @@ interface AccessRow {
109
194
  expiresAt: number;
110
195
  revoked: boolean;
111
196
  family: string | null;
197
+ scope: string[];
112
198
  }
113
199
 
114
200
  interface RefreshRow {
@@ -118,6 +204,10 @@ interface RefreshRow {
118
204
  used: boolean;
119
205
  revoked: boolean;
120
206
  scope: string[];
207
+ /** When it was spent, and the pair that use was answered (the grace re-answers it). */
208
+ usedAt?: number;
209
+ successor?: string;
210
+ pairAccess?: string;
121
211
  }
122
212
 
123
213
  function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
@@ -144,7 +234,34 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
144
234
  const refreshTokens = new Map<string, RefreshRow>();
145
235
  const requests: MockAuthRequest[] = [];
146
236
  const writes: Array<{ method: string; path: string; body: unknown }> = [];
237
+ const proposals: MockProposal[] = [];
238
+ const seed = options.seed ?? {};
239
+ const entities: Array<Record<string, unknown>> = [...(seed.entities ?? []), ...(options.entities ?? [])];
240
+ const sessions: MockSeedSession[] = [...(seed.sessions ?? [])];
241
+ const documents: MockSeedDocument[] = [...(seed.documents ?? [])];
242
+ const relations: MockSeedRelation[] = [...(seed.relations ?? [])];
243
+ const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug!]));
244
+ const approvals: string[] = [];
245
+ const seededAt = new Date(now()).toISOString();
246
+ for (const p of seed.proposals ?? []) {
247
+ proposals.push({
248
+ proposalType: "create",
249
+ targetType: "entity",
250
+ targetId: null,
251
+ status: "pending",
252
+ workspaceId: null,
253
+ createdAt: seededAt,
254
+ correlationId: null,
255
+ sessionId: null,
256
+ agentUserId: null,
257
+ ...p,
258
+ });
259
+ }
260
+ const writeMode = options.writeMode ?? (options.actsAs === "person" ? "applied" : "proposed");
261
+ const graceMs = (options.refreshGraceSec ?? 30) * 1000;
262
+ const app = options.app === undefined ? MOCK_APP : options.app;
147
263
  let proposalSeq = 0;
264
+ let graceAnswers = 0;
148
265
 
149
266
  const metadata = (): Record<string, unknown> => {
150
267
  const base: Record<string, unknown> = {
@@ -175,18 +292,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
175
292
  for (const a of access.values()) if (a.family === family) a.revoked = true;
176
293
  };
177
294
 
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 });
295
+ /** The token response; `pair` re-answers an existing access key + refresh token (the grace). */
296
+ const tokenBody = (scope: string[], clientId: string, family: string, pair?: { accessToken: string; refreshToken: string }) => {
297
+ const accessToken = pair?.accessToken ?? `synap_hub_mock_${randomToken()}`;
298
+ if (!pair) access.set(accessToken, { expiresAt: now() + accessTtl * 1000, revoked: false, family: refresh ? family : null, scope });
299
+ const row = access.get(accessToken)!;
181
300
  const body: Record<string, unknown> = {
182
301
  access_token: accessToken,
183
302
  token_type: "Bearer",
184
- expires_in: accessTtl,
303
+ // As the pod: what the key has left, never an assumed hour.
304
+ expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1000)),
185
305
  scope: scope.join(" "),
186
- synap_grant: grant.filter((g) => scope.includes(g.permission)),
306
+ // Each grant row names its space's template when it came from one.
307
+ synap_grant: grant
308
+ .filter((g) => scope.includes(g.permission))
309
+ .map((g) => (g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId)! })),
187
310
  synap_user: user,
311
+ ...(app ? { synap_app: app } : {}),
312
+ ...(options.actsAs ? { synap_acts_as: options.actsAs } : {}),
188
313
  };
189
- if (refresh) {
314
+ if (pair) {
315
+ body.refresh_token = pair.refreshToken;
316
+ if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = Math.floor((refreshTokens.get(pair.refreshToken)!.idleUntil - now()) / 1000);
317
+ } else if (refresh) {
190
318
  const refreshToken = randomToken();
191
319
  refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1000, used: false, revoked: false, scope });
192
320
  body.refresh_token = refreshToken;
@@ -220,6 +348,14 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
220
348
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
221
349
  }
222
350
  if (row.used) {
351
+ // The same pair again, inside the grace, while the successor is unused and its key lives.
352
+ const successor = row.successor ? refreshTokens.get(row.successor) : undefined;
353
+ const key = row.pairAccess ? access.get(row.pairAccess) : undefined;
354
+ const inGrace = graceMs > 0 && row.usedAt !== undefined && now() - row.usedAt < graceMs;
355
+ if (inGrace && successor && !successor.used && !successor.revoked && key && !key.revoked && key.expiresAt > now()) {
356
+ graceAnswers++;
357
+ return json(200, tokenBody(row.scope, row.clientId, row.family, { accessToken: row.pairAccess!, refreshToken: row.successor! }));
358
+ }
223
359
  reuses++;
224
360
  revokeFamily(row.family);
225
361
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
@@ -230,12 +366,151 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
230
366
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
231
367
  }
232
368
  row.used = true;
233
- return json(200, tokenBody(row.scope, row.clientId, row.family));
369
+ row.usedAt = now();
370
+ // One live key per connection: the rotation retires the family's older keys.
371
+ for (const a of access.values()) if (a.family === row.family) a.revoked = true;
372
+ const body = tokenBody(row.scope, row.clientId, row.family);
373
+ row.successor = body.refresh_token as string;
374
+ row.pairAccess = body.access_token as string;
375
+ return json(200, body);
234
376
  }
235
377
  return oauthError("unsupported_grant_type", "Unsupported grant_type");
236
378
  }
237
379
 
238
- function hub(method: string, path: string, headers: Record<string, string>, body: unknown): Response | "reject" {
380
+ /** A focus-session row as the list door answers it: the table's columns (defaults filled), `triage`, `kind`. */
381
+ const sessionRow = (s: MockSeedSession): Record<string, unknown> => ({
382
+ workspaceId: null,
383
+ projectId: null,
384
+ userId: user.id,
385
+ correlationId: null,
386
+ title: null,
387
+ goal: "",
388
+ status: "active",
389
+ templateId: null,
390
+ expectedOutputs: [],
391
+ channelId: null,
392
+ progress: null,
393
+ currentStage: null,
394
+ agentIds: [],
395
+ closedAt: null,
396
+ verificationReport: null,
397
+ metadata: {},
398
+ startedAt: seededAt,
399
+ createdAt: seededAt,
400
+ updatedAt: seededAt,
401
+ kind: "work",
402
+ triage: { pending: false, acceptedAt: null, acceptedBy: null },
403
+ ...s,
404
+ });
405
+
406
+ /** The seeded read doors (1.5); null when `path` is none of them. */
407
+ function readSeeded(path: string, query: URLSearchParams): Response | null {
408
+ const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
409
+ if (conn) {
410
+ const id = decodeURIComponent(conn[1]);
411
+ const ws = query.get("workspaceId");
412
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
413
+ const inLens = (e: Record<string, unknown>) => !ws || e.workspaceId === ws || e.workspaceId == null;
414
+ const connections: Array<Record<string, unknown>> = [];
415
+ const graph = relations
416
+ .filter((r) => r.sourceEntityId === id || r.targetEntityId === id)
417
+ .sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")))
418
+ .slice(0, limit);
419
+ for (const r of graph) {
420
+ const outgoing = r.sourceEntityId === id;
421
+ const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
422
+ const other = entities.find((e) => e.id === otherId);
423
+ // As the pod: an edge whose far end is gone or out of the lens is not a neighbour.
424
+ if (!other || !inLens(other)) continue;
425
+ connections.push({
426
+ entityId: otherId,
427
+ entity: { ...other, type: other.profileSlug ?? other.type ?? null, facetSlugs: other.facetSlugs ?? [] },
428
+ label: r.type,
429
+ direction: outgoing ? "outgoing" : "incoming",
430
+ source: "graph",
431
+ relationId: r.id,
432
+ relationType: r.type,
433
+ createdAt: r.createdAt ?? null,
434
+ });
435
+ }
436
+ for (const s of sessions.filter((x) => x.subjectEntityId === id).slice(0, limit)) {
437
+ const row = sessionRow(s);
438
+ connections.push({
439
+ entityId: s.id,
440
+ entity: null,
441
+ label: (row.title as string | null) || (row.goal as string),
442
+ direction: "incoming",
443
+ source: "focus_session",
444
+ focusSessionId: s.id,
445
+ focusSessionGoal: row.goal,
446
+ focusSessionStatus: row.status,
447
+ focusSessionWorkspaceId: row.workspaceId,
448
+ createdAt: row.createdAt,
449
+ });
450
+ }
451
+ const n = (source: string) => connections.filter((c) => c.source === source).length;
452
+ return json(200, {
453
+ connections,
454
+ counts: { total: connections.length, graph: n("graph"), structural: n("property"), threads: n("thread"), contextChannels: n("context_channel"), focusSessions: n("focus_session") },
455
+ });
456
+ }
457
+ const entity = /^\/entities\/([^/]+)$/.exec(path);
458
+ if (entity) {
459
+ const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
460
+ // The pod answers a miss with an EMPTY 404 body (rest/entities.ts `c.body(null, 404)`).
461
+ return found ? json(200, found) : new Response(null, { status: 404 });
462
+ }
463
+ const doc = /^\/documents\/([^/]+)$/.exec(path);
464
+ if (doc) {
465
+ const format = query.get("format") ?? "raw";
466
+ if (format !== "raw" && format !== "readable") return json(400, { error: "format must be raw or readable" });
467
+ const d = documents.find((x) => x.id === decodeURIComponent(doc[1]));
468
+ if (!d) return json(404, { error: "Document not found" });
469
+ // projectAgentDocument; the mock has no embeds, so `readable` = `raw`, and no sections are parsed.
470
+ return json(200, {
471
+ document: {
472
+ id: d.id,
473
+ title: d.title,
474
+ type: d.type ?? "markdown",
475
+ language: d.language ?? null,
476
+ workspaceId: d.workspaceId ?? null,
477
+ format,
478
+ content: d.content,
479
+ revision: d.revision ?? 1,
480
+ version: d.version ?? 1,
481
+ sections: [],
482
+ diagnostics: [],
483
+ updatedAt: d.updatedAt ?? seededAt,
484
+ createdAt: d.createdAt ?? seededAt,
485
+ },
486
+ });
487
+ }
488
+ if (path === "/focus-sessions") {
489
+ const ws = query.get("workspaceId");
490
+ const project = query.get("projectId");
491
+ if (!ws && !project) return json(400, { error: "workspaceId or projectId is required" });
492
+ const status = query.get("status") ?? "all";
493
+ const lens = query.get("lens") ?? "all";
494
+ const kind = query.get("kind") ?? "all";
495
+ const subject = query.get("subjectEntityId");
496
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 20) || 20, 1), 50);
497
+ const rows = sessions
498
+ .map(sessionRow)
499
+ .filter((r) => (!ws || r.workspaceId === ws) && (!project || r.projectId === project))
500
+ .filter((r) => status === "all" || r.status === status)
501
+ .filter((r) => {
502
+ const pending = (r.triage as { pending?: boolean } | undefined)?.pending === true;
503
+ return lens === "triage" ? pending : lens === "default" ? !pending : true;
504
+ })
505
+ .filter((r) => kind === "all" || r.kind === kind)
506
+ .filter((r) => !subject || r.subjectEntityId === subject)
507
+ .sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
508
+ return json(200, rows.slice(0, limit));
509
+ }
510
+ return null;
511
+ }
512
+
513
+ function hub(method: string, path: string, query: URLSearchParams, headers: Record<string, string>, body: unknown): Response | "reject" {
239
514
  const failure = options.failures?.[path];
240
515
  if (failure === "unreachable") return "reject";
241
516
  const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
@@ -253,16 +528,78 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
253
528
  case "unavailable":
254
529
  return json(503, { error: "Unavailable" });
255
530
  }
256
- // The grant fence (spec 17 §5): aggregate reads are refused to a scoped token.
531
+ // The grant fence: aggregate reads are refused to a space-scoped token.
257
532
  if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
258
533
  return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
259
534
  }
260
535
  if (method === "GET" && path === "/users/me") return json(200, user);
261
- if (method === "GET" && path === "/entities") return json(200, { entities: [], total: 0 });
536
+ if (method === "GET" && path === "/entities") {
537
+ // The pod answers an ARRAY (rest/entities.ts), not a page object.
538
+ const ws = query.get("workspaceId");
539
+ const kind = query.get("profileSlug");
540
+ const offset = Number(query.get("offset") ?? 0) || 0;
541
+ const limit = Number(query.get("limit") ?? 50) || 50;
542
+ const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
543
+ return json(200, rows.slice(offset, offset + limit));
544
+ }
545
+ if (method === "GET" && path === "/proposals") {
546
+ // The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
547
+ const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
548
+ const status = query.get("status") ?? "pending";
549
+ const ws = query.get("workspaceId");
550
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
551
+ const offset = Number(query.get("offset") ?? 0) || 0;
552
+ // proposalIds: comma-separated and/or repeated; present but empty matches nothing.
553
+ const idsRaw = query.getAll("proposalIds");
554
+ const ids = idsRaw.length > 0 ? new Set(idsRaw.flatMap((v) => v.split(",").map((x) => x.trim()).filter(Boolean))) : null;
555
+ const rows = proposals
556
+ .filter((p) => p.workspaceId !== null && readable.has(p.workspaceId))
557
+ .filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws))
558
+ .filter((p) => !ids || ids.has(p.id))
559
+ .reverse(); // newest first
560
+ const page = rows.slice(offset, offset + limit);
561
+ return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
562
+ }
563
+ if (method === "GET") {
564
+ const read = readSeeded(path, query);
565
+ if (read) return read;
566
+ }
567
+ const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
568
+ if (approve) {
569
+ // rejectAgentReviewer: an agent credential never approves, before anything is read.
570
+ if (options.actsAs !== "person") {
571
+ return json(403, { error: "An agent credential cannot approve proposals — approve is human review. Approve from a human session." });
572
+ }
573
+ const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
574
+ if (!p) return json(404, { error: "Proposal not found" });
575
+ // Only where the person granted proposal.approve, for the proposal's own space.
576
+ if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
577
+ return json(403, { error: "You may not approve changes here" });
578
+ }
579
+ if (p.status !== "pending") return json(409, { error: `The proposal is ${p.status}, not pending` });
580
+ p.status = "approved";
581
+ approvals.push(p.id);
582
+ return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
583
+ }
262
584
  if (method !== "GET") {
263
585
  writes.push({ method, path, body });
264
- if ((options.writeMode ?? "proposed") === "proposed") {
586
+ const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
587
+ const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
588
+ if (writeMode === "proposed") {
265
589
  const proposalId = `prop_mock_${++proposalSeq}`;
590
+ proposals.push({
591
+ id: proposalId,
592
+ proposalType: method === "POST" ? "create" : method === "DELETE" ? "delete" : "update",
593
+ targetType: path.split("/")[1]?.replace(/s$/, "") ?? "entity",
594
+ targetId: null,
595
+ status: "pending",
596
+ workspaceId,
597
+ createdAt: new Date(now()).toISOString(),
598
+ correlationId: null,
599
+ sessionId: null,
600
+ agentUserId: MOCK_APP_AGENT_ID,
601
+ ...(typeof fields.title === "string" ? { summary: `Create "${fields.title}"` } : {}),
602
+ });
266
603
  return json(200, {
267
604
  status: "proposed",
268
605
  message: "Sent for review",
@@ -270,7 +607,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
270
607
  reviewUrl: `${issuer}/open/${proposalId}`,
271
608
  });
272
609
  }
273
- return json(200, { status: "created", id: `ent_mock_${writes.length}` });
610
+ const id = `ent_mock_${writes.length}`;
611
+ if (method === "POST" && path === "/entities") entities.push({ id, ...fields });
612
+ return json(200, { status: "created", id });
274
613
  }
275
614
  return json(404, { error: "Not found" });
276
615
  }
@@ -311,7 +650,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
311
650
  } catch {
312
651
  body = rawBody;
313
652
  }
314
- const res = hub(method, hubPath, headers, body);
653
+ const res = hub(method, hubPath, url.searchParams, headers, body);
315
654
  if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
316
655
  return res;
317
656
  }
@@ -323,6 +662,21 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
323
662
  fetch: mockFetch as typeof fetch,
324
663
  requests,
325
664
  writes,
665
+ proposals,
666
+ approvals,
667
+ settleProposal(id, status) {
668
+ const p = proposals.find((x) => x.id === id);
669
+ if (p) p.status = status;
670
+ },
671
+ async redeemElsewhere(t) {
672
+ const row = refreshTokens.get(t);
673
+ const res = await token({ grant_type: "refresh_token", refresh_token: t, client_id: row?.clientId ?? "" });
674
+ if (res.status !== 200) throw new Error(`redeemElsewhere: the pod answered ${res.status}`);
675
+ return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number };
676
+ },
677
+ get graceAnswers() {
678
+ return graceAnswers;
679
+ },
326
680
  authorize(authorizeUrl, decision = {}) {
327
681
  const url = new URL(authorizeUrl);
328
682
  if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
@@ -369,6 +723,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
369
723
  for (const row of access.values()) row.revoked = true;
370
724
  for (const row of refreshTokens.values()) row.revoked = true;
371
725
  },
726
+ revokeAccessKeys() {
727
+ for (const row of access.values()) row.revoked = true;
728
+ },
372
729
  liveRefreshTokens() {
373
730
  return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
374
731
  },