@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
package/dist/testing.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import {
2
+ CLOUD_STATE_RE,
3
+ LOOPBACK_HOSTS,
2
4
  pkceChallenge,
3
5
  randomToken
4
- } from "./chunk-J7XHAAFJ.js";
6
+ } from "./chunk-UK2U6SMA.js";
5
7
 
6
8
  // src/testing/fixtures.ts
7
9
  var MOCK_FORM = {
@@ -42,6 +44,26 @@ var MOCK_SHARE = {
42
44
  },
43
45
  publishedOn: "2026-09-27"
44
46
  };
47
+ var MOCK_HTML_SHARE = {
48
+ resourceType: "entity",
49
+ title: "Estimate for Northwind",
50
+ properties: { total: "4 800 \u20AC", validUntil: "2026-11-30" },
51
+ body: {
52
+ format: "html",
53
+ content: [
54
+ "<!doctype html>",
55
+ '<html lang="en"><head><meta charset="utf-8"><title>Estimate</title>',
56
+ "<style>body{font-family:Georgia,serif;max-width:40rem;margin:2rem auto;line-height:1.5}td{padding:.25rem .75rem}</style>",
57
+ "</head><body>",
58
+ "<h1>Estimate for Northwind</h1>",
59
+ "<table><tr><td>Discovery workshop</td><td>1 200 \u20AC</td></tr><tr><td>Build</td><td>3 600 \u20AC</td></tr></table>",
60
+ "<p><strong>Total: 4 800 \u20AC</strong></p>",
61
+ "<script>document.body.textContent = 'scripts never run in a sandboxed frame'</script>",
62
+ "</body></html>"
63
+ ].join("")
64
+ },
65
+ publishedOn: "2026-10-22"
66
+ };
45
67
  function publicId(prefix, n) {
46
68
  return `p_${prefix}${String(n).padStart(16 - prefix.length, "0")}`;
47
69
  }
@@ -105,6 +127,8 @@ var MOCK_GRANT = [
105
127
  { permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
106
128
  ];
107
129
  var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
130
+ var MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
131
+ var MOCK_APP_AGENT_ID = "agent_mock_app";
108
132
  function json(status, body, headers = {}) {
109
133
  return new Response(JSON.stringify(body), {
110
134
  status,
@@ -127,7 +151,34 @@ function createMockAuthServer(options = {}) {
127
151
  const refreshTokens = /* @__PURE__ */ new Map();
128
152
  const requests = [];
129
153
  const writes = [];
154
+ const proposals = [];
155
+ const seed = options.seed ?? {};
156
+ const entities = [...seed.entities ?? [], ...options.entities ?? []];
157
+ const sessions = [...seed.sessions ?? []];
158
+ const documents = [...seed.documents ?? []];
159
+ const relations = [...seed.relations ?? []];
160
+ const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug]));
161
+ const approvals = [];
162
+ const seededAt = new Date(now()).toISOString();
163
+ for (const p of seed.proposals ?? []) {
164
+ proposals.push({
165
+ proposalType: "create",
166
+ targetType: "entity",
167
+ targetId: null,
168
+ status: "pending",
169
+ workspaceId: null,
170
+ createdAt: seededAt,
171
+ correlationId: null,
172
+ sessionId: null,
173
+ agentUserId: null,
174
+ ...p
175
+ });
176
+ }
177
+ const writeMode = options.writeMode ?? (options.actsAs === "person" ? "applied" : "proposed");
178
+ const graceMs = (options.refreshGraceSec ?? 30) * 1e3;
179
+ const app = options.app === void 0 ? MOCK_APP : options.app;
130
180
  let proposalSeq = 0;
181
+ let graceAnswers = 0;
131
182
  const metadata = () => {
132
183
  const base = {
133
184
  issuer,
@@ -155,18 +206,26 @@ function createMockAuthServer(options = {}) {
155
206
  for (const r of refreshTokens.values()) if (r.family === family) r.revoked = true;
156
207
  for (const a of access.values()) if (a.family === family) a.revoked = true;
157
208
  };
158
- const tokenBody = (scope, clientId, family) => {
159
- const accessToken = `synap_hub_mock_${randomToken()}`;
160
- access.set(accessToken, { expiresAt: now() + accessTtl * 1e3, revoked: false, family: refresh ? family : null });
209
+ const tokenBody = (scope, clientId, family, pair) => {
210
+ const accessToken = pair?.accessToken ?? `synap_hub_mock_${randomToken()}`;
211
+ if (!pair) access.set(accessToken, { expiresAt: now() + accessTtl * 1e3, revoked: false, family: refresh ? family : null, scope });
212
+ const row = access.get(accessToken);
161
213
  const body = {
162
214
  access_token: accessToken,
163
215
  token_type: "Bearer",
164
- expires_in: accessTtl,
216
+ // As the pod: what the key has left, never an assumed hour.
217
+ expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1e3)),
165
218
  scope: scope.join(" "),
166
- synap_grant: grant.filter((g) => scope.includes(g.permission)),
167
- synap_user: user
219
+ // Each grant row names its space's template when it came from one.
220
+ synap_grant: grant.filter((g) => scope.includes(g.permission)).map((g) => g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId) }),
221
+ synap_user: user,
222
+ ...app ? { synap_app: app } : {},
223
+ ...options.actsAs ? { synap_acts_as: options.actsAs } : {}
168
224
  };
169
- if (refresh) {
225
+ if (pair) {
226
+ body.refresh_token = pair.refreshToken;
227
+ if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = Math.floor((refreshTokens.get(pair.refreshToken).idleUntil - now()) / 1e3);
228
+ } else if (refresh) {
170
229
  const refreshToken = randomToken();
171
230
  refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1e3, used: false, revoked: false, scope });
172
231
  body.refresh_token = refreshToken;
@@ -198,6 +257,13 @@ function createMockAuthServer(options = {}) {
198
257
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
199
258
  }
200
259
  if (row.used) {
260
+ const successor = row.successor ? refreshTokens.get(row.successor) : void 0;
261
+ const key = row.pairAccess ? access.get(row.pairAccess) : void 0;
262
+ const inGrace = graceMs > 0 && row.usedAt !== void 0 && now() - row.usedAt < graceMs;
263
+ if (inGrace && successor && !successor.used && !successor.revoked && key && !key.revoked && key.expiresAt > now()) {
264
+ graceAnswers++;
265
+ return json(200, tokenBody(row.scope, row.clientId, row.family, { accessToken: row.pairAccess, refreshToken: row.successor }));
266
+ }
201
267
  reuses++;
202
268
  revokeFamily(row.family);
203
269
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
@@ -208,11 +274,132 @@ function createMockAuthServer(options = {}) {
208
274
  return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
209
275
  }
210
276
  row.used = true;
211
- return json(200, tokenBody(row.scope, row.clientId, row.family));
277
+ row.usedAt = now();
278
+ for (const a of access.values()) if (a.family === row.family) a.revoked = true;
279
+ const body = tokenBody(row.scope, row.clientId, row.family);
280
+ row.successor = body.refresh_token;
281
+ row.pairAccess = body.access_token;
282
+ return json(200, body);
212
283
  }
213
284
  return oauthError("unsupported_grant_type", "Unsupported grant_type");
214
285
  }
215
- function hub(method, path, headers, body) {
286
+ const sessionRow = (s) => ({
287
+ workspaceId: null,
288
+ projectId: null,
289
+ userId: user.id,
290
+ correlationId: null,
291
+ title: null,
292
+ goal: "",
293
+ status: "active",
294
+ templateId: null,
295
+ expectedOutputs: [],
296
+ channelId: null,
297
+ progress: null,
298
+ currentStage: null,
299
+ agentIds: [],
300
+ closedAt: null,
301
+ verificationReport: null,
302
+ metadata: {},
303
+ startedAt: seededAt,
304
+ createdAt: seededAt,
305
+ updatedAt: seededAt,
306
+ kind: "work",
307
+ triage: { pending: false, acceptedAt: null, acceptedBy: null },
308
+ ...s
309
+ });
310
+ function readSeeded(path, query) {
311
+ const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
312
+ if (conn) {
313
+ const id = decodeURIComponent(conn[1]);
314
+ const ws = query.get("workspaceId");
315
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
316
+ const inLens = (e) => !ws || e.workspaceId === ws || e.workspaceId == null;
317
+ const connections = [];
318
+ const graph = relations.filter((r) => r.sourceEntityId === id || r.targetEntityId === id).sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? ""))).slice(0, limit);
319
+ for (const r of graph) {
320
+ const outgoing = r.sourceEntityId === id;
321
+ const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
322
+ const other = entities.find((e) => e.id === otherId);
323
+ if (!other || !inLens(other)) continue;
324
+ connections.push({
325
+ entityId: otherId,
326
+ entity: { ...other, type: other.profileSlug ?? other.type ?? null, facetSlugs: other.facetSlugs ?? [] },
327
+ label: r.type,
328
+ direction: outgoing ? "outgoing" : "incoming",
329
+ source: "graph",
330
+ relationId: r.id,
331
+ relationType: r.type,
332
+ createdAt: r.createdAt ?? null
333
+ });
334
+ }
335
+ for (const s of sessions.filter((x) => x.subjectEntityId === id).slice(0, limit)) {
336
+ const row = sessionRow(s);
337
+ connections.push({
338
+ entityId: s.id,
339
+ entity: null,
340
+ label: row.title || row.goal,
341
+ direction: "incoming",
342
+ source: "focus_session",
343
+ focusSessionId: s.id,
344
+ focusSessionGoal: row.goal,
345
+ focusSessionStatus: row.status,
346
+ focusSessionWorkspaceId: row.workspaceId,
347
+ createdAt: row.createdAt
348
+ });
349
+ }
350
+ const n = (source) => connections.filter((c) => c.source === source).length;
351
+ return json(200, {
352
+ connections,
353
+ counts: { total: connections.length, graph: n("graph"), structural: n("property"), threads: n("thread"), contextChannels: n("context_channel"), focusSessions: n("focus_session") }
354
+ });
355
+ }
356
+ const entity = /^\/entities\/([^/]+)$/.exec(path);
357
+ if (entity) {
358
+ const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
359
+ return found ? json(200, found) : new Response(null, { status: 404 });
360
+ }
361
+ const doc = /^\/documents\/([^/]+)$/.exec(path);
362
+ if (doc) {
363
+ const format = query.get("format") ?? "raw";
364
+ if (format !== "raw" && format !== "readable") return json(400, { error: "format must be raw or readable" });
365
+ const d = documents.find((x) => x.id === decodeURIComponent(doc[1]));
366
+ if (!d) return json(404, { error: "Document not found" });
367
+ return json(200, {
368
+ document: {
369
+ id: d.id,
370
+ title: d.title,
371
+ type: d.type ?? "markdown",
372
+ language: d.language ?? null,
373
+ workspaceId: d.workspaceId ?? null,
374
+ format,
375
+ content: d.content,
376
+ revision: d.revision ?? 1,
377
+ version: d.version ?? 1,
378
+ sections: [],
379
+ diagnostics: [],
380
+ updatedAt: d.updatedAt ?? seededAt,
381
+ createdAt: d.createdAt ?? seededAt
382
+ }
383
+ });
384
+ }
385
+ if (path === "/focus-sessions") {
386
+ const ws = query.get("workspaceId");
387
+ const project = query.get("projectId");
388
+ if (!ws && !project) return json(400, { error: "workspaceId or projectId is required" });
389
+ const status = query.get("status") ?? "all";
390
+ const lens = query.get("lens") ?? "all";
391
+ const kind = query.get("kind") ?? "all";
392
+ const subject = query.get("subjectEntityId");
393
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 20) || 20, 1), 50);
394
+ const rows = sessions.map(sessionRow).filter((r) => (!ws || r.workspaceId === ws) && (!project || r.projectId === project)).filter((r) => status === "all" || r.status === status).filter((r) => {
395
+ const pending = r.triage?.pending === true;
396
+ return lens === "triage" ? pending : lens === "default" ? !pending : true;
397
+ }).filter((r) => kind === "all" || r.kind === kind).filter((r) => !subject || r.subjectEntityId === subject).sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
398
+ return json(200, rows.slice(0, limit));
399
+ }
400
+ return null;
401
+ }
402
+ function hub(method, path, query, headers, body) {
216
403
  const failure2 = options.failures?.[path];
217
404
  if (failure2 === "unreachable") return "reject";
218
405
  const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
@@ -234,11 +421,64 @@ function createMockAuthServer(options = {}) {
234
421
  return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
235
422
  }
236
423
  if (method === "GET" && path === "/users/me") return json(200, user);
237
- if (method === "GET" && path === "/entities") return json(200, { entities: [], total: 0 });
424
+ if (method === "GET" && path === "/entities") {
425
+ const ws = query.get("workspaceId");
426
+ const kind = query.get("profileSlug");
427
+ const offset = Number(query.get("offset") ?? 0) || 0;
428
+ const limit = Number(query.get("limit") ?? 50) || 50;
429
+ const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
430
+ return json(200, rows.slice(offset, offset + limit));
431
+ }
432
+ if (method === "GET" && path === "/proposals") {
433
+ const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
434
+ const status = query.get("status") ?? "pending";
435
+ const ws = query.get("workspaceId");
436
+ const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
437
+ const offset = Number(query.get("offset") ?? 0) || 0;
438
+ const idsRaw = query.getAll("proposalIds");
439
+ const ids = idsRaw.length > 0 ? new Set(idsRaw.flatMap((v) => v.split(",").map((x) => x.trim()).filter(Boolean))) : null;
440
+ const rows = proposals.filter((p) => p.workspaceId !== null && readable.has(p.workspaceId)).filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws)).filter((p) => !ids || ids.has(p.id)).reverse();
441
+ const page = rows.slice(offset, offset + limit);
442
+ return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
443
+ }
444
+ if (method === "GET") {
445
+ const read = readSeeded(path, query);
446
+ if (read) return read;
447
+ }
448
+ const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
449
+ if (approve) {
450
+ if (options.actsAs !== "person") {
451
+ return json(403, { error: "An agent credential cannot approve proposals \u2014 approve is human review. Approve from a human session." });
452
+ }
453
+ const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
454
+ if (!p) return json(404, { error: "Proposal not found" });
455
+ if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
456
+ return json(403, { error: "You may not approve changes here" });
457
+ }
458
+ if (p.status !== "pending") return json(409, { error: `The proposal is ${p.status}, not pending` });
459
+ p.status = "approved";
460
+ approvals.push(p.id);
461
+ return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
462
+ }
238
463
  if (method !== "GET") {
239
464
  writes.push({ method, path, body });
240
- if ((options.writeMode ?? "proposed") === "proposed") {
465
+ const fields = body && typeof body === "object" ? body : {};
466
+ const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
467
+ if (writeMode === "proposed") {
241
468
  const proposalId = `prop_mock_${++proposalSeq}`;
469
+ proposals.push({
470
+ id: proposalId,
471
+ proposalType: method === "POST" ? "create" : method === "DELETE" ? "delete" : "update",
472
+ targetType: path.split("/")[1]?.replace(/s$/, "") ?? "entity",
473
+ targetId: null,
474
+ status: "pending",
475
+ workspaceId,
476
+ createdAt: new Date(now()).toISOString(),
477
+ correlationId: null,
478
+ sessionId: null,
479
+ agentUserId: MOCK_APP_AGENT_ID,
480
+ ...typeof fields.title === "string" ? { summary: `Create "${fields.title}"` } : {}
481
+ });
242
482
  return json(200, {
243
483
  status: "proposed",
244
484
  message: "Sent for review",
@@ -246,7 +486,9 @@ function createMockAuthServer(options = {}) {
246
486
  reviewUrl: `${issuer}/open/${proposalId}`
247
487
  });
248
488
  }
249
- return json(200, { status: "created", id: `ent_mock_${writes.length}` });
489
+ const id = `ent_mock_${writes.length}`;
490
+ if (method === "POST" && path === "/entities") entities.push({ id, ...fields });
491
+ return json(200, { status: "created", id });
250
492
  }
251
493
  return json(404, { error: "Not found" });
252
494
  }
@@ -285,7 +527,7 @@ function createMockAuthServer(options = {}) {
285
527
  } catch {
286
528
  body = rawBody;
287
529
  }
288
- const res = hub(method, hubPath, headers, body);
530
+ const res = hub(method, hubPath, url.searchParams, headers, body);
289
531
  if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
290
532
  return res;
291
533
  }
@@ -296,6 +538,21 @@ function createMockAuthServer(options = {}) {
296
538
  fetch: mockFetch,
297
539
  requests,
298
540
  writes,
541
+ proposals,
542
+ approvals,
543
+ settleProposal(id, status) {
544
+ const p = proposals.find((x) => x.id === id);
545
+ if (p) p.status = status;
546
+ },
547
+ async redeemElsewhere(t) {
548
+ const row = refreshTokens.get(t);
549
+ const res = await token({ grant_type: "refresh_token", refresh_token: t, client_id: row?.clientId ?? "" });
550
+ if (res.status !== 200) throw new Error(`redeemElsewhere: the pod answered ${res.status}`);
551
+ return await res.json();
552
+ },
553
+ get graceAnswers() {
554
+ return graceAnswers;
555
+ },
299
556
  authorize(authorizeUrl, decision = {}) {
300
557
  const url = new URL(authorizeUrl);
301
558
  if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
@@ -341,6 +598,9 @@ function createMockAuthServer(options = {}) {
341
598
  for (const row of access.values()) row.revoked = true;
342
599
  for (const row of refreshTokens.values()) row.revoked = true;
343
600
  },
601
+ revokeAccessKeys() {
602
+ for (const row of access.values()) row.revoked = true;
603
+ },
344
604
  liveRefreshTokens() {
345
605
  return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
346
606
  },
@@ -351,9 +611,84 @@ function createMockAuthServer(options = {}) {
351
611
  };
352
612
  }
353
613
 
614
+ // src/testing/seed.ts
615
+ var KEYS = ["entities", "sessions", "documents", "relations", "proposals", "spaces"];
616
+ var REQUIRED = {
617
+ entities: ["id"],
618
+ sessions: ["id"],
619
+ documents: ["id", "title", "content"],
620
+ relations: ["id", "sourceEntityId", "targetEntityId", "type"],
621
+ proposals: ["id"],
622
+ spaces: ["id", "name"]
623
+ };
624
+ function parseMockPodSeed(input) {
625
+ const value = typeof input === "string" ? JSON.parse(input) : input;
626
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("seed: expected an object");
627
+ const obj = value;
628
+ for (const key of Object.keys(obj)) {
629
+ if (!KEYS.includes(key)) throw new TypeError(`seed.${key}: unknown collection (expected ${KEYS.join(", ")})`);
630
+ }
631
+ for (const key of KEYS) {
632
+ const rows = obj[key];
633
+ if (rows === void 0) continue;
634
+ if (!Array.isArray(rows)) throw new TypeError(`seed.${key}: expected an array`);
635
+ rows.forEach((row, i) => {
636
+ if (!row || typeof row !== "object" || Array.isArray(row)) throw new TypeError(`seed.${key}[${i}]: expected an object`);
637
+ for (const field of REQUIRED[key]) {
638
+ if (typeof row[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
639
+ }
640
+ });
641
+ }
642
+ return JSON.parse(JSON.stringify(obj));
643
+ }
644
+
645
+ // src/testing/cloud.ts
646
+ var MOCK_CLOUD_URL = "https://cp.mock-synap.test";
647
+ function createMockCloud(options = {}) {
648
+ const base = (options.url ?? MOCK_CLOUD_URL).replace(/\/+$/, "");
649
+ const requests = [];
650
+ return {
651
+ url: base,
652
+ requests,
653
+ choose(location, { pod, metadata }) {
654
+ const back = check(location, metadata);
655
+ back.searchParams.set("iss", pod);
656
+ back.searchParams.set("state", new URL(location).searchParams.get("state"));
657
+ return back.href;
658
+ },
659
+ cancel(location, { metadata }) {
660
+ const back = check(location, metadata);
661
+ back.searchParams.set("state", new URL(location).searchParams.get("state"));
662
+ back.searchParams.set("error", "access_denied");
663
+ return back.href;
664
+ }
665
+ };
666
+ function check(location, metadata) {
667
+ requests.push(location);
668
+ const url = new URL(location);
669
+ if (`${url.origin}${url.pathname}` !== `${base}/connect/pod`) throw new Error(`Not this CP's /connect/pod: ${location}`);
670
+ const clientId = url.searchParams.get("client_id");
671
+ const state = url.searchParams.get("state");
672
+ if (!clientId || !cimdUrlAccepted(clientId)) throw new Error("Chooser refused: client_id must be an https CIMD URL");
673
+ if (!state || !CLOUD_STATE_RE.test(state)) throw new Error("Chooser refused: state is missing or malformed");
674
+ if (clientId !== metadata.client_id) throw new Error("Chooser refused: the client metadata is not this client_id's");
675
+ return new URL(metadata.initiate_login_uri);
676
+ }
677
+ function cimdUrlAccepted(clientId) {
678
+ let url;
679
+ try {
680
+ url = new URL(clientId);
681
+ } catch {
682
+ return false;
683
+ }
684
+ if (url.protocol === "https:") return true;
685
+ return url.protocol === "http:" && options.allowInsecureLoopback === true && LOOPBACK_HOSTS.has(url.hostname);
686
+ }
687
+ }
688
+
354
689
  // src/testing/index.ts
355
690
  var MOCK_POD_URL = "https://mock.synap.invalid";
356
- var MOCK_TOKENS = { form: "demo-form", share: "demo", site: "demo-site" };
691
+ var MOCK_TOKENS = { form: "demo-form", share: "demo", htmlShare: "demo-html", site: "demo-site" };
357
692
  var MOCK_RETRY_AFTER_SEC = 30;
358
693
  var MOCK_FAILURE_TOKENS = {
359
694
  /** 429 with no `Retry-After` (what the pod's public doors send today). */
@@ -533,7 +868,7 @@ function invalidKeys(form, body) {
533
868
  }
534
869
  function createMockPod(options = {}) {
535
870
  const forms = options.forms ?? { [MOCK_TOKENS.form]: MOCK_FORM };
536
- const shares = options.shares ?? { [MOCK_TOKENS.share]: MOCK_SHARE };
871
+ const shares = options.shares ?? { [MOCK_TOKENS.share]: MOCK_SHARE, [MOCK_TOKENS.htmlShare]: MOCK_HTML_SHARE };
537
872
  const sites = options.sites ?? { [MOCK_TOKENS.site]: MOCK_SITE };
538
873
  const requests = [];
539
874
  const submissions = [];
@@ -598,10 +933,14 @@ function createMockPod(options = {}) {
598
933
  };
599
934
  }
600
935
  export {
936
+ MOCK_APP,
937
+ MOCK_APP_AGENT_ID,
601
938
  MOCK_AUTH_ISSUER,
939
+ MOCK_CLOUD_URL,
602
940
  MOCK_FAILURE_TOKENS,
603
941
  MOCK_FORM,
604
942
  MOCK_GRANT,
943
+ MOCK_HTML_SHARE,
605
944
  MOCK_POD_URL,
606
945
  MOCK_RETRY_AFTER_SEC,
607
946
  MOCK_SHARE,
@@ -609,6 +948,8 @@ export {
609
948
  MOCK_TOKENS,
610
949
  MOCK_USER,
611
950
  createMockAuthServer,
612
- createMockPod
951
+ createMockCloud,
952
+ createMockPod,
953
+ parseMockPodSeed
613
954
  };
614
955
  //# sourceMappingURL=testing.js.map