@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
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Connecting through Synap Cloud: the person picks their pod on the Control
3
+ * Plane instead of typing its address.
4
+ *
5
+ * app POST <cloud handler> → 303 <cp>/connect/pod?client_id=<CIMD url>&state=<nonce>
6
+ * CP the person signs in, picks a pod → 302 <initiate_login_uri>?iss=<pod issuer>&state=<nonce>
7
+ * app GET <cloud handler>?iss&state → state checked against this browser's sealed
8
+ * cookie → the SAME sign-in as a typed address
9
+ * (discovery, fresh PKCE + state, `iss` on the answer)
10
+ *
11
+ * The CP never sees a code or a token, and takes the return URL from the
12
+ * app's client metadata, never from the query. `state` is what makes the
13
+ * return trustworthy: it is drawn here, kept sealed in an HttpOnly cookie of
14
+ * this browser, and compared in constant time, so an `iss` link that another
15
+ * site sends a visitor to (login CSRF into an attacker's pod) is refused. A
16
+ * return without a matching `state` never starts a sign-in.
17
+ *
18
+ * The CP's answer only names a pod. The pod itself is then validated exactly
19
+ * like a typed address (`discoverPod`), and its answer like any other.
20
+ */
21
+ import { canonicalPodUrl } from "./discovery";
22
+ import { constantTimeEqual, randomToken } from "./encoding";
23
+ import { ConnectError, fromOAuthError } from "./errors";
24
+ import { CLOUD_STATE_RE, LOOPBACK_HOSTS } from "./rules";
25
+ import { seal, unseal, type AppSecret } from "./seal";
26
+
27
+ /** How long a Synap Cloud sign-in may take, from the click to the pod chooser's return. */
28
+ export const CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1000;
29
+
30
+
31
+ /** A sealed `CloudTransaction`: opaque, safe to put in an HttpOnly cookie. */
32
+ export type SealedCloudTransaction = string & { readonly __sealedCloudTransaction: true };
33
+
34
+ /** What a started Synap Cloud sign-in remembers (sealed; never sent to the CP). */
35
+ export interface CloudTransaction {
36
+ v: 1;
37
+ state: string;
38
+ clientId: string;
39
+ /** The CP base it was sent to. */
40
+ cloudUrl: string;
41
+ next: string | null;
42
+ spaceHint: string | null;
43
+ prompt: "consent" | null;
44
+ createdAt: number;
45
+ }
46
+
47
+ export interface CloudConnectConfig {
48
+ /** The Control Plane's base URL, e.g. https://api.synap.live. Unset = no Synap Cloud option. */
49
+ cloudUrl?: string | null;
50
+ /** Accept a CP on http://localhost (development). */
51
+ allowInsecureLoopback?: boolean;
52
+ }
53
+
54
+ /** The CP base (no trailing slash), or a ConnectError (`misconfigured`). */
55
+ export function cloudBaseUrl(cloudUrl: string, opts: { allowInsecureLoopback?: boolean } = {}): string {
56
+ let url: URL;
57
+ try {
58
+ url = new URL(cloudUrl.trim());
59
+ } catch {
60
+ throw new ConnectError("misconfigured", "cloudUrl is not a URL");
61
+ }
62
+ const loopback = LOOPBACK_HOSTS.has(url.hostname);
63
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
64
+ throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
65
+ }
66
+ if (url.username || url.password || url.search || url.hash) {
67
+ throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
68
+ }
69
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
70
+ }
71
+
72
+ /** Whether "Continue with Synap Cloud" can be offered: a valid Control Plane URL is configured. */
73
+ export function cloudConnectAvailable(config?: CloudConnectConfig | null): boolean {
74
+ if (!config?.cloudUrl) return false;
75
+ try {
76
+ cloudBaseUrl(config.cloudUrl, config);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ export interface StartCloudConnectOptions extends CloudConnectConfig {
84
+ /** The app's client_id (its CIMD URL): the CP reads `initiate_login_uri` from it. */
85
+ clientId: string;
86
+ /**
87
+ * SERVER-ONLY app secret that seals the transaction. Required: without it the call throws `misconfigured`.
88
+ * Optional in the type only for 1.x semver; required in 2.0.
89
+ */
90
+ secret?: AppSecret;
91
+ /** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
92
+ next?: string | null;
93
+ /** A space hint for the pod's consent (`synap_space_hint`); a pod may ignore it. */
94
+ spaceHint?: string | null;
95
+ /** Show the pod's consent again even if this browser is already connected to the pod the person picks. */
96
+ prompt?: "consent" | null;
97
+ /** @deprecated Ignored: the SDK always draws its own 32-byte state. Removed in 2.0. */
98
+ state?: string;
99
+ now?: () => number;
100
+ }
101
+
102
+ export interface StartedCloudConnect {
103
+ /** Redirect the browser here (303): the CP's pod chooser. */
104
+ url: string;
105
+ /** Store in an HttpOnly cookie until the CP returns. */
106
+ transaction: SealedCloudTransaction;
107
+ /** ms since epoch. */
108
+ expiresAt: number;
109
+ }
110
+
111
+ /**
112
+ * Start a Synap Cloud sign-in. Throws `cp_unavailable` at once when no CP URL
113
+ * is configured, `misconfigured` without a secret: both SYNCHRONOUSLY, before
114
+ * a Promise exists, so wrap the call in try/catch rather than relying on
115
+ * `.catch` (kept from 1.3 for compatibility; 2.0 makes it a plain async
116
+ * function with a required secret).
117
+ */
118
+ export function startCloudConnect(opts: StartCloudConnectOptions): Promise<StartedCloudConnect> {
119
+ if (!cloudConnectAvailable(opts)) {
120
+ throw new ConnectError("cp_unavailable", "cp: not configured — pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
121
+ }
122
+ if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
123
+ const base = cloudBaseUrl(opts.cloudUrl!, opts);
124
+ const secret = opts.secret;
125
+ return (async () => {
126
+ const now = (opts.now ?? Date.now)();
127
+ const state = randomToken();
128
+ const url = new URL(`${base}/connect/pod`);
129
+ url.searchParams.set("client_id", opts.clientId);
130
+ url.searchParams.set("state", state);
131
+ const tx: CloudTransaction = {
132
+ v: 1,
133
+ state,
134
+ clientId: opts.clientId,
135
+ cloudUrl: base,
136
+ next: opts.next ?? null,
137
+ spaceHint: opts.spaceHint ?? null,
138
+ prompt: opts.prompt === "consent" ? "consent" : null,
139
+ createdAt: now,
140
+ };
141
+ const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
142
+ const transaction = (await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt })) as SealedCloudTransaction;
143
+ return { url: url.href, transaction, expiresAt };
144
+ })();
145
+ }
146
+
147
+ export interface AcceptCloudReturnOptions {
148
+ /** The URL the CP sent the browser back to (`?iss=…&state=…`). */
149
+ url: string | URL;
150
+ transaction: string | null | undefined;
151
+ clientId: string;
152
+ secret: AppSecret;
153
+ allowInsecureLoopback?: boolean;
154
+ now?: () => number;
155
+ }
156
+
157
+ export interface CloudReturn {
158
+ /** The pod the person picked (canonical issuer), still to be discovered and validated. */
159
+ pod: string;
160
+ next: string | null;
161
+ spaceHint: string | null;
162
+ prompt: "consent" | null;
163
+ }
164
+
165
+ /**
166
+ * Check the CP's return against the sealed transaction of THIS browser.
167
+ * Throws `expired_transaction` (none, foreign, tampered or older than 10
168
+ * minutes), `mismatch` (the state is not this browser's, or no pod is named),
169
+ * `denied` (the person cancelled on the chooser: `error=access_denied`, no
170
+ * `iss`; nothing is started), `invalid_pod_address`.
171
+ * `state` is compared BEFORE anything else in the query is used.
172
+ */
173
+ export async function acceptCloudReturn(opts: AcceptCloudReturnOptions): Promise<CloudReturn> {
174
+ if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
175
+ const opened = await unseal<CloudTransaction>(opts.transaction, {
176
+ secret: opts.secret,
177
+ purpose: "synap-connect/cloud",
178
+ now: (opts.now ?? Date.now)(),
179
+ });
180
+ if (!opened.ok) {
181
+ throw new ConnectError(
182
+ "expired_transaction",
183
+ opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed",
184
+ );
185
+ }
186
+ const tx = opened.value;
187
+ if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
188
+ throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
189
+ }
190
+ if (tx.clientId !== opts.clientId) {
191
+ throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
192
+ }
193
+ const params = new URL(opts.url).searchParams;
194
+ const state = params.get("state");
195
+ if (state === null || !CLOUD_STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
196
+ throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
197
+ }
198
+ const error = params.get("error");
199
+ if (error !== null) {
200
+ throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
201
+ }
202
+ const iss = params.get("iss");
203
+ if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
204
+ return {
205
+ pod: canonicalPodUrl(iss, opts),
206
+ next: tx.next,
207
+ spaceHint: tx.spaceHint,
208
+ prompt: tx.prompt === "consent" ? "consent" : null,
209
+ };
210
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Pod discovery (spec 17 §6.3): a typed address or a launch `iss` → the pod's
3
- * authorization server metadata (RFC 8414), validated before any redirect.
2
+ * Pod discovery: a typed address or a launch `iss` → the pod's authorization
3
+ * server metadata (RFC 8414), validated before any redirect.
4
4
  *
5
5
  * - the metadata's `issuer` must equal the address it was fetched for, byte
6
6
  * for byte (RFC 8414 §3.3): a document naming another issuer is not used;
@@ -11,15 +11,17 @@
11
11
  * The fetch runs on the app's server with an address a visitor typed, so it is
12
12
  * fenced: https only (http only on loopback when `allowInsecureLoopback`), no
13
13
  * IP literal outside public space, no single-label or `.local`/`.internal`
14
- * host, no redirect followed, 5 s, 64 KB. Residual: a public DNS name that
15
- * resolves to a private address cannot be refused without a resolver; a
16
- * deployment that cares adds egress rules.
14
+ * host, no redirect followed, 5 s, 64 KB counted on the stream. At most three
15
+ * addresses are tried per call (`discoverPod`). Residual: a public DNS name
16
+ * that resolves to a private address cannot be refused without a resolver; a
17
+ * deployment that cares adds egress rules, and an app rate-limits the route
18
+ * that calls this (it fetches an address a visitor chose).
17
19
  *
18
- * Connecting through Synap Cloud (the CP pod chooser, wave C1) is not
19
- * available yet: `cloudConnectAvailable()` is false and `startCloudConnect`
20
- * throws `cp_unavailable`.
20
+ * Connecting through Synap Cloud (the Control Plane's pod chooser) is ./cloud.ts.
21
21
  */
22
22
  import { ConnectError, retryAfterSeconds } from "./errors";
23
+ import { readCapped, timeoutSignal } from "./http";
24
+ import { CLOUD_STATE_RE, LOOPBACK_HOSTS, SPACE_HINT_RE } from "./rules";
23
25
  import type { PodIssuer } from "./types";
24
26
 
25
27
  const METADATA_PATH = "/.well-known/oauth-authorization-server";
@@ -33,8 +35,6 @@ export interface DiscoverOptions {
33
35
  now?: () => number;
34
36
  }
35
37
 
36
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
37
-
38
38
  function isPrivateIpv4(host: string): boolean {
39
39
  const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
40
40
  if (!m) return false;
@@ -104,10 +104,14 @@ function sameOriginEndpoint(value: unknown, issuer: string): string | null {
104
104
  }
105
105
 
106
106
  async function readJsonCapped(res: Response): Promise<unknown> {
107
- const text = await res.text();
108
- if (text.length > MAX_METADATA_BYTES) throw new ConnectError("not_a_pod", "Metadata document too large");
107
+ const read = await readCapped(res, MAX_METADATA_BYTES);
108
+ if (!read.ok) {
109
+ throw read.reason === "too_large"
110
+ ? new ConnectError("not_a_pod", "Metadata document too large")
111
+ : new ConnectError("pod_unreachable", "The metadata answer broke off");
112
+ }
109
113
  try {
110
- return JSON.parse(text);
114
+ return JSON.parse(read.text);
111
115
  } catch {
112
116
  throw new ConnectError("not_a_pod", "Metadata is not JSON");
113
117
  }
@@ -122,18 +126,25 @@ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<Po
122
126
  method: "GET",
123
127
  headers: { Accept: "application/json" },
124
128
  redirect: "manual",
125
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
129
+ signal: timeoutSignal(TIMEOUT_MS),
126
130
  });
127
131
  } catch {
128
132
  throw new ConnectError("pod_unreachable", "The pod did not answer");
129
133
  }
134
+ if (res.status >= 300 && res.status < 400) {
135
+ await res.body?.cancel().catch(() => {});
136
+ throw new ConnectError("pod_moved", `The address answered ${res.status}: the pod moved (its new address is not followed)`);
137
+ }
130
138
  if (res.status === 429) {
131
139
  throw new ConnectError("rate_limited", "The pod asked to slow down", {
132
140
  retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
133
141
  });
134
142
  }
135
143
  if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
136
- if (res.status !== 200) throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
144
+ if (res.status !== 200) {
145
+ await res.body?.cancel().catch(() => {});
146
+ throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
147
+ }
137
148
  const doc = await readJsonCapped(res);
138
149
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
139
150
  const m = doc as Record<string, unknown>;
@@ -167,42 +178,53 @@ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<Po
167
178
  };
168
179
  }
169
180
 
181
+ /** Synap Cloud's pod domain: pods are `<name>.pod.synap.live`; `<name>.synap.live` and `pod.<name>.synap.live` are older names. */
182
+ const CLOUD_DOMAIN = "synap.live";
183
+ const CLOUD_POD_ZONE = `pod.${CLOUD_DOMAIN}`;
184
+ const POD_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
185
+
186
+ /** The addresses to try after the typed one fails, in order (at most two). */
187
+ function fallbackIssuers(first: string): string[] {
188
+ const url = new URL(first);
189
+ const host = url.hostname;
190
+ if (url.pathname !== "/" || !host.includes(".") || host.endsWith(`.${CLOUD_POD_ZONE}`)) return [];
191
+ const label = host.endsWith(`.${CLOUD_DOMAIN}`) ? host.slice(0, -CLOUD_DOMAIN.length - 1) : null;
192
+ if (label !== null && POD_NAME_RE.test(label)) {
193
+ // A Synap Cloud name typed the old way: the pod's current name, then the old nested one.
194
+ return [`${url.protocol}//${label}.${CLOUD_POD_ZONE}`, `${url.protocol}//pod.${url.host}`];
195
+ }
196
+ return host.startsWith("pod.") ? [] : [`${url.protocol}//pod.${url.host}`];
197
+ }
198
+
170
199
  /**
171
200
  * A typed pod address (or a launch `iss`) → its validated authorization server.
172
- * Tries the address, then `pod.<host>` (so "antoine.synap.live" finds
173
- * "pod.antoine.synap.live").
201
+ * Tries the address, then the pod names it may stand for: on Synap Cloud,
202
+ * "you.synap.live" finds "you.pod.synap.live" (and the older
203
+ * "pod.you.synap.live"); elsewhere, "example.com" finds "pod.example.com".
204
+ * A redirect is never followed: an old name that answers 308 counts as a
205
+ * miss (`pod_moved` when nothing else answers).
174
206
  */
175
207
  export async function discoverPod(input: string, opts: DiscoverOptions = {}): Promise<PodIssuer> {
176
208
  const first = canonicalPodUrl(input, opts);
209
+ let firstError: unknown;
177
210
  try {
178
211
  return await discoverIssuer(first, opts);
179
212
  } catch (err) {
180
- const url = new URL(first);
181
- const retry =
182
- err instanceof ConnectError &&
183
- (err.code === "not_a_pod" || err.code === "pod_unreachable") &&
184
- !url.hostname.startsWith("pod.") &&
185
- url.pathname === "/" &&
186
- url.hostname.includes(".");
187
- if (!retry) throw err;
188
- const second = `${url.protocol}//pod.${url.host}`;
213
+ firstError = err;
214
+ }
215
+ const retryable = (err: unknown) =>
216
+ err instanceof ConnectError && (err.code === "not_a_pod" || err.code === "pod_unreachable" || err.code === "pod_moved");
217
+ if (!retryable(firstError)) throw firstError;
218
+ let tooOld: ConnectError | null = null;
219
+ for (const candidate of fallbackIssuers(first)) {
189
220
  try {
190
- return await discoverIssuer(second, opts);
191
- } catch (err2) {
221
+ return await discoverIssuer(candidate, opts);
222
+ } catch (err) {
192
223
  // A pod that is there but too old says more than "nothing answered".
193
- throw err2 instanceof ConnectError && err2.code === "pod_unsupported" ? err2 : err;
224
+ if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
194
225
  }
195
226
  }
196
- }
197
-
198
- /** Whether "Continue with Synap Cloud" can be offered. False until the CP pod chooser (C1) ships. */
199
- export function cloudConnectAvailable(): boolean {
200
- return false;
201
- }
202
-
203
- /** The Synap Cloud pod chooser (C1). Not available yet: always throws `cp_unavailable`. */
204
- export function startCloudConnect(_opts: { clientId: string; state?: string }): never {
205
- throw new ConnectError("cp_unavailable", "cp: not available — connecting through Synap Cloud is not built yet; use a pod address");
227
+ throw tooOld ?? firstError;
206
228
  }
207
229
 
208
230
  /**
@@ -229,18 +251,27 @@ export function safeNextPath(value: string | null | undefined, appOrigin: string
229
251
  }
230
252
 
231
253
  export interface LaunchHint {
232
- /** The pod issuer the launcher named (`iss`), to pre-fill and CONFIRM (T6), never to auto-connect. */
254
+ /** The pod issuer the launcher named (`iss`), to pre-fill and CONFIRM, never to auto-connect. */
233
255
  pod: string | null;
234
256
  /** Where to land after connecting (`target_link_uri`), same origin only. */
235
257
  next: string | null;
236
- /** A space to preselect on consent (`synap_space_hint`). */
258
+ /** A space hint for the pod's consent (`synap_space_hint`); a pod may ignore it. */
237
259
  spaceHint: string | null;
260
+ /**
261
+ * The `state` of a Synap Cloud return (the CP pod chooser answers
262
+ * `initiate_login_uri?iss=…&state=…`, or `?state=…&error=access_denied` when
263
+ * the person cancelled), shape-checked only. NOT verified here:
264
+ * send the request's query to the cloud handler (GET `paths.cloud`), which
265
+ * checks it against this browser's sealed cookie. Never act on `pod` because
266
+ * a state is present.
267
+ */
268
+ cloudState: string | null;
238
269
  }
239
270
 
240
271
  /**
241
- * Read a third-party-initiated login (OIDC Core §4, spec 17 §7.2): the browser
272
+ * Read a third-party-initiated login (OIDC Core §4): the browser
242
273
  * opens the app's `initiate_login_uri?iss=…&target_link_uri=…`. One parser for
243
- * the browser launch and, later, the Synap Cloud chooser's return.
274
+ * the browser launch and the Synap Cloud chooser's return (`cloudState`).
244
275
  */
245
276
  export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allowInsecureLoopback?: boolean } = {}): LaunchHint {
246
277
  const params = new URL(url).searchParams;
@@ -254,9 +285,13 @@ export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allow
254
285
  }
255
286
  }
256
287
  const hint = params.get("synap_space_hint");
288
+ const state = params.get("state");
257
289
  return {
258
290
  pod,
259
291
  next: safeNextPath(params.get("target_link_uri"), appOrigin),
260
- spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
292
+ spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
293
+ // A browser launch never carries a state: any well-formed one is a Synap Cloud return
294
+ // (a pick: iss + state; a cancel: state + error=access_denied, no iss).
295
+ cloudState: state && CLOUD_STATE_RE.test(state) ? state : null,
261
296
  };
262
297
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Web-standard primitives (WebCrypto, TextEncoder, btoa/atob): the same code
3
- * runs on Node 18+, edge runtimes and workers. No Node import.
3
+ * runs on Node 20+, edge runtimes and workers. No Node import.
4
4
  */
5
5
 
6
6
  const encoder = new TextEncoder();
@@ -13,7 +13,7 @@ export type ConnectErrorCode =
13
13
  | "pod_unreachable"
14
14
  /** A Synap pod, but too old for app sign-in (no CIMD, no `iss`, no S256). */
15
15
  | "pod_unsupported"
16
- /** Connecting through Synap Cloud is not available yet. */
16
+ /** Connecting through Synap Cloud is not configured for this app (no Control Plane URL). */
17
17
  | "cp_unavailable"
18
18
  /** The person said no on the consent screen. */
19
19
  | "denied"
@@ -29,6 +29,12 @@ export type ConnectErrorCode =
29
29
  | "expired"
30
30
  /** The pod refused the connection's refresh token while it should still live. */
31
31
  | "revoked"
32
+ /**
33
+ * The pod answered a redirect where it must answer itself (a moved pod
34
+ * answers 308 from its old name for a while): connect again with its new
35
+ * address. The new address is never taken from the redirect.
36
+ */
37
+ | "pod_moved"
32
38
  /** The pod answered something the SDK cannot use. */
33
39
  | "server_error";
34
40
 
@@ -69,6 +75,7 @@ export const CONNECT_ERROR_CODES: readonly ConnectErrorCode[] = [
69
75
  "rate_limited",
70
76
  "expired",
71
77
  "revoked",
78
+ "pod_moved",
72
79
  "server_error",
73
80
  ];
74
81
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The OAuth 2.1 authorization-code flow of a pod app (spec 17 §6.4-§6.7), as
2
+ * The OAuth 2.1 authorization-code flow of a pod app, as
3
3
  * plain functions over a SEALED transaction string. `createPodAppAuth` wires
4
4
  * them to cookies and route handlers; use these directly for another store.
5
5
  *
@@ -18,15 +18,19 @@
18
18
  import { ConnectError, fromOAuthError, retryAfterSeconds } from "./errors";
19
19
  import { constantTimeEqual, pkceChallenge, randomToken } from "./encoding";
20
20
  import { discoverPod, type DiscoverOptions } from "./discovery";
21
+ import { readCapped, timeoutSignal } from "./http";
22
+ import { LOOPBACK_HOSTS } from "./rules";
21
23
  import { seal, unseal, type AppSecret } from "./seal";
22
- import type { PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
24
+ import type { PodActsAs, PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
23
25
 
24
26
  /** How long a started sign-in stays valid. */
25
27
  export const TRANSACTION_TTL_MS = 10 * 60 * 1000;
26
- /** The refresh token's idle life when the pod does not say (spec 17 F5: 30 days). */
28
+ /** The refresh token's idle life when the pod does not say (the pod's: 30 days). */
27
29
  export const DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1000;
28
30
  const DEFAULT_ACCESS_TTL_SEC = 3600;
29
31
  const TIMEOUT_MS = 10_000;
32
+ /** A token response is a few KB; more is not a token response. */
33
+ const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
30
34
 
31
35
  /** A sealed `ConnectTransaction`: opaque, safe to put in an HttpOnly cookie. */
32
36
  export type SealedTransaction = string & { readonly __sealedTransaction: true };
@@ -66,6 +70,12 @@ export interface StartConnectOptions extends FlowOptions {
66
70
  spaceHint?: string | null;
67
71
  /** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
68
72
  next?: string | null;
73
+ /**
74
+ * `"consent"`: the person asked to see the consent screen again (to widen or
75
+ * narrow what the app may touch). Sent as OIDC `prompt=consent`; the sign-in
76
+ * is otherwise the same (fresh PKCE verifier and state).
77
+ */
78
+ prompt?: "consent" | null;
69
79
  allowInsecureLoopback?: boolean;
70
80
  }
71
81
 
@@ -114,6 +124,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
114
124
  url.searchParams.set("code_challenge_method", "S256");
115
125
  if (resource) url.searchParams.set("resource", resource);
116
126
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
127
+ if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
117
128
 
118
129
  const tx: ConnectTransaction = {
119
130
  v: 1,
@@ -233,20 +244,30 @@ async function tokenRequest(
233
244
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
234
245
  body: body.toString(),
235
246
  redirect: "manual",
236
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
247
+ signal: timeoutSignal(TIMEOUT_MS),
237
248
  });
238
249
  } catch {
239
250
  throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
240
251
  }
252
+ if (res.status >= 300 && res.status < 400) {
253
+ await res.body?.cancel().catch(() => {});
254
+ throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
255
+ }
241
256
  if (res.status === 429) {
242
257
  throw new ConnectError("rate_limited", "The pod asked to slow down", {
243
258
  retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
244
259
  });
245
260
  }
246
261
  if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
262
+ const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
263
+ if (!read.ok) {
264
+ throw read.reason === "too_large"
265
+ ? new ConnectError("server_error", "The token endpoint's answer is too large")
266
+ : new ConnectError("pod_unreachable", "The token endpoint's answer broke off");
267
+ }
247
268
  let json: unknown;
248
269
  try {
249
- json = await res.json();
270
+ json = JSON.parse(read.text);
250
271
  } catch {
251
272
  throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
252
273
  }
@@ -263,9 +284,46 @@ function parseGrant(value: unknown): PodGrant[] | null {
263
284
  const out: PodGrant[] = [];
264
285
  for (const g of value) {
265
286
  if (!g || typeof g !== "object") return null;
266
- const { permission, workspaceId, workspaceName } = g as Record<string, unknown>;
287
+ const { permission, workspaceId, workspaceName, templateSlug } = g as Record<string, unknown>;
267
288
  if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
268
- out.push({ permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" });
289
+ const row: PodGrant = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
290
+ // The space's template, omitted when it came from none.
291
+ if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
292
+ out.push(row);
293
+ }
294
+ return out;
295
+ }
296
+
297
+ /**
298
+ * `synap_acts_as`: read from EVERY token response, never carried
299
+ * over from the previous one, so a pod that stops saying "person" (or never
300
+ * did) gives an agent connection.
301
+ */
302
+ function parseActsAs(value: unknown): PodActsAs | undefined {
303
+ return value === "person" || value === "agent" ? value : undefined;
304
+ }
305
+
306
+ const APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
307
+
308
+ /**
309
+ * `synap_app` (Synap extension): the pod's id for this app connection and its
310
+ * page for it. The page is on the pod's console, which may be another host
311
+ * than the issuer, so it is kept when it is an absolute https URL (http only on
312
+ * loopback) without credentials; anything else is dropped.
313
+ */
314
+ function parseApp(value: unknown): { appId?: string; manageUrl?: string } {
315
+ if (!value || typeof value !== "object") return {};
316
+ const { id, manageUrl } = value as Record<string, unknown>;
317
+ const out: { appId?: string; manageUrl?: string } = {};
318
+ if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
319
+ if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
320
+ try {
321
+ const url = new URL(manageUrl);
322
+ const secure = url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
323
+ if (secure && !url.username && !url.password) out.manageUrl = url.href;
324
+ } catch {
325
+ // not a URL: no link
326
+ }
269
327
  }
270
328
  return out;
271
329
  }
@@ -278,7 +336,9 @@ function parseUser(value: unknown): PodUser | null {
278
336
 
279
337
  /**
280
338
  * A token response → a connection. On a refresh, `previous` supplies what the
281
- * response may omit (grant, user). A refresh response WITHOUT a new
339
+ * response may omit (grant, user, app; never `actsAs`). `expires_in` is read as sent: a refresh
340
+ * answered within the pod's reuse grace hands back the live key the
341
+ * first redeem got, with less than an hour left. A refresh response WITHOUT a new
282
342
  * refresh_token drops the old one: with single-use rotation, resending a
283
343
  * consumed token reads as theft and revokes the whole family.
284
344
  */
@@ -322,6 +382,13 @@ export function parseTokenResponse(
322
382
  user,
323
383
  connectedAt: ctx.previous?.connectedAt ?? ctx.now,
324
384
  };
385
+ const app = parseApp(body.synap_app);
386
+ const appId = app.appId ?? ctx.previous?.appId;
387
+ const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
388
+ if (appId !== undefined) connection.appId = appId;
389
+ if (manageUrl !== undefined) connection.manageUrl = manageUrl;
390
+ const actsAs = parseActsAs(body.synap_acts_as);
391
+ if (actsAs !== undefined) connection.actsAs = actsAs;
325
392
  if (typeof refresh_token === "string" && refresh_token !== "") {
326
393
  connection.refreshToken = refresh_token;
327
394
  const idle =
@@ -335,7 +402,7 @@ export function parseTokenResponse(
335
402
 
336
403
  /**
337
404
  * How long a rotation's result is remembered for its SPENT refresh token.
338
- * The pod (PB3) refuses a concurrent second redeem softly, but a token
405
+ * The pod refuses a concurrent second redeem softly, but a token
339
406
  * presented AGAIN after its use is a reuse: the whole family and the live
340
407
  * access key are revoked. A browser easily sends that replay: parallel
341
408
  * requests, a second tab, a response it had not stored yet, all still
@@ -361,7 +428,7 @@ function pruneRotations(now: number): void {
361
428
 
362
429
  /**
363
430
  * Exchange the refresh token for a new access token and the rotated refresh
364
- * token (PB3: single-use, 30 days from issue, reach re-derived by the pod).
431
+ * token (single-use, 30 days from issue, its reach re-derived by the pod).
365
432
  *
366
433
  * SINGLE-FLIGHT per connection within this process: concurrent callers share
367
434
  * one request, and for `ROTATION_MEMORY_MS` after it a caller still holding
@@ -375,7 +442,7 @@ function pruneRotations(now: number): void {
375
442
  * (invalid_grant while it should live: spent, revoked, the app removed or
376
443
  * narrowed to nothing, or a concurrent redeem lost), `misconfigured`
377
444
  * (invalid_client, unauthorized_client), `rate_limited`, `pod_unreachable`,
378
- * `server_error`.
445
+ * `pod_moved` (the token endpoint answered a redirect), `server_error`.
379
446
  */
380
447
  export function refreshConnection(connection: PodConnection, opts: FlowOptions = {}): Promise<PodConnection> {
381
448
  const clock = opts.now ?? Date.now;
@@ -438,8 +505,9 @@ export async function revokeConnection(connection: PodConnection, opts: FlowOpti
438
505
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
439
506
  body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
440
507
  redirect: "manual",
441
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
508
+ signal: timeoutSignal(TIMEOUT_MS),
442
509
  });
510
+ await res.body?.cancel().catch(() => {});
443
511
  if (res.status !== 200) revoked = false;
444
512
  } catch {
445
513
  revoked = false;