@synap-core/sdk 1.5.0 → 1.5.2
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/AGENTS.md +250 -0
- package/CHANGELOG.md +148 -0
- package/README.md +17 -1
- package/dist/connect.cjs +38 -7
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +55 -3
- package/dist/connect.d.ts +55 -3
- package/dist/connect.js +38 -7
- package/dist/connect.js.map +1 -1
- package/dist/testing.cjs +188 -16
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +69 -2
- package/dist/testing.d.ts +69 -2
- package/dist/testing.js +188 -16
- package/dist/testing.js.map +1 -1
- package/package.json +10 -9
- package/src/connect/auth.ts +29 -6
- package/src/connect/client.ts +58 -2
- package/src/connect/flow.ts +23 -2
- package/src/connect/hub-types.ts +17 -0
- package/src/connect/index.ts +2 -0
- package/src/testing/auth-server.ts +230 -18
- package/src/testing/roles.ts +27 -0
- package/src/testing/seed.ts +78 -2
|
@@ -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,19 +57,38 @@
|
|
|
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";
|
|
55
65
|
|
|
66
|
+
/** Space -> readable kinds (null = all), what a token's `entity.*.read` grant rows add up to. */
|
|
67
|
+
type ReadSpaces = ReadonlyMap<string, ReadonlySet<string> | null>;
|
|
68
|
+
|
|
56
69
|
export const MOCK_GRANT: PodGrant[] = [
|
|
57
70
|
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
58
71
|
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
59
72
|
];
|
|
60
73
|
|
|
74
|
+
/** The pod's step-up refusal text (backend `STEP_UP_REQUIRED_MESSAGE`). */
|
|
75
|
+
const STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
|
|
76
|
+
|
|
61
77
|
export const MOCK_USER: PodUser = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
62
78
|
|
|
63
|
-
export type MockHubFailure =
|
|
79
|
+
export type MockHubFailure =
|
|
80
|
+
| "rate_limited"
|
|
81
|
+
| "forbidden"
|
|
82
|
+
| "door_unsupported"
|
|
83
|
+
/** 403 `{ error: <the pod's step-up message> }`: a stale device at a credential door (the Hub REST body has no reasonCode). */
|
|
84
|
+
| "step_up_required"
|
|
85
|
+
/** 409 problem `CP_ACCOUNT_EMAIL_REQUIRED` (Control Plane: no verified email). */
|
|
86
|
+
| "email_unverified"
|
|
87
|
+
/** 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE` (Control Plane: an app origin that is not HTTPS). */
|
|
88
|
+
| "origin_insecure"
|
|
89
|
+
| "unreachable"
|
|
90
|
+
| "server"
|
|
91
|
+
| "unavailable";
|
|
64
92
|
|
|
65
93
|
export interface MockAuthServerOptions {
|
|
66
94
|
issuer?: string;
|
|
@@ -95,6 +123,14 @@ export interface MockAuthServerOptions {
|
|
|
95
123
|
* not name it; the SDK reads an agent connection).
|
|
96
124
|
*/
|
|
97
125
|
actsAs?: PodActsAs;
|
|
126
|
+
/**
|
|
127
|
+
* A Synap app's device starts STALE: `POST /proposals/:id/approve` is refused
|
|
128
|
+
* (403 `{ error: <step-up message> }`, the Hub REST shape) until the person
|
|
129
|
+
* re-confirms at the pod: `/authorize?prompt=login`, the code redeemed with the
|
|
130
|
+
* device's refresh token as `synap_device_refresh_token`. Needs `actsAs: "person"`.
|
|
131
|
+
* `expireStepUp()` makes it stale again (the 15-minute window).
|
|
132
|
+
*/
|
|
133
|
+
requireStepUp?: boolean;
|
|
98
134
|
/**
|
|
99
135
|
* The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
|
|
100
136
|
* before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
|
|
@@ -165,6 +201,10 @@ export interface MockAuthServer {
|
|
|
165
201
|
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
166
202
|
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
167
203
|
consumeRefreshToken(token: string): void;
|
|
204
|
+
/** The pod now says `synap_acts_as: v` (null = says nothing): the app left, or joined, the Synap list. */
|
|
205
|
+
setActsAs(v: PodActsAs | null): void;
|
|
206
|
+
/** The device's last real sign-in is older than the step-up window. */
|
|
207
|
+
expireStepUp(): void;
|
|
168
208
|
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
169
209
|
loseNextRefreshRace(): void;
|
|
170
210
|
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
@@ -188,6 +228,8 @@ interface CodeRow {
|
|
|
188
228
|
challenge: string;
|
|
189
229
|
resource: string | null;
|
|
190
230
|
scope: string[];
|
|
231
|
+
/** `prompt=login` from a Synap app: its code re-confirms a device (and must come with its refresh token). */
|
|
232
|
+
stepUp: boolean;
|
|
191
233
|
}
|
|
192
234
|
|
|
193
235
|
interface AccessRow {
|
|
@@ -236,13 +278,68 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
236
278
|
const writes: Array<{ method: string; path: string; body: unknown }> = [];
|
|
237
279
|
const proposals: MockProposal[] = [];
|
|
238
280
|
const seed = options.seed ?? {};
|
|
239
|
-
|
|
281
|
+
// One role model: a seed is desugared by parseMockPodSeed; `entities` (and a seed built in code) here.
|
|
282
|
+
const entities: Array<Record<string, unknown>> = [...(seed.entities ?? []), ...(options.entities ?? [])].map((e, i) =>
|
|
283
|
+
desugarSeedRoles(e, `entities[${i}]`),
|
|
284
|
+
);
|
|
240
285
|
const sessions: MockSeedSession[] = [...(seed.sessions ?? [])];
|
|
241
286
|
const documents: MockSeedDocument[] = [...(seed.documents ?? [])];
|
|
242
287
|
const relations: MockSeedRelation[] = [...(seed.relations ?? [])];
|
|
243
288
|
const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug!]));
|
|
244
289
|
const approvals: string[] = [];
|
|
245
290
|
const seededAt = new Date(now()).toISOString();
|
|
291
|
+
|
|
292
|
+
/** The roles (facets) an entity wears: the seed's `facets` (`seedRoles` was desugared into them). */
|
|
293
|
+
const rolesOf = (e: Record<string, unknown>): MockSeedFacet[] =>
|
|
294
|
+
Array.isArray(e.facets) ? (e.facets as MockSeedFacet[]).filter((f) => typeof f?.slug === "string") : [];
|
|
295
|
+
/**
|
|
296
|
+
* What the token's grant lets it read of an entity (the pod's grant clause):
|
|
297
|
+
* a row of a granted space, or a pod-wide row (`workspaceId` null) with a live
|
|
298
|
+
* role in one. The pod sends no role field on an entity row, so the mock takes
|
|
299
|
+
* its roles as SEED input (`facets`) and answers them only when asked.
|
|
300
|
+
*/
|
|
301
|
+
function readableEntity(e: Record<string, unknown>, spaces: ReadSpaces, only?: string | null): boolean {
|
|
302
|
+
if (typeof e.workspaceId === "string" && kindReadable(e, spaces, e.workspaceId) && (!only || e.workspaceId === only)) return true;
|
|
303
|
+
// 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.
|
|
304
|
+
return facetsOf(e, spaces, only ?? undefined).some((f) => e.workspaceId == null || !f.sharedRole);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The roles of `e` a read sees: what a member of a granted space sees
|
|
308
|
+
* (backend `facetVisibilityConditions` under the token's grant), i.e. a role
|
|
309
|
+
* whose seed space is granted; a workspace lens sees that space's roles only.
|
|
310
|
+
*/
|
|
311
|
+
const facetsOf = (e: Record<string, unknown>, spaces: ReadSpaces, lens?: string): MockSeedFacet[] =>
|
|
312
|
+
rolesOf(e).filter((f) => typeof f.workspaceId === "string" && kindReadable(e, spaces, f.workspaceId) && (lens === undefined || f.workspaceId === lens));
|
|
313
|
+
/** backend `FacetRowAnnotation`: what `includeFacets=true` puts on a row. */
|
|
314
|
+
const facetRow = (f: MockSeedFacet) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
|
|
315
|
+
/** A row as the Hub answers it (`entityToWire`): roles ride on it only when asked. */
|
|
316
|
+
const entityWire = (e: Record<string, unknown>): Record<string, unknown> => {
|
|
317
|
+
const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
|
|
318
|
+
return wire;
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* Space -> the kinds the token reads there (null = every kind), as the pod's
|
|
322
|
+
* kindClause: `entity.<kind>.read` narrows to that kind; `entity.read` and
|
|
323
|
+
* `entity.*.read` read all.
|
|
324
|
+
*/
|
|
325
|
+
const readSpaces = (scope: readonly string[]): ReadSpaces => {
|
|
326
|
+
const out = new Map<string, Set<string> | null>();
|
|
327
|
+
for (const g of grant) {
|
|
328
|
+
const m = /^entity\.(?:(.+)\.)?read$/.exec(g.permission);
|
|
329
|
+
if (!m || !scope.includes(g.permission)) continue;
|
|
330
|
+
const kind = m[1];
|
|
331
|
+
if (kind === undefined || kind === "*") out.set(g.workspaceId, null);
|
|
332
|
+
else if (out.get(g.workspaceId) !== null) out.set(g.workspaceId, (out.get(g.workspaceId) ?? new Set<string>()).add(kind));
|
|
333
|
+
else continue;
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
};
|
|
337
|
+
/** Whether the token reads rows of `e`'s kind in `space`. */
|
|
338
|
+
const kindReadable = (e: Record<string, unknown>, spaces: ReadSpaces, space: string): boolean => {
|
|
339
|
+
if (!spaces.has(space)) return false;
|
|
340
|
+
const kinds = spaces.get(space);
|
|
341
|
+
return kinds === null || kinds === undefined ? kinds === null : kinds.has(String(e.profileSlug ?? e.type ?? ""));
|
|
342
|
+
};
|
|
246
343
|
for (const p of seed.proposals ?? []) {
|
|
247
344
|
proposals.push({
|
|
248
345
|
proposalType: "create",
|
|
@@ -257,7 +354,11 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
257
354
|
...p,
|
|
258
355
|
});
|
|
259
356
|
}
|
|
260
|
-
|
|
357
|
+
// What the pod says about the app, re-judged on every token response (`setActsAs` moves it).
|
|
358
|
+
let actsAs = options.actsAs;
|
|
359
|
+
const writeMode = () => options.writeMode ?? (actsAs === "person" ? "applied" : "proposed");
|
|
360
|
+
// A Synap app's device clock: stale until the person re-confirms at the pod (`requireStepUp`).
|
|
361
|
+
let deviceFresh = !options.requireStepUp;
|
|
261
362
|
const graceMs = (options.refreshGraceSec ?? 30) * 1000;
|
|
262
363
|
const app = options.app === undefined ? MOCK_APP : options.app;
|
|
263
364
|
let proposalSeq = 0;
|
|
@@ -309,7 +410,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
309
410
|
.map((g) => (g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId)! })),
|
|
310
411
|
synap_user: user,
|
|
311
412
|
...(app ? { synap_app: app } : {}),
|
|
312
|
-
...(
|
|
413
|
+
...(actsAs ? { synap_acts_as: actsAs } : {}),
|
|
313
414
|
};
|
|
314
415
|
if (pair) {
|
|
315
416
|
body.refresh_token = pair.refreshToken;
|
|
@@ -336,6 +437,25 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
336
437
|
if (form.resource !== undefined && row.resource && form.resource !== row.resource) {
|
|
337
438
|
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
338
439
|
}
|
|
440
|
+
// A Synap app's step-up (`routes.ts`): the code and the device's refresh token must agree.
|
|
441
|
+
if (actsAs === "person" && row.stepUp !== Boolean(form.synap_device_refresh_token)) {
|
|
442
|
+
return oauthError(
|
|
443
|
+
"invalid_grant",
|
|
444
|
+
row.stepUp
|
|
445
|
+
? "This code re-confirms a device: send its refresh token as synap_device_refresh_token"
|
|
446
|
+
: "This code signs a new device in; a re-confirmation asks with prompt=login",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (row.stepUp) {
|
|
450
|
+
const device = refreshTokens.get(form.synap_device_refresh_token!);
|
|
451
|
+
if (!device || device.clientId !== row.clientId || device.used || device.revoked) {
|
|
452
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
453
|
+
}
|
|
454
|
+
// The device keeps its family: its clock moves, nothing else is made or ended.
|
|
455
|
+
device.used = true;
|
|
456
|
+
deviceFresh = true;
|
|
457
|
+
return json(200, tokenBody(row.scope, row.clientId, device.family));
|
|
458
|
+
}
|
|
339
459
|
// The latest connect wins: a new code ends this client's older families.
|
|
340
460
|
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
341
461
|
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
@@ -404,11 +524,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
404
524
|
});
|
|
405
525
|
|
|
406
526
|
/** The seeded read doors (1.5); null when `path` is none of them. */
|
|
407
|
-
function readSeeded(path: string, query: URLSearchParams): Response | null {
|
|
527
|
+
function readSeeded(path: string, query: URLSearchParams, scope: readonly string[]): Response | null {
|
|
528
|
+
const spaces = readSpaces(scope);
|
|
408
529
|
const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
|
|
409
530
|
if (conn) {
|
|
410
531
|
const id = decodeURIComponent(conn[1]);
|
|
411
|
-
const ws = query.get("workspaceId");
|
|
532
|
+
const ws = query.get("workspaceId") ?? undefined;
|
|
412
533
|
const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
|
|
413
534
|
const inLens = (e: Record<string, unknown>) => !ws || e.workspaceId === ws || e.workspaceId == null;
|
|
414
535
|
const connections: Array<Record<string, unknown>> = [];
|
|
@@ -421,10 +542,18 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
421
542
|
const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
|
|
422
543
|
const other = entities.find((e) => e.id === otherId);
|
|
423
544
|
// 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;
|
|
545
|
+
if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
|
|
546
|
+
// An edge between two pod-wide records is pod-wide (owner-only): not readable by a space-scoped app.
|
|
547
|
+
const self = entities.find((e) => e.id === id);
|
|
548
|
+
if (self && self.workspaceId == null && other.workspaceId == null) continue;
|
|
425
549
|
connections.push({
|
|
426
550
|
entityId: otherId,
|
|
427
|
-
|
|
551
|
+
// relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
|
|
552
|
+
entity: {
|
|
553
|
+
...entityWire(other),
|
|
554
|
+
type: other.profileSlug ?? other.type ?? null,
|
|
555
|
+
facetSlugs: rolesOf(other).length > 0 ? facetsOf(other, spaces, ws).map((f) => f.slug) : (other.facetSlugs ?? []),
|
|
556
|
+
},
|
|
428
557
|
label: r.type,
|
|
429
558
|
direction: outgoing ? "outgoing" : "incoming",
|
|
430
559
|
source: "graph",
|
|
@@ -457,8 +586,41 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
457
586
|
const entity = /^\/entities\/([^/]+)$/.exec(path);
|
|
458
587
|
if (entity) {
|
|
459
588
|
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 });
|
|
589
|
+
// 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.
|
|
590
|
+
return found && readableEntity(found, spaces) ? json(200, entityWire(found)) : new Response(null, { status: 404 });
|
|
591
|
+
}
|
|
592
|
+
const roles = /^\/entities\/([^/]+)\/facets$/.exec(path);
|
|
593
|
+
if (roles) {
|
|
594
|
+
const found = entities.find((e) => e.id === decodeURIComponent(roles[1]));
|
|
595
|
+
if (!found || !readableEntity(found, spaces)) return new Response(null, { status: 404 });
|
|
596
|
+
// getEffectiveFacets under the whole floor: each live facet row, its role-profile, that profile's effective properties.
|
|
597
|
+
return json(200, {
|
|
598
|
+
facets: facetsOf(found, spaces).map((f) => ({
|
|
599
|
+
facet: {
|
|
600
|
+
id: f.facetId,
|
|
601
|
+
entityId: found.id,
|
|
602
|
+
profileId: `profile_${f.slug}`,
|
|
603
|
+
userId: found.userId ?? user.id,
|
|
604
|
+
// A stamped facet answers its space; a shared role's facet is stored pod-wide.
|
|
605
|
+
workspaceId: f.sharedRole ? null : f.workspaceId,
|
|
606
|
+
contextEntityId: null,
|
|
607
|
+
status: f.status ?? null,
|
|
608
|
+
statusCategory: null,
|
|
609
|
+
properties: f.properties ?? {},
|
|
610
|
+
metadata: {},
|
|
611
|
+
createdByKind: null,
|
|
612
|
+
createdByUserId: null,
|
|
613
|
+
agentUserId: null,
|
|
614
|
+
sourceProposalId: null,
|
|
615
|
+
correlationId: null,
|
|
616
|
+
createdAt: seededAt,
|
|
617
|
+
updatedAt: seededAt,
|
|
618
|
+
deletedAt: null,
|
|
619
|
+
},
|
|
620
|
+
profile: { id: `profile_${f.slug}`, slug: f.slug, profileKind: "role" },
|
|
621
|
+
effectiveProperties: [],
|
|
622
|
+
})),
|
|
623
|
+
});
|
|
462
624
|
}
|
|
463
625
|
const doc = /^\/documents\/([^/]+)$/.exec(path);
|
|
464
626
|
if (doc) {
|
|
@@ -523,6 +685,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
523
685
|
return json(403, { error: "Forbidden" });
|
|
524
686
|
case "door_unsupported":
|
|
525
687
|
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
688
|
+
case "step_up_required":
|
|
689
|
+
return json(403, { error: STEP_UP_MESSAGE });
|
|
690
|
+
case "email_unverified":
|
|
691
|
+
return json(409, { error: "Add and verify an email on your Synap account to connect a pod.", code: "CP_ACCOUNT_EMAIL_REQUIRED", recovery: { action: "update_control_plane_profile" } });
|
|
692
|
+
case "origin_insecure":
|
|
693
|
+
return json(409, { error: "This application must be served over HTTPS to connect to a Pod.", code: "FEDERATED_APPLICATION_ORIGIN_INSECURE" });
|
|
526
694
|
case "server":
|
|
527
695
|
return json(500, { error: "Internal error" });
|
|
528
696
|
case "unavailable":
|
|
@@ -535,12 +703,31 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
535
703
|
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
536
704
|
if (method === "GET" && path === "/entities") {
|
|
537
705
|
// The pod answers an ARRAY (rest/entities.ts), not a page object.
|
|
538
|
-
const ws = query.get("workspaceId");
|
|
706
|
+
const ws = query.get("workspaceId") || undefined;
|
|
539
707
|
const kind = query.get("profileSlug");
|
|
708
|
+
const role = query.get("facetSlug");
|
|
709
|
+
// A workspace lens keeps the pod-wide rows unless told not to (rest/entities.ts: `!== "false"`).
|
|
710
|
+
const podWide = query.get("includePodWide") !== "false";
|
|
711
|
+
const withFacets = query.get("includeFacets") === "true";
|
|
540
712
|
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
541
713
|
const limit = Number(query.get("limit") ?? 50) || 50;
|
|
542
|
-
|
|
543
|
-
|
|
714
|
+
// Granted spaces' rows, plus the pod-wide rows roled in them (read-only, as a member of the space).
|
|
715
|
+
const spaces = readSpaces(row.scope);
|
|
716
|
+
const rows = entities.filter((e) => {
|
|
717
|
+
if (!readableEntity(e, spaces, ws)) return false;
|
|
718
|
+
// includePodWide=false keeps the rows with a role STAMPED in the space; it drops those reached only through a shared role.
|
|
719
|
+
if (ws && !podWide && e.workspaceId !== ws && !facetsOf(e, spaces, ws).some((f) => !f.sharedRole)) return false;
|
|
720
|
+
const slugs = facetsOf(e, spaces, ws).map((f) => f.slug);
|
|
721
|
+
// A profileSlug naming a role matches its wearers too (entities.list's facet branch).
|
|
722
|
+
return (!kind || e.profileSlug === kind || slugs.includes(kind)) && (!role || slugs.includes(role));
|
|
723
|
+
});
|
|
724
|
+
return json(
|
|
725
|
+
200,
|
|
726
|
+
rows.slice(offset, offset + limit).map((e) => {
|
|
727
|
+
const facets = withFacets ? facetsOf(e, spaces, ws).map(facetRow) : [];
|
|
728
|
+
return facets.length > 0 ? { ...entityWire(e), facetSlugs: facets.map((f) => f.slug), facets } : entityWire(e);
|
|
729
|
+
}),
|
|
730
|
+
);
|
|
544
731
|
}
|
|
545
732
|
if (method === "GET" && path === "/proposals") {
|
|
546
733
|
// The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
|
|
@@ -561,15 +748,16 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
561
748
|
return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
|
|
562
749
|
}
|
|
563
750
|
if (method === "GET") {
|
|
564
|
-
const read = readSeeded(path, query);
|
|
751
|
+
const read = readSeeded(path, query, row.scope);
|
|
565
752
|
if (read) return read;
|
|
566
753
|
}
|
|
567
754
|
const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
|
|
568
755
|
if (approve) {
|
|
569
756
|
// rejectAgentReviewer: an agent credential never approves, before anything is read.
|
|
570
|
-
if (
|
|
757
|
+
if (actsAs !== "person") {
|
|
571
758
|
return json(403, { error: "An agent credential cannot approve proposals — approve is human review. Approve from a human session." });
|
|
572
759
|
}
|
|
760
|
+
if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
|
|
573
761
|
const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
|
|
574
762
|
if (!p) return json(404, { error: "Proposal not found" });
|
|
575
763
|
// Only where the person granted proposal.approve, for the proposal's own space.
|
|
@@ -581,11 +769,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
581
769
|
approvals.push(p.id);
|
|
582
770
|
return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
|
|
583
771
|
}
|
|
772
|
+
// Roles: an app cannot add one to an existing entity (no grantable `facet.attach`), nor change one on a pod-wide record (`permits()` refuses null).
|
|
773
|
+
const attach = method === "POST" ? /^\/entities\/([^/]+)\/facets$/.exec(path) : null;
|
|
774
|
+
const facetPatch = method === "PATCH" ? /^\/facets\/([^/]+)$/.exec(path) : null;
|
|
775
|
+
if (attach) return json(403, { error: "grant does not allow" });
|
|
776
|
+
if (facetPatch) {
|
|
777
|
+
const fid = decodeURIComponent(facetPatch[1]);
|
|
778
|
+
const parent = entities.find((e) => rolesOf(e).some((f) => f.facetId === fid));
|
|
779
|
+
if (parent && parent.workspaceId == null) return json(403, { error: "grant does not allow" });
|
|
780
|
+
}
|
|
781
|
+
// A pod-wide row's core fields are outside a space-scoped grant: refused, nothing written, no proposal.
|
|
782
|
+
// The pod: 403 "grant does not allow" on the connected person's own record, 404 on a teammate's (outside the write floor).
|
|
783
|
+
const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
|
|
784
|
+
if (patched) {
|
|
785
|
+
const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
|
|
786
|
+
if (target && target.workspaceId == null) {
|
|
787
|
+
return typeof target.userId === "string" && target.userId !== user.id ? new Response(null, { status: 404 }) : json(403, { error: "grant does not allow" });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
584
790
|
if (method !== "GET") {
|
|
585
791
|
writes.push({ method, path, body });
|
|
586
792
|
const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
587
793
|
const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
|
|
588
|
-
if (writeMode === "proposed") {
|
|
794
|
+
if (writeMode() === "proposed") {
|
|
589
795
|
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
590
796
|
proposals.push({
|
|
591
797
|
id: proposalId,
|
|
@@ -706,9 +912,15 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
706
912
|
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
707
913
|
if (decision.deny) return finish({ error: "access_denied" });
|
|
708
914
|
const code = randomToken();
|
|
709
|
-
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge")!, resource, scope });
|
|
915
|
+
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge")!, resource, scope, stepUp: actsAs === "person" && q.get("prompt") === "login" });
|
|
710
916
|
return finish({ code });
|
|
711
917
|
},
|
|
918
|
+
setActsAs(v) {
|
|
919
|
+
actsAs = v ?? undefined;
|
|
920
|
+
},
|
|
921
|
+
expireStepUp() {
|
|
922
|
+
deviceFresh = false;
|
|
923
|
+
},
|
|
712
924
|
consumeRefreshToken(t) {
|
|
713
925
|
const row = refreshTokens.get(t);
|
|
714
926
|
if (row) row.used = true;
|
|
@@ -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
|
}
|