@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/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.
|
|
@@ -218,10 +223,25 @@ function isSynapAppOrigin(origin: string): boolean {
|
|
|
218
223
|
}
|
|
219
224
|
|
|
220
225
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
226
|
+
* Mock mode's origin (`http://localhost`): it signs in to the SDK's mock pod
|
|
227
|
+
* only, since a real pod refuses an http client_id before it reads a single
|
|
228
|
+
* permission (synap-backend `routers/oauth/client-id-url.ts`
|
|
229
|
+
* `assertClientMetadataUrl`: "client_id URL must use https"). So the review
|
|
230
|
+
* verbs are not refused here, and an app that asks for them can run on the mock.
|
|
231
|
+
*/
|
|
232
|
+
function isMockModeOrigin(origin: string): boolean {
|
|
233
|
+
const url = new URL(origin);
|
|
234
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
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
|
|
223
242
|
* `entity`, an action in the pod's grant actions (the review verbs only for a
|
|
224
|
-
* Synap app). Stricter on one point: no `*`
|
|
243
|
+
* Synap app, or on mock mode's loopback origin). Stricter on one point: no `*`
|
|
244
|
+
* at all (`can()` matches exactly).
|
|
225
245
|
*/
|
|
226
246
|
function assertAppPermission(permission: string, origin: string): void {
|
|
227
247
|
const segs = permission.split(".");
|
|
@@ -232,7 +252,7 @@ function assertAppPermission(permission: string, origin: string): void {
|
|
|
232
252
|
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
233
253
|
}
|
|
234
254
|
if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
|
|
235
|
-
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && isSynapAppOrigin(origin)) return;
|
|
255
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && (isSynapAppOrigin(origin) || isMockModeOrigin(origin))) return;
|
|
236
256
|
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
237
257
|
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
238
258
|
}
|
|
@@ -405,7 +425,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
405
425
|
};
|
|
406
426
|
|
|
407
427
|
/** Off to the pod's consent: fresh PKCE + state, the transaction cookie set (extra cookies first). */
|
|
408
|
-
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[] = []) {
|
|
409
429
|
const started = await startConnect({
|
|
410
430
|
...flow,
|
|
411
431
|
pod: opts.pod,
|
|
@@ -445,7 +465,9 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
445
465
|
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
446
466
|
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
447
467
|
const space = formField(form, "space");
|
|
448
|
-
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;
|
|
449
471
|
try {
|
|
450
472
|
if (!prompt && (await liveConnectionTo(request, pod))) return redirect(new URL(next, origin).href);
|
|
451
473
|
return await toPod({ pod, next, space, prompt });
|
|
@@ -463,6 +485,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
463
485
|
...flow,
|
|
464
486
|
callbackUrl: request.url,
|
|
465
487
|
transaction,
|
|
488
|
+
current: await store.read(request).catch(() => null),
|
|
466
489
|
clientId,
|
|
467
490
|
redirectUri,
|
|
468
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);
|
|
@@ -501,6 +554,9 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
|
|
|
501
554
|
offset: clamp(opts.offset, 0, Number.MAX_SAFE_INTEGER),
|
|
502
555
|
sort: opts.sort,
|
|
503
556
|
includePodWide: opts.includePodWide,
|
|
557
|
+
facetSlug: opts.facetSlug,
|
|
558
|
+
// Sent only when asked: the default request stays the 1.5.0 one.
|
|
559
|
+
includeFacets: opts.includeFacets ? true : undefined,
|
|
504
560
|
}, (data) => (Array.isArray(data) ? (data.filter(hasId) as PodEntity[]) : null));
|
|
505
561
|
}
|
|
506
562
|
|
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/hub-types.ts
CHANGED
|
@@ -38,9 +38,22 @@ export interface PodEntity {
|
|
|
38
38
|
/** ISO 8601. */
|
|
39
39
|
createdAt?: string;
|
|
40
40
|
updatedAt?: string;
|
|
41
|
+
/** The roles it wears, with `includeFacets`; absent when it wears none. */
|
|
42
|
+
facetSlugs?: string[];
|
|
43
|
+
/** Each role's overlay, with `includeFacets`; absent when it wears none. */
|
|
44
|
+
facets?: PodEntityFacet[];
|
|
41
45
|
[key: string]: unknown;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** One role an entity wears (`FacetRowAnnotation`). */
|
|
49
|
+
export interface PodEntityFacet {
|
|
50
|
+
facetId: string;
|
|
51
|
+
/** The role, e.g. `lead`. */
|
|
52
|
+
slug: string;
|
|
53
|
+
properties: Record<string, unknown>;
|
|
54
|
+
status: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
export interface ListEntitiesOptions {
|
|
45
58
|
/** One granted space. Omitted: every space the connection can read. */
|
|
46
59
|
workspaceId?: string;
|
|
@@ -53,6 +66,10 @@ export interface ListEntitiesOptions {
|
|
|
53
66
|
sort?: string;
|
|
54
67
|
/** With `workspaceId`: also the person's pod-wide rows (the pod's default: true). */
|
|
55
68
|
includePodWide?: boolean;
|
|
69
|
+
/** Only the entities wearing this role, e.g. `lead`. */
|
|
70
|
+
facetSlug?: string;
|
|
71
|
+
/** Put `facetSlugs` and `facets` on each row that wears a role. */
|
|
72
|
+
includeFacets?: boolean;
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
export type PodConnectionSource = "graph" | "property" | "thread" | "context_channel" | "focus_session";
|
package/src/connect/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type {
|
|
|
30
30
|
PodClientOptions,
|
|
31
31
|
PodError,
|
|
32
32
|
PodErrorCode,
|
|
33
|
+
PodRecovery,
|
|
33
34
|
PodRefusalReason,
|
|
34
35
|
PodResult,
|
|
35
36
|
PodWriteResult,
|
|
@@ -48,6 +49,7 @@ export type {
|
|
|
48
49
|
PodEntity,
|
|
49
50
|
PodEntityConnection,
|
|
50
51
|
PodEntityConnections,
|
|
52
|
+
PodEntityFacet,
|
|
51
53
|
PodFileUrl,
|
|
52
54
|
PodFocusSession,
|
|
53
55
|
PodSessionSlot,
|