@synap-core/sdk 1.3.0 → 1.5.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +212 -12
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +790 -161
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +610 -37
  10. package/dist/connect.d.ts +610 -37
  11. package/dist/connect.js +777 -162
  12. package/dist/connect.js.map +1 -1
  13. package/dist/public.cjs +9 -2
  14. package/dist/public.cjs.map +1 -1
  15. package/dist/public.d.cts +19 -4
  16. package/dist/public.d.ts +19 -4
  17. package/dist/public.js +8 -2
  18. package/dist/public.js.map +1 -1
  19. package/dist/testing.cjs +366 -17
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +190 -7
  22. package/dist/testing.d.ts +190 -7
  23. package/dist/testing.js +358 -17
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
  26. package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
  27. package/package.json +36 -12
  28. package/src/connect/auth.ts +314 -55
  29. package/src/connect/client.ts +472 -26
  30. package/src/connect/cloud.ts +210 -0
  31. package/src/connect/discovery.ts +79 -44
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +8 -1
  34. package/src/connect/flow.ts +80 -12
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +254 -0
  37. package/src/connect/index.ts +69 -23
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/seal.ts +1 -1
  40. package/src/connect/store.ts +50 -8
  41. package/src/connect/types.ts +48 -4
  42. package/src/public/client.ts +14 -2
  43. package/src/public/safe-markdown.ts +13 -0
  44. package/src/public/share-model.ts +6 -2
  45. package/src/public/site-model.ts +5 -3
  46. package/src/testing/auth-server.ts +377 -20
  47. package/src/testing/cloud.ts +83 -0
  48. package/src/testing/fixtures.ts +26 -0
  49. package/src/testing/index.ts +10 -6
  50. package/src/testing/seed.ts +120 -0
  51. package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/dist/connect.js CHANGED
@@ -1,15 +1,19 @@
1
1
  import {
2
+ CLOUD_STATE_RE,
3
+ LOOPBACK_HOSTS,
4
+ SPACE_HINT_RE,
2
5
  base64url,
3
6
  constantTimeEqual,
4
7
  fromBase64url,
5
8
  fromUtf8,
9
+ isIpLiteral,
6
10
  parseCookies,
7
11
  pkceChallenge,
8
12
  randomBytes,
9
13
  randomToken,
10
14
  serializeCookie,
11
15
  utf8
12
- } from "./chunk-J7XHAAFJ.js";
16
+ } from "./chunk-UK2U6SMA.js";
13
17
 
14
18
  // src/connect/guard.ts
15
19
  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.";
@@ -52,6 +56,7 @@ var CONNECT_ERROR_CODES = [
52
56
  "rate_limited",
53
57
  "expired",
54
58
  "revoked",
59
+ "pod_moved",
55
60
  "server_error"
56
61
  ];
57
62
  function parseConnectErrorCode(value) {
@@ -87,11 +92,44 @@ function retryAfterSeconds(header, now) {
87
92
  return Math.max(0, Math.ceil((at - now) / 1e3));
88
93
  }
89
94
 
95
+ // src/connect/http.ts
96
+ function timeoutSignal(ms) {
97
+ return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
98
+ }
99
+ async function readCapped(res, maxBytes) {
100
+ const declared = Number(res.headers.get("content-length"));
101
+ if (Number.isFinite(declared) && declared > maxBytes) {
102
+ await res.body?.cancel().catch(() => {
103
+ });
104
+ return { ok: false, reason: "too_large" };
105
+ }
106
+ if (!res.body) return { ok: true, text: "" };
107
+ const reader = res.body.getReader();
108
+ const decoder = new TextDecoder();
109
+ let total = 0;
110
+ let text = "";
111
+ try {
112
+ for (; ; ) {
113
+ const { done, value } = await reader.read();
114
+ if (done) break;
115
+ total += value.byteLength;
116
+ if (total > maxBytes) {
117
+ await reader.cancel().catch(() => {
118
+ });
119
+ return { ok: false, reason: "too_large" };
120
+ }
121
+ text += decoder.decode(value, { stream: true });
122
+ }
123
+ } catch {
124
+ return { ok: false, reason: "unreadable" };
125
+ }
126
+ return { ok: true, text: text + decoder.decode() };
127
+ }
128
+
90
129
  // src/connect/discovery.ts
91
130
  var METADATA_PATH = "/.well-known/oauth-authorization-server";
92
131
  var TIMEOUT_MS = 5e3;
93
132
  var MAX_METADATA_BYTES = 64 * 1024;
94
- var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
95
133
  function isPrivateIpv4(host) {
96
134
  const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
97
135
  if (!m) return false;
@@ -144,10 +182,12 @@ function sameOriginEndpoint(value, issuer) {
144
182
  }
145
183
  }
146
184
  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");
185
+ const read = await readCapped(res, MAX_METADATA_BYTES);
186
+ if (!read.ok) {
187
+ throw read.reason === "too_large" ? new ConnectError("not_a_pod", "Metadata document too large") : new ConnectError("pod_unreachable", "The metadata answer broke off");
188
+ }
149
189
  try {
150
- return JSON.parse(text);
190
+ return JSON.parse(read.text);
151
191
  } catch {
152
192
  throw new ConnectError("not_a_pod", "Metadata is not JSON");
153
193
  }
@@ -160,18 +200,27 @@ async function discoverIssuer(issuer, opts) {
160
200
  method: "GET",
161
201
  headers: { Accept: "application/json" },
162
202
  redirect: "manual",
163
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : void 0
203
+ signal: timeoutSignal(TIMEOUT_MS)
164
204
  });
165
205
  } catch {
166
206
  throw new ConnectError("pod_unreachable", "The pod did not answer");
167
207
  }
208
+ if (res.status >= 300 && res.status < 400) {
209
+ await res.body?.cancel().catch(() => {
210
+ });
211
+ throw new ConnectError("pod_moved", `The address answered ${res.status}: the pod moved (its new address is not followed)`);
212
+ }
168
213
  if (res.status === 429) {
169
214
  throw new ConnectError("rate_limited", "The pod asked to slow down", {
170
215
  retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
171
216
  });
172
217
  }
173
218
  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})`);
219
+ if (res.status !== 200) {
220
+ await res.body?.cancel().catch(() => {
221
+ });
222
+ throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
223
+ }
175
224
  const doc = await readJsonCapped(res);
176
225
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
177
226
  const m = doc;
@@ -197,27 +246,38 @@ async function discoverIssuer(issuer, opts) {
197
246
  refreshSupported: list(m.grant_types_supported)?.includes("refresh_token") ?? false
198
247
  };
199
248
  }
249
+ var CLOUD_DOMAIN = "synap.live";
250
+ var CLOUD_POD_ZONE = `pod.${CLOUD_DOMAIN}`;
251
+ var POD_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
252
+ function fallbackIssuers(first) {
253
+ const url = new URL(first);
254
+ const host = url.hostname;
255
+ if (url.pathname !== "/" || !host.includes(".") || host.endsWith(`.${CLOUD_POD_ZONE}`)) return [];
256
+ const label = host.endsWith(`.${CLOUD_DOMAIN}`) ? host.slice(0, -CLOUD_DOMAIN.length - 1) : null;
257
+ if (label !== null && POD_NAME_RE.test(label)) {
258
+ return [`${url.protocol}//${label}.${CLOUD_POD_ZONE}`, `${url.protocol}//pod.${url.host}`];
259
+ }
260
+ return host.startsWith("pod.") ? [] : [`${url.protocol}//pod.${url.host}`];
261
+ }
200
262
  async function discoverPod(input, opts = {}) {
201
263
  const first = canonicalPodUrl(input, opts);
264
+ let firstError;
202
265
  try {
203
266
  return await discoverIssuer(first, opts);
204
267
  } 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}`;
268
+ firstError = err;
269
+ }
270
+ const retryable = (err) => err instanceof ConnectError && (err.code === "not_a_pod" || err.code === "pod_unreachable" || err.code === "pod_moved");
271
+ if (!retryable(firstError)) throw firstError;
272
+ let tooOld = null;
273
+ for (const candidate of fallbackIssuers(first)) {
209
274
  try {
210
- return await discoverIssuer(second, opts);
211
- } catch (err2) {
212
- throw err2 instanceof ConnectError && err2.code === "pod_unsupported" ? err2 : err;
275
+ return await discoverIssuer(candidate, opts);
276
+ } catch (err) {
277
+ if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
213
278
  }
214
279
  }
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");
280
+ throw tooOld ?? firstError;
221
281
  }
222
282
  function safeNextPath(value, appOrigin) {
223
283
  if (!value || value.length > 2048) return null;
@@ -248,29 +308,253 @@ function acceptLaunch(url, appOrigin, opts = {}) {
248
308
  }
249
309
  }
250
310
  const hint = params.get("synap_space_hint");
311
+ const state = params.get("state");
251
312
  return {
252
313
  pod,
253
314
  next: safeNextPath(params.get("target_link_uri"), appOrigin),
254
- spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null
315
+ spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
316
+ // A browser launch never carries a state: any well-formed one is a Synap Cloud return
317
+ // (a pick: iss + state; a cancel: state + error=access_denied, no iss).
318
+ cloudState: state && CLOUD_STATE_RE.test(state) ? state : null
319
+ };
320
+ }
321
+
322
+ // src/connect/seal.ts
323
+ var VERSION = "v1";
324
+ var IV_BYTES = 12;
325
+ var TAG_BYTES = 16;
326
+ var MIN_SECRET_LENGTH = 32;
327
+ var SALT = utf8("synap-connect/v1");
328
+ var keyCache = /* @__PURE__ */ new Map();
329
+ function secretsOf(secret) {
330
+ const list = typeof secret === "string" ? [secret] : secret;
331
+ if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
332
+ throw new ConnectError(
333
+ "misconfigured",
334
+ `The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
335
+ );
336
+ }
337
+ return list;
338
+ }
339
+ function assertAppSecret(secret) {
340
+ secretsOf(secret);
341
+ }
342
+ function keyFor(secret, purpose) {
343
+ const cacheKey = `${purpose}\0${secret}`;
344
+ let key = keyCache.get(cacheKey);
345
+ if (!key) {
346
+ key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
347
+ (base) => crypto.subtle.deriveKey(
348
+ { name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
349
+ base,
350
+ { name: "AES-GCM", length: 256 },
351
+ false,
352
+ ["encrypt", "decrypt"]
353
+ )
354
+ );
355
+ keyCache.set(cacheKey, key);
356
+ }
357
+ return key;
358
+ }
359
+ async function seal(value, opts) {
360
+ const [current] = secretsOf(opts.secret);
361
+ const iv = randomBytes(IV_BYTES);
362
+ const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
363
+ const ciphertext = await crypto.subtle.encrypt(
364
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
365
+ await keyFor(current, opts.purpose),
366
+ plaintext
367
+ );
368
+ const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
369
+ out.set(iv, 0);
370
+ out.set(new Uint8Array(ciphertext), IV_BYTES);
371
+ return `${VERSION}.${base64url(out)}`;
372
+ }
373
+ async function unseal(sealed, opts) {
374
+ const secrets = secretsOf(opts.secret);
375
+ const dot = sealed.indexOf(".");
376
+ if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
377
+ const bytes = fromBase64url(sealed.slice(dot + 1));
378
+ if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
379
+ const iv = bytes.slice(0, IV_BYTES);
380
+ const ciphertext = bytes.slice(IV_BYTES);
381
+ for (const secret of secrets) {
382
+ let plaintext;
383
+ try {
384
+ plaintext = await crypto.subtle.decrypt(
385
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
386
+ await keyFor(secret, opts.purpose),
387
+ ciphertext
388
+ );
389
+ } catch {
390
+ continue;
391
+ }
392
+ let parsed;
393
+ try {
394
+ parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
395
+ } catch {
396
+ return { ok: false, reason: "invalid" };
397
+ }
398
+ if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
399
+ if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
400
+ return { ok: true, value: parsed.data, expiresAt: parsed.exp };
401
+ }
402
+ return { ok: false, reason: "invalid" };
403
+ }
404
+
405
+ // src/connect/cloud.ts
406
+ var CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
407
+ function cloudBaseUrl(cloudUrl, opts = {}) {
408
+ let url;
409
+ try {
410
+ url = new URL(cloudUrl.trim());
411
+ } catch {
412
+ throw new ConnectError("misconfigured", "cloudUrl is not a URL");
413
+ }
414
+ const loopback = LOOPBACK_HOSTS.has(url.hostname);
415
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
416
+ throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
417
+ }
418
+ if (url.username || url.password || url.search || url.hash) {
419
+ throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
420
+ }
421
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
422
+ }
423
+ function cloudConnectAvailable(config) {
424
+ if (!config?.cloudUrl) return false;
425
+ try {
426
+ cloudBaseUrl(config.cloudUrl, config);
427
+ return true;
428
+ } catch {
429
+ return false;
430
+ }
431
+ }
432
+ function startCloudConnect(opts) {
433
+ if (!cloudConnectAvailable(opts)) {
434
+ throw new ConnectError("cp_unavailable", "cp: not configured \u2014 pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
435
+ }
436
+ if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
437
+ const base = cloudBaseUrl(opts.cloudUrl, opts);
438
+ const secret = opts.secret;
439
+ return (async () => {
440
+ const now = (opts.now ?? Date.now)();
441
+ const state = randomToken();
442
+ const url = new URL(`${base}/connect/pod`);
443
+ url.searchParams.set("client_id", opts.clientId);
444
+ url.searchParams.set("state", state);
445
+ const tx = {
446
+ v: 1,
447
+ state,
448
+ clientId: opts.clientId,
449
+ cloudUrl: base,
450
+ next: opts.next ?? null,
451
+ spaceHint: opts.spaceHint ?? null,
452
+ prompt: opts.prompt === "consent" ? "consent" : null,
453
+ createdAt: now
454
+ };
455
+ const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
456
+ const transaction = await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt });
457
+ return { url: url.href, transaction, expiresAt };
458
+ })();
459
+ }
460
+ async function acceptCloudReturn(opts) {
461
+ if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
462
+ const opened = await unseal(opts.transaction, {
463
+ secret: opts.secret,
464
+ purpose: "synap-connect/cloud",
465
+ now: (opts.now ?? Date.now)()
466
+ });
467
+ if (!opened.ok) {
468
+ throw new ConnectError(
469
+ "expired_transaction",
470
+ opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed"
471
+ );
472
+ }
473
+ const tx = opened.value;
474
+ if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
475
+ throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
476
+ }
477
+ if (tx.clientId !== opts.clientId) {
478
+ throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
479
+ }
480
+ const params = new URL(opts.url).searchParams;
481
+ const state = params.get("state");
482
+ if (state === null || !CLOUD_STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
483
+ throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
484
+ }
485
+ const error = params.get("error");
486
+ if (error !== null) {
487
+ throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
488
+ }
489
+ const iss = params.get("iss");
490
+ if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
491
+ return {
492
+ pod: canonicalPodUrl(iss, opts),
493
+ next: tx.next,
494
+ spaceHint: tx.spaceHint,
495
+ prompt: tx.prompt === "consent" ? "consent" : null
255
496
  };
256
497
  }
257
498
 
258
499
  // src/connect/client.ts
500
+ function connectionActsAs(connection) {
501
+ return connection.actsAs === "person" ? "person" : "agent";
502
+ }
503
+ function humanSlots(session) {
504
+ return session.expectedOutputs.filter((slot) => slot.owner === "human" && slot.status !== "done" && !slot.retiredAt);
505
+ }
259
506
  function can(connection, permission, workspaceId) {
260
507
  return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
261
508
  }
509
+ function spaces(connection) {
510
+ const bySpace = /* @__PURE__ */ new Map();
511
+ for (const g of connection.grant) {
512
+ const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
513
+ if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
514
+ if (!space.templateSlug && g.templateSlug) space.templateSlug = g.templateSlug;
515
+ if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
516
+ bySpace.set(g.workspaceId, space);
517
+ }
518
+ return [...bySpace.values()];
519
+ }
520
+ var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
521
+ var PROPOSAL_IDS_MAX = 100;
262
522
  var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
523
+ var TIMEOUT_MS2 = 1e4;
524
+ var MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
263
525
  function hubUrl(podUrl, path, query) {
264
- if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
265
- throw new TypeError(`Not a Hub API path: ${path}`);
526
+ if (!SAFE_PATH.test(path) || /%2e/i.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) return null;
527
+ let url;
528
+ try {
529
+ url = new URL(`${podUrl}/api/hub${path}`);
530
+ } catch {
531
+ return null;
266
532
  }
267
- const url = new URL(`${podUrl}/api/hub${path}`);
533
+ if (url.origin !== new URL(podUrl).origin || !(url.pathname === "/api/hub" || url.pathname.startsWith("/api/hub/"))) return null;
268
534
  for (const [key, value] of Object.entries(query ?? {})) {
269
535
  if (value === void 0 || value === null) continue;
270
536
  for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
271
537
  }
272
538
  return url;
273
539
  }
540
+ function renewalError(err) {
541
+ const e = isConnectError(err) ? err : null;
542
+ const detail = e?.message ?? (err instanceof Error ? err.message : "The connection could not be renewed");
543
+ switch (e?.code) {
544
+ case "revoked":
545
+ return { code: "unauthorized", status: 401, detail };
546
+ case "expired":
547
+ return { code: "expired", status: null, detail };
548
+ case "rate_limited":
549
+ return { code: "rate_limited", status: null, detail, ...e.retryAfterSec !== void 0 ? { retryAfterSec: e.retryAfterSec } : {} };
550
+ case "pod_unreachable":
551
+ return { code: "pod_unreachable", status: null, detail };
552
+ case "pod_moved":
553
+ return { code: "pod_moved", status: null, detail };
554
+ default:
555
+ return { code: "server", status: null, detail };
556
+ }
557
+ }
274
558
  function errorFor(status, body, retryAfter, now) {
275
559
  const obj = body && typeof body === "object" ? body : {};
276
560
  const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : void 0;
@@ -285,6 +569,7 @@ function errorFor(status, body, retryAfter, now) {
285
569
  return { code: "rate_limited", ...base, ...retryAfterSec !== void 0 ? { retryAfterSec } : {} };
286
570
  }
287
571
  if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
572
+ if (status >= 300 && status < 400) return { code: "pod_moved", ...base };
288
573
  return { code: "server", ...base };
289
574
  }
290
575
  function reviewUrlFor(podUrl, reviewUrl, proposalId) {
@@ -297,13 +582,37 @@ function reviewUrlFor(podUrl, reviewUrl, proposalId) {
297
582
  }
298
583
  return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
299
584
  }
585
+ function refused(code, reason, detail) {
586
+ return { ok: false, error: { code, status: null, detail, reason } };
587
+ }
588
+ function asObject(value) {
589
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
590
+ }
591
+ function hasId(value) {
592
+ return asObject(value) !== null && typeof value.id === "string";
593
+ }
300
594
  function createPodClient(connection, opts = {}) {
301
595
  const now = opts.now ?? Date.now;
302
596
  const doFetch = () => opts.fetch ?? fetch;
303
- async function request(method, path, body, query) {
304
- const url = hubUrl(connection.podUrl, path, query);
305
- if (connection.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
306
- const headers = { Accept: "application/json", Authorization: `Bearer ${connection.accessToken}` };
597
+ let current = connection;
598
+ let renewal = null;
599
+ function renewOnce(refused2) {
600
+ if (current !== refused2) return Promise.resolve({ ok: true, data: current });
601
+ renewal ??= opts.renew(refused2).then(
602
+ (next) => {
603
+ current = next;
604
+ return { ok: true, data: next };
605
+ },
606
+ (err) => ({ ok: false, error: renewalError(err) })
607
+ );
608
+ return renewal;
609
+ }
610
+ async function request(method, path, body, query, retried = false) {
611
+ const sent = current;
612
+ const url = hubUrl(sent.podUrl, path, query);
613
+ if (!url) return refused("invalid", "invalid_input", "Not a Hub API path: encode each path segment (encodeURIComponent)");
614
+ if (sent.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
615
+ const headers = { Accept: "application/json", Authorization: `Bearer ${sent.accessToken}` };
307
616
  if (body !== void 0) headers["Content-Type"] = "application/json";
308
617
  let res;
309
618
  try {
@@ -311,21 +620,33 @@ function createPodClient(connection, opts = {}) {
311
620
  method,
312
621
  headers,
313
622
  body: body === void 0 ? void 0 : JSON.stringify(body),
314
- redirect: "manual"
623
+ redirect: "manual",
624
+ signal: timeoutSignal(opts.timeoutMs ?? TIMEOUT_MS2)
315
625
  });
316
626
  } catch {
317
627
  return { ok: false, error: { code: "pod_unreachable", status: null } };
318
628
  }
629
+ const read2 = await readCapped(res, opts.maxResponseBytes ?? MAX_RESPONSE_BYTES);
630
+ if (!read2.ok) {
631
+ return read2.reason === "too_large" ? { ok: false, error: { code: "server", status: res.status, detail: "The pod's answer is larger than maxResponseBytes" } } : { ok: false, error: { code: "pod_unreachable", status: res.status, detail: "The pod's answer broke off" } };
632
+ }
319
633
  let data = null;
320
- const text = await res.text().catch(() => "");
321
- if (text) {
634
+ if (read2.text) {
322
635
  try {
323
- data = JSON.parse(text);
636
+ data = JSON.parse(read2.text);
324
637
  } catch {
325
638
  data = null;
326
639
  }
327
640
  }
328
641
  if (res.status >= 200 && res.status < 300) return { ok: true, data };
642
+ if (res.status === 401 && sent.refreshToken) {
643
+ if (!opts.renew) return { ok: false, error: { ...errorFor(401, data, null, now()), refreshable: true } };
644
+ if (!retried) {
645
+ const renewed = await renewOnce(sent);
646
+ if (!renewed.ok) return renewed;
647
+ return request(method, path, body, query, true);
648
+ }
649
+ }
329
650
  return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
330
651
  }
331
652
  async function write(method, path, body) {
@@ -338,17 +659,189 @@ function createPodClient(connection, opts = {}) {
338
659
  ok: true,
339
660
  status: "proposed",
340
661
  proposalId,
341
- reviewUrl: reviewUrlFor(connection.podUrl, obj.reviewUrl, proposalId),
662
+ reviewUrl: reviewUrlFor(current.podUrl, obj.reviewUrl, proposalId),
342
663
  data: result.data
343
664
  };
344
665
  }
345
666
  return { ok: true, status: "applied", data: result.data };
346
667
  }
668
+ async function listPendingProposals(opts2 = {}) {
669
+ if (!can(current, "proposal.read", opts2.workspaceId)) {
670
+ return refused("forbidden", "not_granted", "The connection was not granted proposal.read in that space");
671
+ }
672
+ let only = null;
673
+ if (opts2.proposalIds !== void 0) {
674
+ if (opts2.proposalIds.length > PROPOSAL_IDS_MAX || opts2.proposalIds.some((id) => !ID_RE.test(id))) {
675
+ return refused("invalid", "invalid_input", `proposalIds must be at most ${PROPOSAL_IDS_MAX} proposal ids`);
676
+ }
677
+ if (opts2.proposalIds.length === 0) return { ok: true, data: { total: 0, proposals: [], hasMore: false } };
678
+ only = new Set(opts2.proposalIds);
679
+ }
680
+ const limit = Math.min(Math.max(Math.floor(opts2.limit ?? 20), 1), 200);
681
+ const offset = Math.max(Math.floor(opts2.offset ?? 0), 0);
682
+ const result = await request("GET", "/proposals", void 0, {
683
+ status: "pending",
684
+ view: "basic",
685
+ limit,
686
+ offset,
687
+ workspaceId: opts2.workspaceId,
688
+ ...only ? { proposalIds: [...only].join(",") } : {}
689
+ });
690
+ if (!result.ok) return result;
691
+ const obj = result.data && typeof result.data === "object" ? result.data : {};
692
+ if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
693
+ return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
694
+ }
695
+ const str = (v) => typeof v === "string" && v ? v : null;
696
+ const proposals = [];
697
+ for (const row of obj.proposals) {
698
+ if (!row || typeof row !== "object") continue;
699
+ const r = row;
700
+ const id = str(r.id);
701
+ if (!id || only && !only.has(id)) continue;
702
+ proposals.push({
703
+ id,
704
+ proposalType: str(r.proposalType) ?? "",
705
+ targetType: str(r.targetType) ?? "",
706
+ targetId: str(r.targetId),
707
+ workspaceId: str(r.workspaceId),
708
+ createdAt: str(r.createdAt) ?? "",
709
+ agentUserId: str(r.agentUserId),
710
+ sessionId: str(r.sessionId),
711
+ ...str(r.summary) ? { summary: str(r.summary) } : {},
712
+ reviewUrl: reviewUrlFor(current.podUrl, null, id)
713
+ });
714
+ }
715
+ return {
716
+ ok: true,
717
+ data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total }
718
+ };
719
+ }
720
+ async function read(path, query, shape) {
721
+ const result = await request("GET", path, void 0, query);
722
+ if (!result.ok) return result;
723
+ const data = shape(result.data);
724
+ if (data === null) return { ok: false, error: { code: "server", status: 200, detail: `The pod's ${path.split("/")[1]} answer is not the expected shape` } };
725
+ return { ok: true, data };
726
+ }
727
+ const badId = (what) => refused("invalid", "invalid_input", `Not a ${what} id`);
728
+ const clamp = (n, min, max) => n === void 0 ? void 0 : Math.min(Math.max(Math.floor(n), min), max);
729
+ function listEntities(opts2 = {}) {
730
+ return read("/entities", {
731
+ workspaceId: opts2.workspaceId,
732
+ profileSlug: opts2.profileSlug,
733
+ limit: clamp(opts2.limit, 1, 1e3),
734
+ offset: clamp(opts2.offset, 0, Number.MAX_SAFE_INTEGER),
735
+ sort: opts2.sort,
736
+ includePodWide: opts2.includePodWide
737
+ }, (data) => Array.isArray(data) ? data.filter(hasId) : null);
738
+ }
739
+ async function getEntity(id) {
740
+ if (!ID_RE.test(id)) return badId("entity");
741
+ return read(`/entities/${id}`, void 0, (data) => hasId(data) ? data : null);
742
+ }
743
+ async function getEntityConnections(id, opts2 = {}) {
744
+ if (!ID_RE.test(id)) return badId("entity");
745
+ return read(`/entities/${id}/connections`, { workspaceId: opts2.workspaceId, limit: clamp(opts2.limit, 1, 200) }, (data) => {
746
+ const obj = asObject(data);
747
+ if (!obj || !Array.isArray(obj.connections)) return null;
748
+ const connections = obj.connections.filter(
749
+ (c) => !!c && typeof c === "object" && typeof c.entityId === "string"
750
+ );
751
+ const counts = asObject(obj.counts) ?? {};
752
+ const count = (key, source) => typeof counts[key] === "number" ? counts[key] : connections.filter((c) => c.source === source).length;
753
+ return {
754
+ connections,
755
+ counts: {
756
+ total: typeof counts.total === "number" ? counts.total : connections.length,
757
+ graph: count("graph", "graph"),
758
+ structural: count("structural", "property"),
759
+ threads: count("threads", "thread"),
760
+ contextChannels: count("contextChannels", "context_channel"),
761
+ focusSessions: count("focusSessions", "focus_session")
762
+ }
763
+ };
764
+ });
765
+ }
766
+ async function getDocument(id, opts2 = {}) {
767
+ if (!ID_RE.test(id)) return badId("document");
768
+ return read(`/documents/${id}`, { format: opts2.format }, (data) => {
769
+ const doc = asObject(asObject(data)?.document);
770
+ if (!doc || typeof doc.id !== "string" || typeof doc.content !== "string") return null;
771
+ return { ...doc, sections: Array.isArray(doc.sections) ? doc.sections : [] };
772
+ });
773
+ }
774
+ async function listFocusSessions(opts2) {
775
+ if (!opts2?.workspaceId && !opts2?.projectId) return refused("invalid", "invalid_input", "workspaceId or projectId is required");
776
+ return read("/focus-sessions", {
777
+ workspaceId: opts2.workspaceId,
778
+ projectId: opts2.projectId,
779
+ status: opts2.status,
780
+ limit: clamp(opts2.limit, 1, 50),
781
+ lens: opts2.lens,
782
+ kind: opts2.kind,
783
+ subjectEntityId: opts2.subjectEntityId
784
+ }, (data) => {
785
+ if (!Array.isArray(data)) return null;
786
+ return data.filter(hasId).map((row) => ({
787
+ ...row,
788
+ expectedOutputs: Array.isArray(row.expectedOutputs) ? row.expectedOutputs.filter((s) => !!s && typeof s === "object" && typeof s.label === "string") : []
789
+ }));
790
+ });
791
+ }
792
+ async function getFileUrl(entityId) {
793
+ if (!ID_RE.test(entityId)) return badId("file");
794
+ return read(`/files/${entityId}/url`, void 0, (data) => {
795
+ const obj = asObject(data);
796
+ if (!obj || typeof obj.url !== "string" || typeof obj.expiresAt !== "string") return null;
797
+ return { url: obj.url, expiresAt: obj.expiresAt, mimeType: typeof obj.mimeType === "string" ? obj.mimeType : null };
798
+ });
799
+ }
800
+ async function approveProposal(id, opts2 = {}) {
801
+ if (connectionActsAs(current) !== "person") {
802
+ return refused("forbidden", "agent_cannot_approve", "An agent connection can never approve: approval is the person's");
803
+ }
804
+ if (!can(current, "proposal.approve", opts2.workspaceId)) {
805
+ return refused("forbidden", "not_granted", "The connection was not granted proposal.approve in that space");
806
+ }
807
+ if (!ID_RE.test(id)) return badId("proposal");
808
+ const result = await request("POST", `/proposals/${id}/approve`, {});
809
+ if (!result.ok) return result;
810
+ const obj = asObject(result.data);
811
+ if (!obj || typeof obj.proposalId !== "string") {
812
+ return { ok: false, error: { code: "server", status: 200, detail: "The pod's approve answer names no proposalId" } };
813
+ }
814
+ return {
815
+ ok: true,
816
+ data: {
817
+ proposalId: obj.proposalId,
818
+ ...typeof obj.primaryId === "string" ? { primaryId: obj.primaryId } : {},
819
+ ...typeof obj.linked === "number" ? { linked: obj.linked } : {},
820
+ proposal: asObject(obj.proposal)
821
+ }
822
+ };
823
+ }
347
824
  return {
348
825
  podUrl: connection.podUrl,
349
- user: connection.user,
350
- grant: connection.grant,
351
- can: (permission, workspaceId) => can(connection, permission, workspaceId),
826
+ get user() {
827
+ return current.user;
828
+ },
829
+ get grant() {
830
+ return current.grant;
831
+ },
832
+ get actsAs() {
833
+ return connectionActsAs(current);
834
+ },
835
+ can: (permission, workspaceId) => can(current, permission, workspaceId),
836
+ spaces: () => spaces(current),
837
+ listPendingProposals,
838
+ listEntities,
839
+ getEntity,
840
+ getEntityConnections,
841
+ getDocument,
842
+ listFocusSessions,
843
+ getFileUrl,
844
+ approveProposal,
352
845
  get(path, query) {
353
846
  return request("GET", path, void 0, query);
354
847
  },
@@ -367,94 +860,12 @@ function createPodClient(connection, opts = {}) {
367
860
  };
368
861
  }
369
862
 
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
863
  // src/connect/flow.ts
454
864
  var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
455
865
  var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
456
866
  var DEFAULT_ACCESS_TTL_SEC = 3600;
457
- var TIMEOUT_MS2 = 1e4;
867
+ var TIMEOUT_MS3 = 1e4;
868
+ var MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
458
869
  var PERMISSION_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i;
459
870
  function assertPermissions(permissions) {
460
871
  const unique = [...new Set(permissions)];
@@ -484,6 +895,7 @@ async function startConnect(opts) {
484
895
  url.searchParams.set("code_challenge_method", "S256");
485
896
  if (resource) url.searchParams.set("resource", resource);
486
897
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
898
+ if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
487
899
  const tx = {
488
900
  v: 1,
489
901
  issuer: issuer.issuer,
@@ -571,20 +983,29 @@ async function tokenRequest(endpoint, body, during, opts) {
571
983
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
572
984
  body: body.toString(),
573
985
  redirect: "manual",
574
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS2) : void 0
986
+ signal: timeoutSignal(TIMEOUT_MS3)
575
987
  });
576
988
  } catch {
577
989
  throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
578
990
  }
991
+ if (res.status >= 300 && res.status < 400) {
992
+ await res.body?.cancel().catch(() => {
993
+ });
994
+ throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
995
+ }
579
996
  if (res.status === 429) {
580
997
  throw new ConnectError("rate_limited", "The pod asked to slow down", {
581
998
  retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
582
999
  });
583
1000
  }
584
1001
  if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
1002
+ const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
1003
+ if (!read.ok) {
1004
+ throw read.reason === "too_large" ? new ConnectError("server_error", "The token endpoint's answer is too large") : new ConnectError("pod_unreachable", "The token endpoint's answer broke off");
1005
+ }
585
1006
  let json;
586
1007
  try {
587
- json = await res.json();
1008
+ json = JSON.parse(read.text);
588
1009
  } catch {
589
1010
  throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
590
1011
  }
@@ -600,9 +1021,30 @@ function parseGrant(value) {
600
1021
  const out = [];
601
1022
  for (const g of value) {
602
1023
  if (!g || typeof g !== "object") return null;
603
- const { permission, workspaceId, workspaceName } = g;
1024
+ const { permission, workspaceId, workspaceName, templateSlug } = g;
604
1025
  if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
605
- out.push({ permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" });
1026
+ const row = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
1027
+ if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
1028
+ out.push(row);
1029
+ }
1030
+ return out;
1031
+ }
1032
+ function parseActsAs(value) {
1033
+ return value === "person" || value === "agent" ? value : void 0;
1034
+ }
1035
+ var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
1036
+ function parseApp(value) {
1037
+ if (!value || typeof value !== "object") return {};
1038
+ const { id, manageUrl } = value;
1039
+ const out = {};
1040
+ if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
1041
+ if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
1042
+ try {
1043
+ const url = new URL(manageUrl);
1044
+ const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
1045
+ if (secure && !url.username && !url.password) out.manageUrl = url.href;
1046
+ } catch {
1047
+ }
606
1048
  }
607
1049
  return out;
608
1050
  }
@@ -640,6 +1082,13 @@ function parseTokenResponse(body, ctx) {
640
1082
  user,
641
1083
  connectedAt: ctx.previous?.connectedAt ?? ctx.now
642
1084
  };
1085
+ const app = parseApp(body.synap_app);
1086
+ const appId = app.appId ?? ctx.previous?.appId;
1087
+ const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
1088
+ if (appId !== void 0) connection.appId = appId;
1089
+ if (manageUrl !== void 0) connection.manageUrl = manageUrl;
1090
+ const actsAs = parseActsAs(body.synap_acts_as);
1091
+ if (actsAs !== void 0) connection.actsAs = actsAs;
643
1092
  if (typeof refresh_token === "string" && refresh_token !== "") {
644
1093
  connection.refreshToken = refresh_token;
645
1094
  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;
@@ -705,7 +1154,9 @@ async function revokeConnection(connection, opts = {}) {
705
1154
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
706
1155
  body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
707
1156
  redirect: "manual",
708
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS2) : void 0
1157
+ signal: timeoutSignal(TIMEOUT_MS3)
1158
+ });
1159
+ await res.body?.cancel().catch(() => {
709
1160
  });
710
1161
  if (res.status !== 200) revoked = false;
711
1162
  } catch {
@@ -717,7 +1168,25 @@ async function revokeConnection(connection, opts = {}) {
717
1168
 
718
1169
  // src/connect/store.ts
719
1170
  function cookieNames(secure) {
720
- return secure ? { session: "__Host-synap_session", transaction: "__Host-synap_tx" } : { session: "synap_session", transaction: "synap_tx" };
1171
+ 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" };
1172
+ }
1173
+ var MARKER_TTL_SEC = 400 * 24 * 60 * 60;
1174
+ function markerCookie(podUrl, opts) {
1175
+ return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
1176
+ maxAgeSec: MARKER_TTL_SEC,
1177
+ secure: opts.secure
1178
+ });
1179
+ }
1180
+ function readMarker(cookieHeader, opts) {
1181
+ const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
1182
+ const bytes = raw ? fromBase64url(raw) : null;
1183
+ if (!bytes || bytes.length > 512) return null;
1184
+ const podUrl = fromUtf8(bytes);
1185
+ try {
1186
+ return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
1187
+ } catch {
1188
+ return null;
1189
+ }
721
1190
  }
722
1191
  var CHUNK_SIZE = 3800;
723
1192
  var MAX_CHUNKS = 4;
@@ -729,6 +1198,7 @@ function pack(c) {
729
1198
  for (const g of c.grant) {
730
1199
  const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
731
1200
  row[2].push(g.permission);
1201
+ if (g.templateSlug && row[3] === void 0) row[3] = g.templateSlug;
732
1202
  bySpace.set(g.workspaceId, row);
733
1203
  }
734
1204
  const { grant: _grant, ...rest } = c;
@@ -738,7 +1208,7 @@ function unpack(s) {
738
1208
  if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
739
1209
  const { g, ...rest } = s;
740
1210
  const grant = g.flatMap(
741
- ([workspaceId, workspaceName, permissions]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName }))
1211
+ ([workspaceId, workspaceName, permissions, templateSlug]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName, ...typeof templateSlug === "string" ? { templateSlug } : {} }))
742
1212
  );
743
1213
  return { ...rest, grant };
744
1214
  }
@@ -811,14 +1281,36 @@ var DEFAULT_PATHS = {
811
1281
  connect: "/api/synap/connect",
812
1282
  callback: "/api/synap/callback",
813
1283
  disconnect: "/api/synap/disconnect",
1284
+ cloud: "/api/synap/cloud",
814
1285
  connectPage: "/connect",
815
1286
  resultPage: "/connect/result",
816
1287
  afterConnect: "/"
817
1288
  };
818
1289
  var REFRESH_WINDOW_MS = 5 * 60 * 1e3;
819
- var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
820
1290
  var TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
821
1291
  var MAX_METADATA_BYTES2 = 5 * 1024;
1292
+ var MAX_REQUESTS = 32;
1293
+ var KEPT_RETRY_MS = 6e4;
1294
+ var GRANT_ACTIONS = ["read", "create", "update", "delete"];
1295
+ var SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
1296
+ var PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
1297
+ function isSynapAppOrigin(origin) {
1298
+ return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
1299
+ }
1300
+ function assertAppPermission(permission, origin) {
1301
+ const segs = permission.split(".");
1302
+ const fail = (why) => {
1303
+ throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
1304
+ };
1305
+ if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
1306
+ fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
1307
+ }
1308
+ if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
1309
+ if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && isSynapAppOrigin(origin)) return;
1310
+ if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
1311
+ fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
1312
+ }
1313
+ }
822
1314
  function appOriginOf(appUrl) {
823
1315
  let url;
824
1316
  try {
@@ -827,8 +1319,11 @@ function appOriginOf(appUrl) {
827
1319
  throw new ConnectError("misconfigured", "appUrl is not a URL");
828
1320
  }
829
1321
  const secure = url.protocol === "https:";
830
- if (!secure && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
831
- throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
1322
+ if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
1323
+ throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
1324
+ }
1325
+ if (secure && isIpLiteral(url.hostname)) {
1326
+ throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
832
1327
  }
833
1328
  if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
834
1329
  throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
@@ -846,9 +1341,12 @@ function buildClientMetadata(opts) {
846
1341
  for (const [name, path] of Object.entries(paths)) assertPath(name, path);
847
1342
  const clientName = opts.clientName.trim();
848
1343
  if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
1344
+ if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
1345
+ throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
1346
+ }
849
1347
  assertPermissions(opts.requests.map((r) => r.permission));
850
1348
  for (const r of opts.requests) {
851
- if (r.permission.includes("*")) throw new ConnectError("misconfigured", "a permission cannot contain *");
1349
+ assertAppPermission(r.permission, origin);
852
1350
  if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
853
1351
  throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
854
1352
  }
@@ -902,7 +1400,7 @@ async function readForm(request) {
902
1400
  }
903
1401
  function isPrefetchOrAsset(request) {
904
1402
  const h = request.headers;
905
- if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
1403
+ if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
906
1404
  if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
907
1405
  const path = new URL(request.url).pathname;
908
1406
  return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
@@ -913,6 +1411,7 @@ function createPodAppAuth(config) {
913
1411
  const { origin, secure } = appOriginOf(config.appUrl);
914
1412
  const paths = { ...DEFAULT_PATHS, ...config.paths };
915
1413
  const metadata = buildClientMetadata({ ...config, paths });
1414
+ const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
916
1415
  const clientId = metadata.client_id;
917
1416
  const redirectUri = metadata.redirect_uris[0];
918
1417
  const permissions = config.requests.map((r) => r.permission);
@@ -929,6 +1428,37 @@ function createPodAppAuth(config) {
929
1428
  return e;
930
1429
  };
931
1430
  const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
1431
+ const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
1432
+ const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
1433
+ const marker = (podUrl) => markerCookie(podUrl, { secure, name: names.marker });
1434
+ const asRequest = (source) => source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
1435
+ const lapsedIn = (request) => {
1436
+ const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
1437
+ return podUrl ? { podUrl } : null;
1438
+ };
1439
+ async function toPod(opts, extra = []) {
1440
+ const started = await startConnect({
1441
+ ...flow,
1442
+ pod: opts.pod,
1443
+ clientId,
1444
+ redirectUri,
1445
+ permissions,
1446
+ secret: config.secret,
1447
+ resource: config.resource,
1448
+ spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
1449
+ next: opts.next,
1450
+ prompt: opts.prompt,
1451
+ allowInsecureLoopback: config.allowInsecureLoopback
1452
+ });
1453
+ return redirect(started.authorizeUrl, [
1454
+ ...extra,
1455
+ serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
1456
+ ]);
1457
+ }
1458
+ async function liveConnectionTo(request, pod) {
1459
+ const existing = await store.read(request);
1460
+ return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
1461
+ }
932
1462
  async function clientMetadata() {
933
1463
  return new Response(JSON.stringify(metadata), {
934
1464
  status: 200,
@@ -942,26 +1472,10 @@ function createPodAppAuth(config) {
942
1472
  const pod = formField(form, "pod")?.slice(0, 512) ?? "";
943
1473
  const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
944
1474
  const space = formField(form, "space");
1475
+ const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
945
1476
  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
- ]);
1477
+ if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
1478
+ return await toPod({ pod, next, space, prompt });
965
1479
  } catch (err) {
966
1480
  const e = fail(err, "connect");
967
1481
  return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
@@ -980,7 +1494,7 @@ function createPodAppAuth(config) {
980
1494
  secret: config.secret
981
1495
  });
982
1496
  const cookies = await store.write(connection, request);
983
- return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
1497
+ return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
984
1498
  } catch (err) {
985
1499
  const e = fail(err, "callback");
986
1500
  return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
@@ -995,9 +1509,56 @@ function createPodAppAuth(config) {
995
1509
  const { revoked } = await revokeConnection(connection, flow);
996
1510
  if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
997
1511
  }
998
- return redirect(new URL(next, origin).href, await store.clear(request));
1512
+ return redirect(new URL(next, origin).href, [...await store.clear(request), clearMarker]);
1513
+ }
1514
+ async function cloudConnect(request) {
1515
+ if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
1516
+ if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
1517
+ const form = await readForm(request);
1518
+ const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
1519
+ const space = formField(form, "space");
1520
+ try {
1521
+ const started = await startCloudConnect({
1522
+ cloudUrl,
1523
+ allowInsecureLoopback: config.allowInsecureLoopback,
1524
+ clientId,
1525
+ secret: config.secret,
1526
+ next,
1527
+ spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
1528
+ prompt: formField(form, "prompt") === "consent" ? "consent" : null,
1529
+ now
1530
+ });
1531
+ return redirect(started.url, [
1532
+ serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1e3, secure })
1533
+ ]);
1534
+ } catch (err) {
1535
+ const e = fail(err, "connect");
1536
+ return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
1537
+ }
999
1538
  }
1000
- async function refreshIfNeeded(request) {
1539
+ async function cloudReturn(request) {
1540
+ if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
1541
+ let pod = null;
1542
+ try {
1543
+ const back = await acceptCloudReturn({
1544
+ url: request.url,
1545
+ transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
1546
+ clientId,
1547
+ secret: config.secret,
1548
+ allowInsecureLoopback: config.allowInsecureLoopback,
1549
+ now
1550
+ });
1551
+ pod = back.pod;
1552
+ const next = back.next ?? paths.afterConnect;
1553
+ if (!back.prompt && await liveConnectionTo(request, back.pod)) return redirect(new URL(next, origin).href, [clearCloud]);
1554
+ return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
1555
+ } catch (err) {
1556
+ const e = fail(err, "connect");
1557
+ return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
1558
+ }
1559
+ }
1560
+ const keptFailures = /* @__PURE__ */ new Map();
1561
+ async function refreshIfNeeded(request, opts = {}) {
1001
1562
  const connection = await store.read(request);
1002
1563
  const unchanged = (status, conn, error) => ({
1003
1564
  status,
@@ -1006,21 +1567,33 @@ function createPodAppAuth(config) {
1006
1567
  cookieHeader: null,
1007
1568
  ...error ? { error } : {}
1008
1569
  });
1009
- if (!connection) return unchanged("none", null);
1570
+ if (!connection) {
1571
+ const lapsed = lapsedIn(request);
1572
+ return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
1573
+ }
1010
1574
  if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
1011
1575
  const t = now();
1012
- const accessLives = connection.accessExpiresAt > t;
1013
- if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
1576
+ const accessLives = !opts.force && connection.accessExpiresAt > t;
1577
+ if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
1014
1578
  if (!connection.refreshToken) {
1015
1579
  return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
1016
1580
  }
1581
+ for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
1582
+ const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : void 0;
1583
+ if (keptFailure) return unchanged("kept", connection, keptFailure.error);
1017
1584
  let next;
1018
1585
  try {
1019
1586
  next = await refreshConnection(connection, flow);
1020
1587
  } catch (err) {
1021
1588
  const e = fail(err, "refresh");
1022
- if (accessLives) return unchanged("kept", connection, e);
1023
- if (e.code === "revoked" || e.code === "expired") return unchanged(e.code, null, e);
1589
+ if (accessLives) {
1590
+ keptFailures.set(connection.refreshToken, { error: e, at: t });
1591
+ return unchanged("kept", connection, e);
1592
+ }
1593
+ if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
1594
+ const cleared = await store.clear(request).catch(() => []);
1595
+ return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
1596
+ }
1024
1597
  return unchanged("unavailable", null, e);
1025
1598
  }
1026
1599
  let setCookies;
@@ -1031,6 +1604,7 @@ function createPodAppAuth(config) {
1031
1604
  const cleared = await store.clear(request).catch(() => []);
1032
1605
  return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
1033
1606
  }
1607
+ setCookies = [...setCookies, marker(next.podUrl)];
1034
1608
  return {
1035
1609
  status: "refreshed",
1036
1610
  connection: next,
@@ -1038,35 +1612,71 @@ function createPodAppAuth(config) {
1038
1612
  cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies)
1039
1613
  };
1040
1614
  }
1615
+ function renewer(persist, source) {
1616
+ return async (connection) => {
1617
+ let next;
1618
+ try {
1619
+ next = await refreshConnection(connection, flow);
1620
+ } catch (err) {
1621
+ throw fail(err, "refresh");
1622
+ }
1623
+ try {
1624
+ const cookies = await store.write(next, asRequest(source ?? null));
1625
+ await persist([...cookies, marker(next.podUrl)]);
1626
+ } catch (err) {
1627
+ fail(err, "refresh");
1628
+ throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
1629
+ }
1630
+ return next;
1631
+ };
1632
+ }
1041
1633
  return {
1042
1634
  clientId,
1043
1635
  redirectUri,
1044
1636
  metadata,
1045
1637
  paths,
1046
- handlers: { clientMetadata, connect, callback, disconnect },
1047
- readConnection: (source) => store.read(source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} })),
1638
+ cloudConnectAvailable: cloudUrl !== null,
1639
+ handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
1640
+ readConnection: (source) => store.read(asRequest(source)),
1641
+ async readLapsed(source) {
1642
+ const request = asRequest(source);
1643
+ return await store.read(request) ? null : lapsedIn(request);
1644
+ },
1048
1645
  refreshIfNeeded,
1049
- client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts })
1646
+ client(connection, opts = {}) {
1647
+ const { persist, request, ...clientOpts } = opts;
1648
+ return createPodClient(connection, {
1649
+ fetch: config.fetch,
1650
+ now: config.now,
1651
+ ...persist ? { renew: renewer(persist, request) } : {},
1652
+ ...clientOpts
1653
+ });
1654
+ }
1050
1655
  };
1051
1656
  }
1052
1657
 
1053
1658
  // src/connect/index.ts
1054
1659
  assertServerRuntime();
1055
1660
  export {
1661
+ CLOUD_TRANSACTION_TTL_MS,
1056
1662
  CONNECT_ERROR_CODES,
1057
1663
  ConnectError,
1058
1664
  DEFAULT_PATHS,
1059
1665
  DEFAULT_REFRESH_IDLE_MS,
1666
+ MARKER_TTL_SEC,
1060
1667
  MIN_SECRET_LENGTH,
1061
1668
  REFRESH_WINDOW_MS,
1062
1669
  ROTATION_MEMORY_MS,
1063
1670
  TRANSACTION_TTL_MS,
1671
+ acceptCloudReturn,
1064
1672
  acceptLaunch,
1065
1673
  applySetCookies,
1066
1674
  buildClientMetadata,
1067
1675
  can,
1068
1676
  canonicalPodUrl,
1677
+ cloudBaseUrl,
1069
1678
  cloudConnectAvailable,
1679
+ connectionActsAs,
1070
1680
  connectionExpiresAt,
1071
1681
  cookieNames,
1072
1682
  createPodAppAuth,
@@ -1074,17 +1684,22 @@ export {
1074
1684
  createSealedCookieStore,
1075
1685
  discoverPod,
1076
1686
  handleCallback,
1687
+ humanSlots,
1077
1688
  isBrowserRuntime,
1078
1689
  isConnectError,
1690
+ isPrefetchOrAsset,
1691
+ markerCookie,
1079
1692
  openTransaction,
1080
1693
  parseConnectErrorCode,
1081
1694
  parseTokenResponse,
1082
1695
  pkceChallenge,
1696
+ readMarker,
1083
1697
  refreshConnection,
1084
1698
  reviewUrlFor,
1085
1699
  revokeConnection,
1086
1700
  safeNextPath,
1087
1701
  seal,
1702
+ spaces,
1088
1703
  startCloudConnect,
1089
1704
  startConnect,
1090
1705
  unseal