@synap-core/sdk 1.3.0 → 1.4.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/README.md +81 -7
- package/dist/{client-CVUT1Mcf.d.cts → client-C5sF20f1.d.cts} +1 -1
- package/dist/{client-CVUT1Mcf.d.ts → client-C5sF20f1.d.ts} +1 -1
- package/dist/connect.cjs +397 -116
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +220 -15
- package/dist/connect.d.ts +220 -15
- package/dist/connect.js +389 -116
- package/dist/connect.js.map +1 -1
- package/dist/public.d.cts +2 -2
- package/dist/public.d.ts +2 -2
- package/dist/testing.cjs +138 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +92 -4
- package/dist/testing.d.ts +92 -4
- package/dist/testing.js +134 -11
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-C2KGNXEn.d.cts} +20 -1
- package/dist/{types-DpqPNaaa.d.ts → types-C2KGNXEn.d.ts} +20 -1
- package/package.json +8 -8
- package/src/connect/auth.ts +161 -31
- package/src/connect/client.ts +107 -1
- package/src/connect/cloud.ts +206 -0
- package/src/connect/discovery.ts +15 -14
- package/src/connect/errors.ts +1 -1
- package/src/connect/flow.ts +41 -1
- package/src/connect/index.ts +39 -11
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +41 -3
- package/src/connect/types.ts +20 -0
- package/src/testing/auth-server.ts +144 -12
- package/src/testing/cloud.ts +84 -0
- package/src/testing/index.ts +3 -1
package/dist/connect.js
CHANGED
|
@@ -213,12 +213,6 @@ async function discoverPod(input, opts = {}) {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
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");
|
|
221
|
-
}
|
|
222
216
|
function safeNextPath(value, appOrigin) {
|
|
223
217
|
if (!value || value.length > 2048) return null;
|
|
224
218
|
if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) {
|
|
@@ -248,10 +242,193 @@ function acceptLaunch(url, appOrigin, opts = {}) {
|
|
|
248
242
|
}
|
|
249
243
|
}
|
|
250
244
|
const hint = params.get("synap_space_hint");
|
|
245
|
+
const state = params.get("state");
|
|
251
246
|
return {
|
|
252
247
|
pod,
|
|
253
248
|
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
254
|
-
spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null
|
|
249
|
+
spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
|
|
250
|
+
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
251
|
+
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
252
|
+
cloudState: state && /^[A-Za-z0-9._~-]{16,256}$/.test(state) ? state : null
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/connect/seal.ts
|
|
257
|
+
var VERSION = "v1";
|
|
258
|
+
var IV_BYTES = 12;
|
|
259
|
+
var TAG_BYTES = 16;
|
|
260
|
+
var MIN_SECRET_LENGTH = 32;
|
|
261
|
+
var SALT = utf8("synap-connect/v1");
|
|
262
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
263
|
+
function secretsOf(secret) {
|
|
264
|
+
const list = typeof secret === "string" ? [secret] : secret;
|
|
265
|
+
if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
|
|
266
|
+
throw new ConnectError(
|
|
267
|
+
"misconfigured",
|
|
268
|
+
`The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return list;
|
|
272
|
+
}
|
|
273
|
+
function assertAppSecret(secret) {
|
|
274
|
+
secretsOf(secret);
|
|
275
|
+
}
|
|
276
|
+
function keyFor(secret, purpose) {
|
|
277
|
+
const cacheKey = `${purpose}\0${secret}`;
|
|
278
|
+
let key = keyCache.get(cacheKey);
|
|
279
|
+
if (!key) {
|
|
280
|
+
key = crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]).then(
|
|
281
|
+
(base) => crypto.subtle.deriveKey(
|
|
282
|
+
{ name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
|
|
283
|
+
base,
|
|
284
|
+
{ name: "AES-GCM", length: 256 },
|
|
285
|
+
false,
|
|
286
|
+
["encrypt", "decrypt"]
|
|
287
|
+
)
|
|
288
|
+
);
|
|
289
|
+
keyCache.set(cacheKey, key);
|
|
290
|
+
}
|
|
291
|
+
return key;
|
|
292
|
+
}
|
|
293
|
+
async function seal(value, opts) {
|
|
294
|
+
const [current] = secretsOf(opts.secret);
|
|
295
|
+
const iv = randomBytes(IV_BYTES);
|
|
296
|
+
const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
|
|
297
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
298
|
+
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
299
|
+
await keyFor(current, opts.purpose),
|
|
300
|
+
plaintext
|
|
301
|
+
);
|
|
302
|
+
const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
|
|
303
|
+
out.set(iv, 0);
|
|
304
|
+
out.set(new Uint8Array(ciphertext), IV_BYTES);
|
|
305
|
+
return `${VERSION}.${base64url(out)}`;
|
|
306
|
+
}
|
|
307
|
+
async function unseal(sealed, opts) {
|
|
308
|
+
const secrets = secretsOf(opts.secret);
|
|
309
|
+
const dot = sealed.indexOf(".");
|
|
310
|
+
if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
|
|
311
|
+
const bytes = fromBase64url(sealed.slice(dot + 1));
|
|
312
|
+
if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
|
|
313
|
+
const iv = bytes.slice(0, IV_BYTES);
|
|
314
|
+
const ciphertext = bytes.slice(IV_BYTES);
|
|
315
|
+
for (const secret of secrets) {
|
|
316
|
+
let plaintext;
|
|
317
|
+
try {
|
|
318
|
+
plaintext = await crypto.subtle.decrypt(
|
|
319
|
+
{ name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
|
|
320
|
+
await keyFor(secret, opts.purpose),
|
|
321
|
+
ciphertext
|
|
322
|
+
);
|
|
323
|
+
} catch {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
let parsed;
|
|
327
|
+
try {
|
|
328
|
+
parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext)));
|
|
329
|
+
} catch {
|
|
330
|
+
return { ok: false, reason: "invalid" };
|
|
331
|
+
}
|
|
332
|
+
if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
|
|
333
|
+
if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
|
|
334
|
+
return { ok: true, value: parsed.data, expiresAt: parsed.exp };
|
|
335
|
+
}
|
|
336
|
+
return { ok: false, reason: "invalid" };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/connect/cloud.ts
|
|
340
|
+
var CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
341
|
+
var STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
342
|
+
var LOOPBACK_HOSTS2 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
343
|
+
function cloudBaseUrl(cloudUrl, opts = {}) {
|
|
344
|
+
let url;
|
|
345
|
+
try {
|
|
346
|
+
url = new URL(cloudUrl.trim());
|
|
347
|
+
} catch {
|
|
348
|
+
throw new ConnectError("misconfigured", "cloudUrl is not a URL");
|
|
349
|
+
}
|
|
350
|
+
const loopback = LOOPBACK_HOSTS2.has(url.hostname);
|
|
351
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
|
|
352
|
+
throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
|
|
353
|
+
}
|
|
354
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
355
|
+
throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
|
|
356
|
+
}
|
|
357
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
358
|
+
}
|
|
359
|
+
function cloudConnectAvailable(config) {
|
|
360
|
+
if (!config?.cloudUrl) return false;
|
|
361
|
+
try {
|
|
362
|
+
cloudBaseUrl(config.cloudUrl, config);
|
|
363
|
+
return true;
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function startCloudConnect(opts) {
|
|
369
|
+
if (!cloudConnectAvailable(opts)) {
|
|
370
|
+
throw new ConnectError("cp_unavailable", "cp: not configured \u2014 pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
|
|
371
|
+
}
|
|
372
|
+
if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
|
|
373
|
+
const base = cloudBaseUrl(opts.cloudUrl, opts);
|
|
374
|
+
const secret = opts.secret;
|
|
375
|
+
return (async () => {
|
|
376
|
+
const now = (opts.now ?? Date.now)();
|
|
377
|
+
const state = randomToken();
|
|
378
|
+
const url = new URL(`${base}/connect/pod`);
|
|
379
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
380
|
+
url.searchParams.set("state", state);
|
|
381
|
+
const tx = {
|
|
382
|
+
v: 1,
|
|
383
|
+
state,
|
|
384
|
+
clientId: opts.clientId,
|
|
385
|
+
cloudUrl: base,
|
|
386
|
+
next: opts.next ?? null,
|
|
387
|
+
spaceHint: opts.spaceHint ?? null,
|
|
388
|
+
prompt: opts.prompt === "consent" ? "consent" : null,
|
|
389
|
+
createdAt: now
|
|
390
|
+
};
|
|
391
|
+
const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
|
|
392
|
+
const transaction = await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt });
|
|
393
|
+
return { url: url.href, transaction, expiresAt };
|
|
394
|
+
})();
|
|
395
|
+
}
|
|
396
|
+
async function acceptCloudReturn(opts) {
|
|
397
|
+
if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
|
|
398
|
+
const opened = await unseal(opts.transaction, {
|
|
399
|
+
secret: opts.secret,
|
|
400
|
+
purpose: "synap-connect/cloud",
|
|
401
|
+
now: (opts.now ?? Date.now)()
|
|
402
|
+
});
|
|
403
|
+
if (!opened.ok) {
|
|
404
|
+
throw new ConnectError(
|
|
405
|
+
"expired_transaction",
|
|
406
|
+
opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed"
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const tx = opened.value;
|
|
410
|
+
if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
|
|
411
|
+
throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
|
|
412
|
+
}
|
|
413
|
+
if (tx.clientId !== opts.clientId) {
|
|
414
|
+
throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
|
|
415
|
+
}
|
|
416
|
+
const params = new URL(opts.url).searchParams;
|
|
417
|
+
const state = params.get("state");
|
|
418
|
+
if (state === null || !STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
|
|
419
|
+
throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
|
|
420
|
+
}
|
|
421
|
+
const error = params.get("error");
|
|
422
|
+
if (error !== null) {
|
|
423
|
+
throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
|
|
424
|
+
}
|
|
425
|
+
const iss = params.get("iss");
|
|
426
|
+
if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
|
|
427
|
+
return {
|
|
428
|
+
pod: canonicalPodUrl(iss, opts),
|
|
429
|
+
next: tx.next,
|
|
430
|
+
spaceHint: tx.spaceHint,
|
|
431
|
+
prompt: tx.prompt === "consent" ? "consent" : null
|
|
255
432
|
};
|
|
256
433
|
}
|
|
257
434
|
|
|
@@ -259,6 +436,16 @@ function acceptLaunch(url, appOrigin, opts = {}) {
|
|
|
259
436
|
function can(connection, permission, workspaceId) {
|
|
260
437
|
return connection.grant.some((g) => g.permission === permission && (workspaceId === void 0 || g.workspaceId === workspaceId));
|
|
261
438
|
}
|
|
439
|
+
function spaces(connection) {
|
|
440
|
+
const bySpace = /* @__PURE__ */ new Map();
|
|
441
|
+
for (const g of connection.grant) {
|
|
442
|
+
const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
|
|
443
|
+
if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
|
|
444
|
+
if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
|
|
445
|
+
bySpace.set(g.workspaceId, space);
|
|
446
|
+
}
|
|
447
|
+
return [...bySpace.values()];
|
|
448
|
+
}
|
|
262
449
|
var SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
|
|
263
450
|
function hubUrl(podUrl, path, query) {
|
|
264
451
|
if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
|
|
@@ -344,11 +531,59 @@ function createPodClient(connection, opts = {}) {
|
|
|
344
531
|
}
|
|
345
532
|
return { ok: true, status: "applied", data: result.data };
|
|
346
533
|
}
|
|
534
|
+
async function listPendingProposals(opts2 = {}) {
|
|
535
|
+
if (!can(connection, "proposal.read", opts2.workspaceId)) {
|
|
536
|
+
return {
|
|
537
|
+
ok: false,
|
|
538
|
+
error: { code: "forbidden", status: null, detail: "The connection was not granted proposal.read in that space" }
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const limit = Math.min(Math.max(Math.floor(opts2.limit ?? 20), 1), 200);
|
|
542
|
+
const offset = Math.max(Math.floor(opts2.offset ?? 0), 0);
|
|
543
|
+
const result = await request("GET", "/proposals", void 0, {
|
|
544
|
+
status: "pending",
|
|
545
|
+
view: "basic",
|
|
546
|
+
limit,
|
|
547
|
+
offset,
|
|
548
|
+
workspaceId: opts2.workspaceId
|
|
549
|
+
});
|
|
550
|
+
if (!result.ok) return result;
|
|
551
|
+
const obj = result.data && typeof result.data === "object" ? result.data : {};
|
|
552
|
+
if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
|
|
553
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
|
|
554
|
+
}
|
|
555
|
+
const str = (v) => typeof v === "string" && v ? v : null;
|
|
556
|
+
const proposals = [];
|
|
557
|
+
for (const row of obj.proposals) {
|
|
558
|
+
if (!row || typeof row !== "object") continue;
|
|
559
|
+
const r = row;
|
|
560
|
+
const id = str(r.id);
|
|
561
|
+
if (!id) continue;
|
|
562
|
+
proposals.push({
|
|
563
|
+
id,
|
|
564
|
+
proposalType: str(r.proposalType) ?? "",
|
|
565
|
+
targetType: str(r.targetType) ?? "",
|
|
566
|
+
targetId: str(r.targetId),
|
|
567
|
+
workspaceId: str(r.workspaceId),
|
|
568
|
+
createdAt: str(r.createdAt) ?? "",
|
|
569
|
+
agentUserId: str(r.agentUserId),
|
|
570
|
+
sessionId: str(r.sessionId),
|
|
571
|
+
...str(r.summary) ? { summary: str(r.summary) } : {},
|
|
572
|
+
reviewUrl: reviewUrlFor(connection.podUrl, null, id)
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
ok: true,
|
|
577
|
+
data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total }
|
|
578
|
+
};
|
|
579
|
+
}
|
|
347
580
|
return {
|
|
348
581
|
podUrl: connection.podUrl,
|
|
349
582
|
user: connection.user,
|
|
350
583
|
grant: connection.grant,
|
|
351
584
|
can: (permission, workspaceId) => can(connection, permission, workspaceId),
|
|
585
|
+
spaces: () => spaces(connection),
|
|
586
|
+
listPendingProposals,
|
|
352
587
|
get(path, query) {
|
|
353
588
|
return request("GET", path, void 0, query);
|
|
354
589
|
},
|
|
@@ -367,89 +602,6 @@ function createPodClient(connection, opts = {}) {
|
|
|
367
602
|
};
|
|
368
603
|
}
|
|
369
604
|
|
|
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
605
|
// src/connect/flow.ts
|
|
454
606
|
var TRANSACTION_TTL_MS = 10 * 60 * 1e3;
|
|
455
607
|
var DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -484,6 +636,7 @@ async function startConnect(opts) {
|
|
|
484
636
|
url.searchParams.set("code_challenge_method", "S256");
|
|
485
637
|
if (resource) url.searchParams.set("resource", resource);
|
|
486
638
|
if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
|
|
639
|
+
if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
|
|
487
640
|
const tx = {
|
|
488
641
|
v: 1,
|
|
489
642
|
issuer: issuer.issuer,
|
|
@@ -606,6 +759,23 @@ function parseGrant(value) {
|
|
|
606
759
|
}
|
|
607
760
|
return out;
|
|
608
761
|
}
|
|
762
|
+
var APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
763
|
+
var LOOPBACK_HOSTS3 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
764
|
+
function parseApp(value) {
|
|
765
|
+
if (!value || typeof value !== "object") return {};
|
|
766
|
+
const { id, manageUrl } = value;
|
|
767
|
+
const out = {};
|
|
768
|
+
if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
|
|
769
|
+
if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
|
|
770
|
+
try {
|
|
771
|
+
const url = new URL(manageUrl);
|
|
772
|
+
const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS3.has(url.hostname);
|
|
773
|
+
if (secure && !url.username && !url.password) out.manageUrl = url.href;
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
609
779
|
function parseUser(value) {
|
|
610
780
|
if (!value || typeof value !== "object") return null;
|
|
611
781
|
const { id, name } = value;
|
|
@@ -640,6 +810,11 @@ function parseTokenResponse(body, ctx) {
|
|
|
640
810
|
user,
|
|
641
811
|
connectedAt: ctx.previous?.connectedAt ?? ctx.now
|
|
642
812
|
};
|
|
813
|
+
const app = parseApp(body.synap_app);
|
|
814
|
+
const appId = app.appId ?? ctx.previous?.appId;
|
|
815
|
+
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
816
|
+
if (appId !== void 0) connection.appId = appId;
|
|
817
|
+
if (manageUrl !== void 0) connection.manageUrl = manageUrl;
|
|
643
818
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
644
819
|
connection.refreshToken = refresh_token;
|
|
645
820
|
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;
|
|
@@ -717,7 +892,25 @@ async function revokeConnection(connection, opts = {}) {
|
|
|
717
892
|
|
|
718
893
|
// src/connect/store.ts
|
|
719
894
|
function cookieNames(secure) {
|
|
720
|
-
return secure ? { session: "__Host-synap_session", transaction: "__Host-synap_tx" } : { session: "synap_session", transaction: "synap_tx" };
|
|
895
|
+
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" };
|
|
896
|
+
}
|
|
897
|
+
var MARKER_TTL_SEC = 400 * 24 * 60 * 60;
|
|
898
|
+
function markerCookie(podUrl, opts) {
|
|
899
|
+
return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
|
|
900
|
+
maxAgeSec: MARKER_TTL_SEC,
|
|
901
|
+
secure: opts.secure
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
function readMarker(cookieHeader, opts) {
|
|
905
|
+
const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
|
|
906
|
+
const bytes = raw ? fromBase64url(raw) : null;
|
|
907
|
+
if (!bytes || bytes.length > 512) return null;
|
|
908
|
+
const podUrl = fromUtf8(bytes);
|
|
909
|
+
try {
|
|
910
|
+
return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
|
|
911
|
+
} catch {
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
721
914
|
}
|
|
722
915
|
var CHUNK_SIZE = 3800;
|
|
723
916
|
var MAX_CHUNKS = 4;
|
|
@@ -811,6 +1004,7 @@ var DEFAULT_PATHS = {
|
|
|
811
1004
|
connect: "/api/synap/connect",
|
|
812
1005
|
callback: "/api/synap/callback",
|
|
813
1006
|
disconnect: "/api/synap/disconnect",
|
|
1007
|
+
cloud: "/api/synap/cloud",
|
|
814
1008
|
connectPage: "/connect",
|
|
815
1009
|
resultPage: "/connect/result",
|
|
816
1010
|
afterConnect: "/"
|
|
@@ -902,7 +1096,7 @@ async function readForm(request) {
|
|
|
902
1096
|
}
|
|
903
1097
|
function isPrefetchOrAsset(request) {
|
|
904
1098
|
const h = request.headers;
|
|
905
|
-
if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
1099
|
+
if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
906
1100
|
if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
|
|
907
1101
|
const path = new URL(request.url).pathname;
|
|
908
1102
|
return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
|
|
@@ -913,6 +1107,7 @@ function createPodAppAuth(config) {
|
|
|
913
1107
|
const { origin, secure } = appOriginOf(config.appUrl);
|
|
914
1108
|
const paths = { ...DEFAULT_PATHS, ...config.paths };
|
|
915
1109
|
const metadata = buildClientMetadata({ ...config, paths });
|
|
1110
|
+
const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
|
|
916
1111
|
const clientId = metadata.client_id;
|
|
917
1112
|
const redirectUri = metadata.redirect_uris[0];
|
|
918
1113
|
const permissions = config.requests.map((r) => r.permission);
|
|
@@ -929,6 +1124,37 @@ function createPodAppAuth(config) {
|
|
|
929
1124
|
return e;
|
|
930
1125
|
};
|
|
931
1126
|
const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
|
|
1127
|
+
const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
|
|
1128
|
+
const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
|
|
1129
|
+
const marker = (podUrl) => markerCookie(podUrl, { secure, name: names.marker });
|
|
1130
|
+
const asRequest = (source) => source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
|
|
1131
|
+
const lapsedIn = (request) => {
|
|
1132
|
+
const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
|
|
1133
|
+
return podUrl ? { podUrl } : null;
|
|
1134
|
+
};
|
|
1135
|
+
async function toPod(opts, extra = []) {
|
|
1136
|
+
const started = await startConnect({
|
|
1137
|
+
...flow,
|
|
1138
|
+
pod: opts.pod,
|
|
1139
|
+
clientId,
|
|
1140
|
+
redirectUri,
|
|
1141
|
+
permissions,
|
|
1142
|
+
secret: config.secret,
|
|
1143
|
+
resource: config.resource,
|
|
1144
|
+
spaceHint: opts.space && /^[A-Za-z0-9_-]{1,64}$/.test(opts.space) ? opts.space : null,
|
|
1145
|
+
next: opts.next,
|
|
1146
|
+
prompt: opts.prompt,
|
|
1147
|
+
allowInsecureLoopback: config.allowInsecureLoopback
|
|
1148
|
+
});
|
|
1149
|
+
return redirect(started.authorizeUrl, [
|
|
1150
|
+
...extra,
|
|
1151
|
+
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1e3, secure })
|
|
1152
|
+
]);
|
|
1153
|
+
}
|
|
1154
|
+
async function liveConnectionTo(request, pod) {
|
|
1155
|
+
const existing = await store.read(request);
|
|
1156
|
+
return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
|
|
1157
|
+
}
|
|
932
1158
|
async function clientMetadata() {
|
|
933
1159
|
return new Response(JSON.stringify(metadata), {
|
|
934
1160
|
status: 200,
|
|
@@ -942,26 +1168,10 @@ function createPodAppAuth(config) {
|
|
|
942
1168
|
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
943
1169
|
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
944
1170
|
const space = formField(form, "space");
|
|
1171
|
+
const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
|
|
945
1172
|
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
|
-
]);
|
|
1173
|
+
if (!prompt && await liveConnectionTo(request, pod)) return redirect(new URL(next, origin).href);
|
|
1174
|
+
return await toPod({ pod, next, space, prompt });
|
|
965
1175
|
} catch (err) {
|
|
966
1176
|
const e = fail(err, "connect");
|
|
967
1177
|
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
|
|
@@ -980,7 +1190,7 @@ function createPodAppAuth(config) {
|
|
|
980
1190
|
secret: config.secret
|
|
981
1191
|
});
|
|
982
1192
|
const cookies = await store.write(connection, request);
|
|
983
|
-
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
|
|
1193
|
+
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
|
|
984
1194
|
} catch (err) {
|
|
985
1195
|
const e = fail(err, "callback");
|
|
986
1196
|
return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
|
|
@@ -995,7 +1205,53 @@ function createPodAppAuth(config) {
|
|
|
995
1205
|
const { revoked } = await revokeConnection(connection, flow);
|
|
996
1206
|
if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
|
|
997
1207
|
}
|
|
998
|
-
return redirect(new URL(next, origin).href, await store.clear(request));
|
|
1208
|
+
return redirect(new URL(next, origin).href, [...await store.clear(request), clearMarker]);
|
|
1209
|
+
}
|
|
1210
|
+
async function cloudConnect(request) {
|
|
1211
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
1212
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
1213
|
+
const form = await readForm(request);
|
|
1214
|
+
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
1215
|
+
const space = formField(form, "space");
|
|
1216
|
+
try {
|
|
1217
|
+
const started = await startCloudConnect({
|
|
1218
|
+
cloudUrl,
|
|
1219
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
1220
|
+
clientId,
|
|
1221
|
+
secret: config.secret,
|
|
1222
|
+
next,
|
|
1223
|
+
spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
|
|
1224
|
+
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
1225
|
+
now
|
|
1226
|
+
});
|
|
1227
|
+
return redirect(started.url, [
|
|
1228
|
+
serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1e3, secure })
|
|
1229
|
+
]);
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
const e = fail(err, "connect");
|
|
1232
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
async function cloudReturn(request) {
|
|
1236
|
+
if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
|
|
1237
|
+
let pod = null;
|
|
1238
|
+
try {
|
|
1239
|
+
const back = await acceptCloudReturn({
|
|
1240
|
+
url: request.url,
|
|
1241
|
+
transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
|
|
1242
|
+
clientId,
|
|
1243
|
+
secret: config.secret,
|
|
1244
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
1245
|
+
now
|
|
1246
|
+
});
|
|
1247
|
+
pod = back.pod;
|
|
1248
|
+
const next = back.next ?? paths.afterConnect;
|
|
1249
|
+
if (!back.prompt && await liveConnectionTo(request, back.pod)) return redirect(new URL(next, origin).href, [clearCloud]);
|
|
1250
|
+
return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
const e = fail(err, "connect");
|
|
1253
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
1254
|
+
}
|
|
999
1255
|
}
|
|
1000
1256
|
async function refreshIfNeeded(request) {
|
|
1001
1257
|
const connection = await store.read(request);
|
|
@@ -1006,7 +1262,10 @@ function createPodAppAuth(config) {
|
|
|
1006
1262
|
cookieHeader: null,
|
|
1007
1263
|
...error ? { error } : {}
|
|
1008
1264
|
});
|
|
1009
|
-
if (!connection)
|
|
1265
|
+
if (!connection) {
|
|
1266
|
+
const lapsed = lapsedIn(request);
|
|
1267
|
+
return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
|
|
1268
|
+
}
|
|
1010
1269
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
1011
1270
|
const t = now();
|
|
1012
1271
|
const accessLives = connection.accessExpiresAt > t;
|
|
@@ -1031,6 +1290,7 @@ function createPodAppAuth(config) {
|
|
|
1031
1290
|
const cleared = await store.clear(request).catch(() => []);
|
|
1032
1291
|
return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
|
|
1033
1292
|
}
|
|
1293
|
+
setCookies = [...setCookies, marker(next.podUrl)];
|
|
1034
1294
|
return {
|
|
1035
1295
|
status: "refreshed",
|
|
1036
1296
|
connection: next,
|
|
@@ -1043,8 +1303,13 @@ function createPodAppAuth(config) {
|
|
|
1043
1303
|
redirectUri,
|
|
1044
1304
|
metadata,
|
|
1045
1305
|
paths,
|
|
1046
|
-
|
|
1047
|
-
|
|
1306
|
+
cloudConnectAvailable: cloudUrl !== null,
|
|
1307
|
+
handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
|
|
1308
|
+
readConnection: (source) => store.read(asRequest(source)),
|
|
1309
|
+
async readLapsed(source) {
|
|
1310
|
+
const request = asRequest(source);
|
|
1311
|
+
return await store.read(request) ? null : lapsedIn(request);
|
|
1312
|
+
},
|
|
1048
1313
|
refreshIfNeeded,
|
|
1049
1314
|
client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts })
|
|
1050
1315
|
};
|
|
@@ -1053,19 +1318,23 @@ function createPodAppAuth(config) {
|
|
|
1053
1318
|
// src/connect/index.ts
|
|
1054
1319
|
assertServerRuntime();
|
|
1055
1320
|
export {
|
|
1321
|
+
CLOUD_TRANSACTION_TTL_MS,
|
|
1056
1322
|
CONNECT_ERROR_CODES,
|
|
1057
1323
|
ConnectError,
|
|
1058
1324
|
DEFAULT_PATHS,
|
|
1059
1325
|
DEFAULT_REFRESH_IDLE_MS,
|
|
1326
|
+
MARKER_TTL_SEC,
|
|
1060
1327
|
MIN_SECRET_LENGTH,
|
|
1061
1328
|
REFRESH_WINDOW_MS,
|
|
1062
1329
|
ROTATION_MEMORY_MS,
|
|
1063
1330
|
TRANSACTION_TTL_MS,
|
|
1331
|
+
acceptCloudReturn,
|
|
1064
1332
|
acceptLaunch,
|
|
1065
1333
|
applySetCookies,
|
|
1066
1334
|
buildClientMetadata,
|
|
1067
1335
|
can,
|
|
1068
1336
|
canonicalPodUrl,
|
|
1337
|
+
cloudBaseUrl,
|
|
1069
1338
|
cloudConnectAvailable,
|
|
1070
1339
|
connectionExpiresAt,
|
|
1071
1340
|
cookieNames,
|
|
@@ -1076,15 +1345,19 @@ export {
|
|
|
1076
1345
|
handleCallback,
|
|
1077
1346
|
isBrowserRuntime,
|
|
1078
1347
|
isConnectError,
|
|
1348
|
+
isPrefetchOrAsset,
|
|
1349
|
+
markerCookie,
|
|
1079
1350
|
openTransaction,
|
|
1080
1351
|
parseConnectErrorCode,
|
|
1081
1352
|
parseTokenResponse,
|
|
1082
1353
|
pkceChallenge,
|
|
1354
|
+
readMarker,
|
|
1083
1355
|
refreshConnection,
|
|
1084
1356
|
reviewUrlFor,
|
|
1085
1357
|
revokeConnection,
|
|
1086
1358
|
safeNextPath,
|
|
1087
1359
|
seal,
|
|
1360
|
+
spaces,
|
|
1088
1361
|
startCloudConnect,
|
|
1089
1362
|
startConnect,
|
|
1090
1363
|
unseal
|