@synap-core/sdk 1.3.0 → 1.4.0
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/README.md +81 -7
- package/dist/{client-CVUT1Mcf.d.cts → client-C5sF20f1.d.cts} +1 -1
- package/dist/{client-CVUT1Mcf.d.ts → client-C5sF20f1.d.ts} +1 -1
- package/dist/connect.cjs +397 -116
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +220 -15
- package/dist/connect.d.ts +220 -15
- package/dist/connect.js +389 -116
- package/dist/connect.js.map +1 -1
- package/dist/public.d.cts +2 -2
- package/dist/public.d.ts +2 -2
- package/dist/testing.cjs +138 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +92 -4
- package/dist/testing.d.ts +92 -4
- package/dist/testing.js +134 -11
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-C2KGNXEn.d.cts} +20 -1
- package/dist/{types-DpqPNaaa.d.ts → types-C2KGNXEn.d.ts} +20 -1
- package/package.json +8 -8
- package/src/connect/auth.ts +161 -31
- package/src/connect/client.ts +107 -1
- package/src/connect/cloud.ts +206 -0
- package/src/connect/discovery.ts +15 -14
- package/src/connect/errors.ts +1 -1
- package/src/connect/flow.ts +41 -1
- package/src/connect/index.ts +39 -11
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +41 -3
- package/src/connect/types.ts +20 -0
- package/src/testing/auth-server.ts +144 -12
- package/src/testing/cloud.ts +84 -0
- package/src/testing/index.ts +3 -1
package/src/connect/auth.ts
CHANGED
|
@@ -8,15 +8,26 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Handlers:
|
|
10
10
|
* GET <metadata> the app's client metadata document (its client_id URL)
|
|
11
|
-
* POST <connect> form { pod, next?, space? } → 303 to the pod's /authorize
|
|
11
|
+
* POST <connect> form { pod, next?, space?, prompt? } → 303 to the pod's /authorize
|
|
12
12
|
* GET <callback> the pod's answer → sealed session cookie → 303 to `next`
|
|
13
13
|
* POST <disconnect> revoke at the pod (best effort), clear the cookie → 303
|
|
14
|
+
* POST <cloud> form { next?, space?, prompt? } → 303 to the Synap Cloud pod chooser
|
|
15
|
+
* GET <cloud> the chooser's return (?iss&state, state checked) → 303 to the pod's /authorize
|
|
16
|
+
*
|
|
17
|
+
* `prompt=consent` asks for the pod's consent screen again (widen or narrow
|
|
18
|
+
* what the app may touch); without it a person already connected to that pod
|
|
19
|
+
* goes straight to `next`. Either way a sign-in is a fresh PKCE + state flow.
|
|
14
20
|
*
|
|
15
21
|
* `connect` and `disconnect` change state, so they are POST and same-origin
|
|
16
22
|
* only (Origin, else Sec-Fetch-Site): a third-party page cannot start a
|
|
17
23
|
* sign-in into an attacker's pod for a visitor (login CSRF) or sign them out.
|
|
18
24
|
* Failures redirect with `?error=<ConnectErrorCode>` and nothing else.
|
|
25
|
+
*
|
|
26
|
+
* Each successful sign-in or refresh also sets a marker cookie holding the
|
|
27
|
+
* pod's URL and no secret (`markerCookie`), so a connection that lapsed reads
|
|
28
|
+
* as "expired" (`readLapsed`, `RefreshOutcome.lapsed`), not "never connected".
|
|
19
29
|
*/
|
|
30
|
+
import { acceptCloudReturn, cloudBaseUrl, CLOUD_TRANSACTION_TTL_MS, startCloudConnect } from "./cloud";
|
|
20
31
|
import { safeNextPath, canonicalPodUrl } from "./discovery";
|
|
21
32
|
import { ConnectError, isConnectError } from "./errors";
|
|
22
33
|
import { serializeCookie, parseCookies } from "./encoding";
|
|
@@ -24,7 +35,7 @@ import { createPodClient, type PodClient, type PodClientOptions } from "./client
|
|
|
24
35
|
import { assertPermissions, handleCallback, refreshConnection, revokeConnection, startConnect, TRANSACTION_TTL_MS } from "./flow";
|
|
25
36
|
import { assertServerRuntime } from "./guard";
|
|
26
37
|
import { assertAppSecret, type AppSecret } from "./seal";
|
|
27
|
-
import { applySetCookies, cookieNames, createSealedCookieStore, type ConnectionStore } from "./store";
|
|
38
|
+
import { applySetCookies, cookieNames, createSealedCookieStore, markerCookie, readMarker, type ConnectionStore } from "./store";
|
|
28
39
|
import type { AppRequest, ClientMetadataDocument, PodConnection } from "./types";
|
|
29
40
|
|
|
30
41
|
export interface PodAppPaths {
|
|
@@ -33,6 +44,8 @@ export interface PodAppPaths {
|
|
|
33
44
|
connect: string;
|
|
34
45
|
callback: string;
|
|
35
46
|
disconnect: string;
|
|
47
|
+
/** Synap Cloud: POST starts it, GET is the pod chooser's return (forwarded from the connect page). */
|
|
48
|
+
cloud: string;
|
|
36
49
|
/** The app's connect page (also the CIMD `initiate_login_uri`). */
|
|
37
50
|
connectPage: string;
|
|
38
51
|
/** Where a failed callback lands, with `?error=<code>`. */
|
|
@@ -46,6 +59,7 @@ export const DEFAULT_PATHS: PodAppPaths = {
|
|
|
46
59
|
connect: "/api/synap/connect",
|
|
47
60
|
callback: "/api/synap/callback",
|
|
48
61
|
disconnect: "/api/synap/disconnect",
|
|
62
|
+
cloud: "/api/synap/cloud",
|
|
49
63
|
connectPage: "/connect",
|
|
50
64
|
resultPage: "/connect/result",
|
|
51
65
|
afterConnect: "/",
|
|
@@ -70,7 +84,12 @@ export interface PodAppAuthConfig {
|
|
|
70
84
|
store?: ConnectionStore;
|
|
71
85
|
/** RFC 8707 resource asked for (default the Hub API). */
|
|
72
86
|
resource?: "api/hub" | "mcp" | null;
|
|
73
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* The Synap Control Plane's URL (e.g. https://api.synap.live): offers
|
|
89
|
+
* "Continue with Synap Cloud" (`cloudConnectAvailable`). Unset = address only.
|
|
90
|
+
*/
|
|
91
|
+
cloudUrl?: string | null;
|
|
92
|
+
/** Accept pods (and a Control Plane) on http://localhost (development against a local pod). */
|
|
74
93
|
allowInsecureLoopback?: boolean;
|
|
75
94
|
/** Developer log of every refused sign-in; messages never carry a token. Default: console.warn. */
|
|
76
95
|
onError?: (error: ConnectError, where: "connect" | "callback" | "disconnect" | "refresh") => void;
|
|
@@ -105,6 +124,12 @@ export interface RefreshOutcome {
|
|
|
105
124
|
/** The request's Cookie header with the refreshed value, to forward to the render (null = unchanged). */
|
|
106
125
|
cookieHeader: string | null;
|
|
107
126
|
error?: ConnectError;
|
|
127
|
+
/**
|
|
128
|
+
* With `none`: this browser WAS connected to that pod and the connection
|
|
129
|
+
* lapsed (its sealed cookie is gone, idle past its life). Say "expired,
|
|
130
|
+
* connect again" with the pod pre-filled, not "connect your pod".
|
|
131
|
+
*/
|
|
132
|
+
lapsed?: { podUrl: string };
|
|
108
133
|
}
|
|
109
134
|
|
|
110
135
|
export interface PodAppAuth {
|
|
@@ -112,11 +137,17 @@ export interface PodAppAuth {
|
|
|
112
137
|
redirectUri: string;
|
|
113
138
|
metadata: ClientMetadataDocument;
|
|
114
139
|
paths: PodAppPaths;
|
|
140
|
+
/** Whether "Continue with Synap Cloud" can be offered (`cloudUrl` is configured). */
|
|
141
|
+
cloudConnectAvailable: boolean;
|
|
115
142
|
handlers: {
|
|
116
143
|
clientMetadata(request: Request): Promise<Response>;
|
|
117
144
|
connect(request: Request): Promise<Response>;
|
|
118
145
|
callback(request: Request): Promise<Response>;
|
|
119
146
|
disconnect(request: Request): Promise<Response>;
|
|
147
|
+
/** POST: start a Synap Cloud sign-in (same-origin, form { next?, space?, prompt? }). */
|
|
148
|
+
cloudConnect(request: Request): Promise<Response>;
|
|
149
|
+
/** GET: the Synap Cloud pod chooser's return (`?iss&state`), checked against this browser's cookie. */
|
|
150
|
+
cloudReturn(request: Request): Promise<Response>;
|
|
120
151
|
};
|
|
121
152
|
/**
|
|
122
153
|
* The stored connection (no refresh, no expiry check), or null. Takes the
|
|
@@ -124,6 +155,12 @@ export interface PodAppAuth {
|
|
|
124
155
|
* pass `(await headers()).get("cookie")`).
|
|
125
156
|
*/
|
|
126
157
|
readConnection(source: Request | string | null): Promise<PodConnection | null>;
|
|
158
|
+
/**
|
|
159
|
+
* The pod this browser WAS connected to, when no connection is stored any
|
|
160
|
+
* more (it lapsed); null when one is stored or this browser never connected
|
|
161
|
+
* (or disconnected). Same sources as `readConnection`.
|
|
162
|
+
*/
|
|
163
|
+
readLapsed(source: Request | string | null): Promise<{ podUrl: string } | null>;
|
|
127
164
|
/** For `proxy.ts` / a route handler: rotate near expiry, lost-race safe. */
|
|
128
165
|
refreshIfNeeded(request: Request): Promise<RefreshOutcome>;
|
|
129
166
|
/** A Hub REST client for the connection. */
|
|
@@ -231,9 +268,23 @@ async function readForm(request: Request): Promise<FormData | null> {
|
|
|
231
268
|
}
|
|
232
269
|
}
|
|
233
270
|
|
|
234
|
-
|
|
271
|
+
/**
|
|
272
|
+
* A prefetch or a static asset: `refreshIfNeeded` answers `skipped` for these.
|
|
273
|
+
*
|
|
274
|
+
* LIMIT (Next.js 16 `proxy.ts` on the Node runtime): Next strips its router
|
|
275
|
+
* headers (`next-router-prefetch`, `rsc`, …, `FLIGHT_HEADERS` in
|
|
276
|
+
* next/dist/server/web/adapter.js) and the `_rsc` query BEFORE the proxy runs,
|
|
277
|
+
* so a router prefetch is invisible here. Skip prefetches in the proxy
|
|
278
|
+
* MATCHER, which still sees the raw headers:
|
|
279
|
+
*
|
|
280
|
+
* export const config = { matcher: [{ source: "/((?!_next/static|_next/image|.*\\.\\w+$).*)",
|
|
281
|
+
* missing: [{ type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }] }] };
|
|
282
|
+
*
|
|
283
|
+
* Route handlers and other frameworks see the headers, and this still checks them.
|
|
284
|
+
*/
|
|
285
|
+
export function isPrefetchOrAsset(request: Request): boolean {
|
|
235
286
|
const h = request.headers;
|
|
236
|
-
if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
287
|
+
if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
237
288
|
if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
|
|
238
289
|
const path = new URL(request.url).pathname;
|
|
239
290
|
return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
|
|
@@ -245,6 +296,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
245
296
|
const { origin, secure } = appOriginOf(config.appUrl);
|
|
246
297
|
const paths: PodAppPaths = { ...DEFAULT_PATHS, ...config.paths };
|
|
247
298
|
const metadata = buildClientMetadata({ ...config, paths });
|
|
299
|
+
const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
|
|
248
300
|
const clientId = metadata.client_id;
|
|
249
301
|
const redirectUri = metadata.redirect_uris[0];
|
|
250
302
|
const permissions = config.requests.map((r) => r.permission);
|
|
@@ -263,6 +315,42 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
263
315
|
return e;
|
|
264
316
|
};
|
|
265
317
|
const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
|
|
318
|
+
const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
|
|
319
|
+
const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
|
|
320
|
+
const marker = (podUrl: string) => markerCookie(podUrl, { secure, name: names.marker });
|
|
321
|
+
const asRequest = (source: Request | string | null) =>
|
|
322
|
+
source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
|
|
323
|
+
const lapsedIn = (request: Request) => {
|
|
324
|
+
const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
|
|
325
|
+
return podUrl ? { podUrl } : null;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** Off to the pod's consent: fresh PKCE + state, the transaction cookie set (extra cookies first). */
|
|
329
|
+
async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | null }, extra: string[] = []) {
|
|
330
|
+
const started = await startConnect({
|
|
331
|
+
...flow,
|
|
332
|
+
pod: opts.pod,
|
|
333
|
+
clientId,
|
|
334
|
+
redirectUri,
|
|
335
|
+
permissions,
|
|
336
|
+
secret: config.secret,
|
|
337
|
+
resource: config.resource,
|
|
338
|
+
spaceHint: opts.space && /^[A-Za-z0-9_-]{1,64}$/.test(opts.space) ? opts.space : null,
|
|
339
|
+
next: opts.next,
|
|
340
|
+
prompt: opts.prompt,
|
|
341
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
342
|
+
});
|
|
343
|
+
return redirect(started.authorizeUrl, [
|
|
344
|
+
...extra,
|
|
345
|
+
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1000, secure }),
|
|
346
|
+
]);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A returning person already connected to this pod goes straight on (unless they asked to see the consent again). */
|
|
350
|
+
async function liveConnectionTo(request: Request, pod: string): Promise<boolean> {
|
|
351
|
+
const existing = await store.read(request);
|
|
352
|
+
return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
|
|
353
|
+
}
|
|
266
354
|
|
|
267
355
|
async function clientMetadata(): Promise<Response> {
|
|
268
356
|
return new Response(JSON.stringify(metadata), {
|
|
@@ -278,27 +366,10 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
278
366
|
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
279
367
|
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
280
368
|
const space = formField(form, "space");
|
|
369
|
+
const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
|
|
281
370
|
try {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config)) {
|
|
285
|
-
return redirect(new URL(next, origin).href);
|
|
286
|
-
}
|
|
287
|
-
const started = await startConnect({
|
|
288
|
-
...flow,
|
|
289
|
-
pod,
|
|
290
|
-
clientId,
|
|
291
|
-
redirectUri,
|
|
292
|
-
permissions,
|
|
293
|
-
secret: config.secret,
|
|
294
|
-
resource: config.resource,
|
|
295
|
-
spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
|
|
296
|
-
next,
|
|
297
|
-
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
298
|
-
});
|
|
299
|
-
return redirect(started.authorizeUrl, [
|
|
300
|
-
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1000, secure }),
|
|
301
|
-
]);
|
|
371
|
+
if (!prompt && (await liveConnectionTo(request, pod))) return redirect(new URL(next, origin).href);
|
|
372
|
+
return await toPod({ pod, next, space, prompt });
|
|
302
373
|
} catch (err) {
|
|
303
374
|
const e = fail(err, "connect");
|
|
304
375
|
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
|
|
@@ -318,7 +389,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
318
389
|
secret: config.secret,
|
|
319
390
|
});
|
|
320
391
|
const cookies = await store.write(connection, request);
|
|
321
|
-
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
|
|
392
|
+
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
|
|
322
393
|
} catch (err) {
|
|
323
394
|
const e = fail(err, "callback");
|
|
324
395
|
return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
|
|
@@ -334,7 +405,58 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
334
405
|
const { revoked } = await revokeConnection(connection, flow);
|
|
335
406
|
if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
|
|
336
407
|
}
|
|
337
|
-
|
|
408
|
+
// A sign-out is not a lapse: the marker goes too.
|
|
409
|
+
return redirect(new URL(next, origin).href, [...(await store.clear(request)), clearMarker]);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function cloudConnect(request: Request): Promise<Response> {
|
|
413
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
414
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
415
|
+
const form = await readForm(request);
|
|
416
|
+
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
417
|
+
const space = formField(form, "space");
|
|
418
|
+
try {
|
|
419
|
+
const started = await startCloudConnect({
|
|
420
|
+
cloudUrl,
|
|
421
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
422
|
+
clientId,
|
|
423
|
+
secret: config.secret,
|
|
424
|
+
next,
|
|
425
|
+
spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
|
|
426
|
+
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
427
|
+
now,
|
|
428
|
+
});
|
|
429
|
+
return redirect(started.url, [
|
|
430
|
+
serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1000, secure }),
|
|
431
|
+
]);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
const e = fail(err, "connect");
|
|
434
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function cloudReturn(request: Request): Promise<Response> {
|
|
439
|
+
if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
|
|
440
|
+
let pod: string | null = null;
|
|
441
|
+
try {
|
|
442
|
+
// The state is checked against THIS browser's cookie before the pod is used.
|
|
443
|
+
const back = await acceptCloudReturn({
|
|
444
|
+
url: request.url,
|
|
445
|
+
transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
|
|
446
|
+
clientId,
|
|
447
|
+
secret: config.secret,
|
|
448
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
449
|
+
now,
|
|
450
|
+
});
|
|
451
|
+
pod = back.pod;
|
|
452
|
+
const next = back.next ?? paths.afterConnect;
|
|
453
|
+
if (!back.prompt && (await liveConnectionTo(request, back.pod))) return redirect(new URL(next, origin).href, [clearCloud]);
|
|
454
|
+
return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
const e = fail(err, "connect");
|
|
457
|
+
// The pod is echoed only once the state matched: a forged return pre-fills nothing.
|
|
458
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
459
|
+
}
|
|
338
460
|
}
|
|
339
461
|
|
|
340
462
|
async function refreshIfNeeded(request: Request): Promise<RefreshOutcome> {
|
|
@@ -346,7 +468,10 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
346
468
|
cookieHeader: null,
|
|
347
469
|
...(error ? { error } : {}),
|
|
348
470
|
});
|
|
349
|
-
if (!connection)
|
|
471
|
+
if (!connection) {
|
|
472
|
+
const lapsed = lapsedIn(request);
|
|
473
|
+
return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
|
|
474
|
+
}
|
|
350
475
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
351
476
|
const t = now();
|
|
352
477
|
const accessLives = connection.accessExpiresAt > t;
|
|
@@ -383,6 +508,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
383
508
|
const cleared = await store.clear(request).catch(() => [] as string[]);
|
|
384
509
|
return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
|
|
385
510
|
}
|
|
511
|
+
setCookies = [...setCookies, marker(next.podUrl)];
|
|
386
512
|
return {
|
|
387
513
|
status: "refreshed",
|
|
388
514
|
connection: next,
|
|
@@ -396,9 +522,13 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
396
522
|
redirectUri,
|
|
397
523
|
metadata,
|
|
398
524
|
paths,
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
525
|
+
cloudConnectAvailable: cloudUrl !== null,
|
|
526
|
+
handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
|
|
527
|
+
readConnection: (source) => store.read(asRequest(source)),
|
|
528
|
+
async readLapsed(source) {
|
|
529
|
+
const request = asRequest(source);
|
|
530
|
+
return (await store.read(request)) ? null : lapsedIn(request);
|
|
531
|
+
},
|
|
402
532
|
refreshIfNeeded,
|
|
403
533
|
client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts }),
|
|
404
534
|
};
|
package/src/connect/client.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* redirect is followed.
|
|
18
18
|
*/
|
|
19
19
|
import { retryAfterSeconds } from "./errors";
|
|
20
|
-
import type { PodConnection, PodGrant } from "./types";
|
|
20
|
+
import type { PodConnection, PodGrant, PodSpace } from "./types";
|
|
21
21
|
|
|
22
22
|
export type PodErrorCode =
|
|
23
23
|
| "expired"
|
|
@@ -49,6 +49,38 @@ export type PodWriteResult<T> =
|
|
|
49
49
|
|
|
50
50
|
export type QueryValue = string | number | boolean | null | undefined | ReadonlyArray<string | number | boolean>;
|
|
51
51
|
|
|
52
|
+
/** One pending proposal, the pod's summary row (`GET /proposals?view=basic`). */
|
|
53
|
+
export interface PendingProposal {
|
|
54
|
+
id: string;
|
|
55
|
+
proposalType: string;
|
|
56
|
+
targetType: string;
|
|
57
|
+
targetId: string | null;
|
|
58
|
+
workspaceId: string | null;
|
|
59
|
+
/** ISO 8601, as the pod sent it. */
|
|
60
|
+
createdAt: string;
|
|
61
|
+
/** The agent that filed it (this app's agent, or another one), when known. */
|
|
62
|
+
agentUserId: string | null;
|
|
63
|
+
sessionId: string | null;
|
|
64
|
+
summary?: string;
|
|
65
|
+
/** Where the person reviews it: the pod's deep link. */
|
|
66
|
+
reviewUrl: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PendingProposals {
|
|
70
|
+
/** Every pending proposal the connection can see, not only this page (the pod COUNTs it). */
|
|
71
|
+
total: number;
|
|
72
|
+
proposals: PendingProposal[];
|
|
73
|
+
hasMore: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ListPendingProposalsOptions {
|
|
77
|
+
/** One granted space only. */
|
|
78
|
+
workspaceId?: string;
|
|
79
|
+
/** 1-200, default 20. */
|
|
80
|
+
limit?: number;
|
|
81
|
+
offset?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
52
84
|
export interface PodClientOptions {
|
|
53
85
|
fetch?: typeof fetch;
|
|
54
86
|
now?: () => number;
|
|
@@ -60,6 +92,16 @@ export interface PodClient {
|
|
|
60
92
|
grant: PodGrant[];
|
|
61
93
|
/** Whether the connection was granted `permission` (in `workspaceId` when given). */
|
|
62
94
|
can(permission: string, workspaceId?: string): boolean;
|
|
95
|
+
/** The granted spaces, each with its permissions (`spaces(connection)`). */
|
|
96
|
+
spaces(): PodSpace[];
|
|
97
|
+
/**
|
|
98
|
+
* "Waiting for review: N". The person's pending proposals in the spaces this
|
|
99
|
+
* connection may read proposals in (any agent's, not only this app's), from
|
|
100
|
+
* the pod's seamed `GET /proposals` door. Needs the `proposal.read`
|
|
101
|
+
* permission (ask for it in `requests`): without it the pod would answer an
|
|
102
|
+
* empty list, so this answers `forbidden` instead, without a request.
|
|
103
|
+
*/
|
|
104
|
+
listPendingProposals(opts?: ListPendingProposalsOptions): Promise<PodResult<PendingProposals>>;
|
|
63
105
|
get<T = unknown>(path: string, query?: Record<string, QueryValue>): Promise<PodResult<T>>;
|
|
64
106
|
post<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
65
107
|
patch<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
@@ -72,6 +114,21 @@ export function can(connection: Pick<PodConnection, "grant">, permission: string
|
|
|
72
114
|
return connection.grant.some((g) => g.permission === permission && (workspaceId === undefined || g.workspaceId === workspaceId));
|
|
73
115
|
}
|
|
74
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The grant by space, in the grant's order, each permission once. The pod
|
|
119
|
+
* sends one row per permission per space; a space switcher wants one per space.
|
|
120
|
+
*/
|
|
121
|
+
export function spaces(connection: Pick<PodConnection, "grant">): PodSpace[] {
|
|
122
|
+
const bySpace = new Map<string, PodSpace>();
|
|
123
|
+
for (const g of connection.grant) {
|
|
124
|
+
const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
|
|
125
|
+
if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
|
|
126
|
+
if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
|
|
127
|
+
bySpace.set(g.workspaceId, space);
|
|
128
|
+
}
|
|
129
|
+
return [...bySpace.values()];
|
|
130
|
+
}
|
|
131
|
+
|
|
75
132
|
const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
|
|
76
133
|
|
|
77
134
|
function hubUrl(podUrl: string, path: string, query?: Record<string, QueryValue>): URL {
|
|
@@ -169,11 +226,60 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
|
|
|
169
226
|
return { ok: true, status: "applied", data: result.data as T };
|
|
170
227
|
}
|
|
171
228
|
|
|
229
|
+
async function listPendingProposals(opts: ListPendingProposalsOptions = {}): Promise<PodResult<PendingProposals>> {
|
|
230
|
+
if (!can(connection, "proposal.read", opts.workspaceId)) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: { code: "forbidden", status: null, detail: "The connection was not granted proposal.read in that space" },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const limit = Math.min(Math.max(Math.floor(opts.limit ?? 20), 1), 200);
|
|
237
|
+
const offset = Math.max(Math.floor(opts.offset ?? 0), 0);
|
|
238
|
+
const result = await request("GET", "/proposals", undefined, {
|
|
239
|
+
status: "pending",
|
|
240
|
+
view: "basic",
|
|
241
|
+
limit,
|
|
242
|
+
offset,
|
|
243
|
+
workspaceId: opts.workspaceId,
|
|
244
|
+
});
|
|
245
|
+
if (!result.ok) return result;
|
|
246
|
+
const obj = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
|
|
247
|
+
if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
|
|
248
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
|
|
249
|
+
}
|
|
250
|
+
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
|
251
|
+
const proposals: PendingProposal[] = [];
|
|
252
|
+
for (const row of obj.proposals as unknown[]) {
|
|
253
|
+
if (!row || typeof row !== "object") continue;
|
|
254
|
+
const r = row as Record<string, unknown>;
|
|
255
|
+
const id = str(r.id);
|
|
256
|
+
if (!id) continue;
|
|
257
|
+
proposals.push({
|
|
258
|
+
id,
|
|
259
|
+
proposalType: str(r.proposalType) ?? "",
|
|
260
|
+
targetType: str(r.targetType) ?? "",
|
|
261
|
+
targetId: str(r.targetId),
|
|
262
|
+
workspaceId: str(r.workspaceId),
|
|
263
|
+
createdAt: str(r.createdAt) ?? "",
|
|
264
|
+
agentUserId: str(r.agentUserId),
|
|
265
|
+
sessionId: str(r.sessionId),
|
|
266
|
+
...(str(r.summary) ? { summary: str(r.summary)! } : {}),
|
|
267
|
+
reviewUrl: reviewUrlFor(connection.podUrl, null, id),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
172
276
|
return {
|
|
173
277
|
podUrl: connection.podUrl,
|
|
174
278
|
user: connection.user,
|
|
175
279
|
grant: connection.grant,
|
|
176
280
|
can: (permission, workspaceId) => can(connection, permission, workspaceId),
|
|
281
|
+
spaces: () => spaces(connection),
|
|
282
|
+
listPendingProposals,
|
|
177
283
|
get<T>(path: string, query?: Record<string, QueryValue>) {
|
|
178
284
|
return request("GET", path, undefined, query) as Promise<PodResult<T>>;
|
|
179
285
|
},
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connecting through Synap Cloud (wave C1, doc 18 §1.1): the person picks
|
|
3
|
+
* their pod on the Control Plane instead of typing its address.
|
|
4
|
+
*
|
|
5
|
+
* app POST <cloud handler> → 303 <cp>/connect/pod?client_id=<CIMD url>&state=<nonce>
|
|
6
|
+
* CP the person signs in, picks a pod → 302 <initiate_login_uri>?iss=<pod issuer>&state=<nonce>
|
|
7
|
+
* app GET <cloud handler>?iss&state → state checked against this browser's sealed
|
|
8
|
+
* cookie → the SAME sign-in as a typed address
|
|
9
|
+
* (discovery, fresh PKCE + state, `iss` on the answer)
|
|
10
|
+
*
|
|
11
|
+
* The CP never sees a code or a token, and takes the return URL from the
|
|
12
|
+
* app's client metadata, never from the query. `state` is what makes the
|
|
13
|
+
* return trustworthy: it is drawn here, kept sealed in an HttpOnly cookie of
|
|
14
|
+
* this browser, and compared in constant time, so an `iss` link that another
|
|
15
|
+
* site sends a visitor to (login CSRF into an attacker's pod) is refused. A
|
|
16
|
+
* return without a matching `state` never starts a sign-in.
|
|
17
|
+
*
|
|
18
|
+
* The CP's answer only names a pod. The pod itself is then validated exactly
|
|
19
|
+
* like a typed address (`discoverPod`), and its answer like any other.
|
|
20
|
+
*/
|
|
21
|
+
import { canonicalPodUrl } from "./discovery";
|
|
22
|
+
import { constantTimeEqual, randomToken } from "./encoding";
|
|
23
|
+
import { ConnectError, fromOAuthError } from "./errors";
|
|
24
|
+
import { seal, unseal, type AppSecret } from "./seal";
|
|
25
|
+
|
|
26
|
+
/** How long a Synap Cloud sign-in may take, from the click to the pod chooser's return. */
|
|
27
|
+
export const CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** The state the CP accepts (`^[A-Za-z0-9._~-]{16,256}$`); ours is 43 base64url characters. */
|
|
30
|
+
const STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
31
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
32
|
+
|
|
33
|
+
/** A sealed `CloudTransaction`: opaque, safe to put in an HttpOnly cookie. */
|
|
34
|
+
export type SealedCloudTransaction = string & { readonly __sealedCloudTransaction: true };
|
|
35
|
+
|
|
36
|
+
/** What a started Synap Cloud sign-in remembers (sealed; never sent to the CP). */
|
|
37
|
+
export interface CloudTransaction {
|
|
38
|
+
v: 1;
|
|
39
|
+
state: string;
|
|
40
|
+
clientId: string;
|
|
41
|
+
/** The CP base it was sent to. */
|
|
42
|
+
cloudUrl: string;
|
|
43
|
+
next: string | null;
|
|
44
|
+
spaceHint: string | null;
|
|
45
|
+
prompt: "consent" | null;
|
|
46
|
+
createdAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CloudConnectConfig {
|
|
50
|
+
/** The Control Plane's base URL, e.g. https://api.synap.live. Unset = no Synap Cloud option. */
|
|
51
|
+
cloudUrl?: string | null;
|
|
52
|
+
/** Accept a CP on http://localhost (development). */
|
|
53
|
+
allowInsecureLoopback?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The CP base (no trailing slash), or a ConnectError (`misconfigured`). */
|
|
57
|
+
export function cloudBaseUrl(cloudUrl: string, opts: { allowInsecureLoopback?: boolean } = {}): string {
|
|
58
|
+
let url: URL;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(cloudUrl.trim());
|
|
61
|
+
} catch {
|
|
62
|
+
throw new ConnectError("misconfigured", "cloudUrl is not a URL");
|
|
63
|
+
}
|
|
64
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
65
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
|
|
66
|
+
throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
|
|
67
|
+
}
|
|
68
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
69
|
+
throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
|
|
70
|
+
}
|
|
71
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether "Continue with Synap Cloud" can be offered: a valid Control Plane URL is configured. */
|
|
75
|
+
export function cloudConnectAvailable(config?: CloudConnectConfig | null): boolean {
|
|
76
|
+
if (!config?.cloudUrl) return false;
|
|
77
|
+
try {
|
|
78
|
+
cloudBaseUrl(config.cloudUrl, config);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface StartCloudConnectOptions extends CloudConnectConfig {
|
|
86
|
+
/** The app's client_id (its CIMD URL): the CP reads `initiate_login_uri` from it. */
|
|
87
|
+
clientId: string;
|
|
88
|
+
/** SERVER-ONLY app secret that seals the transaction. */
|
|
89
|
+
secret?: AppSecret;
|
|
90
|
+
/** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
|
|
91
|
+
next?: string | null;
|
|
92
|
+
/** A space to preselect on the pod's consent. */
|
|
93
|
+
spaceHint?: string | null;
|
|
94
|
+
/** Show the pod's consent again even if this browser is already connected to the pod the person picks. */
|
|
95
|
+
prompt?: "consent" | null;
|
|
96
|
+
/** @deprecated Ignored: the SDK always draws its own 32-byte state. */
|
|
97
|
+
state?: string;
|
|
98
|
+
now?: () => number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface StartedCloudConnect {
|
|
102
|
+
/** Redirect the browser here (303): the CP's pod chooser. */
|
|
103
|
+
url: string;
|
|
104
|
+
/** Store in an HttpOnly cookie until the CP returns. */
|
|
105
|
+
transaction: SealedCloudTransaction;
|
|
106
|
+
/** ms since epoch. */
|
|
107
|
+
expiresAt: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start a Synap Cloud sign-in. Throws `cp_unavailable` at once when no CP URL
|
|
112
|
+
* is configured (as 1.3 always did), `misconfigured` without a secret.
|
|
113
|
+
*/
|
|
114
|
+
export function startCloudConnect(opts: StartCloudConnectOptions): Promise<StartedCloudConnect> {
|
|
115
|
+
if (!cloudConnectAvailable(opts)) {
|
|
116
|
+
throw new ConnectError("cp_unavailable", "cp: not configured — pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
|
|
117
|
+
}
|
|
118
|
+
if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
|
|
119
|
+
const base = cloudBaseUrl(opts.cloudUrl!, opts);
|
|
120
|
+
const secret = opts.secret;
|
|
121
|
+
return (async () => {
|
|
122
|
+
const now = (opts.now ?? Date.now)();
|
|
123
|
+
const state = randomToken();
|
|
124
|
+
const url = new URL(`${base}/connect/pod`);
|
|
125
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
126
|
+
url.searchParams.set("state", state);
|
|
127
|
+
const tx: CloudTransaction = {
|
|
128
|
+
v: 1,
|
|
129
|
+
state,
|
|
130
|
+
clientId: opts.clientId,
|
|
131
|
+
cloudUrl: base,
|
|
132
|
+
next: opts.next ?? null,
|
|
133
|
+
spaceHint: opts.spaceHint ?? null,
|
|
134
|
+
prompt: opts.prompt === "consent" ? "consent" : null,
|
|
135
|
+
createdAt: now,
|
|
136
|
+
};
|
|
137
|
+
const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
|
|
138
|
+
const transaction = (await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt })) as SealedCloudTransaction;
|
|
139
|
+
return { url: url.href, transaction, expiresAt };
|
|
140
|
+
})();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface AcceptCloudReturnOptions {
|
|
144
|
+
/** The URL the CP sent the browser back to (`?iss=…&state=…`). */
|
|
145
|
+
url: string | URL;
|
|
146
|
+
transaction: string | null | undefined;
|
|
147
|
+
clientId: string;
|
|
148
|
+
secret: AppSecret;
|
|
149
|
+
allowInsecureLoopback?: boolean;
|
|
150
|
+
now?: () => number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface CloudReturn {
|
|
154
|
+
/** The pod the person picked (canonical issuer), still to be discovered and validated. */
|
|
155
|
+
pod: string;
|
|
156
|
+
next: string | null;
|
|
157
|
+
spaceHint: string | null;
|
|
158
|
+
prompt: "consent" | null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check the CP's return against the sealed transaction of THIS browser.
|
|
163
|
+
* Throws `expired_transaction` (none, foreign, tampered or older than 10
|
|
164
|
+
* minutes), `mismatch` (the state is not this browser's, or no pod is named),
|
|
165
|
+
* `denied` (the person cancelled on the chooser: `error=access_denied`, no
|
|
166
|
+
* `iss`; nothing is started), `invalid_pod_address`.
|
|
167
|
+
* `state` is compared BEFORE anything else in the query is used.
|
|
168
|
+
*/
|
|
169
|
+
export async function acceptCloudReturn(opts: AcceptCloudReturnOptions): Promise<CloudReturn> {
|
|
170
|
+
if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
|
|
171
|
+
const opened = await unseal<CloudTransaction>(opts.transaction, {
|
|
172
|
+
secret: opts.secret,
|
|
173
|
+
purpose: "synap-connect/cloud",
|
|
174
|
+
now: (opts.now ?? Date.now)(),
|
|
175
|
+
});
|
|
176
|
+
if (!opened.ok) {
|
|
177
|
+
throw new ConnectError(
|
|
178
|
+
"expired_transaction",
|
|
179
|
+
opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const tx = opened.value;
|
|
183
|
+
if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
|
|
184
|
+
throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
|
|
185
|
+
}
|
|
186
|
+
if (tx.clientId !== opts.clientId) {
|
|
187
|
+
throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
|
|
188
|
+
}
|
|
189
|
+
const params = new URL(opts.url).searchParams;
|
|
190
|
+
const state = params.get("state");
|
|
191
|
+
if (state === null || !STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
|
|
192
|
+
throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
|
|
193
|
+
}
|
|
194
|
+
const error = params.get("error");
|
|
195
|
+
if (error !== null) {
|
|
196
|
+
throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
|
|
197
|
+
}
|
|
198
|
+
const iss = params.get("iss");
|
|
199
|
+
if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
|
|
200
|
+
return {
|
|
201
|
+
pod: canonicalPodUrl(iss, opts),
|
|
202
|
+
next: tx.next,
|
|
203
|
+
spaceHint: tx.spaceHint,
|
|
204
|
+
prompt: tx.prompt === "consent" ? "consent" : null,
|
|
205
|
+
};
|
|
206
|
+
}
|