@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.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CLOUD_STATE_RE,
|
|
3
|
+
LOOPBACK_HOSTS,
|
|
4
|
+
SPACE_HINT_RE,
|
|
2
5
|
base64url,
|
|
3
6
|
constantTimeEqual,
|
|
4
7
|
fromBase64url,
|
|
5
8
|
fromUtf8,
|
|
9
|
+
isIpLiteral,
|
|
6
10
|
parseCookies,
|
|
7
11
|
pkceChallenge,
|
|
8
12
|
randomBytes,
|
|
9
13
|
randomToken,
|
|
10
14
|
serializeCookie,
|
|
11
15
|
utf8
|
|
12
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-UK2U6SMA.js";
|
|
13
17
|
|
|
14
18
|
// src/connect/guard.ts
|
|
15
19
|
var BROWSER_REFUSAL = "@synap-core/sdk/connect is server-only: it holds the pod's tokens and the app secret. Import it from route handlers, proxy.ts or server code, never from a client component.";
|
|
@@ -52,6 +56,7 @@ var CONNECT_ERROR_CODES = [
|
|
|
52
56
|
"rate_limited",
|
|
53
57
|
"expired",
|
|
54
58
|
"revoked",
|
|
59
|
+
"pod_moved",
|
|
55
60
|
"server_error"
|
|
56
61
|
];
|
|
57
62
|
function parseConnectErrorCode(value) {
|
|
@@ -87,11 +92,44 @@ function retryAfterSeconds(header, now) {
|
|
|
87
92
|
return Math.max(0, Math.ceil((at - now) / 1e3));
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
// src/connect/http.ts
|
|
96
|
+
function timeoutSignal(ms) {
|
|
97
|
+
return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
|
|
98
|
+
}
|
|
99
|
+
async function readCapped(res, maxBytes) {
|
|
100
|
+
const declared = Number(res.headers.get("content-length"));
|
|
101
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
102
|
+
await res.body?.cancel().catch(() => {
|
|
103
|
+
});
|
|
104
|
+
return { ok: false, reason: "too_large" };
|
|
105
|
+
}
|
|
106
|
+
if (!res.body) return { ok: true, text: "" };
|
|
107
|
+
const reader = res.body.getReader();
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
let total = 0;
|
|
110
|
+
let text = "";
|
|
111
|
+
try {
|
|
112
|
+
for (; ; ) {
|
|
113
|
+
const { done, value } = await reader.read();
|
|
114
|
+
if (done) break;
|
|
115
|
+
total += value.byteLength;
|
|
116
|
+
if (total > maxBytes) {
|
|
117
|
+
await reader.cancel().catch(() => {
|
|
118
|
+
});
|
|
119
|
+
return { ok: false, reason: "too_large" };
|
|
120
|
+
}
|
|
121
|
+
text += decoder.decode(value, { stream: true });
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return { ok: false, reason: "unreadable" };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, text: text + decoder.decode() };
|
|
127
|
+
}
|
|
128
|
+
|
|
90
129
|
// src/connect/discovery.ts
|
|
91
130
|
var METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
92
131
|
var TIMEOUT_MS = 5e3;
|
|
93
132
|
var MAX_METADATA_BYTES = 64 * 1024;
|
|
94
|
-
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
95
133
|
function isPrivateIpv4(host) {
|
|
96
134
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
97
135
|
if (!m) return false;
|
|
@@ -144,10 +182,12 @@ function sameOriginEndpoint(value, issuer) {
|
|
|
144
182
|
}
|
|
145
183
|
}
|
|
146
184
|
async function readJsonCapped(res) {
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
185
|
+
const read = await readCapped(res, MAX_METADATA_BYTES);
|
|
186
|
+
if (!read.ok) {
|
|
187
|
+
throw read.reason === "too_large" ? new ConnectError("not_a_pod", "Metadata document too large") : new ConnectError("pod_unreachable", "The metadata answer broke off");
|
|
188
|
+
}
|
|
149
189
|
try {
|
|
150
|
-
return JSON.parse(text);
|
|
190
|
+
return JSON.parse(read.text);
|
|
151
191
|
} catch {
|
|
152
192
|
throw new ConnectError("not_a_pod", "Metadata is not JSON");
|
|
153
193
|
}
|
|
@@ -160,18 +200,27 @@ async function discoverIssuer(issuer, opts) {
|
|
|
160
200
|
method: "GET",
|
|
161
201
|
headers: { Accept: "application/json" },
|
|
162
202
|
redirect: "manual",
|
|
163
|
-
signal:
|
|
203
|
+
signal: timeoutSignal(TIMEOUT_MS)
|
|
164
204
|
});
|
|
165
205
|
} catch {
|
|
166
206
|
throw new ConnectError("pod_unreachable", "The pod did not answer");
|
|
167
207
|
}
|
|
208
|
+
if (res.status >= 300 && res.status < 400) {
|
|
209
|
+
await res.body?.cancel().catch(() => {
|
|
210
|
+
});
|
|
211
|
+
throw new ConnectError("pod_moved", `The address answered ${res.status}: the pod moved (its new address is not followed)`);
|
|
212
|
+
}
|
|
168
213
|
if (res.status === 429) {
|
|
169
214
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
170
215
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
171
216
|
});
|
|
172
217
|
}
|
|
173
218
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
|
|
174
|
-
if (res.status !== 200)
|
|
219
|
+
if (res.status !== 200) {
|
|
220
|
+
await res.body?.cancel().catch(() => {
|
|
221
|
+
});
|
|
222
|
+
throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
|
|
223
|
+
}
|
|
175
224
|
const doc = await readJsonCapped(res);
|
|
176
225
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
|
|
177
226
|
const m = doc;
|
|
@@ -197,27 +246,38 @@ async function discoverIssuer(issuer, opts) {
|
|
|
197
246
|
refreshSupported: list(m.grant_types_supported)?.includes("refresh_token") ?? false
|
|
198
247
|
};
|
|
199
248
|
}
|
|
249
|
+
var CLOUD_DOMAIN = "synap.live";
|
|
250
|
+
var CLOUD_POD_ZONE = `pod.${CLOUD_DOMAIN}`;
|
|
251
|
+
var POD_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
252
|
+
function fallbackIssuers(first) {
|
|
253
|
+
const url = new URL(first);
|
|
254
|
+
const host = url.hostname;
|
|
255
|
+
if (url.pathname !== "/" || !host.includes(".") || host.endsWith(`.${CLOUD_POD_ZONE}`)) return [];
|
|
256
|
+
const label = host.endsWith(`.${CLOUD_DOMAIN}`) ? host.slice(0, -CLOUD_DOMAIN.length - 1) : null;
|
|
257
|
+
if (label !== null && POD_NAME_RE.test(label)) {
|
|
258
|
+
return [`${url.protocol}//${label}.${CLOUD_POD_ZONE}`, `${url.protocol}//pod.${url.host}`];
|
|
259
|
+
}
|
|
260
|
+
return host.startsWith("pod.") ? [] : [`${url.protocol}//pod.${url.host}`];
|
|
261
|
+
}
|
|
200
262
|
async function discoverPod(input, opts = {}) {
|
|
201
263
|
const first = canonicalPodUrl(input, opts);
|
|
264
|
+
let firstError;
|
|
202
265
|
try {
|
|
203
266
|
return await discoverIssuer(first, opts);
|
|
204
267
|
} catch (err) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
268
|
+
firstError = err;
|
|
269
|
+
}
|
|
270
|
+
const retryable = (err) => err instanceof ConnectError && (err.code === "not_a_pod" || err.code === "pod_unreachable" || err.code === "pod_moved");
|
|
271
|
+
if (!retryable(firstError)) throw firstError;
|
|
272
|
+
let tooOld = null;
|
|
273
|
+
for (const candidate of fallbackIssuers(first)) {
|
|
209
274
|
try {
|
|
210
|
-
return await discoverIssuer(
|
|
211
|
-
} catch (
|
|
212
|
-
|
|
275
|
+
return await discoverIssuer(candidate, opts);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
|
|
213
278
|
}
|
|
214
279
|
}
|
|
215
|
-
|
|
216
|
-
function cloudConnectAvailable() {
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
function startCloudConnect(_opts) {
|
|
220
|
-
throw new ConnectError("cp_unavailable", "cp: not available \u2014 connecting through Synap Cloud is not built yet; use a pod address");
|
|
280
|
+
throw tooOld ?? firstError;
|
|
221
281
|
}
|
|
222
282
|
function safeNextPath(value, appOrigin) {
|
|
223
283
|
if (!value || value.length > 2048) return null;
|
|
@@ -248,29 +308,253 @@ function acceptLaunch(url, appOrigin, opts = {}) {
|
|
|
248
308
|
}
|
|
249
309
|
}
|
|
250
310
|
const hint = params.get("synap_space_hint");
|
|
311
|
+
const state = params.get("state");
|
|
251
312
|
return {
|
|
252
313
|
pod,
|
|
253
314
|
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
254
|
-
spaceHint: hint &&
|
|
315
|
+
spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
|
|
316
|
+
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
317
|
+
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
318
|
+
cloudState: state && CLOUD_STATE_RE.test(state) ? state : null
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/connect/seal.ts
|
|
323
|
+
var VERSION = "v1";
|
|
324
|
+
var IV_BYTES = 12;
|
|
325
|
+
var TAG_BYTES = 16;
|
|
326
|
+
var MIN_SECRET_LENGTH = 32;
|
|
327
|
+
var SALT = utf8("synap-connect/v1");
|
|
328
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
329
|
+
function secretsOf(secret) {
|
|
330
|
+
const list = typeof secret === "string" ? [secret] : secret;
|
|
331
|
+
if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
|
|
332
|
+
throw new ConnectError(
|
|
333
|
+
"misconfigured",
|
|
334
|
+
`The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return list;
|
|
338
|
+
}
|
|
339
|
+
function assertAppSecret(secret) {
|
|
340
|
+
secretsOf(secret);
|
|
341
|
+
}
|
|
342
|
+
function keyFor(secret, purpose) {
|
|
343
|
+
const cacheKey = `${purpose}\0${secret}`;
|
|
344
|
+
let key = keyCache.get(cacheKey);
|
|
345
|
+
if (!key) {
|
|
346
|
+
key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
|
|
347
|
+
(base) => crypto.subtle.deriveKey(
|
|
348
|
+
{ name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
|
|
349
|
+
base,
|
|
350
|
+
{ name: "AES-GCM", length: 256 },
|
|
351
|
+
false,
|
|
352
|
+
["encrypt", "decrypt"]
|
|
353
|
+
)
|
|
354
|
+
);
|
|
355
|
+
keyCache.set(cacheKey, key);
|
|
356
|
+
}
|
|
357
|
+
return key;
|
|
358
|
+
}
|
|
359
|
+
async function seal(value, opts) {
|
|
360
|
+
const [current] = secretsOf(opts.secret);
|
|
361
|
+
const iv = randomBytes(IV_BYTES);
|
|
362
|
+
const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
|
|
363
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
364
|
+
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
365
|
+
await keyFor(current, opts.purpose),
|
|
366
|
+
plaintext
|
|
367
|
+
);
|
|
368
|
+
const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
|
|
369
|
+
out.set(iv, 0);
|
|
370
|
+
out.set(new Uint8Array(ciphertext), IV_BYTES);
|
|
371
|
+
return `${VERSION}.${base64url(out)}`;
|
|
372
|
+
}
|
|
373
|
+
async function unseal(sealed, opts) {
|
|
374
|
+
const secrets = secretsOf(opts.secret);
|
|
375
|
+
const dot = sealed.indexOf(".");
|
|
376
|
+
if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
|
|
377
|
+
const bytes = fromBase64url(sealed.slice(dot + 1));
|
|
378
|
+
if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
|
|
379
|
+
const iv = bytes.slice(0, IV_BYTES);
|
|
380
|
+
const ciphertext = bytes.slice(IV_BYTES);
|
|
381
|
+
for (const secret of secrets) {
|
|
382
|
+
let plaintext;
|
|
383
|
+
try {
|
|
384
|
+
plaintext = await crypto.subtle.decrypt(
|
|
385
|
+
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
386
|
+
await keyFor(secret, opts.purpose),
|
|
387
|
+
ciphertext
|
|
388
|
+
);
|
|
389
|
+
} catch {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
let parsed;
|
|
393
|
+
try {
|
|
394
|
+
parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
|
|
395
|
+
} catch {
|
|
396
|
+
return { ok: false, reason: "invalid" };
|
|
397
|
+
}
|
|
398
|
+
if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
|
|
399
|
+
if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
|
|
400
|
+
return { ok: true, value: parsed.data, expiresAt: parsed.exp };
|
|
401
|
+
}
|
|
402
|
+
return { ok: false, reason: "invalid" };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/connect/cloud.ts
|
|
406
|
+
var CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
407
|
+
function cloudBaseUrl(cloudUrl, opts = {}) {
|
|
408
|
+
let url;
|
|
409
|
+
try {
|
|
410
|
+
url = new URL(cloudUrl.trim());
|
|
411
|
+
} catch {
|
|
412
|
+
throw new ConnectError("misconfigured", "cloudUrl is not a URL");
|
|
413
|
+
}
|
|
414
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
415
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
|
|
416
|
+
throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
|
|
417
|
+
}
|
|
418
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
419
|
+
throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
|
|
420
|
+
}
|
|
421
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
422
|
+
}
|
|
423
|
+
function cloudConnectAvailable(config) {
|
|
424
|
+
if (!config?.cloudUrl) return false;
|
|
425
|
+
try {
|
|
426
|
+
cloudBaseUrl(config.cloudUrl, config);
|
|
427
|
+
return true;
|
|
428
|
+
} catch {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function startCloudConnect(opts) {
|
|
433
|
+
if (!cloudConnectAvailable(opts)) {
|
|
434
|
+
throw new ConnectError("cp_unavailable", "cp: not configured \u2014 pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
|
|
435
|
+
}
|
|
436
|
+
if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
|
|
437
|
+
const base = cloudBaseUrl(opts.cloudUrl, opts);
|
|
438
|
+
const secret = opts.secret;
|
|
439
|
+
return (async () => {
|
|
440
|
+
const now = (opts.now ?? Date.now)();
|
|
441
|
+
const state = randomToken();
|
|
442
|
+
const url = new URL(`${base}/connect/pod`);
|
|
443
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
444
|
+
url.searchParams.set("state", state);
|
|
445
|
+
const tx = {
|
|
446
|
+
v: 1,
|
|
447
|
+
state,
|
|
448
|
+
clientId: opts.clientId,
|
|
449
|
+
cloudUrl: base,
|
|
450
|
+
next: opts.next ?? null,
|
|
451
|
+
spaceHint: opts.spaceHint ?? null,
|
|
452
|
+
prompt: opts.prompt === "consent" ? "consent" : null,
|
|
453
|
+
createdAt: now
|
|
454
|
+
};
|
|
455
|
+
const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
|
|
456
|
+
const transaction = await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt });
|
|
457
|
+
return { url: url.href, transaction, expiresAt };
|
|
458
|
+
})();
|
|
459
|
+
}
|
|
460
|
+
async function acceptCloudReturn(opts) {
|
|
461
|
+
if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
|
|
462
|
+
const opened = await unseal(opts.transaction, {
|
|
463
|
+
secret: opts.secret,
|
|
464
|
+
purpose: "synap-connect/cloud",
|
|
465
|
+
now: (opts.now ?? Date.now)()
|
|
466
|
+
});
|
|
467
|
+
if (!opened.ok) {
|
|
468
|
+
throw new ConnectError(
|
|
469
|
+
"expired_transaction",
|
|
470
|
+
opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed"
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const tx = opened.value;
|
|
474
|
+
if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
|
|
475
|
+
throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
|
|
476
|
+
}
|
|
477
|
+
if (tx.clientId !== opts.clientId) {
|
|
478
|
+
throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
|
|
479
|
+
}
|
|
480
|
+
const params = new URL(opts.url).searchParams;
|
|
481
|
+
const state = params.get("state");
|
|
482
|
+
if (state === null || !CLOUD_STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
|
|
483
|
+
throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
|
|
484
|
+
}
|
|
485
|
+
const error = params.get("error");
|
|
486
|
+
if (error !== null) {
|
|
487
|
+
throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
|
|
488
|
+
}
|
|
489
|
+
const iss = params.get("iss");
|
|
490
|
+
if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
|
|
491
|
+
return {
|
|
492
|
+
pod: canonicalPodUrl(iss, opts),
|
|
493
|
+
next: tx.next,
|
|
494
|
+
spaceHint: tx.spaceHint,
|
|
495
|
+
prompt: tx.prompt === "consent" ? "consent" : null
|
|
255
496
|
};
|
|
256
497
|
}
|
|
257
498
|
|
|
258
499
|
// src/connect/client.ts
|
|
500
|
+
function connectionActsAs(connection) {
|
|
501
|
+
return connection.actsAs === "person" ? "person" : "agent";
|
|
502
|
+
}
|
|
503
|
+
function humanSlots(session) {
|
|
504
|
+
return session.expectedOutputs.filter((slot) => slot.owner === "human" && slot.status !== "done" && !slot.retiredAt);
|
|
505
|
+
}
|
|
259
506
|
function can(connection, permission, workspaceId) {
|
|
260
507
|
return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
|
|
261
508
|
}
|
|
509
|
+
function spaces(connection) {
|
|
510
|
+
const bySpace = /* @__PURE__ */ new Map();
|
|
511
|
+
for (const g of connection.grant) {
|
|
512
|
+
const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
|
|
513
|
+
if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
|
|
514
|
+
if (!space.templateSlug && g.templateSlug) space.templateSlug = g.templateSlug;
|
|
515
|
+
if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
|
|
516
|
+
bySpace.set(g.workspaceId, space);
|
|
517
|
+
}
|
|
518
|
+
return [...bySpace.values()];
|
|
519
|
+
}
|
|
520
|
+
var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
521
|
+
var PROPOSAL_IDS_MAX = 100;
|
|
262
522
|
var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
|
|
523
|
+
var TIMEOUT_MS2 = 1e4;
|
|
524
|
+
var MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
263
525
|
function hubUrl(podUrl, path, query) {
|
|
264
|
-
if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\"))
|
|
265
|
-
|
|
526
|
+
if (!SAFE_PATH.test(path) || /%2e/i.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) return null;
|
|
527
|
+
let url;
|
|
528
|
+
try {
|
|
529
|
+
url = new URL(`${podUrl}/api/hub${path}`);
|
|
530
|
+
} catch {
|
|
531
|
+
return null;
|
|
266
532
|
}
|
|
267
|
-
|
|
533
|
+
if (url.origin !== new URL(podUrl).origin || !(url.pathname === "/api/hub" || url.pathname.startsWith("/api/hub/"))) return null;
|
|
268
534
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
269
535
|
if (value === void 0 || value === null) continue;
|
|
270
536
|
for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
|
|
271
537
|
}
|
|
272
538
|
return url;
|
|
273
539
|
}
|
|
540
|
+
function renewalError(err) {
|
|
541
|
+
const e = isConnectError(err) ? err : null;
|
|
542
|
+
const detail = e?.message ?? (err instanceof Error ? err.message : "The connection could not be renewed");
|
|
543
|
+
switch (e?.code) {
|
|
544
|
+
case "revoked":
|
|
545
|
+
return { code: "unauthorized", status: 401, detail };
|
|
546
|
+
case "expired":
|
|
547
|
+
return { code: "expired", status: null, detail };
|
|
548
|
+
case "rate_limited":
|
|
549
|
+
return { code: "rate_limited", status: null, detail, ...e.retryAfterSec !== void 0 ? { retryAfterSec: e.retryAfterSec } : {} };
|
|
550
|
+
case "pod_unreachable":
|
|
551
|
+
return { code: "pod_unreachable", status: null, detail };
|
|
552
|
+
case "pod_moved":
|
|
553
|
+
return { code: "pod_moved", status: null, detail };
|
|
554
|
+
default:
|
|
555
|
+
return { code: "server", status: null, detail };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
274
558
|
function errorFor(status, body, retryAfter, now) {
|
|
275
559
|
const obj = body && typeof body === "object" ? body : {};
|
|
276
560
|
const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : void 0;
|
|
@@ -285,6 +569,7 @@ function errorFor(status, body, retryAfter, now) {
|
|
|
285
569
|
return { code: "rate_limited", ...base, ...retryAfterSec !== void 0 ? { retryAfterSec } : {} };
|
|
286
570
|
}
|
|
287
571
|
if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
|
|
572
|
+
if (status >= 300 && status < 400) return { code: "pod_moved", ...base };
|
|
288
573
|
return { code: "server", ...base };
|
|
289
574
|
}
|
|
290
575
|
function reviewUrlFor(podUrl, reviewUrl, proposalId) {
|
|
@@ -297,13 +582,37 @@ function reviewUrlFor(podUrl, reviewUrl, proposalId) {
|
|
|
297
582
|
}
|
|
298
583
|
return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
|
|
299
584
|
}
|
|
585
|
+
function refused(code, reason, detail) {
|
|
586
|
+
return { ok: false, error: { code, status: null, detail, reason } };
|
|
587
|
+
}
|
|
588
|
+
function asObject(value) {
|
|
589
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
590
|
+
}
|
|
591
|
+
function hasId(value) {
|
|
592
|
+
return asObject(value) !== null && typeof value.id === "string";
|
|
593
|
+
}
|
|
300
594
|
function createPodClient(connection, opts = {}) {
|
|
301
595
|
const now = opts.now ?? Date.now;
|
|
302
596
|
const doFetch = () => opts.fetch ?? fetch;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
597
|
+
let current = connection;
|
|
598
|
+
let renewal = null;
|
|
599
|
+
function renewOnce(refused2) {
|
|
600
|
+
if (current !== refused2) return Promise.resolve({ ok: true, data: current });
|
|
601
|
+
renewal ??= opts.renew(refused2).then(
|
|
602
|
+
(next) => {
|
|
603
|
+
current = next;
|
|
604
|
+
return { ok: true, data: next };
|
|
605
|
+
},
|
|
606
|
+
(err) => ({ ok: false, error: renewalError(err) })
|
|
607
|
+
);
|
|
608
|
+
return renewal;
|
|
609
|
+
}
|
|
610
|
+
async function request(method, path, body, query, retried = false) {
|
|
611
|
+
const sent = current;
|
|
612
|
+
const url = hubUrl(sent.podUrl, path, query);
|
|
613
|
+
if (!url) return refused("invalid", "invalid_input", "Not a Hub API path: encode each path segment (encodeURIComponent)");
|
|
614
|
+
if (sent.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
|
|
615
|
+
const headers = { Accept: "application/json", Authorization: `Bearer ${sent.accessToken}` };
|
|
307
616
|
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
308
617
|
let res;
|
|
309
618
|
try {
|
|
@@ -311,21 +620,33 @@ function createPodClient(connection, opts = {}) {
|
|
|
311
620
|
method,
|
|
312
621
|
headers,
|
|
313
622
|
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
314
|
-
redirect: "manual"
|
|
623
|
+
redirect: "manual",
|
|
624
|
+
signal: timeoutSignal(opts.timeoutMs ?? TIMEOUT_MS2)
|
|
315
625
|
});
|
|
316
626
|
} catch {
|
|
317
627
|
return { ok: false, error: { code: "pod_unreachable", status: null } };
|
|
318
628
|
}
|
|
629
|
+
const read2 = await readCapped(res, opts.maxResponseBytes ?? MAX_RESPONSE_BYTES);
|
|
630
|
+
if (!read2.ok) {
|
|
631
|
+
return read2.reason === "too_large" ? { ok: false, error: { code: "server", status: res.status, detail: "The pod's answer is larger than maxResponseBytes" } } : { ok: false, error: { code: "pod_unreachable", status: res.status, detail: "The pod's answer broke off" } };
|
|
632
|
+
}
|
|
319
633
|
let data = null;
|
|
320
|
-
|
|
321
|
-
if (text) {
|
|
634
|
+
if (read2.text) {
|
|
322
635
|
try {
|
|
323
|
-
data = JSON.parse(text);
|
|
636
|
+
data = JSON.parse(read2.text);
|
|
324
637
|
} catch {
|
|
325
638
|
data = null;
|
|
326
639
|
}
|
|
327
640
|
}
|
|
328
641
|
if (res.status >= 200 && res.status < 300) return { ok: true, data };
|
|
642
|
+
if (res.status === 401 && sent.refreshToken) {
|
|
643
|
+
if (!opts.renew) return { ok: false, error: { ...errorFor(401, data, null, now()), refreshable: true } };
|
|
644
|
+
if (!retried) {
|
|
645
|
+
const renewed = await renewOnce(sent);
|
|
646
|
+
if (!renewed.ok) return renewed;
|
|
647
|
+
return request(method, path, body, query, true);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
329
650
|
return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
|
|
330
651
|
}
|
|
331
652
|
async function write(method, path, body) {
|
|
@@ -338,17 +659,189 @@ function createPodClient(connection, opts = {}) {
|
|
|
338
659
|
ok: true,
|
|
339
660
|
status: "proposed",
|
|
340
661
|
proposalId,
|
|
341
|
-
reviewUrl: reviewUrlFor(
|
|
662
|
+
reviewUrl: reviewUrlFor(current.podUrl, obj.reviewUrl, proposalId),
|
|
342
663
|
data: result.data
|
|
343
664
|
};
|
|
344
665
|
}
|
|
345
666
|
return { ok: true, status: "applied", data: result.data };
|
|
346
667
|
}
|
|
668
|
+
async function listPendingProposals(opts2 = {}) {
|
|
669
|
+
if (!can(current, "proposal.read", opts2.workspaceId)) {
|
|
670
|
+
return refused("forbidden", "not_granted", "The connection was not granted proposal.read in that space");
|
|
671
|
+
}
|
|
672
|
+
let only = null;
|
|
673
|
+
if (opts2.proposalIds !== void 0) {
|
|
674
|
+
if (opts2.proposalIds.length > PROPOSAL_IDS_MAX || opts2.proposalIds.some((id) => !ID_RE.test(id))) {
|
|
675
|
+
return refused("invalid", "invalid_input", `proposalIds must be at most ${PROPOSAL_IDS_MAX} proposal ids`);
|
|
676
|
+
}
|
|
677
|
+
if (opts2.proposalIds.length === 0) return { ok: true, data: { total: 0, proposals: [], hasMore: false } };
|
|
678
|
+
only = new Set(opts2.proposalIds);
|
|
679
|
+
}
|
|
680
|
+
const limit = Math.min(Math.max(Math.floor(opts2.limit ?? 20), 1), 200);
|
|
681
|
+
const offset = Math.max(Math.floor(opts2.offset ?? 0), 0);
|
|
682
|
+
const result = await request("GET", "/proposals", void 0, {
|
|
683
|
+
status: "pending",
|
|
684
|
+
view: "basic",
|
|
685
|
+
limit,
|
|
686
|
+
offset,
|
|
687
|
+
workspaceId: opts2.workspaceId,
|
|
688
|
+
...only ? { proposalIds: [...only].join(",") } : {}
|
|
689
|
+
});
|
|
690
|
+
if (!result.ok) return result;
|
|
691
|
+
const obj = result.data && typeof result.data === "object" ? result.data : {};
|
|
692
|
+
if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
|
|
693
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
|
|
694
|
+
}
|
|
695
|
+
const str = (v) => typeof v === "string" && v ? v : null;
|
|
696
|
+
const proposals = [];
|
|
697
|
+
for (const row of obj.proposals) {
|
|
698
|
+
if (!row || typeof row !== "object") continue;
|
|
699
|
+
const r = row;
|
|
700
|
+
const id = str(r.id);
|
|
701
|
+
if (!id || only && !only.has(id)) continue;
|
|
702
|
+
proposals.push({
|
|
703
|
+
id,
|
|
704
|
+
proposalType: str(r.proposalType) ?? "",
|
|
705
|
+
targetType: str(r.targetType) ?? "",
|
|
706
|
+
targetId: str(r.targetId),
|
|
707
|
+
workspaceId: str(r.workspaceId),
|
|
708
|
+
createdAt: str(r.createdAt) ?? "",
|
|
709
|
+
agentUserId: str(r.agentUserId),
|
|
710
|
+
sessionId: str(r.sessionId),
|
|
711
|
+
...str(r.summary) ? { summary: str(r.summary) } : {},
|
|
712
|
+
reviewUrl: reviewUrlFor(current.podUrl, null, id)
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
ok: true,
|
|
717
|
+
data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total }
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
async function read(path, query, shape) {
|
|
721
|
+
const result = await request("GET", path, void 0, query);
|
|
722
|
+
if (!result.ok) return result;
|
|
723
|
+
const data = shape(result.data);
|
|
724
|
+
if (data === null) return { ok: false, error: { code: "server", status: 200, detail: `The pod's ${path.split("/")[1]} answer is not the expected shape` } };
|
|
725
|
+
return { ok: true, data };
|
|
726
|
+
}
|
|
727
|
+
const badId = (what) => refused("invalid", "invalid_input", `Not a ${what} id`);
|
|
728
|
+
const clamp = (n, min, max) => n === void 0 ? void 0 : Math.min(Math.max(Math.floor(n), min), max);
|
|
729
|
+
function listEntities(opts2 = {}) {
|
|
730
|
+
return read("/entities", {
|
|
731
|
+
workspaceId: opts2.workspaceId,
|
|
732
|
+
profileSlug: opts2.profileSlug,
|
|
733
|
+
limit: clamp(opts2.limit, 1, 1e3),
|
|
734
|
+
offset: clamp(opts2.offset, 0, Number.MAX_SAFE_INTEGER),
|
|
735
|
+
sort: opts2.sort,
|
|
736
|
+
includePodWide: opts2.includePodWide
|
|
737
|
+
}, (data) => Array.isArray(data) ? data.filter(hasId) : null);
|
|
738
|
+
}
|
|
739
|
+
async function getEntity(id) {
|
|
740
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
741
|
+
return read(`/entities/${id}`, void 0, (data) => hasId(data) ? data : null);
|
|
742
|
+
}
|
|
743
|
+
async function getEntityConnections(id, opts2 = {}) {
|
|
744
|
+
if (!ID_RE.test(id)) return badId("entity");
|
|
745
|
+
return read(`/entities/${id}/connections`, { workspaceId: opts2.workspaceId, limit: clamp(opts2.limit, 1, 200) }, (data) => {
|
|
746
|
+
const obj = asObject(data);
|
|
747
|
+
if (!obj || !Array.isArray(obj.connections)) return null;
|
|
748
|
+
const connections = obj.connections.filter(
|
|
749
|
+
(c) => !!c && typeof c === "object" && typeof c.entityId === "string"
|
|
750
|
+
);
|
|
751
|
+
const counts = asObject(obj.counts) ?? {};
|
|
752
|
+
const count = (key, source) => typeof counts[key] === "number" ? counts[key] : connections.filter((c) => c.source === source).length;
|
|
753
|
+
return {
|
|
754
|
+
connections,
|
|
755
|
+
counts: {
|
|
756
|
+
total: typeof counts.total === "number" ? counts.total : connections.length,
|
|
757
|
+
graph: count("graph", "graph"),
|
|
758
|
+
structural: count("structural", "property"),
|
|
759
|
+
threads: count("threads", "thread"),
|
|
760
|
+
contextChannels: count("contextChannels", "context_channel"),
|
|
761
|
+
focusSessions: count("focusSessions", "focus_session")
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
async function getDocument(id, opts2 = {}) {
|
|
767
|
+
if (!ID_RE.test(id)) return badId("document");
|
|
768
|
+
return read(`/documents/${id}`, { format: opts2.format }, (data) => {
|
|
769
|
+
const doc = asObject(asObject(data)?.document);
|
|
770
|
+
if (!doc || typeof doc.id !== "string" || typeof doc.content !== "string") return null;
|
|
771
|
+
return { ...doc, sections: Array.isArray(doc.sections) ? doc.sections : [] };
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
async function listFocusSessions(opts2) {
|
|
775
|
+
if (!opts2?.workspaceId && !opts2?.projectId) return refused("invalid", "invalid_input", "workspaceId or projectId is required");
|
|
776
|
+
return read("/focus-sessions", {
|
|
777
|
+
workspaceId: opts2.workspaceId,
|
|
778
|
+
projectId: opts2.projectId,
|
|
779
|
+
status: opts2.status,
|
|
780
|
+
limit: clamp(opts2.limit, 1, 50),
|
|
781
|
+
lens: opts2.lens,
|
|
782
|
+
kind: opts2.kind,
|
|
783
|
+
subjectEntityId: opts2.subjectEntityId
|
|
784
|
+
}, (data) => {
|
|
785
|
+
if (!Array.isArray(data)) return null;
|
|
786
|
+
return data.filter(hasId).map((row) => ({
|
|
787
|
+
...row,
|
|
788
|
+
expectedOutputs: Array.isArray(row.expectedOutputs) ? row.expectedOutputs.filter((s) => !!s && typeof s === "object" && typeof s.label === "string") : []
|
|
789
|
+
}));
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
async function getFileUrl(entityId) {
|
|
793
|
+
if (!ID_RE.test(entityId)) return badId("file");
|
|
794
|
+
return read(`/files/${entityId}/url`, void 0, (data) => {
|
|
795
|
+
const obj = asObject(data);
|
|
796
|
+
if (!obj || typeof obj.url !== "string" || typeof obj.expiresAt !== "string") return null;
|
|
797
|
+
return { url: obj.url, expiresAt: obj.expiresAt, mimeType: typeof obj.mimeType === "string" ? obj.mimeType : null };
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
async function approveProposal(id, opts2 = {}) {
|
|
801
|
+
if (connectionActsAs(current) !== "person") {
|
|
802
|
+
return refused("forbidden", "agent_cannot_approve", "An agent connection can never approve: approval is the person's");
|
|
803
|
+
}
|
|
804
|
+
if (!can(current, "proposal.approve", opts2.workspaceId)) {
|
|
805
|
+
return refused("forbidden", "not_granted", "The connection was not granted proposal.approve in that space");
|
|
806
|
+
}
|
|
807
|
+
if (!ID_RE.test(id)) return badId("proposal");
|
|
808
|
+
const result = await request("POST", `/proposals/${id}/approve`, {});
|
|
809
|
+
if (!result.ok) return result;
|
|
810
|
+
const obj = asObject(result.data);
|
|
811
|
+
if (!obj || typeof obj.proposalId !== "string") {
|
|
812
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's approve answer names no proposalId" } };
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
ok: true,
|
|
816
|
+
data: {
|
|
817
|
+
proposalId: obj.proposalId,
|
|
818
|
+
...typeof obj.primaryId === "string" ? { primaryId: obj.primaryId } : {},
|
|
819
|
+
...typeof obj.linked === "number" ? { linked: obj.linked } : {},
|
|
820
|
+
proposal: asObject(obj.proposal)
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
347
824
|
return {
|
|
348
825
|
podUrl: connection.podUrl,
|
|
349
|
-
user
|
|
350
|
-
|
|
351
|
-
|
|
826
|
+
get user() {
|
|
827
|
+
return current.user;
|
|
828
|
+
},
|
|
829
|
+
get grant() {
|
|
830
|
+
return current.grant;
|
|
831
|
+
},
|
|
832
|
+
get actsAs() {
|
|
833
|
+
return connectionActsAs(current);
|
|
834
|
+
},
|
|
835
|
+
can: (permission, workspaceId) => can(current, permission, workspaceId),
|
|
836
|
+
spaces: () => spaces(current),
|
|
837
|
+
listPendingProposals,
|
|
838
|
+
listEntities,
|
|
839
|
+
getEntity,
|
|
840
|
+
getEntityConnections,
|
|
841
|
+
getDocument,
|
|
842
|
+
listFocusSessions,
|
|
843
|
+
getFileUrl,
|
|
844
|
+
approveProposal,
|
|
352
845
|
get(path, query) {
|
|
353
846
|
return request("GET", path, void 0, query);
|
|
354
847
|
},
|
|
@@ -367,94 +860,12 @@ function createPodClient(connection, opts = {}) {
|
|
|
367
860
|
};
|
|
368
861
|
}
|
|
369
862
|
|
|
370
|
-
// src/connect/seal.ts
|
|
371
|
-
var VERSION = "v1";
|
|
372
|
-
var IV_BYTES = 12;
|
|
373
|
-
var TAG_BYTES = 16;
|
|
374
|
-
var MIN_SECRET_LENGTH = 32;
|
|
375
|
-
var SALT = utf8("synap-connect/v1");
|
|
376
|
-
var keyCache = /* @__PURE__ */ new Map();
|
|
377
|
-
function secretsOf(secret) {
|
|
378
|
-
const list = typeof secret === "string" ? [secret] : secret;
|
|
379
|
-
if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
|
|
380
|
-
throw new ConnectError(
|
|
381
|
-
"misconfigured",
|
|
382
|
-
`The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
return list;
|
|
386
|
-
}
|
|
387
|
-
function assertAppSecret(secret) {
|
|
388
|
-
secretsOf(secret);
|
|
389
|
-
}
|
|
390
|
-
function keyFor(secret, purpose) {
|
|
391
|
-
const cacheKey = `${purpose}\0${secret}`;
|
|
392
|
-
let key = keyCache.get(cacheKey);
|
|
393
|
-
if (!key) {
|
|
394
|
-
key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
|
|
395
|
-
(base) => crypto.subtle.deriveKey(
|
|
396
|
-
{ name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
|
|
397
|
-
base,
|
|
398
|
-
{ name: "AES-GCM", length: 256 },
|
|
399
|
-
false,
|
|
400
|
-
["encrypt", "decrypt"]
|
|
401
|
-
)
|
|
402
|
-
);
|
|
403
|
-
keyCache.set(cacheKey, key);
|
|
404
|
-
}
|
|
405
|
-
return key;
|
|
406
|
-
}
|
|
407
|
-
async function seal(value, opts) {
|
|
408
|
-
const [current] = secretsOf(opts.secret);
|
|
409
|
-
const iv = randomBytes(IV_BYTES);
|
|
410
|
-
const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
|
|
411
|
-
const ciphertext = await crypto.subtle.encrypt(
|
|
412
|
-
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
413
|
-
await keyFor(current, opts.purpose),
|
|
414
|
-
plaintext
|
|
415
|
-
);
|
|
416
|
-
const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
|
|
417
|
-
out.set(iv, 0);
|
|
418
|
-
out.set(new Uint8Array(ciphertext), IV_BYTES);
|
|
419
|
-
return `${VERSION}.${base64url(out)}`;
|
|
420
|
-
}
|
|
421
|
-
async function unseal(sealed, opts) {
|
|
422
|
-
const secrets = secretsOf(opts.secret);
|
|
423
|
-
const dot = sealed.indexOf(".");
|
|
424
|
-
if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
|
|
425
|
-
const bytes = fromBase64url(sealed.slice(dot + 1));
|
|
426
|
-
if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
|
|
427
|
-
const iv = bytes.slice(0, IV_BYTES);
|
|
428
|
-
const ciphertext = bytes.slice(IV_BYTES);
|
|
429
|
-
for (const secret of secrets) {
|
|
430
|
-
let plaintext;
|
|
431
|
-
try {
|
|
432
|
-
plaintext = await crypto.subtle.decrypt(
|
|
433
|
-
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
434
|
-
await keyFor(secret, opts.purpose),
|
|
435
|
-
ciphertext
|
|
436
|
-
);
|
|
437
|
-
} catch {
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
let parsed;
|
|
441
|
-
try {
|
|
442
|
-
parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
|
|
443
|
-
} catch {
|
|
444
|
-
return { ok: false, reason: "invalid" };
|
|
445
|
-
}
|
|
446
|
-
if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
|
|
447
|
-
if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
|
|
448
|
-
return { ok: true, value: parsed.data, expiresAt: parsed.exp };
|
|
449
|
-
}
|
|
450
|
-
return { ok: false, reason: "invalid" };
|
|
451
|
-
}
|
|
452
|
-
|
|
453
863
|
// src/connect/flow.ts
|
|
454
864
|
var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
455
865
|
var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
456
866
|
var DEFAULT_ACCESS_TTL_SEC = 3600;
|
|
457
|
-
var
|
|
867
|
+
var TIMEOUT_MS3 = 1e4;
|
|
868
|
+
var MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
458
869
|
var PERMISSION_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i;
|
|
459
870
|
function assertPermissions(permissions) {
|
|
460
871
|
const unique = [...new Set(permissions)];
|
|
@@ -484,6 +895,7 @@ async function startConnect(opts) {
|
|
|
484
895
|
url.searchParams.set("code_challenge_method", "S256");
|
|
485
896
|
if (resource) url.searchParams.set("resource", resource);
|
|
486
897
|
if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
|
|
898
|
+
if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
|
|
487
899
|
const tx = {
|
|
488
900
|
v: 1,
|
|
489
901
|
issuer: issuer.issuer,
|
|
@@ -571,20 +983,29 @@ async function tokenRequest(endpoint, body, during, opts) {
|
|
|
571
983
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
572
984
|
body: body.toString(),
|
|
573
985
|
redirect: "manual",
|
|
574
|
-
signal:
|
|
986
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
575
987
|
});
|
|
576
988
|
} catch {
|
|
577
989
|
throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
|
|
578
990
|
}
|
|
991
|
+
if (res.status >= 300 && res.status < 400) {
|
|
992
|
+
await res.body?.cancel().catch(() => {
|
|
993
|
+
});
|
|
994
|
+
throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
|
|
995
|
+
}
|
|
579
996
|
if (res.status === 429) {
|
|
580
997
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
581
998
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)())
|
|
582
999
|
});
|
|
583
1000
|
}
|
|
584
1001
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
|
|
1002
|
+
const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
1003
|
+
if (!read.ok) {
|
|
1004
|
+
throw read.reason === "too_large" ? new ConnectError("server_error", "The token endpoint's answer is too large") : new ConnectError("pod_unreachable", "The token endpoint's answer broke off");
|
|
1005
|
+
}
|
|
585
1006
|
let json;
|
|
586
1007
|
try {
|
|
587
|
-
json =
|
|
1008
|
+
json = JSON.parse(read.text);
|
|
588
1009
|
} catch {
|
|
589
1010
|
throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
|
|
590
1011
|
}
|
|
@@ -600,9 +1021,30 @@ function parseGrant(value) {
|
|
|
600
1021
|
const out = [];
|
|
601
1022
|
for (const g of value) {
|
|
602
1023
|
if (!g || typeof g !== "object") return null;
|
|
603
|
-
const { permission, workspaceId, workspaceName } = g;
|
|
1024
|
+
const { permission, workspaceId, workspaceName, templateSlug } = g;
|
|
604
1025
|
if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
|
|
605
|
-
|
|
1026
|
+
const row = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
|
|
1027
|
+
if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
|
|
1028
|
+
out.push(row);
|
|
1029
|
+
}
|
|
1030
|
+
return out;
|
|
1031
|
+
}
|
|
1032
|
+
function parseActsAs(value) {
|
|
1033
|
+
return value === "person" || value === "agent" ? value : void 0;
|
|
1034
|
+
}
|
|
1035
|
+
var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
1036
|
+
function parseApp(value) {
|
|
1037
|
+
if (!value || typeof value !== "object") return {};
|
|
1038
|
+
const { id, manageUrl } = value;
|
|
1039
|
+
const out = {};
|
|
1040
|
+
if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
|
|
1041
|
+
if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
|
|
1042
|
+
try {
|
|
1043
|
+
const url = new URL(manageUrl);
|
|
1044
|
+
const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
1045
|
+
if (secure && !url.username && !url.password) out.manageUrl = url.href;
|
|
1046
|
+
} catch {
|
|
1047
|
+
}
|
|
606
1048
|
}
|
|
607
1049
|
return out;
|
|
608
1050
|
}
|
|
@@ -640,6 +1082,13 @@ function parseTokenResponse(body, ctx) {
|
|
|
640
1082
|
user,
|
|
641
1083
|
connectedAt: ctx.previous?.connectedAt ?? ctx.now
|
|
642
1084
|
};
|
|
1085
|
+
const app = parseApp(body.synap_app);
|
|
1086
|
+
const appId = app.appId ?? ctx.previous?.appId;
|
|
1087
|
+
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
1088
|
+
if (appId !== void 0) connection.appId = appId;
|
|
1089
|
+
if (manageUrl !== void 0) connection.manageUrl = manageUrl;
|
|
1090
|
+
const actsAs = parseActsAs(body.synap_acts_as);
|
|
1091
|
+
if (actsAs !== void 0) connection.actsAs = actsAs;
|
|
643
1092
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
644
1093
|
connection.refreshToken = refresh_token;
|
|
645
1094
|
const idle = typeof refresh_token_expires_in === "number" && Number.isFinite(refresh_token_expires_in) && refresh_token_expires_in > 0 ? refresh_token_expires_in * 1e3 : DEFAULT_REFRESH_IDLE_MS;
|
|
@@ -705,7 +1154,9 @@ async function revokeConnection(connection, opts = {}) {
|
|
|
705
1154
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
706
1155
|
body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
|
|
707
1156
|
redirect: "manual",
|
|
708
|
-
signal:
|
|
1157
|
+
signal: timeoutSignal(TIMEOUT_MS3)
|
|
1158
|
+
});
|
|
1159
|
+
await res.body?.cancel().catch(() => {
|
|
709
1160
|
});
|
|
710
1161
|
if (res.status !== 200) revoked = false;
|
|
711
1162
|
} catch {
|
|
@@ -717,7 +1168,25 @@ async function revokeConnection(connection, opts = {}) {
|
|
|
717
1168
|
|
|
718
1169
|
// src/connect/store.ts
|
|
719
1170
|
function cookieNames(secure) {
|
|
720
|
-
return secure ? { session: "__Host-synap_session", transaction: "__Host-synap_tx" } : { session: "synap_session", transaction: "synap_tx" };
|
|
1171
|
+
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" };
|
|
1172
|
+
}
|
|
1173
|
+
var MARKER_TTL_SEC = 400 * 24 * 60 * 60;
|
|
1174
|
+
function markerCookie(podUrl, opts) {
|
|
1175
|
+
return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
|
|
1176
|
+
maxAgeSec: MARKER_TTL_SEC,
|
|
1177
|
+
secure: opts.secure
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
function readMarker(cookieHeader, opts) {
|
|
1181
|
+
const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
|
|
1182
|
+
const bytes = raw ? fromBase64url(raw) : null;
|
|
1183
|
+
if (!bytes || bytes.length > 512) return null;
|
|
1184
|
+
const podUrl = fromUtf8(bytes);
|
|
1185
|
+
try {
|
|
1186
|
+
return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
|
|
1187
|
+
} catch {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
721
1190
|
}
|
|
722
1191
|
var CHUNK_SIZE = 3800;
|
|
723
1192
|
var MAX_CHUNKS = 4;
|
|
@@ -729,6 +1198,7 @@ function pack(c) {
|
|
|
729
1198
|
for (const g of c.grant) {
|
|
730
1199
|
const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
731
1200
|
row[2].push(g.permission);
|
|
1201
|
+
if (g.templateSlug && row[3] === void 0) row[3] = g.templateSlug;
|
|
732
1202
|
bySpace.set(g.workspaceId, row);
|
|
733
1203
|
}
|
|
734
1204
|
const { grant: _grant, ...rest } = c;
|
|
@@ -738,7 +1208,7 @@ function unpack(s) {
|
|
|
738
1208
|
if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
|
|
739
1209
|
const { g, ...rest } = s;
|
|
740
1210
|
const grant = g.flatMap(
|
|
741
|
-
([workspaceId, workspaceName, permissions]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName }))
|
|
1211
|
+
([workspaceId, workspaceName, permissions, templateSlug]) => permissions.map((permission) => ({ permission, workspaceId, workspaceName, ...typeof templateSlug === "string" ? { templateSlug } : {} }))
|
|
742
1212
|
);
|
|
743
1213
|
return { ...rest, grant };
|
|
744
1214
|
}
|
|
@@ -811,14 +1281,36 @@ var DEFAULT_PATHS = {
|
|
|
811
1281
|
connect: "/api/synap/connect",
|
|
812
1282
|
callback: "/api/synap/callback",
|
|
813
1283
|
disconnect: "/api/synap/disconnect",
|
|
1284
|
+
cloud: "/api/synap/cloud",
|
|
814
1285
|
connectPage: "/connect",
|
|
815
1286
|
resultPage: "/connect/result",
|
|
816
1287
|
afterConnect: "/"
|
|
817
1288
|
};
|
|
818
1289
|
var REFRESH_WINDOW_MS = 5 * 60 * 1e3;
|
|
819
|
-
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
820
1290
|
var TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
821
1291
|
var MAX_METADATA_BYTES2 = 5 * 1024;
|
|
1292
|
+
var MAX_REQUESTS = 32;
|
|
1293
|
+
var KEPT_RETRY_MS = 6e4;
|
|
1294
|
+
var GRANT_ACTIONS = ["read", "create", "update", "delete"];
|
|
1295
|
+
var SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
|
|
1296
|
+
var PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
1297
|
+
function isSynapAppOrigin(origin) {
|
|
1298
|
+
return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
|
|
1299
|
+
}
|
|
1300
|
+
function assertAppPermission(permission, origin) {
|
|
1301
|
+
const segs = permission.split(".");
|
|
1302
|
+
const fail = (why) => {
|
|
1303
|
+
throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
|
|
1304
|
+
};
|
|
1305
|
+
if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
|
|
1306
|
+
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
1307
|
+
}
|
|
1308
|
+
if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
|
|
1309
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && isSynapAppOrigin(origin)) return;
|
|
1310
|
+
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
1311
|
+
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
822
1314
|
function appOriginOf(appUrl) {
|
|
823
1315
|
let url;
|
|
824
1316
|
try {
|
|
@@ -827,8 +1319,11 @@ function appOriginOf(appUrl) {
|
|
|
827
1319
|
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
828
1320
|
}
|
|
829
1321
|
const secure = url.protocol === "https:";
|
|
830
|
-
if (!secure && !(url.protocol === "http:" &&
|
|
831
|
-
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
1322
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
1323
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
|
|
1324
|
+
}
|
|
1325
|
+
if (secure && isIpLiteral(url.hostname)) {
|
|
1326
|
+
throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
|
|
832
1327
|
}
|
|
833
1328
|
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
834
1329
|
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
@@ -846,9 +1341,12 @@ function buildClientMetadata(opts) {
|
|
|
846
1341
|
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
847
1342
|
const clientName = opts.clientName.trim();
|
|
848
1343
|
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
1344
|
+
if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
|
|
1345
|
+
throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
|
|
1346
|
+
}
|
|
849
1347
|
assertPermissions(opts.requests.map((r) => r.permission));
|
|
850
1348
|
for (const r of opts.requests) {
|
|
851
|
-
|
|
1349
|
+
assertAppPermission(r.permission, origin);
|
|
852
1350
|
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
853
1351
|
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
854
1352
|
}
|
|
@@ -902,7 +1400,7 @@ async function readForm(request) {
|
|
|
902
1400
|
}
|
|
903
1401
|
function isPrefetchOrAsset(request) {
|
|
904
1402
|
const h = request.headers;
|
|
905
|
-
if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
1403
|
+
if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
906
1404
|
if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
|
|
907
1405
|
const path = new URL(request.url).pathname;
|
|
908
1406
|
return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
|
|
@@ -913,6 +1411,7 @@ function createPodAppAuth(config) {
|
|
|
913
1411
|
const { origin, secure } = appOriginOf(config.appUrl);
|
|
914
1412
|
const paths = { ...DEFAULT_PATHS, ...config.paths };
|
|
915
1413
|
const metadata = buildClientMetadata({ ...config, paths });
|
|
1414
|
+
const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
|
|
916
1415
|
const clientId = metadata.client_id;
|
|
917
1416
|
const redirectUri = metadata.redirect_uris[0];
|
|
918
1417
|
const permissions = config.requests.map((r) => r.permission);
|
|
@@ -929,6 +1428,37 @@ function createPodAppAuth(config) {
|
|
|
929
1428
|
return e;
|
|
930
1429
|
};
|
|
931
1430
|
const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
|
|
1431
|
+
const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
|
|
1432
|
+
const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
|
|
1433
|
+
const marker = (podUrl) => markerCookie(podUrl, { secure, name: names.marker });
|
|
1434
|
+
const asRequest = (source) => source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
|
|
1435
|
+
const lapsedIn = (request) => {
|
|
1436
|
+
const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
|
|
1437
|
+
return podUrl ? { podUrl } : null;
|
|
1438
|
+
};
|
|
1439
|
+
async function toPod(opts, extra = []) {
|
|
1440
|
+
const started = await startConnect({
|
|
1441
|
+
...flow,
|
|
1442
|
+
pod: opts.pod,
|
|
1443
|
+
clientId,
|
|
1444
|
+
redirectUri,
|
|
1445
|
+
permissions,
|
|
1446
|
+
secret: config.secret,
|
|
1447
|
+
resource: config.resource,
|
|
1448
|
+
spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
|
|
1449
|
+
next: opts.next,
|
|
1450
|
+
prompt: opts.prompt,
|
|
1451
|
+
allowInsecureLoopback: config.allowInsecureLoopback
|
|
1452
|
+
});
|
|
1453
|
+
return redirect(started.authorizeUrl, [
|
|
1454
|
+
...extra,
|
|
1455
|
+
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
|
|
1456
|
+
]);
|
|
1457
|
+
}
|
|
1458
|
+
async function liveConnectionTo(request, pod) {
|
|
1459
|
+
const existing = await store.read(request);
|
|
1460
|
+
return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
|
|
1461
|
+
}
|
|
932
1462
|
async function clientMetadata() {
|
|
933
1463
|
return new Response(JSON.stringify(metadata), {
|
|
934
1464
|
status: 200,
|
|
@@ -942,26 +1472,10 @@ function createPodAppAuth(config) {
|
|
|
942
1472
|
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
943
1473
|
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
944
1474
|
const space = formField(form, "space");
|
|
1475
|
+
const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
|
|
945
1476
|
try {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
return redirect(new URL(next, origin).href);
|
|
949
|
-
}
|
|
950
|
-
const started = await startConnect({
|
|
951
|
-
...flow,
|
|
952
|
-
pod,
|
|
953
|
-
clientId,
|
|
954
|
-
redirectUri,
|
|
955
|
-
permissions,
|
|
956
|
-
secret: config.secret,
|
|
957
|
-
resource: config.resource,
|
|
958
|
-
spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
|
|
959
|
-
next,
|
|
960
|
-
allowInsecureLoopback: config.allowInsecureLoopback
|
|
961
|
-
});
|
|
962
|
-
return redirect(started.authorizeUrl, [
|
|
963
|
-
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
|
|
964
|
-
]);
|
|
1477
|
+
if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
|
|
1478
|
+
return await toPod({ pod, next, space, prompt });
|
|
965
1479
|
} catch (err) {
|
|
966
1480
|
const e = fail(err, "connect");
|
|
967
1481
|
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
|
|
@@ -980,7 +1494,7 @@ function createPodAppAuth(config) {
|
|
|
980
1494
|
secret: config.secret
|
|
981
1495
|
});
|
|
982
1496
|
const cookies = await store.write(connection, request);
|
|
983
|
-
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
|
|
1497
|
+
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
|
|
984
1498
|
} catch (err) {
|
|
985
1499
|
const e = fail(err, "callback");
|
|
986
1500
|
return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
|
|
@@ -995,9 +1509,56 @@ function createPodAppAuth(config) {
|
|
|
995
1509
|
const { revoked } = await revokeConnection(connection, flow);
|
|
996
1510
|
if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
|
|
997
1511
|
}
|
|
998
|
-
return redirect(new URL(next, origin).href, await store.clear(request));
|
|
1512
|
+
return redirect(new URL(next, origin).href, [...await store.clear(request), clearMarker]);
|
|
1513
|
+
}
|
|
1514
|
+
async function cloudConnect(request) {
|
|
1515
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
1516
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
1517
|
+
const form = await readForm(request);
|
|
1518
|
+
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
1519
|
+
const space = formField(form, "space");
|
|
1520
|
+
try {
|
|
1521
|
+
const started = await startCloudConnect({
|
|
1522
|
+
cloudUrl,
|
|
1523
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
1524
|
+
clientId,
|
|
1525
|
+
secret: config.secret,
|
|
1526
|
+
next,
|
|
1527
|
+
spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
|
|
1528
|
+
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
1529
|
+
now
|
|
1530
|
+
});
|
|
1531
|
+
return redirect(started.url, [
|
|
1532
|
+
serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1e3, secure })
|
|
1533
|
+
]);
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
const e = fail(err, "connect");
|
|
1536
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
|
|
1537
|
+
}
|
|
999
1538
|
}
|
|
1000
|
-
async function
|
|
1539
|
+
async function cloudReturn(request) {
|
|
1540
|
+
if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
|
|
1541
|
+
let pod = null;
|
|
1542
|
+
try {
|
|
1543
|
+
const back = await acceptCloudReturn({
|
|
1544
|
+
url: request.url,
|
|
1545
|
+
transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
|
|
1546
|
+
clientId,
|
|
1547
|
+
secret: config.secret,
|
|
1548
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
1549
|
+
now
|
|
1550
|
+
});
|
|
1551
|
+
pod = back.pod;
|
|
1552
|
+
const next = back.next ?? paths.afterConnect;
|
|
1553
|
+
if (!back.prompt && await liveConnectionTo(request, back.pod)) return redirect(new URL(next, origin).href, [clearCloud]);
|
|
1554
|
+
return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
const e = fail(err, "connect");
|
|
1557
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const keptFailures = /* @__PURE__ */ new Map();
|
|
1561
|
+
async function refreshIfNeeded(request, opts = {}) {
|
|
1001
1562
|
const connection = await store.read(request);
|
|
1002
1563
|
const unchanged = (status, conn, error) => ({
|
|
1003
1564
|
status,
|
|
@@ -1006,21 +1567,33 @@ function createPodAppAuth(config) {
|
|
|
1006
1567
|
cookieHeader: null,
|
|
1007
1568
|
...error ? { error } : {}
|
|
1008
1569
|
});
|
|
1009
|
-
if (!connection)
|
|
1570
|
+
if (!connection) {
|
|
1571
|
+
const lapsed = lapsedIn(request);
|
|
1572
|
+
return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
|
|
1573
|
+
}
|
|
1010
1574
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
1011
1575
|
const t = now();
|
|
1012
|
-
const accessLives = connection.accessExpiresAt > t;
|
|
1013
|
-
if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1576
|
+
const accessLives = !opts.force && connection.accessExpiresAt > t;
|
|
1577
|
+
if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
1014
1578
|
if (!connection.refreshToken) {
|
|
1015
1579
|
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
1016
1580
|
}
|
|
1581
|
+
for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
|
|
1582
|
+
const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : void 0;
|
|
1583
|
+
if (keptFailure) return unchanged("kept", connection, keptFailure.error);
|
|
1017
1584
|
let next;
|
|
1018
1585
|
try {
|
|
1019
1586
|
next = await refreshConnection(connection, flow);
|
|
1020
1587
|
} catch (err) {
|
|
1021
1588
|
const e = fail(err, "refresh");
|
|
1022
|
-
if (accessLives)
|
|
1023
|
-
|
|
1589
|
+
if (accessLives) {
|
|
1590
|
+
keptFailures.set(connection.refreshToken, { error: e, at: t });
|
|
1591
|
+
return unchanged("kept", connection, e);
|
|
1592
|
+
}
|
|
1593
|
+
if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
|
|
1594
|
+
const cleared = await store.clear(request).catch(() => []);
|
|
1595
|
+
return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
|
|
1596
|
+
}
|
|
1024
1597
|
return unchanged("unavailable", null, e);
|
|
1025
1598
|
}
|
|
1026
1599
|
let setCookies;
|
|
@@ -1031,6 +1604,7 @@ function createPodAppAuth(config) {
|
|
|
1031
1604
|
const cleared = await store.clear(request).catch(() => []);
|
|
1032
1605
|
return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
|
|
1033
1606
|
}
|
|
1607
|
+
setCookies = [...setCookies, marker(next.podUrl)];
|
|
1034
1608
|
return {
|
|
1035
1609
|
status: "refreshed",
|
|
1036
1610
|
connection: next,
|
|
@@ -1038,35 +1612,71 @@ function createPodAppAuth(config) {
|
|
|
1038
1612
|
cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies)
|
|
1039
1613
|
};
|
|
1040
1614
|
}
|
|
1615
|
+
function renewer(persist, source) {
|
|
1616
|
+
return async (connection) => {
|
|
1617
|
+
let next;
|
|
1618
|
+
try {
|
|
1619
|
+
next = await refreshConnection(connection, flow);
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
throw fail(err, "refresh");
|
|
1622
|
+
}
|
|
1623
|
+
try {
|
|
1624
|
+
const cookies = await store.write(next, asRequest(source ?? null));
|
|
1625
|
+
await persist([...cookies, marker(next.podUrl)]);
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
fail(err, "refresh");
|
|
1628
|
+
throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
|
|
1629
|
+
}
|
|
1630
|
+
return next;
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1041
1633
|
return {
|
|
1042
1634
|
clientId,
|
|
1043
1635
|
redirectUri,
|
|
1044
1636
|
metadata,
|
|
1045
1637
|
paths,
|
|
1046
|
-
|
|
1047
|
-
|
|
1638
|
+
cloudConnectAvailable: cloudUrl !== null,
|
|
1639
|
+
handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
|
|
1640
|
+
readConnection: (source) => store.read(asRequest(source)),
|
|
1641
|
+
async readLapsed(source) {
|
|
1642
|
+
const request = asRequest(source);
|
|
1643
|
+
return await store.read(request) ? null : lapsedIn(request);
|
|
1644
|
+
},
|
|
1048
1645
|
refreshIfNeeded,
|
|
1049
|
-
client
|
|
1646
|
+
client(connection, opts = {}) {
|
|
1647
|
+
const { persist, request, ...clientOpts } = opts;
|
|
1648
|
+
return createPodClient(connection, {
|
|
1649
|
+
fetch: config.fetch,
|
|
1650
|
+
now: config.now,
|
|
1651
|
+
...persist ? { renew: renewer(persist, request) } : {},
|
|
1652
|
+
...clientOpts
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1050
1655
|
};
|
|
1051
1656
|
}
|
|
1052
1657
|
|
|
1053
1658
|
// src/connect/index.ts
|
|
1054
1659
|
assertServerRuntime();
|
|
1055
1660
|
export {
|
|
1661
|
+
CLOUD_TRANSACTION_TTL_MS,
|
|
1056
1662
|
CONNECT_ERROR_CODES,
|
|
1057
1663
|
ConnectError,
|
|
1058
1664
|
DEFAULT_PATHS,
|
|
1059
1665
|
DEFAULT_REFRESH_IDLE_MS,
|
|
1666
|
+
MARKER_TTL_SEC,
|
|
1060
1667
|
MIN_SECRET_LENGTH,
|
|
1061
1668
|
REFRESH_WINDOW_MS,
|
|
1062
1669
|
ROTATION_MEMORY_MS,
|
|
1063
1670
|
TRANSACTION_TTL_MS,
|
|
1671
|
+
acceptCloudReturn,
|
|
1064
1672
|
acceptLaunch,
|
|
1065
1673
|
applySetCookies,
|
|
1066
1674
|
buildClientMetadata,
|
|
1067
1675
|
can,
|
|
1068
1676
|
canonicalPodUrl,
|
|
1677
|
+
cloudBaseUrl,
|
|
1069
1678
|
cloudConnectAvailable,
|
|
1679
|
+
connectionActsAs,
|
|
1070
1680
|
connectionExpiresAt,
|
|
1071
1681
|
cookieNames,
|
|
1072
1682
|
createPodAppAuth,
|
|
@@ -1074,17 +1684,22 @@ export {
|
|
|
1074
1684
|
createSealedCookieStore,
|
|
1075
1685
|
discoverPod,
|
|
1076
1686
|
handleCallback,
|
|
1687
|
+
humanSlots,
|
|
1077
1688
|
isBrowserRuntime,
|
|
1078
1689
|
isConnectError,
|
|
1690
|
+
isPrefetchOrAsset,
|
|
1691
|
+
markerCookie,
|
|
1079
1692
|
openTransaction,
|
|
1080
1693
|
parseConnectErrorCode,
|
|
1081
1694
|
parseTokenResponse,
|
|
1082
1695
|
pkceChallenge,
|
|
1696
|
+
readMarker,
|
|
1083
1697
|
refreshConnection,
|
|
1084
1698
|
reviewUrlFor,
|
|
1085
1699
|
revokeConnection,
|
|
1086
1700
|
safeNextPath,
|
|
1087
1701
|
seal,
|
|
1702
|
+
spaces,
|
|
1088
1703
|
startCloudConnect,
|
|
1089
1704
|
startConnect,
|
|
1090
1705
|
unseal
|