@synap-core/sdk 1.5.1 → 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 +83 -17
- package/README.md +3 -3
- package/dist/connect.cjs +29 -5
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +39 -3
- package/dist/connect.d.ts +39 -3
- package/dist/connect.js +29 -5
- package/dist/connect.js.map +1 -1
- package/dist/testing.cjs +68 -9
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +19 -1
- package/dist/testing.d.ts +19 -1
- package/dist/testing.js +68 -9
- package/dist/testing.js.map +1 -1
- package/package.json +10 -9
- package/src/connect/auth.ts +14 -4
- package/src/connect/client.ts +55 -2
- package/src/connect/flow.ts +23 -2
- package/src/connect/index.ts +1 -0
- package/src/testing/auth-server.ts +118 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synap-core/sdk",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "Type-safe tRPC client SDK for Synap data pods, public-door client, and server-only pod-app sign-in",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -55,11 +55,17 @@
|
|
|
55
55
|
"dist",
|
|
56
56
|
"src",
|
|
57
57
|
"!src/__tests__",
|
|
58
|
-
"CHANGELOG.md"
|
|
58
|
+
"CHANGELOG.md",
|
|
59
|
+
"AGENTS.md"
|
|
59
60
|
],
|
|
60
61
|
"engines": {
|
|
61
62
|
"node": ">=20"
|
|
62
63
|
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "tsup",
|
|
66
|
+
"typecheck": "tsc --noEmit",
|
|
67
|
+
"test": "vitest run"
|
|
68
|
+
},
|
|
63
69
|
"keywords": [
|
|
64
70
|
"synap",
|
|
65
71
|
"sdk",
|
|
@@ -91,10 +97,5 @@
|
|
|
91
97
|
"url": "git+https://github.com/Synap-core/app-bases.git",
|
|
92
98
|
"directory": "packages/sdk"
|
|
93
99
|
},
|
|
94
|
-
"license": "MIT"
|
|
95
|
-
|
|
96
|
-
"build": "tsup",
|
|
97
|
-
"typecheck": "tsc --noEmit",
|
|
98
|
-
"test": "vitest run"
|
|
99
|
-
}
|
|
100
|
-
}
|
|
100
|
+
"license": "MIT"
|
|
101
|
+
}
|
package/src/connect/auth.ts
CHANGED
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* what the app may touch); without it a person already connected to that pod
|
|
19
19
|
* goes straight to `next`. Either way a sign-in is a fresh PKCE + state flow.
|
|
20
20
|
*
|
|
21
|
+
* `prompt=login` is the step-up: a credential door refused a stale device
|
|
22
|
+
* (`step_up_required`), so the person re-confirms at the POD (never through
|
|
23
|
+
* Synap Cloud) and the callback redeems the code with the connection's refresh
|
|
24
|
+
* token. Then retry the refused call once.
|
|
25
|
+
*
|
|
21
26
|
* `connect` and `disconnect` change state, so they are POST and same-origin
|
|
22
27
|
* only (Origin, else Sec-Fetch-Site): a third-party page cannot start a
|
|
23
28
|
* sign-in into an attacker's pod for a visitor (login CSRF) or sign them out.
|
|
@@ -230,8 +235,10 @@ function isMockModeOrigin(origin: string): boolean {
|
|
|
230
235
|
}
|
|
231
236
|
|
|
232
237
|
/**
|
|
233
|
-
* One `synap_requests[].permission`, refused here
|
|
234
|
-
*
|
|
238
|
+
* One `synap_requests[].permission`, refused here when the pod would refuse
|
|
239
|
+
* it (at most as strict: any `https://<name>.synap.live` passes the review-verb
|
|
240
|
+
* check here, while the pod admits only its own list of Synap app ids, which
|
|
241
|
+
* this SDK does not copy; the pod's answer is the authority): `<subject>[.<kind>].<action>`, lowercase, a kind only on
|
|
235
242
|
* `entity`, an action in the pod's grant actions (the review verbs only for a
|
|
236
243
|
* Synap app, or on mock mode's loopback origin). Stricter on one point: no `*`
|
|
237
244
|
* at all (`can()` matches exactly).
|
|
@@ -418,7 +425,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
418
425
|
};
|
|
419
426
|
|
|
420
427
|
/** Off to the pod's consent: fresh PKCE + state, the transaction cookie set (extra cookies first). */
|
|
421
|
-
async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | null }, extra: string[] = []) {
|
|
428
|
+
async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | "login" | null }, extra: string[] = []) {
|
|
422
429
|
const started = await startConnect({
|
|
423
430
|
...flow,
|
|
424
431
|
pod: opts.pod,
|
|
@@ -458,7 +465,9 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
458
465
|
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
459
466
|
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
460
467
|
const space = formField(form, "space");
|
|
461
|
-
const
|
|
468
|
+
const asked = formField(form, "prompt");
|
|
469
|
+
// `login` is a step-up of the pod's own sign-in; it never goes through Synap Cloud (a Cloud sign-in never counts).
|
|
470
|
+
const prompt = asked === "consent" || asked === "login" ? asked : null;
|
|
462
471
|
try {
|
|
463
472
|
if (!prompt && (await liveConnectionTo(request, pod))) return redirect(new URL(next, origin).href);
|
|
464
473
|
return await toPod({ pod, next, space, prompt });
|
|
@@ -476,6 +485,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
476
485
|
...flow,
|
|
477
486
|
callbackUrl: request.url,
|
|
478
487
|
transaction,
|
|
488
|
+
current: await store.read(request).catch(() => null),
|
|
479
489
|
clientId,
|
|
480
490
|
redirectUri,
|
|
481
491
|
secret: config.secret,
|
package/src/connect/client.ts
CHANGED
|
@@ -66,8 +66,29 @@ export type PodErrorCode =
|
|
|
66
66
|
| "pod_unreachable"
|
|
67
67
|
/** The pod answered a redirect (a moved pod answers 308 from its old name): connect again at its new address. */
|
|
68
68
|
| "pod_moved"
|
|
69
|
+
/**
|
|
70
|
+
* A credential door (approving a proposal that widens who or what can reach
|
|
71
|
+
* the pod) wants the person to re-confirm at the POD within 15 minutes; a
|
|
72
|
+
* Synap Cloud sign-in never counts. Recovery: `prompt_login`.
|
|
73
|
+
*/
|
|
74
|
+
| "step_up_required"
|
|
75
|
+
/** Synap Cloud refused to vouch a person with no verified email. Recovery: `verify_email`. */
|
|
76
|
+
| "email_unverified"
|
|
77
|
+
/** Synap Cloud vouches an app origin over HTTPS only. Recovery: `use_https`. */
|
|
78
|
+
| "origin_insecure"
|
|
69
79
|
| "server";
|
|
70
80
|
|
|
81
|
+
/**
|
|
82
|
+
* What a typed refusal asks the app to do:
|
|
83
|
+
* - `prompt_login`: send the person through the connect handler with
|
|
84
|
+
* `prompt=login` (re-confirm at the pod; the SDK redeems it with the
|
|
85
|
+
* device's refresh token), then retry ONCE;
|
|
86
|
+
* - `verify_email`: tell the person to verify their Synap email;
|
|
87
|
+
* - `use_https`: serve the app over HTTPS (`http://localhost` signs in to a
|
|
88
|
+
* local Control Plane started with `CP_ALLOW_DEV_ORIGINS=true`, never to api.synap.live).
|
|
89
|
+
*/
|
|
90
|
+
export type PodRecovery = "prompt_login" | "verify_email" | "use_https";
|
|
91
|
+
|
|
71
92
|
/**
|
|
72
93
|
* Why the SDK refused before sending anything (`status: null`):
|
|
73
94
|
* - `agent_cannot_approve`: an agent connection asked to approve (the pod
|
|
@@ -86,6 +107,8 @@ export interface PodError {
|
|
|
86
107
|
detail?: string;
|
|
87
108
|
/** Set when the SDK refused locally (1.5). */
|
|
88
109
|
reason?: PodRefusalReason;
|
|
110
|
+
/** What to do next, on `step_up_required`, `email_unverified` and `origin_insecure`. */
|
|
111
|
+
recovery?: PodRecovery;
|
|
89
112
|
/**
|
|
90
113
|
* On `unauthorized` (1.5): the pod refused the access token, the connection
|
|
91
114
|
* still holds a refresh token, and this client could not renew it (no
|
|
@@ -297,14 +320,44 @@ function renewalError(err: unknown): PodError {
|
|
|
297
320
|
}
|
|
298
321
|
}
|
|
299
322
|
|
|
323
|
+
/** The pod's step-up refusal (backend `STEP_UP_REQUIRED_MESSAGE`); the Hub's REST body carries only this text. */
|
|
324
|
+
const STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
|
|
325
|
+
|
|
326
|
+
/** The Control Plane's public problem codes (409, `{ error, code, recovery }`). */
|
|
327
|
+
const CP_PROBLEMS: Readonly<Record<string, { code: PodErrorCode; recovery: PodRecovery }>> = {
|
|
328
|
+
CP_ACCOUNT_EMAIL_REQUIRED: { code: "email_unverified", recovery: "verify_email" },
|
|
329
|
+
FEDERATED_APPLICATION_ORIGIN_INSECURE: { code: "origin_insecure", recovery: "use_https" },
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
function isStepUp(obj: Record<string, unknown>): boolean {
|
|
333
|
+
const data = (v: unknown) => (v && typeof v === "object" ? (v as Record<string, unknown>) : {});
|
|
334
|
+
// tRPC shape (`error.data.reasonCode`), a flat reasonCode, or the Hub REST body: `{ error: <message> }`.
|
|
335
|
+
const nested = data(data(obj.error).data).reasonCode ?? data(obj.data).reasonCode ?? obj.reasonCode;
|
|
336
|
+
return nested === "STEP_UP_REQUIRED" || obj.error === STEP_UP_MESSAGE || obj.message === STEP_UP_MESSAGE;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A 403 or 409 body → its typed code. A step-up is read on a 403 only, a Control
|
|
341
|
+
* Plane problem on a 409 only; everything else keeps the plain `forbidden` /
|
|
342
|
+
* `door_unsupported` / `conflict`, so a typed refusal is never read as a generic one.
|
|
343
|
+
*/
|
|
344
|
+
export function classifyRefusal(status: 403 | 409 | number, body: unknown): { code: PodErrorCode; recovery?: PodRecovery } {
|
|
345
|
+
const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
346
|
+
if (status === 403) {
|
|
347
|
+
if (isStepUp(obj)) return { code: "step_up_required", recovery: "prompt_login" };
|
|
348
|
+
return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden" };
|
|
349
|
+
}
|
|
350
|
+
const problem = typeof obj.code === "string" ? CP_PROBLEMS[obj.code] : undefined;
|
|
351
|
+
return problem ?? { code: "conflict" };
|
|
352
|
+
}
|
|
353
|
+
|
|
300
354
|
function errorFor(status: number, body: unknown, retryAfter: string | null, now: number): PodError {
|
|
301
355
|
const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
302
356
|
const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : undefined;
|
|
303
357
|
const base = { status, ...(detail !== undefined ? { detail } : {}) };
|
|
304
358
|
if (status === 401) return { code: "unauthorized", ...base };
|
|
305
|
-
if (status === 403
|
|
359
|
+
if (status === 403 || status === 409) return { ...classifyRefusal(status, body), ...base };
|
|
306
360
|
if (status === 404) return { code: "not_found", ...base };
|
|
307
|
-
if (status === 409) return { code: "conflict", ...base };
|
|
308
361
|
if (status === 400 || status === 422) return { code: "invalid", ...base };
|
|
309
362
|
if (status === 429) {
|
|
310
363
|
const retryAfterSec = retryAfterSeconds(retryAfter, now);
|
package/src/connect/flow.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface ConnectTransaction {
|
|
|
48
48
|
resource: string | null;
|
|
49
49
|
/** A same-origin path to land on afterwards. */
|
|
50
50
|
next: string | null;
|
|
51
|
+
/** The sign-in re-confirms a device that is already connected (`prompt=login`). */
|
|
52
|
+
stepUp?: boolean;
|
|
51
53
|
createdAt: number;
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -74,8 +76,13 @@ export interface StartConnectOptions extends FlowOptions {
|
|
|
74
76
|
* `"consent"`: the person asked to see the consent screen again (to widen or
|
|
75
77
|
* narrow what the app may touch). Sent as OIDC `prompt=consent`; the sign-in
|
|
76
78
|
* is otherwise the same (fresh PKCE verifier and state).
|
|
79
|
+
*
|
|
80
|
+
* `"login"`: a step-up. A credential door refused a stale device
|
|
81
|
+
* (`step_up_required`): the person re-confirms at the pod (OIDC
|
|
82
|
+
* `prompt=login`) and the callback redeems the code WITH the connection's
|
|
83
|
+
* refresh token (`synap_device_refresh_token`; see `HandleCallbackOptions.current`).
|
|
77
84
|
*/
|
|
78
|
-
prompt?: "consent" | null;
|
|
85
|
+
prompt?: "consent" | "login" | null;
|
|
79
86
|
allowInsecureLoopback?: boolean;
|
|
80
87
|
}
|
|
81
88
|
|
|
@@ -124,7 +131,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
|
|
|
124
131
|
url.searchParams.set("code_challenge_method", "S256");
|
|
125
132
|
if (resource) url.searchParams.set("resource", resource);
|
|
126
133
|
if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
|
|
127
|
-
if (opts.prompt === "consent") url.searchParams.set("prompt",
|
|
134
|
+
if (opts.prompt === "consent" || opts.prompt === "login") url.searchParams.set("prompt", opts.prompt);
|
|
128
135
|
|
|
129
136
|
const tx: ConnectTransaction = {
|
|
130
137
|
v: 1,
|
|
@@ -137,6 +144,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
|
|
|
137
144
|
verifier,
|
|
138
145
|
resource,
|
|
139
146
|
next: opts.next ?? null,
|
|
147
|
+
...(opts.prompt === "login" ? { stepUp: true } : {}),
|
|
140
148
|
createdAt: now,
|
|
141
149
|
};
|
|
142
150
|
const expiresAt = now + TRANSACTION_TTL_MS;
|
|
@@ -179,6 +187,13 @@ export interface HandleCallbackOptions extends FlowOptions {
|
|
|
179
187
|
clientId: string;
|
|
180
188
|
redirectUri: string;
|
|
181
189
|
secret: AppSecret;
|
|
190
|
+
/**
|
|
191
|
+
* The connection this browser holds now. A step-up (`prompt=login`) of a
|
|
192
|
+
* Synap app's device is redeemed with ITS refresh token (the pod moves that
|
|
193
|
+
* device's clock instead of making a second device); without one, or for an
|
|
194
|
+
* app that is not a person's device, the code is redeemed as any other.
|
|
195
|
+
*/
|
|
196
|
+
current?: PodConnection | null;
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
export interface CallbackResult {
|
|
@@ -219,6 +234,11 @@ export async function handleCallback(opts: HandleCallbackOptions): Promise<Callb
|
|
|
219
234
|
code_verifier: tx.verifier,
|
|
220
235
|
});
|
|
221
236
|
if (tx.resource) body.set("resource", tx.resource);
|
|
237
|
+
const device =
|
|
238
|
+
tx.stepUp && opts.current && opts.current.actsAs === "person" && opts.current.podUrl === tx.issuer && opts.current.clientId === tx.clientId
|
|
239
|
+
? opts.current
|
|
240
|
+
: null;
|
|
241
|
+
if (device?.refreshToken) body.set("synap_device_refresh_token", device.refreshToken);
|
|
222
242
|
const json = await tokenRequest(tx.tokenEndpoint, body, "token", opts);
|
|
223
243
|
const connection = parseTokenResponse(json, {
|
|
224
244
|
podUrl: tx.issuer,
|
|
@@ -226,6 +246,7 @@ export async function handleCallback(opts: HandleCallbackOptions): Promise<Callb
|
|
|
226
246
|
tokenEndpoint: tx.tokenEndpoint,
|
|
227
247
|
revocationEndpoint: tx.revocationEndpoint,
|
|
228
248
|
now: (opts.now ?? Date.now)(),
|
|
249
|
+
...(device ? { previous: device } : {}),
|
|
229
250
|
});
|
|
230
251
|
return { connection, next: tx.next };
|
|
231
252
|
}
|
package/src/connect/index.ts
CHANGED
|
@@ -63,14 +63,32 @@ import type { MockPodSeed, MockSeedDocument, MockSeedFacet, MockSeedRelation, Mo
|
|
|
63
63
|
/** A placeholder pod origin; never contacted. */
|
|
64
64
|
export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
65
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
|
+
|
|
66
69
|
export const MOCK_GRANT: PodGrant[] = [
|
|
67
70
|
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
68
71
|
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
69
72
|
];
|
|
70
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
|
+
|
|
71
77
|
export const MOCK_USER: PodUser = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
72
78
|
|
|
73
|
-
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";
|
|
74
92
|
|
|
75
93
|
export interface MockAuthServerOptions {
|
|
76
94
|
issuer?: string;
|
|
@@ -105,6 +123,14 @@ export interface MockAuthServerOptions {
|
|
|
105
123
|
* not name it; the SDK reads an agent connection).
|
|
106
124
|
*/
|
|
107
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;
|
|
108
134
|
/**
|
|
109
135
|
* The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
|
|
110
136
|
* before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
|
|
@@ -175,6 +201,10 @@ export interface MockAuthServer {
|
|
|
175
201
|
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
176
202
|
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
177
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;
|
|
178
208
|
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
179
209
|
loseNextRefreshRace(): void;
|
|
180
210
|
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
@@ -198,6 +228,8 @@ interface CodeRow {
|
|
|
198
228
|
challenge: string;
|
|
199
229
|
resource: string | null;
|
|
200
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;
|
|
201
233
|
}
|
|
202
234
|
|
|
203
235
|
interface AccessRow {
|
|
@@ -266,8 +298,8 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
266
298
|
* role in one. The pod sends no role field on an entity row, so the mock takes
|
|
267
299
|
* its roles as SEED input (`facets`) and answers them only when asked.
|
|
268
300
|
*/
|
|
269
|
-
function readableEntity(e: Record<string, unknown>, spaces:
|
|
270
|
-
if (typeof e.workspaceId === "string" &&
|
|
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;
|
|
271
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.
|
|
272
304
|
return facetsOf(e, spaces, only ?? undefined).some((f) => e.workspaceId == null || !f.sharedRole);
|
|
273
305
|
}
|
|
@@ -276,8 +308,8 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
276
308
|
* (backend `facetVisibilityConditions` under the token's grant), i.e. a role
|
|
277
309
|
* whose seed space is granted; a workspace lens sees that space's roles only.
|
|
278
310
|
*/
|
|
279
|
-
const facetsOf = (e: Record<string, unknown>, spaces:
|
|
280
|
-
rolesOf(e).filter((f) => typeof f.workspaceId === "string" && spaces
|
|
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));
|
|
281
313
|
/** backend `FacetRowAnnotation`: what `includeFacets=true` puts on a row. */
|
|
282
314
|
const facetRow = (f: MockSeedFacet) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
|
|
283
315
|
/** A row as the Hub answers it (`entityToWire`): roles ride on it only when asked. */
|
|
@@ -285,8 +317,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
285
317
|
const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
|
|
286
318
|
return wire;
|
|
287
319
|
};
|
|
288
|
-
|
|
289
|
-
|
|
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
|
+
};
|
|
290
343
|
for (const p of seed.proposals ?? []) {
|
|
291
344
|
proposals.push({
|
|
292
345
|
proposalType: "create",
|
|
@@ -301,7 +354,11 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
301
354
|
...p,
|
|
302
355
|
});
|
|
303
356
|
}
|
|
304
|
-
|
|
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;
|
|
305
362
|
const graceMs = (options.refreshGraceSec ?? 30) * 1000;
|
|
306
363
|
const app = options.app === undefined ? MOCK_APP : options.app;
|
|
307
364
|
let proposalSeq = 0;
|
|
@@ -353,7 +410,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
353
410
|
.map((g) => (g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId)! })),
|
|
354
411
|
synap_user: user,
|
|
355
412
|
...(app ? { synap_app: app } : {}),
|
|
356
|
-
...(
|
|
413
|
+
...(actsAs ? { synap_acts_as: actsAs } : {}),
|
|
357
414
|
};
|
|
358
415
|
if (pair) {
|
|
359
416
|
body.refresh_token = pair.refreshToken;
|
|
@@ -380,6 +437,25 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
380
437
|
if (form.resource !== undefined && row.resource && form.resource !== row.resource) {
|
|
381
438
|
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
382
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
|
+
}
|
|
383
459
|
// The latest connect wins: a new code ends this client's older families.
|
|
384
460
|
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
385
461
|
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
@@ -467,6 +543,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
467
543
|
const other = entities.find((e) => e.id === otherId);
|
|
468
544
|
// As the pod: an edge whose far end is gone or out of the lens is not a neighbour.
|
|
469
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;
|
|
470
549
|
connections.push({
|
|
471
550
|
entityId: otherId,
|
|
472
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).
|
|
@@ -606,6 +685,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
606
685
|
return json(403, { error: "Forbidden" });
|
|
607
686
|
case "door_unsupported":
|
|
608
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" });
|
|
609
694
|
case "server":
|
|
610
695
|
return json(500, { error: "Internal error" });
|
|
611
696
|
case "unavailable":
|
|
@@ -669,9 +754,10 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
669
754
|
const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
|
|
670
755
|
if (approve) {
|
|
671
756
|
// rejectAgentReviewer: an agent credential never approves, before anything is read.
|
|
672
|
-
if (
|
|
757
|
+
if (actsAs !== "person") {
|
|
673
758
|
return json(403, { error: "An agent credential cannot approve proposals — approve is human review. Approve from a human session." });
|
|
674
759
|
}
|
|
760
|
+
if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
|
|
675
761
|
const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
|
|
676
762
|
if (!p) return json(404, { error: "Proposal not found" });
|
|
677
763
|
// Only where the person granted proposal.approve, for the proposal's own space.
|
|
@@ -683,17 +769,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
683
769
|
approvals.push(p.id);
|
|
684
770
|
return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
|
|
685
771
|
}
|
|
686
|
-
//
|
|
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).
|
|
687
783
|
const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
|
|
688
784
|
if (patched) {
|
|
689
785
|
const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
|
|
690
|
-
if (target && target.workspaceId == null)
|
|
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
|
+
}
|
|
691
789
|
}
|
|
692
790
|
if (method !== "GET") {
|
|
693
791
|
writes.push({ method, path, body });
|
|
694
792
|
const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
695
793
|
const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
|
|
696
|
-
if (writeMode === "proposed") {
|
|
794
|
+
if (writeMode() === "proposed") {
|
|
697
795
|
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
698
796
|
proposals.push({
|
|
699
797
|
id: proposalId,
|
|
@@ -814,9 +912,15 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
814
912
|
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
815
913
|
if (decision.deny) return finish({ error: "access_denied" });
|
|
816
914
|
const code = randomToken();
|
|
817
|
-
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" });
|
|
818
916
|
return finish({ code });
|
|
819
917
|
},
|
|
918
|
+
setActsAs(v) {
|
|
919
|
+
actsAs = v ?? undefined;
|
|
920
|
+
},
|
|
921
|
+
expireStepUp() {
|
|
922
|
+
deviceFresh = false;
|
|
923
|
+
},
|
|
820
924
|
consumeRefreshToken(t) {
|
|
821
925
|
const row = refreshTokens.get(t);
|
|
822
926
|
if (row) row.used = true;
|