@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
@@ -1,23 +1,58 @@
1
1
  /**
2
2
  * A server-side Hub REST client for one PodConnection: `<pod>/api/hub/*` with
3
3
  * the connection's Bearer token. Results, not throws: `{ ok: true, … }` or
4
- * `{ ok: false, error }` with a typed `code`.
4
+ * `{ ok: false, error }` with a typed `code`, a malformed path included.
5
5
  *
6
6
  * - `expired`: the access token is past its expiry here; nothing was sent
7
7
  * (refresh in `proxy.ts` / a route handler, or connect again);
8
- * - `unauthorized`: the pod refused the token (revoked, or expired there);
8
+ * - `unauthorized`: the pod refused the token. With a `renew` (a client that
9
+ * can persist cookies) the connection is renewed once and the request
10
+ * retried once first; `unauthorized` then means the pod refused the
11
+ * connection itself. Without one, `refreshable` says a renewal may help;
9
12
  * - `forbidden`: not granted; `door_unsupported`: a door a space-scoped app
10
- * token can never use (403 GRANT_DOOR_UNSUPPORTED, spec 17 §5);
11
- * - `rate_limited` with `retryAfterSec`; `pod_unreachable` (network, 502-504).
13
+ * token can never use (403 GRANT_DOOR_UNSUPPORTED);
14
+ * - `rate_limited` with `retryAfterSec`; `pod_unreachable` (network, a
15
+ * timeout, 502-504); `pod_moved` (a redirect: connect again);
16
+ * - `invalid`: the pod refused the input (400/422), or the SDK did (a path
17
+ * that could leave the Hub API: encode each segment, `encodeURIComponent`).
12
18
  *
13
19
  * Writes are governed: `proposed` is SUCCESS (queued for the person's review,
14
- * never retry it), with `reviewUrl` on the pod.
20
+ * never retry it), with `reviewUrl` on the pod. That is the rule of an AGENT
21
+ * connection (`actsAs: "agent"`, every app but an official Synap one): its
22
+ * writes are proposals. A PERSON connection (`actsAs: "person"`, an official
23
+ * Synap app) writes as the person, directly: its writes apply and come back
24
+ * `applied`, with no review step and no proposal. The result
25
+ * still reports what the pod did, so a `proposed` answer to a person (a space
26
+ * that reviews even its members' changes) is still success, never an error.
15
27
  *
16
- * The token goes to the pod's own origin only: paths are pod-relative, no
17
- * redirect is followed.
28
+ * Typed methods (1.5) for the doors a pod app reads: `listEntities`,
29
+ * `getEntity`, `getEntityConnections`, `getDocument`, `listFocusSessions`,
30
+ * `getFileUrl`, `listPendingProposals`, `approveProposal`. Same results, same
31
+ * codes; a refusal the SDK can decide alone (no grant, an agent approving, a
32
+ * bad id) answers with `status: null` and a `reason`, and sends nothing.
33
+ *
34
+ * The token goes to the pod's own origin only: paths are pod-relative and
35
+ * checked again after URL resolution, no redirect is followed. Each request
36
+ * is bounded: 10 s and 8 MiB by default (`timeoutMs`, `maxResponseBytes`).
18
37
  */
19
- import { retryAfterSeconds } from "./errors";
20
- import type { PodConnection, PodGrant } from "./types";
38
+ import { isConnectError, retryAfterSeconds, type ConnectError } from "./errors";
39
+ import { readCapped, timeoutSignal } from "./http";
40
+ import type {
41
+ ApproveProposalOptions,
42
+ GetDocumentOptions,
43
+ GetEntityConnectionsOptions,
44
+ ListEntitiesOptions,
45
+ ListFocusSessionsOptions,
46
+ PodApproval,
47
+ PodDocument,
48
+ PodEntity,
49
+ PodEntityConnection,
50
+ PodEntityConnections,
51
+ PodFileUrl,
52
+ PodFocusSession,
53
+ PodSessionSlot,
54
+ } from "./hub-types";
55
+ import type { PodActsAs, PodConnection, PodGrant, PodSpace } from "./types";
21
56
 
22
57
  export type PodErrorCode =
23
58
  | "expired"
@@ -29,8 +64,19 @@ export type PodErrorCode =
29
64
  | "conflict"
30
65
  | "rate_limited"
31
66
  | "pod_unreachable"
67
+ /** The pod answered a redirect (a moved pod answers 308 from its old name): connect again at its new address. */
68
+ | "pod_moved"
32
69
  | "server";
33
70
 
71
+ /**
72
+ * Why the SDK refused before sending anything (`status: null`):
73
+ * - `agent_cannot_approve`: an agent connection asked to approve (the pod
74
+ * never lets an agent credential approve; `code: "forbidden"`);
75
+ * - `not_granted`: the connection holds no permission for it (`forbidden`);
76
+ * - `invalid_input`: an id, a path or an option the pod would refuse (`invalid`).
77
+ */
78
+ export type PodRefusalReason = "agent_cannot_approve" | "not_granted" | "invalid_input";
79
+
34
80
  export interface PodError {
35
81
  code: PodErrorCode;
36
82
  /** The HTTP status, or null when nothing was answered. */
@@ -38,6 +84,17 @@ export interface PodError {
38
84
  retryAfterSec?: number;
39
85
  /** The pod's own error text, for logs only. */
40
86
  detail?: string;
87
+ /** Set when the SDK refused locally (1.5). */
88
+ reason?: PodRefusalReason;
89
+ /**
90
+ * On `unauthorized` (1.5): the pod refused the access token, the connection
91
+ * still holds a refresh token, and this client could not renew it (no
92
+ * `renew`). A forced refresh may restore it (a narrow, or a pod-side "Revoke
93
+ * key", ends the key, not the connection): run `refreshIfNeeded(request,
94
+ * { force: true })` where cookies can be set. Absent = the pod refused the
95
+ * connection itself: connect again.
96
+ */
97
+ refreshable?: true;
41
98
  }
42
99
 
43
100
  export type PodResult<T> = { ok: true; data: T } | { ok: false; error: PodError };
@@ -49,17 +106,105 @@ export type PodWriteResult<T> =
49
106
 
50
107
  export type QueryValue = string | number | boolean | null | undefined | ReadonlyArray<string | number | boolean>;
51
108
 
109
+ /** One pending proposal, the pod's summary row (`GET /proposals?view=basic`). */
110
+ export interface PendingProposal {
111
+ id: string;
112
+ proposalType: string;
113
+ targetType: string;
114
+ targetId: string | null;
115
+ workspaceId: string | null;
116
+ /** ISO 8601, as the pod sent it. */
117
+ createdAt: string;
118
+ /** The agent that filed it (this app's agent, or another one), when known. */
119
+ agentUserId: string | null;
120
+ sessionId: string | null;
121
+ summary?: string;
122
+ /** Where the person reviews it: the pod's deep link. */
123
+ reviewUrl: string;
124
+ }
125
+
126
+ export interface PendingProposals {
127
+ /** Every pending proposal the connection can see, not only this page (the pod COUNTs it). */
128
+ total: number;
129
+ proposals: PendingProposal[];
130
+ hasMore: boolean;
131
+ }
132
+
133
+ export interface ListPendingProposalsOptions {
134
+ /** One granted space only. */
135
+ workspaceId?: string;
136
+ /** 1-200, default 20. */
137
+ limit?: number;
138
+ offset?: number;
139
+ /**
140
+ * Only these proposals (at most 100 ids; the pod's `proposalIds` filter,
141
+ * pods that do not filter are filtered here). An empty list matches nothing (answered without a request).
142
+ */
143
+ proposalIds?: readonly string[];
144
+ }
145
+
52
146
  export interface PodClientOptions {
53
147
  fetch?: typeof fetch;
54
148
  now?: () => number;
149
+ /** Per Hub request, default 10 000 ms; a request past it is `pod_unreachable`. */
150
+ timeoutMs?: number;
151
+ /** The most a Hub answer may weigh, default 8 MiB; a bigger one is `server` (the body is not read further). */
152
+ maxResponseBytes?: number;
153
+ /**
154
+ * Renew the connection after the pod refused its access token (401) while it
155
+ * holds a refresh token: rotate it, PERSIST the result (the old refresh token
156
+ * is spent once the pod answers), then return it; throw the refresh's
157
+ * `ConnectError` when it fails. The client calls it at most once (concurrent
158
+ * 401s share the call), retries the refused request once with the result, and
159
+ * uses it from then on. `PodAppAuth.client(connection, { persist })` builds
160
+ * one. Without it a 401 stays `unauthorized` (with `refreshable`).
161
+ */
162
+ renew?: (connection: PodConnection) => Promise<PodConnection>;
55
163
  }
56
164
 
57
165
  export interface PodClient {
58
166
  podUrl: string;
59
- user: PodConnection["user"];
60
- grant: PodGrant[];
167
+ /** The connection's, or the renewed one's after a 401 (`renew`). */
168
+ readonly user: PodConnection["user"];
169
+ readonly grant: PodGrant[];
170
+ /** Who the token acts as (`connectionActsAs`): `"agent"` unless the pod said `"person"`. */
171
+ readonly actsAs: PodActsAs;
61
172
  /** Whether the connection was granted `permission` (in `workspaceId` when given). */
62
173
  can(permission: string, workspaceId?: string): boolean;
174
+ /** The granted spaces, each with its permissions (`spaces(connection)`). */
175
+ spaces(): PodSpace[];
176
+ /**
177
+ * "Waiting for review: N". The person's pending proposals in the spaces this
178
+ * connection may read proposals in (any agent's, not only this app's), from
179
+ * the pod's seamed `GET /proposals` door. Needs the `proposal.read`
180
+ * permission (ask for it in `requests`): without it the pod would answer an
181
+ * empty list, so this answers `forbidden` instead, without a request.
182
+ */
183
+ listPendingProposals(opts?: ListPendingProposalsOptions): Promise<PodResult<PendingProposals>>;
184
+ /** `GET /entities` (no full-text `q`: a space-scoped token may list, never search). */
185
+ listEntities(opts?: ListEntitiesOptions): Promise<PodResult<PodEntity[]>>;
186
+ /** `GET /entities/:id`; `not_found` when it does not exist or is not readable. */
187
+ getEntity(id: string): Promise<PodResult<PodEntity>>;
188
+ /** `GET /entities/:id/connections`: its relations, property links, threads and sessions. */
189
+ getEntityConnections(id: string, opts?: GetEntityConnectionsOptions): Promise<PodResult<PodEntityConnections>>;
190
+ /** `GET /documents/:id`: content, revision, sections. */
191
+ getDocument(id: string, opts?: GetDocumentOptions): Promise<PodResult<PodDocument>>;
192
+ /**
193
+ * `GET /focus-sessions` in a space (or a project), newest first, with their
194
+ * slots (`expectedOutputs`; `humanSlots(session)` = what waits on the person).
195
+ * `invalid` without a request when neither `workspaceId` nor `projectId` is given.
196
+ */
197
+ listFocusSessions(opts: ListFocusSessionsOptions): Promise<PodResult<PodFocusSession[]>>;
198
+ /** `GET /files/:id/url`: a signed download URL for a file entity (about an hour). */
199
+ getFileUrl(entityId: string): Promise<PodResult<PodFileUrl>>;
200
+ /**
201
+ * `POST /proposals/:id/approve`, as the person. Only a PERSON connection
202
+ * (`actsAs: "person"`) holding `proposal.approve` can: an agent connection
203
+ * gets `forbidden` / `agent_cannot_approve` and NOTHING is sent (the pod
204
+ * never lets an agent approve); without the permission, `forbidden` /
205
+ * `not_granted`, also without a request.
206
+ */
207
+ approveProposal(id: string, opts?: ApproveProposalOptions): Promise<PodResult<PodApproval>>;
63
208
  get<T = unknown>(path: string, query?: Record<string, QueryValue>): Promise<PodResult<T>>;
64
209
  post<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
65
210
  patch<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
@@ -67,18 +212,62 @@ export interface PodClient {
67
212
  delete<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
68
213
  }
69
214
 
215
+ /** Who `connection`'s token acts as: `"person"` only when the pod said so, else `"agent"`. */
216
+ export function connectionActsAs(connection: Pick<PodConnection, "actsAs">): PodActsAs {
217
+ return connection.actsAs === "person" ? "person" : "agent";
218
+ }
219
+
220
+ /**
221
+ * The slots of `session` that wait on the person (Home's "needs you"):
222
+ * `owner: "human"`, not done, not retired.
223
+ */
224
+ export function humanSlots(session: Pick<PodFocusSession, "expectedOutputs">): PodSessionSlot[] {
225
+ return session.expectedOutputs.filter((slot) => slot.owner === "human" && slot.status !== "done" && !slot.retiredAt);
226
+ }
227
+
70
228
  /** Whether `connection` was granted `permission` (in `workspaceId` when given). Exact match: grants carry no wildcard. */
71
229
  export function can(connection: Pick<PodConnection, "grant">, permission: string, workspaceId?: string): boolean {
72
230
  return connection.grant.some((g) => g.permission === permission && (workspaceId === undefined || g.workspaceId === workspaceId));
73
231
  }
74
232
 
233
+ /**
234
+ * The grant by space, in the grant's order, each permission once. The pod
235
+ * sends one row per permission per space; a space switcher wants one per space.
236
+ */
237
+ export function spaces(connection: Pick<PodConnection, "grant">): PodSpace[] {
238
+ const bySpace = new Map<string, PodSpace>();
239
+ for (const g of connection.grant) {
240
+ const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
241
+ if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
242
+ if (!space.templateSlug && g.templateSlug) space.templateSlug = g.templateSlug;
243
+ if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
244
+ bySpace.set(g.workspaceId, space);
245
+ }
246
+ return [...bySpace.values()];
247
+ }
248
+
249
+ /** An id the SDK puts in a path: uuids, the pod's short ids, the mock's ids. */
250
+ const ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
251
+ const PROPOSAL_IDS_MAX = 100;
252
+
75
253
  const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
254
+ const TIMEOUT_MS = 10_000;
255
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
76
256
 
77
- function hubUrl(podUrl: string, path: string, query?: Record<string, QueryValue>): URL {
78
- if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
79
- throw new TypeError(`Not a Hub API path: ${path}`);
257
+ /**
258
+ * `<pod>/api/hub<path>`, or null when `path` could leave the Hub API: not
259
+ * pod-relative, a dot segment, plain or percent-encoded (WHATWG resolves
260
+ * `%2e%2e` as `..`), a backslash, a query. The resolved URL is checked too.
261
+ */
262
+ function hubUrl(podUrl: string, path: string, query?: Record<string, QueryValue>): URL | null {
263
+ if (!SAFE_PATH.test(path) || /%2e/i.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) return null;
264
+ let url: URL;
265
+ try {
266
+ url = new URL(`${podUrl}/api/hub${path}`);
267
+ } catch {
268
+ return null;
80
269
  }
81
- const url = new URL(`${podUrl}/api/hub${path}`);
270
+ if (url.origin !== new URL(podUrl).origin || !(url.pathname === "/api/hub" || url.pathname.startsWith("/api/hub/"))) return null;
82
271
  for (const [key, value] of Object.entries(query ?? {})) {
83
272
  if (value === undefined || value === null) continue;
84
273
  for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
@@ -86,6 +275,28 @@ function hubUrl(podUrl: string, path: string, query?: Record<string, QueryValue>
86
275
  return url;
87
276
  }
88
277
 
278
+ /** A failed renewal (`renew` threw) as the result of the request it was for. */
279
+ function renewalError(err: unknown): PodError {
280
+ const e: ConnectError | null = isConnectError(err) ? err : null;
281
+ const detail = e?.message ?? (err instanceof Error ? err.message : "The connection could not be renewed");
282
+ switch (e?.code) {
283
+ case "revoked":
284
+ // The pod refused the connection itself: connect again.
285
+ return { code: "unauthorized", status: 401, detail };
286
+ case "expired":
287
+ // It lapsed, or its renewal could not be stored: connect again.
288
+ return { code: "expired", status: null, detail };
289
+ case "rate_limited":
290
+ return { code: "rate_limited", status: null, detail, ...(e.retryAfterSec !== undefined ? { retryAfterSec: e.retryAfterSec } : {}) };
291
+ case "pod_unreachable":
292
+ return { code: "pod_unreachable", status: null, detail };
293
+ case "pod_moved":
294
+ return { code: "pod_moved", status: null, detail };
295
+ default:
296
+ return { code: "server", status: null, detail };
297
+ }
298
+ }
299
+
89
300
  function errorFor(status: number, body: unknown, retryAfter: string | null, now: number): PodError {
90
301
  const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
91
302
  const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : undefined;
@@ -100,6 +311,7 @@ function errorFor(status: number, body: unknown, retryAfter: string | null, now:
100
311
  return { code: "rate_limited", ...base, ...(retryAfterSec !== undefined ? { retryAfterSec } : {}) };
101
312
  }
102
313
  if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
314
+ if (status >= 300 && status < 400) return { code: "pod_moved", ...base };
103
315
  return { code: "server", ...base };
104
316
  }
105
317
 
@@ -119,14 +331,44 @@ export function reviewUrlFor(podUrl: string, reviewUrl: unknown, proposalId: str
119
331
  return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
120
332
  }
121
333
 
334
+ function refused(code: PodErrorCode, reason: PodRefusalReason, detail: string): { ok: false; error: PodError } {
335
+ return { ok: false, error: { code, status: null, detail, reason } };
336
+ }
337
+
338
+ function asObject(value: unknown): Record<string, unknown> | null {
339
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
340
+ }
341
+
342
+ function hasId(value: unknown): value is Record<string, unknown> & { id: string } {
343
+ return asObject(value) !== null && typeof (value as Record<string, unknown>).id === "string";
344
+ }
345
+
122
346
  export function createPodClient(connection: PodConnection, opts: PodClientOptions = {}): PodClient {
123
347
  const now = opts.now ?? Date.now;
124
348
  const doFetch = () => opts.fetch ?? fetch;
349
+ /** The connection in use: `connection`, or what `renew` returned after a 401. */
350
+ let current = connection;
351
+ /** The one renewal this client may run: a 401 forces ONE refresh, never a loop. */
352
+ let renewal: Promise<PodResult<PodConnection>> | null = null;
125
353
 
126
- async function request(method: string, path: string, body: unknown, query?: Record<string, QueryValue>): Promise<PodResult<unknown>> {
127
- const url = hubUrl(connection.podUrl, path, query);
128
- if (connection.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
129
- const headers: Record<string, string> = { Accept: "application/json", Authorization: `Bearer ${connection.accessToken}` };
354
+ function renewOnce(refused: PodConnection): Promise<PodResult<PodConnection>> {
355
+ if (current !== refused) return Promise.resolve({ ok: true, data: current });
356
+ renewal ??= opts.renew!(refused).then(
357
+ (next): PodResult<PodConnection> => {
358
+ current = next;
359
+ return { ok: true, data: next };
360
+ },
361
+ (err: unknown): PodResult<PodConnection> => ({ ok: false, error: renewalError(err) }),
362
+ );
363
+ return renewal;
364
+ }
365
+
366
+ async function request(method: string, path: string, body: unknown, query?: Record<string, QueryValue>, retried = false): Promise<PodResult<unknown>> {
367
+ const sent = current;
368
+ const url = hubUrl(sent.podUrl, path, query);
369
+ if (!url) return refused("invalid", "invalid_input", "Not a Hub API path: encode each path segment (encodeURIComponent)");
370
+ if (sent.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
371
+ const headers: Record<string, string> = { Accept: "application/json", Authorization: `Bearer ${sent.accessToken}` };
130
372
  if (body !== undefined) headers["Content-Type"] = "application/json";
131
373
  let res: Response;
132
374
  try {
@@ -135,20 +377,35 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
135
377
  headers,
136
378
  body: body === undefined ? undefined : JSON.stringify(body),
137
379
  redirect: "manual",
380
+ signal: timeoutSignal(opts.timeoutMs ?? TIMEOUT_MS),
138
381
  });
139
382
  } catch {
140
383
  return { ok: false, error: { code: "pod_unreachable", status: null } };
141
384
  }
385
+ const read = await readCapped(res, opts.maxResponseBytes ?? MAX_RESPONSE_BYTES);
386
+ if (!read.ok) {
387
+ return read.reason === "too_large"
388
+ ? { ok: false, error: { code: "server", status: res.status, detail: "The pod's answer is larger than maxResponseBytes" } }
389
+ : { ok: false, error: { code: "pod_unreachable", status: res.status, detail: "The pod's answer broke off" } };
390
+ }
142
391
  let data: unknown = null;
143
- const text = await res.text().catch(() => "");
144
- if (text) {
392
+ if (read.text) {
145
393
  try {
146
- data = JSON.parse(text);
394
+ data = JSON.parse(read.text);
147
395
  } catch {
148
396
  data = null;
149
397
  }
150
398
  }
151
399
  if (res.status >= 200 && res.status < 300) return { ok: true, data };
400
+ if (res.status === 401 && sent.refreshToken) {
401
+ // The key was refused, not necessarily the connection (a narrow or a pod-side "Revoke key" ends the key only).
402
+ if (!opts.renew) return { ok: false, error: { ...errorFor(401, data, null, now()), refreshable: true } };
403
+ if (!retried) {
404
+ const renewed = await renewOnce(sent);
405
+ if (!renewed.ok) return renewed;
406
+ return request(method, path, body, query, true);
407
+ }
408
+ }
152
409
  return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
153
410
  }
154
411
 
@@ -162,18 +419,207 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
162
419
  ok: true,
163
420
  status: "proposed",
164
421
  proposalId,
165
- reviewUrl: reviewUrlFor(connection.podUrl, obj.reviewUrl, proposalId),
422
+ reviewUrl: reviewUrlFor(current.podUrl, obj.reviewUrl, proposalId),
166
423
  data: result.data as T,
167
424
  };
168
425
  }
169
426
  return { ok: true, status: "applied", data: result.data as T };
170
427
  }
171
428
 
429
+ async function listPendingProposals(opts: ListPendingProposalsOptions = {}): Promise<PodResult<PendingProposals>> {
430
+ if (!can(current, "proposal.read", opts.workspaceId)) {
431
+ return refused("forbidden", "not_granted", "The connection was not granted proposal.read in that space");
432
+ }
433
+ let only: Set<string> | null = null;
434
+ if (opts.proposalIds !== undefined) {
435
+ if (opts.proposalIds.length > PROPOSAL_IDS_MAX || opts.proposalIds.some((id) => !ID_RE.test(id))) {
436
+ return refused("invalid", "invalid_input", `proposalIds must be at most ${PROPOSAL_IDS_MAX} proposal ids`);
437
+ }
438
+ // `?proposalIds=` matches nothing on the pod; our query builder would drop it and ask for ALL.
439
+ if (opts.proposalIds.length === 0) return { ok: true, data: { total: 0, proposals: [], hasMore: false } };
440
+ only = new Set(opts.proposalIds);
441
+ }
442
+ const limit = Math.min(Math.max(Math.floor(opts.limit ?? 20), 1), 200);
443
+ const offset = Math.max(Math.floor(opts.offset ?? 0), 0);
444
+ const result = await request("GET", "/proposals", undefined, {
445
+ status: "pending",
446
+ view: "basic",
447
+ limit,
448
+ offset,
449
+ workspaceId: opts.workspaceId,
450
+ ...(only ? { proposalIds: [...only].join(",") } : {}),
451
+ });
452
+ if (!result.ok) return result;
453
+ const obj = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
454
+ if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
455
+ return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
456
+ }
457
+ const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
458
+ const proposals: PendingProposal[] = [];
459
+ for (const row of obj.proposals as unknown[]) {
460
+ if (!row || typeof row !== "object") continue;
461
+ const r = row as Record<string, unknown>;
462
+ const id = str(r.id);
463
+ // A pod older than the filter ignores it: never hand back a proposal that was not asked for.
464
+ if (!id || (only && !only.has(id))) continue;
465
+ proposals.push({
466
+ id,
467
+ proposalType: str(r.proposalType) ?? "",
468
+ targetType: str(r.targetType) ?? "",
469
+ targetId: str(r.targetId),
470
+ workspaceId: str(r.workspaceId),
471
+ createdAt: str(r.createdAt) ?? "",
472
+ agentUserId: str(r.agentUserId),
473
+ sessionId: str(r.sessionId),
474
+ ...(str(r.summary) ? { summary: str(r.summary)! } : {}),
475
+ reviewUrl: reviewUrlFor(current.podUrl, null, id),
476
+ });
477
+ }
478
+ return {
479
+ ok: true,
480
+ data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total },
481
+ };
482
+ }
483
+
484
+ /** A read whose 2xx body must pass `shape`; anything else is the pod answering what the SDK cannot use. */
485
+ async function read<T>(path: string, query: Record<string, QueryValue> | undefined, shape: (data: unknown) => T | null): Promise<PodResult<T>> {
486
+ const result = await request("GET", path, undefined, query);
487
+ if (!result.ok) return result;
488
+ const data = shape(result.data);
489
+ if (data === null) return { ok: false, error: { code: "server", status: 200, detail: `The pod's ${path.split("/")[1]} answer is not the expected shape` } };
490
+ return { ok: true, data };
491
+ }
492
+
493
+ const badId = (what: string) => refused("invalid", "invalid_input", `Not a ${what} id`);
494
+ const clamp = (n: number | undefined, min: number, max: number) => (n === undefined ? undefined : Math.min(Math.max(Math.floor(n), min), max));
495
+
496
+ function listEntities(opts: ListEntitiesOptions = {}): Promise<PodResult<PodEntity[]>> {
497
+ return read("/entities", {
498
+ workspaceId: opts.workspaceId,
499
+ profileSlug: opts.profileSlug,
500
+ limit: clamp(opts.limit, 1, 1000),
501
+ offset: clamp(opts.offset, 0, Number.MAX_SAFE_INTEGER),
502
+ sort: opts.sort,
503
+ includePodWide: opts.includePodWide,
504
+ }, (data) => (Array.isArray(data) ? (data.filter(hasId) as PodEntity[]) : null));
505
+ }
506
+
507
+ async function getEntity(id: string): Promise<PodResult<PodEntity>> {
508
+ if (!ID_RE.test(id)) return badId("entity");
509
+ return read(`/entities/${id}`, undefined, (data) => (hasId(data) ? (data as PodEntity) : null));
510
+ }
511
+
512
+ async function getEntityConnections(id: string, opts: GetEntityConnectionsOptions = {}): Promise<PodResult<PodEntityConnections>> {
513
+ if (!ID_RE.test(id)) return badId("entity");
514
+ return read(`/entities/${id}/connections`, { workspaceId: opts.workspaceId, limit: clamp(opts.limit, 1, 200) }, (data) => {
515
+ const obj = asObject(data);
516
+ if (!obj || !Array.isArray(obj.connections)) return null;
517
+ const connections = obj.connections.filter(
518
+ (c): c is PodEntityConnection => !!c && typeof c === "object" && typeof (c as Record<string, unknown>).entityId === "string",
519
+ );
520
+ const counts = asObject(obj.counts) ?? {};
521
+ const count = (key: string, source: string) =>
522
+ typeof counts[key] === "number" ? (counts[key] as number) : connections.filter((c) => c.source === source).length;
523
+ return {
524
+ connections,
525
+ counts: {
526
+ total: typeof counts.total === "number" ? counts.total : connections.length,
527
+ graph: count("graph", "graph"),
528
+ structural: count("structural", "property"),
529
+ threads: count("threads", "thread"),
530
+ contextChannels: count("contextChannels", "context_channel"),
531
+ focusSessions: count("focusSessions", "focus_session"),
532
+ },
533
+ };
534
+ });
535
+ }
536
+
537
+ async function getDocument(id: string, opts: GetDocumentOptions = {}): Promise<PodResult<PodDocument>> {
538
+ if (!ID_RE.test(id)) return badId("document");
539
+ return read(`/documents/${id}`, { format: opts.format }, (data) => {
540
+ const doc = asObject(asObject(data)?.document);
541
+ if (!doc || typeof doc.id !== "string" || typeof doc.content !== "string") return null;
542
+ return { ...doc, sections: Array.isArray(doc.sections) ? doc.sections : [] } as PodDocument;
543
+ });
544
+ }
545
+
546
+ async function listFocusSessions(opts: ListFocusSessionsOptions): Promise<PodResult<PodFocusSession[]>> {
547
+ if (!opts?.workspaceId && !opts?.projectId) return refused("invalid", "invalid_input", "workspaceId or projectId is required");
548
+ return read("/focus-sessions", {
549
+ workspaceId: opts.workspaceId,
550
+ projectId: opts.projectId,
551
+ status: opts.status,
552
+ limit: clamp(opts.limit, 1, 50),
553
+ lens: opts.lens,
554
+ kind: opts.kind,
555
+ subjectEntityId: opts.subjectEntityId,
556
+ }, (data) => {
557
+ if (!Array.isArray(data)) return null;
558
+ return data.filter(hasId).map((row) => ({
559
+ ...row,
560
+ expectedOutputs: Array.isArray(row.expectedOutputs)
561
+ ? row.expectedOutputs.filter((s): s is PodSessionSlot => !!s && typeof s === "object" && typeof (s as Record<string, unknown>).label === "string")
562
+ : [],
563
+ })) as PodFocusSession[];
564
+ });
565
+ }
566
+
567
+ async function getFileUrl(entityId: string): Promise<PodResult<PodFileUrl>> {
568
+ if (!ID_RE.test(entityId)) return badId("file");
569
+ return read(`/files/${entityId}/url`, undefined, (data) => {
570
+ const obj = asObject(data);
571
+ if (!obj || typeof obj.url !== "string" || typeof obj.expiresAt !== "string") return null;
572
+ return { url: obj.url, expiresAt: obj.expiresAt, mimeType: typeof obj.mimeType === "string" ? obj.mimeType : null };
573
+ });
574
+ }
575
+
576
+ async function approveProposal(id: string, opts: ApproveProposalOptions = {}): Promise<PodResult<PodApproval>> {
577
+ // Checked first, before anything else: an agent credential never reaches the approve door.
578
+ if (connectionActsAs(current) !== "person") {
579
+ return refused("forbidden", "agent_cannot_approve", "An agent connection can never approve: approval is the person's");
580
+ }
581
+ if (!can(current, "proposal.approve", opts.workspaceId)) {
582
+ return refused("forbidden", "not_granted", "The connection was not granted proposal.approve in that space");
583
+ }
584
+ if (!ID_RE.test(id)) return badId("proposal");
585
+ const result = await request("POST", `/proposals/${id}/approve`, {});
586
+ if (!result.ok) return result;
587
+ const obj = asObject(result.data);
588
+ if (!obj || typeof obj.proposalId !== "string") {
589
+ return { ok: false, error: { code: "server", status: 200, detail: "The pod's approve answer names no proposalId" } };
590
+ }
591
+ return {
592
+ ok: true,
593
+ data: {
594
+ proposalId: obj.proposalId,
595
+ ...(typeof obj.primaryId === "string" ? { primaryId: obj.primaryId } : {}),
596
+ ...(typeof obj.linked === "number" ? { linked: obj.linked } : {}),
597
+ proposal: asObject(obj.proposal),
598
+ },
599
+ };
600
+ }
601
+
172
602
  return {
173
603
  podUrl: connection.podUrl,
174
- user: connection.user,
175
- grant: connection.grant,
176
- can: (permission, workspaceId) => can(connection, permission, workspaceId),
604
+ get user() {
605
+ return current.user;
606
+ },
607
+ get grant() {
608
+ return current.grant;
609
+ },
610
+ get actsAs() {
611
+ return connectionActsAs(current);
612
+ },
613
+ can: (permission, workspaceId) => can(current, permission, workspaceId),
614
+ spaces: () => spaces(current),
615
+ listPendingProposals,
616
+ listEntities,
617
+ getEntity,
618
+ getEntityConnections,
619
+ getDocument,
620
+ listFocusSessions,
621
+ getFileUrl,
622
+ approveProposal,
177
623
  get<T>(path: string, query?: Record<string, QueryValue>) {
178
624
  return request("GET", path, undefined, query) as Promise<PodResult<T>>;
179
625
  },