@synap-core/sdk 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/README.md +136 -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 +412 -64
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +400 -32
- package/dist/connect.d.ts +400 -32
- package/dist/connect.js +407 -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 +234 -12
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +122 -27
- package/dist/testing.d.ts +122 -27
- package/dist/testing.js +230 -12
- 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 +156 -27
- package/src/connect/client.ts +374 -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 +254 -0
- package/src/connect/index.ts +60 -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 +239 -14
- package/src/testing/cloud.ts +3 -4
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +8 -6
- package/src/testing/seed.ts +120 -0
- package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/dist/connect.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CLOUD_STATE_RE,
|
|
3
|
+
LOOPBACK_HOSTS,
|
|
4
|
+
SPACE_HINT_RE,
|
|
2
5
|
base64url,
|
|
3
6
|
constantTimeEqual,
|
|
4
7
|
fromBase64url,
|
|
5
8
|
fromUtf8,
|
|
9
|
+
isIpLiteral,
|
|
6
10
|
parseCookies,
|
|
7
11
|
pkceChallenge,
|
|
8
12
|
randomBytes,
|
|
9
13
|
randomToken,
|
|
10
14
|
serializeCookie,
|
|
11
15
|
utf8
|
|
12
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-UK2U6SMA.js";
|
|
13
17
|
|
|
14
18
|
// src/connect/guard.ts
|
|
15
19
|
var BROWSER_REFUSAL = "@synap-core/sdk/connect is server-only: it holds the pod's tokens and the app secret. Import it from route handlers, proxy.ts or server code, never from a client component.";
|
|
@@ -52,6 +56,7 @@ var CONNECT_ERROR_CODES = [
|
|
|
52
56
|
"rate_limited",
|
|
53
57
|
"expired",
|
|
54
58
|
"revoked",
|
|
59
|
+
"pod_moved",
|
|
55
60
|
"server_error"
|
|
56
61
|
];
|
|
57
62
|
function parseConnectErrorCode(value) {
|
|
@@ -87,11 +92,44 @@ function retryAfterSeconds(header, now) {
|
|
|
87
92
|
return Math.max(0, Math.ceil((at - now) / 1e3));
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
// src/connect/http.ts
|
|
96
|
+
function timeoutSignal(ms) {
|
|
97
|
+
return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
|
|
98
|
+
}
|
|
99
|
+
async function readCapped(res, maxBytes) {
|
|
100
|
+
const declared = Number(res.headers.get("content-length"));
|
|
101
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
102
|
+
await res.body?.cancel().catch(() => {
|
|
103
|
+
});
|
|
104
|
+
return { ok: false, reason: "too_large" };
|
|
105
|
+
}
|
|
106
|
+
if (!res.body) return { ok: true, text: "" };
|
|
107
|
+
const reader = res.body.getReader();
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
let total = 0;
|
|
110
|
+
let text = "";
|
|
111
|
+
try {
|
|
112
|
+
for (; ; ) {
|
|
113
|
+
const { done, value } = await reader.read();
|
|
114
|
+
if (done) break;
|
|
115
|
+
total += value.byteLength;
|
|
116
|
+
if (total > maxBytes) {
|
|
117
|
+
await reader.cancel().catch(() => {
|
|
118
|
+
});
|
|
119
|
+
return { ok: false, reason: "too_large" };
|
|
120
|
+
}
|
|
121
|
+
text += decoder.decode(value, { stream: true });
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return { ok: false, reason: "unreadable" };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, text: text + decoder.decode() };
|
|
127
|
+
}
|
|
128
|
+
|
|
90
129
|
// src/connect/discovery.ts
|
|
91
130
|
var METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
92
131
|
var TIMEOUT_MS = 5e3;
|
|
93
132
|
var MAX_METADATA_BYTES = 64 * 1024;
|
|
94
|
-
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
95
133
|
function isPrivateIpv4(host) {
|
|
96
134
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
97
135
|
if (!m) return false;
|
|
@@ -144,10 +182,12 @@ function sameOriginEndpoint(value, issuer) {
|
|
|
144
182
|
}
|
|
145
183
|
}
|
|
146
184
|
async function readJsonCapped(res) {
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
185
|
+
const read = await readCapped(res, MAX_METADATA_BYTES);
|
|
186
|
+
if (!read.ok) {
|
|
187
|
+
throw read.reason === "too_large" ? new ConnectError("not_a_pod", "Metadata document too large") : new ConnectError("pod_unreachable", "The metadata answer broke off");
|
|
188
|
+
}
|
|
149
189
|
try {
|
|
150
|
-
return JSON.parse(text);
|
|
190
|
+
return JSON.parse(read.text);
|
|
151
191
|
} catch {
|
|
152
192
|
throw new ConnectError("not_a_pod", "Metadata is not JSON");
|
|
153
193
|
}
|
|
@@ -160,18 +200,27 @@ async function discoverIssuer(issuer, opts) {
|
|
|
160
200
|
method: "GET",
|
|
161
201
|
headers: { Accept: "application/json" },
|
|
162
202
|
redirect: "manual",
|
|
163
|
-
signal:
|
|
203
|
+
signal: timeoutSignal(TIMEOUT_MS)
|
|
164
204
|
});
|
|
165
205
|
} catch {
|
|
166
206
|
throw new ConnectError("pod_unreachable", "The pod did not answer");
|
|
167
207
|
}
|
|
208
|
+
if (res.status >= 300 && res.status < 400) {
|
|
209
|
+
await res.body?.cancel().catch(() => {
|
|
210
|
+
});
|
|
211
|
+
throw new ConnectError("pod_moved", `The address answered ${res.status}: the pod moved (its new address is not followed)`);
|
|
212
|
+
}
|
|
168
213
|
if (res.status === 429) {
|
|
169
214
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
170
215
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
171
216
|
});
|
|
172
217
|
}
|
|
173
218
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
|
|
174
|
-
if (res.status !== 200)
|
|
219
|
+
if (res.status !== 200) {
|
|
220
|
+
await res.body?.cancel().catch(() => {
|
|
221
|
+
});
|
|
222
|
+
throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
|
|
223
|
+
}
|
|
175
224
|
const doc = await readJsonCapped(res);
|
|
176
225
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
|
|
177
226
|
const m = doc;
|
|
@@ -197,21 +246,38 @@ async function discoverIssuer(issuer, opts) {
|
|
|
197
246
|
refreshSupported: list(m.grant_types_supported)?.includes("refresh_token") ?? false
|
|
198
247
|
};
|
|
199
248
|
}
|
|
249
|
+
var CLOUD_DOMAIN = "synap.live";
|
|
250
|
+
var CLOUD_POD_ZONE = `pod.${CLOUD_DOMAIN}`;
|
|
251
|
+
var POD_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
252
|
+
function fallbackIssuers(first) {
|
|
253
|
+
const url = new URL(first);
|
|
254
|
+
const host = url.hostname;
|
|
255
|
+
if (url.pathname !== "/" || !host.includes(".") || host.endsWith(`.${CLOUD_POD_ZONE}`)) return [];
|
|
256
|
+
const label = host.endsWith(`.${CLOUD_DOMAIN}`) ? host.slice(0, -CLOUD_DOMAIN.length - 1) : null;
|
|
257
|
+
if (label !== null && POD_NAME_RE.test(label)) {
|
|
258
|
+
return [`${url.protocol}//${label}.${CLOUD_POD_ZONE}`, `${url.protocol}//pod.${url.host}`];
|
|
259
|
+
}
|
|
260
|
+
return host.startsWith("pod.") ? [] : [`${url.protocol}//pod.${url.host}`];
|
|
261
|
+
}
|
|
200
262
|
async function discoverPod(input, opts = {}) {
|
|
201
263
|
const first = canonicalPodUrl(input, opts);
|
|
264
|
+
let firstError;
|
|
202
265
|
try {
|
|
203
266
|
return await discoverIssuer(first, opts);
|
|
204
267
|
} catch (err) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
268
|
+
firstError = err;
|
|
269
|
+
}
|
|
270
|
+
const retryable = (err) => err instanceof ConnectError && (err.code === "not_a_pod" || err.code === "pod_unreachable" || err.code === "pod_moved");
|
|
271
|
+
if (!retryable(firstError)) throw firstError;
|
|
272
|
+
let tooOld = null;
|
|
273
|
+
for (const candidate of fallbackIssuers(first)) {
|
|
209
274
|
try {
|
|
210
|
-
return await discoverIssuer(
|
|
211
|
-
} catch (
|
|
212
|
-
|
|
275
|
+
return await discoverIssuer(candidate, opts);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
|
|
213
278
|
}
|
|
214
279
|
}
|
|
280
|
+
throw tooOld ?? firstError;
|
|
215
281
|
}
|
|
216
282
|
function safeNextPath(value, appOrigin) {
|
|
217
283
|
if (!value || value.length > 2048) return null;
|
|
@@ -246,10 +312,10 @@ function acceptLaunch(url, appOrigin, opts = {}) {
|
|
|
246
312
|
return {
|
|
247
313
|
pod,
|
|
248
314
|
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
249
|
-
spaceHint: hint &&
|
|
315
|
+
spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
|
|
250
316
|
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
251
317
|
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
252
|
-
cloudState: state &&
|
|
318
|
+
cloudState: state && CLOUD_STATE_RE.test(state) ? state : null
|
|
253
319
|
};
|
|
254
320
|
}
|
|
255
321
|
|
|
@@ -338,8 +404,6 @@ async function unseal(sealed, opts) {
|
|
|
338
404
|
|
|
339
405
|
// src/connect/cloud.ts
|
|
340
406
|
var CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
341
|
-
var STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
342
|
-
var LOOPBACK_HOSTS2 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
343
407
|
function cloudBaseUrl(cloudUrl, opts = {}) {
|
|
344
408
|
let url;
|
|
345
409
|
try {
|
|
@@ -347,7 +411,7 @@ function cloudBaseUrl(cloudUrl, opts = {}) {
|
|
|
347
411
|
} catch {
|
|
348
412
|
throw new ConnectError("misconfigured", "cloudUrl is not a URL");
|
|
349
413
|
}
|
|
350
|
-
const loopback =
|
|
414
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
351
415
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
|
|
352
416
|
throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
|
|
353
417
|
}
|
|
@@ -415,7 +479,7 @@ async function acceptCloudReturn(opts) {
|
|
|
415
479
|
}
|
|
416
480
|
const params = new URL(opts.url).searchParams;
|
|
417
481
|
const state = params.get("state");
|
|
418
|
-
if (state === null || !
|
|
482
|
+
if (state === null || !CLOUD_STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
|
|
419
483
|
throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
|
|
420
484
|
}
|
|
421
485
|
const error = params.get("error");
|
|
@@ -433,6 +497,12 @@ async function acceptCloudReturn(opts) {
|
|
|
433
497
|
}
|
|
434
498
|
|
|
435
499
|
// src/connect/client.ts
|
|
500
|
+
function connectionActsAs(connection) {
|
|
501
|
+
return connection.actsAs === "person" ? "person" : "agent";
|
|
502
|
+
}
|
|
503
|
+
function humanSlots(session) {
|
|
504
|
+
return session.expectedOutputs.filter((slot) => slot.owner === "human" && slot.status !== "done" && !slot.retiredAt);
|
|
505
|
+
}
|
|
436
506
|
function can(connection, permission, workspaceId) {
|
|
437
507
|
return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
|
|
438
508
|
}
|
|
@@ -441,23 +511,50 @@ function spaces(connection) {
|
|
|
441
511
|
for (const g of connection.grant) {
|
|
442
512
|
const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
|
|
443
513
|
if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
|
|
514
|
+
if (!space.templateSlug && g.templateSlug) space.templateSlug = g.templateSlug;
|
|
444
515
|
if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
|
|
445
516
|
bySpace.set(g.workspaceId, space);
|
|
446
517
|
}
|
|
447
518
|
return [...bySpace.values()];
|
|
448
519
|
}
|
|
520
|
+
var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
521
|
+
var PROPOSAL_IDS_MAX = 100;
|
|
449
522
|
var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
|
|
523
|
+
var TIMEOUT_MS2 = 1e4;
|
|
524
|
+
var MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
450
525
|
function hubUrl(podUrl, path, query) {
|
|
451
|
-
if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\"))
|
|
452
|
-
|
|
526
|
+
if (!SAFE_PATH.test(path) || /%2e/i.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) return null;
|
|
527
|
+
let url;
|
|
528
|
+
try {
|
|
529
|
+
url = new URL(`${podUrl}/api/hub${path}`);
|
|
530
|
+
} catch {
|
|
531
|
+
return null;
|
|
453
532
|
}
|
|
454
|
-
|
|
533
|
+
if (url.origin !== new URL(podUrl).origin || !(url.pathname === "/api/hub" || url.pathname.startsWith("/api/hub/"))) return null;
|
|
455
534
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
456
535
|
if (value === void 0 || value === null) continue;
|
|
457
536
|
for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
|
|
458
537
|
}
|
|
459
538
|
return url;
|
|
460
539
|
}
|
|
540
|
+
function renewalError(err) {
|
|
541
|
+
const e = isConnectError(err) ? err : null;
|
|
542
|
+
const detail = e?.message ?? (err instanceof Error ? err.message : "The connection could not be renewed");
|
|
543
|
+
switch (e?.code) {
|
|
544
|
+
case "revoked":
|
|
545
|
+
return { code: "unauthorized", status: 401, detail };
|
|
546
|
+
case "expired":
|
|
547
|
+
return { code: "expired", status: null, detail };
|
|
548
|
+
case "rate_limited":
|
|
549
|
+
return { code: "rate_limited", status: null, detail, ...e.retryAfterSec !== void 0 ? { retryAfterSec: e.retryAfterSec } : {} };
|
|
550
|
+
case "pod_unreachable":
|
|
551
|
+
return { code: "pod_unreachable", status: null, detail };
|
|
552
|
+
case "pod_moved":
|
|
553
|
+
return { code: "pod_moved", status: null, detail };
|
|
554
|
+
default:
|
|
555
|
+
return { code: "server", status: null, detail };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
461
558
|
function errorFor(status, body, retryAfter, now) {
|
|
462
559
|
const obj = body && typeof body === "object" ? body : {};
|
|
463
560
|
const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : void 0;
|
|
@@ -472,6 +569,7 @@ function errorFor(status, body, retryAfter, now) {
|
|
|
472
569
|
return { code: "rate_limited", ...base, ...retryAfterSec !== void 0 ? { retryAfterSec } : {} };
|
|
473
570
|
}
|
|
474
571
|
if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
|
|
572
|
+
if (status >= 300 && status < 400) return { code: "pod_moved", ...base };
|
|
475
573
|
return { code: "server", ...base };
|
|
476
574
|
}
|
|
477
575
|
function reviewUrlFor(podUrl, reviewUrl, proposalId) {
|
|
@@ -484,13 +582,37 @@ function reviewUrlFor(podUrl, reviewUrl, proposalId) {
|
|
|
484
582
|
}
|
|
485
583
|
return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
|
|
486
584
|
}
|
|
585
|
+
function refused(code, reason, detail) {
|
|
586
|
+
return { ok: false, error: { code, status: null, detail, reason } };
|
|
587
|
+
}
|
|
588
|
+
function asObject(value) {
|
|
589
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
590
|
+
}
|
|
591
|
+
function hasId(value) {
|
|
592
|
+
return asObject(value) !== null && typeof value.id === "string";
|
|
593
|
+
}
|
|
487
594
|
function createPodClient(connection, opts = {}) {
|
|
488
595
|
const now = opts.now ?? Date.now;
|
|
489
596
|
const doFetch = () => opts.fetch ?? fetch;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
597
|
+
let current = connection;
|
|
598
|
+
let renewal = null;
|
|
599
|
+
function renewOnce(refused2) {
|
|
600
|
+
if (current !== refused2) return Promise.resolve({ ok: true, data: current });
|
|
601
|
+
renewal ??= opts.renew(refused2).then(
|
|
602
|
+
(next) => {
|
|
603
|
+
current = next;
|
|
604
|
+
return { ok: true, data: next };
|
|
605
|
+
},
|
|
606
|
+
(err) => ({ ok: false, error: renewalError(err) })
|
|
607
|
+
);
|
|
608
|
+
return renewal;
|
|
609
|
+
}
|
|
610
|
+
async function request(method, path, body, query, retried = false) {
|
|
611
|
+
const sent = current;
|
|
612
|
+
const url = hubUrl(sent.podUrl, path, query);
|
|
613
|
+
if (!url) return refused("invalid", "invalid_input", "Not a Hub API path: encode each path segment (encodeURIComponent)");
|
|
614
|
+
if (sent.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
|
|
615
|
+
const headers = { Accept: "application/json", Authorization: `Bearer ${sent.accessToken}` };
|
|
494
616
|
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
495
617
|
let res;
|
|
496
618
|
try {
|
|
@@ -498,21 +620,33 @@ function createPodClient(connection, opts = {}) {
|
|
|
498
620
|
method,
|
|
499
621
|
headers,
|
|
500
622
|
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
501
|
-
redirect: "manual"
|
|
623
|
+
redirect: "manual",
|
|
624
|
+
signal: timeoutSignal(opts.timeoutMs ?? TIMEOUT_MS2)
|
|
502
625
|
});
|
|
503
626
|
} catch {
|
|
504
627
|
return { ok: false, error: { code: "pod_unreachable", status: null } };
|
|
505
628
|
}
|
|
629
|
+
const read2 = await readCapped(res, opts.maxResponseBytes ?? MAX_RESPONSE_BYTES);
|
|
630
|
+
if (!read2.ok) {
|
|
631
|
+
return read2.reason === "too_large" ? { ok: false, error: { code: "server", status: res.status, detail: "The pod's answer is larger than maxResponseBytes" } } : { ok: false, error: { code: "pod_unreachable", status: res.status, detail: "The pod's answer broke off" } };
|
|
632
|
+
}
|
|
506
633
|
let data = null;
|
|
507
|
-
|
|
508
|
-
if (text) {
|
|
634
|
+
if (read2.text) {
|
|
509
635
|
try {
|
|
510
|
-
data = JSON.parse(text);
|
|
636
|
+
data = JSON.parse(read2.text);
|
|
511
637
|
} catch {
|
|
512
638
|
data = null;
|
|
513
639
|
}
|
|
514
640
|
}
|
|
515
641
|
if (res.status >= 200 && res.status < 300) return { ok: true, data };
|
|
642
|
+
if (res.status === 401 && sent.refreshToken) {
|
|
643
|
+
if (!opts.renew) return { ok: false, error: { ...errorFor(401, data, null, now()), refreshable: true } };
|
|
644
|
+
if (!retried) {
|
|
645
|
+
const renewed = await renewOnce(sent);
|
|
646
|
+
if (!renewed.ok) return renewed;
|
|
647
|
+
return request(method, path, body, query, true);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
516
650
|
return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
|
|
517
651
|
}
|
|
518
652
|
async function write(method, path, body) {
|
|
@@ -525,18 +659,23 @@ function createPodClient(connection, opts = {}) {
|
|
|
525
659
|
ok: true,
|
|
526
660
|
status: "proposed",
|
|
527
661
|
proposalId,
|
|
528
|
-
reviewUrl: reviewUrlFor(
|
|
662
|
+
reviewUrl: reviewUrlFor(current.podUrl, obj.reviewUrl, proposalId),
|
|
529
663
|
data: result.data
|
|
530
664
|
};
|
|
531
665
|
}
|
|
532
666
|
return { ok: true, status: "applied", data: result.data };
|
|
533
667
|
}
|
|
534
668
|
async function listPendingProposals(opts2 = {}) {
|
|
535
|
-
if (!can(
|
|
536
|
-
return
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
669
|
+
if (!can(current, "proposal.read", opts2.workspaceId)) {
|
|
670
|
+
return refused("forbidden", "not_granted", "The connection was not granted proposal.read in that space");
|
|
671
|
+
}
|
|
672
|
+
let only = null;
|
|
673
|
+
if (opts2.proposalIds !== void 0) {
|
|
674
|
+
if (opts2.proposalIds.length > PROPOSAL_IDS_MAX || opts2.proposalIds.some((id) => !ID_RE.test(id))) {
|
|
675
|
+
return refused("invalid", "invalid_input", `proposalIds must be at most ${PROPOSAL_IDS_MAX} proposal ids`);
|
|
676
|
+
}
|
|
677
|
+
if (opts2.proposalIds.length === 0) return { ok: true, data: { total: 0, proposals: [], hasMore: false } };
|
|
678
|
+
only = new Set(opts2.proposalIds);
|
|
540
679
|
}
|
|
541
680
|
const limit = Math.min(Math.max(Math.floor(opts2.limit ?? 20), 1), 200);
|
|
542
681
|
const offset = Math.max(Math.floor(opts2.offset ?? 0), 0);
|
|
@@ -545,7 +684,8 @@ function createPodClient(connection, opts = {}) {
|
|
|
545
684
|
view: "basic",
|
|
546
685
|
limit,
|
|
547
686
|
offset,
|
|
548
|
-
workspaceId: opts2.workspaceId
|
|
687
|
+
workspaceId: opts2.workspaceId,
|
|
688
|
+
...only ? { proposalIds: [...only].join(",") } : {}
|
|
549
689
|
});
|
|
550
690
|
if (!result.ok) return result;
|
|
551
691
|
const obj = result.data && typeof result.data === "object" ? result.data : {};
|
|
@@ -558,7 +698,7 @@ function createPodClient(connection, opts = {}) {
|
|
|
558
698
|
if (!row || typeof row !== "object") continue;
|
|
559
699
|
const r = row;
|
|
560
700
|
const id = str(r.id);
|
|
561
|
-
if (!id) continue;
|
|
701
|
+
if (!id || only && !only.has(id)) continue;
|
|
562
702
|
proposals.push({
|
|
563
703
|
id,
|
|
564
704
|
proposalType: str(r.proposalType) ?? "",
|
|
@@ -569,7 +709,7 @@ function createPodClient(connection, opts = {}) {
|
|
|
569
709
|
agentUserId: str(r.agentUserId),
|
|
570
710
|
sessionId: str(r.sessionId),
|
|
571
711
|
...str(r.summary) ? { summary: str(r.summary) } : {},
|
|
572
|
-
reviewUrl: reviewUrlFor(
|
|
712
|
+
reviewUrl: reviewUrlFor(current.podUrl, null, id)
|
|
573
713
|
});
|
|
574
714
|
}
|
|
575
715
|
return {
|
|
@@ -577,13 +717,131 @@ function createPodClient(connection, opts = {}) {
|
|
|
577
717
|
data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total }
|
|
578
718
|
};
|
|
579
719
|
}
|
|
720
|
+
async function read(path, query, shape) {
|
|
721
|
+
const result = await request("GET", path, void 0, query);
|
|
722
|
+
if (!result.ok) return result;
|
|
723
|
+
const data = shape(result.data);
|
|
724
|
+
if (data === null) return { ok: false, error: { code: "server", status: 200, detail: `The pod's ${path.split("/")[1]} answer is not the expected shape` } };
|
|
725
|
+
return { ok: true, data };
|
|
726
|
+
}
|
|
727
|
+
const badId = (what) => refused("invalid", "invalid_input", `Not a ${what} id`);
|
|
728
|
+
const clamp = (n, min, max) => n === void 0 ? void 0 : Math.min(Math.max(Math.floor(n), min), max);
|
|
729
|
+
function listEntities(opts2 = {}) {
|
|
730
|
+
return read("/entities", {
|
|
731
|
+
workspaceId: opts2.workspaceId,
|
|
732
|
+
profileSlug: opts2.profileSlug,
|
|
733
|
+
limit: clamp(opts2.limit, 1, 1e3),
|
|
734
|
+
offset: clamp(opts2.offset, 0, Number.MAX_SAFE_INTEGER),
|
|
735
|
+
sort: opts2.sort,
|
|
736
|
+
includePodWide: opts2.includePodWide
|
|
737
|
+
}, (data) => Array.isArray(data) ? data.filter(hasId) : null);
|
|
738
|
+
}
|
|
739
|
+
async function getEntity(id) {
|
|
740
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
741
|
+
return read(`/entities/${id}`, void 0, (data) => hasId(data) ? data : null);
|
|
742
|
+
}
|
|
743
|
+
async function getEntityConnections(id, opts2 = {}) {
|
|
744
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
745
|
+
return read(`/entities/${id}/connections`, { workspaceId: opts2.workspaceId, limit: clamp(opts2.limit, 1, 200) }, (data) => {
|
|
746
|
+
const obj = asObject(data);
|
|
747
|
+
if (!obj || !Array.isArray(obj.connections)) return null;
|
|
748
|
+
const connections = obj.connections.filter(
|
|
749
|
+
(c) => !!c && typeof c === "object" && typeof c.entityId === "string"
|
|
750
|
+
);
|
|
751
|
+
const counts = asObject(obj.counts) ?? {};
|
|
752
|
+
const count = (key, source) => typeof counts[key] === "number" ? counts[key] : connections.filter((c) => c.source === source).length;
|
|
753
|
+
return {
|
|
754
|
+
connections,
|
|
755
|
+
counts: {
|
|
756
|
+
total: typeof counts.total === "number" ? counts.total : connections.length,
|
|
757
|
+
graph: count("graph", "graph"),
|
|
758
|
+
structural: count("structural", "property"),
|
|
759
|
+
threads: count("threads", "thread"),
|
|
760
|
+
contextChannels: count("contextChannels", "context_channel"),
|
|
761
|
+
focusSessions: count("focusSessions", "focus_session")
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
async function getDocument(id, opts2 = {}) {
|
|
767
|
+
if (!ID_RE.test(id)) return badId("document");
|
|
768
|
+
return read(`/documents/${id}`, { format: opts2.format }, (data) => {
|
|
769
|
+
const doc = asObject(asObject(data)?.document);
|
|
770
|
+
if (!doc || typeof doc.id !== "string" || typeof doc.content !== "string") return null;
|
|
771
|
+
return { ...doc, sections: Array.isArray(doc.sections) ? doc.sections : [] };
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
async function listFocusSessions(opts2) {
|
|
775
|
+
if (!opts2?.workspaceId && !opts2?.projectId) return refused("invalid", "invalid_input", "workspaceId or projectId is required");
|
|
776
|
+
return read("/focus-sessions", {
|
|
777
|
+
workspaceId: opts2.workspaceId,
|
|
778
|
+
projectId: opts2.projectId,
|
|
779
|
+
status: opts2.status,
|
|
780
|
+
limit: clamp(opts2.limit, 1, 50),
|
|
781
|
+
lens: opts2.lens,
|
|
782
|
+
kind: opts2.kind,
|
|
783
|
+
subjectEntityId: opts2.subjectEntityId
|
|
784
|
+
}, (data) => {
|
|
785
|
+
if (!Array.isArray(data)) return null;
|
|
786
|
+
return data.filter(hasId).map((row) => ({
|
|
787
|
+
...row,
|
|
788
|
+
expectedOutputs: Array.isArray(row.expectedOutputs) ? row.expectedOutputs.filter((s) => !!s && typeof s === "object" && typeof s.label === "string") : []
|
|
789
|
+
}));
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
async function getFileUrl(entityId) {
|
|
793
|
+
if (!ID_RE.test(entityId)) return badId("file");
|
|
794
|
+
return read(`/files/${entityId}/url`, void 0, (data) => {
|
|
795
|
+
const obj = asObject(data);
|
|
796
|
+
if (!obj || typeof obj.url !== "string" || typeof obj.expiresAt !== "string") return null;
|
|
797
|
+
return { url: obj.url, expiresAt: obj.expiresAt, mimeType: typeof obj.mimeType === "string" ? obj.mimeType : null };
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
async function approveProposal(id, opts2 = {}) {
|
|
801
|
+
if (connectionActsAs(current) !== "person") {
|
|
802
|
+
return refused("forbidden", "agent_cannot_approve", "An agent connection can never approve: approval is the person's");
|
|
803
|
+
}
|
|
804
|
+
if (!can(current, "proposal.approve", opts2.workspaceId)) {
|
|
805
|
+
return refused("forbidden", "not_granted", "The connection was not granted proposal.approve in that space");
|
|
806
|
+
}
|
|
807
|
+
if (!ID_RE.test(id)) return badId("proposal");
|
|
808
|
+
const result = await request("POST", `/proposals/${id}/approve`, {});
|
|
809
|
+
if (!result.ok) return result;
|
|
810
|
+
const obj = asObject(result.data);
|
|
811
|
+
if (!obj || typeof obj.proposalId !== "string") {
|
|
812
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's approve answer names no proposalId" } };
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
ok: true,
|
|
816
|
+
data: {
|
|
817
|
+
proposalId: obj.proposalId,
|
|
818
|
+
...typeof obj.primaryId === "string" ? { primaryId: obj.primaryId } : {},
|
|
819
|
+
...typeof obj.linked === "number" ? { linked: obj.linked } : {},
|
|
820
|
+
proposal: asObject(obj.proposal)
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
580
824
|
return {
|
|
581
825
|
podUrl: connection.podUrl,
|
|
582
|
-
user
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
826
|
+
get user() {
|
|
827
|
+
return current.user;
|
|
828
|
+
},
|
|
829
|
+
get grant() {
|
|
830
|
+
return current.grant;
|
|
831
|
+
},
|
|
832
|
+
get actsAs() {
|
|
833
|
+
return connectionActsAs(current);
|
|
834
|
+
},
|
|
835
|
+
can: (permission, workspaceId) => can(current, permission, workspaceId),
|
|
836
|
+
spaces: () => spaces(current),
|
|
586
837
|
listPendingProposals,
|
|
838
|
+
listEntities,
|
|
839
|
+
getEntity,
|
|
840
|
+
getEntityConnections,
|
|
841
|
+
getDocument,
|
|
842
|
+
listFocusSessions,
|
|
843
|
+
getFileUrl,
|
|
844
|
+
approveProposal,
|
|
587
845
|
get(path, query) {
|
|
588
846
|
return request("GET", path, void 0, query);
|
|
589
847
|
},
|
|
@@ -606,7 +864,8 @@ function createPodClient(connection, opts = {}) {
|
|
|
606
864
|
var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
607
865
|
var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
608
866
|
var DEFAULT_ACCESS_TTL_SEC = 3600;
|
|
609
|
-
var
|
|
867
|
+
var TIMEOUT_MS3 = 1e4;
|
|
868
|
+
var MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
610
869
|
var PERMISSION_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i;
|
|
611
870
|
function assertPermissions(permissions) {
|
|
612
871
|
const unique = [...new Set(permissions)];
|
|
@@ -724,20 +983,29 @@ async function tokenRequest(endpoint, body, during, opts) {
|
|
|
724
983
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
725
984
|
body: body.toString(),
|
|
726
985
|
redirect: "manual",
|
|
727
|
-
signal:
|
|
986
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
728
987
|
});
|
|
729
988
|
} catch {
|
|
730
989
|
throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
|
|
731
990
|
}
|
|
991
|
+
if (res.status >= 300 && res.status < 400) {
|
|
992
|
+
await res.body?.cancel().catch(() => {
|
|
993
|
+
});
|
|
994
|
+
throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
|
|
995
|
+
}
|
|
732
996
|
if (res.status === 429) {
|
|
733
997
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
734
998
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
735
999
|
});
|
|
736
1000
|
}
|
|
737
1001
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
|
|
1002
|
+
const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
1003
|
+
if (!read.ok) {
|
|
1004
|
+
throw read.reason === "too_large" ? new ConnectError("server_error", "The token endpoint's answer is too large") : new ConnectError("pod_unreachable", "The token endpoint's answer broke off");
|
|
1005
|
+
}
|
|
738
1006
|
let json;
|
|
739
1007
|
try {
|
|
740
|
-
json =
|
|
1008
|
+
json = JSON.parse(read.text);
|
|
741
1009
|
} catch {
|
|
742
1010
|
throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
|
|
743
1011
|
}
|
|
@@ -753,14 +1021,18 @@ function parseGrant(value) {
|
|
|
753
1021
|
const out = [];
|
|
754
1022
|
for (const g of value) {
|
|
755
1023
|
if (!g || typeof g !== "object") return null;
|
|
756
|
-
const { permission, workspaceId, workspaceName } = g;
|
|
1024
|
+
const { permission, workspaceId, workspaceName, templateSlug } = g;
|
|
757
1025
|
if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
|
|
758
|
-
|
|
1026
|
+
const row = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
|
|
1027
|
+
if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
|
|
1028
|
+
out.push(row);
|
|
759
1029
|
}
|
|
760
1030
|
return out;
|
|
761
1031
|
}
|
|
1032
|
+
function parseActsAs(value) {
|
|
1033
|
+
return value === "person" || value === "agent" ? value : void 0;
|
|
1034
|
+
}
|
|
762
1035
|
var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
763
|
-
var LOOPBACK_HOSTS3 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
764
1036
|
function parseApp(value) {
|
|
765
1037
|
if (!value || typeof value !== "object") return {};
|
|
766
1038
|
const { id, manageUrl } = value;
|
|
@@ -769,7 +1041,7 @@ function parseApp(value) {
|
|
|
769
1041
|
if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
|
|
770
1042
|
try {
|
|
771
1043
|
const url = new URL(manageUrl);
|
|
772
|
-
const secure = url.protocol === "https:" || url.protocol === "http:" &&
|
|
1044
|
+
const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
773
1045
|
if (secure && !url.username && !url.password) out.manageUrl = url.href;
|
|
774
1046
|
} catch {
|
|
775
1047
|
}
|
|
@@ -815,6 +1087,8 @@ function parseTokenResponse(body, ctx) {
|
|
|
815
1087
|
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
816
1088
|
if (appId !== void 0) connection.appId = appId;
|
|
817
1089
|
if (manageUrl !== void 0) connection.manageUrl = manageUrl;
|
|
1090
|
+
const actsAs = parseActsAs(body.synap_acts_as);
|
|
1091
|
+
if (actsAs !== void 0) connection.actsAs = actsAs;
|
|
818
1092
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
819
1093
|
connection.refreshToken = refresh_token;
|
|
820
1094
|
const idle = typeof refresh_token_expires_in === "number" && Number.isFinite(refresh_token_expires_in) && refresh_token_expires_in > 0 ? refresh_token_expires_in * 1e3 : DEFAULT_REFRESH_IDLE_MS;
|
|
@@ -880,7 +1154,9 @@ async function revokeConnection(connection, opts = {}) {
|
|
|
880
1154
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
881
1155
|
body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
|
|
882
1156
|
redirect: "manual",
|
|
883
|
-
signal:
|
|
1157
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
1158
|
+
});
|
|
1159
|
+
await res.body?.cancel().catch(() => {
|
|
884
1160
|
});
|
|
885
1161
|
if (res.status !== 200) revoked = false;
|
|
886
1162
|
} catch {
|
|
@@ -922,6 +1198,7 @@ function pack(c) {
|
|
|
922
1198
|
for (const g of c.grant) {
|
|
923
1199
|
const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
924
1200
|
row[2].push(g.permission);
|
|
1201
|
+
if (g.templateSlug && row[3] === void 0) row[3] = g.templateSlug;
|
|
925
1202
|
bySpace.set(g.workspaceId, row);
|
|
926
1203
|
}
|
|
927
1204
|
const { grant: _grant, ...rest } = c;
|
|
@@ -931,7 +1208,7 @@ function unpack(s) {
|
|
|
931
1208
|
if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
|
|
932
1209
|
const { g, ...rest } = s;
|
|
933
1210
|
const grant = g.flatMap(
|
|
934
|
-
([workspaceId, workspaceName, permissions]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName }))
|
|
1211
|
+
([workspaceId, workspaceName, permissions, templateSlug]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName, ...typeof templateSlug === "string" ? { templateSlug } : {} }))
|
|
935
1212
|
);
|
|
936
1213
|
return { ...rest, grant };
|
|
937
1214
|
}
|
|
@@ -1010,9 +1287,30 @@ var DEFAULT_PATHS = {
|
|
|
1010
1287
|
afterConnect: "/"
|
|
1011
1288
|
};
|
|
1012
1289
|
var REFRESH_WINDOW_MS = 5 * 60 * 1e3;
|
|
1013
|
-
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1014
1290
|
var TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
1015
1291
|
var MAX_METADATA_BYTES2 = 5 * 1024;
|
|
1292
|
+
var MAX_REQUESTS = 32;
|
|
1293
|
+
var KEPT_RETRY_MS = 6e4;
|
|
1294
|
+
var GRANT_ACTIONS = ["read", "create", "update", "delete"];
|
|
1295
|
+
var SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
|
|
1296
|
+
var PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
1297
|
+
function isSynapAppOrigin(origin) {
|
|
1298
|
+
return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
|
|
1299
|
+
}
|
|
1300
|
+
function assertAppPermission(permission, origin) {
|
|
1301
|
+
const segs = permission.split(".");
|
|
1302
|
+
const fail = (why) => {
|
|
1303
|
+
throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
|
|
1304
|
+
};
|
|
1305
|
+
if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
|
|
1306
|
+
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
1307
|
+
}
|
|
1308
|
+
if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
|
|
1309
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && isSynapAppOrigin(origin)) return;
|
|
1310
|
+
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
1311
|
+
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1016
1314
|
function appOriginOf(appUrl) {
|
|
1017
1315
|
let url;
|
|
1018
1316
|
try {
|
|
@@ -1021,8 +1319,11 @@ function appOriginOf(appUrl) {
|
|
|
1021
1319
|
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
1022
1320
|
}
|
|
1023
1321
|
const secure = url.protocol === "https:";
|
|
1024
|
-
if (!secure && !(url.protocol === "http:" &&
|
|
1025
|
-
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
1322
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
1323
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
|
|
1324
|
+
}
|
|
1325
|
+
if (secure && isIpLiteral(url.hostname)) {
|
|
1326
|
+
throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
|
|
1026
1327
|
}
|
|
1027
1328
|
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
1028
1329
|
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
@@ -1040,9 +1341,12 @@ function buildClientMetadata(opts) {
|
|
|
1040
1341
|
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
1041
1342
|
const clientName = opts.clientName.trim();
|
|
1042
1343
|
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
1344
|
+
if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
|
|
1345
|
+
throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
|
|
1346
|
+
}
|
|
1043
1347
|
assertPermissions(opts.requests.map((r) => r.permission));
|
|
1044
1348
|
for (const r of opts.requests) {
|
|
1045
|
-
|
|
1349
|
+
assertAppPermission(r.permission, origin);
|
|
1046
1350
|
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
1047
1351
|
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
1048
1352
|
}
|
|
@@ -1141,7 +1445,7 @@ function createPodAppAuth(config) {
|
|
|
1141
1445
|
permissions,
|
|
1142
1446
|
secret: config.secret,
|
|
1143
1447
|
resource: config.resource,
|
|
1144
|
-
spaceHint: opts.space &&
|
|
1448
|
+
spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
|
|
1145
1449
|
next: opts.next,
|
|
1146
1450
|
prompt: opts.prompt,
|
|
1147
1451
|
allowInsecureLoopback: config.allowInsecureLoopback
|
|
@@ -1220,7 +1524,7 @@ function createPodAppAuth(config) {
|
|
|
1220
1524
|
clientId,
|
|
1221
1525
|
secret: config.secret,
|
|
1222
1526
|
next,
|
|
1223
|
-
spaceHint: space &&
|
|
1527
|
+
spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
|
|
1224
1528
|
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
1225
1529
|
now
|
|
1226
1530
|
});
|
|
@@ -1253,7 +1557,8 @@ function createPodAppAuth(config) {
|
|
|
1253
1557
|
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
1254
1558
|
}
|
|
1255
1559
|
}
|
|
1256
|
-
|
|
1560
|
+
const keptFailures = /* @__PURE__ */ new Map();
|
|
1561
|
+
async function refreshIfNeeded(request, opts = {}) {
|
|
1257
1562
|
const connection = await store.read(request);
|
|
1258
1563
|
const unchanged = (status, conn, error) => ({
|
|
1259
1564
|
status,
|
|
@@ -1268,18 +1573,27 @@ function createPodAppAuth(config) {
|
|
|
1268
1573
|
}
|
|
1269
1574
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
1270
1575
|
const t = now();
|
|
1271
|
-
const accessLives = connection.accessExpiresAt > t;
|
|
1272
|
-
if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1576
|
+
const accessLives = !opts.force && connection.accessExpiresAt > t;
|
|
1577
|
+
if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1273
1578
|
if (!connection.refreshToken) {
|
|
1274
1579
|
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
1275
1580
|
}
|
|
1581
|
+
for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
|
|
1582
|
+
const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : void 0;
|
|
1583
|
+
if (keptFailure) return unchanged("kept", connection, keptFailure.error);
|
|
1276
1584
|
let next;
|
|
1277
1585
|
try {
|
|
1278
1586
|
next = await refreshConnection(connection, flow);
|
|
1279
1587
|
} catch (err) {
|
|
1280
1588
|
const e = fail(err, "refresh");
|
|
1281
|
-
if (accessLives)
|
|
1282
|
-
|
|
1589
|
+
if (accessLives) {
|
|
1590
|
+
keptFailures.set(connection.refreshToken, { error: e, at: t });
|
|
1591
|
+
return unchanged("kept", connection, e);
|
|
1592
|
+
}
|
|
1593
|
+
if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
|
|
1594
|
+
const cleared = await store.clear(request).catch(() => []);
|
|
1595
|
+
return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
|
|
1596
|
+
}
|
|
1283
1597
|
return unchanged("unavailable", null, e);
|
|
1284
1598
|
}
|
|
1285
1599
|
let setCookies;
|
|
@@ -1298,6 +1612,24 @@ function createPodAppAuth(config) {
|
|
|
1298
1612
|
cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies)
|
|
1299
1613
|
};
|
|
1300
1614
|
}
|
|
1615
|
+
function renewer(persist, source) {
|
|
1616
|
+
return async (connection) => {
|
|
1617
|
+
let next;
|
|
1618
|
+
try {
|
|
1619
|
+
next = await refreshConnection(connection, flow);
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
throw fail(err, "refresh");
|
|
1622
|
+
}
|
|
1623
|
+
try {
|
|
1624
|
+
const cookies = await store.write(next, asRequest(source ?? null));
|
|
1625
|
+
await persist([...cookies, marker(next.podUrl)]);
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
fail(err, "refresh");
|
|
1628
|
+
throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
|
|
1629
|
+
}
|
|
1630
|
+
return next;
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1301
1633
|
return {
|
|
1302
1634
|
clientId,
|
|
1303
1635
|
redirectUri,
|
|
@@ -1311,7 +1643,15 @@ function createPodAppAuth(config) {
|
|
|
1311
1643
|
return await store.read(request) ? null : lapsedIn(request);
|
|
1312
1644
|
},
|
|
1313
1645
|
refreshIfNeeded,
|
|
1314
|
-
client
|
|
1646
|
+
client(connection, opts = {}) {
|
|
1647
|
+
const { persist, request, ...clientOpts } = opts;
|
|
1648
|
+
return createPodClient(connection, {
|
|
1649
|
+
fetch: config.fetch,
|
|
1650
|
+
now: config.now,
|
|
1651
|
+
...persist ? { renew: renewer(persist, request) } : {},
|
|
1652
|
+
...clientOpts
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1315
1655
|
};
|
|
1316
1656
|
}
|
|
1317
1657
|
|
|
@@ -1336,6 +1676,7 @@ export {
|
|
|
1336
1676
|
canonicalPodUrl,
|
|
1337
1677
|
cloudBaseUrl,
|
|
1338
1678
|
cloudConnectAvailable,
|
|
1679
|
+
connectionActsAs,
|
|
1339
1680
|
connectionExpiresAt,
|
|
1340
1681
|
cookieNames,
|
|
1341
1682
|
createPodAppAuth,
|
|
@@ -1343,6 +1684,7 @@ export {
|
|
|
1343
1684
|
createSealedCookieStore,
|
|
1344
1685
|
discoverPod,
|
|
1345
1686
|
handleCallback,
|
|
1687
|
+
humanSlots,
|
|
1346
1688
|
isBrowserRuntime,
|
|
1347
1689
|
isConnectError,
|
|
1348
1690
|
isPrefetchOrAsset,
|