@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/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,134 @@ 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
|
+
facetSlug: opts2.facetSlug,
|
|
738
|
+
// Sent only when asked: the default request stays the 1.5.0 one.
|
|
739
|
+
includeFacets: opts2.includeFacets ? true : void 0
|
|
740
|
+
}, (data) => Array.isArray(data) ? data.filter(hasId) : null);
|
|
741
|
+
}
|
|
742
|
+
async function getEntity(id) {
|
|
743
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
744
|
+
return read(`/entities/${id}`, void 0, (data) => hasId(data) ? data : null);
|
|
745
|
+
}
|
|
746
|
+
async function getEntityConnections(id, opts2 = {}) {
|
|
747
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
748
|
+
return read(`/entities/${id}/connections`, { workspaceId: opts2.workspaceId, limit: clamp(opts2.limit, 1, 200) }, (data) => {
|
|
749
|
+
const obj = asObject(data);
|
|
750
|
+
if (!obj || !Array.isArray(obj.connections)) return null;
|
|
751
|
+
const connections = obj.connections.filter(
|
|
752
|
+
(c) => !!c && typeof c === "object" && typeof c.entityId === "string"
|
|
753
|
+
);
|
|
754
|
+
const counts = asObject(obj.counts) ?? {};
|
|
755
|
+
const count = (key, source) => typeof counts[key] === "number" ? counts[key] : connections.filter((c) => c.source === source).length;
|
|
756
|
+
return {
|
|
757
|
+
connections,
|
|
758
|
+
counts: {
|
|
759
|
+
total: typeof counts.total === "number" ? counts.total : connections.length,
|
|
760
|
+
graph: count("graph", "graph"),
|
|
761
|
+
structural: count("structural", "property"),
|
|
762
|
+
threads: count("threads", "thread"),
|
|
763
|
+
contextChannels: count("contextChannels", "context_channel"),
|
|
764
|
+
focusSessions: count("focusSessions", "focus_session")
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
async function getDocument(id, opts2 = {}) {
|
|
770
|
+
if (!ID_RE.test(id)) return badId("document");
|
|
771
|
+
return read(`/documents/${id}`, { format: opts2.format }, (data) => {
|
|
772
|
+
const doc = asObject(asObject(data)?.document);
|
|
773
|
+
if (!doc || typeof doc.id !== "string" || typeof doc.content !== "string") return null;
|
|
774
|
+
return { ...doc, sections: Array.isArray(doc.sections) ? doc.sections : [] };
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
async function listFocusSessions(opts2) {
|
|
778
|
+
if (!opts2?.workspaceId && !opts2?.projectId) return refused("invalid", "invalid_input", "workspaceId or projectId is required");
|
|
779
|
+
return read("/focus-sessions", {
|
|
780
|
+
workspaceId: opts2.workspaceId,
|
|
781
|
+
projectId: opts2.projectId,
|
|
782
|
+
status: opts2.status,
|
|
783
|
+
limit: clamp(opts2.limit, 1, 50),
|
|
784
|
+
lens: opts2.lens,
|
|
785
|
+
kind: opts2.kind,
|
|
786
|
+
subjectEntityId: opts2.subjectEntityId
|
|
787
|
+
}, (data) => {
|
|
788
|
+
if (!Array.isArray(data)) return null;
|
|
789
|
+
return data.filter(hasId).map((row) => ({
|
|
790
|
+
...row,
|
|
791
|
+
expectedOutputs: Array.isArray(row.expectedOutputs) ? row.expectedOutputs.filter((s) => !!s && typeof s === "object" && typeof s.label === "string") : []
|
|
792
|
+
}));
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
async function getFileUrl(entityId) {
|
|
796
|
+
if (!ID_RE.test(entityId)) return badId("file");
|
|
797
|
+
return read(`/files/${entityId}/url`, void 0, (data) => {
|
|
798
|
+
const obj = asObject(data);
|
|
799
|
+
if (!obj || typeof obj.url !== "string" || typeof obj.expiresAt !== "string") return null;
|
|
800
|
+
return { url: obj.url, expiresAt: obj.expiresAt, mimeType: typeof obj.mimeType === "string" ? obj.mimeType : null };
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
async function approveProposal(id, opts2 = {}) {
|
|
804
|
+
if (connectionActsAs(current) !== "person") {
|
|
805
|
+
return refused("forbidden", "agent_cannot_approve", "An agent connection can never approve: approval is the person's");
|
|
806
|
+
}
|
|
807
|
+
if (!can(current, "proposal.approve", opts2.workspaceId)) {
|
|
808
|
+
return refused("forbidden", "not_granted", "The connection was not granted proposal.approve in that space");
|
|
809
|
+
}
|
|
810
|
+
if (!ID_RE.test(id)) return badId("proposal");
|
|
811
|
+
const result = await request("POST", `/proposals/${id}/approve`, {});
|
|
812
|
+
if (!result.ok) return result;
|
|
813
|
+
const obj = asObject(result.data);
|
|
814
|
+
if (!obj || typeof obj.proposalId !== "string") {
|
|
815
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's approve answer names no proposalId" } };
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
ok: true,
|
|
819
|
+
data: {
|
|
820
|
+
proposalId: obj.proposalId,
|
|
821
|
+
...typeof obj.primaryId === "string" ? { primaryId: obj.primaryId } : {},
|
|
822
|
+
...typeof obj.linked === "number" ? { linked: obj.linked } : {},
|
|
823
|
+
proposal: asObject(obj.proposal)
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
580
827
|
return {
|
|
581
828
|
podUrl: connection.podUrl,
|
|
582
|
-
user
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
829
|
+
get user() {
|
|
830
|
+
return current.user;
|
|
831
|
+
},
|
|
832
|
+
get grant() {
|
|
833
|
+
return current.grant;
|
|
834
|
+
},
|
|
835
|
+
get actsAs() {
|
|
836
|
+
return connectionActsAs(current);
|
|
837
|
+
},
|
|
838
|
+
can: (permission, workspaceId) => can(current, permission, workspaceId),
|
|
839
|
+
spaces: () => spaces(current),
|
|
586
840
|
listPendingProposals,
|
|
841
|
+
listEntities,
|
|
842
|
+
getEntity,
|
|
843
|
+
getEntityConnections,
|
|
844
|
+
getDocument,
|
|
845
|
+
listFocusSessions,
|
|
846
|
+
getFileUrl,
|
|
847
|
+
approveProposal,
|
|
587
848
|
get(path, query) {
|
|
588
849
|
return request("GET", path, void 0, query);
|
|
589
850
|
},
|
|
@@ -606,7 +867,8 @@ function createPodClient(connection, opts = {}) {
|
|
|
606
867
|
var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
607
868
|
var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
608
869
|
var DEFAULT_ACCESS_TTL_SEC = 3600;
|
|
609
|
-
var
|
|
870
|
+
var TIMEOUT_MS3 = 1e4;
|
|
871
|
+
var MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
610
872
|
var PERMISSION_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i;
|
|
611
873
|
function assertPermissions(permissions) {
|
|
612
874
|
const unique = [...new Set(permissions)];
|
|
@@ -724,20 +986,29 @@ async function tokenRequest(endpoint, body, during, opts) {
|
|
|
724
986
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
725
987
|
body: body.toString(),
|
|
726
988
|
redirect: "manual",
|
|
727
|
-
signal:
|
|
989
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
728
990
|
});
|
|
729
991
|
} catch {
|
|
730
992
|
throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
|
|
731
993
|
}
|
|
994
|
+
if (res.status >= 300 && res.status < 400) {
|
|
995
|
+
await res.body?.cancel().catch(() => {
|
|
996
|
+
});
|
|
997
|
+
throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
|
|
998
|
+
}
|
|
732
999
|
if (res.status === 429) {
|
|
733
1000
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
734
1001
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
735
1002
|
});
|
|
736
1003
|
}
|
|
737
1004
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
|
|
1005
|
+
const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
1006
|
+
if (!read.ok) {
|
|
1007
|
+
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");
|
|
1008
|
+
}
|
|
738
1009
|
let json;
|
|
739
1010
|
try {
|
|
740
|
-
json =
|
|
1011
|
+
json = JSON.parse(read.text);
|
|
741
1012
|
} catch {
|
|
742
1013
|
throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
|
|
743
1014
|
}
|
|
@@ -753,14 +1024,18 @@ function parseGrant(value) {
|
|
|
753
1024
|
const out = [];
|
|
754
1025
|
for (const g of value) {
|
|
755
1026
|
if (!g || typeof g !== "object") return null;
|
|
756
|
-
const { permission, workspaceId, workspaceName } = g;
|
|
1027
|
+
const { permission, workspaceId, workspaceName, templateSlug } = g;
|
|
757
1028
|
if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
|
|
758
|
-
|
|
1029
|
+
const row = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
|
|
1030
|
+
if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
|
|
1031
|
+
out.push(row);
|
|
759
1032
|
}
|
|
760
1033
|
return out;
|
|
761
1034
|
}
|
|
1035
|
+
function parseActsAs(value) {
|
|
1036
|
+
return value === "person" || value === "agent" ? value : void 0;
|
|
1037
|
+
}
|
|
762
1038
|
var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
763
|
-
var LOOPBACK_HOSTS3 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
764
1039
|
function parseApp(value) {
|
|
765
1040
|
if (!value || typeof value !== "object") return {};
|
|
766
1041
|
const { id, manageUrl } = value;
|
|
@@ -769,7 +1044,7 @@ function parseApp(value) {
|
|
|
769
1044
|
if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
|
|
770
1045
|
try {
|
|
771
1046
|
const url = new URL(manageUrl);
|
|
772
|
-
const secure = url.protocol === "https:" || url.protocol === "http:" &&
|
|
1047
|
+
const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
773
1048
|
if (secure && !url.username && !url.password) out.manageUrl = url.href;
|
|
774
1049
|
} catch {
|
|
775
1050
|
}
|
|
@@ -815,6 +1090,8 @@ function parseTokenResponse(body, ctx) {
|
|
|
815
1090
|
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
816
1091
|
if (appId !== void 0) connection.appId = appId;
|
|
817
1092
|
if (manageUrl !== void 0) connection.manageUrl = manageUrl;
|
|
1093
|
+
const actsAs = parseActsAs(body.synap_acts_as);
|
|
1094
|
+
if (actsAs !== void 0) connection.actsAs = actsAs;
|
|
818
1095
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
819
1096
|
connection.refreshToken = refresh_token;
|
|
820
1097
|
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 +1157,9 @@ async function revokeConnection(connection, opts = {}) {
|
|
|
880
1157
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
881
1158
|
body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
|
|
882
1159
|
redirect: "manual",
|
|
883
|
-
signal:
|
|
1160
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
1161
|
+
});
|
|
1162
|
+
await res.body?.cancel().catch(() => {
|
|
884
1163
|
});
|
|
885
1164
|
if (res.status !== 200) revoked = false;
|
|
886
1165
|
} catch {
|
|
@@ -922,6 +1201,7 @@ function pack(c) {
|
|
|
922
1201
|
for (const g of c.grant) {
|
|
923
1202
|
const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
924
1203
|
row[2].push(g.permission);
|
|
1204
|
+
if (g.templateSlug && row[3] === void 0) row[3] = g.templateSlug;
|
|
925
1205
|
bySpace.set(g.workspaceId, row);
|
|
926
1206
|
}
|
|
927
1207
|
const { grant: _grant, ...rest } = c;
|
|
@@ -931,7 +1211,7 @@ function unpack(s) {
|
|
|
931
1211
|
if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
|
|
932
1212
|
const { g, ...rest } = s;
|
|
933
1213
|
const grant = g.flatMap(
|
|
934
|
-
([workspaceId, workspaceName, permissions]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName }))
|
|
1214
|
+
([workspaceId, workspaceName, permissions, templateSlug]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName, ...typeof templateSlug === "string" ? { templateSlug } : {} }))
|
|
935
1215
|
);
|
|
936
1216
|
return { ...rest, grant };
|
|
937
1217
|
}
|
|
@@ -1010,9 +1290,34 @@ var DEFAULT_PATHS = {
|
|
|
1010
1290
|
afterConnect: "/"
|
|
1011
1291
|
};
|
|
1012
1292
|
var REFRESH_WINDOW_MS = 5 * 60 * 1e3;
|
|
1013
|
-
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1014
1293
|
var TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
1015
1294
|
var MAX_METADATA_BYTES2 = 5 * 1024;
|
|
1295
|
+
var MAX_REQUESTS = 32;
|
|
1296
|
+
var KEPT_RETRY_MS = 6e4;
|
|
1297
|
+
var GRANT_ACTIONS = ["read", "create", "update", "delete"];
|
|
1298
|
+
var SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
|
|
1299
|
+
var PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
1300
|
+
function isSynapAppOrigin(origin) {
|
|
1301
|
+
return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
|
|
1302
|
+
}
|
|
1303
|
+
function isMockModeOrigin(origin) {
|
|
1304
|
+
const url = new URL(origin);
|
|
1305
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
1306
|
+
}
|
|
1307
|
+
function assertAppPermission(permission, origin) {
|
|
1308
|
+
const segs = permission.split(".");
|
|
1309
|
+
const fail = (why) => {
|
|
1310
|
+
throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
|
|
1311
|
+
};
|
|
1312
|
+
if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
|
|
1313
|
+
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
1314
|
+
}
|
|
1315
|
+
if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
|
|
1316
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && (isSynapAppOrigin(origin) || isMockModeOrigin(origin))) return;
|
|
1317
|
+
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
1318
|
+
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1016
1321
|
function appOriginOf(appUrl) {
|
|
1017
1322
|
let url;
|
|
1018
1323
|
try {
|
|
@@ -1021,8 +1326,11 @@ function appOriginOf(appUrl) {
|
|
|
1021
1326
|
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
1022
1327
|
}
|
|
1023
1328
|
const secure = url.protocol === "https:";
|
|
1024
|
-
if (!secure && !(url.protocol === "http:" &&
|
|
1025
|
-
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
1329
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
1330
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
|
|
1331
|
+
}
|
|
1332
|
+
if (secure && isIpLiteral(url.hostname)) {
|
|
1333
|
+
throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
|
|
1026
1334
|
}
|
|
1027
1335
|
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
1028
1336
|
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
@@ -1040,9 +1348,12 @@ function buildClientMetadata(opts) {
|
|
|
1040
1348
|
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
1041
1349
|
const clientName = opts.clientName.trim();
|
|
1042
1350
|
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
1351
|
+
if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
|
|
1352
|
+
throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
|
|
1353
|
+
}
|
|
1043
1354
|
assertPermissions(opts.requests.map((r) => r.permission));
|
|
1044
1355
|
for (const r of opts.requests) {
|
|
1045
|
-
|
|
1356
|
+
assertAppPermission(r.permission, origin);
|
|
1046
1357
|
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
1047
1358
|
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
1048
1359
|
}
|
|
@@ -1141,7 +1452,7 @@ function createPodAppAuth(config) {
|
|
|
1141
1452
|
permissions,
|
|
1142
1453
|
secret: config.secret,
|
|
1143
1454
|
resource: config.resource,
|
|
1144
|
-
spaceHint: opts.space &&
|
|
1455
|
+
spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
|
|
1145
1456
|
next: opts.next,
|
|
1146
1457
|
prompt: opts.prompt,
|
|
1147
1458
|
allowInsecureLoopback: config.allowInsecureLoopback
|
|
@@ -1220,7 +1531,7 @@ function createPodAppAuth(config) {
|
|
|
1220
1531
|
clientId,
|
|
1221
1532
|
secret: config.secret,
|
|
1222
1533
|
next,
|
|
1223
|
-
spaceHint: space &&
|
|
1534
|
+
spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
|
|
1224
1535
|
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
1225
1536
|
now
|
|
1226
1537
|
});
|
|
@@ -1253,7 +1564,8 @@ function createPodAppAuth(config) {
|
|
|
1253
1564
|
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
1254
1565
|
}
|
|
1255
1566
|
}
|
|
1256
|
-
|
|
1567
|
+
const keptFailures = /* @__PURE__ */ new Map();
|
|
1568
|
+
async function refreshIfNeeded(request, opts = {}) {
|
|
1257
1569
|
const connection = await store.read(request);
|
|
1258
1570
|
const unchanged = (status, conn, error) => ({
|
|
1259
1571
|
status,
|
|
@@ -1268,18 +1580,27 @@ function createPodAppAuth(config) {
|
|
|
1268
1580
|
}
|
|
1269
1581
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
1270
1582
|
const t = now();
|
|
1271
|
-
const accessLives = connection.accessExpiresAt > t;
|
|
1272
|
-
if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1583
|
+
const accessLives = !opts.force && connection.accessExpiresAt > t;
|
|
1584
|
+
if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1273
1585
|
if (!connection.refreshToken) {
|
|
1274
1586
|
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
1275
1587
|
}
|
|
1588
|
+
for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
|
|
1589
|
+
const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : void 0;
|
|
1590
|
+
if (keptFailure) return unchanged("kept", connection, keptFailure.error);
|
|
1276
1591
|
let next;
|
|
1277
1592
|
try {
|
|
1278
1593
|
next = await refreshConnection(connection, flow);
|
|
1279
1594
|
} catch (err) {
|
|
1280
1595
|
const e = fail(err, "refresh");
|
|
1281
|
-
if (accessLives)
|
|
1282
|
-
|
|
1596
|
+
if (accessLives) {
|
|
1597
|
+
keptFailures.set(connection.refreshToken, { error: e, at: t });
|
|
1598
|
+
return unchanged("kept", connection, e);
|
|
1599
|
+
}
|
|
1600
|
+
if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
|
|
1601
|
+
const cleared = await store.clear(request).catch(() => []);
|
|
1602
|
+
return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
|
|
1603
|
+
}
|
|
1283
1604
|
return unchanged("unavailable", null, e);
|
|
1284
1605
|
}
|
|
1285
1606
|
let setCookies;
|
|
@@ -1298,6 +1619,24 @@ function createPodAppAuth(config) {
|
|
|
1298
1619
|
cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies)
|
|
1299
1620
|
};
|
|
1300
1621
|
}
|
|
1622
|
+
function renewer(persist, source) {
|
|
1623
|
+
return async (connection) => {
|
|
1624
|
+
let next;
|
|
1625
|
+
try {
|
|
1626
|
+
next = await refreshConnection(connection, flow);
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
throw fail(err, "refresh");
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
const cookies = await store.write(next, asRequest(source ?? null));
|
|
1632
|
+
await persist([...cookies, marker(next.podUrl)]);
|
|
1633
|
+
} catch (err) {
|
|
1634
|
+
fail(err, "refresh");
|
|
1635
|
+
throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
|
|
1636
|
+
}
|
|
1637
|
+
return next;
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1301
1640
|
return {
|
|
1302
1641
|
clientId,
|
|
1303
1642
|
redirectUri,
|
|
@@ -1311,7 +1650,15 @@ function createPodAppAuth(config) {
|
|
|
1311
1650
|
return await store.read(request) ? null : lapsedIn(request);
|
|
1312
1651
|
},
|
|
1313
1652
|
refreshIfNeeded,
|
|
1314
|
-
client
|
|
1653
|
+
client(connection, opts = {}) {
|
|
1654
|
+
const { persist, request, ...clientOpts } = opts;
|
|
1655
|
+
return createPodClient(connection, {
|
|
1656
|
+
fetch: config.fetch,
|
|
1657
|
+
now: config.now,
|
|
1658
|
+
...persist ? { renew: renewer(persist, request) } : {},
|
|
1659
|
+
...clientOpts
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1315
1662
|
};
|
|
1316
1663
|
}
|
|
1317
1664
|
|
|
@@ -1336,6 +1683,7 @@ export {
|
|
|
1336
1683
|
canonicalPodUrl,
|
|
1337
1684
|
cloudBaseUrl,
|
|
1338
1685
|
cloudConnectAvailable,
|
|
1686
|
+
connectionActsAs,
|
|
1339
1687
|
connectionExpiresAt,
|
|
1340
1688
|
cookieNames,
|
|
1341
1689
|
createPodAppAuth,
|
|
@@ -1343,6 +1691,7 @@ export {
|
|
|
1343
1691
|
createSealedCookieStore,
|
|
1344
1692
|
discoverPod,
|
|
1345
1693
|
handleCallback,
|
|
1694
|
+
humanSlots,
|
|
1346
1695
|
isBrowserRuntime,
|
|
1347
1696
|
isConnectError,
|
|
1348
1697
|
isPrefetchOrAsset,
|