@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.cjs CHANGED
@@ -20,19 +20,23 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/connect/index.ts
21
21
  var connect_exports = {};
22
22
  __export(connect_exports, {
23
+ CLOUD_TRANSACTION_TTL_MS: () => CLOUD_TRANSACTION_TTL_MS,
23
24
  CONNECT_ERROR_CODES: () => CONNECT_ERROR_CODES,
24
25
  ConnectError: () => ConnectError,
25
26
  DEFAULT_PATHS: () => DEFAULT_PATHS,
26
27
  DEFAULT_REFRESH_IDLE_MS: () => DEFAULT_REFRESH_IDLE_MS,
28
+ MARKER_TTL_SEC: () => MARKER_TTL_SEC,
27
29
  MIN_SECRET_LENGTH: () => MIN_SECRET_LENGTH,
28
30
  REFRESH_WINDOW_MS: () => REFRESH_WINDOW_MS,
29
31
  ROTATION_MEMORY_MS: () => ROTATION_MEMORY_MS,
30
32
  TRANSACTION_TTL_MS: () => TRANSACTION_TTL_MS,
33
+ acceptCloudReturn: () => acceptCloudReturn,
31
34
  acceptLaunch: () => acceptLaunch,
32
35
  applySetCookies: () => applySetCookies,
33
36
  buildClientMetadata: () => buildClientMetadata,
34
37
  can: () => can,
35
38
  canonicalPodUrl: () => canonicalPodUrl,
39
+ cloudBaseUrl: () => cloudBaseUrl,
36
40
  cloudConnectAvailable: () => cloudConnectAvailable,
37
41
  connectionExpiresAt: () => connectionExpiresAt,
38
42
  cookieNames: () => cookieNames,
@@ -43,15 +47,19 @@ __export(connect_exports, {
43
47
  handleCallback: () => handleCallback,
44
48
  isBrowserRuntime: () => isBrowserRuntime,
45
49
  isConnectError: () => isConnectError,
50
+ isPrefetchOrAsset: () => isPrefetchOrAsset,
51
+ markerCookie: () => markerCookie,
46
52
  openTransaction: () => openTransaction,
47
53
  parseConnectErrorCode: () => parseConnectErrorCode,
48
54
  parseTokenResponse: () => parseTokenResponse,
49
55
  pkceChallenge: () => pkceChallenge,
56
+ readMarker: () => readMarker,
50
57
  refreshConnection: () => refreshConnection,
51
58
  reviewUrlFor: () => reviewUrlFor,
52
59
  revokeConnection: () => revokeConnection,
53
60
  safeNextPath: () => safeNextPath,
54
61
  seal: () => seal,
62
+ spaces: () => spaces,
55
63
  startCloudConnect: () => startCloudConnect,
56
64
  startConnect: () => startConnect,
57
65
  unseal: () => unseal
@@ -260,12 +268,6 @@ async function discoverPod(input, opts = {}) {
260
268
  }
261
269
  }
262
270
  }
263
- function cloudConnectAvailable() {
264
- return false;
265
- }
266
- function startCloudConnect(_opts) {
267
- throw new ConnectError("cp_unavailable", "cp: not available \u2014 connecting through Synap Cloud is not built yet; use a pod address");
268
- }
269
271
  function safeNextPath(value, appOrigin) {
270
272
  if (!value || value.length > 2048) return null;
271
273
  if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) {
@@ -295,10 +297,14 @@ function acceptLaunch(url, appOrigin, opts = {}) {
295
297
  }
296
298
  }
297
299
  const hint = params.get("synap_space_hint");
300
+ const state = params.get("state");
298
301
  return {
299
302
  pod,
300
303
  next: safeNextPath(params.get("target_link_uri"), appOrigin),
301
- spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null
304
+ spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
305
+ // A browser launch never carries a state: any well-formed one is a Synap Cloud return
306
+ // (a pick: iss + state; a cancel: state + error=access_denied, no iss).
307
+ cloudState: state && /^[A-Za-z0-9._~-]{16,256}$/.test(state) ? state : null
302
308
  };
303
309
  }
304
310
 
@@ -368,10 +374,199 @@ function serializeCookie(name, value, attrs) {
368
374
  return parts.join("; ");
369
375
  }
370
376
 
377
+ // src/connect/seal.ts
378
+ var VERSION = "v1";
379
+ var IV_BYTES = 12;
380
+ var TAG_BYTES = 16;
381
+ var MIN_SECRET_LENGTH = 32;
382
+ var SALT = utf8("synap-connect/v1");
383
+ var keyCache = /* @__PURE__ */ new Map();
384
+ function secretsOf(secret) {
385
+ const list = typeof secret === "string" ? [secret] : secret;
386
+ if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
387
+ throw new ConnectError(
388
+ "misconfigured",
389
+ `The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
390
+ );
391
+ }
392
+ return list;
393
+ }
394
+ function assertAppSecret(secret) {
395
+ secretsOf(secret);
396
+ }
397
+ function keyFor(secret, purpose) {
398
+ const cacheKey = `${purpose}\0${secret}`;
399
+ let key = keyCache.get(cacheKey);
400
+ if (!key) {
401
+ key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
402
+ (base) => crypto.subtle.deriveKey(
403
+ { name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
404
+ base,
405
+ { name: "AES-GCM", length: 256 },
406
+ false,
407
+ ["encrypt", "decrypt"]
408
+ )
409
+ );
410
+ keyCache.set(cacheKey, key);
411
+ }
412
+ return key;
413
+ }
414
+ async function seal(value, opts) {
415
+ const [current] = secretsOf(opts.secret);
416
+ const iv = randomBytes(IV_BYTES);
417
+ const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
418
+ const ciphertext = await crypto.subtle.encrypt(
419
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
420
+ await keyFor(current, opts.purpose),
421
+ plaintext
422
+ );
423
+ const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
424
+ out.set(iv, 0);
425
+ out.set(new Uint8Array(ciphertext), IV_BYTES);
426
+ return `${VERSION}.${base64url(out)}`;
427
+ }
428
+ async function unseal(sealed, opts) {
429
+ const secrets = secretsOf(opts.secret);
430
+ const dot = sealed.indexOf(".");
431
+ if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
432
+ const bytes = fromBase64url(sealed.slice(dot + 1));
433
+ if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
434
+ const iv = bytes.slice(0, IV_BYTES);
435
+ const ciphertext = bytes.slice(IV_BYTES);
436
+ for (const secret of secrets) {
437
+ let plaintext;
438
+ try {
439
+ plaintext = await crypto.subtle.decrypt(
440
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
441
+ await keyFor(secret, opts.purpose),
442
+ ciphertext
443
+ );
444
+ } catch {
445
+ continue;
446
+ }
447
+ let parsed;
448
+ try {
449
+ parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
450
+ } catch {
451
+ return { ok: false, reason: "invalid" };
452
+ }
453
+ if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
454
+ if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
455
+ return { ok: true, value: parsed.data, expiresAt: parsed.exp };
456
+ }
457
+ return { ok: false, reason: "invalid" };
458
+ }
459
+
460
+ // src/connect/cloud.ts
461
+ var CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
462
+ var STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
463
+ var LOOPBACK_HOSTS2 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
464
+ function cloudBaseUrl(cloudUrl, opts = {}) {
465
+ let url;
466
+ try {
467
+ url = new URL(cloudUrl.trim());
468
+ } catch {
469
+ throw new ConnectError("misconfigured", "cloudUrl is not a URL");
470
+ }
471
+ const loopback = LOOPBACK_HOSTS2.has(url.hostname);
472
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
473
+ throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
474
+ }
475
+ if (url.username || url.password || url.search || url.hash) {
476
+ throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
477
+ }
478
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
479
+ }
480
+ function cloudConnectAvailable(config) {
481
+ if (!config?.cloudUrl) return false;
482
+ try {
483
+ cloudBaseUrl(config.cloudUrl, config);
484
+ return true;
485
+ } catch {
486
+ return false;
487
+ }
488
+ }
489
+ function startCloudConnect(opts) {
490
+ if (!cloudConnectAvailable(opts)) {
491
+ throw new ConnectError("cp_unavailable", "cp: not configured \u2014 pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
492
+ }
493
+ if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
494
+ const base = cloudBaseUrl(opts.cloudUrl, opts);
495
+ const secret = opts.secret;
496
+ return (async () => {
497
+ const now = (opts.now ?? Date.now)();
498
+ const state = randomToken();
499
+ const url = new URL(`${base}/connect/pod`);
500
+ url.searchParams.set("client_id", opts.clientId);
501
+ url.searchParams.set("state", state);
502
+ const tx = {
503
+ v: 1,
504
+ state,
505
+ clientId: opts.clientId,
506
+ cloudUrl: base,
507
+ next: opts.next ?? null,
508
+ spaceHint: opts.spaceHint ?? null,
509
+ prompt: opts.prompt === "consent" ? "consent" : null,
510
+ createdAt: now
511
+ };
512
+ const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
513
+ const transaction = await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt });
514
+ return { url: url.href, transaction, expiresAt };
515
+ })();
516
+ }
517
+ async function acceptCloudReturn(opts) {
518
+ if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
519
+ const opened = await unseal(opts.transaction, {
520
+ secret: opts.secret,
521
+ purpose: "synap-connect/cloud",
522
+ now: (opts.now ?? Date.now)()
523
+ });
524
+ if (!opened.ok) {
525
+ throw new ConnectError(
526
+ "expired_transaction",
527
+ opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed"
528
+ );
529
+ }
530
+ const tx = opened.value;
531
+ if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
532
+ throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
533
+ }
534
+ if (tx.clientId !== opts.clientId) {
535
+ throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
536
+ }
537
+ const params = new URL(opts.url).searchParams;
538
+ const state = params.get("state");
539
+ if (state === null || !STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
540
+ throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
541
+ }
542
+ const error = params.get("error");
543
+ if (error !== null) {
544
+ throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
545
+ }
546
+ const iss = params.get("iss");
547
+ if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
548
+ return {
549
+ pod: canonicalPodUrl(iss, opts),
550
+ next: tx.next,
551
+ spaceHint: tx.spaceHint,
552
+ prompt: tx.prompt === "consent" ? "consent" : null
553
+ };
554
+ }
555
+
371
556
  // src/connect/client.ts
372
557
  function can(connection, permission, workspaceId) {
373
558
  return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
374
559
  }
560
+ function spaces(connection) {
561
+ const bySpace = /* @__PURE__ */ new Map();
562
+ for (const g of connection.grant) {
563
+ const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
564
+ if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
565
+ if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
566
+ bySpace.set(g.workspaceId, space);
567
+ }
568
+ return [...bySpace.values()];
569
+ }
375
570
  var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
376
571
  function hubUrl(podUrl, path, query) {
377
572
  if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
@@ -457,11 +652,59 @@ function createPodClient(connection, opts = {}) {
457
652
  }
458
653
  return { ok: true, status: "applied", data: result.data };
459
654
  }
655
+ async function listPendingProposals(opts2 = {}) {
656
+ if (!can(connection, "proposal.read", opts2.workspaceId)) {
657
+ return {
658
+ ok: false,
659
+ error: { code: "forbidden", status: null, detail: "The connection was not granted proposal.read in that space" }
660
+ };
661
+ }
662
+ const limit = Math.min(Math.max(Math.floor(opts2.limit ?? 20), 1), 200);
663
+ const offset = Math.max(Math.floor(opts2.offset ?? 0), 0);
664
+ const result = await request("GET", "/proposals", void 0, {
665
+ status: "pending",
666
+ view: "basic",
667
+ limit,
668
+ offset,
669
+ workspaceId: opts2.workspaceId
670
+ });
671
+ if (!result.ok) return result;
672
+ const obj = result.data && typeof result.data === "object" ? result.data : {};
673
+ if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
674
+ return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
675
+ }
676
+ const str = (v) => typeof v === "string" && v ? v : null;
677
+ const proposals = [];
678
+ for (const row of obj.proposals) {
679
+ if (!row || typeof row !== "object") continue;
680
+ const r = row;
681
+ const id = str(r.id);
682
+ if (!id) continue;
683
+ proposals.push({
684
+ id,
685
+ proposalType: str(r.proposalType) ?? "",
686
+ targetType: str(r.targetType) ?? "",
687
+ targetId: str(r.targetId),
688
+ workspaceId: str(r.workspaceId),
689
+ createdAt: str(r.createdAt) ?? "",
690
+ agentUserId: str(r.agentUserId),
691
+ sessionId: str(r.sessionId),
692
+ ...str(r.summary) ? { summary: str(r.summary) } : {},
693
+ reviewUrl: reviewUrlFor(connection.podUrl, null, id)
694
+ });
695
+ }
696
+ return {
697
+ ok: true,
698
+ data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total }
699
+ };
700
+ }
460
701
  return {
461
702
  podUrl: connection.podUrl,
462
703
  user: connection.user,
463
704
  grant: connection.grant,
464
705
  can: (permission, workspaceId) => can(connection, permission, workspaceId),
706
+ spaces: () => spaces(connection),
707
+ listPendingProposals,
465
708
  get(path, query) {
466
709
  return request("GET", path, void 0, query);
467
710
  },
@@ -480,89 +723,6 @@ function createPodClient(connection, opts = {}) {
480
723
  };
481
724
  }
482
725
 
483
- // src/connect/seal.ts
484
- var VERSION = "v1";
485
- var IV_BYTES = 12;
486
- var TAG_BYTES = 16;
487
- var MIN_SECRET_LENGTH = 32;
488
- var SALT = utf8("synap-connect/v1");
489
- var keyCache = /* @__PURE__ */ new Map();
490
- function secretsOf(secret) {
491
- const list = typeof secret === "string" ? [secret] : secret;
492
- if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
493
- throw new ConnectError(
494
- "misconfigured",
495
- `The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
496
- );
497
- }
498
- return list;
499
- }
500
- function assertAppSecret(secret) {
501
- secretsOf(secret);
502
- }
503
- function keyFor(secret, purpose) {
504
- const cacheKey = `${purpose}\0${secret}`;
505
- let key = keyCache.get(cacheKey);
506
- if (!key) {
507
- key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
508
- (base) => crypto.subtle.deriveKey(
509
- { name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
510
- base,
511
- { name: "AES-GCM", length: 256 },
512
- false,
513
- ["encrypt", "decrypt"]
514
- )
515
- );
516
- keyCache.set(cacheKey, key);
517
- }
518
- return key;
519
- }
520
- async function seal(value, opts) {
521
- const [current] = secretsOf(opts.secret);
522
- const iv = randomBytes(IV_BYTES);
523
- const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
524
- const ciphertext = await crypto.subtle.encrypt(
525
- { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
526
- await keyFor(current, opts.purpose),
527
- plaintext
528
- );
529
- const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
530
- out.set(iv, 0);
531
- out.set(new Uint8Array(ciphertext), IV_BYTES);
532
- return `${VERSION}.${base64url(out)}`;
533
- }
534
- async function unseal(sealed, opts) {
535
- const secrets = secretsOf(opts.secret);
536
- const dot = sealed.indexOf(".");
537
- if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
538
- const bytes = fromBase64url(sealed.slice(dot + 1));
539
- if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
540
- const iv = bytes.slice(0, IV_BYTES);
541
- const ciphertext = bytes.slice(IV_BYTES);
542
- for (const secret of secrets) {
543
- let plaintext;
544
- try {
545
- plaintext = await crypto.subtle.decrypt(
546
- { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
547
- await keyFor(secret, opts.purpose),
548
- ciphertext
549
- );
550
- } catch {
551
- continue;
552
- }
553
- let parsed;
554
- try {
555
- parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
556
- } catch {
557
- return { ok: false, reason: "invalid" };
558
- }
559
- if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
560
- if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
561
- return { ok: true, value: parsed.data, expiresAt: parsed.exp };
562
- }
563
- return { ok: false, reason: "invalid" };
564
- }
565
-
566
726
  // src/connect/flow.ts
567
727
  var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
568
728
  var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -597,6 +757,7 @@ async function startConnect(opts) {
597
757
  url.searchParams.set("code_challenge_method", "S256");
598
758
  if (resource) url.searchParams.set("resource", resource);
599
759
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
760
+ if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
600
761
  const tx = {
601
762
  v: 1,
602
763
  issuer: issuer.issuer,
@@ -719,6 +880,23 @@ function parseGrant(value) {
719
880
  }
720
881
  return out;
721
882
  }
883
+ var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
884
+ var LOOPBACK_HOSTS3 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
885
+ function parseApp(value) {
886
+ if (!value || typeof value !== "object") return {};
887
+ const { id, manageUrl } = value;
888
+ const out = {};
889
+ if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
890
+ if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
891
+ try {
892
+ const url = new URL(manageUrl);
893
+ const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS3.has(url.hostname);
894
+ if (secure && !url.username && !url.password) out.manageUrl = url.href;
895
+ } catch {
896
+ }
897
+ }
898
+ return out;
899
+ }
722
900
  function parseUser(value) {
723
901
  if (!value || typeof value !== "object") return null;
724
902
  const { id, name } = value;
@@ -753,6 +931,11 @@ function parseTokenResponse(body, ctx) {
753
931
  user,
754
932
  connectedAt: ctx.previous?.connectedAt ?? ctx.now
755
933
  };
934
+ const app = parseApp(body.synap_app);
935
+ const appId = app.appId ?? ctx.previous?.appId;
936
+ const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
937
+ if (appId !== void 0) connection.appId = appId;
938
+ if (manageUrl !== void 0) connection.manageUrl = manageUrl;
756
939
  if (typeof refresh_token === "string" && refresh_token !== "") {
757
940
  connection.refreshToken = refresh_token;
758
941
  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;
@@ -830,7 +1013,25 @@ async function revokeConnection(connection, opts = {}) {
830
1013
 
831
1014
  // src/connect/store.ts
832
1015
  function cookieNames(secure) {
833
- return secure ? { session: "__Host-synap_session", transaction: "__Host-synap_tx" } : { session: "synap_session", transaction: "synap_tx" };
1016
+ 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" };
1017
+ }
1018
+ var MARKER_TTL_SEC = 400 * 24 * 60 * 60;
1019
+ function markerCookie(podUrl, opts) {
1020
+ return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
1021
+ maxAgeSec: MARKER_TTL_SEC,
1022
+ secure: opts.secure
1023
+ });
1024
+ }
1025
+ function readMarker(cookieHeader, opts) {
1026
+ const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
1027
+ const bytes = raw ? fromBase64url(raw) : null;
1028
+ if (!bytes || bytes.length > 512) return null;
1029
+ const podUrl = fromUtf8(bytes);
1030
+ try {
1031
+ return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
1032
+ } catch {
1033
+ return null;
1034
+ }
834
1035
  }
835
1036
  var CHUNK_SIZE = 3800;
836
1037
  var MAX_CHUNKS = 4;
@@ -924,6 +1125,7 @@ var DEFAULT_PATHS = {
924
1125
  connect: "/api/synap/connect",
925
1126
  callback: "/api/synap/callback",
926
1127
  disconnect: "/api/synap/disconnect",
1128
+ cloud: "/api/synap/cloud",
927
1129
  connectPage: "/connect",
928
1130
  resultPage: "/connect/result",
929
1131
  afterConnect: "/"
@@ -1015,7 +1217,7 @@ async function readForm(request) {
1015
1217
  }
1016
1218
  function isPrefetchOrAsset(request) {
1017
1219
  const h = request.headers;
1018
- if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
1220
+ if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
1019
1221
  if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
1020
1222
  const path = new URL(request.url).pathname;
1021
1223
  return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
@@ -1026,6 +1228,7 @@ function createPodAppAuth(config) {
1026
1228
  const { origin, secure } = appOriginOf(config.appUrl);
1027
1229
  const paths = { ...DEFAULT_PATHS, ...config.paths };
1028
1230
  const metadata = buildClientMetadata({ ...config, paths });
1231
+ const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
1029
1232
  const clientId = metadata.client_id;
1030
1233
  const redirectUri = metadata.redirect_uris[0];
1031
1234
  const permissions = config.requests.map((r) => r.permission);
@@ -1042,6 +1245,37 @@ function createPodAppAuth(config) {
1042
1245
  return e;
1043
1246
  };
1044
1247
  const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
1248
+ const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
1249
+ const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
1250
+ const marker = (podUrl) => markerCookie(podUrl, { secure, name: names.marker });
1251
+ const asRequest = (source) => source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
1252
+ const lapsedIn = (request) => {
1253
+ const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
1254
+ return podUrl ? { podUrl } : null;
1255
+ };
1256
+ async function toPod(opts, extra = []) {
1257
+ const started = await startConnect({
1258
+ ...flow,
1259
+ pod: opts.pod,
1260
+ clientId,
1261
+ redirectUri,
1262
+ permissions,
1263
+ secret: config.secret,
1264
+ resource: config.resource,
1265
+ spaceHint: opts.space && /^[A-Za-z0-9_-]{1,64}$/.test(opts.space) ? opts.space : null,
1266
+ next: opts.next,
1267
+ prompt: opts.prompt,
1268
+ allowInsecureLoopback: config.allowInsecureLoopback
1269
+ });
1270
+ return redirect(started.authorizeUrl, [
1271
+ ...extra,
1272
+ serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
1273
+ ]);
1274
+ }
1275
+ async function liveConnectionTo(request, pod) {
1276
+ const existing = await store.read(request);
1277
+ return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
1278
+ }
1045
1279
  async function clientMetadata() {
1046
1280
  return new Response(JSON.stringify(metadata), {
1047
1281
  status: 200,
@@ -1055,26 +1289,10 @@ function createPodAppAuth(config) {
1055
1289
  const pod = formField(form, "pod")?.slice(0, 512) ?? "";
1056
1290
  const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
1057
1291
  const space = formField(form, "space");
1292
+ const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
1058
1293
  try {
1059
- const existing = await store.read(request);
1060
- if (existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config)) {
1061
- return redirect(new URL(next, origin).href);
1062
- }
1063
- const started = await startConnect({
1064
- ...flow,
1065
- pod,
1066
- clientId,
1067
- redirectUri,
1068
- permissions,
1069
- secret: config.secret,
1070
- resource: config.resource,
1071
- spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
1072
- next,
1073
- allowInsecureLoopback: config.allowInsecureLoopback
1074
- });
1075
- return redirect(started.authorizeUrl, [
1076
- serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
1077
- ]);
1294
+ if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
1295
+ return await toPod({ pod, next, space, prompt });
1078
1296
  } catch (err) {
1079
1297
  const e = fail(err, "connect");
1080
1298
  return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
@@ -1093,7 +1311,7 @@ function createPodAppAuth(config) {
1093
1311
  secret: config.secret
1094
1312
  });
1095
1313
  const cookies = await store.write(connection, request);
1096
- return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
1314
+ return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
1097
1315
  } catch (err) {
1098
1316
  const e = fail(err, "callback");
1099
1317
  return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
@@ -1108,7 +1326,53 @@ function createPodAppAuth(config) {
1108
1326
  const { revoked } = await revokeConnection(connection, flow);
1109
1327
  if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
1110
1328
  }
1111
- return redirect(new URL(next, origin).href, await store.clear(request));
1329
+ return redirect(new URL(next, origin).href, [...await store.clear(request), clearMarker]);
1330
+ }
1331
+ async function cloudConnect(request) {
1332
+ if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
1333
+ if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
1334
+ const form = await readForm(request);
1335
+ const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
1336
+ const space = formField(form, "space");
1337
+ try {
1338
+ const started = await startCloudConnect({
1339
+ cloudUrl,
1340
+ allowInsecureLoopback: config.allowInsecureLoopback,
1341
+ clientId,
1342
+ secret: config.secret,
1343
+ next,
1344
+ spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
1345
+ prompt: formField(form, "prompt") === "consent" ? "consent" : null,
1346
+ now
1347
+ });
1348
+ return redirect(started.url, [
1349
+ serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1e3, secure })
1350
+ ]);
1351
+ } catch (err) {
1352
+ const e = fail(err, "connect");
1353
+ return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
1354
+ }
1355
+ }
1356
+ async function cloudReturn(request) {
1357
+ if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
1358
+ let pod = null;
1359
+ try {
1360
+ const back = await acceptCloudReturn({
1361
+ url: request.url,
1362
+ transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
1363
+ clientId,
1364
+ secret: config.secret,
1365
+ allowInsecureLoopback: config.allowInsecureLoopback,
1366
+ now
1367
+ });
1368
+ pod = back.pod;
1369
+ const next = back.next ?? paths.afterConnect;
1370
+ if (!back.prompt && await liveConnectionTo(request, back.pod)) return redirect(new URL(next, origin).href, [clearCloud]);
1371
+ return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
1372
+ } catch (err) {
1373
+ const e = fail(err, "connect");
1374
+ return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
1375
+ }
1112
1376
  }
1113
1377
  async function refreshIfNeeded(request) {
1114
1378
  const connection = await store.read(request);
@@ -1119,7 +1383,10 @@ function createPodAppAuth(config) {
1119
1383
  cookieHeader: null,
1120
1384
  ...error ? { error } : {}
1121
1385
  });
1122
- if (!connection) return unchanged("none", null);
1386
+ if (!connection) {
1387
+ const lapsed = lapsedIn(request);
1388
+ return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
1389
+ }
1123
1390
  if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
1124
1391
  const t = now();
1125
1392
  const accessLives = connection.accessExpiresAt > t;
@@ -1144,6 +1411,7 @@ function createPodAppAuth(config) {
1144
1411
  const cleared = await store.clear(request).catch(() => []);
1145
1412
  return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
1146
1413
  }
1414
+ setCookies = [...setCookies, marker(next.podUrl)];
1147
1415
  return {
1148
1416
  status: "refreshed",
1149
1417
  connection: next,
@@ -1156,8 +1424,13 @@ function createPodAppAuth(config) {
1156
1424
  redirectUri,
1157
1425
  metadata,
1158
1426
  paths,
1159
- handlers: { clientMetadata, connect, callback, disconnect },
1160
- readConnection: (source) => store.read(source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} })),
1427
+ cloudConnectAvailable: cloudUrl !== null,
1428
+ handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
1429
+ readConnection: (source) => store.read(asRequest(source)),
1430
+ async readLapsed(source) {
1431
+ const request = asRequest(source);
1432
+ return await store.read(request) ? null : lapsedIn(request);
1433
+ },
1161
1434
  refreshIfNeeded,
1162
1435
  client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts })
1163
1436
  };
@@ -1167,19 +1440,23 @@ function createPodAppAuth(config) {
1167
1440
  assertServerRuntime();
1168
1441
  // Annotate the CommonJS export names for ESM import in node:
1169
1442
  0 && (module.exports = {
1443
+ CLOUD_TRANSACTION_TTL_MS,
1170
1444
  CONNECT_ERROR_CODES,
1171
1445
  ConnectError,
1172
1446
  DEFAULT_PATHS,
1173
1447
  DEFAULT_REFRESH_IDLE_MS,
1448
+ MARKER_TTL_SEC,
1174
1449
  MIN_SECRET_LENGTH,
1175
1450
  REFRESH_WINDOW_MS,
1176
1451
  ROTATION_MEMORY_MS,
1177
1452
  TRANSACTION_TTL_MS,
1453
+ acceptCloudReturn,
1178
1454
  acceptLaunch,
1179
1455
  applySetCookies,
1180
1456
  buildClientMetadata,
1181
1457
  can,
1182
1458
  canonicalPodUrl,
1459
+ cloudBaseUrl,
1183
1460
  cloudConnectAvailable,
1184
1461
  connectionExpiresAt,
1185
1462
  cookieNames,
@@ -1190,15 +1467,19 @@ assertServerRuntime();
1190
1467
  handleCallback,
1191
1468
  isBrowserRuntime,
1192
1469
  isConnectError,
1470
+ isPrefetchOrAsset,
1471
+ markerCookie,
1193
1472
  openTransaction,
1194
1473
  parseConnectErrorCode,
1195
1474
  parseTokenResponse,
1196
1475
  pkceChallenge,
1476
+ readMarker,
1197
1477
  refreshConnection,
1198
1478
  reviewUrlFor,
1199
1479
  revokeConnection,
1200
1480
  safeNextPath,
1201
1481
  seal,
1482
+ spaces,
1202
1483
  startCloudConnect,
1203
1484
  startConnect,
1204
1485
  unseal