@synap-core/sdk 1.5.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 +82 -0
- package/README.md +16 -0
- package/dist/connect.cjs +9 -2
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +17 -1
- package/dist/connect.d.ts +17 -1
- package/dist/connect.js +9 -2
- package/dist/connect.js.map +1 -1
- package/dist/testing.cjs +124 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +50 -1
- package/dist/testing.d.ts +50 -1
- package/dist/testing.js +124 -11
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
- package/src/connect/auth.ts +15 -2
- package/src/connect/client.ts +3 -0
- package/src/connect/hub-types.ts +17 -0
- package/src/connect/index.ts +1 -0
- package/src/testing/auth-server.ts +120 -12
- package/src/testing/roles.ts +27 -0
- package/src/testing/seed.ts +78 -2
package/src/connect/hub-types.ts
CHANGED
|
@@ -38,9 +38,22 @@ export interface PodEntity {
|
|
|
38
38
|
/** ISO 8601. */
|
|
39
39
|
createdAt?: string;
|
|
40
40
|
updatedAt?: string;
|
|
41
|
+
/** The roles it wears, with `includeFacets`; absent when it wears none. */
|
|
42
|
+
facetSlugs?: string[];
|
|
43
|
+
/** Each role's overlay, with `includeFacets`; absent when it wears none. */
|
|
44
|
+
facets?: PodEntityFacet[];
|
|
41
45
|
[key: string]: unknown;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** One role an entity wears (`FacetRowAnnotation`). */
|
|
49
|
+
export interface PodEntityFacet {
|
|
50
|
+
facetId: string;
|
|
51
|
+
/** The role, e.g. `lead`. */
|
|
52
|
+
slug: string;
|
|
53
|
+
properties: Record<string, unknown>;
|
|
54
|
+
status: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
export interface ListEntitiesOptions {
|
|
45
58
|
/** One granted space. Omitted: every space the connection can read. */
|
|
46
59
|
workspaceId?: string;
|
|
@@ -53,6 +66,10 @@ export interface ListEntitiesOptions {
|
|
|
53
66
|
sort?: string;
|
|
54
67
|
/** With `workspaceId`: also the person's pod-wide rows (the pod's default: true). */
|
|
55
68
|
includePodWide?: boolean;
|
|
69
|
+
/** Only the entities wearing this role, e.g. `lead`. */
|
|
70
|
+
facetSlug?: string;
|
|
71
|
+
/** Put `facetSlugs` and `facets` on each row that wears a role. */
|
|
72
|
+
includeFacets?: boolean;
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
export type PodConnectionSource = "graph" | "property" | "thread" | "context_channel" | "focus_session";
|
package/src/connect/index.ts
CHANGED
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
* the sessions about it), `GET /documents/:id` (`{ document }`), `GET
|
|
30
30
|
* /focus-sessions` (an ARRAY; `workspaceId` or `projectId` required; status,
|
|
31
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.
|
|
32
41
|
*
|
|
33
42
|
* Who the token acts as: `actsAs: "person"` answers
|
|
34
43
|
* `synap_acts_as: "person"`, applies writes directly (unless `writeMode`
|
|
@@ -48,7 +57,8 @@
|
|
|
48
57
|
*/
|
|
49
58
|
import { pkceChallenge, randomToken } from "../connect/encoding";
|
|
50
59
|
import type { PodActsAs, PodGrant, PodUser } from "../connect/types";
|
|
51
|
-
import
|
|
60
|
+
import { desugarSeedRoles } from "./roles";
|
|
61
|
+
import type { MockPodSeed, MockSeedDocument, MockSeedFacet, MockSeedRelation, MockSeedSession } from "./seed";
|
|
52
62
|
|
|
53
63
|
/** A placeholder pod origin; never contacted. */
|
|
54
64
|
export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
@@ -236,13 +246,47 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
236
246
|
const writes: Array<{ method: string; path: string; body: unknown }> = [];
|
|
237
247
|
const proposals: MockProposal[] = [];
|
|
238
248
|
const seed = options.seed ?? {};
|
|
239
|
-
|
|
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
|
+
);
|
|
240
253
|
const sessions: MockSeedSession[] = [...(seed.sessions ?? [])];
|
|
241
254
|
const documents: MockSeedDocument[] = [...(seed.documents ?? [])];
|
|
242
255
|
const relations: MockSeedRelation[] = [...(seed.relations ?? [])];
|
|
243
256
|
const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug!]));
|
|
244
257
|
const approvals: string[] = [];
|
|
245
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));
|
|
246
290
|
for (const p of seed.proposals ?? []) {
|
|
247
291
|
proposals.push({
|
|
248
292
|
proposalType: "create",
|
|
@@ -404,11 +448,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
404
448
|
});
|
|
405
449
|
|
|
406
450
|
/** The seeded read doors (1.5); null when `path` is none of them. */
|
|
407
|
-
function readSeeded(path: string, query: URLSearchParams): Response | null {
|
|
451
|
+
function readSeeded(path: string, query: URLSearchParams, scope: readonly string[]): Response | null {
|
|
452
|
+
const spaces = readSpaces(scope);
|
|
408
453
|
const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
|
|
409
454
|
if (conn) {
|
|
410
455
|
const id = decodeURIComponent(conn[1]);
|
|
411
|
-
const ws = query.get("workspaceId");
|
|
456
|
+
const ws = query.get("workspaceId") ?? undefined;
|
|
412
457
|
const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
|
|
413
458
|
const inLens = (e: Record<string, unknown>) => !ws || e.workspaceId === ws || e.workspaceId == null;
|
|
414
459
|
const connections: Array<Record<string, unknown>> = [];
|
|
@@ -421,10 +466,15 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
421
466
|
const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
|
|
422
467
|
const other = entities.find((e) => e.id === otherId);
|
|
423
468
|
// 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;
|
|
469
|
+
if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
|
|
425
470
|
connections.push({
|
|
426
471
|
entityId: otherId,
|
|
427
|
-
|
|
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
|
+
},
|
|
428
478
|
label: r.type,
|
|
429
479
|
direction: outgoing ? "outgoing" : "incoming",
|
|
430
480
|
source: "graph",
|
|
@@ -457,8 +507,41 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
457
507
|
const entity = /^\/entities\/([^/]+)$/.exec(path);
|
|
458
508
|
if (entity) {
|
|
459
509
|
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 });
|
|
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
|
+
});
|
|
462
545
|
}
|
|
463
546
|
const doc = /^\/documents\/([^/]+)$/.exec(path);
|
|
464
547
|
if (doc) {
|
|
@@ -535,12 +618,31 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
535
618
|
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
536
619
|
if (method === "GET" && path === "/entities") {
|
|
537
620
|
// The pod answers an ARRAY (rest/entities.ts), not a page object.
|
|
538
|
-
const ws = query.get("workspaceId");
|
|
621
|
+
const ws = query.get("workspaceId") || undefined;
|
|
539
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";
|
|
540
627
|
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
541
628
|
const limit = Number(query.get("limit") ?? 50) || 50;
|
|
542
|
-
|
|
543
|
-
|
|
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
|
+
);
|
|
544
646
|
}
|
|
545
647
|
if (method === "GET" && path === "/proposals") {
|
|
546
648
|
// The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
|
|
@@ -561,7 +663,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
561
663
|
return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
|
|
562
664
|
}
|
|
563
665
|
if (method === "GET") {
|
|
564
|
-
const read = readSeeded(path, query);
|
|
666
|
+
const read = readSeeded(path, query, row.scope);
|
|
565
667
|
if (read) return read;
|
|
566
668
|
}
|
|
567
669
|
const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
|
|
@@ -581,6 +683,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
581
683
|
approvals.push(p.id);
|
|
582
684
|
return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
|
|
583
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
|
+
}
|
|
584
692
|
if (method !== "GET") {
|
|
585
693
|
writes.push({ method, path, body });
|
|
586
694
|
const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
@@ -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
|
+
}
|
package/src/testing/seed.ts
CHANGED
|
@@ -14,8 +14,41 @@
|
|
|
14
14
|
* `relations` as graph edges for `GET /entities/:id/connections`, `proposals`
|
|
15
15
|
* as `GET /proposals?view=basic` rows, `spaces` name each granted space's
|
|
16
16
|
* template (`synap_grant[].templateSlug` on the token response).
|
|
17
|
+
*
|
|
18
|
+
* An entity's roles ride on its row as `facets`, the shape `GET /entities?
|
|
19
|
+
* includeFacets=true` answers them (`{ facetId, slug, properties, status }`),
|
|
20
|
+
* plus the seed-only space each is stamped in, so a seed row reads like a pod
|
|
21
|
+
* row read with includeFacets. The mock derives `facetSlugs` and the reach rule
|
|
22
|
+
* from them and serves neither field unless asked, as the pod.
|
|
17
23
|
*/
|
|
18
24
|
import type { MockProposal } from "./auth-server";
|
|
25
|
+
import { desugarSeedRoles } from "./roles";
|
|
26
|
+
|
|
27
|
+
/** One role an entity wears (backend `FacetRowAnnotation`, plus the space it is stamped in). */
|
|
28
|
+
export interface MockSeedFacet {
|
|
29
|
+
facetId: string;
|
|
30
|
+
/** The role's slug, e.g. `lead`. */
|
|
31
|
+
slug: string;
|
|
32
|
+
/** The facet's overlay properties. Default `{}`. */
|
|
33
|
+
properties?: Record<string, unknown>;
|
|
34
|
+
/** Its lifecycle status. Default null. */
|
|
35
|
+
status?: string | null;
|
|
36
|
+
/**
|
|
37
|
+
* Seed only, never served: the space the role is in, i.e. the space whose
|
|
38
|
+
* members see the facet (stamped there, or a pod-wide facet of a role granted
|
|
39
|
+
* to it). A role in a granted space puts a pod-wide entity in the app's
|
|
40
|
+
* reach; a workspace lens sees the roles of that space only.
|
|
41
|
+
*/
|
|
42
|
+
workspaceId: string;
|
|
43
|
+
/**
|
|
44
|
+
* Seed only, never served: the role is SHARED, i.e. stored pod-wide (null
|
|
45
|
+
* `workspaceId`) and granted to `workspaceId`, which then means "the space it
|
|
46
|
+
* is granted to". Default false = stamped in `workspaceId`. The pod treats the
|
|
47
|
+
* two alike for reach, except `includePodWide=false` on a space lens, which
|
|
48
|
+
* drops a pod-wide row reached only through a shared role.
|
|
49
|
+
*/
|
|
50
|
+
sharedRole?: boolean;
|
|
51
|
+
}
|
|
19
52
|
|
|
20
53
|
export interface MockSeedEntity {
|
|
21
54
|
id: string;
|
|
@@ -23,6 +56,21 @@ export interface MockSeedEntity {
|
|
|
23
56
|
workspaceId?: string | null;
|
|
24
57
|
title?: string | null;
|
|
25
58
|
properties?: Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* The roles (facets) it wears, in the wire shape plus the space each is in
|
|
61
|
+
* (`MockSeedFacet`). SEED INPUT ONLY: the pod sends them only to
|
|
62
|
+
* `includeFacets`, and so does the mock. A pod-wide entity (`workspaceId:
|
|
63
|
+
* null`) with a role in a granted space is readable by the app, as a member
|
|
64
|
+
* of that space sees it. Absent = none.
|
|
65
|
+
*/
|
|
66
|
+
facets?: MockSeedFacet[];
|
|
67
|
+
/**
|
|
68
|
+
* @deprecated Use `facets`; removed in 2.0. Shorthand for `facets` entries
|
|
69
|
+
* with no id and no overlay: `parseMockPodSeed` turns each into a facet (a
|
|
70
|
+
* generated `facetId`, empty properties, a null status). A role stated in
|
|
71
|
+
* both is refused.
|
|
72
|
+
*/
|
|
73
|
+
seedRoles?: Array<{ profileSlug: string; workspaceId: string }>;
|
|
26
74
|
[key: string]: unknown;
|
|
27
75
|
}
|
|
28
76
|
|
|
@@ -113,8 +161,36 @@ export function parseMockPodSeed(input: string | unknown): MockPodSeed {
|
|
|
113
161
|
for (const field of REQUIRED[key]) {
|
|
114
162
|
if (typeof (row as Record<string, unknown>)[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
|
|
115
163
|
}
|
|
164
|
+
if (key === "entities") checkFacets((row as Record<string, unknown>).facets, `seed.entities[${i}].facets`);
|
|
116
165
|
});
|
|
117
166
|
}
|
|
118
|
-
|
|
119
|
-
|
|
167
|
+
(obj.entities as Array<Record<string, unknown>> | undefined)?.forEach((row, i) => {
|
|
168
|
+
const roles = row.seedRoles;
|
|
169
|
+
if (roles === undefined) return;
|
|
170
|
+
if (!Array.isArray(roles)) throw new TypeError(`seed.entities[${i}].seedRoles: expected an array`);
|
|
171
|
+
roles.forEach((r, j) => {
|
|
172
|
+
for (const field of ["profileSlug", "workspaceId"]) {
|
|
173
|
+
if (!r || typeof r !== "object" || typeof (r as Record<string, unknown>)[field] !== "string") {
|
|
174
|
+
throw new TypeError(`seed.entities[${i}].seedRoles[${j}].${field}: expected a string`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// A JSON round trip: what the mock serves is what a file could hold. Then ONE role model: seedRoles → facets.
|
|
180
|
+
const out = JSON.parse(JSON.stringify(obj)) as MockPodSeed;
|
|
181
|
+
if (out.entities) out.entities = out.entities.map((row, i) => desugarSeedRoles(row, `seed.entities[${i}]`) as MockSeedEntity);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function checkFacets(facets: unknown, at: string): void {
|
|
186
|
+
if (facets === undefined) return;
|
|
187
|
+
if (!Array.isArray(facets)) throw new TypeError(`${at}: expected an array`);
|
|
188
|
+
facets.forEach((f, j) => {
|
|
189
|
+
if (!f || typeof f !== "object" || Array.isArray(f)) throw new TypeError(`${at}[${j}]: expected an object`);
|
|
190
|
+
for (const field of ["facetId", "slug", "workspaceId"]) {
|
|
191
|
+
if (typeof (f as Record<string, unknown>)[field] !== "string") throw new TypeError(`${at}[${j}].${field}: expected a string`);
|
|
192
|
+
}
|
|
193
|
+
const shared = (f as Record<string, unknown>).sharedRole;
|
|
194
|
+
if (shared !== undefined && typeof shared !== "boolean") throw new TypeError(`${at}[${j}].sharedRole: expected a boolean`);
|
|
195
|
+
});
|
|
120
196
|
}
|