@synap-core/sdk 1.4.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 (50) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +136 -10
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +412 -64
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +400 -32
  10. package/dist/connect.d.ts +400 -32
  11. package/dist/connect.js +407 -65
  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 +234 -12
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +122 -27
  22. package/dist/testing.d.ts +122 -27
  23. package/dist/testing.js +230 -12
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
  26. package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
  27. package/package.json +30 -6
  28. package/src/connect/auth.ts +156 -27
  29. package/src/connect/client.ts +374 -34
  30. package/src/connect/cloud.ts +14 -10
  31. package/src/connect/discovery.ts +66 -32
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +7 -0
  34. package/src/connect/flow.ts +42 -14
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +254 -0
  37. package/src/connect/index.ts +60 -42
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/store.ts +9 -5
  40. package/src/connect/types.ts +28 -4
  41. package/src/public/client.ts +14 -2
  42. package/src/public/safe-markdown.ts +13 -0
  43. package/src/public/share-model.ts +6 -2
  44. package/src/public/site-model.ts +5 -3
  45. package/src/testing/auth-server.ts +239 -14
  46. package/src/testing/cloud.ts +3 -4
  47. package/src/testing/fixtures.ts +26 -0
  48. package/src/testing/index.ts +8 -6
  49. package/src/testing/seed.ts +120 -0
  50. package/dist/chunk-J7XHAAFJ.js.map +0 -1
@@ -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,13 +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 ./cloud.ts.
20
+ * Connecting through Synap Cloud (the Control Plane's pod chooser) is ./cloud.ts.
19
21
  */
20
22
  import { ConnectError, retryAfterSeconds } from "./errors";
23
+ import { readCapped, timeoutSignal } from "./http";
24
+ import { CLOUD_STATE_RE, LOOPBACK_HOSTS, SPACE_HINT_RE } from "./rules";
21
25
  import type { PodIssuer } from "./types";
22
26
 
23
27
  const METADATA_PATH = "/.well-known/oauth-authorization-server";
@@ -31,8 +35,6 @@ export interface DiscoverOptions {
31
35
  now?: () => number;
32
36
  }
33
37
 
34
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
35
-
36
38
  function isPrivateIpv4(host: string): boolean {
37
39
  const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
38
40
  if (!m) return false;
@@ -102,10 +104,14 @@ function sameOriginEndpoint(value: unknown, issuer: string): string | null {
102
104
  }
103
105
 
104
106
  async function readJsonCapped(res: Response): Promise<unknown> {
105
- const text = await res.text();
106
- 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
+ }
107
113
  try {
108
- return JSON.parse(text);
114
+ return JSON.parse(read.text);
109
115
  } catch {
110
116
  throw new ConnectError("not_a_pod", "Metadata is not JSON");
111
117
  }
@@ -120,18 +126,25 @@ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<Po
120
126
  method: "GET",
121
127
  headers: { Accept: "application/json" },
122
128
  redirect: "manual",
123
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
129
+ signal: timeoutSignal(TIMEOUT_MS),
124
130
  });
125
131
  } catch {
126
132
  throw new ConnectError("pod_unreachable", "The pod did not answer");
127
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
+ }
128
138
  if (res.status === 429) {
129
139
  throw new ConnectError("rate_limited", "The pod asked to slow down", {
130
140
  retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
131
141
  });
132
142
  }
133
143
  if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
134
- 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
+ }
135
148
  const doc = await readJsonCapped(res);
136
149
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
137
150
  const m = doc as Record<string, unknown>;
@@ -165,32 +178,53 @@ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<Po
165
178
  };
166
179
  }
167
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
+
168
199
  /**
169
200
  * A typed pod address (or a launch `iss`) → its validated authorization server.
170
- * Tries the address, then `pod.<host>` (so "antoine.synap.live" finds
171
- * "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).
172
206
  */
173
207
  export async function discoverPod(input: string, opts: DiscoverOptions = {}): Promise<PodIssuer> {
174
208
  const first = canonicalPodUrl(input, opts);
209
+ let firstError: unknown;
175
210
  try {
176
211
  return await discoverIssuer(first, opts);
177
212
  } catch (err) {
178
- const url = new URL(first);
179
- const retry =
180
- err instanceof ConnectError &&
181
- (err.code === "not_a_pod" || err.code === "pod_unreachable") &&
182
- !url.hostname.startsWith("pod.") &&
183
- url.pathname === "/" &&
184
- url.hostname.includes(".");
185
- if (!retry) throw err;
186
- 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)) {
187
220
  try {
188
- return await discoverIssuer(second, opts);
189
- } catch (err2) {
221
+ return await discoverIssuer(candidate, opts);
222
+ } catch (err) {
190
223
  // A pod that is there but too old says more than "nothing answered".
191
- throw err2 instanceof ConnectError && err2.code === "pod_unsupported" ? err2 : err;
224
+ if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
192
225
  }
193
226
  }
227
+ throw tooOld ?? firstError;
194
228
  }
195
229
 
196
230
  /**
@@ -217,11 +251,11 @@ export function safeNextPath(value: string | null | undefined, appOrigin: string
217
251
  }
218
252
 
219
253
  export interface LaunchHint {
220
- /** 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. */
221
255
  pod: string | null;
222
256
  /** Where to land after connecting (`target_link_uri`), same origin only. */
223
257
  next: string | null;
224
- /** 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. */
225
259
  spaceHint: string | null;
226
260
  /**
227
261
  * The `state` of a Synap Cloud return (the CP pod chooser answers
@@ -235,7 +269,7 @@ export interface LaunchHint {
235
269
  }
236
270
 
237
271
  /**
238
- * 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
239
273
  * opens the app's `initiate_login_uri?iss=…&target_link_uri=…`. One parser for
240
274
  * the browser launch and the Synap Cloud chooser's return (`cloudState`).
241
275
  */
@@ -255,9 +289,9 @@ export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allow
255
289
  return {
256
290
  pod,
257
291
  next: safeNextPath(params.get("target_link_uri"), appOrigin),
258
- spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
292
+ spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
259
293
  // A browser launch never carries a state: any well-formed one is a Synap Cloud return
260
294
  // (a pick: iss + state; a cancel: state + error=access_denied, no iss).
261
- cloudState: state && /^[A-Za-z0-9._~-]{16,256}$/.test(state) ? state : null,
295
+ cloudState: state && CLOUD_STATE_RE.test(state) ? state : null,
262
296
  };
263
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();
@@ -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 };
@@ -240,20 +244,30 @@ async function tokenRequest(
240
244
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
241
245
  body: body.toString(),
242
246
  redirect: "manual",
243
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
247
+ signal: timeoutSignal(TIMEOUT_MS),
244
248
  });
245
249
  } catch {
246
250
  throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
247
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
+ }
248
256
  if (res.status === 429) {
249
257
  throw new ConnectError("rate_limited", "The pod asked to slow down", {
250
258
  retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
251
259
  });
252
260
  }
253
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
+ }
254
268
  let json: unknown;
255
269
  try {
256
- json = await res.json();
270
+ json = JSON.parse(read.text);
257
271
  } catch {
258
272
  throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
259
273
  }
@@ -270,15 +284,26 @@ function parseGrant(value: unknown): PodGrant[] | null {
270
284
  const out: PodGrant[] = [];
271
285
  for (const g of value) {
272
286
  if (!g || typeof g !== "object") return null;
273
- const { permission, workspaceId, workspaceName } = g as Record<string, unknown>;
287
+ const { permission, workspaceId, workspaceName, templateSlug } = g as Record<string, unknown>;
274
288
  if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
275
- 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);
276
293
  }
277
294
  return out;
278
295
  }
279
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
+
280
306
  const APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
281
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
282
307
 
283
308
  /**
284
309
  * `synap_app` (Synap extension): the pod's id for this app connection and its
@@ -311,8 +336,8 @@ function parseUser(value: unknown): PodUser | null {
311
336
 
312
337
  /**
313
338
  * A token response → a connection. On a refresh, `previous` supplies what the
314
- * response may omit (grant, user, app). `expires_in` is read as sent: a refresh
315
- * answered within the pod's reuse grace (PB3b) hands back the live key the
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
316
341
  * first redeem got, with less than an hour left. A refresh response WITHOUT a new
317
342
  * refresh_token drops the old one: with single-use rotation, resending a
318
343
  * consumed token reads as theft and revokes the whole family.
@@ -362,6 +387,8 @@ export function parseTokenResponse(
362
387
  const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
363
388
  if (appId !== undefined) connection.appId = appId;
364
389
  if (manageUrl !== undefined) connection.manageUrl = manageUrl;
390
+ const actsAs = parseActsAs(body.synap_acts_as);
391
+ if (actsAs !== undefined) connection.actsAs = actsAs;
365
392
  if (typeof refresh_token === "string" && refresh_token !== "") {
366
393
  connection.refreshToken = refresh_token;
367
394
  const idle =
@@ -375,7 +402,7 @@ export function parseTokenResponse(
375
402
 
376
403
  /**
377
404
  * How long a rotation's result is remembered for its SPENT refresh token.
378
- * The pod (PB3) refuses a concurrent second redeem softly, but a token
405
+ * The pod refuses a concurrent second redeem softly, but a token
379
406
  * presented AGAIN after its use is a reuse: the whole family and the live
380
407
  * access key are revoked. A browser easily sends that replay: parallel
381
408
  * requests, a second tab, a response it had not stored yet, all still
@@ -401,7 +428,7 @@ function pruneRotations(now: number): void {
401
428
 
402
429
  /**
403
430
  * Exchange the refresh token for a new access token and the rotated refresh
404
- * 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).
405
432
  *
406
433
  * SINGLE-FLIGHT per connection within this process: concurrent callers share
407
434
  * one request, and for `ROTATION_MEMORY_MS` after it a caller still holding
@@ -415,7 +442,7 @@ function pruneRotations(now: number): void {
415
442
  * (invalid_grant while it should live: spent, revoked, the app removed or
416
443
  * narrowed to nothing, or a concurrent redeem lost), `misconfigured`
417
444
  * (invalid_client, unauthorized_client), `rate_limited`, `pod_unreachable`,
418
- * `server_error`.
445
+ * `pod_moved` (the token endpoint answered a redirect), `server_error`.
419
446
  */
420
447
  export function refreshConnection(connection: PodConnection, opts: FlowOptions = {}): Promise<PodConnection> {
421
448
  const clock = opts.now ?? Date.now;
@@ -478,8 +505,9 @@ export async function revokeConnection(connection: PodConnection, opts: FlowOpti
478
505
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
479
506
  body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
480
507
  redirect: "manual",
481
- signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
508
+ signal: timeoutSignal(TIMEOUT_MS),
482
509
  });
510
+ await res.body?.cancel().catch(() => {});
483
511
  if (res.status !== 200) revoked = false;
484
512
  } catch {
485
513
  revoked = false;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reading answers from hosts the SDK does not control: a pod a visitor typed,
3
+ * its token endpoint, its Hub API. Bounded in time (`timeoutSignal`) and in
4
+ * bytes (`readCapped`): the bytes are counted on the stream, so an answer past
5
+ * the cap is cancelled there, never buffered whole first.
6
+ */
7
+
8
+ /** An abort signal after `ms`, where the runtime has `AbortSignal.timeout` (workers and edge runtimes may not). */
9
+ export function timeoutSignal(ms: number): AbortSignal | undefined {
10
+ return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined;
11
+ }
12
+
13
+ export type CappedRead = { ok: true; text: string } | { ok: false; reason: "too_large" | "unreadable" };
14
+
15
+ /** The body as UTF-8 text, at most `maxBytes` bytes. */
16
+ export async function readCapped(res: Response, maxBytes: number): Promise<CappedRead> {
17
+ const declared = Number(res.headers.get("content-length"));
18
+ if (Number.isFinite(declared) && declared > maxBytes) {
19
+ await res.body?.cancel().catch(() => {});
20
+ return { ok: false, reason: "too_large" };
21
+ }
22
+ if (!res.body) return { ok: true, text: "" };
23
+ const reader = res.body.getReader();
24
+ const decoder = new TextDecoder();
25
+ let total = 0;
26
+ let text = "";
27
+ try {
28
+ for (;;) {
29
+ const { done, value } = await reader.read();
30
+ if (done) break;
31
+ total += value.byteLength;
32
+ if (total > maxBytes) {
33
+ await reader.cancel().catch(() => {});
34
+ return { ok: false, reason: "too_large" };
35
+ }
36
+ text += decoder.decode(value, { stream: true });
37
+ }
38
+ } catch {
39
+ return { ok: false, reason: "unreadable" };
40
+ }
41
+ return { ok: true, text: text + decoder.decode() };
42
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Wire shapes of the Hub REST doors `createPodClient`'s typed methods read.
3
+ *
4
+ * DRIFT RISK: hand-written from synap-backend main (2026-10-22), each from the
5
+ * route itself, never from `@synap-core/hub-rest-client` alone:
6
+ * - entities: `routers/hub-protocol/rest/entities.ts` (`GET /entities`,
7
+ * `GET /entities/:id` → `entityToWire`, `_codecs/entity.ts`
8
+ * `WireEntitySchema`; `GET /entities/:id/connections` → `relations.ts`
9
+ * `getConnections`, `services/object-graph/entity-connections.ts`);
10
+ * - documents: `rest/documents.ts` `GET /documents/:id` →
11
+ * `services/document-patch/read-document.ts` `projectAgentDocument`;
12
+ * - focus sessions: `rest/focus-sessions.ts` `GET /focus-sessions` (raw rows +
13
+ * `triage` + `kind`), slots `services/focus-sessions/update-session.ts`
14
+ * `expectedOutputWireSchema`;
15
+ * - files: `rest/entities.ts` `GET /files/:id/url` → `file-upload.ts`
16
+ * `signFileEntityUrl`;
17
+ * - approve: `rest/proposals.ts` `POST /proposals/:id/approve`.
18
+ * Fields the SDK does not read stay on the objects (index signatures): the pod
19
+ * sends more than is typed here. The mock pod (`@synap-core/sdk/testing`)
20
+ * answers these shapes; `src/__tests__/connect.typed.test.ts` drives both.
21
+ */
22
+
23
+ /** One entity, as `GET /entities` and `GET /entities/:id` answer it (`WireEntitySchema`). */
24
+ export interface PodEntity {
25
+ id: string;
26
+ profileSlug: string;
27
+ /** Deprecated alias of `profileSlug`, still sent. */
28
+ type?: string;
29
+ title?: string | null;
30
+ preview?: string | null;
31
+ description?: string | null;
32
+ properties: Record<string, unknown>;
33
+ systemData?: Record<string, unknown>;
34
+ userId: string;
35
+ workspaceId?: string | null;
36
+ documentId?: string | null;
37
+ version?: number;
38
+ /** ISO 8601. */
39
+ createdAt?: string;
40
+ updatedAt?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ export interface ListEntitiesOptions {
45
+ /** One granted space. Omitted: every space the connection can read. */
46
+ workspaceId?: string;
47
+ /** One kind, e.g. `post`. */
48
+ profileSlug?: string;
49
+ /** 1-1000 (the pod's bounds); the pod's default is 20. */
50
+ limit?: number;
51
+ offset?: number;
52
+ /** e.g. `updatedAt:desc`. */
53
+ sort?: string;
54
+ /** With `workspaceId`: also the person's pod-wide rows (the pod's default: true). */
55
+ includePodWide?: boolean;
56
+ }
57
+
58
+ export type PodConnectionSource = "graph" | "property" | "thread" | "context_channel" | "focus_session";
59
+
60
+ /** One edge of `GET /entities/:id/connections` (`EntityConnection`). */
61
+ export interface PodEntityConnection {
62
+ /** The other end: an entity, or the channel / session for those sources. */
63
+ entityId: string;
64
+ /** The other entity's row (title, `type` = its kind, workspaceId, facetSlugs, …); null for channels and sessions. */
65
+ entity: {
66
+ title?: string | null;
67
+ type?: string | null;
68
+ workspaceId?: string | null;
69
+ facetSlugs?: string[] | null;
70
+ [key: string]: unknown;
71
+ } | null;
72
+ label: string;
73
+ direction: "outgoing" | "incoming" | "structural";
74
+ source: PodConnectionSource;
75
+ relationId?: string;
76
+ relationType?: string;
77
+ storedAs?: "link";
78
+ propertySlug?: string;
79
+ propertyLabel?: string;
80
+ channelId?: string;
81
+ channelRelationshipType?: string;
82
+ channelTitle?: string | null;
83
+ channelWorkspaceId?: string | null;
84
+ focusSessionId?: string;
85
+ focusSessionGoal?: string;
86
+ focusSessionStatus?: string;
87
+ focusSessionWorkspaceId?: string | null;
88
+ /** ISO 8601, or null (a property link has no date). */
89
+ createdAt?: string | null;
90
+ [key: string]: unknown;
91
+ }
92
+
93
+ export interface PodEntityConnections {
94
+ connections: PodEntityConnection[];
95
+ counts: {
96
+ total: number;
97
+ graph: number;
98
+ structural: number;
99
+ threads: number;
100
+ contextChannels: number;
101
+ focusSessions: number;
102
+ };
103
+ }
104
+
105
+ export interface GetEntityConnectionsOptions {
106
+ /** A space lens; omitted = everything the connection can read. */
107
+ workspaceId?: string;
108
+ /** Per source, 1-200 (the pod's default: 50). */
109
+ limit?: number;
110
+ }
111
+
112
+ /** A document's top-level section (`listDocumentSections`). */
113
+ export interface PodDocumentSection {
114
+ id: string;
115
+ /** Who owns it (a person's section is not an agent's to change). */
116
+ owner: string;
117
+ title: string | null;
118
+ author: string | null;
119
+ }
120
+
121
+ /** `GET /documents/:id` → `document` (`projectAgentDocument`). */
122
+ export interface PodDocument {
123
+ id: string;
124
+ title: string;
125
+ /** `markdown`, `text`, `html`, … */
126
+ type: string;
127
+ language: string | null;
128
+ workspaceId: string | null;
129
+ format: "raw" | "readable";
130
+ /** Markdown; `readable` replaces embeds by their fallback text. */
131
+ content: string;
132
+ /** Pass back as `baseRevision` on a guarded edit. */
133
+ revision: number;
134
+ version: number;
135
+ sections: PodDocumentSection[];
136
+ /** What will not render; null when the check could not run (`diagnosticsError`). */
137
+ diagnostics: unknown[] | null;
138
+ diagnosticsError?: string;
139
+ updatedAt: string;
140
+ createdAt: string;
141
+ [key: string]: unknown;
142
+ }
143
+
144
+ export interface GetDocumentOptions {
145
+ /** `raw` (the stored markdown, the pod's default) or `readable`. */
146
+ format?: "raw" | "readable";
147
+ }
148
+
149
+ /** `focus_sessions.status` (`@synap-core/types/focus-sessions` SESSION_STATUSES). */
150
+ export type PodSessionStatus = "active" | "paused" | "forming" | "scheduled" | "closed" | "failed" | "cancelled" | "stale";
151
+
152
+ /**
153
+ * One declared deliverable of a session (`expectedOutputWireSchema`). A slot
154
+ * with `owner: "human"` is waiting on the person: `blockedReason` and `why` say
155
+ * for what, `ask` how they can answer (confirm, choose, form, act, provide).
156
+ */
157
+ export interface PodSessionSlot {
158
+ kind: string;
159
+ label: string;
160
+ key?: string;
161
+ optional?: boolean;
162
+ status?: "pending" | "done";
163
+ /** Absent = `agent`. */
164
+ owner?: "human" | "agent";
165
+ blockedReason?: string;
166
+ why?: string;
167
+ /** ISO 8601, stamped by the pod with `owner: "human"`. */
168
+ owedSince?: string;
169
+ /** How the person can answer (`@synap-core/types/ask`), or null. */
170
+ ask?: unknown;
171
+ answer?: {
172
+ text: string;
173
+ messageId: string | null;
174
+ answeredBy: string;
175
+ answeredAt: string;
176
+ question?: string;
177
+ value?: unknown;
178
+ [key: string]: unknown;
179
+ };
180
+ retiredAt?: string;
181
+ [key: string]: unknown;
182
+ }
183
+
184
+ /** One row of `GET /focus-sessions`. `expectedOutputs` is always an array here (the SDK drops anything else). */
185
+ export interface PodFocusSession {
186
+ id: string;
187
+ workspaceId: string | null;
188
+ projectId: string | null;
189
+ userId: string;
190
+ title: string | null;
191
+ goal: string;
192
+ status: PodSessionStatus | (string & {});
193
+ templateId: string | null;
194
+ expectedOutputs: PodSessionSlot[];
195
+ channelId: string | null;
196
+ progress: number | null;
197
+ currentStage: string | null;
198
+ agentIds: string[];
199
+ closedAt: string | null;
200
+ metadata: unknown;
201
+ startedAt: string;
202
+ createdAt: string;
203
+ updatedAt: string;
204
+ /** Projected by the list door: `work`, `run` (a playbook or automation ran it) or `receipt`. */
205
+ kind?: "work" | "run" | "receipt";
206
+ /** Projected by the list door: an agent-started session the person has not accepted yet. */
207
+ triage?: { pending: boolean; acceptedAt: string | null; acceptedBy: string | null };
208
+ [key: string]: unknown;
209
+ }
210
+
211
+ export interface ListFocusSessionsOptions {
212
+ /** A space; `workspaceId` or `projectId` is required (the pod's rule). */
213
+ workspaceId?: string;
214
+ projectId?: string;
215
+ /** Default `all` (the pod's default on this door). */
216
+ status?: PodSessionStatus | "all";
217
+ /** 1-50 (the pod's bounds); the pod's default is 20. */
218
+ limit?: number;
219
+ /** Triage lens: `all` (default here), `default` (hides unaccepted agent sessions), `triage`. */
220
+ lens?: "all" | "default" | "triage";
221
+ /** Population lens: `all` (default here), `work`, `run`, `receipt`. */
222
+ kind?: "all" | "work" | "run" | "receipt";
223
+ /** Sessions about one thing. */
224
+ subjectEntityId?: string;
225
+ }
226
+
227
+ /** `GET /files/:id/url`: a signed GET for a file entity's blob. */
228
+ export interface PodFileUrl {
229
+ /** Served as an `attachment`, with the stored type. */
230
+ url: string;
231
+ /** ISO 8601; the signature's end (about an hour). */
232
+ expiresAt: string;
233
+ /** Decide drawability on this, never on the URL; null when unknown. */
234
+ mimeType: string | null;
235
+ }
236
+
237
+ /** `POST /proposals/:id/approve`. */
238
+ export interface PodApproval {
239
+ proposalId: string;
240
+ /** The row the approval produced or linked, when it names one. */
241
+ primaryId?: string;
242
+ linked?: number;
243
+ /** The proposal after the approval (status, execution result), when the pod could read it back. */
244
+ proposal: Record<string, unknown> | null;
245
+ }
246
+
247
+ export interface ApproveProposalOptions {
248
+ /**
249
+ * The proposal's space, when known: refused locally unless the connection
250
+ * holds `proposal.approve` there. Omitted: needs it in some space, and the
251
+ * pod checks the proposal's own space.
252
+ */
253
+ workspaceId?: string;
254
+ }