@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/dist/connect.js CHANGED
@@ -213,12 +213,6 @@ async function discoverPod(input, opts = {}) {
213
213
  }
214
214
  }
215
215
  }
216
- function cloudConnectAvailable() {
217
- return false;
218
- }
219
- function startCloudConnect(_opts) {
220
- throw new ConnectError("cp_unavailable", "cp: not available \u2014 connecting through Synap Cloud is not built yet; use a pod address");
221
- }
222
216
  function safeNextPath(value, appOrigin) {
223
217
  if (!value || value.length > 2048) return null;
224
218
  if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) {
@@ -248,10 +242,193 @@ function acceptLaunch(url, appOrigin, opts = {}) {
248
242
  }
249
243
  }
250
244
  const hint = params.get("synap_space_hint");
245
+ const state = params.get("state");
251
246
  return {
252
247
  pod,
253
248
  next: safeNextPath(params.get("target_link_uri"), appOrigin),
254
- spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null
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
255
432
  };
256
433
  }
257
434
 
@@ -259,6 +436,16 @@ function acceptLaunch(url, appOrigin, opts = {}) {
259
436
  function can(connection, permission, workspaceId) {
260
437
  return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
261
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
+ }
262
449
  var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
263
450
  function hubUrl(podUrl, path, query) {
264
451
  if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
@@ -344,11 +531,59 @@ function createPodClient(connection, opts = {}) {
344
531
  }
345
532
  return { ok: true, status: "applied", data: result.data };
346
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
+ }
347
580
  return {
348
581
  podUrl: connection.podUrl,
349
582
  user: connection.user,
350
583
  grant: connection.grant,
351
584
  can: (permission, workspaceId) => can(connection, permission, workspaceId),
585
+ spaces: () => spaces(connection),
586
+ listPendingProposals,
352
587
  get(path, query) {
353
588
  return request("GET", path, void 0, query);
354
589
  },
@@ -367,89 +602,6 @@ function createPodClient(connection, opts = {}) {
367
602
  };
368
603
  }
369
604
 
370
- // src/connect/seal.ts
371
- var VERSION = "v1";
372
- var IV_BYTES = 12;
373
- var TAG_BYTES = 16;
374
- var MIN_SECRET_LENGTH = 32;
375
- var SALT = utf8("synap-connect/v1");
376
- var keyCache = /* @__PURE__ */ new Map();
377
- function secretsOf(secret) {
378
- const list = typeof secret === "string" ? [secret] : secret;
379
- if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
380
- throw new ConnectError(
381
- "misconfigured",
382
- `The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
383
- );
384
- }
385
- return list;
386
- }
387
- function assertAppSecret(secret) {
388
- secretsOf(secret);
389
- }
390
- function keyFor(secret, purpose) {
391
- const cacheKey = `${purpose}\0${secret}`;
392
- let key = keyCache.get(cacheKey);
393
- if (!key) {
394
- key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
395
- (base) => crypto.subtle.deriveKey(
396
- { name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
397
- base,
398
- { name: "AES-GCM", length: 256 },
399
- false,
400
- ["encrypt", "decrypt"]
401
- )
402
- );
403
- keyCache.set(cacheKey, key);
404
- }
405
- return key;
406
- }
407
- async function seal(value, opts) {
408
- const [current] = secretsOf(opts.secret);
409
- const iv = randomBytes(IV_BYTES);
410
- const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
411
- const ciphertext = await crypto.subtle.encrypt(
412
- { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
413
- await keyFor(current, opts.purpose),
414
- plaintext
415
- );
416
- const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
417
- out.set(iv, 0);
418
- out.set(new Uint8Array(ciphertext), IV_BYTES);
419
- return `${VERSION}.${base64url(out)}`;
420
- }
421
- async function unseal(sealed, opts) {
422
- const secrets = secretsOf(opts.secret);
423
- const dot = sealed.indexOf(".");
424
- if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
425
- const bytes = fromBase64url(sealed.slice(dot + 1));
426
- if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
427
- const iv = bytes.slice(0, IV_BYTES);
428
- const ciphertext = bytes.slice(IV_BYTES);
429
- for (const secret of secrets) {
430
- let plaintext;
431
- try {
432
- plaintext = await crypto.subtle.decrypt(
433
- { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
434
- await keyFor(secret, opts.purpose),
435
- ciphertext
436
- );
437
- } catch {
438
- continue;
439
- }
440
- let parsed;
441
- try {
442
- parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
443
- } catch {
444
- return { ok: false, reason: "invalid" };
445
- }
446
- if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
447
- if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
448
- return { ok: true, value: parsed.data, expiresAt: parsed.exp };
449
- }
450
- return { ok: false, reason: "invalid" };
451
- }
452
-
453
605
  // src/connect/flow.ts
454
606
  var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
455
607
  var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -484,6 +636,7 @@ async function startConnect(opts) {
484
636
  url.searchParams.set("code_challenge_method", "S256");
485
637
  if (resource) url.searchParams.set("resource", resource);
486
638
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
639
+ if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
487
640
  const tx = {
488
641
  v: 1,
489
642
  issuer: issuer.issuer,
@@ -606,6 +759,23 @@ function parseGrant(value) {
606
759
  }
607
760
  return out;
608
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
+ }
609
779
  function parseUser(value) {
610
780
  if (!value || typeof value !== "object") return null;
611
781
  const { id, name } = value;
@@ -640,6 +810,11 @@ function parseTokenResponse(body, ctx) {
640
810
  user,
641
811
  connectedAt: ctx.previous?.connectedAt ?? ctx.now
642
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;
643
818
  if (typeof refresh_token === "string" && refresh_token !== "") {
644
819
  connection.refreshToken = refresh_token;
645
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;
@@ -717,7 +892,25 @@ async function revokeConnection(connection, opts = {}) {
717
892
 
718
893
  // src/connect/store.ts
719
894
  function cookieNames(secure) {
720
- return secure ? { session: "__Host-synap_session", transaction: "__Host-synap_tx" } : { session: "synap_session", transaction: "synap_tx" };
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
+ }
721
914
  }
722
915
  var CHUNK_SIZE = 3800;
723
916
  var MAX_CHUNKS = 4;
@@ -811,6 +1004,7 @@ var DEFAULT_PATHS = {
811
1004
  connect: "/api/synap/connect",
812
1005
  callback: "/api/synap/callback",
813
1006
  disconnect: "/api/synap/disconnect",
1007
+ cloud: "/api/synap/cloud",
814
1008
  connectPage: "/connect",
815
1009
  resultPage: "/connect/result",
816
1010
  afterConnect: "/"
@@ -902,7 +1096,7 @@ async function readForm(request) {
902
1096
  }
903
1097
  function isPrefetchOrAsset(request) {
904
1098
  const h = request.headers;
905
- if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
1099
+ if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
906
1100
  if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
907
1101
  const path = new URL(request.url).pathname;
908
1102
  return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
@@ -913,6 +1107,7 @@ function createPodAppAuth(config) {
913
1107
  const { origin, secure } = appOriginOf(config.appUrl);
914
1108
  const paths = { ...DEFAULT_PATHS, ...config.paths };
915
1109
  const metadata = buildClientMetadata({ ...config, paths });
1110
+ const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
916
1111
  const clientId = metadata.client_id;
917
1112
  const redirectUri = metadata.redirect_uris[0];
918
1113
  const permissions = config.requests.map((r) => r.permission);
@@ -929,6 +1124,37 @@ function createPodAppAuth(config) {
929
1124
  return e;
930
1125
  };
931
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
+ }
932
1158
  async function clientMetadata() {
933
1159
  return new Response(JSON.stringify(metadata), {
934
1160
  status: 200,
@@ -942,26 +1168,10 @@ function createPodAppAuth(config) {
942
1168
  const pod = formField(form, "pod")?.slice(0, 512) ?? "";
943
1169
  const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
944
1170
  const space = formField(form, "space");
1171
+ const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
945
1172
  try {
946
- const existing = await store.read(request);
947
- if (existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config)) {
948
- return redirect(new URL(next, origin).href);
949
- }
950
- const started = await startConnect({
951
- ...flow,
952
- pod,
953
- clientId,
954
- redirectUri,
955
- permissions,
956
- secret: config.secret,
957
- resource: config.resource,
958
- spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
959
- next,
960
- allowInsecureLoopback: config.allowInsecureLoopback
961
- });
962
- return redirect(started.authorizeUrl, [
963
- serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
964
- ]);
1173
+ if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
1174
+ return await toPod({ pod, next, space, prompt });
965
1175
  } catch (err) {
966
1176
  const e = fail(err, "connect");
967
1177
  return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
@@ -980,7 +1190,7 @@ function createPodAppAuth(config) {
980
1190
  secret: config.secret
981
1191
  });
982
1192
  const cookies = await store.write(connection, request);
983
- return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
1193
+ return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
984
1194
  } catch (err) {
985
1195
  const e = fail(err, "callback");
986
1196
  return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
@@ -995,7 +1205,53 @@ function createPodAppAuth(config) {
995
1205
  const { revoked } = await revokeConnection(connection, flow);
996
1206
  if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
997
1207
  }
998
- return redirect(new URL(next, origin).href, await store.clear(request));
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
+ }
999
1255
  }
1000
1256
  async function refreshIfNeeded(request) {
1001
1257
  const connection = await store.read(request);
@@ -1006,7 +1262,10 @@ function createPodAppAuth(config) {
1006
1262
  cookieHeader: null,
1007
1263
  ...error ? { error } : {}
1008
1264
  });
1009
- if (!connection) return unchanged("none", null);
1265
+ if (!connection) {
1266
+ const lapsed = lapsedIn(request);
1267
+ return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
1268
+ }
1010
1269
  if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
1011
1270
  const t = now();
1012
1271
  const accessLives = connection.accessExpiresAt > t;
@@ -1031,6 +1290,7 @@ function createPodAppAuth(config) {
1031
1290
  const cleared = await store.clear(request).catch(() => []);
1032
1291
  return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
1033
1292
  }
1293
+ setCookies = [...setCookies, marker(next.podUrl)];
1034
1294
  return {
1035
1295
  status: "refreshed",
1036
1296
  connection: next,
@@ -1043,8 +1303,13 @@ function createPodAppAuth(config) {
1043
1303
  redirectUri,
1044
1304
  metadata,
1045
1305
  paths,
1046
- handlers: { clientMetadata, connect, callback, disconnect },
1047
- readConnection: (source) => store.read(source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} })),
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
+ },
1048
1313
  refreshIfNeeded,
1049
1314
  client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts })
1050
1315
  };
@@ -1053,19 +1318,23 @@ function createPodAppAuth(config) {
1053
1318
  // src/connect/index.ts
1054
1319
  assertServerRuntime();
1055
1320
  export {
1321
+ CLOUD_TRANSACTION_TTL_MS,
1056
1322
  CONNECT_ERROR_CODES,
1057
1323
  ConnectError,
1058
1324
  DEFAULT_PATHS,
1059
1325
  DEFAULT_REFRESH_IDLE_MS,
1326
+ MARKER_TTL_SEC,
1060
1327
  MIN_SECRET_LENGTH,
1061
1328
  REFRESH_WINDOW_MS,
1062
1329
  ROTATION_MEMORY_MS,
1063
1330
  TRANSACTION_TTL_MS,
1331
+ acceptCloudReturn,
1064
1332
  acceptLaunch,
1065
1333
  applySetCookies,
1066
1334
  buildClientMetadata,
1067
1335
  can,
1068
1336
  canonicalPodUrl,
1337
+ cloudBaseUrl,
1069
1338
  cloudConnectAvailable,
1070
1339
  connectionExpiresAt,
1071
1340
  cookieNames,
@@ -1076,15 +1345,19 @@ export {
1076
1345
  handleCallback,
1077
1346
  isBrowserRuntime,
1078
1347
  isConnectError,
1348
+ isPrefetchOrAsset,
1349
+ markerCookie,
1079
1350
  openTransaction,
1080
1351
  parseConnectErrorCode,
1081
1352
  parseTokenResponse,
1082
1353
  pkceChallenge,
1354
+ readMarker,
1083
1355
  refreshConnection,
1084
1356
  reviewUrlFor,
1085
1357
  revokeConnection,
1086
1358
  safeNextPath,
1087
1359
  seal,
1360
+ spaces,
1088
1361
  startCloudConnect,
1089
1362
  startConnect,
1090
1363
  unseal