@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
package/dist/testing.d.cts
CHANGED
|
@@ -51,14 +51,63 @@ declare const MOCK_SITE: MockSite;
|
|
|
51
51
|
* `relations` as graph edges for `GET /entities/:id/connections`, `proposals`
|
|
52
52
|
* as `GET /proposals?view=basic` rows, `spaces` name each granted space's
|
|
53
53
|
* template (`synap_grant[].templateSlug` on the token response).
|
|
54
|
+
*
|
|
55
|
+
* An entity's roles ride on its row as `facets`, the shape `GET /entities?
|
|
56
|
+
* includeFacets=true` answers them (`{ facetId, slug, properties, status }`),
|
|
57
|
+
* plus the seed-only space each is stamped in, so a seed row reads like a pod
|
|
58
|
+
* row read with includeFacets. The mock derives `facetSlugs` and the reach rule
|
|
59
|
+
* from them and serves neither field unless asked, as the pod.
|
|
54
60
|
*/
|
|
55
61
|
|
|
62
|
+
/** One role an entity wears (backend `FacetRowAnnotation`, plus the space it is stamped in). */
|
|
63
|
+
interface MockSeedFacet {
|
|
64
|
+
facetId: string;
|
|
65
|
+
/** The role's slug, e.g. `lead`. */
|
|
66
|
+
slug: string;
|
|
67
|
+
/** The facet's overlay properties. Default `{}`. */
|
|
68
|
+
properties?: Record<string, unknown>;
|
|
69
|
+
/** Its lifecycle status. Default null. */
|
|
70
|
+
status?: string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Seed only, never served: the space the role is in, i.e. the space whose
|
|
73
|
+
* members see the facet (stamped there, or a pod-wide facet of a role granted
|
|
74
|
+
* to it). A role in a granted space puts a pod-wide entity in the app's
|
|
75
|
+
* reach; a workspace lens sees the roles of that space only.
|
|
76
|
+
*/
|
|
77
|
+
workspaceId: string;
|
|
78
|
+
/**
|
|
79
|
+
* Seed only, never served: the role is SHARED, i.e. stored pod-wide (null
|
|
80
|
+
* `workspaceId`) and granted to `workspaceId`, which then means "the space it
|
|
81
|
+
* is granted to". Default false = stamped in `workspaceId`. The pod treats the
|
|
82
|
+
* two alike for reach, except `includePodWide=false` on a space lens, which
|
|
83
|
+
* drops a pod-wide row reached only through a shared role.
|
|
84
|
+
*/
|
|
85
|
+
sharedRole?: boolean;
|
|
86
|
+
}
|
|
56
87
|
interface MockSeedEntity {
|
|
57
88
|
id: string;
|
|
58
89
|
profileSlug?: string;
|
|
59
90
|
workspaceId?: string | null;
|
|
60
91
|
title?: string | null;
|
|
61
92
|
properties?: Record<string, unknown>;
|
|
93
|
+
/**
|
|
94
|
+
* The roles (facets) it wears, in the wire shape plus the space each is in
|
|
95
|
+
* (`MockSeedFacet`). SEED INPUT ONLY: the pod sends them only to
|
|
96
|
+
* `includeFacets`, and so does the mock. A pod-wide entity (`workspaceId:
|
|
97
|
+
* null`) with a role in a granted space is readable by the app, as a member
|
|
98
|
+
* of that space sees it. Absent = none.
|
|
99
|
+
*/
|
|
100
|
+
facets?: MockSeedFacet[];
|
|
101
|
+
/**
|
|
102
|
+
* @deprecated Use `facets`; removed in 2.0. Shorthand for `facets` entries
|
|
103
|
+
* with no id and no overlay: `parseMockPodSeed` turns each into a facet (a
|
|
104
|
+
* generated `facetId`, empty properties, a null status). A role stated in
|
|
105
|
+
* both is refused.
|
|
106
|
+
*/
|
|
107
|
+
seedRoles?: Array<{
|
|
108
|
+
profileSlug: string;
|
|
109
|
+
workspaceId: string;
|
|
110
|
+
}>;
|
|
62
111
|
[key: string]: unknown;
|
|
63
112
|
}
|
|
64
113
|
interface MockSeedSession {
|
|
@@ -123,7 +172,13 @@ declare function parseMockPodSeed(input: string | unknown): MockPodSeed;
|
|
|
123
172
|
declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
124
173
|
declare const MOCK_GRANT: PodGrant[];
|
|
125
174
|
declare const MOCK_USER: PodUser;
|
|
126
|
-
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
|
|
175
|
+
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
|
|
176
|
+
/** 403 `{ error: <the pod's step-up message> }`: a stale device at a credential door (the Hub REST body has no reasonCode). */
|
|
177
|
+
| "step_up_required"
|
|
178
|
+
/** 409 problem `CP_ACCOUNT_EMAIL_REQUIRED` (Control Plane: no verified email). */
|
|
179
|
+
| "email_unverified"
|
|
180
|
+
/** 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE` (Control Plane: an app origin that is not HTTPS). */
|
|
181
|
+
| "origin_insecure" | "unreachable" | "server" | "unavailable";
|
|
127
182
|
interface MockAuthServerOptions {
|
|
128
183
|
issuer?: string;
|
|
129
184
|
/** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
|
|
@@ -160,6 +215,14 @@ interface MockAuthServerOptions {
|
|
|
160
215
|
* not name it; the SDK reads an agent connection).
|
|
161
216
|
*/
|
|
162
217
|
actsAs?: PodActsAs;
|
|
218
|
+
/**
|
|
219
|
+
* A Synap app's device starts STALE: `POST /proposals/:id/approve` is refused
|
|
220
|
+
* (403 `{ error: <step-up message> }`, the Hub REST shape) until the person
|
|
221
|
+
* re-confirms at the pod: `/authorize?prompt=login`, the code redeemed with the
|
|
222
|
+
* device's refresh token as `synap_device_refresh_token`. Needs `actsAs: "person"`.
|
|
223
|
+
* `expireStepUp()` makes it stale again (the 15-minute window).
|
|
224
|
+
*/
|
|
225
|
+
requireStepUp?: boolean;
|
|
163
226
|
/**
|
|
164
227
|
* The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
|
|
165
228
|
* before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
|
|
@@ -235,6 +298,10 @@ interface MockAuthServer {
|
|
|
235
298
|
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
236
299
|
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
237
300
|
consumeRefreshToken(token: string): void;
|
|
301
|
+
/** The pod now says `synap_acts_as: v` (null = says nothing): the app left, or joined, the Synap list. */
|
|
302
|
+
setActsAs(v: PodActsAs | null): void;
|
|
303
|
+
/** The device's last real sign-in is older than the step-up window. */
|
|
304
|
+
expireStepUp(): void;
|
|
238
305
|
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
239
306
|
loseNextRefreshRace(): void;
|
|
240
307
|
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
@@ -361,4 +428,4 @@ interface MockPod {
|
|
|
361
428
|
}
|
|
362
429
|
declare function createMockPod(options?: MockPodOptions): MockPod;
|
|
363
430
|
|
|
364
|
-
export { type AuthorizeDecision, MOCK_APP, MOCK_APP_AGENT_ID, MOCK_AUTH_ISSUER, MOCK_CLOUD_URL, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_HTML_SHARE, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCloud, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockPodSeed, type MockProposal, type MockRequest, type MockSeedDocument, type MockSeedEntity, type MockSeedRelation, type MockSeedSession, type MockSeedSpace, type MockSite, type MockSubmission, createMockAuthServer, createMockCloud, createMockPod, parseMockPodSeed };
|
|
431
|
+
export { type AuthorizeDecision, MOCK_APP, MOCK_APP_AGENT_ID, MOCK_AUTH_ISSUER, MOCK_CLOUD_URL, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_HTML_SHARE, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCloud, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockPodSeed, type MockProposal, type MockRequest, type MockSeedDocument, type MockSeedEntity, type MockSeedFacet, type MockSeedRelation, type MockSeedSession, type MockSeedSpace, type MockSite, type MockSubmission, createMockAuthServer, createMockCloud, createMockPod, parseMockPodSeed };
|
package/dist/testing.d.ts
CHANGED
|
@@ -51,14 +51,63 @@ declare const MOCK_SITE: MockSite;
|
|
|
51
51
|
* `relations` as graph edges for `GET /entities/:id/connections`, `proposals`
|
|
52
52
|
* as `GET /proposals?view=basic` rows, `spaces` name each granted space's
|
|
53
53
|
* template (`synap_grant[].templateSlug` on the token response).
|
|
54
|
+
*
|
|
55
|
+
* An entity's roles ride on its row as `facets`, the shape `GET /entities?
|
|
56
|
+
* includeFacets=true` answers them (`{ facetId, slug, properties, status }`),
|
|
57
|
+
* plus the seed-only space each is stamped in, so a seed row reads like a pod
|
|
58
|
+
* row read with includeFacets. The mock derives `facetSlugs` and the reach rule
|
|
59
|
+
* from them and serves neither field unless asked, as the pod.
|
|
54
60
|
*/
|
|
55
61
|
|
|
62
|
+
/** One role an entity wears (backend `FacetRowAnnotation`, plus the space it is stamped in). */
|
|
63
|
+
interface MockSeedFacet {
|
|
64
|
+
facetId: string;
|
|
65
|
+
/** The role's slug, e.g. `lead`. */
|
|
66
|
+
slug: string;
|
|
67
|
+
/** The facet's overlay properties. Default `{}`. */
|
|
68
|
+
properties?: Record<string, unknown>;
|
|
69
|
+
/** Its lifecycle status. Default null. */
|
|
70
|
+
status?: string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Seed only, never served: the space the role is in, i.e. the space whose
|
|
73
|
+
* members see the facet (stamped there, or a pod-wide facet of a role granted
|
|
74
|
+
* to it). A role in a granted space puts a pod-wide entity in the app's
|
|
75
|
+
* reach; a workspace lens sees the roles of that space only.
|
|
76
|
+
*/
|
|
77
|
+
workspaceId: string;
|
|
78
|
+
/**
|
|
79
|
+
* Seed only, never served: the role is SHARED, i.e. stored pod-wide (null
|
|
80
|
+
* `workspaceId`) and granted to `workspaceId`, which then means "the space it
|
|
81
|
+
* is granted to". Default false = stamped in `workspaceId`. The pod treats the
|
|
82
|
+
* two alike for reach, except `includePodWide=false` on a space lens, which
|
|
83
|
+
* drops a pod-wide row reached only through a shared role.
|
|
84
|
+
*/
|
|
85
|
+
sharedRole?: boolean;
|
|
86
|
+
}
|
|
56
87
|
interface MockSeedEntity {
|
|
57
88
|
id: string;
|
|
58
89
|
profileSlug?: string;
|
|
59
90
|
workspaceId?: string | null;
|
|
60
91
|
title?: string | null;
|
|
61
92
|
properties?: Record<string, unknown>;
|
|
93
|
+
/**
|
|
94
|
+
* The roles (facets) it wears, in the wire shape plus the space each is in
|
|
95
|
+
* (`MockSeedFacet`). SEED INPUT ONLY: the pod sends them only to
|
|
96
|
+
* `includeFacets`, and so does the mock. A pod-wide entity (`workspaceId:
|
|
97
|
+
* null`) with a role in a granted space is readable by the app, as a member
|
|
98
|
+
* of that space sees it. Absent = none.
|
|
99
|
+
*/
|
|
100
|
+
facets?: MockSeedFacet[];
|
|
101
|
+
/**
|
|
102
|
+
* @deprecated Use `facets`; removed in 2.0. Shorthand for `facets` entries
|
|
103
|
+
* with no id and no overlay: `parseMockPodSeed` turns each into a facet (a
|
|
104
|
+
* generated `facetId`, empty properties, a null status). A role stated in
|
|
105
|
+
* both is refused.
|
|
106
|
+
*/
|
|
107
|
+
seedRoles?: Array<{
|
|
108
|
+
profileSlug: string;
|
|
109
|
+
workspaceId: string;
|
|
110
|
+
}>;
|
|
62
111
|
[key: string]: unknown;
|
|
63
112
|
}
|
|
64
113
|
interface MockSeedSession {
|
|
@@ -123,7 +172,13 @@ declare function parseMockPodSeed(input: string | unknown): MockPodSeed;
|
|
|
123
172
|
declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
124
173
|
declare const MOCK_GRANT: PodGrant[];
|
|
125
174
|
declare const MOCK_USER: PodUser;
|
|
126
|
-
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
|
|
175
|
+
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
|
|
176
|
+
/** 403 `{ error: <the pod's step-up message> }`: a stale device at a credential door (the Hub REST body has no reasonCode). */
|
|
177
|
+
| "step_up_required"
|
|
178
|
+
/** 409 problem `CP_ACCOUNT_EMAIL_REQUIRED` (Control Plane: no verified email). */
|
|
179
|
+
| "email_unverified"
|
|
180
|
+
/** 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE` (Control Plane: an app origin that is not HTTPS). */
|
|
181
|
+
| "origin_insecure" | "unreachable" | "server" | "unavailable";
|
|
127
182
|
interface MockAuthServerOptions {
|
|
128
183
|
issuer?: string;
|
|
129
184
|
/** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
|
|
@@ -160,6 +215,14 @@ interface MockAuthServerOptions {
|
|
|
160
215
|
* not name it; the SDK reads an agent connection).
|
|
161
216
|
*/
|
|
162
217
|
actsAs?: PodActsAs;
|
|
218
|
+
/**
|
|
219
|
+
* A Synap app's device starts STALE: `POST /proposals/:id/approve` is refused
|
|
220
|
+
* (403 `{ error: <step-up message> }`, the Hub REST shape) until the person
|
|
221
|
+
* re-confirms at the pod: `/authorize?prompt=login`, the code redeemed with the
|
|
222
|
+
* device's refresh token as `synap_device_refresh_token`. Needs `actsAs: "person"`.
|
|
223
|
+
* `expireStepUp()` makes it stale again (the 15-minute window).
|
|
224
|
+
*/
|
|
225
|
+
requireStepUp?: boolean;
|
|
163
226
|
/**
|
|
164
227
|
* The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
|
|
165
228
|
* before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
|
|
@@ -235,6 +298,10 @@ interface MockAuthServer {
|
|
|
235
298
|
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
236
299
|
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
237
300
|
consumeRefreshToken(token: string): void;
|
|
301
|
+
/** The pod now says `synap_acts_as: v` (null = says nothing): the app left, or joined, the Synap list. */
|
|
302
|
+
setActsAs(v: PodActsAs | null): void;
|
|
303
|
+
/** The device's last real sign-in is older than the step-up window. */
|
|
304
|
+
expireStepUp(): void;
|
|
238
305
|
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
239
306
|
loseNextRefreshRace(): void;
|
|
240
307
|
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
@@ -361,4 +428,4 @@ interface MockPod {
|
|
|
361
428
|
}
|
|
362
429
|
declare function createMockPod(options?: MockPodOptions): MockPod;
|
|
363
430
|
|
|
364
|
-
export { type AuthorizeDecision, MOCK_APP, MOCK_APP_AGENT_ID, MOCK_AUTH_ISSUER, MOCK_CLOUD_URL, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_HTML_SHARE, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCloud, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockPodSeed, type MockProposal, type MockRequest, type MockSeedDocument, type MockSeedEntity, type MockSeedRelation, type MockSeedSession, type MockSeedSpace, type MockSite, type MockSubmission, createMockAuthServer, createMockCloud, createMockPod, parseMockPodSeed };
|
|
431
|
+
export { type AuthorizeDecision, MOCK_APP, MOCK_APP_AGENT_ID, MOCK_AUTH_ISSUER, MOCK_CLOUD_URL, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_HTML_SHARE, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCloud, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockPodSeed, type MockProposal, type MockRequest, type MockSeedDocument, type MockSeedEntity, type MockSeedFacet, type MockSeedRelation, type MockSeedSession, type MockSeedSpace, type MockSite, type MockSubmission, createMockAuthServer, createMockCloud, createMockPod, parseMockPodSeed };
|
package/dist/testing.js
CHANGED
|
@@ -120,12 +120,27 @@ var MOCK_SITE = {
|
|
|
120
120
|
}
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
// src/testing/roles.ts
|
|
124
|
+
function desugarSeedRoles(row, at) {
|
|
125
|
+
if (!Array.isArray(row.seedRoles)) return row;
|
|
126
|
+
const { seedRoles, ...rest } = row;
|
|
127
|
+
const facets = Array.isArray(row.facets) ? row.facets : [];
|
|
128
|
+
const shorthand = seedRoles.filter((r) => typeof r?.profileSlug === "string" && typeof r.workspaceId === "string").map((r, i) => ({ facetId: `seedrole_${String(row.id)}_${i}`, slug: r.profileSlug, workspaceId: r.workspaceId }));
|
|
129
|
+
for (const r of shorthand) {
|
|
130
|
+
if (facets.some((f) => f?.slug === r.slug && f.workspaceId === r.workspaceId)) {
|
|
131
|
+
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`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { ...rest, facets: [...facets, ...shorthand] };
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
// src/testing/auth-server.ts
|
|
124
138
|
var MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
125
139
|
var MOCK_GRANT = [
|
|
126
140
|
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
127
141
|
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
|
|
128
142
|
];
|
|
143
|
+
var STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
|
|
129
144
|
var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
130
145
|
var MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
|
|
131
146
|
var MOCK_APP_AGENT_ID = "agent_mock_app";
|
|
@@ -153,13 +168,43 @@ function createMockAuthServer(options = {}) {
|
|
|
153
168
|
const writes = [];
|
|
154
169
|
const proposals = [];
|
|
155
170
|
const seed = options.seed ?? {};
|
|
156
|
-
const entities = [...seed.entities ?? [], ...options.entities ?? []]
|
|
171
|
+
const entities = [...seed.entities ?? [], ...options.entities ?? []].map(
|
|
172
|
+
(e, i) => desugarSeedRoles(e, `entities[${i}]`)
|
|
173
|
+
);
|
|
157
174
|
const sessions = [...seed.sessions ?? []];
|
|
158
175
|
const documents = [...seed.documents ?? []];
|
|
159
176
|
const relations = [...seed.relations ?? []];
|
|
160
177
|
const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug]));
|
|
161
178
|
const approvals = [];
|
|
162
179
|
const seededAt = new Date(now()).toISOString();
|
|
180
|
+
const rolesOf = (e) => Array.isArray(e.facets) ? e.facets.filter((f) => typeof f?.slug === "string") : [];
|
|
181
|
+
function readableEntity(e, spaces, only) {
|
|
182
|
+
if (typeof e.workspaceId === "string" && kindReadable(e, spaces, e.workspaceId) && (!only || e.workspaceId === only)) return true;
|
|
183
|
+
return facetsOf(e, spaces, only ?? void 0).some((f) => e.workspaceId == null || !f.sharedRole);
|
|
184
|
+
}
|
|
185
|
+
const facetsOf = (e, spaces, lens) => rolesOf(e).filter((f) => typeof f.workspaceId === "string" && kindReadable(e, spaces, f.workspaceId) && (lens === void 0 || f.workspaceId === lens));
|
|
186
|
+
const facetRow = (f) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
|
|
187
|
+
const entityWire = (e) => {
|
|
188
|
+
const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
|
|
189
|
+
return wire;
|
|
190
|
+
};
|
|
191
|
+
const readSpaces = (scope) => {
|
|
192
|
+
const out = /* @__PURE__ */ new Map();
|
|
193
|
+
for (const g of grant) {
|
|
194
|
+
const m = /^entity\.(?:(.+)\.)?read$/.exec(g.permission);
|
|
195
|
+
if (!m || !scope.includes(g.permission)) continue;
|
|
196
|
+
const kind = m[1];
|
|
197
|
+
if (kind === void 0 || kind === "*") out.set(g.workspaceId, null);
|
|
198
|
+
else if (out.get(g.workspaceId) !== null) out.set(g.workspaceId, (out.get(g.workspaceId) ?? /* @__PURE__ */ new Set()).add(kind));
|
|
199
|
+
else continue;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
};
|
|
203
|
+
const kindReadable = (e, spaces, space) => {
|
|
204
|
+
if (!spaces.has(space)) return false;
|
|
205
|
+
const kinds = spaces.get(space);
|
|
206
|
+
return kinds === null || kinds === void 0 ? kinds === null : kinds.has(String(e.profileSlug ?? e.type ?? ""));
|
|
207
|
+
};
|
|
163
208
|
for (const p of seed.proposals ?? []) {
|
|
164
209
|
proposals.push({
|
|
165
210
|
proposalType: "create",
|
|
@@ -174,7 +219,9 @@ function createMockAuthServer(options = {}) {
|
|
|
174
219
|
...p
|
|
175
220
|
});
|
|
176
221
|
}
|
|
177
|
-
|
|
222
|
+
let actsAs = options.actsAs;
|
|
223
|
+
const writeMode = () => options.writeMode ?? (actsAs === "person" ? "applied" : "proposed");
|
|
224
|
+
let deviceFresh = !options.requireStepUp;
|
|
178
225
|
const graceMs = (options.refreshGraceSec ?? 30) * 1e3;
|
|
179
226
|
const app = options.app === void 0 ? MOCK_APP : options.app;
|
|
180
227
|
let proposalSeq = 0;
|
|
@@ -220,7 +267,7 @@ function createMockAuthServer(options = {}) {
|
|
|
220
267
|
synap_grant: grant.filter((g) => scope.includes(g.permission)).map((g) => g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId) }),
|
|
221
268
|
synap_user: user,
|
|
222
269
|
...app ? { synap_app: app } : {},
|
|
223
|
-
...
|
|
270
|
+
...actsAs ? { synap_acts_as: actsAs } : {}
|
|
224
271
|
};
|
|
225
272
|
if (pair) {
|
|
226
273
|
body.refresh_token = pair.refreshToken;
|
|
@@ -246,6 +293,21 @@ function createMockAuthServer(options = {}) {
|
|
|
246
293
|
if (form.resource !== void 0 && row.resource && form.resource !== row.resource) {
|
|
247
294
|
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
248
295
|
}
|
|
296
|
+
if (actsAs === "person" && row.stepUp !== Boolean(form.synap_device_refresh_token)) {
|
|
297
|
+
return oauthError(
|
|
298
|
+
"invalid_grant",
|
|
299
|
+
row.stepUp ? "This code re-confirms a device: send its refresh token as synap_device_refresh_token" : "This code signs a new device in; a re-confirmation asks with prompt=login"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (row.stepUp) {
|
|
303
|
+
const device = refreshTokens.get(form.synap_device_refresh_token);
|
|
304
|
+
if (!device || device.clientId !== row.clientId || device.used || device.revoked) {
|
|
305
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
306
|
+
}
|
|
307
|
+
device.used = true;
|
|
308
|
+
deviceFresh = true;
|
|
309
|
+
return json(200, tokenBody(row.scope, row.clientId, device.family));
|
|
310
|
+
}
|
|
249
311
|
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
250
312
|
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
251
313
|
}
|
|
@@ -307,11 +369,12 @@ function createMockAuthServer(options = {}) {
|
|
|
307
369
|
triage: { pending: false, acceptedAt: null, acceptedBy: null },
|
|
308
370
|
...s
|
|
309
371
|
});
|
|
310
|
-
function readSeeded(path, query) {
|
|
372
|
+
function readSeeded(path, query, scope) {
|
|
373
|
+
const spaces = readSpaces(scope);
|
|
311
374
|
const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
|
|
312
375
|
if (conn) {
|
|
313
376
|
const id = decodeURIComponent(conn[1]);
|
|
314
|
-
const ws = query.get("workspaceId");
|
|
377
|
+
const ws = query.get("workspaceId") ?? void 0;
|
|
315
378
|
const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
|
|
316
379
|
const inLens = (e) => !ws || e.workspaceId === ws || e.workspaceId == null;
|
|
317
380
|
const connections = [];
|
|
@@ -320,10 +383,17 @@ function createMockAuthServer(options = {}) {
|
|
|
320
383
|
const outgoing = r.sourceEntityId === id;
|
|
321
384
|
const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
|
|
322
385
|
const other = entities.find((e) => e.id === otherId);
|
|
323
|
-
if (!other || !inLens(other)) continue;
|
|
386
|
+
if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
|
|
387
|
+
const self = entities.find((e) => e.id === id);
|
|
388
|
+
if (self && self.workspaceId == null && other.workspaceId == null) continue;
|
|
324
389
|
connections.push({
|
|
325
390
|
entityId: otherId,
|
|
326
|
-
|
|
391
|
+
// relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
|
|
392
|
+
entity: {
|
|
393
|
+
...entityWire(other),
|
|
394
|
+
type: other.profileSlug ?? other.type ?? null,
|
|
395
|
+
facetSlugs: rolesOf(other).length > 0 ? facetsOf(other, spaces, ws).map((f) => f.slug) : other.facetSlugs ?? []
|
|
396
|
+
},
|
|
327
397
|
label: r.type,
|
|
328
398
|
direction: outgoing ? "outgoing" : "incoming",
|
|
329
399
|
source: "graph",
|
|
@@ -356,7 +426,39 @@ function createMockAuthServer(options = {}) {
|
|
|
356
426
|
const entity = /^\/entities\/([^/]+)$/.exec(path);
|
|
357
427
|
if (entity) {
|
|
358
428
|
const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
|
|
359
|
-
return found ? json(200, found) : new Response(null, { status: 404 });
|
|
429
|
+
return found && readableEntity(found, spaces) ? json(200, entityWire(found)) : new Response(null, { status: 404 });
|
|
430
|
+
}
|
|
431
|
+
const roles = /^\/entities\/([^/]+)\/facets$/.exec(path);
|
|
432
|
+
if (roles) {
|
|
433
|
+
const found = entities.find((e) => e.id === decodeURIComponent(roles[1]));
|
|
434
|
+
if (!found || !readableEntity(found, spaces)) return new Response(null, { status: 404 });
|
|
435
|
+
return json(200, {
|
|
436
|
+
facets: facetsOf(found, spaces).map((f) => ({
|
|
437
|
+
facet: {
|
|
438
|
+
id: f.facetId,
|
|
439
|
+
entityId: found.id,
|
|
440
|
+
profileId: `profile_${f.slug}`,
|
|
441
|
+
userId: found.userId ?? user.id,
|
|
442
|
+
// A stamped facet answers its space; a shared role's facet is stored pod-wide.
|
|
443
|
+
workspaceId: f.sharedRole ? null : f.workspaceId,
|
|
444
|
+
contextEntityId: null,
|
|
445
|
+
status: f.status ?? null,
|
|
446
|
+
statusCategory: null,
|
|
447
|
+
properties: f.properties ?? {},
|
|
448
|
+
metadata: {},
|
|
449
|
+
createdByKind: null,
|
|
450
|
+
createdByUserId: null,
|
|
451
|
+
agentUserId: null,
|
|
452
|
+
sourceProposalId: null,
|
|
453
|
+
correlationId: null,
|
|
454
|
+
createdAt: seededAt,
|
|
455
|
+
updatedAt: seededAt,
|
|
456
|
+
deletedAt: null
|
|
457
|
+
},
|
|
458
|
+
profile: { id: `profile_${f.slug}`, slug: f.slug, profileKind: "role" },
|
|
459
|
+
effectiveProperties: []
|
|
460
|
+
}))
|
|
461
|
+
});
|
|
360
462
|
}
|
|
361
463
|
const doc = /^\/documents\/([^/]+)$/.exec(path);
|
|
362
464
|
if (doc) {
|
|
@@ -412,6 +514,12 @@ function createMockAuthServer(options = {}) {
|
|
|
412
514
|
return json(403, { error: "Forbidden" });
|
|
413
515
|
case "door_unsupported":
|
|
414
516
|
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
517
|
+
case "step_up_required":
|
|
518
|
+
return json(403, { error: STEP_UP_MESSAGE });
|
|
519
|
+
case "email_unverified":
|
|
520
|
+
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" } });
|
|
521
|
+
case "origin_insecure":
|
|
522
|
+
return json(409, { error: "This application must be served over HTTPS to connect to a Pod.", code: "FEDERATED_APPLICATION_ORIGIN_INSECURE" });
|
|
415
523
|
case "server":
|
|
416
524
|
return json(500, { error: "Internal error" });
|
|
417
525
|
case "unavailable":
|
|
@@ -422,12 +530,27 @@ function createMockAuthServer(options = {}) {
|
|
|
422
530
|
}
|
|
423
531
|
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
424
532
|
if (method === "GET" && path === "/entities") {
|
|
425
|
-
const ws = query.get("workspaceId");
|
|
533
|
+
const ws = query.get("workspaceId") || void 0;
|
|
426
534
|
const kind = query.get("profileSlug");
|
|
535
|
+
const role = query.get("facetSlug");
|
|
536
|
+
const podWide = query.get("includePodWide") !== "false";
|
|
537
|
+
const withFacets = query.get("includeFacets") === "true";
|
|
427
538
|
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
428
539
|
const limit = Number(query.get("limit") ?? 50) || 50;
|
|
429
|
-
const
|
|
430
|
-
|
|
540
|
+
const spaces = readSpaces(row.scope);
|
|
541
|
+
const rows = entities.filter((e) => {
|
|
542
|
+
if (!readableEntity(e, spaces, ws)) return false;
|
|
543
|
+
if (ws && !podWide && e.workspaceId !== ws && !facetsOf(e, spaces, ws).some((f) => !f.sharedRole)) return false;
|
|
544
|
+
const slugs = facetsOf(e, spaces, ws).map((f) => f.slug);
|
|
545
|
+
return (!kind || e.profileSlug === kind || slugs.includes(kind)) && (!role || slugs.includes(role));
|
|
546
|
+
});
|
|
547
|
+
return json(
|
|
548
|
+
200,
|
|
549
|
+
rows.slice(offset, offset + limit).map((e) => {
|
|
550
|
+
const facets = withFacets ? facetsOf(e, spaces, ws).map(facetRow) : [];
|
|
551
|
+
return facets.length > 0 ? { ...entityWire(e), facetSlugs: facets.map((f) => f.slug), facets } : entityWire(e);
|
|
552
|
+
})
|
|
553
|
+
);
|
|
431
554
|
}
|
|
432
555
|
if (method === "GET" && path === "/proposals") {
|
|
433
556
|
const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
|
|
@@ -442,14 +565,15 @@ function createMockAuthServer(options = {}) {
|
|
|
442
565
|
return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
|
|
443
566
|
}
|
|
444
567
|
if (method === "GET") {
|
|
445
|
-
const read = readSeeded(path, query);
|
|
568
|
+
const read = readSeeded(path, query, row.scope);
|
|
446
569
|
if (read) return read;
|
|
447
570
|
}
|
|
448
571
|
const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
|
|
449
572
|
if (approve) {
|
|
450
|
-
if (
|
|
573
|
+
if (actsAs !== "person") {
|
|
451
574
|
return json(403, { error: "An agent credential cannot approve proposals \u2014 approve is human review. Approve from a human session." });
|
|
452
575
|
}
|
|
576
|
+
if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
|
|
453
577
|
const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
|
|
454
578
|
if (!p) return json(404, { error: "Proposal not found" });
|
|
455
579
|
if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
|
|
@@ -460,11 +584,26 @@ function createMockAuthServer(options = {}) {
|
|
|
460
584
|
approvals.push(p.id);
|
|
461
585
|
return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
|
|
462
586
|
}
|
|
587
|
+
const attach = method === "POST" ? /^\/entities\/([^/]+)\/facets$/.exec(path) : null;
|
|
588
|
+
const facetPatch = method === "PATCH" ? /^\/facets\/([^/]+)$/.exec(path) : null;
|
|
589
|
+
if (attach) return json(403, { error: "grant does not allow" });
|
|
590
|
+
if (facetPatch) {
|
|
591
|
+
const fid = decodeURIComponent(facetPatch[1]);
|
|
592
|
+
const parent = entities.find((e) => rolesOf(e).some((f) => f.facetId === fid));
|
|
593
|
+
if (parent && parent.workspaceId == null) return json(403, { error: "grant does not allow" });
|
|
594
|
+
}
|
|
595
|
+
const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
|
|
596
|
+
if (patched) {
|
|
597
|
+
const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
|
|
598
|
+
if (target && target.workspaceId == null) {
|
|
599
|
+
return typeof target.userId === "string" && target.userId !== user.id ? new Response(null, { status: 404 }) : json(403, { error: "grant does not allow" });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
463
602
|
if (method !== "GET") {
|
|
464
603
|
writes.push({ method, path, body });
|
|
465
604
|
const fields = body && typeof body === "object" ? body : {};
|
|
466
605
|
const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
|
|
467
|
-
if (writeMode === "proposed") {
|
|
606
|
+
if (writeMode() === "proposed") {
|
|
468
607
|
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
469
608
|
proposals.push({
|
|
470
609
|
id: proposalId,
|
|
@@ -581,9 +720,15 @@ function createMockAuthServer(options = {}) {
|
|
|
581
720
|
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
582
721
|
if (decision.deny) return finish({ error: "access_denied" });
|
|
583
722
|
const code = randomToken();
|
|
584
|
-
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope });
|
|
723
|
+
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope, stepUp: actsAs === "person" && q.get("prompt") === "login" });
|
|
585
724
|
return finish({ code });
|
|
586
725
|
},
|
|
726
|
+
setActsAs(v) {
|
|
727
|
+
actsAs = v ?? void 0;
|
|
728
|
+
},
|
|
729
|
+
expireStepUp() {
|
|
730
|
+
deviceFresh = false;
|
|
731
|
+
},
|
|
587
732
|
consumeRefreshToken(t) {
|
|
588
733
|
const row = refreshTokens.get(t);
|
|
589
734
|
if (row) row.used = true;
|
|
@@ -637,9 +782,36 @@ function parseMockPodSeed(input) {
|
|
|
637
782
|
for (const field of REQUIRED[key]) {
|
|
638
783
|
if (typeof row[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
|
|
639
784
|
}
|
|
785
|
+
if (key === "entities") checkFacets(row.facets, `seed.entities[${i}].facets`);
|
|
640
786
|
});
|
|
641
787
|
}
|
|
642
|
-
|
|
788
|
+
obj.entities?.forEach((row, i) => {
|
|
789
|
+
const roles = row.seedRoles;
|
|
790
|
+
if (roles === void 0) return;
|
|
791
|
+
if (!Array.isArray(roles)) throw new TypeError(`seed.entities[${i}].seedRoles: expected an array`);
|
|
792
|
+
roles.forEach((r, j) => {
|
|
793
|
+
for (const field of ["profileSlug", "workspaceId"]) {
|
|
794
|
+
if (!r || typeof r !== "object" || typeof r[field] !== "string") {
|
|
795
|
+
throw new TypeError(`seed.entities[${i}].seedRoles[${j}].${field}: expected a string`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
const out = JSON.parse(JSON.stringify(obj));
|
|
801
|
+
if (out.entities) out.entities = out.entities.map((row, i) => desugarSeedRoles(row, `seed.entities[${i}]`));
|
|
802
|
+
return out;
|
|
803
|
+
}
|
|
804
|
+
function checkFacets(facets, at) {
|
|
805
|
+
if (facets === void 0) return;
|
|
806
|
+
if (!Array.isArray(facets)) throw new TypeError(`${at}: expected an array`);
|
|
807
|
+
facets.forEach((f, j) => {
|
|
808
|
+
if (!f || typeof f !== "object" || Array.isArray(f)) throw new TypeError(`${at}[${j}]: expected an object`);
|
|
809
|
+
for (const field of ["facetId", "slug", "workspaceId"]) {
|
|
810
|
+
if (typeof f[field] !== "string") throw new TypeError(`${at}[${j}].${field}: expected a string`);
|
|
811
|
+
}
|
|
812
|
+
const shared = f.sharedRole;
|
|
813
|
+
if (shared !== void 0 && typeof shared !== "boolean") throw new TypeError(`${at}[${j}].sharedRole: expected a boolean`);
|
|
814
|
+
});
|
|
643
815
|
}
|
|
644
816
|
|
|
645
817
|
// src/testing/cloud.ts
|