@synap-core/sdk 1.2.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.
@@ -0,0 +1,1365 @@
1
+ import {
2
+ base64url,
3
+ constantTimeEqual,
4
+ fromBase64url,
5
+ fromUtf8,
6
+ parseCookies,
7
+ pkceChallenge,
8
+ randomBytes,
9
+ randomToken,
10
+ serializeCookie,
11
+ utf8
12
+ } from "./chunk-J7XHAAFJ.js";
13
+
14
+ // src/connect/guard.ts
15
+ var BROWSER_REFUSAL = "@synap-core/sdk/connect is server-only: it holds the pod's tokens and the app secret. Import it from route handlers, proxy.ts or server code, never from a client component.";
16
+ function isBrowserRuntime() {
17
+ const g = globalThis;
18
+ return typeof g.window !== "undefined" && typeof g.document !== "undefined";
19
+ }
20
+ function assertServerRuntime() {
21
+ if (isBrowserRuntime()) throw new Error(BROWSER_REFUSAL);
22
+ }
23
+
24
+ // src/connect/errors.ts
25
+ var ConnectError = class extends Error {
26
+ code;
27
+ /** Seconds to wait, from `Retry-After`, when `code` is `rate_limited`. */
28
+ retryAfterSec;
29
+ /** The OAuth error code the pod sent, when there was one (logs only). */
30
+ oauthError;
31
+ constructor(code, message, extra = {}) {
32
+ super(message);
33
+ this.name = "ConnectError";
34
+ this.code = code;
35
+ if (extra.retryAfterSec !== void 0) this.retryAfterSec = extra.retryAfterSec;
36
+ if (extra.oauthError !== void 0) this.oauthError = extra.oauthError;
37
+ }
38
+ };
39
+ function isConnectError(err) {
40
+ return err instanceof ConnectError;
41
+ }
42
+ var CONNECT_ERROR_CODES = [
43
+ "invalid_pod_address",
44
+ "not_a_pod",
45
+ "pod_unreachable",
46
+ "pod_unsupported",
47
+ "cp_unavailable",
48
+ "denied",
49
+ "expired_transaction",
50
+ "mismatch",
51
+ "misconfigured",
52
+ "rate_limited",
53
+ "expired",
54
+ "revoked",
55
+ "server_error"
56
+ ];
57
+ function parseConnectErrorCode(value) {
58
+ return CONNECT_ERROR_CODES.includes(value ?? "") ? value : null;
59
+ }
60
+ function fromOAuthError(oauthError, during) {
61
+ switch (oauthError) {
62
+ case "access_denied":
63
+ return "denied";
64
+ case "temporarily_unavailable":
65
+ case "server_error":
66
+ return "pod_unreachable";
67
+ case "invalid_grant":
68
+ return during === "refresh" ? "revoked" : "expired_transaction";
69
+ case "invalid_request":
70
+ case "invalid_client":
71
+ case "invalid_scope":
72
+ case "invalid_target":
73
+ case "unauthorized_client":
74
+ case "unsupported_grant_type":
75
+ case "unsupported_response_type":
76
+ return "misconfigured";
77
+ default:
78
+ return "server_error";
79
+ }
80
+ }
81
+ function retryAfterSeconds(header, now) {
82
+ if (!header) return void 0;
83
+ const trimmed = header.trim();
84
+ if (/^\d{1,9}$/.test(trimmed)) return Number(trimmed);
85
+ const at = Date.parse(trimmed);
86
+ if (Number.isNaN(at)) return void 0;
87
+ return Math.max(0, Math.ceil((at - now) / 1e3));
88
+ }
89
+
90
+ // src/connect/discovery.ts
91
+ var METADATA_PATH = "/.well-known/oauth-authorization-server";
92
+ var TIMEOUT_MS = 5e3;
93
+ var MAX_METADATA_BYTES = 64 * 1024;
94
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
95
+ function isPrivateIpv4(host) {
96
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
97
+ if (!m) return false;
98
+ const [a, b] = [Number(m[1]), Number(m[2])];
99
+ return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a >= 224;
100
+ }
101
+ function canonicalPodUrl(input, opts = {}) {
102
+ const raw = input.trim();
103
+ if (!raw || raw.length > 512) throw new ConnectError("invalid_pod_address", "Empty or overlong pod address");
104
+ let url;
105
+ try {
106
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
107
+ } catch {
108
+ throw new ConnectError("invalid_pod_address", "Not a URL");
109
+ }
110
+ const host = url.hostname.toLowerCase();
111
+ const loopback = LOOPBACK_HOSTS.has(host);
112
+ if (url.protocol === "http:" && !(loopback && opts.allowInsecureLoopback)) {
113
+ throw new ConnectError("invalid_pod_address", "A pod address must use https");
114
+ }
115
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
116
+ throw new ConnectError("invalid_pod_address", "A pod address must use https");
117
+ }
118
+ if (url.username || url.password || url.search || url.hash) {
119
+ throw new ConnectError("invalid_pod_address", "A pod address has no credentials, query or fragment");
120
+ }
121
+ if (!(loopback && opts.allowInsecureLoopback)) {
122
+ if (loopback || host.startsWith("[") || isPrivateIpv4(host) || /^\d+$/.test(host)) {
123
+ throw new ConnectError("invalid_pod_address", "A pod address must be a public host name");
124
+ }
125
+ if (!host.includes(".") || /\.(local|internal|localhost|lan|home\.arpa)$/.test(host)) {
126
+ throw new ConnectError("invalid_pod_address", "A pod address must be a public host name");
127
+ }
128
+ }
129
+ const path = url.pathname.replace(/\/+$/, "");
130
+ return `${url.origin}${path}`;
131
+ }
132
+ function metadataUrl(issuer) {
133
+ const url = new URL(issuer);
134
+ const path = url.pathname === "/" ? "" : url.pathname;
135
+ return `${url.origin}${METADATA_PATH}${path}`;
136
+ }
137
+ function sameOriginEndpoint(value, issuer) {
138
+ if (typeof value !== "string") return null;
139
+ try {
140
+ const url = new URL(value);
141
+ return url.origin === new URL(issuer).origin && !url.username && !url.hash ? url.href : null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+ async function readJsonCapped(res) {
147
+ const text = await res.text();
148
+ if (text.length > MAX_METADATA_BYTES) throw new ConnectError("not_a_pod", "Metadata document too large");
149
+ try {
150
+ return JSON.parse(text);
151
+ } catch {
152
+ throw new ConnectError("not_a_pod", "Metadata is not JSON");
153
+ }
154
+ }
155
+ async function discoverIssuer(issuer, opts) {
156
+ const doFetch = opts.fetch ?? fetch;
157
+ let res;
158
+ try {
159
+ res = await doFetch(metadataUrl(issuer), {
160
+ method: "GET",
161
+ headers: { Accept: "application/json" },
162
+ redirect: "manual",
163
+ signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : void 0
164
+ });
165
+ } catch {
166
+ throw new ConnectError("pod_unreachable", "The pod did not answer");
167
+ }
168
+ if (res.status === 429) {
169
+ throw new ConnectError("rate_limited", "The pod asked to slow down", {
170
+ retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
171
+ });
172
+ }
173
+ if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
174
+ if (res.status !== 200) throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
175
+ const doc = await readJsonCapped(res);
176
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
177
+ const m = doc;
178
+ if (m.issuer !== issuer) {
179
+ throw new ConnectError("not_a_pod", "The metadata names a different issuer than the address it was fetched from");
180
+ }
181
+ const list = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : null;
182
+ const pkce = list(m.code_challenge_methods_supported);
183
+ const responseTypes = list(m.response_types_supported);
184
+ if (m.authorization_response_iss_parameter_supported !== true || m.client_id_metadata_document_supported !== true || !pkce?.includes("S256") || responseTypes !== null && !responseTypes.includes("code")) {
185
+ throw new ConnectError("pod_unsupported", "This pod does not support app sign-in (CIMD, iss, S256)");
186
+ }
187
+ const authorizationEndpoint = sameOriginEndpoint(m.authorization_endpoint, issuer);
188
+ const tokenEndpoint = sameOriginEndpoint(m.token_endpoint, issuer);
189
+ if (!authorizationEndpoint || !tokenEndpoint) {
190
+ throw new ConnectError("pod_unsupported", "The pod's endpoints are missing or not on its own origin");
191
+ }
192
+ return {
193
+ issuer,
194
+ authorizationEndpoint,
195
+ tokenEndpoint,
196
+ revocationEndpoint: sameOriginEndpoint(m.revocation_endpoint, issuer),
197
+ refreshSupported: list(m.grant_types_supported)?.includes("refresh_token") ?? false
198
+ };
199
+ }
200
+ async function discoverPod(input, opts = {}) {
201
+ const first = canonicalPodUrl(input, opts);
202
+ try {
203
+ return await discoverIssuer(first, opts);
204
+ } catch (err) {
205
+ const url = new URL(first);
206
+ const retry = err instanceof ConnectError && (err.code === "not_a_pod" || err.code === "pod_unreachable") && !url.hostname.startsWith("pod.") && url.pathname === "/" && url.hostname.includes(".");
207
+ if (!retry) throw err;
208
+ const second = `${url.protocol}//pod.${url.host}`;
209
+ try {
210
+ return await discoverIssuer(second, opts);
211
+ } catch (err2) {
212
+ throw err2 instanceof ConnectError && err2.code === "pod_unsupported" ? err2 : err;
213
+ }
214
+ }
215
+ }
216
+ function safeNextPath(value, appOrigin) {
217
+ if (!value || value.length > 2048) return null;
218
+ if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) {
219
+ try {
220
+ const url = new URL(value, appOrigin);
221
+ return url.origin === appOrigin ? `${url.pathname}${url.search}${url.hash}` : null;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+ try {
227
+ const url = new URL(value);
228
+ return url.origin === appOrigin ? `${url.pathname}${url.search}${url.hash}` : null;
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+ function acceptLaunch(url, appOrigin, opts = {}) {
234
+ const params = new URL(url).searchParams;
235
+ let pod = null;
236
+ const iss = params.get("iss");
237
+ if (iss) {
238
+ try {
239
+ pod = canonicalPodUrl(iss, opts);
240
+ } catch {
241
+ pod = null;
242
+ }
243
+ }
244
+ const hint = params.get("synap_space_hint");
245
+ const state = params.get("state");
246
+ return {
247
+ pod,
248
+ next: safeNextPath(params.get("target_link_uri"), appOrigin),
249
+ spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
250
+ // A browser launch never carries a state: any well-formed one is a Synap Cloud return
251
+ // (a pick: iss + state; a cancel: state + error=access_denied, no iss).
252
+ cloudState: state && /^[A-Za-z0-9._~-]{16,256}$/.test(state) ? state : null
253
+ };
254
+ }
255
+
256
+ // src/connect/seal.ts
257
+ var VERSION = "v1";
258
+ var IV_BYTES = 12;
259
+ var TAG_BYTES = 16;
260
+ var MIN_SECRET_LENGTH = 32;
261
+ var SALT = utf8("synap-connect/v1");
262
+ var keyCache = /* @__PURE__ */ new Map();
263
+ function secretsOf(secret) {
264
+ const list = typeof secret === "string" ? [secret] : secret;
265
+ if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
266
+ throw new ConnectError(
267
+ "misconfigured",
268
+ `The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
269
+ );
270
+ }
271
+ return list;
272
+ }
273
+ function assertAppSecret(secret) {
274
+ secretsOf(secret);
275
+ }
276
+ function keyFor(secret, purpose) {
277
+ const cacheKey = `${purpose}\0${secret}`;
278
+ let key = keyCache.get(cacheKey);
279
+ if (!key) {
280
+ key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
281
+ (base) => crypto.subtle.deriveKey(
282
+ { name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
283
+ base,
284
+ { name: "AES-GCM", length: 256 },
285
+ false,
286
+ ["encrypt", "decrypt"]
287
+ )
288
+ );
289
+ keyCache.set(cacheKey, key);
290
+ }
291
+ return key;
292
+ }
293
+ async function seal(value, opts) {
294
+ const [current] = secretsOf(opts.secret);
295
+ const iv = randomBytes(IV_BYTES);
296
+ const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
297
+ const ciphertext = await crypto.subtle.encrypt(
298
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
299
+ await keyFor(current, opts.purpose),
300
+ plaintext
301
+ );
302
+ const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
303
+ out.set(iv, 0);
304
+ out.set(new Uint8Array(ciphertext), IV_BYTES);
305
+ return `${VERSION}.${base64url(out)}`;
306
+ }
307
+ async function unseal(sealed, opts) {
308
+ const secrets = secretsOf(opts.secret);
309
+ const dot = sealed.indexOf(".");
310
+ if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
311
+ const bytes = fromBase64url(sealed.slice(dot + 1));
312
+ if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
313
+ const iv = bytes.slice(0, IV_BYTES);
314
+ const ciphertext = bytes.slice(IV_BYTES);
315
+ for (const secret of secrets) {
316
+ let plaintext;
317
+ try {
318
+ plaintext = await crypto.subtle.decrypt(
319
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
320
+ await keyFor(secret, opts.purpose),
321
+ ciphertext
322
+ );
323
+ } catch {
324
+ continue;
325
+ }
326
+ let parsed;
327
+ try {
328
+ parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
329
+ } catch {
330
+ return { ok: false, reason: "invalid" };
331
+ }
332
+ if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
333
+ if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
334
+ return { ok: true, value: parsed.data, expiresAt: parsed.exp };
335
+ }
336
+ return { ok: false, reason: "invalid" };
337
+ }
338
+
339
+ // src/connect/cloud.ts
340
+ var CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
341
+ var STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
342
+ var LOOPBACK_HOSTS2 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
343
+ function cloudBaseUrl(cloudUrl, opts = {}) {
344
+ let url;
345
+ try {
346
+ url = new URL(cloudUrl.trim());
347
+ } catch {
348
+ throw new ConnectError("misconfigured", "cloudUrl is not a URL");
349
+ }
350
+ const loopback = LOOPBACK_HOSTS2.has(url.hostname);
351
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
352
+ throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
353
+ }
354
+ if (url.username || url.password || url.search || url.hash) {
355
+ throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
356
+ }
357
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
358
+ }
359
+ function cloudConnectAvailable(config) {
360
+ if (!config?.cloudUrl) return false;
361
+ try {
362
+ cloudBaseUrl(config.cloudUrl, config);
363
+ return true;
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
368
+ function startCloudConnect(opts) {
369
+ if (!cloudConnectAvailable(opts)) {
370
+ throw new ConnectError("cp_unavailable", "cp: not configured \u2014 pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
371
+ }
372
+ if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
373
+ const base = cloudBaseUrl(opts.cloudUrl, opts);
374
+ const secret = opts.secret;
375
+ return (async () => {
376
+ const now = (opts.now ?? Date.now)();
377
+ const state = randomToken();
378
+ const url = new URL(`${base}/connect/pod`);
379
+ url.searchParams.set("client_id", opts.clientId);
380
+ url.searchParams.set("state", state);
381
+ const tx = {
382
+ v: 1,
383
+ state,
384
+ clientId: opts.clientId,
385
+ cloudUrl: base,
386
+ next: opts.next ?? null,
387
+ spaceHint: opts.spaceHint ?? null,
388
+ prompt: opts.prompt === "consent" ? "consent" : null,
389
+ createdAt: now
390
+ };
391
+ const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
392
+ const transaction = await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt });
393
+ return { url: url.href, transaction, expiresAt };
394
+ })();
395
+ }
396
+ async function acceptCloudReturn(opts) {
397
+ if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
398
+ const opened = await unseal(opts.transaction, {
399
+ secret: opts.secret,
400
+ purpose: "synap-connect/cloud",
401
+ now: (opts.now ?? Date.now)()
402
+ });
403
+ if (!opened.ok) {
404
+ throw new ConnectError(
405
+ "expired_transaction",
406
+ opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed"
407
+ );
408
+ }
409
+ const tx = opened.value;
410
+ if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
411
+ throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
412
+ }
413
+ if (tx.clientId !== opts.clientId) {
414
+ throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
415
+ }
416
+ const params = new URL(opts.url).searchParams;
417
+ const state = params.get("state");
418
+ if (state === null || !STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
419
+ throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
420
+ }
421
+ const error = params.get("error");
422
+ if (error !== null) {
423
+ throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
424
+ }
425
+ const iss = params.get("iss");
426
+ if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
427
+ return {
428
+ pod: canonicalPodUrl(iss, opts),
429
+ next: tx.next,
430
+ spaceHint: tx.spaceHint,
431
+ prompt: tx.prompt === "consent" ? "consent" : null
432
+ };
433
+ }
434
+
435
+ // src/connect/client.ts
436
+ function can(connection, permission, workspaceId) {
437
+ return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
438
+ }
439
+ function spaces(connection) {
440
+ const bySpace = /* @__PURE__ */ new Map();
441
+ for (const g of connection.grant) {
442
+ const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
443
+ if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
444
+ if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
445
+ bySpace.set(g.workspaceId, space);
446
+ }
447
+ return [...bySpace.values()];
448
+ }
449
+ var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
450
+ function hubUrl(podUrl, path, query) {
451
+ if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
452
+ throw new TypeError(`Not a Hub API path: ${path}`);
453
+ }
454
+ const url = new URL(`${podUrl}/api/hub${path}`);
455
+ for (const [key, value] of Object.entries(query ?? {})) {
456
+ if (value === void 0 || value === null) continue;
457
+ for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
458
+ }
459
+ return url;
460
+ }
461
+ function errorFor(status, body, retryAfter, now) {
462
+ const obj = body && typeof body === "object" ? body : {};
463
+ const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : void 0;
464
+ const base = { status, ...detail !== void 0 ? { detail } : {} };
465
+ if (status === 401) return { code: "unauthorized", ...base };
466
+ if (status === 403) return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden", ...base };
467
+ if (status === 404) return { code: "not_found", ...base };
468
+ if (status === 409) return { code: "conflict", ...base };
469
+ if (status === 400 || status === 422) return { code: "invalid", ...base };
470
+ if (status === 429) {
471
+ const retryAfterSec = retryAfterSeconds(retryAfter, now);
472
+ return { code: "rate_limited", ...base, ...retryAfterSec !== void 0 ? { retryAfterSec } : {} };
473
+ }
474
+ if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
475
+ return { code: "server", ...base };
476
+ }
477
+ function reviewUrlFor(podUrl, reviewUrl, proposalId) {
478
+ if (typeof reviewUrl === "string" && reviewUrl) {
479
+ try {
480
+ const url = new URL(reviewUrl, `${podUrl}/`);
481
+ if (url.origin === new URL(podUrl).origin) return url.href;
482
+ } catch {
483
+ }
484
+ }
485
+ return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
486
+ }
487
+ function createPodClient(connection, opts = {}) {
488
+ const now = opts.now ?? Date.now;
489
+ const doFetch = () => opts.fetch ?? fetch;
490
+ async function request(method, path, body, query) {
491
+ const url = hubUrl(connection.podUrl, path, query);
492
+ if (connection.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
493
+ const headers = { Accept: "application/json", Authorization: `Bearer ${connection.accessToken}` };
494
+ if (body !== void 0) headers["Content-Type"] = "application/json";
495
+ let res;
496
+ try {
497
+ res = await doFetch()(url.href, {
498
+ method,
499
+ headers,
500
+ body: body === void 0 ? void 0 : JSON.stringify(body),
501
+ redirect: "manual"
502
+ });
503
+ } catch {
504
+ return { ok: false, error: { code: "pod_unreachable", status: null } };
505
+ }
506
+ let data = null;
507
+ const text = await res.text().catch(() => "");
508
+ if (text) {
509
+ try {
510
+ data = JSON.parse(text);
511
+ } catch {
512
+ data = null;
513
+ }
514
+ }
515
+ if (res.status >= 200 && res.status < 300) return { ok: true, data };
516
+ return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
517
+ }
518
+ async function write(method, path, body) {
519
+ const result = await request(method, path, body);
520
+ if (!result.ok) return result;
521
+ const obj = result.data && typeof result.data === "object" ? result.data : {};
522
+ const proposalId = typeof obj.proposalId === "string" && obj.proposalId ? obj.proposalId : null;
523
+ if (obj.status === "proposed" || obj.status === "pending" && proposalId) {
524
+ return {
525
+ ok: true,
526
+ status: "proposed",
527
+ proposalId,
528
+ reviewUrl: reviewUrlFor(connection.podUrl, obj.reviewUrl, proposalId),
529
+ data: result.data
530
+ };
531
+ }
532
+ return { ok: true, status: "applied", data: result.data };
533
+ }
534
+ async function listPendingProposals(opts2 = {}) {
535
+ if (!can(connection, "proposal.read", opts2.workspaceId)) {
536
+ return {
537
+ ok: false,
538
+ error: { code: "forbidden", status: null, detail: "The connection was not granted proposal.read in that space" }
539
+ };
540
+ }
541
+ const limit = Math.min(Math.max(Math.floor(opts2.limit ?? 20), 1), 200);
542
+ const offset = Math.max(Math.floor(opts2.offset ?? 0), 0);
543
+ const result = await request("GET", "/proposals", void 0, {
544
+ status: "pending",
545
+ view: "basic",
546
+ limit,
547
+ offset,
548
+ workspaceId: opts2.workspaceId
549
+ });
550
+ if (!result.ok) return result;
551
+ const obj = result.data && typeof result.data === "object" ? result.data : {};
552
+ if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
553
+ return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
554
+ }
555
+ const str = (v) => typeof v === "string" && v ? v : null;
556
+ const proposals = [];
557
+ for (const row of obj.proposals) {
558
+ if (!row || typeof row !== "object") continue;
559
+ const r = row;
560
+ const id = str(r.id);
561
+ if (!id) continue;
562
+ proposals.push({
563
+ id,
564
+ proposalType: str(r.proposalType) ?? "",
565
+ targetType: str(r.targetType) ?? "",
566
+ targetId: str(r.targetId),
567
+ workspaceId: str(r.workspaceId),
568
+ createdAt: str(r.createdAt) ?? "",
569
+ agentUserId: str(r.agentUserId),
570
+ sessionId: str(r.sessionId),
571
+ ...str(r.summary) ? { summary: str(r.summary) } : {},
572
+ reviewUrl: reviewUrlFor(connection.podUrl, null, id)
573
+ });
574
+ }
575
+ return {
576
+ ok: true,
577
+ data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total }
578
+ };
579
+ }
580
+ return {
581
+ podUrl: connection.podUrl,
582
+ user: connection.user,
583
+ grant: connection.grant,
584
+ can: (permission, workspaceId) => can(connection, permission, workspaceId),
585
+ spaces: () => spaces(connection),
586
+ listPendingProposals,
587
+ get(path, query) {
588
+ return request("GET", path, void 0, query);
589
+ },
590
+ post(path, body) {
591
+ return write("POST", path, body);
592
+ },
593
+ patch(path, body) {
594
+ return write("PATCH", path, body);
595
+ },
596
+ put(path, body) {
597
+ return write("PUT", path, body);
598
+ },
599
+ delete(path, body) {
600
+ return write("DELETE", path, body);
601
+ }
602
+ };
603
+ }
604
+
605
+ // src/connect/flow.ts
606
+ var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
607
+ var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
608
+ var DEFAULT_ACCESS_TTL_SEC = 3600;
609
+ var TIMEOUT_MS2 = 1e4;
610
+ var PERMISSION_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i;
611
+ function assertPermissions(permissions) {
612
+ const unique = [...new Set(permissions)];
613
+ if (unique.length === 0 || unique.length > 32 || unique.some((p) => !PERMISSION_RE.test(p))) {
614
+ throw new ConnectError(
615
+ "misconfigured",
616
+ "permissions must be 1-32 grant-grammar strings such as entity.post.read (no *)"
617
+ );
618
+ }
619
+ return unique;
620
+ }
621
+ async function startConnect(opts) {
622
+ const now = (opts.now ?? Date.now)();
623
+ const permissions = assertPermissions(opts.permissions);
624
+ const issuer = typeof opts.pod === "string" ? await discoverPod(opts.pod, { fetch: opts.fetch, now: opts.now, allowInsecureLoopback: opts.allowInsecureLoopback }) : opts.pod;
625
+ const verifier = randomToken();
626
+ const state = randomToken();
627
+ const resourceKind = opts.resource === void 0 ? "api/hub" : opts.resource;
628
+ const resource = resourceKind ? `${issuer.issuer}/${resourceKind}` : null;
629
+ const url = new URL(issuer.authorizationEndpoint);
630
+ url.searchParams.set("response_type", "code");
631
+ url.searchParams.set("client_id", opts.clientId);
632
+ url.searchParams.set("redirect_uri", opts.redirectUri);
633
+ url.searchParams.set("scope", permissions.join(" "));
634
+ url.searchParams.set("state", state);
635
+ url.searchParams.set("code_challenge", await pkceChallenge(verifier));
636
+ url.searchParams.set("code_challenge_method", "S256");
637
+ if (resource) url.searchParams.set("resource", resource);
638
+ if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
639
+ if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
640
+ const tx = {
641
+ v: 1,
642
+ issuer: issuer.issuer,
643
+ tokenEndpoint: issuer.tokenEndpoint,
644
+ revocationEndpoint: issuer.revocationEndpoint,
645
+ clientId: opts.clientId,
646
+ redirectUri: opts.redirectUri,
647
+ state,
648
+ verifier,
649
+ resource,
650
+ next: opts.next ?? null,
651
+ createdAt: now
652
+ };
653
+ const expiresAt = now + TRANSACTION_TTL_MS;
654
+ const transaction = await seal(tx, {
655
+ secret: opts.secret,
656
+ purpose: "synap-connect/transaction",
657
+ expiresAt
658
+ });
659
+ return { authorizeUrl: url.toString(), transaction, expiresAt, issuer };
660
+ }
661
+ async function openTransaction(sealed, opts) {
662
+ if (!sealed) throw new ConnectError("expired_transaction", "No sign-in in progress in this browser");
663
+ const opened = await unseal(sealed, {
664
+ secret: opts.secret,
665
+ purpose: "synap-connect/transaction",
666
+ now: (opts.now ?? Date.now)()
667
+ });
668
+ if (!opened.ok) {
669
+ throw new ConnectError(
670
+ "expired_transaction",
671
+ opened.reason === "expired" ? "The sign-in is older than 10 minutes" : "The sign-in cookie is not one this app sealed"
672
+ );
673
+ }
674
+ const tx = opened.value;
675
+ if (!tx || tx.v !== 1 || typeof tx.state !== "string" || typeof tx.issuer !== "string") {
676
+ throw new ConnectError("expired_transaction", "The sign-in cookie is not one this app sealed");
677
+ }
678
+ return tx;
679
+ }
680
+ async function handleCallback(opts) {
681
+ const tx = await openTransaction(opts.transaction, opts);
682
+ if (tx.clientId !== opts.clientId || tx.redirectUri !== opts.redirectUri) {
683
+ throw new ConnectError("misconfigured", "The sign-in was started for another client_id or redirect_uri");
684
+ }
685
+ const params = new URL(opts.callbackUrl).searchParams;
686
+ const iss = params.get("iss");
687
+ if (iss === null || iss !== tx.issuer) {
688
+ throw new ConnectError("mismatch", iss === null ? "The answer names no issuer" : "The answer comes from another pod than the one asked");
689
+ }
690
+ const state = params.get("state");
691
+ if (state === null || !constantTimeEqual(state, tx.state)) {
692
+ throw new ConnectError("mismatch", "The answer does not belong to the sign-in this browser started");
693
+ }
694
+ const error = params.get("error");
695
+ if (error !== null) {
696
+ throw new ConnectError(fromOAuthError(error, "authorize"), `The pod answered ${error}`, { oauthError: error });
697
+ }
698
+ const code = params.get("code");
699
+ if (!code) throw new ConnectError("server_error", "The answer carries no code");
700
+ const body = new URLSearchParams({
701
+ grant_type: "authorization_code",
702
+ code,
703
+ redirect_uri: tx.redirectUri,
704
+ client_id: tx.clientId,
705
+ code_verifier: tx.verifier
706
+ });
707
+ if (tx.resource) body.set("resource", tx.resource);
708
+ const json = await tokenRequest(tx.tokenEndpoint, body, "token", opts);
709
+ const connection = parseTokenResponse(json, {
710
+ podUrl: tx.issuer,
711
+ clientId: tx.clientId,
712
+ tokenEndpoint: tx.tokenEndpoint,
713
+ revocationEndpoint: tx.revocationEndpoint,
714
+ now: (opts.now ?? Date.now)()
715
+ });
716
+ return { connection, next: tx.next };
717
+ }
718
+ async function tokenRequest(endpoint, body, during, opts) {
719
+ const doFetch = opts.fetch ?? fetch;
720
+ let res;
721
+ try {
722
+ res = await doFetch(endpoint, {
723
+ method: "POST",
724
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
725
+ body: body.toString(),
726
+ redirect: "manual",
727
+ signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS2) : void 0
728
+ });
729
+ } catch {
730
+ throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
731
+ }
732
+ if (res.status === 429) {
733
+ throw new ConnectError("rate_limited", "The pod asked to slow down", {
734
+ retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
735
+ });
736
+ }
737
+ if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
738
+ let json;
739
+ try {
740
+ json = await res.json();
741
+ } catch {
742
+ throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
743
+ }
744
+ const obj = json && typeof json === "object" && !Array.isArray(json) ? json : {};
745
+ if (res.status !== 200) {
746
+ const oauthError = typeof obj.error === "string" ? obj.error : "server_error";
747
+ throw new ConnectError(fromOAuthError(oauthError, during), `The token endpoint answered ${oauthError}`, { oauthError });
748
+ }
749
+ return obj;
750
+ }
751
+ function parseGrant(value) {
752
+ if (!Array.isArray(value)) return null;
753
+ const out = [];
754
+ for (const g of value) {
755
+ if (!g || typeof g !== "object") return null;
756
+ const { permission, workspaceId, workspaceName } = g;
757
+ if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
758
+ out.push({ permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" });
759
+ }
760
+ return out;
761
+ }
762
+ var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
763
+ var LOOPBACK_HOSTS3 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
764
+ function parseApp(value) {
765
+ if (!value || typeof value !== "object") return {};
766
+ const { id, manageUrl } = value;
767
+ const out = {};
768
+ if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
769
+ if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
770
+ try {
771
+ const url = new URL(manageUrl);
772
+ const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS3.has(url.hostname);
773
+ if (secure && !url.username && !url.password) out.manageUrl = url.href;
774
+ } catch {
775
+ }
776
+ }
777
+ return out;
778
+ }
779
+ function parseUser(value) {
780
+ if (!value || typeof value !== "object") return null;
781
+ const { id, name } = value;
782
+ return typeof id === "string" ? { id, name: typeof name === "string" ? name : "" } : null;
783
+ }
784
+ function parseTokenResponse(body, ctx) {
785
+ const { access_token, token_type, expires_in, scope, refresh_token, refresh_token_expires_in } = body;
786
+ if (typeof access_token !== "string" || access_token === "") {
787
+ throw new ConnectError("server_error", "The token response has no access_token");
788
+ }
789
+ if (typeof token_type !== "string" || token_type.toLowerCase() !== "bearer") {
790
+ throw new ConnectError("server_error", "The token response is not a Bearer token");
791
+ }
792
+ if (expires_in !== void 0 && (typeof expires_in !== "number" || !Number.isFinite(expires_in) || expires_in < 0)) {
793
+ throw new ConnectError("server_error", "The token response has an invalid expires_in");
794
+ }
795
+ const grant = parseGrant(body.synap_grant) ?? ctx.previous?.grant ?? null;
796
+ const user = parseUser(body.synap_user) ?? ctx.previous?.user ?? null;
797
+ if (!grant || !user) {
798
+ throw new ConnectError("pod_unsupported", "The token response lacks synap_grant / synap_user (pod older than app sign-in)");
799
+ }
800
+ const scopes = typeof scope === "string" ? scope.split(/\s+/).filter(Boolean) : ctx.previous?.scope ?? [...new Set(grant.map((g) => g.permission))];
801
+ const connection = {
802
+ podUrl: ctx.podUrl,
803
+ clientId: ctx.clientId,
804
+ tokenEndpoint: ctx.tokenEndpoint,
805
+ revocationEndpoint: ctx.revocationEndpoint,
806
+ accessToken: access_token,
807
+ accessExpiresAt: ctx.now + (typeof expires_in === "number" ? expires_in : DEFAULT_ACCESS_TTL_SEC) * 1e3,
808
+ scope: scopes,
809
+ grant,
810
+ user,
811
+ connectedAt: ctx.previous?.connectedAt ?? ctx.now
812
+ };
813
+ const app = parseApp(body.synap_app);
814
+ const appId = app.appId ?? ctx.previous?.appId;
815
+ const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
816
+ if (appId !== void 0) connection.appId = appId;
817
+ if (manageUrl !== void 0) connection.manageUrl = manageUrl;
818
+ if (typeof refresh_token === "string" && refresh_token !== "") {
819
+ connection.refreshToken = refresh_token;
820
+ const idle = typeof refresh_token_expires_in === "number" && Number.isFinite(refresh_token_expires_in) && refresh_token_expires_in > 0 ? refresh_token_expires_in * 1e3 : DEFAULT_REFRESH_IDLE_MS;
821
+ connection.refreshExpiresAt = ctx.now + idle;
822
+ }
823
+ return connection;
824
+ }
825
+ var ROTATION_MEMORY_MS = 2 * 60 * 1e3;
826
+ var rotations = /* @__PURE__ */ new Map();
827
+ function pruneRotations(now) {
828
+ for (const [token, r] of rotations) {
829
+ if (r.settledAt !== void 0 && now - r.settledAt > ROTATION_MEMORY_MS) rotations.delete(token);
830
+ }
831
+ }
832
+ function refreshConnection(connection, opts = {}) {
833
+ const clock = opts.now ?? Date.now;
834
+ const now = clock();
835
+ const token = connection.refreshToken;
836
+ if (!token) return Promise.reject(new ConnectError("expired", "The pod issued no refresh token; connect again"));
837
+ pruneRotations(now);
838
+ const known = rotations.get(token);
839
+ if (known) return known.result;
840
+ if (connection.refreshExpiresAt !== void 0 && connection.refreshExpiresAt <= now) {
841
+ return Promise.reject(new ConnectError("expired", "The connection was idle too long; connect again"));
842
+ }
843
+ const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: token, client_id: connection.clientId });
844
+ const rotation = {
845
+ result: tokenRequest(connection.tokenEndpoint, body, "refresh", opts).then((json) => {
846
+ const next = parseTokenResponse(json, {
847
+ podUrl: connection.podUrl,
848
+ clientId: connection.clientId,
849
+ tokenEndpoint: connection.tokenEndpoint,
850
+ revocationEndpoint: connection.revocationEndpoint,
851
+ now: clock(),
852
+ previous: connection
853
+ });
854
+ rotation.settledAt = clock();
855
+ return next;
856
+ }).catch((err) => {
857
+ rotations.delete(token);
858
+ if (err instanceof ConnectError && err.code === "revoked" && connection.refreshExpiresAt !== void 0 && connection.refreshExpiresAt <= clock()) {
859
+ throw new ConnectError("expired", err.message, { oauthError: err.oauthError });
860
+ }
861
+ throw err;
862
+ })
863
+ };
864
+ rotations.set(token, rotation);
865
+ return rotation.result;
866
+ }
867
+ async function revokeConnection(connection, opts = {}) {
868
+ if (!connection.revocationEndpoint) return { revoked: false };
869
+ const doFetch = opts.fetch ?? fetch;
870
+ let revoked = true;
871
+ const tokens = [
872
+ [connection.refreshToken, "refresh_token"],
873
+ [connection.accessToken, "access_token"]
874
+ ];
875
+ for (const [token, hint] of tokens) {
876
+ if (!token) continue;
877
+ try {
878
+ const res = await doFetch(connection.revocationEndpoint, {
879
+ method: "POST",
880
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
881
+ body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
882
+ redirect: "manual",
883
+ signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS2) : void 0
884
+ });
885
+ if (res.status !== 200) revoked = false;
886
+ } catch {
887
+ revoked = false;
888
+ }
889
+ }
890
+ return { revoked };
891
+ }
892
+
893
+ // src/connect/store.ts
894
+ function cookieNames(secure) {
895
+ return secure ? { session: "__Host-synap_session", transaction: "__Host-synap_tx", cloud: "__Host-synap_cloud", marker: "__Host-synap_seen" } : { session: "synap_session", transaction: "synap_tx", cloud: "synap_cloud", marker: "synap_seen" };
896
+ }
897
+ var MARKER_TTL_SEC = 400 * 24 * 60 * 60;
898
+ function markerCookie(podUrl, opts) {
899
+ return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
900
+ maxAgeSec: MARKER_TTL_SEC,
901
+ secure: opts.secure
902
+ });
903
+ }
904
+ function readMarker(cookieHeader, opts) {
905
+ const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
906
+ const bytes = raw ? fromBase64url(raw) : null;
907
+ if (!bytes || bytes.length > 512) return null;
908
+ const podUrl = fromUtf8(bytes);
909
+ try {
910
+ return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
911
+ } catch {
912
+ return null;
913
+ }
914
+ }
915
+ var CHUNK_SIZE = 3800;
916
+ var MAX_CHUNKS = 4;
917
+ function chunkName(name, i) {
918
+ return i === 0 ? name : `${name}.${i}`;
919
+ }
920
+ function pack(c) {
921
+ const bySpace = /* @__PURE__ */ new Map();
922
+ for (const g of c.grant) {
923
+ const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
924
+ row[2].push(g.permission);
925
+ bySpace.set(g.workspaceId, row);
926
+ }
927
+ const { grant: _grant, ...rest } = c;
928
+ return { ...rest, g: [...bySpace.values()] };
929
+ }
930
+ function unpack(s) {
931
+ if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
932
+ const { g, ...rest } = s;
933
+ const grant = g.flatMap(
934
+ ([workspaceId, workspaceName, permissions]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName }))
935
+ );
936
+ return { ...rest, grant };
937
+ }
938
+ function connectionExpiresAt(c) {
939
+ return c.refreshToken && c.refreshExpiresAt !== void 0 ? Math.max(c.refreshExpiresAt, c.accessExpiresAt) : c.accessExpiresAt;
940
+ }
941
+ function createSealedCookieStore(opts) {
942
+ const name = opts.name ?? cookieNames(opts.secure).session;
943
+ const now = opts.now ?? Date.now;
944
+ const presentChunks = (request) => {
945
+ const cookies = parseCookies(request.headers.get("cookie"));
946
+ const names = [];
947
+ for (let i = 0; i < MAX_CHUNKS; i++) if (cookies.has(chunkName(name, i))) names.push(chunkName(name, i));
948
+ return { cookies, names };
949
+ };
950
+ return {
951
+ async read(request) {
952
+ const { cookies } = presentChunks(request);
953
+ let sealed = "";
954
+ for (let i = 0; i < MAX_CHUNKS; i++) {
955
+ const part = cookies.get(chunkName(name, i));
956
+ if (part === void 0) break;
957
+ sealed += part;
958
+ }
959
+ if (!sealed) return null;
960
+ const opened = await unseal(sealed, { secret: opts.secret, purpose: "synap-connect/session", now: now() });
961
+ return opened.ok ? unpack(opened.value) : null;
962
+ },
963
+ async write(connection, request) {
964
+ const expiresAt = connectionExpiresAt(connection);
965
+ const sealed = await seal(pack(connection), { secret: opts.secret, purpose: "synap-connect/session", expiresAt });
966
+ const parts = [];
967
+ for (let i = 0; i < sealed.length; i += CHUNK_SIZE) parts.push(sealed.slice(i, i + CHUNK_SIZE));
968
+ if (parts.length > MAX_CHUNKS) {
969
+ throw new ConnectError(
970
+ "misconfigured",
971
+ "The connection is too large for a cookie (a very wide grant); pass a server-side ConnectionStore"
972
+ );
973
+ }
974
+ const maxAgeSec = (expiresAt - now()) / 1e3;
975
+ const out = parts.map((value, i) => serializeCookie(chunkName(name, i), value, { maxAgeSec, secure: opts.secure }));
976
+ for (const stale of presentChunks(request).names.slice(parts.length)) {
977
+ out.push(serializeCookie(stale, "", { maxAgeSec: 0, secure: opts.secure }));
978
+ }
979
+ return out;
980
+ },
981
+ async clear(request) {
982
+ const { names } = presentChunks(request);
983
+ return (names.length > 0 ? names : [name]).map((n) => serializeCookie(n, "", { maxAgeSec: 0, secure: opts.secure }));
984
+ }
985
+ };
986
+ }
987
+ function applySetCookies(cookieHeader, setCookies) {
988
+ const cookies = parseCookies(cookieHeader);
989
+ for (const sc of setCookies) {
990
+ const [pair, ...attrs] = sc.split(";");
991
+ const eq = pair.indexOf("=");
992
+ if (eq < 0) continue;
993
+ const name = pair.slice(0, eq).trim();
994
+ const removed = attrs.some((a) => /^\s*max-age\s*=\s*0\s*$/i.test(a));
995
+ if (removed) cookies.delete(name);
996
+ else cookies.set(name, pair.slice(eq + 1).trim());
997
+ }
998
+ return [...cookies].map(([k, v]) => `${k}=${v}`).join("; ");
999
+ }
1000
+
1001
+ // src/connect/auth.ts
1002
+ var DEFAULT_PATHS = {
1003
+ metadata: "/.well-known/synap-client.json",
1004
+ connect: "/api/synap/connect",
1005
+ callback: "/api/synap/callback",
1006
+ disconnect: "/api/synap/disconnect",
1007
+ cloud: "/api/synap/cloud",
1008
+ connectPage: "/connect",
1009
+ resultPage: "/connect/result",
1010
+ afterConnect: "/"
1011
+ };
1012
+ var REFRESH_WINDOW_MS = 5 * 60 * 1e3;
1013
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
1014
+ var TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
1015
+ var MAX_METADATA_BYTES2 = 5 * 1024;
1016
+ function appOriginOf(appUrl) {
1017
+ let url;
1018
+ try {
1019
+ url = new URL(appUrl);
1020
+ } catch {
1021
+ throw new ConnectError("misconfigured", "appUrl is not a URL");
1022
+ }
1023
+ const secure = url.protocol === "https:";
1024
+ if (!secure && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
1025
+ throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
1026
+ }
1027
+ if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
1028
+ throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
1029
+ }
1030
+ return { origin: url.origin, secure };
1031
+ }
1032
+ function assertPath(name, path) {
1033
+ if (!path.startsWith("/") || path.startsWith("//") || /[?#\\]/.test(path)) {
1034
+ throw new ConnectError("misconfigured", `paths.${name} must be an absolute path on the app`);
1035
+ }
1036
+ }
1037
+ function buildClientMetadata(opts) {
1038
+ const { origin } = appOriginOf(opts.appUrl);
1039
+ const paths = { ...DEFAULT_PATHS, ...opts.paths };
1040
+ for (const [name, path] of Object.entries(paths)) assertPath(name, path);
1041
+ const clientName = opts.clientName.trim();
1042
+ if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
1043
+ assertPermissions(opts.requests.map((r) => r.permission));
1044
+ for (const r of opts.requests) {
1045
+ if (r.permission.includes("*")) throw new ConnectError("misconfigured", "a permission cannot contain *");
1046
+ if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
1047
+ throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
1048
+ }
1049
+ }
1050
+ const templates = [...new Set(opts.templates ?? [])];
1051
+ if (templates.length > 8 || templates.some((t) => !TEMPLATE_SLUG_RE.test(t))) {
1052
+ throw new ConnectError("misconfigured", "at most 8 template slugs (lowercase letters, digits, . _ / -)");
1053
+ }
1054
+ const doc = {
1055
+ client_id: `${origin}${paths.metadata}`,
1056
+ client_name: clientName,
1057
+ client_uri: origin,
1058
+ redirect_uris: [`${origin}${paths.callback}`],
1059
+ grant_types: ["authorization_code", "refresh_token"],
1060
+ response_types: ["code"],
1061
+ token_endpoint_auth_method: "none",
1062
+ initiate_login_uri: `${origin}${paths.connectPage}`,
1063
+ synap_requests: opts.requests.map((r) => ({ permission: r.permission, reason: r.reason.trim() })),
1064
+ ...templates.length > 0 ? { synap_templates: templates } : {}
1065
+ };
1066
+ if (new TextEncoder().encode(JSON.stringify(doc)).length > MAX_METADATA_BYTES2) {
1067
+ throw new ConnectError("misconfigured", "the client metadata document exceeds the pod's 5 KB limit");
1068
+ }
1069
+ return doc;
1070
+ }
1071
+ function redirect(location, setCookies = []) {
1072
+ const headers = new Headers({ Location: location, "Cache-Control": "no-store" });
1073
+ for (const c of setCookies) headers.append("Set-Cookie", c);
1074
+ return new Response(null, { status: 303, headers });
1075
+ }
1076
+ function withQuery(origin, path, params) {
1077
+ const url = new URL(path, origin);
1078
+ for (const [k, v] of Object.entries(params)) if (v) url.searchParams.set(k, v);
1079
+ return url.href;
1080
+ }
1081
+ function sameOrigin(request, origin) {
1082
+ const from = request.headers.get("origin");
1083
+ if (from !== null) return from === origin;
1084
+ return request.headers.get("sec-fetch-site") === "same-origin";
1085
+ }
1086
+ function formField(form, name) {
1087
+ const v = form?.get(name);
1088
+ return typeof v === "string" ? v : null;
1089
+ }
1090
+ async function readForm(request) {
1091
+ try {
1092
+ return await request.formData();
1093
+ } catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+ function isPrefetchOrAsset(request) {
1098
+ const h = request.headers;
1099
+ if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
1100
+ if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
1101
+ const path = new URL(request.url).pathname;
1102
+ return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
1103
+ }
1104
+ function createPodAppAuth(config) {
1105
+ assertServerRuntime();
1106
+ assertAppSecret(config.secret);
1107
+ const { origin, secure } = appOriginOf(config.appUrl);
1108
+ const paths = { ...DEFAULT_PATHS, ...config.paths };
1109
+ const metadata = buildClientMetadata({ ...config, paths });
1110
+ const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
1111
+ const clientId = metadata.client_id;
1112
+ const redirectUri = metadata.redirect_uris[0];
1113
+ const permissions = config.requests.map((r) => r.permission);
1114
+ const names = cookieNames(secure);
1115
+ const now = config.now ?? Date.now;
1116
+ const store = config.store ?? createSealedCookieStore({ secret: config.secret, secure, now });
1117
+ const flow = { fetch: config.fetch, now: config.now };
1118
+ const report = config.onError ?? ((err, where) => {
1119
+ console.warn(`[synap/connect] ${where}: ${err.code}: ${err.message}`);
1120
+ });
1121
+ const fail = (err, where) => {
1122
+ const e = isConnectError(err) ? err : new ConnectError("server_error", err instanceof Error ? err.message : "Unexpected failure");
1123
+ report(e, where);
1124
+ return e;
1125
+ };
1126
+ const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
1127
+ const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
1128
+ const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
1129
+ const marker = (podUrl) => markerCookie(podUrl, { secure, name: names.marker });
1130
+ const asRequest = (source) => source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
1131
+ const lapsedIn = (request) => {
1132
+ const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
1133
+ return podUrl ? { podUrl } : null;
1134
+ };
1135
+ async function toPod(opts, extra = []) {
1136
+ const started = await startConnect({
1137
+ ...flow,
1138
+ pod: opts.pod,
1139
+ clientId,
1140
+ redirectUri,
1141
+ permissions,
1142
+ secret: config.secret,
1143
+ resource: config.resource,
1144
+ spaceHint: opts.space && /^[A-Za-z0-9_-]{1,64}$/.test(opts.space) ? opts.space : null,
1145
+ next: opts.next,
1146
+ prompt: opts.prompt,
1147
+ allowInsecureLoopback: config.allowInsecureLoopback
1148
+ });
1149
+ return redirect(started.authorizeUrl, [
1150
+ ...extra,
1151
+ serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
1152
+ ]);
1153
+ }
1154
+ async function liveConnectionTo(request, pod) {
1155
+ const existing = await store.read(request);
1156
+ return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
1157
+ }
1158
+ async function clientMetadata() {
1159
+ return new Response(JSON.stringify(metadata), {
1160
+ status: 200,
1161
+ headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=300" }
1162
+ });
1163
+ }
1164
+ async function connect(request) {
1165
+ if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
1166
+ if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
1167
+ const form = await readForm(request);
1168
+ const pod = formField(form, "pod")?.slice(0, 512) ?? "";
1169
+ const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
1170
+ const space = formField(form, "space");
1171
+ const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
1172
+ try {
1173
+ if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
1174
+ return await toPod({ pod, next, space, prompt });
1175
+ } catch (err) {
1176
+ const e = fail(err, "connect");
1177
+ return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
1178
+ }
1179
+ }
1180
+ async function callback(request) {
1181
+ if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
1182
+ const transaction = parseCookies(request.headers.get("cookie")).get(names.transaction) ?? null;
1183
+ try {
1184
+ const { connection, next } = await handleCallback({
1185
+ ...flow,
1186
+ callbackUrl: request.url,
1187
+ transaction,
1188
+ clientId,
1189
+ redirectUri,
1190
+ secret: config.secret
1191
+ });
1192
+ const cookies = await store.write(connection, request);
1193
+ return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
1194
+ } catch (err) {
1195
+ const e = fail(err, "callback");
1196
+ return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
1197
+ }
1198
+ }
1199
+ async function disconnect(request) {
1200
+ if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
1201
+ if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
1202
+ const next = safeNextPath(formField(await readForm(request), "next"), origin) ?? paths.connectPage;
1203
+ const connection = await store.read(request);
1204
+ if (connection) {
1205
+ const { revoked } = await revokeConnection(connection, flow);
1206
+ if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
1207
+ }
1208
+ return redirect(new URL(next, origin).href, [...await store.clear(request), clearMarker]);
1209
+ }
1210
+ async function cloudConnect(request) {
1211
+ if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
1212
+ if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
1213
+ const form = await readForm(request);
1214
+ const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
1215
+ const space = formField(form, "space");
1216
+ try {
1217
+ const started = await startCloudConnect({
1218
+ cloudUrl,
1219
+ allowInsecureLoopback: config.allowInsecureLoopback,
1220
+ clientId,
1221
+ secret: config.secret,
1222
+ next,
1223
+ spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
1224
+ prompt: formField(form, "prompt") === "consent" ? "consent" : null,
1225
+ now
1226
+ });
1227
+ return redirect(started.url, [
1228
+ serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1e3, secure })
1229
+ ]);
1230
+ } catch (err) {
1231
+ const e = fail(err, "connect");
1232
+ return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
1233
+ }
1234
+ }
1235
+ async function cloudReturn(request) {
1236
+ if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
1237
+ let pod = null;
1238
+ try {
1239
+ const back = await acceptCloudReturn({
1240
+ url: request.url,
1241
+ transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
1242
+ clientId,
1243
+ secret: config.secret,
1244
+ allowInsecureLoopback: config.allowInsecureLoopback,
1245
+ now
1246
+ });
1247
+ pod = back.pod;
1248
+ const next = back.next ?? paths.afterConnect;
1249
+ if (!back.prompt && await liveConnectionTo(request, back.pod)) return redirect(new URL(next, origin).href, [clearCloud]);
1250
+ return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
1251
+ } catch (err) {
1252
+ const e = fail(err, "connect");
1253
+ return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
1254
+ }
1255
+ }
1256
+ async function refreshIfNeeded(request) {
1257
+ const connection = await store.read(request);
1258
+ const unchanged = (status, conn, error) => ({
1259
+ status,
1260
+ connection: conn,
1261
+ setCookies: [],
1262
+ cookieHeader: null,
1263
+ ...error ? { error } : {}
1264
+ });
1265
+ if (!connection) {
1266
+ const lapsed = lapsedIn(request);
1267
+ return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
1268
+ }
1269
+ if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
1270
+ const t = now();
1271
+ const accessLives = connection.accessExpiresAt > t;
1272
+ if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
1273
+ if (!connection.refreshToken) {
1274
+ return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
1275
+ }
1276
+ let next;
1277
+ try {
1278
+ next = await refreshConnection(connection, flow);
1279
+ } catch (err) {
1280
+ const e = fail(err, "refresh");
1281
+ if (accessLives) return unchanged("kept", connection, e);
1282
+ if (e.code === "revoked" || e.code === "expired") return unchanged(e.code, null, e);
1283
+ return unchanged("unavailable", null, e);
1284
+ }
1285
+ let setCookies;
1286
+ try {
1287
+ setCookies = await store.write(next, request);
1288
+ } catch (err) {
1289
+ const e = fail(err, "refresh");
1290
+ const cleared = await store.clear(request).catch(() => []);
1291
+ return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
1292
+ }
1293
+ setCookies = [...setCookies, marker(next.podUrl)];
1294
+ return {
1295
+ status: "refreshed",
1296
+ connection: next,
1297
+ setCookies,
1298
+ cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies)
1299
+ };
1300
+ }
1301
+ return {
1302
+ clientId,
1303
+ redirectUri,
1304
+ metadata,
1305
+ paths,
1306
+ cloudConnectAvailable: cloudUrl !== null,
1307
+ handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
1308
+ readConnection: (source) => store.read(asRequest(source)),
1309
+ async readLapsed(source) {
1310
+ const request = asRequest(source);
1311
+ return await store.read(request) ? null : lapsedIn(request);
1312
+ },
1313
+ refreshIfNeeded,
1314
+ client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts })
1315
+ };
1316
+ }
1317
+
1318
+ // src/connect/index.ts
1319
+ assertServerRuntime();
1320
+ export {
1321
+ CLOUD_TRANSACTION_TTL_MS,
1322
+ CONNECT_ERROR_CODES,
1323
+ ConnectError,
1324
+ DEFAULT_PATHS,
1325
+ DEFAULT_REFRESH_IDLE_MS,
1326
+ MARKER_TTL_SEC,
1327
+ MIN_SECRET_LENGTH,
1328
+ REFRESH_WINDOW_MS,
1329
+ ROTATION_MEMORY_MS,
1330
+ TRANSACTION_TTL_MS,
1331
+ acceptCloudReturn,
1332
+ acceptLaunch,
1333
+ applySetCookies,
1334
+ buildClientMetadata,
1335
+ can,
1336
+ canonicalPodUrl,
1337
+ cloudBaseUrl,
1338
+ cloudConnectAvailable,
1339
+ connectionExpiresAt,
1340
+ cookieNames,
1341
+ createPodAppAuth,
1342
+ createPodClient,
1343
+ createSealedCookieStore,
1344
+ discoverPod,
1345
+ handleCallback,
1346
+ isBrowserRuntime,
1347
+ isConnectError,
1348
+ isPrefetchOrAsset,
1349
+ markerCookie,
1350
+ openTransaction,
1351
+ parseConnectErrorCode,
1352
+ parseTokenResponse,
1353
+ pkceChallenge,
1354
+ readMarker,
1355
+ refreshConnection,
1356
+ reviewUrlFor,
1357
+ revokeConnection,
1358
+ safeNextPath,
1359
+ seal,
1360
+ spaces,
1361
+ startCloudConnect,
1362
+ startConnect,
1363
+ unseal
1364
+ };
1365
+ //# sourceMappingURL=connect.js.map