@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.
- package/CHANGELOG.md +133 -0
- package/README.md +212 -12
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +790 -161
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +610 -37
- package/dist/connect.d.ts +610 -37
- package/dist/connect.js +777 -162
- package/dist/connect.js.map +1 -1
- package/dist/public.cjs +9 -2
- package/dist/public.cjs.map +1 -1
- package/dist/public.d.cts +19 -4
- package/dist/public.d.ts +19 -4
- package/dist/public.js +8 -2
- package/dist/public.js.map +1 -1
- package/dist/testing.cjs +366 -17
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +190 -7
- package/dist/testing.d.ts +190 -7
- package/dist/testing.js +358 -17
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
- package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
- package/package.json +36 -12
- package/src/connect/auth.ts +314 -55
- package/src/connect/client.ts +472 -26
- package/src/connect/cloud.ts +210 -0
- package/src/connect/discovery.ts +79 -44
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +8 -1
- package/src/connect/flow.ts +80 -12
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +254 -0
- package/src/connect/index.ts +69 -23
- package/src/connect/rules.ts +18 -0
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +50 -8
- package/src/connect/types.ts +48 -4
- package/src/public/client.ts +14 -2
- package/src/public/safe-markdown.ts +13 -0
- package/src/public/share-model.ts +6 -2
- package/src/public/site-model.ts +5 -3
- package/src/testing/auth-server.ts +377 -20
- package/src/testing/cloud.ts +83 -0
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +10 -6
- package/src/testing/seed.ts +120 -0
- package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/src/connect/client.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
11
|
-
* - `rate_limited` with `retryAfterSec`; `pod_unreachable` (network,
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
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
|
},
|