@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.
- package/CHANGELOG.md +215 -0
- package/README.md +152 -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 +419 -64
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +416 -32
- package/dist/connect.d.ts +416 -32
- package/dist/connect.js +414 -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 +350 -15
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +171 -27
- package/dist/testing.d.ts +171 -27
- package/dist/testing.js +346 -15
- 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 +169 -27
- package/src/connect/client.ts +377 -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 +271 -0
- package/src/connect/index.ts +61 -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 +350 -17
- package/src/testing/cloud.ts +3 -4
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +8 -6
- package/src/testing/roles.ts +27 -0
- package/src/testing/seed.ts +196 -0
- 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
|
|
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
|
+
* 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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
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 (
|
|
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
|
},
|
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,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
|
+
}
|