@synap-core/sdk 1.4.0 → 1.5.1

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 +215 -0
  2. package/README.md +152 -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 +419 -64
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +416 -32
  10. package/dist/connect.d.ts +416 -32
  11. package/dist/connect.js +414 -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 +350 -15
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +171 -27
  22. package/dist/testing.d.ts +171 -27
  23. package/dist/testing.js +346 -15
  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 +169 -27
  29. package/src/connect/client.ts +377 -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 +271 -0
  37. package/src/connect/index.ts +61 -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 +350 -17
  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/roles.ts +27 -0
  50. package/src/testing/seed.ts +196 -0
  51. package/dist/chunk-J7XHAAFJ.js.map +0 -1
@@ -8,21 +8,43 @@
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
+ * 1.5.1, roles (seed `entities[].facets`; `seedRoles` is desugared into them): the
33
+ * reach rule (a granted space's rows, plus the pod-wide rows with a role in
34
+ * one); `GET /entities` filters by `facetSlug` (and a `profileSlug` naming a
35
+ * role), drops the pod-wide rows of a workspace lens on `includePodWide=false`,
36
+ * and with `includeFacets=true` puts `facetSlugs` + `facets` on the rows that
37
+ * wear a visible one; `GET /entities/:id/facets` (`{ facets: [{ facet,
38
+ * profile, effectiveProperties }] }`). The token sees a facet as a member of
39
+ * a granted space does (its seed `workspaceId`), never another. A role is known by its slug only: no 404 for an
40
+ * undeclared `facetSlug`, a synthetic profile id, `effectiveProperties` empty.
41
+ *
42
+ * Who the token acts as: `actsAs: "person"` answers
43
+ * `synap_acts_as: "person"`, applies writes directly (unless `writeMode`
44
+ * says otherwise) and lets the token approve where it holds
45
+ * `proposal.approve`; `"agent"` answers `"agent"`; omitted, the field is not
46
+ * sent (a pod that does not name it) and the token is an agent's. An agent's approve is
47
+ * refused 403, as the pod's `rejectAgentReviewer`.
26
48
  *
27
49
  * mock.authorize(authorizeUrl) → the callback URL (approved)
28
50
  * mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
@@ -31,9 +53,12 @@
31
53
  * mock.redeemElsewhere(t) → another app instance rotated t (presenting it within 30 s gets that pair)
32
54
  * mock.consumeRefreshToken(t) → the token was used elsewhere with no successor (presenting it is a reuse)
33
55
  * mock.revokeAll() → the person removed the app in their pod
56
+ * mock.revokeAccessKeys() → a narrow or a pod-side "Revoke key": the live keys stop, the connection (its refresh token) lives
34
57
  */
35
58
  import { pkceChallenge, randomToken } from "../connect/encoding";
36
- import type { PodGrant, PodUser } from "../connect/types";
59
+ import type { PodActsAs, PodGrant, PodUser } from "../connect/types";
60
+ import { desugarSeedRoles } from "./roles";
61
+ import type { MockPodSeed, MockSeedDocument, MockSeedFacet, MockSeedRelation, MockSeedSession } from "./seed";
37
62
 
38
63
  /** A placeholder pod origin; never contacted. */
39
64
  export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
@@ -49,7 +74,7 @@ export type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" |
49
74
 
50
75
  export interface MockAuthServerOptions {
51
76
  issuer?: string;
52
- /** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
77
+ /** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
53
78
  refresh?: boolean;
54
79
  /** Default 3600 (the pod's 1 h). */
55
80
  accessTtlSec?: number;
@@ -57,7 +82,7 @@ export interface MockAuthServerOptions {
57
82
  refreshIdleSec?: number;
58
83
  /** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
59
84
  advertiseRefreshExpiry?: boolean;
60
- /** The reuse grace, seconds (PB3b). Default 30 (the pod's); 0 = off (a pod before PB3b). */
85
+ /** The reuse grace, seconds. Default 30 (the pod's); 0 = off (a pod without the grace). */
61
86
  refreshGraceSec?: number;
62
87
  /**
63
88
  * `synap_app` on token responses: the connection's id and its page on the
@@ -70,8 +95,22 @@ export interface MockAuthServerOptions {
70
95
  user?: PodUser;
71
96
  /** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
72
97
  metadata?: Record<string, unknown>;
73
- /** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
98
+ /**
99
+ * Hub writes: "proposed" (ask-first, the default for an agent token) or
100
+ * "applied" (the default for `actsAs: "person"`).
101
+ */
74
102
  writeMode?: "proposed" | "applied";
103
+ /**
104
+ * `synap_acts_as` on token responses. Omitted = not sent (a pod that does
105
+ * not name it; the SDK reads an agent connection).
106
+ */
107
+ actsAs?: PodActsAs;
108
+ /**
109
+ * The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
110
+ * before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
111
+ * those spaces.
112
+ */
113
+ seed?: MockPodSeed;
75
114
  /** Hub paths (after /api/hub) that answer one failure. */
76
115
  failures?: Record<string, MockHubFailure>;
77
116
  now?: () => number;
@@ -122,8 +161,10 @@ export interface MockAuthServer {
122
161
  requests: MockAuthRequest[];
123
162
  /** Hub writes seen, in order. */
124
163
  writes: Array<{ method: string; path: string; body: unknown }>;
125
- /** Proposals filed by proposed writes, in order. */
164
+ /** Proposals: the seeded ones, then those filed by proposed writes, in order. */
126
165
  proposals: MockProposal[];
166
+ /** Proposals approved through `POST /proposals/:id/approve`, in order. */
167
+ approvals: string[];
127
168
  /** The person approved or rejected one: it leaves the pending list. */
128
169
  settleProposal(id: string, status: "approved" | "rejected"): void;
129
170
  /** Another instance of the app rotated `token` (its own refresh): returns the pair the pod answered it. */
@@ -140,6 +181,12 @@ export interface MockAuthServer {
140
181
  reuses: number;
141
182
  /** The person revoked the app: every token stops working. */
142
183
  revokeAll(): void;
184
+ /**
185
+ * The live access keys stop working, the refresh tokens do not: what a
186
+ * narrow (the slot's key is revoked at once) or a pod-side "Revoke key"
187
+ * does. The next refresh mints a new key.
188
+ */
189
+ revokeAccessKeys(): void;
143
190
  /** Live (unrevoked) refresh tokens. */
144
191
  liveRefreshTokens(): string[];
145
192
  reset(): void;
@@ -198,7 +245,63 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
198
245
  const requests: MockAuthRequest[] = [];
199
246
  const writes: Array<{ method: string; path: string; body: unknown }> = [];
200
247
  const proposals: MockProposal[] = [];
201
- const entities: Array<Record<string, unknown>> = [...(options.entities ?? [])];
248
+ const seed = options.seed ?? {};
249
+ // One role model: a seed is desugared by parseMockPodSeed; `entities` (and a seed built in code) here.
250
+ const entities: Array<Record<string, unknown>> = [...(seed.entities ?? []), ...(options.entities ?? [])].map((e, i) =>
251
+ desugarSeedRoles(e, `entities[${i}]`),
252
+ );
253
+ const sessions: MockSeedSession[] = [...(seed.sessions ?? [])];
254
+ const documents: MockSeedDocument[] = [...(seed.documents ?? [])];
255
+ const relations: MockSeedRelation[] = [...(seed.relations ?? [])];
256
+ const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug!]));
257
+ const approvals: string[] = [];
258
+ const seededAt = new Date(now()).toISOString();
259
+
260
+ /** The roles (facets) an entity wears: the seed's `facets` (`seedRoles` was desugared into them). */
261
+ const rolesOf = (e: Record<string, unknown>): MockSeedFacet[] =>
262
+ Array.isArray(e.facets) ? (e.facets as MockSeedFacet[]).filter((f) => typeof f?.slug === "string") : [];
263
+ /**
264
+ * What the token's grant lets it read of an entity (the pod's grant clause):
265
+ * a row of a granted space, or a pod-wide row (`workspaceId` null) with a live
266
+ * role in one. The pod sends no role field on an entity row, so the mock takes
267
+ * its roles as SEED input (`facets`) and answers them only when asked.
268
+ */
269
+ function readableEntity(e: Record<string, unknown>, spaces: ReadonlySet<string>, only?: string | null): boolean {
270
+ if (typeof e.workspaceId === "string" && spaces.has(e.workspaceId) && (!only || e.workspaceId === only)) return true;
271
+ // A pod-wide row is reached by any role in the space; a row of ANOTHER space only by a role stamped in this one.
272
+ return facetsOf(e, spaces, only ?? undefined).some((f) => e.workspaceId == null || !f.sharedRole);
273
+ }
274
+ /**
275
+ * The roles of `e` a read sees: what a member of a granted space sees
276
+ * (backend `facetVisibilityConditions` under the token's grant), i.e. a role
277
+ * whose seed space is granted; a workspace lens sees that space's roles only.
278
+ */
279
+ const facetsOf = (e: Record<string, unknown>, spaces: ReadonlySet<string>, lens?: string): MockSeedFacet[] =>
280
+ rolesOf(e).filter((f) => typeof f.workspaceId === "string" && spaces.has(f.workspaceId) && (lens === undefined || f.workspaceId === lens));
281
+ /** backend `FacetRowAnnotation`: what `includeFacets=true` puts on a row. */
282
+ const facetRow = (f: MockSeedFacet) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
283
+ /** A row as the Hub answers it (`entityToWire`): roles ride on it only when asked. */
284
+ const entityWire = (e: Record<string, unknown>): Record<string, unknown> => {
285
+ const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
286
+ return wire;
287
+ };
288
+ const readSpaces = (scope: readonly string[]): Set<string> =>
289
+ new Set(grant.filter((g) => /^entity\.(.+\.)?read$/.test(g.permission) && scope.includes(g.permission)).map((g) => g.workspaceId));
290
+ for (const p of seed.proposals ?? []) {
291
+ proposals.push({
292
+ proposalType: "create",
293
+ targetType: "entity",
294
+ targetId: null,
295
+ status: "pending",
296
+ workspaceId: null,
297
+ createdAt: seededAt,
298
+ correlationId: null,
299
+ sessionId: null,
300
+ agentUserId: null,
301
+ ...p,
302
+ });
303
+ }
304
+ const writeMode = options.writeMode ?? (options.actsAs === "person" ? "applied" : "proposed");
202
305
  const graceMs = (options.refreshGraceSec ?? 30) * 1000;
203
306
  const app = options.app === undefined ? MOCK_APP : options.app;
204
307
  let proposalSeq = 0;
@@ -244,9 +347,13 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
244
347
  // As the pod: what the key has left, never an assumed hour.
245
348
  expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1000)),
246
349
  scope: scope.join(" "),
247
- synap_grant: grant.filter((g) => scope.includes(g.permission)),
350
+ // Each grant row names its space's template when it came from one.
351
+ synap_grant: grant
352
+ .filter((g) => scope.includes(g.permission))
353
+ .map((g) => (g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId)! })),
248
354
  synap_user: user,
249
355
  ...(app ? { synap_app: app } : {}),
356
+ ...(options.actsAs ? { synap_acts_as: options.actsAs } : {}),
250
357
  };
251
358
  if (pair) {
252
359
  body.refresh_token = pair.refreshToken;
@@ -285,7 +392,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
285
392
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
286
393
  }
287
394
  if (row.used) {
288
- // PB3b: the same pair again, inside the grace, while the successor is unused and its key lives.
395
+ // The same pair again, inside the grace, while the successor is unused and its key lives.
289
396
  const successor = row.successor ? refreshTokens.get(row.successor) : undefined;
290
397
  const key = row.pairAccess ? access.get(row.pairAccess) : undefined;
291
398
  const inGrace = graceMs > 0 && row.usedAt !== undefined && now() - row.usedAt < graceMs;
@@ -314,6 +421,178 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
314
421
  return oauthError("unsupported_grant_type", "Unsupported grant_type");
315
422
  }
316
423
 
424
+ /** A focus-session row as the list door answers it: the table's columns (defaults filled), `triage`, `kind`. */
425
+ const sessionRow = (s: MockSeedSession): Record<string, unknown> => ({
426
+ workspaceId: null,
427
+ projectId: null,
428
+ userId: user.id,
429
+ correlationId: null,
430
+ title: null,
431
+ goal: "",
432
+ status: "active",
433
+ templateId: null,
434
+ expectedOutputs: [],
435
+ channelId: null,
436
+ progress: null,
437
+ currentStage: null,
438
+ agentIds: [],
439
+ closedAt: null,
440
+ verificationReport: null,
441
+ metadata: {},
442
+ startedAt: seededAt,
443
+ createdAt: seededAt,
444
+ updatedAt: seededAt,
445
+ kind: "work",
446
+ triage: { pending: false, acceptedAt: null, acceptedBy: null },
447
+ ...s,
448
+ });
449
+
450
+ /** The seeded read doors (1.5); null when `path` is none of them. */
451
+ function readSeeded(path: string, query: URLSearchParams, scope: readonly string[]): Response | null {
452
+ const spaces = readSpaces(scope);
453
+ const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
454
+ if (conn) {
455
+ const id = decodeURIComponent(conn[1]);
456
+ const ws = query.get("workspaceId") ?? undefined;
457
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
458
+ const inLens = (e: Record<string, unknown>) => !ws || e.workspaceId === ws || e.workspaceId == null;
459
+ const connections: Array<Record<string, unknown>> = [];
460
+ const graph = relations
461
+ .filter((r) => r.sourceEntityId === id || r.targetEntityId === id)
462
+ .sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")))
463
+ .slice(0, limit);
464
+ for (const r of graph) {
465
+ const outgoing = r.sourceEntityId === id;
466
+ const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
467
+ const other = entities.find((e) => e.id === otherId);
468
+ // As the pod: an edge whose far end is gone or out of the lens is not a neighbour.
469
+ if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
470
+ connections.push({
471
+ entityId: otherId,
472
+ // relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
473
+ entity: {
474
+ ...entityWire(other),
475
+ type: other.profileSlug ?? other.type ?? null,
476
+ facetSlugs: rolesOf(other).length > 0 ? facetsOf(other, spaces, ws).map((f) => f.slug) : (other.facetSlugs ?? []),
477
+ },
478
+ label: r.type,
479
+ direction: outgoing ? "outgoing" : "incoming",
480
+ source: "graph",
481
+ relationId: r.id,
482
+ relationType: r.type,
483
+ createdAt: r.createdAt ?? null,
484
+ });
485
+ }
486
+ for (const s of sessions.filter((x) => x.subjectEntityId === id).slice(0, limit)) {
487
+ const row = sessionRow(s);
488
+ connections.push({
489
+ entityId: s.id,
490
+ entity: null,
491
+ label: (row.title as string | null) || (row.goal as string),
492
+ direction: "incoming",
493
+ source: "focus_session",
494
+ focusSessionId: s.id,
495
+ focusSessionGoal: row.goal,
496
+ focusSessionStatus: row.status,
497
+ focusSessionWorkspaceId: row.workspaceId,
498
+ createdAt: row.createdAt,
499
+ });
500
+ }
501
+ const n = (source: string) => connections.filter((c) => c.source === source).length;
502
+ return json(200, {
503
+ connections,
504
+ counts: { total: connections.length, graph: n("graph"), structural: n("property"), threads: n("thread"), contextChannels: n("context_channel"), focusSessions: n("focus_session") },
505
+ });
506
+ }
507
+ const entity = /^\/entities\/([^/]+)$/.exec(path);
508
+ if (entity) {
509
+ const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
510
+ // The pod answers a miss with an EMPTY 404 body (rest/entities.ts `c.body(null, 404)`); so does a row the grant cannot read.
511
+ return found && readableEntity(found, spaces) ? json(200, entityWire(found)) : new Response(null, { status: 404 });
512
+ }
513
+ const roles = /^\/entities\/([^/]+)\/facets$/.exec(path);
514
+ if (roles) {
515
+ const found = entities.find((e) => e.id === decodeURIComponent(roles[1]));
516
+ if (!found || !readableEntity(found, spaces)) return new Response(null, { status: 404 });
517
+ // getEffectiveFacets under the whole floor: each live facet row, its role-profile, that profile's effective properties.
518
+ return json(200, {
519
+ facets: facetsOf(found, spaces).map((f) => ({
520
+ facet: {
521
+ id: f.facetId,
522
+ entityId: found.id,
523
+ profileId: `profile_${f.slug}`,
524
+ userId: found.userId ?? user.id,
525
+ // A stamped facet answers its space; a shared role's facet is stored pod-wide.
526
+ workspaceId: f.sharedRole ? null : f.workspaceId,
527
+ contextEntityId: null,
528
+ status: f.status ?? null,
529
+ statusCategory: null,
530
+ properties: f.properties ?? {},
531
+ metadata: {},
532
+ createdByKind: null,
533
+ createdByUserId: null,
534
+ agentUserId: null,
535
+ sourceProposalId: null,
536
+ correlationId: null,
537
+ createdAt: seededAt,
538
+ updatedAt: seededAt,
539
+ deletedAt: null,
540
+ },
541
+ profile: { id: `profile_${f.slug}`, slug: f.slug, profileKind: "role" },
542
+ effectiveProperties: [],
543
+ })),
544
+ });
545
+ }
546
+ const doc = /^\/documents\/([^/]+)$/.exec(path);
547
+ if (doc) {
548
+ const format = query.get("format") ?? "raw";
549
+ if (format !== "raw" && format !== "readable") return json(400, { error: "format must be raw or readable" });
550
+ const d = documents.find((x) => x.id === decodeURIComponent(doc[1]));
551
+ if (!d) return json(404, { error: "Document not found" });
552
+ // projectAgentDocument; the mock has no embeds, so `readable` = `raw`, and no sections are parsed.
553
+ return json(200, {
554
+ document: {
555
+ id: d.id,
556
+ title: d.title,
557
+ type: d.type ?? "markdown",
558
+ language: d.language ?? null,
559
+ workspaceId: d.workspaceId ?? null,
560
+ format,
561
+ content: d.content,
562
+ revision: d.revision ?? 1,
563
+ version: d.version ?? 1,
564
+ sections: [],
565
+ diagnostics: [],
566
+ updatedAt: d.updatedAt ?? seededAt,
567
+ createdAt: d.createdAt ?? seededAt,
568
+ },
569
+ });
570
+ }
571
+ if (path === "/focus-sessions") {
572
+ const ws = query.get("workspaceId");
573
+ const project = query.get("projectId");
574
+ if (!ws && !project) return json(400, { error: "workspaceId or projectId is required" });
575
+ const status = query.get("status") ?? "all";
576
+ const lens = query.get("lens") ?? "all";
577
+ const kind = query.get("kind") ?? "all";
578
+ const subject = query.get("subjectEntityId");
579
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 20) || 20, 1), 50);
580
+ const rows = sessions
581
+ .map(sessionRow)
582
+ .filter((r) => (!ws || r.workspaceId === ws) && (!project || r.projectId === project))
583
+ .filter((r) => status === "all" || r.status === status)
584
+ .filter((r) => {
585
+ const pending = (r.triage as { pending?: boolean } | undefined)?.pending === true;
586
+ return lens === "triage" ? pending : lens === "default" ? !pending : true;
587
+ })
588
+ .filter((r) => kind === "all" || r.kind === kind)
589
+ .filter((r) => !subject || r.subjectEntityId === subject)
590
+ .sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
591
+ return json(200, rows.slice(0, limit));
592
+ }
593
+ return null;
594
+ }
595
+
317
596
  function hub(method: string, path: string, query: URLSearchParams, headers: Record<string, string>, body: unknown): Response | "reject" {
318
597
  const failure = options.failures?.[path];
319
598
  if (failure === "unreachable") return "reject";
@@ -332,19 +611,38 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
332
611
  case "unavailable":
333
612
  return json(503, { error: "Unavailable" });
334
613
  }
335
- // The grant fence (spec 17 §5): aggregate reads are refused to a scoped token.
614
+ // The grant fence: aggregate reads are refused to a space-scoped token.
336
615
  if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
337
616
  return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
338
617
  }
339
618
  if (method === "GET" && path === "/users/me") return json(200, user);
340
619
  if (method === "GET" && path === "/entities") {
341
620
  // The pod answers an ARRAY (rest/entities.ts), not a page object.
342
- const ws = query.get("workspaceId");
621
+ const ws = query.get("workspaceId") || undefined;
343
622
  const kind = query.get("profileSlug");
623
+ const role = query.get("facetSlug");
624
+ // A workspace lens keeps the pod-wide rows unless told not to (rest/entities.ts: `!== "false"`).
625
+ const podWide = query.get("includePodWide") !== "false";
626
+ const withFacets = query.get("includeFacets") === "true";
344
627
  const offset = Number(query.get("offset") ?? 0) || 0;
345
628
  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));
629
+ // Granted spaces' rows, plus the pod-wide rows roled in them (read-only, as a member of the space).
630
+ const spaces = readSpaces(row.scope);
631
+ const rows = entities.filter((e) => {
632
+ if (!readableEntity(e, spaces, ws)) return false;
633
+ // includePodWide=false keeps the rows with a role STAMPED in the space; it drops those reached only through a shared role.
634
+ if (ws && !podWide && e.workspaceId !== ws && !facetsOf(e, spaces, ws).some((f) => !f.sharedRole)) return false;
635
+ const slugs = facetsOf(e, spaces, ws).map((f) => f.slug);
636
+ // A profileSlug naming a role matches its wearers too (entities.list's facet branch).
637
+ return (!kind || e.profileSlug === kind || slugs.includes(kind)) && (!role || slugs.includes(role));
638
+ });
639
+ return json(
640
+ 200,
641
+ rows.slice(offset, offset + limit).map((e) => {
642
+ const facets = withFacets ? facetsOf(e, spaces, ws).map(facetRow) : [];
643
+ return facets.length > 0 ? { ...entityWire(e), facetSlugs: facets.map((f) => f.slug), facets } : entityWire(e);
644
+ }),
645
+ );
348
646
  }
349
647
  if (method === "GET" && path === "/proposals") {
350
648
  // The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
@@ -353,18 +651,49 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
353
651
  const ws = query.get("workspaceId");
354
652
  const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
355
653
  const offset = Number(query.get("offset") ?? 0) || 0;
654
+ // proposalIds: comma-separated and/or repeated; present but empty matches nothing.
655
+ const idsRaw = query.getAll("proposalIds");
656
+ const ids = idsRaw.length > 0 ? new Set(idsRaw.flatMap((v) => v.split(",").map((x) => x.trim()).filter(Boolean))) : null;
356
657
  const rows = proposals
357
658
  .filter((p) => p.workspaceId !== null && readable.has(p.workspaceId))
358
659
  .filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws))
660
+ .filter((p) => !ids || ids.has(p.id))
359
661
  .reverse(); // newest first
360
662
  const page = rows.slice(offset, offset + limit);
361
663
  return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
362
664
  }
665
+ if (method === "GET") {
666
+ const read = readSeeded(path, query, row.scope);
667
+ if (read) return read;
668
+ }
669
+ const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
670
+ if (approve) {
671
+ // rejectAgentReviewer: an agent credential never approves, before anything is read.
672
+ if (options.actsAs !== "person") {
673
+ return json(403, { error: "An agent credential cannot approve proposals — approve is human review. Approve from a human session." });
674
+ }
675
+ const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
676
+ if (!p) return json(404, { error: "Proposal not found" });
677
+ // Only where the person granted proposal.approve, for the proposal's own space.
678
+ if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
679
+ return json(403, { error: "You may not approve changes here" });
680
+ }
681
+ if (p.status !== "pending") return json(409, { error: `The proposal is ${p.status}, not pending` });
682
+ p.status = "approved";
683
+ approvals.push(p.id);
684
+ return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
685
+ }
686
+ // A pod-wide row's core fields are outside a space-scoped grant: refused (the pod: 403 or 404, not yet pinned), nothing written, no proposal.
687
+ const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
688
+ if (patched) {
689
+ const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
690
+ if (target && target.workspaceId == null) return json(403, { error: "grant does not allow" });
691
+ }
363
692
  if (method !== "GET") {
364
693
  writes.push({ method, path, body });
365
694
  const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
366
695
  const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
367
- if ((options.writeMode ?? "proposed") === "proposed") {
696
+ if (writeMode === "proposed") {
368
697
  const proposalId = `prop_mock_${++proposalSeq}`;
369
698
  proposals.push({
370
699
  id: proposalId,
@@ -442,6 +771,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
442
771
  requests,
443
772
  writes,
444
773
  proposals,
774
+ approvals,
445
775
  settleProposal(id, status) {
446
776
  const p = proposals.find((x) => x.id === id);
447
777
  if (p) p.status = status;
@@ -501,6 +831,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
501
831
  for (const row of access.values()) row.revoked = true;
502
832
  for (const row of refreshTokens.values()) row.revoked = true;
503
833
  },
834
+ revokeAccessKeys() {
835
+ for (const row of access.values()) row.revoked = true;
836
+ },
504
837
  liveRefreshTokens() {
505
838
  return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
506
839
  },
@@ -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,27 @@
1
+ /**
2
+ * The mock's ONE role model is `facets` (./seed.ts `MockSeedFacet`). The
3
+ * deprecated `seedRoles` shorthand is desugared into it here, once, by
4
+ * `parseMockPodSeed` and by `createMockAuthServer` for `options.entities`, so
5
+ * nothing downstream reads `seedRoles`. Not exported from `@synap-core/sdk/testing`.
6
+ */
7
+ import type { MockSeedFacet } from "./seed";
8
+
9
+ /**
10
+ * `row` with its `seedRoles` turned into `facets` (a generated `facetId`, no
11
+ * overlay). A role stated in both (same slug, same space) is a TypeError
12
+ * naming the entity: one fact, one place, never a silent merge.
13
+ */
14
+ export function desugarSeedRoles(row: Record<string, unknown>, at: string): Record<string, unknown> {
15
+ if (!Array.isArray(row.seedRoles)) return row;
16
+ const { seedRoles, ...rest } = row;
17
+ const facets = Array.isArray(row.facets) ? (row.facets as MockSeedFacet[]) : [];
18
+ const shorthand = (seedRoles as Array<{ profileSlug?: unknown; workspaceId?: unknown }>)
19
+ .filter((r) => typeof r?.profileSlug === "string" && typeof r.workspaceId === "string")
20
+ .map((r, i): MockSeedFacet => ({ facetId: `seedrole_${String(row.id)}_${i}`, slug: r.profileSlug as string, workspaceId: r.workspaceId as string }));
21
+ for (const r of shorthand) {
22
+ if (facets.some((f) => f?.slug === r.slug && f.workspaceId === r.workspaceId)) {
23
+ throw new TypeError(`${at} ("${String(row.id)}"): the role "${r.slug}" in ${r.workspaceId} is in both facets and seedRoles; keep it in facets only`);
24
+ }
25
+ }
26
+ return { ...rest, facets: [...facets, ...shorthand] };
27
+ }