@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.
- package/CHANGELOG.md +215 -0
- package/README.md +152 -10
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +419 -64
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +416 -32
- package/dist/connect.d.ts +416 -32
- package/dist/connect.js +414 -65
- 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 +350 -15
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +171 -27
- package/dist/testing.d.ts +171 -27
- package/dist/testing.js +346 -15
- package/dist/testing.js.map +1 -1
- package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
- package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
- package/package.json +30 -6
- package/src/connect/auth.ts +169 -27
- package/src/connect/client.ts +377 -34
- package/src/connect/cloud.ts +14 -10
- package/src/connect/discovery.ts +66 -32
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +7 -0
- package/src/connect/flow.ts +42 -14
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +271 -0
- package/src/connect/index.ts +61 -42
- package/src/connect/rules.ts +18 -0
- package/src/connect/store.ts +9 -5
- package/src/connect/types.ts +28 -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 +350 -17
- package/src/testing/cloud.ts +3 -4
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +8 -6
- package/src/testing/roles.ts +27 -0
- package/src/testing/seed.ts +196 -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 };
|
|
@@ -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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
231
|
-
return
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
},
|
package/src/connect/cloud.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Connecting through Synap Cloud
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 || !
|
|
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");
|