@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.cjs
CHANGED
|
@@ -38,6 +38,7 @@ __export(connect_exports, {
|
|
|
38
38
|
canonicalPodUrl: () => canonicalPodUrl,
|
|
39
39
|
cloudBaseUrl: () => cloudBaseUrl,
|
|
40
40
|
cloudConnectAvailable: () => cloudConnectAvailable,
|
|
41
|
+
connectionActsAs: () => connectionActsAs,
|
|
41
42
|
connectionExpiresAt: () => connectionExpiresAt,
|
|
42
43
|
cookieNames: () => cookieNames,
|
|
43
44
|
createPodAppAuth: () => createPodAppAuth,
|
|
@@ -45,6 +46,7 @@ __export(connect_exports, {
|
|
|
45
46
|
createSealedCookieStore: () => createSealedCookieStore,
|
|
46
47
|
discoverPod: () => discoverPod,
|
|
47
48
|
handleCallback: () => handleCallback,
|
|
49
|
+
humanSlots: () => humanSlots,
|
|
48
50
|
isBrowserRuntime: () => isBrowserRuntime,
|
|
49
51
|
isConnectError: () => isConnectError,
|
|
50
52
|
isPrefetchOrAsset: () => isPrefetchOrAsset,
|
|
@@ -107,6 +109,7 @@ var CONNECT_ERROR_CODES = [
|
|
|
107
109
|
"rate_limited",
|
|
108
110
|
"expired",
|
|
109
111
|
"revoked",
|
|
112
|
+
"pod_moved",
|
|
110
113
|
"server_error"
|
|
111
114
|
];
|
|
112
115
|
function parseConnectErrorCode(value) {
|
|
@@ -142,11 +145,52 @@ function retryAfterSeconds(header, now) {
|
|
|
142
145
|
return Math.max(0, Math.ceil((at - now) / 1e3));
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
// src/connect/http.ts
|
|
149
|
+
function timeoutSignal(ms) {
|
|
150
|
+
return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
|
|
151
|
+
}
|
|
152
|
+
async function readCapped(res, maxBytes) {
|
|
153
|
+
const declared = Number(res.headers.get("content-length"));
|
|
154
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
155
|
+
await res.body?.cancel().catch(() => {
|
|
156
|
+
});
|
|
157
|
+
return { ok: false, reason: "too_large" };
|
|
158
|
+
}
|
|
159
|
+
if (!res.body) return { ok: true, text: "" };
|
|
160
|
+
const reader = res.body.getReader();
|
|
161
|
+
const decoder2 = new TextDecoder();
|
|
162
|
+
let total = 0;
|
|
163
|
+
let text = "";
|
|
164
|
+
try {
|
|
165
|
+
for (; ; ) {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) break;
|
|
168
|
+
total += value.byteLength;
|
|
169
|
+
if (total > maxBytes) {
|
|
170
|
+
await reader.cancel().catch(() => {
|
|
171
|
+
});
|
|
172
|
+
return { ok: false, reason: "too_large" };
|
|
173
|
+
}
|
|
174
|
+
text += decoder2.decode(value, { stream: true });
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
return { ok: false, reason: "unreadable" };
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, text: text + decoder2.decode() };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/connect/rules.ts
|
|
183
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
184
|
+
var SPACE_HINT_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
185
|
+
var CLOUD_STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
186
|
+
function isIpLiteral(hostname) {
|
|
187
|
+
return hostname.startsWith("[") || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
|
|
188
|
+
}
|
|
189
|
+
|
|
145
190
|
// src/connect/discovery.ts
|
|
146
191
|
var METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
147
192
|
var TIMEOUT_MS = 5e3;
|
|
148
193
|
var MAX_METADATA_BYTES = 64 * 1024;
|
|
149
|
-
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
150
194
|
function isPrivateIpv4(host) {
|
|
151
195
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
152
196
|
if (!m) return false;
|
|
@@ -199,10 +243,12 @@ function sameOriginEndpoint(value, issuer) {
|
|
|
199
243
|
}
|
|
200
244
|
}
|
|
201
245
|
async function readJsonCapped(res) {
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
246
|
+
const read = await readCapped(res, MAX_METADATA_BYTES);
|
|
247
|
+
if (!read.ok) {
|
|
248
|
+
throw read.reason === "too_large" ? new ConnectError("not_a_pod", "Metadata document too large") : new ConnectError("pod_unreachable", "The metadata answer broke off");
|
|
249
|
+
}
|
|
204
250
|
try {
|
|
205
|
-
return JSON.parse(text);
|
|
251
|
+
return JSON.parse(read.text);
|
|
206
252
|
} catch {
|
|
207
253
|
throw new ConnectError("not_a_pod", "Metadata is not JSON");
|
|
208
254
|
}
|
|
@@ -215,18 +261,27 @@ async function discoverIssuer(issuer, opts) {
|
|
|
215
261
|
method: "GET",
|
|
216
262
|
headers: { Accept: "application/json" },
|
|
217
263
|
redirect: "manual",
|
|
218
|
-
signal:
|
|
264
|
+
signal: timeoutSignal(TIMEOUT_MS)
|
|
219
265
|
});
|
|
220
266
|
} catch {
|
|
221
267
|
throw new ConnectError("pod_unreachable", "The pod did not answer");
|
|
222
268
|
}
|
|
269
|
+
if (res.status >= 300 && res.status < 400) {
|
|
270
|
+
await res.body?.cancel().catch(() => {
|
|
271
|
+
});
|
|
272
|
+
throw new ConnectError("pod_moved", `The address answered ${res.status}: the pod moved (its new address is not followed)`);
|
|
273
|
+
}
|
|
223
274
|
if (res.status === 429) {
|
|
224
275
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
225
276
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
226
277
|
});
|
|
227
278
|
}
|
|
228
279
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
|
|
229
|
-
if (res.status !== 200)
|
|
280
|
+
if (res.status !== 200) {
|
|
281
|
+
await res.body?.cancel().catch(() => {
|
|
282
|
+
});
|
|
283
|
+
throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
|
|
284
|
+
}
|
|
230
285
|
const doc = await readJsonCapped(res);
|
|
231
286
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
|
|
232
287
|
const m = doc;
|
|
@@ -252,21 +307,38 @@ async function discoverIssuer(issuer, opts) {
|
|
|
252
307
|
refreshSupported: list(m.grant_types_supported)?.includes("refresh_token") ?? false
|
|
253
308
|
};
|
|
254
309
|
}
|
|
310
|
+
var CLOUD_DOMAIN = "synap.live";
|
|
311
|
+
var CLOUD_POD_ZONE = `pod.${CLOUD_DOMAIN}`;
|
|
312
|
+
var POD_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
313
|
+
function fallbackIssuers(first) {
|
|
314
|
+
const url = new URL(first);
|
|
315
|
+
const host = url.hostname;
|
|
316
|
+
if (url.pathname !== "/" || !host.includes(".") || host.endsWith(`.${CLOUD_POD_ZONE}`)) return [];
|
|
317
|
+
const label = host.endsWith(`.${CLOUD_DOMAIN}`) ? host.slice(0, -CLOUD_DOMAIN.length - 1) : null;
|
|
318
|
+
if (label !== null && POD_NAME_RE.test(label)) {
|
|
319
|
+
return [`${url.protocol}//${label}.${CLOUD_POD_ZONE}`, `${url.protocol}//pod.${url.host}`];
|
|
320
|
+
}
|
|
321
|
+
return host.startsWith("pod.") ? [] : [`${url.protocol}//pod.${url.host}`];
|
|
322
|
+
}
|
|
255
323
|
async function discoverPod(input, opts = {}) {
|
|
256
324
|
const first = canonicalPodUrl(input, opts);
|
|
325
|
+
let firstError;
|
|
257
326
|
try {
|
|
258
327
|
return await discoverIssuer(first, opts);
|
|
259
328
|
} catch (err) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
329
|
+
firstError = err;
|
|
330
|
+
}
|
|
331
|
+
const retryable = (err) => err instanceof ConnectError && (err.code === "not_a_pod" || err.code === "pod_unreachable" || err.code === "pod_moved");
|
|
332
|
+
if (!retryable(firstError)) throw firstError;
|
|
333
|
+
let tooOld = null;
|
|
334
|
+
for (const candidate of fallbackIssuers(first)) {
|
|
264
335
|
try {
|
|
265
|
-
return await discoverIssuer(
|
|
266
|
-
} catch (
|
|
267
|
-
|
|
336
|
+
return await discoverIssuer(candidate, opts);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
|
|
268
339
|
}
|
|
269
340
|
}
|
|
341
|
+
throw tooOld ?? firstError;
|
|
270
342
|
}
|
|
271
343
|
function safeNextPath(value, appOrigin) {
|
|
272
344
|
if (!value || value.length > 2048) return null;
|
|
@@ -301,10 +373,10 @@ function acceptLaunch(url, appOrigin, opts = {}) {
|
|
|
301
373
|
return {
|
|
302
374
|
pod,
|
|
303
375
|
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
304
|
-
spaceHint: hint &&
|
|
376
|
+
spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
|
|
305
377
|
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
306
378
|
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
307
|
-
cloudState: state &&
|
|
379
|
+
cloudState: state && CLOUD_STATE_RE.test(state) ? state : null
|
|
308
380
|
};
|
|
309
381
|
}
|
|
310
382
|
|
|
@@ -459,8 +531,6 @@ async function unseal(sealed, opts) {
|
|
|
459
531
|
|
|
460
532
|
// src/connect/cloud.ts
|
|
461
533
|
var CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
462
|
-
var STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
463
|
-
var LOOPBACK_HOSTS2 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
464
534
|
function cloudBaseUrl(cloudUrl, opts = {}) {
|
|
465
535
|
let url;
|
|
466
536
|
try {
|
|
@@ -468,7 +538,7 @@ function cloudBaseUrl(cloudUrl, opts = {}) {
|
|
|
468
538
|
} catch {
|
|
469
539
|
throw new ConnectError("misconfigured", "cloudUrl is not a URL");
|
|
470
540
|
}
|
|
471
|
-
const loopback =
|
|
541
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
472
542
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
|
|
473
543
|
throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
|
|
474
544
|
}
|
|
@@ -536,7 +606,7 @@ async function acceptCloudReturn(opts) {
|
|
|
536
606
|
}
|
|
537
607
|
const params = new URL(opts.url).searchParams;
|
|
538
608
|
const state = params.get("state");
|
|
539
|
-
if (state === null || !
|
|
609
|
+
if (state === null || !CLOUD_STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
|
|
540
610
|
throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
|
|
541
611
|
}
|
|
542
612
|
const error = params.get("error");
|
|
@@ -554,6 +624,12 @@ async function acceptCloudReturn(opts) {
|
|
|
554
624
|
}
|
|
555
625
|
|
|
556
626
|
// src/connect/client.ts
|
|
627
|
+
function connectionActsAs(connection) {
|
|
628
|
+
return connection.actsAs === "person" ? "person" : "agent";
|
|
629
|
+
}
|
|
630
|
+
function humanSlots(session) {
|
|
631
|
+
return session.expectedOutputs.filter((slot) => slot.owner === "human" && slot.status !== "done" && !slot.retiredAt);
|
|
632
|
+
}
|
|
557
633
|
function can(connection, permission, workspaceId) {
|
|
558
634
|
return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
|
|
559
635
|
}
|
|
@@ -562,23 +638,50 @@ function spaces(connection) {
|
|
|
562
638
|
for (const g of connection.grant) {
|
|
563
639
|
const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
|
|
564
640
|
if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
|
|
641
|
+
if (!space.templateSlug && g.templateSlug) space.templateSlug = g.templateSlug;
|
|
565
642
|
if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
|
|
566
643
|
bySpace.set(g.workspaceId, space);
|
|
567
644
|
}
|
|
568
645
|
return [...bySpace.values()];
|
|
569
646
|
}
|
|
647
|
+
var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
648
|
+
var PROPOSAL_IDS_MAX = 100;
|
|
570
649
|
var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
|
|
650
|
+
var TIMEOUT_MS2 = 1e4;
|
|
651
|
+
var MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
571
652
|
function hubUrl(podUrl, path, query) {
|
|
572
|
-
if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\"))
|
|
573
|
-
|
|
653
|
+
if (!SAFE_PATH.test(path) || /%2e/i.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) return null;
|
|
654
|
+
let url;
|
|
655
|
+
try {
|
|
656
|
+
url = new URL(`${podUrl}/api/hub${path}`);
|
|
657
|
+
} catch {
|
|
658
|
+
return null;
|
|
574
659
|
}
|
|
575
|
-
|
|
660
|
+
if (url.origin !== new URL(podUrl).origin || !(url.pathname === "/api/hub" || url.pathname.startsWith("/api/hub/"))) return null;
|
|
576
661
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
577
662
|
if (value === void 0 || value === null) continue;
|
|
578
663
|
for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
|
|
579
664
|
}
|
|
580
665
|
return url;
|
|
581
666
|
}
|
|
667
|
+
function renewalError(err) {
|
|
668
|
+
const e = isConnectError(err) ? err : null;
|
|
669
|
+
const detail = e?.message ?? (err instanceof Error ? err.message : "The connection could not be renewed");
|
|
670
|
+
switch (e?.code) {
|
|
671
|
+
case "revoked":
|
|
672
|
+
return { code: "unauthorized", status: 401, detail };
|
|
673
|
+
case "expired":
|
|
674
|
+
return { code: "expired", status: null, detail };
|
|
675
|
+
case "rate_limited":
|
|
676
|
+
return { code: "rate_limited", status: null, detail, ...e.retryAfterSec !== void 0 ? { retryAfterSec: e.retryAfterSec } : {} };
|
|
677
|
+
case "pod_unreachable":
|
|
678
|
+
return { code: "pod_unreachable", status: null, detail };
|
|
679
|
+
case "pod_moved":
|
|
680
|
+
return { code: "pod_moved", status: null, detail };
|
|
681
|
+
default:
|
|
682
|
+
return { code: "server", status: null, detail };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
582
685
|
function errorFor(status, body, retryAfter, now) {
|
|
583
686
|
const obj = body && typeof body === "object" ? body : {};
|
|
584
687
|
const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : void 0;
|
|
@@ -593,6 +696,7 @@ function errorFor(status, body, retryAfter, now) {
|
|
|
593
696
|
return { code: "rate_limited", ...base, ...retryAfterSec !== void 0 ? { retryAfterSec } : {} };
|
|
594
697
|
}
|
|
595
698
|
if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
|
|
699
|
+
if (status >= 300 && status < 400) return { code: "pod_moved", ...base };
|
|
596
700
|
return { code: "server", ...base };
|
|
597
701
|
}
|
|
598
702
|
function reviewUrlFor(podUrl, reviewUrl, proposalId) {
|
|
@@ -605,13 +709,37 @@ function reviewUrlFor(podUrl, reviewUrl, proposalId) {
|
|
|
605
709
|
}
|
|
606
710
|
return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
|
|
607
711
|
}
|
|
712
|
+
function refused(code, reason, detail) {
|
|
713
|
+
return { ok: false, error: { code, status: null, detail, reason } };
|
|
714
|
+
}
|
|
715
|
+
function asObject(value) {
|
|
716
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
717
|
+
}
|
|
718
|
+
function hasId(value) {
|
|
719
|
+
return asObject(value) !== null && typeof value.id === "string";
|
|
720
|
+
}
|
|
608
721
|
function createPodClient(connection, opts = {}) {
|
|
609
722
|
const now = opts.now ?? Date.now;
|
|
610
723
|
const doFetch = () => opts.fetch ?? fetch;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
724
|
+
let current = connection;
|
|
725
|
+
let renewal = null;
|
|
726
|
+
function renewOnce(refused2) {
|
|
727
|
+
if (current !== refused2) return Promise.resolve({ ok: true, data: current });
|
|
728
|
+
renewal ??= opts.renew(refused2).then(
|
|
729
|
+
(next) => {
|
|
730
|
+
current = next;
|
|
731
|
+
return { ok: true, data: next };
|
|
732
|
+
},
|
|
733
|
+
(err) => ({ ok: false, error: renewalError(err) })
|
|
734
|
+
);
|
|
735
|
+
return renewal;
|
|
736
|
+
}
|
|
737
|
+
async function request(method, path, body, query, retried = false) {
|
|
738
|
+
const sent = current;
|
|
739
|
+
const url = hubUrl(sent.podUrl, path, query);
|
|
740
|
+
if (!url) return refused("invalid", "invalid_input", "Not a Hub API path: encode each path segment (encodeURIComponent)");
|
|
741
|
+
if (sent.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
|
|
742
|
+
const headers = { Accept: "application/json", Authorization: `Bearer ${sent.accessToken}` };
|
|
615
743
|
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
616
744
|
let res;
|
|
617
745
|
try {
|
|
@@ -619,21 +747,33 @@ function createPodClient(connection, opts = {}) {
|
|
|
619
747
|
method,
|
|
620
748
|
headers,
|
|
621
749
|
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
622
|
-
redirect: "manual"
|
|
750
|
+
redirect: "manual",
|
|
751
|
+
signal: timeoutSignal(opts.timeoutMs ?? TIMEOUT_MS2)
|
|
623
752
|
});
|
|
624
753
|
} catch {
|
|
625
754
|
return { ok: false, error: { code: "pod_unreachable", status: null } };
|
|
626
755
|
}
|
|
756
|
+
const read2 = await readCapped(res, opts.maxResponseBytes ?? MAX_RESPONSE_BYTES);
|
|
757
|
+
if (!read2.ok) {
|
|
758
|
+
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" } };
|
|
759
|
+
}
|
|
627
760
|
let data = null;
|
|
628
|
-
|
|
629
|
-
if (text) {
|
|
761
|
+
if (read2.text) {
|
|
630
762
|
try {
|
|
631
|
-
data = JSON.parse(text);
|
|
763
|
+
data = JSON.parse(read2.text);
|
|
632
764
|
} catch {
|
|
633
765
|
data = null;
|
|
634
766
|
}
|
|
635
767
|
}
|
|
636
768
|
if (res.status >= 200 && res.status < 300) return { ok: true, data };
|
|
769
|
+
if (res.status === 401 && sent.refreshToken) {
|
|
770
|
+
if (!opts.renew) return { ok: false, error: { ...errorFor(401, data, null, now()), refreshable: true } };
|
|
771
|
+
if (!retried) {
|
|
772
|
+
const renewed = await renewOnce(sent);
|
|
773
|
+
if (!renewed.ok) return renewed;
|
|
774
|
+
return request(method, path, body, query, true);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
637
777
|
return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
|
|
638
778
|
}
|
|
639
779
|
async function write(method, path, body) {
|
|
@@ -646,18 +786,23 @@ function createPodClient(connection, opts = {}) {
|
|
|
646
786
|
ok: true,
|
|
647
787
|
status: "proposed",
|
|
648
788
|
proposalId,
|
|
649
|
-
reviewUrl: reviewUrlFor(
|
|
789
|
+
reviewUrl: reviewUrlFor(current.podUrl, obj.reviewUrl, proposalId),
|
|
650
790
|
data: result.data
|
|
651
791
|
};
|
|
652
792
|
}
|
|
653
793
|
return { ok: true, status: "applied", data: result.data };
|
|
654
794
|
}
|
|
655
795
|
async function listPendingProposals(opts2 = {}) {
|
|
656
|
-
if (!can(
|
|
657
|
-
return
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
796
|
+
if (!can(current, "proposal.read", opts2.workspaceId)) {
|
|
797
|
+
return refused("forbidden", "not_granted", "The connection was not granted proposal.read in that space");
|
|
798
|
+
}
|
|
799
|
+
let only = null;
|
|
800
|
+
if (opts2.proposalIds !== void 0) {
|
|
801
|
+
if (opts2.proposalIds.length > PROPOSAL_IDS_MAX || opts2.proposalIds.some((id) => !ID_RE.test(id))) {
|
|
802
|
+
return refused("invalid", "invalid_input", `proposalIds must be at most ${PROPOSAL_IDS_MAX} proposal ids`);
|
|
803
|
+
}
|
|
804
|
+
if (opts2.proposalIds.length === 0) return { ok: true, data: { total: 0, proposals: [], hasMore: false } };
|
|
805
|
+
only = new Set(opts2.proposalIds);
|
|
661
806
|
}
|
|
662
807
|
const limit = Math.min(Math.max(Math.floor(opts2.limit ?? 20), 1), 200);
|
|
663
808
|
const offset = Math.max(Math.floor(opts2.offset ?? 0), 0);
|
|
@@ -666,7 +811,8 @@ function createPodClient(connection, opts = {}) {
|
|
|
666
811
|
view: "basic",
|
|
667
812
|
limit,
|
|
668
813
|
offset,
|
|
669
|
-
workspaceId: opts2.workspaceId
|
|
814
|
+
workspaceId: opts2.workspaceId,
|
|
815
|
+
...only ? { proposalIds: [...only].join(",") } : {}
|
|
670
816
|
});
|
|
671
817
|
if (!result.ok) return result;
|
|
672
818
|
const obj = result.data && typeof result.data === "object" ? result.data : {};
|
|
@@ -679,7 +825,7 @@ function createPodClient(connection, opts = {}) {
|
|
|
679
825
|
if (!row || typeof row !== "object") continue;
|
|
680
826
|
const r = row;
|
|
681
827
|
const id = str(r.id);
|
|
682
|
-
if (!id) continue;
|
|
828
|
+
if (!id || only && !only.has(id)) continue;
|
|
683
829
|
proposals.push({
|
|
684
830
|
id,
|
|
685
831
|
proposalType: str(r.proposalType) ?? "",
|
|
@@ -690,7 +836,7 @@ function createPodClient(connection, opts = {}) {
|
|
|
690
836
|
agentUserId: str(r.agentUserId),
|
|
691
837
|
sessionId: str(r.sessionId),
|
|
692
838
|
...str(r.summary) ? { summary: str(r.summary) } : {},
|
|
693
|
-
reviewUrl: reviewUrlFor(
|
|
839
|
+
reviewUrl: reviewUrlFor(current.podUrl, null, id)
|
|
694
840
|
});
|
|
695
841
|
}
|
|
696
842
|
return {
|
|
@@ -698,13 +844,134 @@ function createPodClient(connection, opts = {}) {
|
|
|
698
844
|
data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total }
|
|
699
845
|
};
|
|
700
846
|
}
|
|
847
|
+
async function read(path, query, shape) {
|
|
848
|
+
const result = await request("GET", path, void 0, query);
|
|
849
|
+
if (!result.ok) return result;
|
|
850
|
+
const data = shape(result.data);
|
|
851
|
+
if (data === null) return { ok: false, error: { code: "server", status: 200, detail: `The pod's ${path.split("/")[1]} answer is not the expected shape` } };
|
|
852
|
+
return { ok: true, data };
|
|
853
|
+
}
|
|
854
|
+
const badId = (what) => refused("invalid", "invalid_input", `Not a ${what} id`);
|
|
855
|
+
const clamp = (n, min, max) => n === void 0 ? void 0 : Math.min(Math.max(Math.floor(n), min), max);
|
|
856
|
+
function listEntities(opts2 = {}) {
|
|
857
|
+
return read("/entities", {
|
|
858
|
+
workspaceId: opts2.workspaceId,
|
|
859
|
+
profileSlug: opts2.profileSlug,
|
|
860
|
+
limit: clamp(opts2.limit, 1, 1e3),
|
|
861
|
+
offset: clamp(opts2.offset, 0, Number.MAX_SAFE_INTEGER),
|
|
862
|
+
sort: opts2.sort,
|
|
863
|
+
includePodWide: opts2.includePodWide,
|
|
864
|
+
facetSlug: opts2.facetSlug,
|
|
865
|
+
// Sent only when asked: the default request stays the 1.5.0 one.
|
|
866
|
+
includeFacets: opts2.includeFacets ? true : void 0
|
|
867
|
+
}, (data) => Array.isArray(data) ? data.filter(hasId) : null);
|
|
868
|
+
}
|
|
869
|
+
async function getEntity(id) {
|
|
870
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
871
|
+
return read(`/entities/${id}`, void 0, (data) => hasId(data) ? data : null);
|
|
872
|
+
}
|
|
873
|
+
async function getEntityConnections(id, opts2 = {}) {
|
|
874
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
875
|
+
return read(`/entities/${id}/connections`, { workspaceId: opts2.workspaceId, limit: clamp(opts2.limit, 1, 200) }, (data) => {
|
|
876
|
+
const obj = asObject(data);
|
|
877
|
+
if (!obj || !Array.isArray(obj.connections)) return null;
|
|
878
|
+
const connections = obj.connections.filter(
|
|
879
|
+
(c) => !!c && typeof c === "object" && typeof c.entityId === "string"
|
|
880
|
+
);
|
|
881
|
+
const counts = asObject(obj.counts) ?? {};
|
|
882
|
+
const count = (key, source) => typeof counts[key] === "number" ? counts[key] : connections.filter((c) => c.source === source).length;
|
|
883
|
+
return {
|
|
884
|
+
connections,
|
|
885
|
+
counts: {
|
|
886
|
+
total: typeof counts.total === "number" ? counts.total : connections.length,
|
|
887
|
+
graph: count("graph", "graph"),
|
|
888
|
+
structural: count("structural", "property"),
|
|
889
|
+
threads: count("threads", "thread"),
|
|
890
|
+
contextChannels: count("contextChannels", "context_channel"),
|
|
891
|
+
focusSessions: count("focusSessions", "focus_session")
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
async function getDocument(id, opts2 = {}) {
|
|
897
|
+
if (!ID_RE.test(id)) return badId("document");
|
|
898
|
+
return read(`/documents/${id}`, { format: opts2.format }, (data) => {
|
|
899
|
+
const doc = asObject(asObject(data)?.document);
|
|
900
|
+
if (!doc || typeof doc.id !== "string" || typeof doc.content !== "string") return null;
|
|
901
|
+
return { ...doc, sections: Array.isArray(doc.sections) ? doc.sections : [] };
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async function listFocusSessions(opts2) {
|
|
905
|
+
if (!opts2?.workspaceId && !opts2?.projectId) return refused("invalid", "invalid_input", "workspaceId or projectId is required");
|
|
906
|
+
return read("/focus-sessions", {
|
|
907
|
+
workspaceId: opts2.workspaceId,
|
|
908
|
+
projectId: opts2.projectId,
|
|
909
|
+
status: opts2.status,
|
|
910
|
+
limit: clamp(opts2.limit, 1, 50),
|
|
911
|
+
lens: opts2.lens,
|
|
912
|
+
kind: opts2.kind,
|
|
913
|
+
subjectEntityId: opts2.subjectEntityId
|
|
914
|
+
}, (data) => {
|
|
915
|
+
if (!Array.isArray(data)) return null;
|
|
916
|
+
return data.filter(hasId).map((row) => ({
|
|
917
|
+
...row,
|
|
918
|
+
expectedOutputs: Array.isArray(row.expectedOutputs) ? row.expectedOutputs.filter((s) => !!s && typeof s === "object" && typeof s.label === "string") : []
|
|
919
|
+
}));
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
async function getFileUrl(entityId) {
|
|
923
|
+
if (!ID_RE.test(entityId)) return badId("file");
|
|
924
|
+
return read(`/files/${entityId}/url`, void 0, (data) => {
|
|
925
|
+
const obj = asObject(data);
|
|
926
|
+
if (!obj || typeof obj.url !== "string" || typeof obj.expiresAt !== "string") return null;
|
|
927
|
+
return { url: obj.url, expiresAt: obj.expiresAt, mimeType: typeof obj.mimeType === "string" ? obj.mimeType : null };
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
async function approveProposal(id, opts2 = {}) {
|
|
931
|
+
if (connectionActsAs(current) !== "person") {
|
|
932
|
+
return refused("forbidden", "agent_cannot_approve", "An agent connection can never approve: approval is the person's");
|
|
933
|
+
}
|
|
934
|
+
if (!can(current, "proposal.approve", opts2.workspaceId)) {
|
|
935
|
+
return refused("forbidden", "not_granted", "The connection was not granted proposal.approve in that space");
|
|
936
|
+
}
|
|
937
|
+
if (!ID_RE.test(id)) return badId("proposal");
|
|
938
|
+
const result = await request("POST", `/proposals/${id}/approve`, {});
|
|
939
|
+
if (!result.ok) return result;
|
|
940
|
+
const obj = asObject(result.data);
|
|
941
|
+
if (!obj || typeof obj.proposalId !== "string") {
|
|
942
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's approve answer names no proposalId" } };
|
|
943
|
+
}
|
|
944
|
+
return {
|
|
945
|
+
ok: true,
|
|
946
|
+
data: {
|
|
947
|
+
proposalId: obj.proposalId,
|
|
948
|
+
...typeof obj.primaryId === "string" ? { primaryId: obj.primaryId } : {},
|
|
949
|
+
...typeof obj.linked === "number" ? { linked: obj.linked } : {},
|
|
950
|
+
proposal: asObject(obj.proposal)
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
}
|
|
701
954
|
return {
|
|
702
955
|
podUrl: connection.podUrl,
|
|
703
|
-
user
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
956
|
+
get user() {
|
|
957
|
+
return current.user;
|
|
958
|
+
},
|
|
959
|
+
get grant() {
|
|
960
|
+
return current.grant;
|
|
961
|
+
},
|
|
962
|
+
get actsAs() {
|
|
963
|
+
return connectionActsAs(current);
|
|
964
|
+
},
|
|
965
|
+
can: (permission, workspaceId) => can(current, permission, workspaceId),
|
|
966
|
+
spaces: () => spaces(current),
|
|
707
967
|
listPendingProposals,
|
|
968
|
+
listEntities,
|
|
969
|
+
getEntity,
|
|
970
|
+
getEntityConnections,
|
|
971
|
+
getDocument,
|
|
972
|
+
listFocusSessions,
|
|
973
|
+
getFileUrl,
|
|
974
|
+
approveProposal,
|
|
708
975
|
get(path, query) {
|
|
709
976
|
return request("GET", path, void 0, query);
|
|
710
977
|
},
|
|
@@ -727,7 +994,8 @@ function createPodClient(connection, opts = {}) {
|
|
|
727
994
|
var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
728
995
|
var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
729
996
|
var DEFAULT_ACCESS_TTL_SEC = 3600;
|
|
730
|
-
var
|
|
997
|
+
var TIMEOUT_MS3 = 1e4;
|
|
998
|
+
var MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
731
999
|
var PERMISSION_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i;
|
|
732
1000
|
function assertPermissions(permissions) {
|
|
733
1001
|
const unique = [...new Set(permissions)];
|
|
@@ -845,20 +1113,29 @@ async function tokenRequest(endpoint, body, during, opts) {
|
|
|
845
1113
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
846
1114
|
body: body.toString(),
|
|
847
1115
|
redirect: "manual",
|
|
848
|
-
signal:
|
|
1116
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
849
1117
|
});
|
|
850
1118
|
} catch {
|
|
851
1119
|
throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
|
|
852
1120
|
}
|
|
1121
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1122
|
+
await res.body?.cancel().catch(() => {
|
|
1123
|
+
});
|
|
1124
|
+
throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
|
|
1125
|
+
}
|
|
853
1126
|
if (res.status === 429) {
|
|
854
1127
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
855
1128
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
856
1129
|
});
|
|
857
1130
|
}
|
|
858
1131
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
|
|
1132
|
+
const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
1133
|
+
if (!read.ok) {
|
|
1134
|
+
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");
|
|
1135
|
+
}
|
|
859
1136
|
let json;
|
|
860
1137
|
try {
|
|
861
|
-
json =
|
|
1138
|
+
json = JSON.parse(read.text);
|
|
862
1139
|
} catch {
|
|
863
1140
|
throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
|
|
864
1141
|
}
|
|
@@ -874,14 +1151,18 @@ function parseGrant(value) {
|
|
|
874
1151
|
const out = [];
|
|
875
1152
|
for (const g of value) {
|
|
876
1153
|
if (!g || typeof g !== "object") return null;
|
|
877
|
-
const { permission, workspaceId, workspaceName } = g;
|
|
1154
|
+
const { permission, workspaceId, workspaceName, templateSlug } = g;
|
|
878
1155
|
if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
|
|
879
|
-
|
|
1156
|
+
const row = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
|
|
1157
|
+
if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
|
|
1158
|
+
out.push(row);
|
|
880
1159
|
}
|
|
881
1160
|
return out;
|
|
882
1161
|
}
|
|
1162
|
+
function parseActsAs(value) {
|
|
1163
|
+
return value === "person" || value === "agent" ? value : void 0;
|
|
1164
|
+
}
|
|
883
1165
|
var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
884
|
-
var LOOPBACK_HOSTS3 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
885
1166
|
function parseApp(value) {
|
|
886
1167
|
if (!value || typeof value !== "object") return {};
|
|
887
1168
|
const { id, manageUrl } = value;
|
|
@@ -890,7 +1171,7 @@ function parseApp(value) {
|
|
|
890
1171
|
if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
|
|
891
1172
|
try {
|
|
892
1173
|
const url = new URL(manageUrl);
|
|
893
|
-
const secure = url.protocol === "https:" || url.protocol === "http:" &&
|
|
1174
|
+
const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
894
1175
|
if (secure && !url.username && !url.password) out.manageUrl = url.href;
|
|
895
1176
|
} catch {
|
|
896
1177
|
}
|
|
@@ -936,6 +1217,8 @@ function parseTokenResponse(body, ctx) {
|
|
|
936
1217
|
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
937
1218
|
if (appId !== void 0) connection.appId = appId;
|
|
938
1219
|
if (manageUrl !== void 0) connection.manageUrl = manageUrl;
|
|
1220
|
+
const actsAs = parseActsAs(body.synap_acts_as);
|
|
1221
|
+
if (actsAs !== void 0) connection.actsAs = actsAs;
|
|
939
1222
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
940
1223
|
connection.refreshToken = refresh_token;
|
|
941
1224
|
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;
|
|
@@ -1001,7 +1284,9 @@ async function revokeConnection(connection, opts = {}) {
|
|
|
1001
1284
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1002
1285
|
body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
|
|
1003
1286
|
redirect: "manual",
|
|
1004
|
-
signal:
|
|
1287
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
1288
|
+
});
|
|
1289
|
+
await res.body?.cancel().catch(() => {
|
|
1005
1290
|
});
|
|
1006
1291
|
if (res.status !== 200) revoked = false;
|
|
1007
1292
|
} catch {
|
|
@@ -1043,6 +1328,7 @@ function pack(c) {
|
|
|
1043
1328
|
for (const g of c.grant) {
|
|
1044
1329
|
const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
1045
1330
|
row[2].push(g.permission);
|
|
1331
|
+
if (g.templateSlug && row[3] === void 0) row[3] = g.templateSlug;
|
|
1046
1332
|
bySpace.set(g.workspaceId, row);
|
|
1047
1333
|
}
|
|
1048
1334
|
const { grant: _grant, ...rest } = c;
|
|
@@ -1052,7 +1338,7 @@ function unpack(s) {
|
|
|
1052
1338
|
if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
|
|
1053
1339
|
const { g, ...rest } = s;
|
|
1054
1340
|
const grant = g.flatMap(
|
|
1055
|
-
([workspaceId, workspaceName, permissions]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName }))
|
|
1341
|
+
([workspaceId, workspaceName, permissions, templateSlug]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName, ...typeof templateSlug === "string" ? { templateSlug } : {} }))
|
|
1056
1342
|
);
|
|
1057
1343
|
return { ...rest, grant };
|
|
1058
1344
|
}
|
|
@@ -1131,9 +1417,34 @@ var DEFAULT_PATHS = {
|
|
|
1131
1417
|
afterConnect: "/"
|
|
1132
1418
|
};
|
|
1133
1419
|
var REFRESH_WINDOW_MS = 5 * 60 * 1e3;
|
|
1134
|
-
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
1135
1420
|
var TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
1136
1421
|
var MAX_METADATA_BYTES2 = 5 * 1024;
|
|
1422
|
+
var MAX_REQUESTS = 32;
|
|
1423
|
+
var KEPT_RETRY_MS = 6e4;
|
|
1424
|
+
var GRANT_ACTIONS = ["read", "create", "update", "delete"];
|
|
1425
|
+
var SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
|
|
1426
|
+
var PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
1427
|
+
function isSynapAppOrigin(origin) {
|
|
1428
|
+
return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
|
|
1429
|
+
}
|
|
1430
|
+
function isMockModeOrigin(origin) {
|
|
1431
|
+
const url = new URL(origin);
|
|
1432
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
1433
|
+
}
|
|
1434
|
+
function assertAppPermission(permission, origin) {
|
|
1435
|
+
const segs = permission.split(".");
|
|
1436
|
+
const fail = (why) => {
|
|
1437
|
+
throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
|
|
1438
|
+
};
|
|
1439
|
+
if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
|
|
1440
|
+
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
1441
|
+
}
|
|
1442
|
+
if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
|
|
1443
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && (isSynapAppOrigin(origin) || isMockModeOrigin(origin))) return;
|
|
1444
|
+
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
1445
|
+
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1137
1448
|
function appOriginOf(appUrl) {
|
|
1138
1449
|
let url;
|
|
1139
1450
|
try {
|
|
@@ -1142,8 +1453,11 @@ function appOriginOf(appUrl) {
|
|
|
1142
1453
|
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
1143
1454
|
}
|
|
1144
1455
|
const secure = url.protocol === "https:";
|
|
1145
|
-
if (!secure && !(url.protocol === "http:" &&
|
|
1146
|
-
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
1456
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
1457
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
|
|
1458
|
+
}
|
|
1459
|
+
if (secure && isIpLiteral(url.hostname)) {
|
|
1460
|
+
throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
|
|
1147
1461
|
}
|
|
1148
1462
|
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
1149
1463
|
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
@@ -1161,9 +1475,12 @@ function buildClientMetadata(opts) {
|
|
|
1161
1475
|
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
1162
1476
|
const clientName = opts.clientName.trim();
|
|
1163
1477
|
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
1478
|
+
if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
|
|
1479
|
+
throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
|
|
1480
|
+
}
|
|
1164
1481
|
assertPermissions(opts.requests.map((r) => r.permission));
|
|
1165
1482
|
for (const r of opts.requests) {
|
|
1166
|
-
|
|
1483
|
+
assertAppPermission(r.permission, origin);
|
|
1167
1484
|
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
1168
1485
|
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
1169
1486
|
}
|
|
@@ -1262,7 +1579,7 @@ function createPodAppAuth(config) {
|
|
|
1262
1579
|
permissions,
|
|
1263
1580
|
secret: config.secret,
|
|
1264
1581
|
resource: config.resource,
|
|
1265
|
-
spaceHint: opts.space &&
|
|
1582
|
+
spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
|
|
1266
1583
|
next: opts.next,
|
|
1267
1584
|
prompt: opts.prompt,
|
|
1268
1585
|
allowInsecureLoopback: config.allowInsecureLoopback
|
|
@@ -1341,7 +1658,7 @@ function createPodAppAuth(config) {
|
|
|
1341
1658
|
clientId,
|
|
1342
1659
|
secret: config.secret,
|
|
1343
1660
|
next,
|
|
1344
|
-
spaceHint: space &&
|
|
1661
|
+
spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
|
|
1345
1662
|
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
1346
1663
|
now
|
|
1347
1664
|
});
|
|
@@ -1374,7 +1691,8 @@ function createPodAppAuth(config) {
|
|
|
1374
1691
|
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
1375
1692
|
}
|
|
1376
1693
|
}
|
|
1377
|
-
|
|
1694
|
+
const keptFailures = /* @__PURE__ */ new Map();
|
|
1695
|
+
async function refreshIfNeeded(request, opts = {}) {
|
|
1378
1696
|
const connection = await store.read(request);
|
|
1379
1697
|
const unchanged = (status, conn, error) => ({
|
|
1380
1698
|
status,
|
|
@@ -1389,18 +1707,27 @@ function createPodAppAuth(config) {
|
|
|
1389
1707
|
}
|
|
1390
1708
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
1391
1709
|
const t = now();
|
|
1392
|
-
const accessLives = connection.accessExpiresAt > t;
|
|
1393
|
-
if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1710
|
+
const accessLives = !opts.force && connection.accessExpiresAt > t;
|
|
1711
|
+
if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1394
1712
|
if (!connection.refreshToken) {
|
|
1395
1713
|
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
1396
1714
|
}
|
|
1715
|
+
for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
|
|
1716
|
+
const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : void 0;
|
|
1717
|
+
if (keptFailure) return unchanged("kept", connection, keptFailure.error);
|
|
1397
1718
|
let next;
|
|
1398
1719
|
try {
|
|
1399
1720
|
next = await refreshConnection(connection, flow);
|
|
1400
1721
|
} catch (err) {
|
|
1401
1722
|
const e = fail(err, "refresh");
|
|
1402
|
-
if (accessLives)
|
|
1403
|
-
|
|
1723
|
+
if (accessLives) {
|
|
1724
|
+
keptFailures.set(connection.refreshToken, { error: e, at: t });
|
|
1725
|
+
return unchanged("kept", connection, e);
|
|
1726
|
+
}
|
|
1727
|
+
if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
|
|
1728
|
+
const cleared = await store.clear(request).catch(() => []);
|
|
1729
|
+
return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
|
|
1730
|
+
}
|
|
1404
1731
|
return unchanged("unavailable", null, e);
|
|
1405
1732
|
}
|
|
1406
1733
|
let setCookies;
|
|
@@ -1419,6 +1746,24 @@ function createPodAppAuth(config) {
|
|
|
1419
1746
|
cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies)
|
|
1420
1747
|
};
|
|
1421
1748
|
}
|
|
1749
|
+
function renewer(persist, source) {
|
|
1750
|
+
return async (connection) => {
|
|
1751
|
+
let next;
|
|
1752
|
+
try {
|
|
1753
|
+
next = await refreshConnection(connection, flow);
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
throw fail(err, "refresh");
|
|
1756
|
+
}
|
|
1757
|
+
try {
|
|
1758
|
+
const cookies = await store.write(next, asRequest(source ?? null));
|
|
1759
|
+
await persist([...cookies, marker(next.podUrl)]);
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
fail(err, "refresh");
|
|
1762
|
+
throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
|
|
1763
|
+
}
|
|
1764
|
+
return next;
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1422
1767
|
return {
|
|
1423
1768
|
clientId,
|
|
1424
1769
|
redirectUri,
|
|
@@ -1432,7 +1777,15 @@ function createPodAppAuth(config) {
|
|
|
1432
1777
|
return await store.read(request) ? null : lapsedIn(request);
|
|
1433
1778
|
},
|
|
1434
1779
|
refreshIfNeeded,
|
|
1435
|
-
client
|
|
1780
|
+
client(connection, opts = {}) {
|
|
1781
|
+
const { persist, request, ...clientOpts } = opts;
|
|
1782
|
+
return createPodClient(connection, {
|
|
1783
|
+
fetch: config.fetch,
|
|
1784
|
+
now: config.now,
|
|
1785
|
+
...persist ? { renew: renewer(persist, request) } : {},
|
|
1786
|
+
...clientOpts
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1436
1789
|
};
|
|
1437
1790
|
}
|
|
1438
1791
|
|
|
@@ -1458,6 +1811,7 @@ assertServerRuntime();
|
|
|
1458
1811
|
canonicalPodUrl,
|
|
1459
1812
|
cloudBaseUrl,
|
|
1460
1813
|
cloudConnectAvailable,
|
|
1814
|
+
connectionActsAs,
|
|
1461
1815
|
connectionExpiresAt,
|
|
1462
1816
|
cookieNames,
|
|
1463
1817
|
createPodAppAuth,
|
|
@@ -1465,6 +1819,7 @@ assertServerRuntime();
|
|
|
1465
1819
|
createSealedCookieStore,
|
|
1466
1820
|
discoverPod,
|
|
1467
1821
|
handleCallback,
|
|
1822
|
+
humanSlots,
|
|
1468
1823
|
isBrowserRuntime,
|
|
1469
1824
|
isConnectError,
|
|
1470
1825
|
isPrefetchOrAsset,
|