@synap-core/sdk 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/README.md +212 -12
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +790 -161
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +610 -37
- package/dist/connect.d.ts +610 -37
- package/dist/connect.js +777 -162
- package/dist/connect.js.map +1 -1
- package/dist/public.cjs +9 -2
- package/dist/public.cjs.map +1 -1
- package/dist/public.d.cts +19 -4
- package/dist/public.d.ts +19 -4
- package/dist/public.js +8 -2
- package/dist/public.js.map +1 -1
- package/dist/testing.cjs +366 -17
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +190 -7
- package/dist/testing.d.ts +190 -7
- package/dist/testing.js +358 -17
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
- package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
- package/package.json +36 -12
- package/src/connect/auth.ts +314 -55
- package/src/connect/client.ts +472 -26
- package/src/connect/cloud.ts +210 -0
- package/src/connect/discovery.ts +79 -44
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +8 -1
- package/src/connect/flow.ts +80 -12
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +254 -0
- package/src/connect/index.ts +69 -23
- package/src/connect/rules.ts +18 -0
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +50 -8
- package/src/connect/types.ts +48 -4
- package/src/public/client.ts +14 -2
- package/src/public/safe-markdown.ts +13 -0
- package/src/public/share-model.ts +6 -2
- package/src/public/site-model.ts +5 -3
- package/src/testing/auth-server.ts +377 -20
- package/src/testing/cloud.ts +83 -0
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +10 -6
- package/src/testing/seed.ts +120 -0
- package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/dist/connect.cjs
CHANGED
|
@@ -20,20 +20,25 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/connect/index.ts
|
|
21
21
|
var connect_exports = {};
|
|
22
22
|
__export(connect_exports, {
|
|
23
|
+
CLOUD_TRANSACTION_TTL_MS: () => CLOUD_TRANSACTION_TTL_MS,
|
|
23
24
|
CONNECT_ERROR_CODES: () => CONNECT_ERROR_CODES,
|
|
24
25
|
ConnectError: () => ConnectError,
|
|
25
26
|
DEFAULT_PATHS: () => DEFAULT_PATHS,
|
|
26
27
|
DEFAULT_REFRESH_IDLE_MS: () => DEFAULT_REFRESH_IDLE_MS,
|
|
28
|
+
MARKER_TTL_SEC: () => MARKER_TTL_SEC,
|
|
27
29
|
MIN_SECRET_LENGTH: () => MIN_SECRET_LENGTH,
|
|
28
30
|
REFRESH_WINDOW_MS: () => REFRESH_WINDOW_MS,
|
|
29
31
|
ROTATION_MEMORY_MS: () => ROTATION_MEMORY_MS,
|
|
30
32
|
TRANSACTION_TTL_MS: () => TRANSACTION_TTL_MS,
|
|
33
|
+
acceptCloudReturn: () => acceptCloudReturn,
|
|
31
34
|
acceptLaunch: () => acceptLaunch,
|
|
32
35
|
applySetCookies: () => applySetCookies,
|
|
33
36
|
buildClientMetadata: () => buildClientMetadata,
|
|
34
37
|
can: () => can,
|
|
35
38
|
canonicalPodUrl: () => canonicalPodUrl,
|
|
39
|
+
cloudBaseUrl: () => cloudBaseUrl,
|
|
36
40
|
cloudConnectAvailable: () => cloudConnectAvailable,
|
|
41
|
+
connectionActsAs: () => connectionActsAs,
|
|
37
42
|
connectionExpiresAt: () => connectionExpiresAt,
|
|
38
43
|
cookieNames: () => cookieNames,
|
|
39
44
|
createPodAppAuth: () => createPodAppAuth,
|
|
@@ -41,17 +46,22 @@ __export(connect_exports, {
|
|
|
41
46
|
createSealedCookieStore: () => createSealedCookieStore,
|
|
42
47
|
discoverPod: () => discoverPod,
|
|
43
48
|
handleCallback: () => handleCallback,
|
|
49
|
+
humanSlots: () => humanSlots,
|
|
44
50
|
isBrowserRuntime: () => isBrowserRuntime,
|
|
45
51
|
isConnectError: () => isConnectError,
|
|
52
|
+
isPrefetchOrAsset: () => isPrefetchOrAsset,
|
|
53
|
+
markerCookie: () => markerCookie,
|
|
46
54
|
openTransaction: () => openTransaction,
|
|
47
55
|
parseConnectErrorCode: () => parseConnectErrorCode,
|
|
48
56
|
parseTokenResponse: () => parseTokenResponse,
|
|
49
57
|
pkceChallenge: () => pkceChallenge,
|
|
58
|
+
readMarker: () => readMarker,
|
|
50
59
|
refreshConnection: () => refreshConnection,
|
|
51
60
|
reviewUrlFor: () => reviewUrlFor,
|
|
52
61
|
revokeConnection: () => revokeConnection,
|
|
53
62
|
safeNextPath: () => safeNextPath,
|
|
54
63
|
seal: () => seal,
|
|
64
|
+
spaces: () => spaces,
|
|
55
65
|
startCloudConnect: () => startCloudConnect,
|
|
56
66
|
startConnect: () => startConnect,
|
|
57
67
|
unseal: () => unseal
|
|
@@ -99,6 +109,7 @@ var CONNECT_ERROR_CODES = [
|
|
|
99
109
|
"rate_limited",
|
|
100
110
|
"expired",
|
|
101
111
|
"revoked",
|
|
112
|
+
"pod_moved",
|
|
102
113
|
"server_error"
|
|
103
114
|
];
|
|
104
115
|
function parseConnectErrorCode(value) {
|
|
@@ -134,11 +145,52 @@ function retryAfterSeconds(header, now) {
|
|
|
134
145
|
return Math.max(0, Math.ceil((at - now) / 1e3));
|
|
135
146
|
}
|
|
136
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
|
+
|
|
137
190
|
// src/connect/discovery.ts
|
|
138
191
|
var METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
139
192
|
var TIMEOUT_MS = 5e3;
|
|
140
193
|
var MAX_METADATA_BYTES = 64 * 1024;
|
|
141
|
-
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
142
194
|
function isPrivateIpv4(host) {
|
|
143
195
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
144
196
|
if (!m) return false;
|
|
@@ -191,10 +243,12 @@ function sameOriginEndpoint(value, issuer) {
|
|
|
191
243
|
}
|
|
192
244
|
}
|
|
193
245
|
async function readJsonCapped(res) {
|
|
194
|
-
const
|
|
195
|
-
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
|
+
}
|
|
196
250
|
try {
|
|
197
|
-
return JSON.parse(text);
|
|
251
|
+
return JSON.parse(read.text);
|
|
198
252
|
} catch {
|
|
199
253
|
throw new ConnectError("not_a_pod", "Metadata is not JSON");
|
|
200
254
|
}
|
|
@@ -207,18 +261,27 @@ async function discoverIssuer(issuer, opts) {
|
|
|
207
261
|
method: "GET",
|
|
208
262
|
headers: { Accept: "application/json" },
|
|
209
263
|
redirect: "manual",
|
|
210
|
-
signal:
|
|
264
|
+
signal: timeoutSignal(TIMEOUT_MS)
|
|
211
265
|
});
|
|
212
266
|
} catch {
|
|
213
267
|
throw new ConnectError("pod_unreachable", "The pod did not answer");
|
|
214
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
|
+
}
|
|
215
274
|
if (res.status === 429) {
|
|
216
275
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
217
276
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
218
277
|
});
|
|
219
278
|
}
|
|
220
279
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
|
|
221
|
-
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
|
+
}
|
|
222
285
|
const doc = await readJsonCapped(res);
|
|
223
286
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
|
|
224
287
|
const m = doc;
|
|
@@ -244,27 +307,38 @@ async function discoverIssuer(issuer, opts) {
|
|
|
244
307
|
refreshSupported: list(m.grant_types_supported)?.includes("refresh_token") ?? false
|
|
245
308
|
};
|
|
246
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
|
+
}
|
|
247
323
|
async function discoverPod(input, opts = {}) {
|
|
248
324
|
const first = canonicalPodUrl(input, opts);
|
|
325
|
+
let firstError;
|
|
249
326
|
try {
|
|
250
327
|
return await discoverIssuer(first, opts);
|
|
251
328
|
} catch (err) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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)) {
|
|
256
335
|
try {
|
|
257
|
-
return await discoverIssuer(
|
|
258
|
-
} catch (
|
|
259
|
-
|
|
336
|
+
return await discoverIssuer(candidate, opts);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
|
|
260
339
|
}
|
|
261
340
|
}
|
|
262
|
-
|
|
263
|
-
function cloudConnectAvailable() {
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
function startCloudConnect(_opts) {
|
|
267
|
-
throw new ConnectError("cp_unavailable", "cp: not available \u2014 connecting through Synap Cloud is not built yet; use a pod address");
|
|
341
|
+
throw tooOld ?? firstError;
|
|
268
342
|
}
|
|
269
343
|
function safeNextPath(value, appOrigin) {
|
|
270
344
|
if (!value || value.length > 2048) return null;
|
|
@@ -295,10 +369,14 @@ function acceptLaunch(url, appOrigin, opts = {}) {
|
|
|
295
369
|
}
|
|
296
370
|
}
|
|
297
371
|
const hint = params.get("synap_space_hint");
|
|
372
|
+
const state = params.get("state");
|
|
298
373
|
return {
|
|
299
374
|
pod,
|
|
300
375
|
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
301
|
-
spaceHint: hint &&
|
|
376
|
+
spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
|
|
377
|
+
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
378
|
+
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
379
|
+
cloudState: state && CLOUD_STATE_RE.test(state) ? state : null
|
|
302
380
|
};
|
|
303
381
|
}
|
|
304
382
|
|
|
@@ -368,22 +446,242 @@ function serializeCookie(name, value, attrs) {
|
|
|
368
446
|
return parts.join("; ");
|
|
369
447
|
}
|
|
370
448
|
|
|
449
|
+
// src/connect/seal.ts
|
|
450
|
+
var VERSION = "v1";
|
|
451
|
+
var IV_BYTES = 12;
|
|
452
|
+
var TAG_BYTES = 16;
|
|
453
|
+
var MIN_SECRET_LENGTH = 32;
|
|
454
|
+
var SALT = utf8("synap-connect/v1");
|
|
455
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
456
|
+
function secretsOf(secret) {
|
|
457
|
+
const list = typeof secret === "string" ? [secret] : secret;
|
|
458
|
+
if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
|
|
459
|
+
throw new ConnectError(
|
|
460
|
+
"misconfigured",
|
|
461
|
+
`The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return list;
|
|
465
|
+
}
|
|
466
|
+
function assertAppSecret(secret) {
|
|
467
|
+
secretsOf(secret);
|
|
468
|
+
}
|
|
469
|
+
function keyFor(secret, purpose) {
|
|
470
|
+
const cacheKey = `${purpose}\0${secret}`;
|
|
471
|
+
let key = keyCache.get(cacheKey);
|
|
472
|
+
if (!key) {
|
|
473
|
+
key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
|
|
474
|
+
(base) => crypto.subtle.deriveKey(
|
|
475
|
+
{ name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
|
|
476
|
+
base,
|
|
477
|
+
{ name: "AES-GCM", length: 256 },
|
|
478
|
+
false,
|
|
479
|
+
["encrypt", "decrypt"]
|
|
480
|
+
)
|
|
481
|
+
);
|
|
482
|
+
keyCache.set(cacheKey, key);
|
|
483
|
+
}
|
|
484
|
+
return key;
|
|
485
|
+
}
|
|
486
|
+
async function seal(value, opts) {
|
|
487
|
+
const [current] = secretsOf(opts.secret);
|
|
488
|
+
const iv = randomBytes(IV_BYTES);
|
|
489
|
+
const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
|
|
490
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
491
|
+
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
492
|
+
await keyFor(current, opts.purpose),
|
|
493
|
+
plaintext
|
|
494
|
+
);
|
|
495
|
+
const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
|
|
496
|
+
out.set(iv, 0);
|
|
497
|
+
out.set(new Uint8Array(ciphertext), IV_BYTES);
|
|
498
|
+
return `${VERSION}.${base64url(out)}`;
|
|
499
|
+
}
|
|
500
|
+
async function unseal(sealed, opts) {
|
|
501
|
+
const secrets = secretsOf(opts.secret);
|
|
502
|
+
const dot = sealed.indexOf(".");
|
|
503
|
+
if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
|
|
504
|
+
const bytes = fromBase64url(sealed.slice(dot + 1));
|
|
505
|
+
if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
|
|
506
|
+
const iv = bytes.slice(0, IV_BYTES);
|
|
507
|
+
const ciphertext = bytes.slice(IV_BYTES);
|
|
508
|
+
for (const secret of secrets) {
|
|
509
|
+
let plaintext;
|
|
510
|
+
try {
|
|
511
|
+
plaintext = await crypto.subtle.decrypt(
|
|
512
|
+
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
513
|
+
await keyFor(secret, opts.purpose),
|
|
514
|
+
ciphertext
|
|
515
|
+
);
|
|
516
|
+
} catch {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
let parsed;
|
|
520
|
+
try {
|
|
521
|
+
parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
|
|
522
|
+
} catch {
|
|
523
|
+
return { ok: false, reason: "invalid" };
|
|
524
|
+
}
|
|
525
|
+
if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
|
|
526
|
+
if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
|
|
527
|
+
return { ok: true, value: parsed.data, expiresAt: parsed.exp };
|
|
528
|
+
}
|
|
529
|
+
return { ok: false, reason: "invalid" };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/connect/cloud.ts
|
|
533
|
+
var CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
534
|
+
function cloudBaseUrl(cloudUrl, opts = {}) {
|
|
535
|
+
let url;
|
|
536
|
+
try {
|
|
537
|
+
url = new URL(cloudUrl.trim());
|
|
538
|
+
} catch {
|
|
539
|
+
throw new ConnectError("misconfigured", "cloudUrl is not a URL");
|
|
540
|
+
}
|
|
541
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
542
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
|
|
543
|
+
throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
|
|
544
|
+
}
|
|
545
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
546
|
+
throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
|
|
547
|
+
}
|
|
548
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
549
|
+
}
|
|
550
|
+
function cloudConnectAvailable(config) {
|
|
551
|
+
if (!config?.cloudUrl) return false;
|
|
552
|
+
try {
|
|
553
|
+
cloudBaseUrl(config.cloudUrl, config);
|
|
554
|
+
return true;
|
|
555
|
+
} catch {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function startCloudConnect(opts) {
|
|
560
|
+
if (!cloudConnectAvailable(opts)) {
|
|
561
|
+
throw new ConnectError("cp_unavailable", "cp: not configured \u2014 pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
|
|
562
|
+
}
|
|
563
|
+
if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
|
|
564
|
+
const base = cloudBaseUrl(opts.cloudUrl, opts);
|
|
565
|
+
const secret = opts.secret;
|
|
566
|
+
return (async () => {
|
|
567
|
+
const now = (opts.now ?? Date.now)();
|
|
568
|
+
const state = randomToken();
|
|
569
|
+
const url = new URL(`${base}/connect/pod`);
|
|
570
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
571
|
+
url.searchParams.set("state", state);
|
|
572
|
+
const tx = {
|
|
573
|
+
v: 1,
|
|
574
|
+
state,
|
|
575
|
+
clientId: opts.clientId,
|
|
576
|
+
cloudUrl: base,
|
|
577
|
+
next: opts.next ?? null,
|
|
578
|
+
spaceHint: opts.spaceHint ?? null,
|
|
579
|
+
prompt: opts.prompt === "consent" ? "consent" : null,
|
|
580
|
+
createdAt: now
|
|
581
|
+
};
|
|
582
|
+
const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
|
|
583
|
+
const transaction = await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt });
|
|
584
|
+
return { url: url.href, transaction, expiresAt };
|
|
585
|
+
})();
|
|
586
|
+
}
|
|
587
|
+
async function acceptCloudReturn(opts) {
|
|
588
|
+
if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
|
|
589
|
+
const opened = await unseal(opts.transaction, {
|
|
590
|
+
secret: opts.secret,
|
|
591
|
+
purpose: "synap-connect/cloud",
|
|
592
|
+
now: (opts.now ?? Date.now)()
|
|
593
|
+
});
|
|
594
|
+
if (!opened.ok) {
|
|
595
|
+
throw new ConnectError(
|
|
596
|
+
"expired_transaction",
|
|
597
|
+
opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed"
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
const tx = opened.value;
|
|
601
|
+
if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
|
|
602
|
+
throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
|
|
603
|
+
}
|
|
604
|
+
if (tx.clientId !== opts.clientId) {
|
|
605
|
+
throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
|
|
606
|
+
}
|
|
607
|
+
const params = new URL(opts.url).searchParams;
|
|
608
|
+
const state = params.get("state");
|
|
609
|
+
if (state === null || !CLOUD_STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
|
|
610
|
+
throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
|
|
611
|
+
}
|
|
612
|
+
const error = params.get("error");
|
|
613
|
+
if (error !== null) {
|
|
614
|
+
throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
|
|
615
|
+
}
|
|
616
|
+
const iss = params.get("iss");
|
|
617
|
+
if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
|
|
618
|
+
return {
|
|
619
|
+
pod: canonicalPodUrl(iss, opts),
|
|
620
|
+
next: tx.next,
|
|
621
|
+
spaceHint: tx.spaceHint,
|
|
622
|
+
prompt: tx.prompt === "consent" ? "consent" : null
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
371
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
|
+
}
|
|
372
633
|
function can(connection, permission, workspaceId) {
|
|
373
634
|
return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
|
|
374
635
|
}
|
|
636
|
+
function spaces(connection) {
|
|
637
|
+
const bySpace = /* @__PURE__ */ new Map();
|
|
638
|
+
for (const g of connection.grant) {
|
|
639
|
+
const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
|
|
640
|
+
if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
|
|
641
|
+
if (!space.templateSlug && g.templateSlug) space.templateSlug = g.templateSlug;
|
|
642
|
+
if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
|
|
643
|
+
bySpace.set(g.workspaceId, space);
|
|
644
|
+
}
|
|
645
|
+
return [...bySpace.values()];
|
|
646
|
+
}
|
|
647
|
+
var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
648
|
+
var PROPOSAL_IDS_MAX = 100;
|
|
375
649
|
var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
|
|
650
|
+
var TIMEOUT_MS2 = 1e4;
|
|
651
|
+
var MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
376
652
|
function hubUrl(podUrl, path, query) {
|
|
377
|
-
if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\"))
|
|
378
|
-
|
|
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;
|
|
379
659
|
}
|
|
380
|
-
|
|
660
|
+
if (url.origin !== new URL(podUrl).origin || !(url.pathname === "/api/hub" || url.pathname.startsWith("/api/hub/"))) return null;
|
|
381
661
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
382
662
|
if (value === void 0 || value === null) continue;
|
|
383
663
|
for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
|
|
384
664
|
}
|
|
385
665
|
return url;
|
|
386
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
|
+
}
|
|
387
685
|
function errorFor(status, body, retryAfter, now) {
|
|
388
686
|
const obj = body && typeof body === "object" ? body : {};
|
|
389
687
|
const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : void 0;
|
|
@@ -398,6 +696,7 @@ function errorFor(status, body, retryAfter, now) {
|
|
|
398
696
|
return { code: "rate_limited", ...base, ...retryAfterSec !== void 0 ? { retryAfterSec } : {} };
|
|
399
697
|
}
|
|
400
698
|
if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
|
|
699
|
+
if (status >= 300 && status < 400) return { code: "pod_moved", ...base };
|
|
401
700
|
return { code: "server", ...base };
|
|
402
701
|
}
|
|
403
702
|
function reviewUrlFor(podUrl, reviewUrl, proposalId) {
|
|
@@ -410,13 +709,37 @@ function reviewUrlFor(podUrl, reviewUrl, proposalId) {
|
|
|
410
709
|
}
|
|
411
710
|
return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
|
|
412
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
|
+
}
|
|
413
721
|
function createPodClient(connection, opts = {}) {
|
|
414
722
|
const now = opts.now ?? Date.now;
|
|
415
723
|
const doFetch = () => opts.fetch ?? fetch;
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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}` };
|
|
420
743
|
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
421
744
|
let res;
|
|
422
745
|
try {
|
|
@@ -424,21 +747,33 @@ function createPodClient(connection, opts = {}) {
|
|
|
424
747
|
method,
|
|
425
748
|
headers,
|
|
426
749
|
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
427
|
-
redirect: "manual"
|
|
750
|
+
redirect: "manual",
|
|
751
|
+
signal: timeoutSignal(opts.timeoutMs ?? TIMEOUT_MS2)
|
|
428
752
|
});
|
|
429
753
|
} catch {
|
|
430
754
|
return { ok: false, error: { code: "pod_unreachable", status: null } };
|
|
431
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
|
+
}
|
|
432
760
|
let data = null;
|
|
433
|
-
|
|
434
|
-
if (text) {
|
|
761
|
+
if (read2.text) {
|
|
435
762
|
try {
|
|
436
|
-
data = JSON.parse(text);
|
|
763
|
+
data = JSON.parse(read2.text);
|
|
437
764
|
} catch {
|
|
438
765
|
data = null;
|
|
439
766
|
}
|
|
440
767
|
}
|
|
441
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
|
+
}
|
|
442
777
|
return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
|
|
443
778
|
}
|
|
444
779
|
async function write(method, path, body) {
|
|
@@ -451,17 +786,189 @@ function createPodClient(connection, opts = {}) {
|
|
|
451
786
|
ok: true,
|
|
452
787
|
status: "proposed",
|
|
453
788
|
proposalId,
|
|
454
|
-
reviewUrl: reviewUrlFor(
|
|
789
|
+
reviewUrl: reviewUrlFor(current.podUrl, obj.reviewUrl, proposalId),
|
|
455
790
|
data: result.data
|
|
456
791
|
};
|
|
457
792
|
}
|
|
458
793
|
return { ok: true, status: "applied", data: result.data };
|
|
459
794
|
}
|
|
795
|
+
async function listPendingProposals(opts2 = {}) {
|
|
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);
|
|
806
|
+
}
|
|
807
|
+
const limit = Math.min(Math.max(Math.floor(opts2.limit ?? 20), 1), 200);
|
|
808
|
+
const offset = Math.max(Math.floor(opts2.offset ?? 0), 0);
|
|
809
|
+
const result = await request("GET", "/proposals", void 0, {
|
|
810
|
+
status: "pending",
|
|
811
|
+
view: "basic",
|
|
812
|
+
limit,
|
|
813
|
+
offset,
|
|
814
|
+
workspaceId: opts2.workspaceId,
|
|
815
|
+
...only ? { proposalIds: [...only].join(",") } : {}
|
|
816
|
+
});
|
|
817
|
+
if (!result.ok) return result;
|
|
818
|
+
const obj = result.data && typeof result.data === "object" ? result.data : {};
|
|
819
|
+
if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
|
|
820
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
|
|
821
|
+
}
|
|
822
|
+
const str = (v) => typeof v === "string" && v ? v : null;
|
|
823
|
+
const proposals = [];
|
|
824
|
+
for (const row of obj.proposals) {
|
|
825
|
+
if (!row || typeof row !== "object") continue;
|
|
826
|
+
const r = row;
|
|
827
|
+
const id = str(r.id);
|
|
828
|
+
if (!id || only && !only.has(id)) continue;
|
|
829
|
+
proposals.push({
|
|
830
|
+
id,
|
|
831
|
+
proposalType: str(r.proposalType) ?? "",
|
|
832
|
+
targetType: str(r.targetType) ?? "",
|
|
833
|
+
targetId: str(r.targetId),
|
|
834
|
+
workspaceId: str(r.workspaceId),
|
|
835
|
+
createdAt: str(r.createdAt) ?? "",
|
|
836
|
+
agentUserId: str(r.agentUserId),
|
|
837
|
+
sessionId: str(r.sessionId),
|
|
838
|
+
...str(r.summary) ? { summary: str(r.summary) } : {},
|
|
839
|
+
reviewUrl: reviewUrlFor(current.podUrl, null, id)
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
ok: true,
|
|
844
|
+
data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total }
|
|
845
|
+
};
|
|
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
|
+
}, (data) => Array.isArray(data) ? data.filter(hasId) : null);
|
|
865
|
+
}
|
|
866
|
+
async function getEntity(id) {
|
|
867
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
868
|
+
return read(`/entities/${id}`, void 0, (data) => hasId(data) ? data : null);
|
|
869
|
+
}
|
|
870
|
+
async function getEntityConnections(id, opts2 = {}) {
|
|
871
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
872
|
+
return read(`/entities/${id}/connections`, { workspaceId: opts2.workspaceId, limit: clamp(opts2.limit, 1, 200) }, (data) => {
|
|
873
|
+
const obj = asObject(data);
|
|
874
|
+
if (!obj || !Array.isArray(obj.connections)) return null;
|
|
875
|
+
const connections = obj.connections.filter(
|
|
876
|
+
(c) => !!c && typeof c === "object" && typeof c.entityId === "string"
|
|
877
|
+
);
|
|
878
|
+
const counts = asObject(obj.counts) ?? {};
|
|
879
|
+
const count = (key, source) => typeof counts[key] === "number" ? counts[key] : connections.filter((c) => c.source === source).length;
|
|
880
|
+
return {
|
|
881
|
+
connections,
|
|
882
|
+
counts: {
|
|
883
|
+
total: typeof counts.total === "number" ? counts.total : connections.length,
|
|
884
|
+
graph: count("graph", "graph"),
|
|
885
|
+
structural: count("structural", "property"),
|
|
886
|
+
threads: count("threads", "thread"),
|
|
887
|
+
contextChannels: count("contextChannels", "context_channel"),
|
|
888
|
+
focusSessions: count("focusSessions", "focus_session")
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
async function getDocument(id, opts2 = {}) {
|
|
894
|
+
if (!ID_RE.test(id)) return badId("document");
|
|
895
|
+
return read(`/documents/${id}`, { format: opts2.format }, (data) => {
|
|
896
|
+
const doc = asObject(asObject(data)?.document);
|
|
897
|
+
if (!doc || typeof doc.id !== "string" || typeof doc.content !== "string") return null;
|
|
898
|
+
return { ...doc, sections: Array.isArray(doc.sections) ? doc.sections : [] };
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
async function listFocusSessions(opts2) {
|
|
902
|
+
if (!opts2?.workspaceId && !opts2?.projectId) return refused("invalid", "invalid_input", "workspaceId or projectId is required");
|
|
903
|
+
return read("/focus-sessions", {
|
|
904
|
+
workspaceId: opts2.workspaceId,
|
|
905
|
+
projectId: opts2.projectId,
|
|
906
|
+
status: opts2.status,
|
|
907
|
+
limit: clamp(opts2.limit, 1, 50),
|
|
908
|
+
lens: opts2.lens,
|
|
909
|
+
kind: opts2.kind,
|
|
910
|
+
subjectEntityId: opts2.subjectEntityId
|
|
911
|
+
}, (data) => {
|
|
912
|
+
if (!Array.isArray(data)) return null;
|
|
913
|
+
return data.filter(hasId).map((row) => ({
|
|
914
|
+
...row,
|
|
915
|
+
expectedOutputs: Array.isArray(row.expectedOutputs) ? row.expectedOutputs.filter((s) => !!s && typeof s === "object" && typeof s.label === "string") : []
|
|
916
|
+
}));
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
async function getFileUrl(entityId) {
|
|
920
|
+
if (!ID_RE.test(entityId)) return badId("file");
|
|
921
|
+
return read(`/files/${entityId}/url`, void 0, (data) => {
|
|
922
|
+
const obj = asObject(data);
|
|
923
|
+
if (!obj || typeof obj.url !== "string" || typeof obj.expiresAt !== "string") return null;
|
|
924
|
+
return { url: obj.url, expiresAt: obj.expiresAt, mimeType: typeof obj.mimeType === "string" ? obj.mimeType : null };
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
async function approveProposal(id, opts2 = {}) {
|
|
928
|
+
if (connectionActsAs(current) !== "person") {
|
|
929
|
+
return refused("forbidden", "agent_cannot_approve", "An agent connection can never approve: approval is the person's");
|
|
930
|
+
}
|
|
931
|
+
if (!can(current, "proposal.approve", opts2.workspaceId)) {
|
|
932
|
+
return refused("forbidden", "not_granted", "The connection was not granted proposal.approve in that space");
|
|
933
|
+
}
|
|
934
|
+
if (!ID_RE.test(id)) return badId("proposal");
|
|
935
|
+
const result = await request("POST", `/proposals/${id}/approve`, {});
|
|
936
|
+
if (!result.ok) return result;
|
|
937
|
+
const obj = asObject(result.data);
|
|
938
|
+
if (!obj || typeof obj.proposalId !== "string") {
|
|
939
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's approve answer names no proposalId" } };
|
|
940
|
+
}
|
|
941
|
+
return {
|
|
942
|
+
ok: true,
|
|
943
|
+
data: {
|
|
944
|
+
proposalId: obj.proposalId,
|
|
945
|
+
...typeof obj.primaryId === "string" ? { primaryId: obj.primaryId } : {},
|
|
946
|
+
...typeof obj.linked === "number" ? { linked: obj.linked } : {},
|
|
947
|
+
proposal: asObject(obj.proposal)
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
}
|
|
460
951
|
return {
|
|
461
952
|
podUrl: connection.podUrl,
|
|
462
|
-
user
|
|
463
|
-
|
|
464
|
-
|
|
953
|
+
get user() {
|
|
954
|
+
return current.user;
|
|
955
|
+
},
|
|
956
|
+
get grant() {
|
|
957
|
+
return current.grant;
|
|
958
|
+
},
|
|
959
|
+
get actsAs() {
|
|
960
|
+
return connectionActsAs(current);
|
|
961
|
+
},
|
|
962
|
+
can: (permission, workspaceId) => can(current, permission, workspaceId),
|
|
963
|
+
spaces: () => spaces(current),
|
|
964
|
+
listPendingProposals,
|
|
965
|
+
listEntities,
|
|
966
|
+
getEntity,
|
|
967
|
+
getEntityConnections,
|
|
968
|
+
getDocument,
|
|
969
|
+
listFocusSessions,
|
|
970
|
+
getFileUrl,
|
|
971
|
+
approveProposal,
|
|
465
972
|
get(path, query) {
|
|
466
973
|
return request("GET", path, void 0, query);
|
|
467
974
|
},
|
|
@@ -480,94 +987,12 @@ function createPodClient(connection, opts = {}) {
|
|
|
480
987
|
};
|
|
481
988
|
}
|
|
482
989
|
|
|
483
|
-
// src/connect/seal.ts
|
|
484
|
-
var VERSION = "v1";
|
|
485
|
-
var IV_BYTES = 12;
|
|
486
|
-
var TAG_BYTES = 16;
|
|
487
|
-
var MIN_SECRET_LENGTH = 32;
|
|
488
|
-
var SALT = utf8("synap-connect/v1");
|
|
489
|
-
var keyCache = /* @__PURE__ */ new Map();
|
|
490
|
-
function secretsOf(secret) {
|
|
491
|
-
const list = typeof secret === "string" ? [secret] : secret;
|
|
492
|
-
if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
|
|
493
|
-
throw new ConnectError(
|
|
494
|
-
"misconfigured",
|
|
495
|
-
`The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
return list;
|
|
499
|
-
}
|
|
500
|
-
function assertAppSecret(secret) {
|
|
501
|
-
secretsOf(secret);
|
|
502
|
-
}
|
|
503
|
-
function keyFor(secret, purpose) {
|
|
504
|
-
const cacheKey = `${purpose}\0${secret}`;
|
|
505
|
-
let key = keyCache.get(cacheKey);
|
|
506
|
-
if (!key) {
|
|
507
|
-
key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
|
|
508
|
-
(base) => crypto.subtle.deriveKey(
|
|
509
|
-
{ name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
|
|
510
|
-
base,
|
|
511
|
-
{ name: "AES-GCM", length: 256 },
|
|
512
|
-
false,
|
|
513
|
-
["encrypt", "decrypt"]
|
|
514
|
-
)
|
|
515
|
-
);
|
|
516
|
-
keyCache.set(cacheKey, key);
|
|
517
|
-
}
|
|
518
|
-
return key;
|
|
519
|
-
}
|
|
520
|
-
async function seal(value, opts) {
|
|
521
|
-
const [current] = secretsOf(opts.secret);
|
|
522
|
-
const iv = randomBytes(IV_BYTES);
|
|
523
|
-
const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
|
|
524
|
-
const ciphertext = await crypto.subtle.encrypt(
|
|
525
|
-
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
526
|
-
await keyFor(current, opts.purpose),
|
|
527
|
-
plaintext
|
|
528
|
-
);
|
|
529
|
-
const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
|
|
530
|
-
out.set(iv, 0);
|
|
531
|
-
out.set(new Uint8Array(ciphertext), IV_BYTES);
|
|
532
|
-
return `${VERSION}.${base64url(out)}`;
|
|
533
|
-
}
|
|
534
|
-
async function unseal(sealed, opts) {
|
|
535
|
-
const secrets = secretsOf(opts.secret);
|
|
536
|
-
const dot = sealed.indexOf(".");
|
|
537
|
-
if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
|
|
538
|
-
const bytes = fromBase64url(sealed.slice(dot + 1));
|
|
539
|
-
if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
|
|
540
|
-
const iv = bytes.slice(0, IV_BYTES);
|
|
541
|
-
const ciphertext = bytes.slice(IV_BYTES);
|
|
542
|
-
for (const secret of secrets) {
|
|
543
|
-
let plaintext;
|
|
544
|
-
try {
|
|
545
|
-
plaintext = await crypto.subtle.decrypt(
|
|
546
|
-
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
547
|
-
await keyFor(secret, opts.purpose),
|
|
548
|
-
ciphertext
|
|
549
|
-
);
|
|
550
|
-
} catch {
|
|
551
|
-
continue;
|
|
552
|
-
}
|
|
553
|
-
let parsed;
|
|
554
|
-
try {
|
|
555
|
-
parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
|
|
556
|
-
} catch {
|
|
557
|
-
return { ok: false, reason: "invalid" };
|
|
558
|
-
}
|
|
559
|
-
if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
|
|
560
|
-
if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
|
|
561
|
-
return { ok: true, value: parsed.data, expiresAt: parsed.exp };
|
|
562
|
-
}
|
|
563
|
-
return { ok: false, reason: "invalid" };
|
|
564
|
-
}
|
|
565
|
-
|
|
566
990
|
// src/connect/flow.ts
|
|
567
991
|
var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
568
992
|
var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
569
993
|
var DEFAULT_ACCESS_TTL_SEC = 3600;
|
|
570
|
-
var
|
|
994
|
+
var TIMEOUT_MS3 = 1e4;
|
|
995
|
+
var MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
571
996
|
var PERMISSION_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i;
|
|
572
997
|
function assertPermissions(permissions) {
|
|
573
998
|
const unique = [...new Set(permissions)];
|
|
@@ -597,6 +1022,7 @@ async function startConnect(opts) {
|
|
|
597
1022
|
url.searchParams.set("code_challenge_method", "S256");
|
|
598
1023
|
if (resource) url.searchParams.set("resource", resource);
|
|
599
1024
|
if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
|
|
1025
|
+
if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
|
|
600
1026
|
const tx = {
|
|
601
1027
|
v: 1,
|
|
602
1028
|
issuer: issuer.issuer,
|
|
@@ -684,20 +1110,29 @@ async function tokenRequest(endpoint, body, during, opts) {
|
|
|
684
1110
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
685
1111
|
body: body.toString(),
|
|
686
1112
|
redirect: "manual",
|
|
687
|
-
signal:
|
|
1113
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
688
1114
|
});
|
|
689
1115
|
} catch {
|
|
690
1116
|
throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
|
|
691
1117
|
}
|
|
1118
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1119
|
+
await res.body?.cancel().catch(() => {
|
|
1120
|
+
});
|
|
1121
|
+
throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
|
|
1122
|
+
}
|
|
692
1123
|
if (res.status === 429) {
|
|
693
1124
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
694
1125
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
695
1126
|
});
|
|
696
1127
|
}
|
|
697
1128
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
|
|
1129
|
+
const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
1130
|
+
if (!read.ok) {
|
|
1131
|
+
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");
|
|
1132
|
+
}
|
|
698
1133
|
let json;
|
|
699
1134
|
try {
|
|
700
|
-
json =
|
|
1135
|
+
json = JSON.parse(read.text);
|
|
701
1136
|
} catch {
|
|
702
1137
|
throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
|
|
703
1138
|
}
|
|
@@ -713,9 +1148,30 @@ function parseGrant(value) {
|
|
|
713
1148
|
const out = [];
|
|
714
1149
|
for (const g of value) {
|
|
715
1150
|
if (!g || typeof g !== "object") return null;
|
|
716
|
-
const { permission, workspaceId, workspaceName } = g;
|
|
1151
|
+
const { permission, workspaceId, workspaceName, templateSlug } = g;
|
|
717
1152
|
if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
|
|
718
|
-
|
|
1153
|
+
const row = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
|
|
1154
|
+
if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
|
|
1155
|
+
out.push(row);
|
|
1156
|
+
}
|
|
1157
|
+
return out;
|
|
1158
|
+
}
|
|
1159
|
+
function parseActsAs(value) {
|
|
1160
|
+
return value === "person" || value === "agent" ? value : void 0;
|
|
1161
|
+
}
|
|
1162
|
+
var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
1163
|
+
function parseApp(value) {
|
|
1164
|
+
if (!value || typeof value !== "object") return {};
|
|
1165
|
+
const { id, manageUrl } = value;
|
|
1166
|
+
const out = {};
|
|
1167
|
+
if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
|
|
1168
|
+
if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
|
|
1169
|
+
try {
|
|
1170
|
+
const url = new URL(manageUrl);
|
|
1171
|
+
const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
1172
|
+
if (secure && !url.username && !url.password) out.manageUrl = url.href;
|
|
1173
|
+
} catch {
|
|
1174
|
+
}
|
|
719
1175
|
}
|
|
720
1176
|
return out;
|
|
721
1177
|
}
|
|
@@ -753,6 +1209,13 @@ function parseTokenResponse(body, ctx) {
|
|
|
753
1209
|
user,
|
|
754
1210
|
connectedAt: ctx.previous?.connectedAt ?? ctx.now
|
|
755
1211
|
};
|
|
1212
|
+
const app = parseApp(body.synap_app);
|
|
1213
|
+
const appId = app.appId ?? ctx.previous?.appId;
|
|
1214
|
+
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
1215
|
+
if (appId !== void 0) connection.appId = appId;
|
|
1216
|
+
if (manageUrl !== void 0) connection.manageUrl = manageUrl;
|
|
1217
|
+
const actsAs = parseActsAs(body.synap_acts_as);
|
|
1218
|
+
if (actsAs !== void 0) connection.actsAs = actsAs;
|
|
756
1219
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
757
1220
|
connection.refreshToken = refresh_token;
|
|
758
1221
|
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;
|
|
@@ -818,7 +1281,9 @@ async function revokeConnection(connection, opts = {}) {
|
|
|
818
1281
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
819
1282
|
body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
|
|
820
1283
|
redirect: "manual",
|
|
821
|
-
signal:
|
|
1284
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
1285
|
+
});
|
|
1286
|
+
await res.body?.cancel().catch(() => {
|
|
822
1287
|
});
|
|
823
1288
|
if (res.status !== 200) revoked = false;
|
|
824
1289
|
} catch {
|
|
@@ -830,7 +1295,25 @@ async function revokeConnection(connection, opts = {}) {
|
|
|
830
1295
|
|
|
831
1296
|
// src/connect/store.ts
|
|
832
1297
|
function cookieNames(secure) {
|
|
833
|
-
return secure ? { session: "__Host-synap_session", transaction: "__Host-synap_tx" } : { session: "synap_session", transaction: "synap_tx" };
|
|
1298
|
+
return secure ? { session: "__Host-synap_session", transaction: "__Host-synap_tx", cloud: "__Host-synap_cloud", marker: "__Host-synap_seen" } : { session: "synap_session", transaction: "synap_tx", cloud: "synap_cloud", marker: "synap_seen" };
|
|
1299
|
+
}
|
|
1300
|
+
var MARKER_TTL_SEC = 400 * 24 * 60 * 60;
|
|
1301
|
+
function markerCookie(podUrl, opts) {
|
|
1302
|
+
return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
|
|
1303
|
+
maxAgeSec: MARKER_TTL_SEC,
|
|
1304
|
+
secure: opts.secure
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
function readMarker(cookieHeader, opts) {
|
|
1308
|
+
const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
|
|
1309
|
+
const bytes = raw ? fromBase64url(raw) : null;
|
|
1310
|
+
if (!bytes || bytes.length > 512) return null;
|
|
1311
|
+
const podUrl = fromUtf8(bytes);
|
|
1312
|
+
try {
|
|
1313
|
+
return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
|
|
1314
|
+
} catch {
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
834
1317
|
}
|
|
835
1318
|
var CHUNK_SIZE = 3800;
|
|
836
1319
|
var MAX_CHUNKS = 4;
|
|
@@ -842,6 +1325,7 @@ function pack(c) {
|
|
|
842
1325
|
for (const g of c.grant) {
|
|
843
1326
|
const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
844
1327
|
row[2].push(g.permission);
|
|
1328
|
+
if (g.templateSlug && row[3] === void 0) row[3] = g.templateSlug;
|
|
845
1329
|
bySpace.set(g.workspaceId, row);
|
|
846
1330
|
}
|
|
847
1331
|
const { grant: _grant, ...rest } = c;
|
|
@@ -851,7 +1335,7 @@ function unpack(s) {
|
|
|
851
1335
|
if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
|
|
852
1336
|
const { g, ...rest } = s;
|
|
853
1337
|
const grant = g.flatMap(
|
|
854
|
-
([workspaceId, workspaceName, permissions]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName }))
|
|
1338
|
+
([workspaceId, workspaceName, permissions, templateSlug]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName, ...typeof templateSlug === "string" ? { templateSlug } : {} }))
|
|
855
1339
|
);
|
|
856
1340
|
return { ...rest, grant };
|
|
857
1341
|
}
|
|
@@ -924,14 +1408,36 @@ var DEFAULT_PATHS = {
|
|
|
924
1408
|
connect: "/api/synap/connect",
|
|
925
1409
|
callback: "/api/synap/callback",
|
|
926
1410
|
disconnect: "/api/synap/disconnect",
|
|
1411
|
+
cloud: "/api/synap/cloud",
|
|
927
1412
|
connectPage: "/connect",
|
|
928
1413
|
resultPage: "/connect/result",
|
|
929
1414
|
afterConnect: "/"
|
|
930
1415
|
};
|
|
931
1416
|
var REFRESH_WINDOW_MS = 5 * 60 * 1e3;
|
|
932
|
-
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
933
1417
|
var TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
934
1418
|
var MAX_METADATA_BYTES2 = 5 * 1024;
|
|
1419
|
+
var MAX_REQUESTS = 32;
|
|
1420
|
+
var KEPT_RETRY_MS = 6e4;
|
|
1421
|
+
var GRANT_ACTIONS = ["read", "create", "update", "delete"];
|
|
1422
|
+
var SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
|
|
1423
|
+
var PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
1424
|
+
function isSynapAppOrigin(origin) {
|
|
1425
|
+
return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
|
|
1426
|
+
}
|
|
1427
|
+
function assertAppPermission(permission, origin) {
|
|
1428
|
+
const segs = permission.split(".");
|
|
1429
|
+
const fail = (why) => {
|
|
1430
|
+
throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
|
|
1431
|
+
};
|
|
1432
|
+
if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
|
|
1433
|
+
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
1434
|
+
}
|
|
1435
|
+
if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
|
|
1436
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && isSynapAppOrigin(origin)) return;
|
|
1437
|
+
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
1438
|
+
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
935
1441
|
function appOriginOf(appUrl) {
|
|
936
1442
|
let url;
|
|
937
1443
|
try {
|
|
@@ -940,8 +1446,11 @@ function appOriginOf(appUrl) {
|
|
|
940
1446
|
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
941
1447
|
}
|
|
942
1448
|
const secure = url.protocol === "https:";
|
|
943
|
-
if (!secure && !(url.protocol === "http:" &&
|
|
944
|
-
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
1449
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
1450
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
|
|
1451
|
+
}
|
|
1452
|
+
if (secure && isIpLiteral(url.hostname)) {
|
|
1453
|
+
throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
|
|
945
1454
|
}
|
|
946
1455
|
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
947
1456
|
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
@@ -959,9 +1468,12 @@ function buildClientMetadata(opts) {
|
|
|
959
1468
|
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
960
1469
|
const clientName = opts.clientName.trim();
|
|
961
1470
|
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
1471
|
+
if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
|
|
1472
|
+
throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
|
|
1473
|
+
}
|
|
962
1474
|
assertPermissions(opts.requests.map((r) => r.permission));
|
|
963
1475
|
for (const r of opts.requests) {
|
|
964
|
-
|
|
1476
|
+
assertAppPermission(r.permission, origin);
|
|
965
1477
|
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
966
1478
|
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
967
1479
|
}
|
|
@@ -1015,7 +1527,7 @@ async function readForm(request) {
|
|
|
1015
1527
|
}
|
|
1016
1528
|
function isPrefetchOrAsset(request) {
|
|
1017
1529
|
const h = request.headers;
|
|
1018
|
-
if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
1530
|
+
if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
1019
1531
|
if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
|
|
1020
1532
|
const path = new URL(request.url).pathname;
|
|
1021
1533
|
return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
|
|
@@ -1026,6 +1538,7 @@ function createPodAppAuth(config) {
|
|
|
1026
1538
|
const { origin, secure } = appOriginOf(config.appUrl);
|
|
1027
1539
|
const paths = { ...DEFAULT_PATHS, ...config.paths };
|
|
1028
1540
|
const metadata = buildClientMetadata({ ...config, paths });
|
|
1541
|
+
const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
|
|
1029
1542
|
const clientId = metadata.client_id;
|
|
1030
1543
|
const redirectUri = metadata.redirect_uris[0];
|
|
1031
1544
|
const permissions = config.requests.map((r) => r.permission);
|
|
@@ -1042,6 +1555,37 @@ function createPodAppAuth(config) {
|
|
|
1042
1555
|
return e;
|
|
1043
1556
|
};
|
|
1044
1557
|
const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
|
|
1558
|
+
const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
|
|
1559
|
+
const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
|
|
1560
|
+
const marker = (podUrl) => markerCookie(podUrl, { secure, name: names.marker });
|
|
1561
|
+
const asRequest = (source) => source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
|
|
1562
|
+
const lapsedIn = (request) => {
|
|
1563
|
+
const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
|
|
1564
|
+
return podUrl ? { podUrl } : null;
|
|
1565
|
+
};
|
|
1566
|
+
async function toPod(opts, extra = []) {
|
|
1567
|
+
const started = await startConnect({
|
|
1568
|
+
...flow,
|
|
1569
|
+
pod: opts.pod,
|
|
1570
|
+
clientId,
|
|
1571
|
+
redirectUri,
|
|
1572
|
+
permissions,
|
|
1573
|
+
secret: config.secret,
|
|
1574
|
+
resource: config.resource,
|
|
1575
|
+
spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
|
|
1576
|
+
next: opts.next,
|
|
1577
|
+
prompt: opts.prompt,
|
|
1578
|
+
allowInsecureLoopback: config.allowInsecureLoopback
|
|
1579
|
+
});
|
|
1580
|
+
return redirect(started.authorizeUrl, [
|
|
1581
|
+
...extra,
|
|
1582
|
+
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
|
|
1583
|
+
]);
|
|
1584
|
+
}
|
|
1585
|
+
async function liveConnectionTo(request, pod) {
|
|
1586
|
+
const existing = await store.read(request);
|
|
1587
|
+
return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
|
|
1588
|
+
}
|
|
1045
1589
|
async function clientMetadata() {
|
|
1046
1590
|
return new Response(JSON.stringify(metadata), {
|
|
1047
1591
|
status: 200,
|
|
@@ -1055,26 +1599,10 @@ function createPodAppAuth(config) {
|
|
|
1055
1599
|
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
1056
1600
|
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
1057
1601
|
const space = formField(form, "space");
|
|
1602
|
+
const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
|
|
1058
1603
|
try {
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
return redirect(new URL(next, origin).href);
|
|
1062
|
-
}
|
|
1063
|
-
const started = await startConnect({
|
|
1064
|
-
...flow,
|
|
1065
|
-
pod,
|
|
1066
|
-
clientId,
|
|
1067
|
-
redirectUri,
|
|
1068
|
-
permissions,
|
|
1069
|
-
secret: config.secret,
|
|
1070
|
-
resource: config.resource,
|
|
1071
|
-
spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
|
|
1072
|
-
next,
|
|
1073
|
-
allowInsecureLoopback: config.allowInsecureLoopback
|
|
1074
|
-
});
|
|
1075
|
-
return redirect(started.authorizeUrl, [
|
|
1076
|
-
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
|
|
1077
|
-
]);
|
|
1604
|
+
if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
|
|
1605
|
+
return await toPod({ pod, next, space, prompt });
|
|
1078
1606
|
} catch (err) {
|
|
1079
1607
|
const e = fail(err, "connect");
|
|
1080
1608
|
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
|
|
@@ -1093,7 +1621,7 @@ function createPodAppAuth(config) {
|
|
|
1093
1621
|
secret: config.secret
|
|
1094
1622
|
});
|
|
1095
1623
|
const cookies = await store.write(connection, request);
|
|
1096
|
-
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
|
|
1624
|
+
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
|
|
1097
1625
|
} catch (err) {
|
|
1098
1626
|
const e = fail(err, "callback");
|
|
1099
1627
|
return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
|
|
@@ -1108,9 +1636,56 @@ function createPodAppAuth(config) {
|
|
|
1108
1636
|
const { revoked } = await revokeConnection(connection, flow);
|
|
1109
1637
|
if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
|
|
1110
1638
|
}
|
|
1111
|
-
return redirect(new URL(next, origin).href, await store.clear(request));
|
|
1639
|
+
return redirect(new URL(next, origin).href, [...await store.clear(request), clearMarker]);
|
|
1640
|
+
}
|
|
1641
|
+
async function cloudConnect(request) {
|
|
1642
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
1643
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
1644
|
+
const form = await readForm(request);
|
|
1645
|
+
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
1646
|
+
const space = formField(form, "space");
|
|
1647
|
+
try {
|
|
1648
|
+
const started = await startCloudConnect({
|
|
1649
|
+
cloudUrl,
|
|
1650
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
1651
|
+
clientId,
|
|
1652
|
+
secret: config.secret,
|
|
1653
|
+
next,
|
|
1654
|
+
spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
|
|
1655
|
+
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
1656
|
+
now
|
|
1657
|
+
});
|
|
1658
|
+
return redirect(started.url, [
|
|
1659
|
+
serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1e3, secure })
|
|
1660
|
+
]);
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
const e = fail(err, "connect");
|
|
1663
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
|
|
1664
|
+
}
|
|
1112
1665
|
}
|
|
1113
|
-
async function
|
|
1666
|
+
async function cloudReturn(request) {
|
|
1667
|
+
if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
|
|
1668
|
+
let pod = null;
|
|
1669
|
+
try {
|
|
1670
|
+
const back = await acceptCloudReturn({
|
|
1671
|
+
url: request.url,
|
|
1672
|
+
transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
|
|
1673
|
+
clientId,
|
|
1674
|
+
secret: config.secret,
|
|
1675
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
1676
|
+
now
|
|
1677
|
+
});
|
|
1678
|
+
pod = back.pod;
|
|
1679
|
+
const next = back.next ?? paths.afterConnect;
|
|
1680
|
+
if (!back.prompt && await liveConnectionTo(request, back.pod)) return redirect(new URL(next, origin).href, [clearCloud]);
|
|
1681
|
+
return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
|
|
1682
|
+
} catch (err) {
|
|
1683
|
+
const e = fail(err, "connect");
|
|
1684
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
const keptFailures = /* @__PURE__ */ new Map();
|
|
1688
|
+
async function refreshIfNeeded(request, opts = {}) {
|
|
1114
1689
|
const connection = await store.read(request);
|
|
1115
1690
|
const unchanged = (status, conn, error) => ({
|
|
1116
1691
|
status,
|
|
@@ -1119,21 +1694,33 @@ function createPodAppAuth(config) {
|
|
|
1119
1694
|
cookieHeader: null,
|
|
1120
1695
|
...error ? { error } : {}
|
|
1121
1696
|
});
|
|
1122
|
-
if (!connection)
|
|
1697
|
+
if (!connection) {
|
|
1698
|
+
const lapsed = lapsedIn(request);
|
|
1699
|
+
return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
|
|
1700
|
+
}
|
|
1123
1701
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
1124
1702
|
const t = now();
|
|
1125
|
-
const accessLives = connection.accessExpiresAt > t;
|
|
1126
|
-
if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1703
|
+
const accessLives = !opts.force && connection.accessExpiresAt > t;
|
|
1704
|
+
if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1127
1705
|
if (!connection.refreshToken) {
|
|
1128
1706
|
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
1129
1707
|
}
|
|
1708
|
+
for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
|
|
1709
|
+
const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : void 0;
|
|
1710
|
+
if (keptFailure) return unchanged("kept", connection, keptFailure.error);
|
|
1130
1711
|
let next;
|
|
1131
1712
|
try {
|
|
1132
1713
|
next = await refreshConnection(connection, flow);
|
|
1133
1714
|
} catch (err) {
|
|
1134
1715
|
const e = fail(err, "refresh");
|
|
1135
|
-
if (accessLives)
|
|
1136
|
-
|
|
1716
|
+
if (accessLives) {
|
|
1717
|
+
keptFailures.set(connection.refreshToken, { error: e, at: t });
|
|
1718
|
+
return unchanged("kept", connection, e);
|
|
1719
|
+
}
|
|
1720
|
+
if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
|
|
1721
|
+
const cleared = await store.clear(request).catch(() => []);
|
|
1722
|
+
return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
|
|
1723
|
+
}
|
|
1137
1724
|
return unchanged("unavailable", null, e);
|
|
1138
1725
|
}
|
|
1139
1726
|
let setCookies;
|
|
@@ -1144,6 +1731,7 @@ function createPodAppAuth(config) {
|
|
|
1144
1731
|
const cleared = await store.clear(request).catch(() => []);
|
|
1145
1732
|
return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
|
|
1146
1733
|
}
|
|
1734
|
+
setCookies = [...setCookies, marker(next.podUrl)];
|
|
1147
1735
|
return {
|
|
1148
1736
|
status: "refreshed",
|
|
1149
1737
|
connection: next,
|
|
@@ -1151,15 +1739,46 @@ function createPodAppAuth(config) {
|
|
|
1151
1739
|
cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies)
|
|
1152
1740
|
};
|
|
1153
1741
|
}
|
|
1742
|
+
function renewer(persist, source) {
|
|
1743
|
+
return async (connection) => {
|
|
1744
|
+
let next;
|
|
1745
|
+
try {
|
|
1746
|
+
next = await refreshConnection(connection, flow);
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
throw fail(err, "refresh");
|
|
1749
|
+
}
|
|
1750
|
+
try {
|
|
1751
|
+
const cookies = await store.write(next, asRequest(source ?? null));
|
|
1752
|
+
await persist([...cookies, marker(next.podUrl)]);
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
fail(err, "refresh");
|
|
1755
|
+
throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
|
|
1756
|
+
}
|
|
1757
|
+
return next;
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1154
1760
|
return {
|
|
1155
1761
|
clientId,
|
|
1156
1762
|
redirectUri,
|
|
1157
1763
|
metadata,
|
|
1158
1764
|
paths,
|
|
1159
|
-
|
|
1160
|
-
|
|
1765
|
+
cloudConnectAvailable: cloudUrl !== null,
|
|
1766
|
+
handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
|
|
1767
|
+
readConnection: (source) => store.read(asRequest(source)),
|
|
1768
|
+
async readLapsed(source) {
|
|
1769
|
+
const request = asRequest(source);
|
|
1770
|
+
return await store.read(request) ? null : lapsedIn(request);
|
|
1771
|
+
},
|
|
1161
1772
|
refreshIfNeeded,
|
|
1162
|
-
client
|
|
1773
|
+
client(connection, opts = {}) {
|
|
1774
|
+
const { persist, request, ...clientOpts } = opts;
|
|
1775
|
+
return createPodClient(connection, {
|
|
1776
|
+
fetch: config.fetch,
|
|
1777
|
+
now: config.now,
|
|
1778
|
+
...persist ? { renew: renewer(persist, request) } : {},
|
|
1779
|
+
...clientOpts
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1163
1782
|
};
|
|
1164
1783
|
}
|
|
1165
1784
|
|
|
@@ -1167,20 +1786,25 @@ function createPodAppAuth(config) {
|
|
|
1167
1786
|
assertServerRuntime();
|
|
1168
1787
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1169
1788
|
0 && (module.exports = {
|
|
1789
|
+
CLOUD_TRANSACTION_TTL_MS,
|
|
1170
1790
|
CONNECT_ERROR_CODES,
|
|
1171
1791
|
ConnectError,
|
|
1172
1792
|
DEFAULT_PATHS,
|
|
1173
1793
|
DEFAULT_REFRESH_IDLE_MS,
|
|
1794
|
+
MARKER_TTL_SEC,
|
|
1174
1795
|
MIN_SECRET_LENGTH,
|
|
1175
1796
|
REFRESH_WINDOW_MS,
|
|
1176
1797
|
ROTATION_MEMORY_MS,
|
|
1177
1798
|
TRANSACTION_TTL_MS,
|
|
1799
|
+
acceptCloudReturn,
|
|
1178
1800
|
acceptLaunch,
|
|
1179
1801
|
applySetCookies,
|
|
1180
1802
|
buildClientMetadata,
|
|
1181
1803
|
can,
|
|
1182
1804
|
canonicalPodUrl,
|
|
1805
|
+
cloudBaseUrl,
|
|
1183
1806
|
cloudConnectAvailable,
|
|
1807
|
+
connectionActsAs,
|
|
1184
1808
|
connectionExpiresAt,
|
|
1185
1809
|
cookieNames,
|
|
1186
1810
|
createPodAppAuth,
|
|
@@ -1188,17 +1812,22 @@ assertServerRuntime();
|
|
|
1188
1812
|
createSealedCookieStore,
|
|
1189
1813
|
discoverPod,
|
|
1190
1814
|
handleCallback,
|
|
1815
|
+
humanSlots,
|
|
1191
1816
|
isBrowserRuntime,
|
|
1192
1817
|
isConnectError,
|
|
1818
|
+
isPrefetchOrAsset,
|
|
1819
|
+
markerCookie,
|
|
1193
1820
|
openTransaction,
|
|
1194
1821
|
parseConnectErrorCode,
|
|
1195
1822
|
parseTokenResponse,
|
|
1196
1823
|
pkceChallenge,
|
|
1824
|
+
readMarker,
|
|
1197
1825
|
refreshConnection,
|
|
1198
1826
|
reviewUrlFor,
|
|
1199
1827
|
revokeConnection,
|
|
1200
1828
|
safeNextPath,
|
|
1201
1829
|
seal,
|
|
1830
|
+
spaces,
|
|
1202
1831
|
startCloudConnect,
|
|
1203
1832
|
startConnect,
|
|
1204
1833
|
unseal
|