@synap-core/sdk 1.4.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 (50) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +136 -10
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +412 -64
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +400 -32
  10. package/dist/connect.d.ts +400 -32
  11. package/dist/connect.js +407 -65
  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 +234 -12
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +122 -27
  22. package/dist/testing.d.ts +122 -27
  23. package/dist/testing.js +230 -12
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
  26. package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
  27. package/package.json +30 -6
  28. package/src/connect/auth.ts +156 -27
  29. package/src/connect/client.ts +374 -34
  30. package/src/connect/cloud.ts +14 -10
  31. package/src/connect/discovery.ts +66 -32
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +7 -0
  34. package/src/connect/flow.ts +42 -14
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +254 -0
  37. package/src/connect/index.ts +60 -42
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/store.ts +9 -5
  40. package/src/connect/types.ts +28 -4
  41. package/src/public/client.ts +14 -2
  42. package/src/public/safe-markdown.ts +13 -0
  43. package/src/public/share-model.ts +6 -2
  44. package/src/public/site-model.ts +5 -3
  45. package/src/testing/auth-server.ts +239 -14
  46. package/src/testing/cloud.ts +3 -4
  47. package/src/testing/fixtures.ts +26 -0
  48. package/src/testing/index.ts +8 -6
  49. package/src/testing/seed.ts +120 -0
  50. package/dist/chunk-J7XHAAFJ.js.map +0 -1
@@ -8,21 +8,34 @@
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
- * The reuse grace (PB3b): within 30 s of its use, a spent token presented
16
+ * The reuse grace: within 30 s of its use, a spent token presented
17
17
  * again with its own client_id gets the SAME pair its first use got (the
18
18
  * successor refresh token and the live key, `expires_in` = what that key has
19
19
  * left), as long as the successor is unused; past it, a reuse.
20
- * `refresh: false` is a pod before PB3 (no refresh_token at all).
20
+ * `refresh: false` is a pod without refresh tokens (no refresh_token at all).
21
21
  *
22
22
  * Hub REST: `GET /users/me`, `GET /entities` (an ARRAY of entities, as the pod
23
23
  * answers; the seeds plus what applied writes created), `GET /proposals`
24
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.
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`.
26
39
  *
27
40
  * mock.authorize(authorizeUrl) → the callback URL (approved)
28
41
  * mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
@@ -31,9 +44,11 @@
31
44
  * mock.redeemElsewhere(t) → another app instance rotated t (presenting it within 30 s gets that pair)
32
45
  * mock.consumeRefreshToken(t) → the token was used elsewhere with no successor (presenting it is a reuse)
33
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
34
48
  */
35
49
  import { pkceChallenge, randomToken } from "../connect/encoding";
36
- 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";
37
52
 
38
53
  /** A placeholder pod origin; never contacted. */
39
54
  export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
@@ -49,7 +64,7 @@ export type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" |
49
64
 
50
65
  export interface MockAuthServerOptions {
51
66
  issuer?: string;
52
- /** 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. */
53
68
  refresh?: boolean;
54
69
  /** Default 3600 (the pod's 1 h). */
55
70
  accessTtlSec?: number;
@@ -57,7 +72,7 @@ export interface MockAuthServerOptions {
57
72
  refreshIdleSec?: number;
58
73
  /** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
59
74
  advertiseRefreshExpiry?: boolean;
60
- /** The reuse grace, seconds (PB3b). Default 30 (the pod's); 0 = off (a pod before PB3b). */
75
+ /** The reuse grace, seconds. Default 30 (the pod's); 0 = off (a pod without the grace). */
61
76
  refreshGraceSec?: number;
62
77
  /**
63
78
  * `synap_app` on token responses: the connection's id and its page on the
@@ -70,8 +85,22 @@ export interface MockAuthServerOptions {
70
85
  user?: PodUser;
71
86
  /** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
72
87
  metadata?: Record<string, unknown>;
73
- /** 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
+ */
74
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;
75
104
  /** Hub paths (after /api/hub) that answer one failure. */
76
105
  failures?: Record<string, MockHubFailure>;
77
106
  now?: () => number;
@@ -122,8 +151,10 @@ export interface MockAuthServer {
122
151
  requests: MockAuthRequest[];
123
152
  /** Hub writes seen, in order. */
124
153
  writes: Array<{ method: string; path: string; body: unknown }>;
125
- /** Proposals filed by proposed writes, in order. */
154
+ /** Proposals: the seeded ones, then those filed by proposed writes, in order. */
126
155
  proposals: MockProposal[];
156
+ /** Proposals approved through `POST /proposals/:id/approve`, in order. */
157
+ approvals: string[];
127
158
  /** The person approved or rejected one: it leaves the pending list. */
128
159
  settleProposal(id: string, status: "approved" | "rejected"): void;
129
160
  /** Another instance of the app rotated `token` (its own refresh): returns the pair the pod answered it. */
@@ -140,6 +171,12 @@ export interface MockAuthServer {
140
171
  reuses: number;
141
172
  /** The person revoked the app: every token stops working. */
142
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;
143
180
  /** Live (unrevoked) refresh tokens. */
144
181
  liveRefreshTokens(): string[];
145
182
  reset(): void;
@@ -198,7 +235,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
198
235
  const requests: MockAuthRequest[] = [];
199
236
  const writes: Array<{ method: string; path: string; body: unknown }> = [];
200
237
  const proposals: MockProposal[] = [];
201
- const entities: Array<Record<string, unknown>> = [...(options.entities ?? [])];
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");
202
261
  const graceMs = (options.refreshGraceSec ?? 30) * 1000;
203
262
  const app = options.app === undefined ? MOCK_APP : options.app;
204
263
  let proposalSeq = 0;
@@ -244,9 +303,13 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
244
303
  // As the pod: what the key has left, never an assumed hour.
245
304
  expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1000)),
246
305
  scope: scope.join(" "),
247
- 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)! })),
248
310
  synap_user: user,
249
311
  ...(app ? { synap_app: app } : {}),
312
+ ...(options.actsAs ? { synap_acts_as: options.actsAs } : {}),
250
313
  };
251
314
  if (pair) {
252
315
  body.refresh_token = pair.refreshToken;
@@ -285,7 +348,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
285
348
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
286
349
  }
287
350
  if (row.used) {
288
- // PB3b: the same pair again, inside the grace, while the successor is unused and its key lives.
351
+ // The same pair again, inside the grace, while the successor is unused and its key lives.
289
352
  const successor = row.successor ? refreshTokens.get(row.successor) : undefined;
290
353
  const key = row.pairAccess ? access.get(row.pairAccess) : undefined;
291
354
  const inGrace = graceMs > 0 && row.usedAt !== undefined && now() - row.usedAt < graceMs;
@@ -314,6 +377,139 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
314
377
  return oauthError("unsupported_grant_type", "Unsupported grant_type");
315
378
  }
316
379
 
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
+
317
513
  function hub(method: string, path: string, query: URLSearchParams, headers: Record<string, string>, body: unknown): Response | "reject" {
318
514
  const failure = options.failures?.[path];
319
515
  if (failure === "unreachable") return "reject";
@@ -332,7 +528,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
332
528
  case "unavailable":
333
529
  return json(503, { error: "Unavailable" });
334
530
  }
335
- // 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.
336
532
  if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
337
533
  return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
338
534
  }
@@ -353,18 +549,43 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
353
549
  const ws = query.get("workspaceId");
354
550
  const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
355
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;
356
555
  const rows = proposals
357
556
  .filter((p) => p.workspaceId !== null && readable.has(p.workspaceId))
358
557
  .filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws))
558
+ .filter((p) => !ids || ids.has(p.id))
359
559
  .reverse(); // newest first
360
560
  const page = rows.slice(offset, offset + limit);
361
561
  return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
362
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
+ }
363
584
  if (method !== "GET") {
364
585
  writes.push({ method, path, body });
365
586
  const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
366
587
  const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
367
- if ((options.writeMode ?? "proposed") === "proposed") {
588
+ if (writeMode === "proposed") {
368
589
  const proposalId = `prop_mock_${++proposalSeq}`;
369
590
  proposals.push({
370
591
  id: proposalId,
@@ -442,6 +663,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
442
663
  requests,
443
664
  writes,
444
665
  proposals,
666
+ approvals,
445
667
  settleProposal(id, status) {
446
668
  const p = proposals.find((x) => x.id === id);
447
669
  if (p) p.status = status;
@@ -501,6 +723,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
501
723
  for (const row of access.values()) row.revoked = true;
502
724
  for (const row of refreshTokens.values()) row.revoked = true;
503
725
  },
726
+ revokeAccessKeys() {
727
+ for (const row of access.values()) row.revoked = true;
728
+ },
504
729
  liveRefreshTokens() {
505
730
  return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
506
731
  },
@@ -1,9 +1,9 @@
1
1
  /**
2
- * A mock Synap Cloud pod chooser (Control Plane `GET /connect/pod`, wave C1)
2
+ * A mock Synap Cloud pod chooser (Control Plane `GET /connect/pod`)
3
3
  * for "Continue with Synap Cloud" tests and a kit's mock mode. No network:
4
4
  * `choose()` stands in for the person signing in to the CP and picking a pod.
5
5
  *
6
- * Wire behaviour mirrors the CP route (doc 18 §1.1): it accepts only
6
+ * Wire behaviour mirrors the CP route: it accepts only
7
7
  * `client_id` (an https CIMD URL; http on loopback only with
8
8
  * `allowInsecureLoopback`, for a kit's mock mode on http://localhost) and
9
9
  * `state` (`^[A-Za-z0-9._~-]{16,256}$`),
@@ -15,13 +15,12 @@
15
15
  * createPodAppAuth({ ...config, cloudUrl: cloud.url });
16
16
  * cloud.choose(location, { pod: mockPod.issuer, metadata: auth.metadata }) → the app's return URL
17
17
  */
18
+ import { CLOUD_STATE_RE as STATE_RE, LOOPBACK_HOSTS } from "../connect/rules";
18
19
  import type { ClientMetadataDocument } from "../connect/types";
19
20
 
20
21
  /** A placeholder Control Plane origin; never contacted. */
21
22
  export const MOCK_CLOUD_URL = "https://cp.mock-synap.test";
22
23
 
23
- const STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
24
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
25
24
 
26
25
  export interface MockCloud {
27
26
  url: string;
@@ -44,6 +44,32 @@ export const MOCK_SHARE: PublicShare = {
44
44
  publishedOn: "2026-09-27",
45
45
  };
46
46
 
47
+ /**
48
+ * A published HTML document (an estimate), as the pod serves an `html` body: a
49
+ * page renders it only inside `<iframe sandbox="" srcdoc>`, so the script below
50
+ * never runs.
51
+ */
52
+ export const MOCK_HTML_SHARE: PublicShare = {
53
+ resourceType: "entity",
54
+ title: "Estimate for Northwind",
55
+ properties: { total: "4 800 €", validUntil: "2026-11-30" },
56
+ body: {
57
+ format: "html",
58
+ content: [
59
+ "<!doctype html>",
60
+ '<html lang="en"><head><meta charset="utf-8"><title>Estimate</title>',
61
+ "<style>body{font-family:Georgia,serif;max-width:40rem;margin:2rem auto;line-height:1.5}td{padding:.25rem .75rem}</style>",
62
+ "</head><body>",
63
+ "<h1>Estimate for Northwind</h1>",
64
+ "<table><tr><td>Discovery workshop</td><td>1 200 €</td></tr><tr><td>Build</td><td>3 600 €</td></tr></table>",
65
+ "<p><strong>Total: 4 800 €</strong></p>",
66
+ "<script>document.body.textContent = 'scripts never run in a sandboxed frame'</script>",
67
+ "</body></html>",
68
+ ].join(""),
69
+ },
70
+ publishedOn: "2026-10-22",
71
+ };
72
+
47
73
  /** One collection of a mock site: its door config and its live records. */
48
74
  export interface MockCollection {
49
75
  /** Default sort keys (`-` = descending); also the sort keys the door accepts. */
@@ -12,12 +12,12 @@
12
12
  * `credentials: "include"` request is refused (it rejects, as a CORS failure
13
13
  * does); with `{ browser: true }` a non-safelisted request header is too.
14
14
  *
15
- * tokens MOCK_TOKENS.form / .share / .site the demo form, share and site
15
+ * tokens MOCK_TOKENS.form / .share / .htmlShare / .site the demo form, shares (markdown, html) and site
16
16
  * tokens MOCK_FAILURE_TOKENS.* one failure on every door
17
17
  * anything else the uniform 404
18
18
  *
19
19
  * Pod-app sign-in (`@synap-core/sdk/connect`): `createMockAuthServer()`, see
20
- * ./auth-server.ts; Synap Cloud's pod chooser: `createMockCloud()`, ./cloud.ts.
20
+ * ./auth-server.ts, seeded from one JSON shape (`parseMockPodSeed`, ./seed.ts); Synap Cloud's pod chooser: `createMockCloud()`, ./cloud.ts.
21
21
  */
22
22
  import type {
23
23
  PublicCollectionPage,
@@ -27,19 +27,21 @@ import type {
27
27
  PublicShare,
28
28
  PublicSite,
29
29
  } from "../public/client";
30
- import { MOCK_FORM, MOCK_SHARE, MOCK_SITE, type MockSite } from "./fixtures";
30
+ import { MOCK_FORM, MOCK_HTML_SHARE, MOCK_SHARE, MOCK_SITE, type MockSite } from "./fixtures";
31
31
 
32
- export { MOCK_FORM, MOCK_SHARE, MOCK_SITE };
32
+ export { MOCK_FORM, MOCK_HTML_SHARE, MOCK_SHARE, MOCK_SITE };
33
33
  export type { MockCollection, MockSite } from "./fixtures";
34
34
  // Pod-app sign-in: a mock authorization server + Hub REST doors.
35
35
  export * from "./auth-server";
36
+ // The mock pod's data as one JSON shape (1.5).
37
+ export * from "./seed";
36
38
  // Synap Cloud: a mock Control Plane pod chooser.
37
39
  export * from "./cloud";
38
40
 
39
41
  /** A placeholder origin; never contacted. */
40
42
  export const MOCK_POD_URL = "https://mock.synap.invalid";
41
43
 
42
- export const MOCK_TOKENS = { form: "demo-form", share: "demo", site: "demo-site" } as const;
44
+ export const MOCK_TOKENS = { form: "demo-form", share: "demo", htmlShare: "demo-html", site: "demo-site" } as const;
43
45
 
44
46
  /** The `Retry-After` the `rateLimitedRetryAfter` token sends. */
45
47
  export const MOCK_RETRY_AFTER_SEC = 30;
@@ -289,7 +291,7 @@ function invalidKeys(form: PublicForm, body: unknown): string[] {
289
291
 
290
292
  export function createMockPod(options: MockPodOptions = {}): MockPod {
291
293
  const forms = options.forms ?? { [MOCK_TOKENS.form]: MOCK_FORM };
292
- const shares = options.shares ?? { [MOCK_TOKENS.share]: MOCK_SHARE };
294
+ const shares = options.shares ?? { [MOCK_TOKENS.share]: MOCK_SHARE, [MOCK_TOKENS.htmlShare]: MOCK_HTML_SHARE };
293
295
  const sites = options.sites ?? { [MOCK_TOKENS.site]: MOCK_SITE };
294
296
  const requests: MockRequest[] = [];
295
297
  const submissions: MockSubmission[] = [];
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The mock pod's data as ONE JSON-serialisable shape, so an app's mock mode,
3
+ * smoke and screenshots replay a recorded snapshot (one file) instead of
4
+ * fixtures built in app code:
5
+ *
6
+ * import { readFileSync } from "node:fs";
7
+ * const seed = parseMockPodSeed(readFileSync("mock/pod-seed.json", "utf8"));
8
+ * const pod = createMockAuthServer({ seed });
9
+ *
10
+ * Rows are the pod's wire shapes (see `@synap-core/sdk/connect` hub-types):
11
+ * `entities` as `GET /entities` answers them, `sessions` as `GET
12
+ * /focus-sessions` rows (missing columns get the pod's defaults), `documents`
13
+ * as `GET /documents/:id` (`sections` and `diagnostics` answered empty),
14
+ * `relations` as graph edges for `GET /entities/:id/connections`, `proposals`
15
+ * as `GET /proposals?view=basic` rows, `spaces` name each granted space's
16
+ * template (`synap_grant[].templateSlug` on the token response).
17
+ */
18
+ import type { MockProposal } from "./auth-server";
19
+
20
+ export interface MockSeedEntity {
21
+ id: string;
22
+ profileSlug?: string;
23
+ workspaceId?: string | null;
24
+ title?: string | null;
25
+ properties?: Record<string, unknown>;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ export interface MockSeedSession {
30
+ id: string;
31
+ workspaceId?: string | null;
32
+ projectId?: string | null;
33
+ goal?: string;
34
+ status?: string;
35
+ /** Slots; `owner: "human"` ones are what waits on the person. */
36
+ expectedOutputs?: Array<Record<string, unknown>>;
37
+ /** The entity it is about: listed among that entity's connections. */
38
+ subjectEntityId?: string | null;
39
+ /** ISO 8601; the list is newest first by it. */
40
+ startedAt?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ export interface MockSeedDocument {
45
+ id: string;
46
+ title: string;
47
+ content: string;
48
+ type?: string;
49
+ language?: string | null;
50
+ workspaceId?: string | null;
51
+ revision?: number;
52
+ version?: number;
53
+ createdAt?: string;
54
+ updatedAt?: string;
55
+ }
56
+
57
+ export interface MockSeedRelation {
58
+ id: string;
59
+ sourceEntityId: string;
60
+ targetEntityId: string;
61
+ type: string;
62
+ createdAt?: string;
63
+ }
64
+
65
+ export interface MockSeedSpace {
66
+ id: string;
67
+ name: string;
68
+ /** The template it came from (e.g. `content-os`); absent = made by hand. */
69
+ templateSlug?: string;
70
+ }
71
+
72
+ export interface MockPodSeed {
73
+ entities?: MockSeedEntity[];
74
+ sessions?: MockSeedSession[];
75
+ documents?: MockSeedDocument[];
76
+ relations?: MockSeedRelation[];
77
+ /** Missing fields default to a pending `create` of an `entity`, with null ids and the seed's load time. */
78
+ proposals?: Array<Partial<MockProposal> & { id: string }>;
79
+ spaces?: MockSeedSpace[];
80
+ }
81
+
82
+ const KEYS = ["entities", "sessions", "documents", "relations", "proposals", "spaces"] as const;
83
+
84
+ /** Required string fields per collection (the rest is free-form, as the pod's rows). */
85
+ const REQUIRED: Record<(typeof KEYS)[number], readonly string[]> = {
86
+ entities: ["id"],
87
+ sessions: ["id"],
88
+ documents: ["id", "title", "content"],
89
+ relations: ["id", "sourceEntityId", "targetEntityId", "type"],
90
+ proposals: ["id"],
91
+ spaces: ["id", "name"],
92
+ };
93
+
94
+ /**
95
+ * Read a seed (JSON text or an already-parsed value) and check its shape:
96
+ * every collection optional, each an array of objects carrying its required
97
+ * string fields; unknown top-level keys are refused (a typo would otherwise
98
+ * seed nothing). Throws `TypeError` naming the first bad path.
99
+ */
100
+ export function parseMockPodSeed(input: string | unknown): MockPodSeed {
101
+ const value: unknown = typeof input === "string" ? JSON.parse(input) : input;
102
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("seed: expected an object");
103
+ const obj = value as Record<string, unknown>;
104
+ for (const key of Object.keys(obj)) {
105
+ if (!(KEYS as readonly string[]).includes(key)) throw new TypeError(`seed.${key}: unknown collection (expected ${KEYS.join(", ")})`);
106
+ }
107
+ for (const key of KEYS) {
108
+ const rows = obj[key];
109
+ if (rows === undefined) continue;
110
+ if (!Array.isArray(rows)) throw new TypeError(`seed.${key}: expected an array`);
111
+ rows.forEach((row, i) => {
112
+ if (!row || typeof row !== "object" || Array.isArray(row)) throw new TypeError(`seed.${key}[${i}]: expected an object`);
113
+ for (const field of REQUIRED[key]) {
114
+ if (typeof (row as Record<string, unknown>)[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
115
+ }
116
+ });
117
+ }
118
+ // A JSON round trip: what the mock serves is what a file could hold.
119
+ return JSON.parse(JSON.stringify(obj)) as MockPodSeed;
120
+ }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/connect/encoding.ts"],"sourcesContent":["/**\n * Web-standard primitives (WebCrypto, TextEncoder, btoa/atob): the same code\n * runs on Node 18+, edge runtimes and workers. No Node import.\n */\n\nconst encoder = new TextEncoder();\nconst decoder = new TextDecoder();\n\n/** `Uint8Array<ArrayBuffer>`: what WebCrypto's `BufferSource` takes under TS 5.7+ lib types. */\nexport type Bytes = Uint8Array<ArrayBuffer>;\n\nexport function utf8(text: string): Bytes {\n return encoder.encode(text);\n}\n\nexport function fromUtf8(bytes: Uint8Array): string {\n return decoder.decode(bytes);\n}\n\nexport function base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** Null when `text` is not base64url. */\nexport function fromBase64url(text: string): Bytes | null {\n if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;\n const padded = text.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"===\".slice((text.length + 3) % 4);\n let binary: string;\n try {\n binary = atob(padded);\n } catch {\n return null;\n }\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n}\n\nexport function randomBytes(length: number): Bytes {\n const out = new Uint8Array(length);\n crypto.getRandomValues(out);\n return out;\n}\n\n/** 32 random bytes, base64url (43 chars): a state value or a PKCE verifier. */\nexport function randomToken(): string {\n return base64url(randomBytes(32));\n}\n\n/** RFC 7636 §4.2: `BASE64URL(SHA256(ASCII(code_verifier)))`. */\nexport async function pkceChallenge(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", utf8(verifier));\n return base64url(new Uint8Array(digest));\n}\n\n/** Compare two strings without an early exit on the first differing byte. */\nexport function constantTimeEqual(a: string, b: string): boolean {\n const x = utf8(a);\n const y = utf8(b);\n let diff = x.length ^ y.length;\n for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);\n return diff === 0;\n}\n\n/** The request's cookies by name (the first value wins, as browsers order the most specific first). */\nexport function parseCookies(header: string | null): Map<string, string> {\n const out = new Map<string, string>();\n if (!header) return out;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq < 0) continue;\n const name = part.slice(0, eq).trim();\n if (!name || out.has(name)) continue;\n let value = part.slice(eq + 1).trim();\n if (value.startsWith('\"') && value.endsWith('\"')) value = value.slice(1, -1);\n out.set(name, value);\n }\n return out;\n}\n\nexport interface CookieAttributes {\n maxAgeSec: number;\n secure: boolean;\n}\n\n/** `HttpOnly; SameSite=Lax; Path=/` always; `Secure` on https. Never a `Domain`. */\nexport function serializeCookie(name: string, value: string, attrs: CookieAttributes): string {\n const parts = [`${name}=${value}`, \"Path=/\", \"HttpOnly\", \"SameSite=Lax\", `Max-Age=${Math.max(0, Math.floor(attrs.maxAgeSec))}`];\n if (attrs.secure) parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n"],"mappings":";AAKA,IAAM,UAAU,IAAI,YAAY;AAChC,IAAM,UAAU,IAAI,YAAY;AAKzB,SAAS,KAAK,MAAqB;AACxC,SAAO,QAAQ,OAAO,IAAI;AAC5B;AAEO,SAAS,SAAS,OAA2B;AAClD,SAAO,QAAQ,OAAO,KAAK;AAC7B;AAEO,SAAS,UAAU,OAA2B;AACnD,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,IAAK,WAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAC7E,SAAO,KAAK,MAAM,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AAC/E;AAGO,SAAS,cAAc,MAA4B;AACxD,MAAI,CAAC,mBAAmB,KAAK,IAAI,EAAG,QAAO;AAC3C,QAAM,SAAS,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,IAAI,MAAM,OAAO,KAAK,SAAS,KAAK,CAAC;AAC7F,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM;AAAA,EACtB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,MAAM,IAAI,WAAW,OAAO,MAAM;AACxC,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,IAAK,KAAI,CAAC,IAAI,OAAO,WAAW,CAAC;AACpE,SAAO;AACT;AAEO,SAAS,YAAY,QAAuB;AACjD,QAAM,MAAM,IAAI,WAAW,MAAM;AACjC,SAAO,gBAAgB,GAAG;AAC1B,SAAO;AACT;AAGO,SAAS,cAAsB;AACpC,SAAO,UAAU,YAAY,EAAE,CAAC;AAClC;AAGA,eAAsB,cAAc,UAAmC;AACrE,QAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,KAAK,QAAQ,CAAC;AACnE,SAAO,UAAU,IAAI,WAAW,MAAM,CAAC;AACzC;AAGO,SAAS,kBAAkB,GAAW,GAAoB;AAC/D,QAAM,IAAI,KAAK,CAAC;AAChB,QAAM,IAAI,KAAK,CAAC;AAChB,MAAI,OAAO,EAAE,SAAS,EAAE;AACxB,WAAS,IAAI,GAAG,IAAI,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAK,UAAS,EAAE,CAAC,KAAK,MAAM,EAAE,CAAC,KAAK;AACtF,SAAO,SAAS;AAClB;AAGO,SAAS,aAAa,QAA4C;AACvE,QAAM,MAAM,oBAAI,IAAoB;AACpC,MAAI,CAAC,OAAQ,QAAO;AACpB,aAAW,QAAQ,OAAO,MAAM,GAAG,GAAG;AACpC,UAAM,KAAK,KAAK,QAAQ,GAAG;AAC3B,QAAI,KAAK,EAAG;AACZ,UAAM,OAAO,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK;AACpC,QAAI,CAAC,QAAQ,IAAI,IAAI,IAAI,EAAG;AAC5B,QAAI,QAAQ,KAAK,MAAM,KAAK,CAAC,EAAE,KAAK;AACpC,QAAI,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,EAAG,SAAQ,MAAM,MAAM,GAAG,EAAE;AAC3E,QAAI,IAAI,MAAM,KAAK;AAAA,EACrB;AACA,SAAO;AACT;AAQO,SAAS,gBAAgB,MAAc,OAAe,OAAiC;AAC5F,QAAM,QAAQ,CAAC,GAAG,IAAI,IAAI,KAAK,IAAI,UAAU,YAAY,gBAAgB,WAAW,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,SAAS,CAAC,CAAC,EAAE;AAC9H,MAAI,MAAM,OAAQ,OAAM,KAAK,QAAQ;AACrC,SAAO,MAAM,KAAK,IAAI;AACxB;","names":[]}