@synap-core/sdk 1.4.0 → 1.5.1

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 +215 -0
  2. package/README.md +152 -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 +419 -64
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +416 -32
  10. package/dist/connect.d.ts +416 -32
  11. package/dist/connect.js +414 -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 +350 -15
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +171 -27
  22. package/dist/testing.d.ts +171 -27
  23. package/dist/testing.js +346 -15
  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 +169 -27
  29. package/src/connect/client.ts +377 -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 +271 -0
  37. package/src/connect/index.ts +61 -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 +350 -17
  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/roles.ts +27 -0
  50. package/src/testing/seed.ts +196 -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, PodSpace } 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 };
@@ -79,17 +136,39 @@ export interface ListPendingProposalsOptions {
79
136
  /** 1-200, default 20. */
80
137
  limit?: number;
81
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[];
82
144
  }
83
145
 
84
146
  export interface PodClientOptions {
85
147
  fetch?: typeof fetch;
86
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>;
87
163
  }
88
164
 
89
165
  export interface PodClient {
90
166
  podUrl: string;
91
- user: PodConnection["user"];
92
- 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;
93
172
  /** Whether the connection was granted `permission` (in `workspaceId` when given). */
94
173
  can(permission: string, workspaceId?: string): boolean;
95
174
  /** The granted spaces, each with its permissions (`spaces(connection)`). */
@@ -102,6 +181,30 @@ export interface PodClient {
102
181
  * empty list, so this answers `forbidden` instead, without a request.
103
182
  */
104
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>>;
105
208
  get<T = unknown>(path: string, query?: Record<string, QueryValue>): Promise<PodResult<T>>;
106
209
  post<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
107
210
  patch<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
@@ -109,6 +212,19 @@ export interface PodClient {
109
212
  delete<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
110
213
  }
111
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
+
112
228
  /** Whether `connection` was granted `permission` (in `workspaceId` when given). Exact match: grants carry no wildcard. */
113
229
  export function can(connection: Pick<PodConnection, "grant">, permission: string, workspaceId?: string): boolean {
114
230
  return connection.grant.some((g) => g.permission === permission && (workspaceId === undefined || g.workspaceId === workspaceId));
@@ -123,19 +239,35 @@ export function spaces(connection: Pick<PodConnection, "grant">): PodSpace[] {
123
239
  for (const g of connection.grant) {
124
240
  const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
125
241
  if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
242
+ if (!space.templateSlug && g.templateSlug) space.templateSlug = g.templateSlug;
126
243
  if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
127
244
  bySpace.set(g.workspaceId, space);
128
245
  }
129
246
  return [...bySpace.values()];
130
247
  }
131
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
+
132
253
  const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
254
+ const TIMEOUT_MS = 10_000;
255
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
133
256
 
134
- function hubUrl(podUrl: string, path: string, query?: Record<string, QueryValue>): URL {
135
- if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
136
- 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;
137
269
  }
138
- 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;
139
271
  for (const [key, value] of Object.entries(query ?? {})) {
140
272
  if (value === undefined || value === null) continue;
141
273
  for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
@@ -143,6 +275,28 @@ function hubUrl(podUrl: string, path: string, query?: Record<string, QueryValue>
143
275
  return url;
144
276
  }
145
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
+
146
300
  function errorFor(status: number, body: unknown, retryAfter: string | null, now: number): PodError {
147
301
  const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
148
302
  const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : undefined;
@@ -157,6 +311,7 @@ function errorFor(status: number, body: unknown, retryAfter: string | null, now:
157
311
  return { code: "rate_limited", ...base, ...(retryAfterSec !== undefined ? { retryAfterSec } : {}) };
158
312
  }
159
313
  if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
314
+ if (status >= 300 && status < 400) return { code: "pod_moved", ...base };
160
315
  return { code: "server", ...base };
161
316
  }
162
317
 
@@ -176,14 +331,44 @@ export function reviewUrlFor(podUrl: string, reviewUrl: unknown, proposalId: str
176
331
  return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
177
332
  }
178
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
+
179
346
  export function createPodClient(connection: PodConnection, opts: PodClientOptions = {}): PodClient {
180
347
  const now = opts.now ?? Date.now;
181
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;
182
353
 
183
- async function request(method: string, path: string, body: unknown, query?: Record<string, QueryValue>): Promise<PodResult<unknown>> {
184
- const url = hubUrl(connection.podUrl, path, query);
185
- if (connection.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
186
- 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}` };
187
372
  if (body !== undefined) headers["Content-Type"] = "application/json";
188
373
  let res: Response;
189
374
  try {
@@ -192,20 +377,35 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
192
377
  headers,
193
378
  body: body === undefined ? undefined : JSON.stringify(body),
194
379
  redirect: "manual",
380
+ signal: timeoutSignal(opts.timeoutMs ?? TIMEOUT_MS),
195
381
  });
196
382
  } catch {
197
383
  return { ok: false, error: { code: "pod_unreachable", status: null } };
198
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
+ }
199
391
  let data: unknown = null;
200
- const text = await res.text().catch(() => "");
201
- if (text) {
392
+ if (read.text) {
202
393
  try {
203
- data = JSON.parse(text);
394
+ data = JSON.parse(read.text);
204
395
  } catch {
205
396
  data = null;
206
397
  }
207
398
  }
208
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
+ }
209
409
  return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
210
410
  }
211
411
 
@@ -219,7 +419,7 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
219
419
  ok: true,
220
420
  status: "proposed",
221
421
  proposalId,
222
- reviewUrl: reviewUrlFor(connection.podUrl, obj.reviewUrl, proposalId),
422
+ reviewUrl: reviewUrlFor(current.podUrl, obj.reviewUrl, proposalId),
223
423
  data: result.data as T,
224
424
  };
225
425
  }
@@ -227,11 +427,17 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
227
427
  }
228
428
 
229
429
  async function listPendingProposals(opts: ListPendingProposalsOptions = {}): Promise<PodResult<PendingProposals>> {
230
- if (!can(connection, "proposal.read", opts.workspaceId)) {
231
- return {
232
- ok: false,
233
- error: { code: "forbidden", status: null, detail: "The connection was not granted proposal.read in that space" },
234
- };
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);
235
441
  }
236
442
  const limit = Math.min(Math.max(Math.floor(opts.limit ?? 20), 1), 200);
237
443
  const offset = Math.max(Math.floor(opts.offset ?? 0), 0);
@@ -241,6 +447,7 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
241
447
  limit,
242
448
  offset,
243
449
  workspaceId: opts.workspaceId,
450
+ ...(only ? { proposalIds: [...only].join(",") } : {}),
244
451
  });
245
452
  if (!result.ok) return result;
246
453
  const obj = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
@@ -253,7 +460,8 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
253
460
  if (!row || typeof row !== "object") continue;
254
461
  const r = row as Record<string, unknown>;
255
462
  const id = str(r.id);
256
- if (!id) continue;
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;
257
465
  proposals.push({
258
466
  id,
259
467
  proposalType: str(r.proposalType) ?? "",
@@ -264,7 +472,7 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
264
472
  agentUserId: str(r.agentUserId),
265
473
  sessionId: str(r.sessionId),
266
474
  ...(str(r.summary) ? { summary: str(r.summary)! } : {}),
267
- reviewUrl: reviewUrlFor(connection.podUrl, null, id),
475
+ reviewUrl: reviewUrlFor(current.podUrl, null, id),
268
476
  });
269
477
  }
270
478
  return {
@@ -273,13 +481,148 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
273
481
  };
274
482
  }
275
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
+ facetSlug: opts.facetSlug,
505
+ // Sent only when asked: the default request stays the 1.5.0 one.
506
+ includeFacets: opts.includeFacets ? true : undefined,
507
+ }, (data) => (Array.isArray(data) ? (data.filter(hasId) as PodEntity[]) : null));
508
+ }
509
+
510
+ async function getEntity(id: string): Promise<PodResult<PodEntity>> {
511
+ if (!ID_RE.test(id)) return badId("entity");
512
+ return read(`/entities/${id}`, undefined, (data) => (hasId(data) ? (data as PodEntity) : null));
513
+ }
514
+
515
+ async function getEntityConnections(id: string, opts: GetEntityConnectionsOptions = {}): Promise<PodResult<PodEntityConnections>> {
516
+ if (!ID_RE.test(id)) return badId("entity");
517
+ return read(`/entities/${id}/connections`, { workspaceId: opts.workspaceId, limit: clamp(opts.limit, 1, 200) }, (data) => {
518
+ const obj = asObject(data);
519
+ if (!obj || !Array.isArray(obj.connections)) return null;
520
+ const connections = obj.connections.filter(
521
+ (c): c is PodEntityConnection => !!c && typeof c === "object" && typeof (c as Record<string, unknown>).entityId === "string",
522
+ );
523
+ const counts = asObject(obj.counts) ?? {};
524
+ const count = (key: string, source: string) =>
525
+ typeof counts[key] === "number" ? (counts[key] as number) : connections.filter((c) => c.source === source).length;
526
+ return {
527
+ connections,
528
+ counts: {
529
+ total: typeof counts.total === "number" ? counts.total : connections.length,
530
+ graph: count("graph", "graph"),
531
+ structural: count("structural", "property"),
532
+ threads: count("threads", "thread"),
533
+ contextChannels: count("contextChannels", "context_channel"),
534
+ focusSessions: count("focusSessions", "focus_session"),
535
+ },
536
+ };
537
+ });
538
+ }
539
+
540
+ async function getDocument(id: string, opts: GetDocumentOptions = {}): Promise<PodResult<PodDocument>> {
541
+ if (!ID_RE.test(id)) return badId("document");
542
+ return read(`/documents/${id}`, { format: opts.format }, (data) => {
543
+ const doc = asObject(asObject(data)?.document);
544
+ if (!doc || typeof doc.id !== "string" || typeof doc.content !== "string") return null;
545
+ return { ...doc, sections: Array.isArray(doc.sections) ? doc.sections : [] } as PodDocument;
546
+ });
547
+ }
548
+
549
+ async function listFocusSessions(opts: ListFocusSessionsOptions): Promise<PodResult<PodFocusSession[]>> {
550
+ if (!opts?.workspaceId && !opts?.projectId) return refused("invalid", "invalid_input", "workspaceId or projectId is required");
551
+ return read("/focus-sessions", {
552
+ workspaceId: opts.workspaceId,
553
+ projectId: opts.projectId,
554
+ status: opts.status,
555
+ limit: clamp(opts.limit, 1, 50),
556
+ lens: opts.lens,
557
+ kind: opts.kind,
558
+ subjectEntityId: opts.subjectEntityId,
559
+ }, (data) => {
560
+ if (!Array.isArray(data)) return null;
561
+ return data.filter(hasId).map((row) => ({
562
+ ...row,
563
+ expectedOutputs: Array.isArray(row.expectedOutputs)
564
+ ? row.expectedOutputs.filter((s): s is PodSessionSlot => !!s && typeof s === "object" && typeof (s as Record<string, unknown>).label === "string")
565
+ : [],
566
+ })) as PodFocusSession[];
567
+ });
568
+ }
569
+
570
+ async function getFileUrl(entityId: string): Promise<PodResult<PodFileUrl>> {
571
+ if (!ID_RE.test(entityId)) return badId("file");
572
+ return read(`/files/${entityId}/url`, undefined, (data) => {
573
+ const obj = asObject(data);
574
+ if (!obj || typeof obj.url !== "string" || typeof obj.expiresAt !== "string") return null;
575
+ return { url: obj.url, expiresAt: obj.expiresAt, mimeType: typeof obj.mimeType === "string" ? obj.mimeType : null };
576
+ });
577
+ }
578
+
579
+ async function approveProposal(id: string, opts: ApproveProposalOptions = {}): Promise<PodResult<PodApproval>> {
580
+ // Checked first, before anything else: an agent credential never reaches the approve door.
581
+ if (connectionActsAs(current) !== "person") {
582
+ return refused("forbidden", "agent_cannot_approve", "An agent connection can never approve: approval is the person's");
583
+ }
584
+ if (!can(current, "proposal.approve", opts.workspaceId)) {
585
+ return refused("forbidden", "not_granted", "The connection was not granted proposal.approve in that space");
586
+ }
587
+ if (!ID_RE.test(id)) return badId("proposal");
588
+ const result = await request("POST", `/proposals/${id}/approve`, {});
589
+ if (!result.ok) return result;
590
+ const obj = asObject(result.data);
591
+ if (!obj || typeof obj.proposalId !== "string") {
592
+ return { ok: false, error: { code: "server", status: 200, detail: "The pod's approve answer names no proposalId" } };
593
+ }
594
+ return {
595
+ ok: true,
596
+ data: {
597
+ proposalId: obj.proposalId,
598
+ ...(typeof obj.primaryId === "string" ? { primaryId: obj.primaryId } : {}),
599
+ ...(typeof obj.linked === "number" ? { linked: obj.linked } : {}),
600
+ proposal: asObject(obj.proposal),
601
+ },
602
+ };
603
+ }
604
+
276
605
  return {
277
606
  podUrl: connection.podUrl,
278
- user: connection.user,
279
- grant: connection.grant,
280
- can: (permission, workspaceId) => can(connection, permission, workspaceId),
281
- spaces: () => spaces(connection),
607
+ get user() {
608
+ return current.user;
609
+ },
610
+ get grant() {
611
+ return current.grant;
612
+ },
613
+ get actsAs() {
614
+ return connectionActsAs(current);
615
+ },
616
+ can: (permission, workspaceId) => can(current, permission, workspaceId),
617
+ spaces: () => spaces(current),
282
618
  listPendingProposals,
619
+ listEntities,
620
+ getEntity,
621
+ getEntityConnections,
622
+ getDocument,
623
+ listFocusSessions,
624
+ getFileUrl,
625
+ approveProposal,
283
626
  get<T>(path: string, query?: Record<string, QueryValue>) {
284
627
  return request("GET", path, undefined, query) as Promise<PodResult<T>>;
285
628
  },
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Connecting through Synap Cloud (wave C1, doc 18 §1.1): the person picks
3
- * their pod on the Control Plane instead of typing its address.
2
+ * Connecting through Synap Cloud: the person picks their pod on the Control
3
+ * Plane instead of typing its address.
4
4
  *
5
5
  * app POST <cloud handler> → 303 <cp>/connect/pod?client_id=<CIMD url>&state=<nonce>
6
6
  * CP the person signs in, picks a pod → 302 <initiate_login_uri>?iss=<pod issuer>&state=<nonce>
@@ -21,14 +21,12 @@
21
21
  import { canonicalPodUrl } from "./discovery";
22
22
  import { constantTimeEqual, randomToken } from "./encoding";
23
23
  import { ConnectError, fromOAuthError } from "./errors";
24
+ import { CLOUD_STATE_RE, LOOPBACK_HOSTS } from "./rules";
24
25
  import { seal, unseal, type AppSecret } from "./seal";
25
26
 
26
27
  /** How long a Synap Cloud sign-in may take, from the click to the pod chooser's return. */
27
28
  export const CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1000;
28
29
 
29
- /** The state the CP accepts (`^[A-Za-z0-9._~-]{16,256}$`); ours is 43 base64url characters. */
30
- const STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
31
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
32
30
 
33
31
  /** A sealed `CloudTransaction`: opaque, safe to put in an HttpOnly cookie. */
34
32
  export type SealedCloudTransaction = string & { readonly __sealedCloudTransaction: true };
@@ -85,15 +83,18 @@ export function cloudConnectAvailable(config?: CloudConnectConfig | null): boole
85
83
  export interface StartCloudConnectOptions extends CloudConnectConfig {
86
84
  /** The app's client_id (its CIMD URL): the CP reads `initiate_login_uri` from it. */
87
85
  clientId: string;
88
- /** SERVER-ONLY app secret that seals the transaction. */
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
+ */
89
90
  secret?: AppSecret;
90
91
  /** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
91
92
  next?: string | null;
92
- /** A space to preselect on the pod's consent. */
93
+ /** A space hint for the pod's consent (`synap_space_hint`); a pod may ignore it. */
93
94
  spaceHint?: string | null;
94
95
  /** Show the pod's consent again even if this browser is already connected to the pod the person picks. */
95
96
  prompt?: "consent" | null;
96
- /** @deprecated Ignored: the SDK always draws its own 32-byte state. */
97
+ /** @deprecated Ignored: the SDK always draws its own 32-byte state. Removed in 2.0. */
97
98
  state?: string;
98
99
  now?: () => number;
99
100
  }
@@ -109,7 +110,10 @@ export interface StartedCloudConnect {
109
110
 
110
111
  /**
111
112
  * Start a Synap Cloud sign-in. Throws `cp_unavailable` at once when no CP URL
112
- * is configured (as 1.3 always did), `misconfigured` without a secret.
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).
113
117
  */
114
118
  export function startCloudConnect(opts: StartCloudConnectOptions): Promise<StartedCloudConnect> {
115
119
  if (!cloudConnectAvailable(opts)) {
@@ -188,7 +192,7 @@ export async function acceptCloudReturn(opts: AcceptCloudReturnOptions): Promise
188
192
  }
189
193
  const params = new URL(opts.url).searchParams;
190
194
  const state = params.get("state");
191
- if (state === null || !STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
195
+ if (state === null || !CLOUD_STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
192
196
  throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
193
197
  }
194
198
  const error = params.get("error");