@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.
- package/CHANGELOG.md +133 -0
- package/README.md +136 -10
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +412 -64
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +400 -32
- package/dist/connect.d.ts +400 -32
- package/dist/connect.js +407 -65
- package/dist/connect.js.map +1 -1
- package/dist/public.cjs +9 -2
- package/dist/public.cjs.map +1 -1
- package/dist/public.d.cts +19 -4
- package/dist/public.d.ts +19 -4
- package/dist/public.js +8 -2
- package/dist/public.js.map +1 -1
- package/dist/testing.cjs +234 -12
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +122 -27
- package/dist/testing.d.ts +122 -27
- package/dist/testing.js +230 -12
- package/dist/testing.js.map +1 -1
- package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
- package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
- package/package.json +30 -6
- package/src/connect/auth.ts +156 -27
- package/src/connect/client.ts +374 -34
- package/src/connect/cloud.ts +14 -10
- package/src/connect/discovery.ts +66 -32
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +7 -0
- package/src/connect/flow.ts +42 -14
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +254 -0
- package/src/connect/index.ts +60 -42
- package/src/connect/rules.ts +18 -0
- package/src/connect/store.ts +9 -5
- package/src/connect/types.ts +28 -4
- package/src/public/client.ts +14 -2
- package/src/public/safe-markdown.ts +13 -0
- package/src/public/share-model.ts +6 -2
- package/src/public/site-model.ts +5 -3
- package/src/testing/auth-server.ts +239 -14
- package/src/testing/cloud.ts +3 -4
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +8 -6
- package/src/testing/seed.ts +120 -0
- 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
|
|
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
|
|
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
|
|
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),
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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 (
|
|
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
|
},
|
package/src/testing/cloud.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A mock Synap Cloud pod chooser (Control Plane `GET /connect/pod
|
|
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
|
|
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;
|
package/src/testing/fixtures.ts
CHANGED
|
@@ -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. */
|
package/src/testing/index.ts
CHANGED
|
@@ -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,
|
|
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":[]}
|