agility-bridge 0.2.0 → 0.3.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/dist/index.mjs +1544 -1162
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -7,17 +7,19 @@ import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkd
|
|
|
7
7
|
import { parse } from "yaml";
|
|
8
8
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
9
|
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
10
|
-
import { and, asc, desc, eq, gte, isNull, lt, lte, or } from "drizzle-orm";
|
|
10
|
+
import { and, asc, desc, eq, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
11
11
|
import { drizzle } from "drizzle-orm/sqlite-proxy";
|
|
12
12
|
import { validator } from "hono/validator";
|
|
13
13
|
import { Hono } from "hono";
|
|
14
14
|
import { HTTPException } from "hono/http-exception";
|
|
15
15
|
import { mergePath } from "hono/utils/url";
|
|
16
|
+
import { LinearRouter } from "hono/router/linear-router";
|
|
16
17
|
import { DatabaseSync } from "node:sqlite";
|
|
17
|
-
import {
|
|
18
|
-
import { serve } from "@hono/node-server";
|
|
18
|
+
import { lookup } from "node:dns";
|
|
19
19
|
import { rootCertificates } from "node:tls";
|
|
20
20
|
import { Agent } from "undici";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { serve } from "@hono/node-server";
|
|
21
23
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
22
24
|
//#region \0rolldown/runtime.js
|
|
23
25
|
var __defProp = Object.defineProperty;
|
|
@@ -350,7 +352,7 @@ var CircuitBreakers = class {
|
|
|
350
352
|
//#endregion
|
|
351
353
|
//#region ../../packages/core/src/version.ts
|
|
352
354
|
/** The agility-bridge version reported to MCP clients. Kept equal to apps/cli/package.json (checked in tests). */
|
|
353
|
-
const VERSION$1 = "0.
|
|
355
|
+
const VERSION$1 = "0.3.0";
|
|
354
356
|
//#endregion
|
|
355
357
|
//#region ../../packages/core/src/agility/xml.ts
|
|
356
358
|
const MAX_DEPTH = 64;
|
|
@@ -830,18 +832,98 @@ function invalid$1(reason) {
|
|
|
830
832
|
* The canonical form of an instance URL, fixed at configuration time (L-054, L-055): HTTPS (plain HTTP only for
|
|
831
833
|
* loopback tests), no user info, query or fragment, the path kept, no trailing slash.
|
|
832
834
|
*/
|
|
833
|
-
function canonicalInstanceUrl(input) {
|
|
835
|
+
function canonicalInstanceUrl(input, options = {}) {
|
|
834
836
|
let url;
|
|
835
837
|
try {
|
|
836
838
|
url = new URL(input.trim());
|
|
837
839
|
} catch {
|
|
838
840
|
throw invalid$1("is not a valid URL");
|
|
839
841
|
}
|
|
840
|
-
|
|
842
|
+
const httpOk = (options.httpLoopback ?? true) && url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
843
|
+
if (url.protocol !== "https:" && !httpOk) throw invalid$1("must use https://");
|
|
841
844
|
if (url.username !== "" || url.password !== "") throw invalid$1("must not contain a user name or password");
|
|
842
845
|
if (url.search !== "" || url.hash !== "" || /[?#]/.test(input)) throw invalid$1("must not contain a query or fragment");
|
|
843
846
|
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
844
847
|
}
|
|
848
|
+
function ipv4(text) {
|
|
849
|
+
const parts = text.split(".");
|
|
850
|
+
if (parts.length !== 4) return null;
|
|
851
|
+
const out = [];
|
|
852
|
+
for (const p of parts) {
|
|
853
|
+
if (!/^\d{1,3}$/.test(p)) return null;
|
|
854
|
+
const n = Number(p);
|
|
855
|
+
if (n > 255) return null;
|
|
856
|
+
out.push(n);
|
|
857
|
+
}
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
function ipv4Class([a, b, c] = []) {
|
|
861
|
+
if (a === void 0 || b === void 0) return "invalid";
|
|
862
|
+
if (a === 192 && b === 0 && (c === 0 || c === 2)) return "reserved";
|
|
863
|
+
if (a === 198 && (b === 18 || b === 19)) return "reserved";
|
|
864
|
+
if (a === 198 && b === 51 && c === 100 || a === 203 && b === 0 && c === 113) return "reserved";
|
|
865
|
+
if (a === 0) return "unspecified";
|
|
866
|
+
if (a === 127) return "loopback";
|
|
867
|
+
if (a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168) return "private";
|
|
868
|
+
if (a === 100 && b >= 64 && b <= 127) return "private";
|
|
869
|
+
if (a === 169 && b === 254) return "link-local";
|
|
870
|
+
if (a >= 224) return "reserved";
|
|
871
|
+
return "public";
|
|
872
|
+
}
|
|
873
|
+
/** Expands an IPv6 literal to 8 groups, or null. */
|
|
874
|
+
function ipv6(text) {
|
|
875
|
+
const raw = text.replace(/^\[|\]$/g, "").toLowerCase();
|
|
876
|
+
if (!raw.includes(":") || raw.includes("%")) return null;
|
|
877
|
+
let head = raw;
|
|
878
|
+
let v4 = null;
|
|
879
|
+
const lastColon = raw.lastIndexOf(":");
|
|
880
|
+
if (raw.slice(lastColon + 1).includes(".")) {
|
|
881
|
+
v4 = ipv4(raw.slice(lastColon + 1));
|
|
882
|
+
if (v4 === null) return null;
|
|
883
|
+
head = `${raw.slice(0, lastColon + 1)}${(v4[0] * 256 + v4[1]).toString(16)}:${(v4[2] * 256 + v4[3]).toString(16)}`;
|
|
884
|
+
}
|
|
885
|
+
const halves = head.split("::");
|
|
886
|
+
if (halves.length > 2) return null;
|
|
887
|
+
const left = halves[0] === "" ? [] : halves[0].split(":");
|
|
888
|
+
const right = halves.length === 1 || halves[1] === "" ? [] : halves[1].split(":");
|
|
889
|
+
const fill = halves.length === 2 ? 8 - left.length - right.length : 0;
|
|
890
|
+
if (fill < 0) return null;
|
|
891
|
+
const groups = [
|
|
892
|
+
...left,
|
|
893
|
+
...Array(fill).fill("0"),
|
|
894
|
+
...right
|
|
895
|
+
];
|
|
896
|
+
if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
|
|
897
|
+
return groups.map((g) => Number.parseInt(g, 16));
|
|
898
|
+
}
|
|
899
|
+
/** Classifies an IPv4 or IPv6 literal (brackets allowed); IPv4-mapped IPv6 is classified as IPv4. */
|
|
900
|
+
function addressClass(ip) {
|
|
901
|
+
const v4 = ipv4(ip);
|
|
902
|
+
if (v4 !== null) return ipv4Class(v4);
|
|
903
|
+
const g = ipv6(ip);
|
|
904
|
+
if (g === null) return "invalid";
|
|
905
|
+
const [g0 = 0, , , , , g5 = 0, g6 = 0, g7 = 0] = g;
|
|
906
|
+
if (g.every((x) => x === 0)) return "unspecified";
|
|
907
|
+
if (g.slice(0, 7).every((x) => x === 0) && g7 === 1) return "loopback";
|
|
908
|
+
const embedded = (hi, lo) => ipv4Class([
|
|
909
|
+
hi >> 8,
|
|
910
|
+
hi & 255,
|
|
911
|
+
lo >> 8,
|
|
912
|
+
lo & 255
|
|
913
|
+
]);
|
|
914
|
+
if (g.slice(0, 5).every((x) => x === 0) && g5 === 65535) return embedded(g6, g7);
|
|
915
|
+
if (g.slice(0, 4).every((x) => x === 0) && g[4] === 65535 && g5 === 0) return embedded(g6, g7);
|
|
916
|
+
if (g.slice(0, 6).every((x) => x === 0)) return embedded(g6, g7);
|
|
917
|
+
if (g0 === 100 && g[1] === 65435 && g.slice(2, 6).every((x) => x === 0)) return embedded(g6, g7);
|
|
918
|
+
if (g0 === 8194) return embedded(g[1] ?? 0, g[2] ?? 0);
|
|
919
|
+
if ((g0 & 65472) === 65216) return "private";
|
|
920
|
+
if (g0 === 256 && g.slice(1, 4).every((x) => x === 0)) return "reserved";
|
|
921
|
+
if ((g0 & 65024) === 64512) return "private";
|
|
922
|
+
if ((g0 & 65472) === 65152) return "link-local";
|
|
923
|
+
if ((g0 & 65280) === 65280) return "reserved";
|
|
924
|
+
if (g0 === 8193 && g[1] === 3512) return "reserved";
|
|
925
|
+
return "public";
|
|
926
|
+
}
|
|
845
927
|
//#endregion
|
|
846
928
|
//#region ../../packages/core/src/agility/limiter.ts
|
|
847
929
|
/** A counting semaphore: at most `max` tasks run at once, the rest wait in order (ADR-0010). */
|
|
@@ -878,12 +960,19 @@ var Semaphore = class {
|
|
|
878
960
|
var CredentialLimiters = class {
|
|
879
961
|
#size;
|
|
880
962
|
#maxKeys;
|
|
963
|
+
#perCall;
|
|
881
964
|
#byKey = /* @__PURE__ */ new Map();
|
|
965
|
+
/**
|
|
966
|
+
* `perCall` (Workers, L-096): every `for()` returns a new semaphore, so a call never waits for a slot another request
|
|
967
|
+
* holds; concurrency is then bounded per call, and Workers bounds connections per request itself.
|
|
968
|
+
*/
|
|
882
969
|
constructor(options = {}) {
|
|
883
970
|
this.#size = options.concurrency ?? 5;
|
|
884
971
|
this.#maxKeys = options.maxKeys ?? 1e4;
|
|
972
|
+
this.#perCall = options.perCall ?? false;
|
|
885
973
|
}
|
|
886
974
|
for(instanceId, credentialId) {
|
|
975
|
+
if (this.#perCall) return new Semaphore(this.#size);
|
|
887
976
|
const key = `${instanceId}\u0000${credentialId}`;
|
|
888
977
|
let sem = this.#byKey.get(key);
|
|
889
978
|
if (sem === void 0) {
|
|
@@ -1229,12 +1318,15 @@ var InstanceMetadata = class {
|
|
|
1229
1318
|
#service;
|
|
1230
1319
|
#ctx;
|
|
1231
1320
|
#cache;
|
|
1321
|
+
/** Loads in flight: shared with other calls, or this call's own (`shareInflight: false`). */
|
|
1322
|
+
#inflight;
|
|
1232
1323
|
/** Asset types this call asked for that the instance does not have (each one a meta.v1 404, review M6). */
|
|
1233
1324
|
#absentInCall = 0;
|
|
1234
1325
|
constructor(service, ctx) {
|
|
1235
1326
|
this.#service = service;
|
|
1236
1327
|
this.#ctx = ctx;
|
|
1237
1328
|
this.#cache = service.cache(ctx.instance.id);
|
|
1329
|
+
this.#inflight = service.options.shareInflight === false ? /* @__PURE__ */ new Map() : this.#cache.inflight;
|
|
1238
1330
|
}
|
|
1239
1331
|
/** The asset type, or AB_INPUT_INVALID when the instance has no such type. */
|
|
1240
1332
|
async assetType(name) {
|
|
@@ -1248,10 +1340,10 @@ var InstanceMetadata = class {
|
|
|
1248
1340
|
const cached = this.#cache.types.get(name);
|
|
1249
1341
|
if (this.#service.fresh(cached)) return cached.value ?? void 0;
|
|
1250
1342
|
if (this.#absentInCall >= MAX_ABSENT_PER_CALL && !KNOWN_TYPES.has(name)) return void 0;
|
|
1251
|
-
let pending = this.#
|
|
1343
|
+
let pending = this.#inflight.get(name);
|
|
1252
1344
|
if (pending === void 0) {
|
|
1253
|
-
pending = this.#load(name).finally(() => this.#
|
|
1254
|
-
this.#
|
|
1345
|
+
pending = this.#load(name).finally(() => this.#inflight.delete(name));
|
|
1346
|
+
this.#inflight.set(name, pending);
|
|
1255
1347
|
}
|
|
1256
1348
|
const meta = await pending;
|
|
1257
1349
|
if (meta === null) this.#absentInCall++;
|
|
@@ -5474,7 +5566,7 @@ const CORE_TOOLS = Object.freeze([
|
|
|
5474
5566
|
]);
|
|
5475
5567
|
//#endregion
|
|
5476
5568
|
//#region package.json
|
|
5477
|
-
var version = "0.
|
|
5569
|
+
var version = "0.3.0";
|
|
5478
5570
|
//#endregion
|
|
5479
5571
|
//#region src/config.ts
|
|
5480
5572
|
var ConfigError = class extends Error {
|
|
@@ -5529,6 +5621,8 @@ const KNOWN_ENV = /* @__PURE__ */ new Set([
|
|
|
5529
5621
|
"AB_AUDIT_RETENTION_DAYS",
|
|
5530
5622
|
"AB_CREDENTIAL_REVALIDATION_HOURS",
|
|
5531
5623
|
"AB_PASSTHROUGH",
|
|
5624
|
+
"AB_ALLOW_HTTP_INSTANCES",
|
|
5625
|
+
"AB_INSTANCE_PRIVATE_ADDRESSES",
|
|
5532
5626
|
"AB_OAUTH",
|
|
5533
5627
|
"AB_PLAN"
|
|
5534
5628
|
]);
|
|
@@ -5586,7 +5680,7 @@ function int(name, value, fallback, min, max) {
|
|
|
5586
5680
|
if (!Number.isInteger(n) || n < min || n > max) throw new ConfigError(`${name} must be an integer from ${min} to ${max}.`);
|
|
5587
5681
|
return n;
|
|
5588
5682
|
}
|
|
5589
|
-
function list$
|
|
5683
|
+
function list$1(name, value, pattern, what) {
|
|
5590
5684
|
if (value === void 0 || value.trim() === "") return void 0;
|
|
5591
5685
|
const items = value.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
5592
5686
|
if (items.length === 0) return void 0;
|
|
@@ -5596,7 +5690,7 @@ function list$2(name, value, pattern, what) {
|
|
|
5596
5690
|
/** `AB_PROJECTS`: unset means every project; set but empty (`" "`, `",,"`) is refused rather than read as "all". */
|
|
5597
5691
|
function projectList(value) {
|
|
5598
5692
|
if (value === void 0) return [];
|
|
5599
|
-
const items = list$
|
|
5693
|
+
const items = list$1("AB_PROJECTS", value, SCOPE_OID$1, "Agility project (use Scope:1234)");
|
|
5600
5694
|
if (items === void 0) throw new ConfigError("AB_PROJECTS is set but empty: unset it to allow every project, or list Scope:<id> tokens.");
|
|
5601
5695
|
return items;
|
|
5602
5696
|
}
|
|
@@ -5817,10 +5911,10 @@ function loadConfig(env, options = {}) {
|
|
|
5817
5911
|
...env["AB_CA_FILE"] ? { caFile: env["AB_CA_FILE"] } : {},
|
|
5818
5912
|
projects: projectList(env["AB_PROJECTS"]),
|
|
5819
5913
|
includeChildProjects: bool("AB_INCLUDE_CHILD_PROJECTS", env["AB_INCLUDE_CHILD_PROJECTS"], true),
|
|
5820
|
-
...list$
|
|
5914
|
+
...list$1("AB_TOOLS", env["AB_TOOLS"], TOOL_NAME$1, "tool name") === void 0 ? {} : { tools: list$1("AB_TOOLS", env["AB_TOOLS"], TOOL_NAME$1, "tool name") },
|
|
5821
5915
|
assetTypes: {
|
|
5822
|
-
...list$
|
|
5823
|
-
deny: list$
|
|
5916
|
+
...list$1("AB_ASSET_TYPES_ALLOW", env["AB_ASSET_TYPES_ALLOW"], TYPE_NAME$1, "asset type") === void 0 ? {} : { allow: list$1("AB_ASSET_TYPES_ALLOW", env["AB_ASSET_TYPES_ALLOW"], TYPE_NAME$1, "asset type") },
|
|
5917
|
+
deny: list$1("AB_ASSET_TYPES_DENY", env["AB_ASSET_TYPES_DENY"], TYPE_NAME$1, "asset type") ?? []
|
|
5824
5918
|
},
|
|
5825
5919
|
genericQuery: bool("AB_GENERIC_QUERY", env["AB_GENERIC_QUERY"], true),
|
|
5826
5920
|
memberEmails: bool("AB_MEMBER_EMAILS", env["AB_MEMBER_EMAILS"], false),
|
|
@@ -6826,6 +6920,36 @@ function createSqlStorage(driver, options = {}) {
|
|
|
6826
6920
|
if (typeof tokenHash !== "string") return null;
|
|
6827
6921
|
return (await db.select().from(accessTokens).where(eq(accessTokens.tokenHash, tokenHash)).limit(1))[0] ?? null;
|
|
6828
6922
|
},
|
|
6923
|
+
async bundleByHash(tokenHash) {
|
|
6924
|
+
if (typeof tokenHash !== "string") return {
|
|
6925
|
+
token: null,
|
|
6926
|
+
grant: null,
|
|
6927
|
+
workspace: null,
|
|
6928
|
+
user: null,
|
|
6929
|
+
instance: null,
|
|
6930
|
+
credential: null
|
|
6931
|
+
};
|
|
6932
|
+
const { accessTokens: t, grants: g, workspaces: w, users: u, instances: i, credentials: c } = schema_exports;
|
|
6933
|
+
const grantIds = db.select({ id: t.grantId }).from(t).where(eq(t.tokenHash, tokenHash));
|
|
6934
|
+
const workspaceIds = db.select({ id: g.workspaceId }).from(g).where(inArray(g.id, grantIds));
|
|
6935
|
+
const credentialIds = db.select({ id: sql`coalesce(${g.credentialId}, ${w.defaultCredentialId})` }).from(g).innerJoin(w, eq(w.id, g.workspaceId)).where(inArray(g.id, grantIds));
|
|
6936
|
+
const [tokens, grantRows, workspaceRows, userRows, instanceRows, credentialRows] = await db.batch([
|
|
6937
|
+
db.select().from(t).where(eq(t.tokenHash, tokenHash)).limit(1),
|
|
6938
|
+
db.select().from(g).where(inArray(g.id, grantIds)).limit(1),
|
|
6939
|
+
db.select().from(w).where(inArray(w.id, workspaceIds)).limit(1),
|
|
6940
|
+
db.select().from(u).where(inArray(u.id, db.select({ id: g.userId }).from(g).where(inArray(g.id, grantIds)))).limit(1),
|
|
6941
|
+
db.select().from(i).where(inArray(i.id, db.select({ id: w.instanceId }).from(w).where(inArray(w.id, workspaceIds)))).limit(1),
|
|
6942
|
+
db.select().from(c).where(inArray(c.id, credentialIds)).limit(1)
|
|
6943
|
+
]);
|
|
6944
|
+
return {
|
|
6945
|
+
token: tokens[0] ?? null,
|
|
6946
|
+
grant: grantRows[0] ?? null,
|
|
6947
|
+
workspace: workspaceRows[0] ?? null,
|
|
6948
|
+
user: userRows[0] ?? null,
|
|
6949
|
+
instance: instanceRows[0] ?? null,
|
|
6950
|
+
credential: credentialRows[0] ?? null
|
|
6951
|
+
};
|
|
6952
|
+
},
|
|
6829
6953
|
list(filter = {}) {
|
|
6830
6954
|
return tokenRepo.list(filter.grantId === void 0 ? void 0 : eq(accessTokens.grantId, filter.grantId));
|
|
6831
6955
|
},
|
|
@@ -6975,950 +7099,943 @@ function createSqlStorage(driver, options = {}) {
|
|
|
6975
7099
|
});
|
|
6976
7100
|
}
|
|
6977
7101
|
//#endregion
|
|
6978
|
-
//#region ../../packages/auth/src/
|
|
6979
|
-
/**
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
7102
|
+
//#region ../../packages/auth/src/secrets/keyring.ts
|
|
7103
|
+
/** Fixed purpose labels; a key derived for one purpose is useless for another. */
|
|
7104
|
+
const KeyPurpose = {
|
|
7105
|
+
/** Agility access tokens of credentials. */
|
|
7106
|
+
Credentials: "credentials",
|
|
7107
|
+
/** Key-check values that detect a wrong master key. */
|
|
7108
|
+
KeyCheck: "key-check",
|
|
7109
|
+
/** Keyed hashes of identities in the audit log (pass-through token fingerprints). */
|
|
7110
|
+
AuditIdentity: "audit-identity",
|
|
7111
|
+
/** Cache entry ids (P8-01). */
|
|
7112
|
+
CacheId: "cache-id",
|
|
7113
|
+
/** Write approval records (P7-01). */
|
|
7114
|
+
Approval: "approval",
|
|
7115
|
+
/** Task records (P8-10). */
|
|
7116
|
+
Task: "task"
|
|
7117
|
+
};
|
|
7118
|
+
const ENCRYPTION = /* @__PURE__ */ new Set([
|
|
7119
|
+
KeyPurpose.Credentials,
|
|
7120
|
+
KeyPurpose.Approval,
|
|
7121
|
+
KeyPurpose.Task
|
|
7122
|
+
]);
|
|
7123
|
+
/** A failure of the secrets layer. Messages never contain key material, ciphertext or plaintext. */
|
|
7124
|
+
var SecretsError = class extends Error {
|
|
7125
|
+
code;
|
|
7126
|
+
constructor(code, message, options) {
|
|
7127
|
+
super(message, options);
|
|
7128
|
+
this.name = "SecretsError";
|
|
7129
|
+
this.code = code;
|
|
6985
7130
|
}
|
|
6986
7131
|
};
|
|
6987
|
-
const
|
|
6988
|
-
const
|
|
6989
|
-
const
|
|
6990
|
-
const
|
|
6991
|
-
function
|
|
6992
|
-
|
|
7132
|
+
const KEY_BYTES = 32;
|
|
7133
|
+
const IV_BYTES = 12;
|
|
7134
|
+
const MASTER_KEY = /^(?:v([1-9][0-9]{0,2}):)?([A-Za-z0-9+/]{43}=)$/;
|
|
7135
|
+
const HKDF_SALT = new TextEncoder().encode("agility-bridge/hkdf/v1");
|
|
7136
|
+
function toBase64(bytes) {
|
|
7137
|
+
let s = "";
|
|
7138
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
7139
|
+
return btoa(s);
|
|
6993
7140
|
}
|
|
6994
|
-
|
|
6995
|
-
const
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7141
|
+
function fromBase64(text) {
|
|
7142
|
+
const s = atob(text);
|
|
7143
|
+
const out = new Uint8Array(s.length);
|
|
7144
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
7145
|
+
return out;
|
|
7146
|
+
}
|
|
7147
|
+
function toBase64Url(bytes) {
|
|
7148
|
+
return toBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
7149
|
+
}
|
|
7150
|
+
function fromBase64Url(text) {
|
|
7151
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new SecretsError("decrypt_failed", "The sealed value is malformed.");
|
|
7152
|
+
const b64 = text.replaceAll("-", "+").replaceAll("_", "/");
|
|
7153
|
+
return fromBase64(b64 + "=".repeat((4 - b64.length % 4) % 4));
|
|
7154
|
+
}
|
|
7155
|
+
const hex = (bytes) => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
7156
|
+
/**
|
|
7157
|
+
* Parses `v<N>:<base64 of 32 bytes>` (bare base64 is version 1). The error never echoes the input, which is a secret
|
|
7158
|
+
* even when malformed.
|
|
7159
|
+
*/
|
|
7160
|
+
function parseMasterKey(text) {
|
|
7161
|
+
const m = MASTER_KEY.exec(typeof text === "string" ? text.trim() : "");
|
|
7162
|
+
if (m === null) throw new SecretsError("invalid_key", "The master key must be v<N>:<base64 of 32 random bytes> (generate one with `agility-bridge init`).");
|
|
7163
|
+
const bytes = fromBase64(m[2]);
|
|
7164
|
+
if (bytes.length !== KEY_BYTES) throw new SecretsError("invalid_key", "The master key must be 32 bytes.");
|
|
7165
|
+
if (bytes.every((b) => b === bytes[0])) throw new SecretsError("invalid_key", "The master key is not random; generate one with `agility-bridge init`.");
|
|
7166
|
+
return Object.freeze({
|
|
7167
|
+
version: m[1] === void 0 ? 1 : Number(m[1]),
|
|
7168
|
+
bytes
|
|
7169
|
+
});
|
|
7170
|
+
}
|
|
7171
|
+
/** A new master key, `v<version>:<base64>`, from the platform CSPRNG. */
|
|
7172
|
+
function generateMasterKey(version = 1) {
|
|
7173
|
+
if (!Number.isInteger(version) || version < 1 || version > 999) throw new SecretsError("invalid_key", "Key versions are 1-999.");
|
|
7174
|
+
return `v${version}:${toBase64(crypto.getRandomValues(new Uint8Array(KEY_BYTES)))}`;
|
|
7175
|
+
}
|
|
7176
|
+
const additionalData = (context, version) => new TextEncoder().encode(`${context}|${version}`);
|
|
7177
|
+
/**
|
|
7178
|
+
* The current master key and, during a rotation, the previous one. Purpose keys are derived once per version and kept
|
|
7179
|
+
* as non-extractable `CryptoKey`s (per process, never request data).
|
|
7180
|
+
*/
|
|
7181
|
+
var Keyring = class Keyring {
|
|
7182
|
+
currentVersion;
|
|
7183
|
+
versions;
|
|
7184
|
+
#roots;
|
|
7185
|
+
#derived = /* @__PURE__ */ new Map();
|
|
7186
|
+
constructor(currentVersion, roots) {
|
|
7187
|
+
this.currentVersion = currentVersion;
|
|
7188
|
+
this.#roots = roots;
|
|
7189
|
+
this.versions = Object.freeze([...roots.keys()].sort((a, b) => a - b));
|
|
7005
7190
|
}
|
|
7006
|
-
/**
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7191
|
+
/** Builds a keyring from the current master key and, during a rotation, the previous one (a lower version). */
|
|
7192
|
+
static async create(current, previous) {
|
|
7193
|
+
const cur = parseMasterKey(current);
|
|
7194
|
+
const roots = /* @__PURE__ */ new Map();
|
|
7195
|
+
const importRoot = (bytes) => crypto.subtle.importKey("raw", bytes, "HKDF", false, ["deriveKey"]);
|
|
7196
|
+
roots.set(cur.version, await importRoot(cur.bytes));
|
|
7197
|
+
if (previous !== void 0) {
|
|
7198
|
+
const prev = parseMasterKey(previous);
|
|
7199
|
+
if (prev.version >= cur.version) throw new SecretsError("invalid_key", `The previous master key (version ${prev.version}) must have a lower version than the current one (version ${cur.version}).`);
|
|
7200
|
+
if (prev.bytes.every((b, i) => b === cur.bytes[i])) throw new SecretsError("invalid_key", "The previous and current master keys are the same key.");
|
|
7201
|
+
roots.set(prev.version, await importRoot(prev.bytes));
|
|
7202
|
+
}
|
|
7203
|
+
return new Keyring(cur.version, roots);
|
|
7010
7204
|
}
|
|
7011
|
-
|
|
7012
|
-
return
|
|
7205
|
+
has(version) {
|
|
7206
|
+
return this.#roots.has(version);
|
|
7013
7207
|
}
|
|
7014
|
-
|
|
7015
|
-
|
|
7016
|
-
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
const instances = {
|
|
7044
|
-
list: () => storage.instances.list(),
|
|
7045
|
-
get: (id) => storage.instances.get(id),
|
|
7046
|
-
async create(input) {
|
|
7047
|
-
needManager(actor);
|
|
7048
|
-
return services.instances.create(input);
|
|
7049
|
-
},
|
|
7050
|
-
async update(id, patch) {
|
|
7051
|
-
needManager(actor);
|
|
7052
|
-
return services.instances.update(id, patch);
|
|
7053
|
-
},
|
|
7054
|
-
/** THREAT_MODEL A14: owner only, with a recent sign-in. */
|
|
7055
|
-
async retarget(id, target) {
|
|
7056
|
-
needOwner(actor, "change an instance's origin or TLS trust");
|
|
7057
|
-
need(actor.kind === "break_glass" || actor.recentAuth, "Sign in again before changing an instance origin.");
|
|
7058
|
-
return services.instances.retarget(id, target);
|
|
7059
|
-
},
|
|
7060
|
-
async delete(id) {
|
|
7061
|
-
needManager(actor);
|
|
7062
|
-
await storage.instances.delete(id);
|
|
7208
|
+
/**
|
|
7209
|
+
* Derives every purpose key of every loaded version now. Workers boot with it (L-096): the keyring is shared by the
|
|
7210
|
+
* requests of an isolate, and a derivation started by one request must not be awaited by another.
|
|
7211
|
+
*/
|
|
7212
|
+
async warm(purposes = Object.values(KeyPurpose)) {
|
|
7213
|
+
await Promise.all(this.versions.flatMap((v) => purposes.map((purpose) => this.#key(purpose, v))));
|
|
7214
|
+
}
|
|
7215
|
+
#key(purpose, version) {
|
|
7216
|
+
const root = this.#roots.get(version);
|
|
7217
|
+
if (root === void 0) return Promise.reject(new SecretsError("key_unavailable", `Master key version ${version} is not loaded (AB_MASTER_KEY_PREVIOUS?).`));
|
|
7218
|
+
const id = `${version}/${purpose}`;
|
|
7219
|
+
let key = this.#derived.get(id);
|
|
7220
|
+
if (key === void 0) {
|
|
7221
|
+
const info = new TextEncoder().encode(`agility-bridge/${purpose}/v${version}`);
|
|
7222
|
+
const algorithm = {
|
|
7223
|
+
name: "HKDF",
|
|
7224
|
+
hash: "SHA-256",
|
|
7225
|
+
salt: HKDF_SALT,
|
|
7226
|
+
info
|
|
7227
|
+
};
|
|
7228
|
+
key = ENCRYPTION.has(purpose) ? crypto.subtle.deriveKey(algorithm, root, {
|
|
7229
|
+
name: "AES-GCM",
|
|
7230
|
+
length: 256
|
|
7231
|
+
}, false, ["encrypt", "decrypt"]) : crypto.subtle.deriveKey(algorithm, root, {
|
|
7232
|
+
name: "HMAC",
|
|
7233
|
+
hash: "SHA-256",
|
|
7234
|
+
length: 256
|
|
7235
|
+
}, false, ["sign"]);
|
|
7236
|
+
this.#derived.set(id, key);
|
|
7063
7237
|
}
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
},
|
|
7078
|
-
|
|
7079
|
-
|
|
7080
|
-
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
7089
|
-
|
|
7090
|
-
|
|
7091
|
-
const
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
async revoke(id) {
|
|
7101
|
-
const c = await load(storage.credentials.get(id), "credential");
|
|
7102
|
-
if (!mineCredential(c)) {
|
|
7103
|
-
if (!isManager(actor)) throw new NotFoundError("No credential with this id.");
|
|
7104
|
-
}
|
|
7105
|
-
return services.credentials.revoke(id);
|
|
7106
|
-
},
|
|
7107
|
-
async delete(id) {
|
|
7108
|
-
needManager(actor);
|
|
7109
|
-
await storage.credentials.delete(id);
|
|
7110
|
-
}
|
|
7111
|
-
};
|
|
7112
|
-
const checkWorkspaceWrites = (input) => {
|
|
7113
|
-
if (input.writePolicy !== void 0) needOwner(actor, "change write settings");
|
|
7114
|
-
if (input.credentialSource === "passthrough") needOwner(actor, "make a workspace pass-through");
|
|
7115
|
-
if (writeTools(input.enabledTools).length > 0) needOwner(actor, "enable write tools");
|
|
7116
|
-
};
|
|
7117
|
-
const workspaces = {
|
|
7118
|
-
async list() {
|
|
7119
|
-
needManager(actor);
|
|
7120
|
-
return storage.workspaces.list();
|
|
7121
|
-
},
|
|
7122
|
-
async get(id) {
|
|
7123
|
-
needManager(actor);
|
|
7124
|
-
return storage.workspaces.get(id);
|
|
7125
|
-
},
|
|
7126
|
-
async create(input) {
|
|
7127
|
-
needManager(actor);
|
|
7128
|
-
checkWorkspaceWrites(input);
|
|
7129
|
-
return services.workspaces.create(input);
|
|
7130
|
-
},
|
|
7131
|
-
async update(id, patch) {
|
|
7132
|
-
needManager(actor);
|
|
7133
|
-
checkWorkspaceWrites(patch);
|
|
7134
|
-
return services.workspaces.update(id, patch);
|
|
7135
|
-
},
|
|
7136
|
-
async disable(id) {
|
|
7137
|
-
needManager(actor);
|
|
7138
|
-
return services.workspaces.disable(id);
|
|
7139
|
-
},
|
|
7140
|
-
async delete(id) {
|
|
7141
|
-
needManager(actor);
|
|
7142
|
-
await services.workspaces.delete(id);
|
|
7238
|
+
return key;
|
|
7239
|
+
}
|
|
7240
|
+
/** Encrypts with the current key; `context` (a record id) and the version are bound as additional data. */
|
|
7241
|
+
async seal(purpose, context, plaintext) {
|
|
7242
|
+
if (!ENCRYPTION.has(purpose)) throw new TypeError(`${purpose} is not an encryption purpose.`);
|
|
7243
|
+
const version = this.currentVersion;
|
|
7244
|
+
const key = await this.#key(purpose, version);
|
|
7245
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
7246
|
+
const data = await crypto.subtle.encrypt({
|
|
7247
|
+
name: "AES-GCM",
|
|
7248
|
+
iv,
|
|
7249
|
+
additionalData: additionalData(context, version),
|
|
7250
|
+
tagLength: 128
|
|
7251
|
+
}, key, new TextEncoder().encode(plaintext));
|
|
7252
|
+
return Object.freeze({
|
|
7253
|
+
ciphertext: toBase64Url(new Uint8Array(data)),
|
|
7254
|
+
iv: toBase64Url(iv),
|
|
7255
|
+
keyVersion: version
|
|
7256
|
+
});
|
|
7257
|
+
}
|
|
7258
|
+
/** Decrypts a sealed value; a wrong key, context or version, or any tampering, fails with `decrypt_failed`. */
|
|
7259
|
+
async open(purpose, context, sealed) {
|
|
7260
|
+
if (!ENCRYPTION.has(purpose)) throw new TypeError(`${purpose} is not an encryption purpose.`);
|
|
7261
|
+
const key = await this.#key(purpose, sealed.keyVersion);
|
|
7262
|
+
const iv = fromBase64Url(sealed.iv);
|
|
7263
|
+
if (iv.length !== IV_BYTES) throw new SecretsError("decrypt_failed", "The sealed value is malformed.");
|
|
7264
|
+
try {
|
|
7265
|
+
const data = await crypto.subtle.decrypt({
|
|
7266
|
+
name: "AES-GCM",
|
|
7267
|
+
iv,
|
|
7268
|
+
additionalData: additionalData(context, sealed.keyVersion),
|
|
7269
|
+
tagLength: 128
|
|
7270
|
+
}, key, fromBase64Url(sealed.ciphertext));
|
|
7271
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
7272
|
+
} catch (error) {
|
|
7273
|
+
throw new SecretsError("decrypt_failed", "The sealed value could not be decrypted with this master key.", { cause: error });
|
|
7143
7274
|
}
|
|
7275
|
+
}
|
|
7276
|
+
/** HMAC-SHA-256 (hex) with the purpose key of `version` (default current). */
|
|
7277
|
+
async mac(purpose, data, version = this.currentVersion) {
|
|
7278
|
+
if (ENCRYPTION.has(purpose)) throw new TypeError(`${purpose} is an encryption purpose.`);
|
|
7279
|
+
const key = await this.#key(purpose, version);
|
|
7280
|
+
return hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data)));
|
|
7281
|
+
}
|
|
7282
|
+
/** The key-check value of a version: proves the key without revealing it. */
|
|
7283
|
+
keyCheckValue(version) {
|
|
7284
|
+
return this.mac(KeyPurpose.KeyCheck, "agility-bridge key check", version);
|
|
7285
|
+
}
|
|
7286
|
+
};
|
|
7287
|
+
/** Constant-time comparison of two strings of equal length (false otherwise). */
|
|
7288
|
+
function timingSafeEqual(a, b) {
|
|
7289
|
+
if (a.length !== b.length) return false;
|
|
7290
|
+
let diff = 0;
|
|
7291
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
7292
|
+
return diff === 0;
|
|
7293
|
+
}
|
|
7294
|
+
//#endregion
|
|
7295
|
+
//#region ../../packages/auth/src/secrets/credentials.ts
|
|
7296
|
+
const KCV_KEY = (version) => `crypto:kcv:v${version}`;
|
|
7297
|
+
const RETIRED_KEY = (version) => `crypto:retired:v${version}`;
|
|
7298
|
+
/** At most the last 4 characters, and nothing for short values. */
|
|
7299
|
+
function tokenHint(token) {
|
|
7300
|
+
return token.length >= 16 ? token.slice(-4) : "";
|
|
7301
|
+
}
|
|
7302
|
+
/** Seals an Agility access token for the credential `credentialId` (additional data: `id | key version`). */
|
|
7303
|
+
async function sealCredentialToken(keyring, credentialId, token) {
|
|
7304
|
+
if (typeof token !== "string" || token.length === 0) throw new TypeError("An access token is required.");
|
|
7305
|
+
const sealed = await keyring.seal(KeyPurpose.Credentials, credentialId, token);
|
|
7306
|
+
return {
|
|
7307
|
+
tokenCiphertext: sealed.ciphertext,
|
|
7308
|
+
tokenIv: sealed.iv,
|
|
7309
|
+
keyVersion: sealed.keyVersion,
|
|
7310
|
+
tokenHint: tokenHint(token)
|
|
7144
7311
|
};
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
if (patch.credentialId !== void 0 && patch.credentialId !== current.credentialId) await needCredentialOwner(patch.credentialId, "put a grant on someone's personal Agility token");
|
|
7168
|
-
return services.grants.update(id, patch);
|
|
7169
|
-
},
|
|
7170
|
-
async revoke(id) {
|
|
7171
|
-
needManager(actor);
|
|
7172
|
-
return services.grants.revoke(id);
|
|
7173
|
-
},
|
|
7174
|
-
async delete(id) {
|
|
7175
|
-
needManager(actor);
|
|
7176
|
-
await services.grants.delete(id);
|
|
7312
|
+
}
|
|
7313
|
+
const toSealed = (c) => ({
|
|
7314
|
+
ciphertext: c.tokenCiphertext,
|
|
7315
|
+
iv: c.tokenIv,
|
|
7316
|
+
keyVersion: c.keyVersion
|
|
7317
|
+
});
|
|
7318
|
+
/** Decrypts a credential's token. Swapping ciphertexts between credentials or versions fails (`decrypt_failed`). */
|
|
7319
|
+
function openCredentialToken(keyring, credential) {
|
|
7320
|
+
return keyring.open(KeyPurpose.Credentials, credential.id, toSealed(credential));
|
|
7321
|
+
}
|
|
7322
|
+
/**
|
|
7323
|
+
* Boot check: every loaded version's key-check value must match the one recorded the first time that version was
|
|
7324
|
+
* used, so a wrong key is refused before it decrypts (or seals) anything, and a version number is never reused for
|
|
7325
|
+
* other key material. The current version must not be retired.
|
|
7326
|
+
*/
|
|
7327
|
+
async function verifyKeyring(storage, keyring) {
|
|
7328
|
+
const warnings = [];
|
|
7329
|
+
for (const version of keyring.versions) {
|
|
7330
|
+
const kcv = await keyring.keyCheckValue(version);
|
|
7331
|
+
if (!await storage.kv.setIfAbsent(KCV_KEY(version), kcv)) {
|
|
7332
|
+
const recorded = await storage.kv.get(KCV_KEY(version));
|
|
7333
|
+
if (recorded === null || !timingSafeEqual(recorded, kcv)) throw new SecretsError("wrong_key", `Master key version ${version} is not the key this database was encrypted with. Use the original key; a version number can never be reused for a different key.`);
|
|
7177
7334
|
}
|
|
7178
|
-
};
|
|
7179
|
-
/** Members manage the tokens of their own grants; owners and admins every token. */
|
|
7180
|
-
async function ownGrant(grantId) {
|
|
7181
|
-
const g = await load(storage.grants.get(grantId), "grant");
|
|
7182
|
-
if (!isManager(actor) && !mineGrant(g)) throw new NotFoundError("No grant with this id.");
|
|
7183
|
-
return g;
|
|
7184
|
-
}
|
|
7185
|
-
/**
|
|
7186
|
-
* A device token on a grant that uses someone's personal Agility token acts as that person in Agility: only they
|
|
7187
|
-
* may issue one (P2-17 review M2). Grants on shared credentials stay with owners and admins.
|
|
7188
|
-
*/
|
|
7189
|
-
async function needCredentialOwner(credentialId, what) {
|
|
7190
|
-
if (credentialId === null) return;
|
|
7191
|
-
const c = await storage.credentials.get(credentialId);
|
|
7192
|
-
if (c !== null && c.ownerUserId !== null && c.ownerUserId !== self) throw new ForbiddenError(`Only the owner of the personal Agility token can ${what}.`);
|
|
7193
7335
|
}
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7336
|
+
if (await storage.kv.get(RETIRED_KEY(keyring.currentVersion)) !== null) throw new SecretsError("retired", `Master key version ${keyring.currentVersion} was retired; use a newer key.`);
|
|
7337
|
+
for (const version of keyring.versions) if (version !== keyring.currentVersion && await storage.kv.get(RETIRED_KEY(version)) !== null) warnings.push(`Master key version ${version} is retired; remove AB_MASTER_KEY_PREVIOUS.`);
|
|
7338
|
+
const stranded = (await keyStatus(storage, keyring)).filter((v) => !v.loaded && v.credentials > 0);
|
|
7339
|
+
for (const v of stranded) warnings.push(`${v.credentials} credential(s) use master key version ${v.version}, which is not loaded; they cannot be used until that key is provided (as AB_MASTER_KEY_PREVIOUS for a rotation) or their tokens are entered again.`);
|
|
7340
|
+
return { warnings };
|
|
7341
|
+
}
|
|
7342
|
+
/** Credentials per key version, and which versions are loaded, current or retired. */
|
|
7343
|
+
async function keyStatus(storage, keyring) {
|
|
7344
|
+
const counts = /* @__PURE__ */ new Map();
|
|
7345
|
+
for (const c of await storage.credentials.list()) counts.set(c.keyVersion, (counts.get(c.keyVersion) ?? 0) + 1);
|
|
7346
|
+
for (const v of keyring.versions) if (!counts.has(v)) counts.set(v, 0);
|
|
7347
|
+
const out = [];
|
|
7348
|
+
for (const [version, credentials] of [...counts].sort((a, b) => a[0] - b[0])) out.push({
|
|
7349
|
+
version,
|
|
7198
7350
|
credentials,
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
async list(grantId) {
|
|
7203
|
-
await ownGrant(grantId);
|
|
7204
|
-
return services.tokens.list(grantId);
|
|
7205
|
-
},
|
|
7206
|
-
async issue(input) {
|
|
7207
|
-
await needCredentialOwner((await ownGrant(input.grantId)).credentialId, "issue device tokens on this grant");
|
|
7208
|
-
return services.tokens.issue(input);
|
|
7209
|
-
},
|
|
7210
|
-
async revoke(id) {
|
|
7211
|
-
await ownGrant((await load(storage.accessTokens.get(id), "token")).grantId);
|
|
7212
|
-
return services.tokens.revoke(id);
|
|
7213
|
-
}
|
|
7214
|
-
}
|
|
7351
|
+
loaded: keyring.has(version),
|
|
7352
|
+
current: version === keyring.currentVersion,
|
|
7353
|
+
retired: await storage.kv.get(RETIRED_KEY(version)) !== null
|
|
7215
7354
|
});
|
|
7355
|
+
return out;
|
|
7216
7356
|
}
|
|
7217
|
-
//#endregion
|
|
7218
|
-
//#region ../../packages/auth/src/access/load.ts
|
|
7219
7357
|
/**
|
|
7220
|
-
*
|
|
7221
|
-
*
|
|
7358
|
+
* Re-encrypts every credential still on an older loaded version with the current key. Each row is replaced only if it
|
|
7359
|
+
* still holds the ciphertext that was decrypted (compare-and-swap), so concurrent edits and interrupted runs are safe:
|
|
7360
|
+
* run it again to resume.
|
|
7222
7361
|
*/
|
|
7223
|
-
async function
|
|
7224
|
-
const
|
|
7225
|
-
if (
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
if (workspace === null) return {
|
|
7234
|
-
grant,
|
|
7235
|
-
workspace: null,
|
|
7236
|
-
instance: null,
|
|
7237
|
-
credential: null,
|
|
7238
|
-
user
|
|
7239
|
-
};
|
|
7240
|
-
const credentialId = grant.credentialId ?? workspace.defaultCredentialId;
|
|
7241
|
-
const [instance, credential] = await Promise.all([storage.instances.get(workspace.instanceId), credentialId === null ? Promise.resolve(null) : storage.credentials.get(credentialId)]);
|
|
7242
|
-
return {
|
|
7243
|
-
grant,
|
|
7244
|
-
workspace,
|
|
7245
|
-
instance,
|
|
7246
|
-
credential,
|
|
7247
|
-
user
|
|
7362
|
+
async function rotateCredentialKeys(storage, keyring, options) {
|
|
7363
|
+
const reason = String(options.reason ?? "").slice(0, 200);
|
|
7364
|
+
if (reason.length === 0) throw new TypeError("A rotation reason is required for the audit log.");
|
|
7365
|
+
const batchSize = Math.max(1, Math.min(options.batchSize ?? 100, 1e3));
|
|
7366
|
+
const now = options.now ?? Date.now;
|
|
7367
|
+
const result = {
|
|
7368
|
+
rotated: 0,
|
|
7369
|
+
skipped: 0,
|
|
7370
|
+
failed: [],
|
|
7371
|
+
remaining: 0
|
|
7248
7372
|
};
|
|
7249
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
const
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7314
|
-
|
|
7315
|
-
* Workspace limits over the deployment defaults (capped by its ceilings), tightened by the grant: numbers take the
|
|
7316
|
-
* minimum, switches stay on only when both allow them. `warnings` name grant values that tried to loosen a limit.
|
|
7317
|
-
*/
|
|
7318
|
-
function effectiveLimits(deployment, workspace, grant) {
|
|
7319
|
-
const warnings = [];
|
|
7320
|
-
const out = { ...deployment.defaults };
|
|
7321
|
-
for (const key of NUMBER_LIMITS) {
|
|
7322
|
-
let value = workspace[key] ?? deployment.defaults[key];
|
|
7323
|
-
const ceiling = deployment.ceilings[key];
|
|
7324
|
-
if (ceiling !== void 0) value = Math.min(value, ceiling);
|
|
7325
|
-
const g = grant[key];
|
|
7326
|
-
if (g !== void 0) {
|
|
7327
|
-
if (g > value) warnings.push(`The grant's ${key} (${g}) is above the workspace's (${value}); ${value} applies.`);
|
|
7328
|
-
value = Math.min(value, g);
|
|
7373
|
+
const doneWith = /* @__PURE__ */ new Set();
|
|
7374
|
+
const details = (from) => ({
|
|
7375
|
+
fromVersion: from,
|
|
7376
|
+
toVersion: keyring.currentVersion,
|
|
7377
|
+
reason,
|
|
7378
|
+
...options.actor?.via !== void 0 ? { via: options.actor.via } : {}
|
|
7379
|
+
});
|
|
7380
|
+
for (let batch = 0; options.maxBatches === void 0 || batch < options.maxBatches; batch++) {
|
|
7381
|
+
const pending = (await storage.credentials.list()).filter((c) => c.keyVersion !== keyring.currentVersion && !doneWith.has(c.id)).slice(0, batchSize);
|
|
7382
|
+
if (pending.length === 0) break;
|
|
7383
|
+
for (const c of pending) {
|
|
7384
|
+
const expected = {
|
|
7385
|
+
tokenCiphertext: c.tokenCiphertext,
|
|
7386
|
+
tokenIv: c.tokenIv,
|
|
7387
|
+
keyVersion: c.keyVersion
|
|
7388
|
+
};
|
|
7389
|
+
let token;
|
|
7390
|
+
try {
|
|
7391
|
+
token = await openCredentialToken(keyring, c);
|
|
7392
|
+
} catch (error) {
|
|
7393
|
+
const reasonCode = error instanceof SecretsError && error.code === "key_unavailable" ? error.code : "decrypt_failed";
|
|
7394
|
+
doneWith.add(c.id);
|
|
7395
|
+
result.failed.push({
|
|
7396
|
+
id: c.id,
|
|
7397
|
+
keyVersion: c.keyVersion,
|
|
7398
|
+
reason: reasonCode
|
|
7399
|
+
});
|
|
7400
|
+
await storage.audit.append({
|
|
7401
|
+
id: crypto.randomUUID(),
|
|
7402
|
+
ts: now(),
|
|
7403
|
+
kind: "admin",
|
|
7404
|
+
action: "credential.rekey",
|
|
7405
|
+
status: "error",
|
|
7406
|
+
errorCode: reasonCode,
|
|
7407
|
+
userId: options.actor?.userId ?? null,
|
|
7408
|
+
instanceId: c.instanceId,
|
|
7409
|
+
targetType: "credential",
|
|
7410
|
+
targetId: c.id,
|
|
7411
|
+
details: details(c.keyVersion)
|
|
7412
|
+
});
|
|
7413
|
+
continue;
|
|
7414
|
+
}
|
|
7415
|
+
const sealed = await sealCredentialToken(keyring, c.id, token);
|
|
7416
|
+
const next = {
|
|
7417
|
+
tokenCiphertext: sealed.tokenCiphertext,
|
|
7418
|
+
tokenIv: sealed.tokenIv,
|
|
7419
|
+
keyVersion: sealed.keyVersion
|
|
7420
|
+
};
|
|
7421
|
+
if (await storage.credentials.replaceSecret(c.id, expected, next)) {
|
|
7422
|
+
result.rotated++;
|
|
7423
|
+
await storage.audit.append({
|
|
7424
|
+
id: crypto.randomUUID(),
|
|
7425
|
+
ts: now(),
|
|
7426
|
+
kind: "admin",
|
|
7427
|
+
action: "credential.rekey",
|
|
7428
|
+
status: "ok",
|
|
7429
|
+
userId: options.actor?.userId ?? null,
|
|
7430
|
+
instanceId: c.instanceId,
|
|
7431
|
+
targetType: "credential",
|
|
7432
|
+
targetId: c.id,
|
|
7433
|
+
details: details(c.keyVersion)
|
|
7434
|
+
});
|
|
7435
|
+
} else {
|
|
7436
|
+
doneWith.add(c.id);
|
|
7437
|
+
result.skipped++;
|
|
7438
|
+
}
|
|
7329
7439
|
}
|
|
7330
|
-
out[key] = value;
|
|
7331
|
-
}
|
|
7332
|
-
for (const key of SWITCHES) {
|
|
7333
|
-
const ws = workspace[key] ?? deployment.defaults[key];
|
|
7334
|
-
const g = grant[key];
|
|
7335
|
-
if (g === true && !ws) warnings.push(`The grant turns ${key} on, but the workspace has it off.`);
|
|
7336
|
-
out[key] = ws && g !== false;
|
|
7337
7440
|
}
|
|
7338
|
-
|
|
7339
|
-
return
|
|
7340
|
-
limits: Object.freeze(out),
|
|
7341
|
-
warnings
|
|
7342
|
-
};
|
|
7343
|
-
}
|
|
7344
|
-
/** Allow lists intersect (absent = no restriction), deny lists union. */
|
|
7345
|
-
function effectiveAssetTypes(workspace, grant) {
|
|
7346
|
-
const wa = workspace?.allow;
|
|
7347
|
-
const ga = grant?.allow;
|
|
7348
|
-
const allow = wa === void 0 ? ga : ga === void 0 ? wa : wa.filter((t) => ga.includes(t));
|
|
7349
|
-
const deny = [.../* @__PURE__ */ new Set([...workspace?.deny ?? [], ...grant?.deny ?? []])];
|
|
7350
|
-
return allow === void 0 ? { deny } : {
|
|
7351
|
-
allow: [...new Set(allow)],
|
|
7352
|
-
deny
|
|
7353
|
-
};
|
|
7354
|
-
}
|
|
7355
|
-
//#endregion
|
|
7356
|
-
//#region ../../packages/auth/src/access/resolve.ts
|
|
7357
|
-
const deny = (reason) => ({
|
|
7358
|
-
ok: false,
|
|
7359
|
-
reason
|
|
7360
|
-
});
|
|
7361
|
-
const expired = (at, now) => at !== null && at <= now;
|
|
7362
|
-
function parseWorkspace(w) {
|
|
7363
|
-
const r = workspacePolicySchema.safeParse({
|
|
7364
|
-
allowedScopeIds: w.allowedScopeIds,
|
|
7365
|
-
includeChildScopes: w.includeChildScopes,
|
|
7366
|
-
assetTypes: w.assetTypes,
|
|
7367
|
-
enabledTools: w.enabledTools,
|
|
7368
|
-
limits: w.limits,
|
|
7369
|
-
writePolicy: w.writePolicy
|
|
7370
|
-
});
|
|
7371
|
-
return r.success ? r.data : null;
|
|
7372
|
-
}
|
|
7373
|
-
function parseGrant(g) {
|
|
7374
|
-
const r = grantPolicySchema.safeParse({
|
|
7375
|
-
tools: g.tools,
|
|
7376
|
-
scopeIds: g.scopeIds,
|
|
7377
|
-
assetTypes: g.assetTypes,
|
|
7378
|
-
limits: g.limits,
|
|
7379
|
-
rateLimit: g.rateLimit
|
|
7380
|
-
});
|
|
7381
|
-
return r.success ? r.data : null;
|
|
7441
|
+
result.remaining = (await storage.credentials.list()).filter((c) => c.keyVersion !== keyring.currentVersion).length;
|
|
7442
|
+
return result;
|
|
7382
7443
|
}
|
|
7383
7444
|
/**
|
|
7384
|
-
*
|
|
7385
|
-
*
|
|
7445
|
+
* Marks a key version as retired once no credential uses it; a retired version is refused as the current key at
|
|
7446
|
+
* boot. The previous key can then be removed from the configuration.
|
|
7386
7447
|
*/
|
|
7387
|
-
function
|
|
7388
|
-
|
|
7389
|
-
if (
|
|
7390
|
-
|
|
7391
|
-
if (
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7448
|
+
async function retireKeyVersion(storage, keyring, version, options) {
|
|
7449
|
+
if (version === keyring.currentVersion) throw new SecretsError("in_use", `Master key version ${version} is the current key.`);
|
|
7450
|
+
if (version > keyring.currentVersion || await storage.kv.get(KCV_KEY(version)) === null) throw new SecretsError("in_use", `Master key version ${version} is not an earlier version of this database.`);
|
|
7451
|
+
const users = await storage.credentials.list({ keyVersion: version });
|
|
7452
|
+
if (users.length > 0) throw new SecretsError("in_use", `${users.length} credential(s) still use master key version ${version}; rotate them first.`);
|
|
7453
|
+
const now = options.now ?? Date.now;
|
|
7454
|
+
await storage.kv.set(RETIRED_KEY(version), String(now()));
|
|
7455
|
+
if (options.record === false) return;
|
|
7456
|
+
await storage.audit.append({
|
|
7457
|
+
id: crypto.randomUUID(),
|
|
7458
|
+
ts: now(),
|
|
7459
|
+
kind: "admin",
|
|
7460
|
+
action: "crypto.retire_key",
|
|
7461
|
+
status: "ok",
|
|
7462
|
+
userId: options.actor?.userId ?? null,
|
|
7463
|
+
targetType: "key_version",
|
|
7464
|
+
targetId: String(version),
|
|
7465
|
+
details: {
|
|
7466
|
+
reason: String(options.reason ?? "").slice(0, 200),
|
|
7467
|
+
...options.actor?.via !== void 0 ? { via: options.actor.via } : {}
|
|
7468
|
+
}
|
|
7469
|
+
});
|
|
7470
|
+
}
|
|
7471
|
+
//#endregion
|
|
7472
|
+
//#region ../../packages/auth/src/access/guards.ts
|
|
7473
|
+
/** The actor may not do this (HTTP 403). The message says which role or condition is needed. */
|
|
7474
|
+
var ForbiddenError = class extends Error {
|
|
7475
|
+
code = "forbidden";
|
|
7476
|
+
constructor(message) {
|
|
7477
|
+
super(message);
|
|
7478
|
+
this.name = "ForbiddenError";
|
|
7407
7479
|
}
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
if (
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7480
|
+
};
|
|
7481
|
+
const roleOf = (a) => a.kind === "break_glass" ? "owner" : a.role;
|
|
7482
|
+
const userIdOf = (a) => a.kind === "user" ? a.userId : null;
|
|
7483
|
+
const isOwner = (a) => roleOf(a) === "owner";
|
|
7484
|
+
const isManager = (a) => roleOf(a) === "owner" || roleOf(a) === "admin";
|
|
7485
|
+
function need(ok, message) {
|
|
7486
|
+
if (!ok) throw new ForbiddenError(message);
|
|
7487
|
+
}
|
|
7488
|
+
const needManager = (a) => need(isManager(a), "This needs an owner or an admin.");
|
|
7489
|
+
const needOwner = (a, what) => need(isOwner(a), `Only an owner can ${what}.`);
|
|
7490
|
+
/** The services, each call checked against the actor's role (and, for members, ownership). */
|
|
7491
|
+
function guardedServices(services, ctx, actor) {
|
|
7492
|
+
const { storage, tools } = ctx;
|
|
7493
|
+
const writeTools = (names) => (names ?? []).filter((n) => tools.get(n) !== void 0 && tools.get(n)?.kind !== "read");
|
|
7494
|
+
const self = userIdOf(actor);
|
|
7495
|
+
async function load(row, what) {
|
|
7496
|
+
const r = await row;
|
|
7497
|
+
if (r === null) throw new NotFoundError(`No ${what} with this id.`);
|
|
7498
|
+
return r;
|
|
7425
7499
|
}
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
if (
|
|
7500
|
+
/** Members see only their own records: anything else is "not found", never "forbidden" (no existence oracle). */
|
|
7501
|
+
function visible(row, mine) {
|
|
7502
|
+
if (row === null || isManager(actor)) return row;
|
|
7503
|
+
return mine(row) ? row : null;
|
|
7429
7504
|
}
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
warnings.push(...limitWarnings);
|
|
7433
|
-
const known = settings.tools;
|
|
7434
|
-
const workspaceTools = ws.enabledTools ?? [...known.keys()].filter((n) => known.get(n)?.kind === "read");
|
|
7435
|
-
const unknown = [...ws.enabledTools ?? [], ...gp.tools ?? []].filter((n) => !known.has(n));
|
|
7436
|
-
if (unknown.length > 0) warnings.push(`Unknown tools ignored: ${[...new Set(unknown)].join(", ")}.`);
|
|
7437
|
-
const notEnabled = (gp.tools ?? []).filter((n) => known.has(n) && !workspaceTools.includes(n));
|
|
7438
|
-
if (notEnabled.length > 0) warnings.push(`The grant names tools the workspace does not enable: ${notEnabled.join(", ")}.`);
|
|
7439
|
-
const writesPossible = settings.writesEnabled && ws.writePolicy.enabled === true && kind === "personal" && (principal.kind === "device_token" || principal.writeScope);
|
|
7440
|
-
const tools = [];
|
|
7441
|
-
const writeTools = [];
|
|
7442
|
-
for (const name of workspaceTools) {
|
|
7443
|
-
const info = known.get(name);
|
|
7444
|
-
if (info === void 0) continue;
|
|
7445
|
-
if (gp.tools !== null && !gp.tools.includes(name)) continue;
|
|
7446
|
-
if (!limits.genericQuery && info.group === "query") continue;
|
|
7447
|
-
if (info.kind !== "read") {
|
|
7448
|
-
if (!(ws.enabledTools?.includes(name) === true && gp.tools?.includes(name) === true) || !writesPossible) {
|
|
7449
|
-
if (gp.tools?.includes(name)) warnings.push(kind === "shared" ? `${name} needs the user's own credential: shared credentials are read-only.` : `${name} is a write tool and writes are not enabled for this request.`);
|
|
7450
|
-
continue;
|
|
7451
|
-
}
|
|
7452
|
-
writeTools.push(name);
|
|
7453
|
-
}
|
|
7454
|
-
tools.push(name);
|
|
7505
|
+
async function activeOwners() {
|
|
7506
|
+
return (await storage.users.list()).filter((u) => u.role === "owner" && u.disabledAt === null);
|
|
7455
7507
|
}
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
keyVersion: credential.keyVersion
|
|
7485
|
-
}
|
|
7486
|
-
},
|
|
7487
|
-
tools: Object.freeze(tools),
|
|
7488
|
-
writeTools: Object.freeze(writeTools),
|
|
7489
|
-
projects: {
|
|
7490
|
-
allowed: Object.freeze([...projects]),
|
|
7491
|
-
includeChildren: ws.includeChildScopes
|
|
7492
|
-
},
|
|
7493
|
-
assetTypes,
|
|
7494
|
-
limits,
|
|
7495
|
-
rateLimit: gp.rateLimit,
|
|
7496
|
-
expiresAt: ends.length === 0 ? null : Math.min(...ends),
|
|
7497
|
-
warnings: Object.freeze(warnings)
|
|
7498
|
-
})
|
|
7508
|
+
const users = {
|
|
7509
|
+
async list() {
|
|
7510
|
+
if (isManager(actor)) return storage.users.list();
|
|
7511
|
+
const me = self === null ? null : await storage.users.get(self);
|
|
7512
|
+
return me === null ? [] : [me];
|
|
7513
|
+
},
|
|
7514
|
+
async get(id) {
|
|
7515
|
+
return visible(await storage.users.get(id), (u) => u.id === self);
|
|
7516
|
+
},
|
|
7517
|
+
async create(input) {
|
|
7518
|
+
needManager(actor);
|
|
7519
|
+
if (input.role !== "member") needOwner(actor, "create owners or admins");
|
|
7520
|
+
return services.users.create(input);
|
|
7521
|
+
},
|
|
7522
|
+
async update(id, patch) {
|
|
7523
|
+
needManager(actor);
|
|
7524
|
+
const target = await load(storage.users.get(id), "user");
|
|
7525
|
+
if (target.role !== "member" || patch.role !== void 0 && patch.role !== "member") needOwner(actor, "change owners, admins or roles above member");
|
|
7526
|
+
if (target.role === "owner" && patch.role !== void 0 && patch.role !== "owner") need((await activeOwners()).filter((u) => u.id !== id).length > 0, "The last active owner cannot lose the owner role.");
|
|
7527
|
+
return services.users.update(id, patch);
|
|
7528
|
+
},
|
|
7529
|
+
async disable(id) {
|
|
7530
|
+
needManager(actor);
|
|
7531
|
+
const target = await load(storage.users.get(id), "user");
|
|
7532
|
+
if (target.role !== "member") needOwner(actor, "disable owners or admins");
|
|
7533
|
+
if (target.role === "owner") need((await activeOwners()).filter((u) => u.id !== id).length > 0, "The last active owner cannot be disabled.");
|
|
7534
|
+
return services.users.disable(id);
|
|
7535
|
+
}
|
|
7499
7536
|
};
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
7519
|
-
|
|
7520
|
-
|
|
7521
|
-
});
|
|
7522
|
-
if (tools.length === 0) return deny("no_tools");
|
|
7523
|
-
const id = fingerprint.slice(0, 32);
|
|
7524
|
-
return {
|
|
7525
|
-
ok: true,
|
|
7526
|
-
access: Object.freeze({
|
|
7527
|
-
workspace: {
|
|
7528
|
-
id: workspace.id,
|
|
7529
|
-
slug: workspace.slug,
|
|
7530
|
-
name: workspace.name
|
|
7531
|
-
},
|
|
7532
|
-
instance: {
|
|
7533
|
-
id: instance.id,
|
|
7534
|
-
baseUrl: instance.baseUrl,
|
|
7535
|
-
tls: instance.tls
|
|
7536
|
-
},
|
|
7537
|
-
grant: {
|
|
7538
|
-
id: `passthrough:${id}`,
|
|
7539
|
-
userId: null,
|
|
7540
|
-
label: "pass-through"
|
|
7541
|
-
},
|
|
7542
|
-
tokenId: `pt:${id}`,
|
|
7543
|
-
credential: {
|
|
7544
|
-
id: `pt:${id}`,
|
|
7545
|
-
kind: "personal",
|
|
7546
|
-
sealed: null
|
|
7547
|
-
},
|
|
7548
|
-
tools: Object.freeze(tools),
|
|
7549
|
-
writeTools: Object.freeze([]),
|
|
7550
|
-
projects: {
|
|
7551
|
-
allowed: Object.freeze([...ws.allowedScopeIds]),
|
|
7552
|
-
includeChildren: ws.includeChildScopes
|
|
7553
|
-
},
|
|
7554
|
-
assetTypes: effectiveAssetTypes(ws.assetTypes, null),
|
|
7555
|
-
limits,
|
|
7556
|
-
rateLimit: null,
|
|
7557
|
-
expiresAt: null,
|
|
7558
|
-
warnings: Object.freeze([])
|
|
7559
|
-
})
|
|
7537
|
+
const instances = {
|
|
7538
|
+
list: () => storage.instances.list(),
|
|
7539
|
+
get: (id) => storage.instances.get(id),
|
|
7540
|
+
async create(input) {
|
|
7541
|
+
needManager(actor);
|
|
7542
|
+
return services.instances.create(input);
|
|
7543
|
+
},
|
|
7544
|
+
async update(id, patch) {
|
|
7545
|
+
needManager(actor);
|
|
7546
|
+
return services.instances.update(id, patch);
|
|
7547
|
+
},
|
|
7548
|
+
/** THREAT_MODEL A14: owner only, with a recent sign-in. */
|
|
7549
|
+
async retarget(id, target) {
|
|
7550
|
+
needOwner(actor, "change an instance's origin or TLS trust");
|
|
7551
|
+
need(actor.kind === "break_glass" || actor.recentAuth, "Sign in again before changing an instance origin.");
|
|
7552
|
+
return services.instances.retarget(id, target);
|
|
7553
|
+
},
|
|
7554
|
+
async delete(id) {
|
|
7555
|
+
needManager(actor);
|
|
7556
|
+
await storage.instances.delete(id);
|
|
7557
|
+
}
|
|
7560
7558
|
};
|
|
7561
|
-
|
|
7562
|
-
|
|
7563
|
-
|
|
7564
|
-
|
|
7565
|
-
|
|
7566
|
-
|
|
7567
|
-
|
|
7568
|
-
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
|
|
7572
|
-
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
7582
|
-
|
|
7583
|
-
|
|
7584
|
-
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
return out;
|
|
7607
|
-
}
|
|
7608
|
-
function toBase64Url(bytes) {
|
|
7609
|
-
return toBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
7610
|
-
}
|
|
7611
|
-
function fromBase64Url(text) {
|
|
7612
|
-
if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new SecretsError("decrypt_failed", "The sealed value is malformed.");
|
|
7613
|
-
const b64 = text.replaceAll("-", "+").replaceAll("_", "/");
|
|
7614
|
-
return fromBase64(b64 + "=".repeat((4 - b64.length % 4) % 4));
|
|
7615
|
-
}
|
|
7616
|
-
const hex = (bytes) => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
7617
|
-
/**
|
|
7618
|
-
* Parses `v<N>:<base64 of 32 bytes>` (bare base64 is version 1). The error never echoes the input, which is a secret
|
|
7619
|
-
* even when malformed.
|
|
7620
|
-
*/
|
|
7621
|
-
function parseMasterKey(text) {
|
|
7622
|
-
const m = MASTER_KEY.exec(typeof text === "string" ? text.trim() : "");
|
|
7623
|
-
if (m === null) throw new SecretsError("invalid_key", "The master key must be v<N>:<base64 of 32 random bytes> (generate one with `agility-bridge init`).");
|
|
7624
|
-
const bytes = fromBase64(m[2]);
|
|
7625
|
-
if (bytes.length !== KEY_BYTES) throw new SecretsError("invalid_key", "The master key must be 32 bytes.");
|
|
7626
|
-
if (bytes.every((b) => b === bytes[0])) throw new SecretsError("invalid_key", "The master key is not random; generate one with `agility-bridge init`.");
|
|
7627
|
-
return Object.freeze({
|
|
7628
|
-
version: m[1] === void 0 ? 1 : Number(m[1]),
|
|
7629
|
-
bytes
|
|
7630
|
-
});
|
|
7631
|
-
}
|
|
7632
|
-
/** A new master key, `v<version>:<base64>`, from the platform CSPRNG. */
|
|
7633
|
-
function generateMasterKey(version = 1) {
|
|
7634
|
-
if (!Number.isInteger(version) || version < 1 || version > 999) throw new SecretsError("invalid_key", "Key versions are 1-999.");
|
|
7635
|
-
return `v${version}:${toBase64(crypto.getRandomValues(new Uint8Array(KEY_BYTES)))}`;
|
|
7636
|
-
}
|
|
7637
|
-
const additionalData = (context, version) => new TextEncoder().encode(`${context}|${version}`);
|
|
7638
|
-
/**
|
|
7639
|
-
* The current master key and, during a rotation, the previous one. Purpose keys are derived once per version and kept
|
|
7640
|
-
* as non-extractable `CryptoKey`s (per process, never request data).
|
|
7641
|
-
*/
|
|
7642
|
-
var Keyring = class Keyring {
|
|
7643
|
-
currentVersion;
|
|
7644
|
-
versions;
|
|
7645
|
-
#roots;
|
|
7646
|
-
#derived = /* @__PURE__ */ new Map();
|
|
7647
|
-
constructor(currentVersion, roots) {
|
|
7648
|
-
this.currentVersion = currentVersion;
|
|
7649
|
-
this.#roots = roots;
|
|
7650
|
-
this.versions = Object.freeze([...roots.keys()].sort((a, b) => a - b));
|
|
7651
|
-
}
|
|
7652
|
-
/** Builds a keyring from the current master key and, during a rotation, the previous one (a lower version). */
|
|
7653
|
-
static async create(current, previous) {
|
|
7654
|
-
const cur = parseMasterKey(current);
|
|
7655
|
-
const roots = /* @__PURE__ */ new Map();
|
|
7656
|
-
const importRoot = (bytes) => crypto.subtle.importKey("raw", bytes, "HKDF", false, ["deriveKey"]);
|
|
7657
|
-
roots.set(cur.version, await importRoot(cur.bytes));
|
|
7658
|
-
if (previous !== void 0) {
|
|
7659
|
-
const prev = parseMasterKey(previous);
|
|
7660
|
-
if (prev.version >= cur.version) throw new SecretsError("invalid_key", `The previous master key (version ${prev.version}) must have a lower version than the current one (version ${cur.version}).`);
|
|
7661
|
-
if (prev.bytes.every((b, i) => b === cur.bytes[i])) throw new SecretsError("invalid_key", "The previous and current master keys are the same key.");
|
|
7662
|
-
roots.set(prev.version, await importRoot(prev.bytes));
|
|
7559
|
+
const mineCredential = (c) => c.ownerUserId !== null && c.ownerUserId === self;
|
|
7560
|
+
const credentials = {
|
|
7561
|
+
async list(filter = {}) {
|
|
7562
|
+
if (isManager(actor)) return storage.credentials.list(filter);
|
|
7563
|
+
if (self === null) return [];
|
|
7564
|
+
return storage.credentials.list({
|
|
7565
|
+
...filter,
|
|
7566
|
+
ownerUserId: self
|
|
7567
|
+
});
|
|
7568
|
+
},
|
|
7569
|
+
async get(id) {
|
|
7570
|
+
return visible(await storage.credentials.get(id), mineCredential);
|
|
7571
|
+
},
|
|
7572
|
+
/** Shared: owners and admins. Personal: only the user themselves (nobody stores a token for someone else). */
|
|
7573
|
+
async create(input) {
|
|
7574
|
+
if (input.ownerUserId === null) needManager(actor);
|
|
7575
|
+
else need(input.ownerUserId === self, "Only the user themselves can store their personal Agility token.");
|
|
7576
|
+
return services.credentials.create(input);
|
|
7577
|
+
},
|
|
7578
|
+
async replaceToken(id, token) {
|
|
7579
|
+
const c = await load(storage.credentials.get(id), "credential");
|
|
7580
|
+
if (c.ownerUserId === null) needManager(actor);
|
|
7581
|
+
else if (!mineCredential(c)) throw new NotFoundError("No credential with this id.");
|
|
7582
|
+
return services.credentials.replaceToken(id, token);
|
|
7583
|
+
},
|
|
7584
|
+
async update(id, patch) {
|
|
7585
|
+
const c = await load(storage.credentials.get(id), "credential");
|
|
7586
|
+
if (!mineCredential(c) && !isManager(actor)) throw new NotFoundError("No credential with this id.");
|
|
7587
|
+
return services.credentials.update(id, patch);
|
|
7588
|
+
},
|
|
7589
|
+
async validate(id) {
|
|
7590
|
+
const c = await load(storage.credentials.get(id), "credential");
|
|
7591
|
+
if (!mineCredential(c) && !isManager(actor)) throw new NotFoundError("No credential with this id.");
|
|
7592
|
+
return services.credentials.validate(id);
|
|
7593
|
+
},
|
|
7594
|
+
async revoke(id) {
|
|
7595
|
+
const c = await load(storage.credentials.get(id), "credential");
|
|
7596
|
+
if (!mineCredential(c)) {
|
|
7597
|
+
if (!isManager(actor)) throw new NotFoundError("No credential with this id.");
|
|
7598
|
+
}
|
|
7599
|
+
return services.credentials.revoke(id);
|
|
7600
|
+
},
|
|
7601
|
+
async delete(id) {
|
|
7602
|
+
needManager(actor);
|
|
7603
|
+
await storage.credentials.delete(id);
|
|
7663
7604
|
}
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7680
|
-
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
|
|
7684
|
-
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
7690
|
-
|
|
7605
|
+
};
|
|
7606
|
+
const checkWorkspaceWrites = (input) => {
|
|
7607
|
+
if (input.writePolicy !== void 0) needOwner(actor, "change write settings");
|
|
7608
|
+
if (input.credentialSource === "passthrough") needOwner(actor, "make a workspace pass-through");
|
|
7609
|
+
if (writeTools(input.enabledTools).length > 0) needOwner(actor, "enable write tools");
|
|
7610
|
+
};
|
|
7611
|
+
const workspaces = {
|
|
7612
|
+
async list() {
|
|
7613
|
+
needManager(actor);
|
|
7614
|
+
return storage.workspaces.list();
|
|
7615
|
+
},
|
|
7616
|
+
async get(id) {
|
|
7617
|
+
needManager(actor);
|
|
7618
|
+
return storage.workspaces.get(id);
|
|
7619
|
+
},
|
|
7620
|
+
async create(input) {
|
|
7621
|
+
needManager(actor);
|
|
7622
|
+
checkWorkspaceWrites(input);
|
|
7623
|
+
return services.workspaces.create(input);
|
|
7624
|
+
},
|
|
7625
|
+
async update(id, patch) {
|
|
7626
|
+
needManager(actor);
|
|
7627
|
+
checkWorkspaceWrites(patch);
|
|
7628
|
+
return services.workspaces.update(id, patch);
|
|
7629
|
+
},
|
|
7630
|
+
async disable(id) {
|
|
7631
|
+
needManager(actor);
|
|
7632
|
+
return services.workspaces.disable(id);
|
|
7633
|
+
},
|
|
7634
|
+
async delete(id) {
|
|
7635
|
+
needManager(actor);
|
|
7636
|
+
await services.workspaces.delete(id);
|
|
7691
7637
|
}
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
},
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
}
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
7726
|
-
} catch (error) {
|
|
7727
|
-
throw new SecretsError("decrypt_failed", "The sealed value could not be decrypted with this master key.", { cause: error });
|
|
7638
|
+
};
|
|
7639
|
+
const mineGrant = (g) => g.userId !== null && g.userId === self;
|
|
7640
|
+
const grants = {
|
|
7641
|
+
async list(filter = {}) {
|
|
7642
|
+
if (isManager(actor)) return storage.grants.list(filter);
|
|
7643
|
+
if (self === null) return [];
|
|
7644
|
+
return storage.grants.list({
|
|
7645
|
+
...filter,
|
|
7646
|
+
userId: self
|
|
7647
|
+
});
|
|
7648
|
+
},
|
|
7649
|
+
async get(id) {
|
|
7650
|
+
return visible(await storage.grants.get(id), mineGrant);
|
|
7651
|
+
},
|
|
7652
|
+
async create(input) {
|
|
7653
|
+
needManager(actor);
|
|
7654
|
+
if (writeTools(input.tools).length > 0) needOwner(actor, "grant write tools");
|
|
7655
|
+
return services.grants.create(input);
|
|
7656
|
+
},
|
|
7657
|
+
async update(id, patch) {
|
|
7658
|
+
needManager(actor);
|
|
7659
|
+
if (writeTools(patch.tools).length > 0) needOwner(actor, "grant write tools");
|
|
7660
|
+
const current = await load(storage.grants.get(id), "grant");
|
|
7661
|
+
if (patch.credentialId !== void 0 && patch.credentialId !== current.credentialId) await needCredentialOwner(patch.credentialId, "put a grant on someone's personal Agility token");
|
|
7662
|
+
return services.grants.update(id, patch);
|
|
7663
|
+
},
|
|
7664
|
+
async revoke(id) {
|
|
7665
|
+
needManager(actor);
|
|
7666
|
+
return services.grants.revoke(id);
|
|
7667
|
+
},
|
|
7668
|
+
async delete(id) {
|
|
7669
|
+
needManager(actor);
|
|
7670
|
+
await services.grants.delete(id);
|
|
7728
7671
|
}
|
|
7672
|
+
};
|
|
7673
|
+
/** Members manage the tokens of their own grants; owners and admins every token. */
|
|
7674
|
+
async function ownGrant(grantId) {
|
|
7675
|
+
const g = await load(storage.grants.get(grantId), "grant");
|
|
7676
|
+
if (!isManager(actor) && !mineGrant(g)) throw new NotFoundError("No grant with this id.");
|
|
7677
|
+
return g;
|
|
7729
7678
|
}
|
|
7730
|
-
/**
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
return this.mac(KeyPurpose.KeyCheck, "agility-bridge key check", version);
|
|
7679
|
+
/**
|
|
7680
|
+
* A device token on a grant that uses someone's personal Agility token acts as that person in Agility: only they
|
|
7681
|
+
* may issue one (P2-17 review M2). Grants on shared credentials stay with owners and admins.
|
|
7682
|
+
*/
|
|
7683
|
+
async function needCredentialOwner(credentialId, what) {
|
|
7684
|
+
if (credentialId === null) return;
|
|
7685
|
+
const c = await storage.credentials.get(credentialId);
|
|
7686
|
+
if (c !== null && c.ownerUserId !== null && c.ownerUserId !== self) throw new ForbiddenError(`Only the owner of the personal Agility token can ${what}.`);
|
|
7739
7687
|
}
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7688
|
+
return Object.freeze({
|
|
7689
|
+
actor,
|
|
7690
|
+
users,
|
|
7691
|
+
instances,
|
|
7692
|
+
credentials,
|
|
7693
|
+
workspaces,
|
|
7694
|
+
grants,
|
|
7695
|
+
tokens: {
|
|
7696
|
+
async list(grantId) {
|
|
7697
|
+
await ownGrant(grantId);
|
|
7698
|
+
return services.tokens.list(grantId);
|
|
7699
|
+
},
|
|
7700
|
+
async issue(input) {
|
|
7701
|
+
await needCredentialOwner((await ownGrant(input.grantId)).credentialId, "issue device tokens on this grant");
|
|
7702
|
+
return services.tokens.issue(input);
|
|
7703
|
+
},
|
|
7704
|
+
async revoke(id) {
|
|
7705
|
+
await ownGrant((await load(storage.accessTokens.get(id), "token")).grantId);
|
|
7706
|
+
return services.tokens.revoke(id);
|
|
7707
|
+
}
|
|
7708
|
+
},
|
|
7709
|
+
keys: {
|
|
7710
|
+
async status() {
|
|
7711
|
+
needOwner(actor, "see the master-key versions");
|
|
7712
|
+
if (ctx.keyring === void 0) throw new ForbiddenError("Master-key operations are not available here.");
|
|
7713
|
+
return keyStatus(storage, ctx.keyring);
|
|
7714
|
+
},
|
|
7715
|
+
async retire(version, reason) {
|
|
7716
|
+
needOwner(actor, "retire a master-key version");
|
|
7717
|
+
need(actor.kind === "break_glass" || actor.recentAuth, "Sign in again before retiring a master-key version.");
|
|
7718
|
+
if (ctx.keyring === void 0) throw new ForbiddenError("Master-key operations are not available here.");
|
|
7719
|
+
await retireKeyVersion(storage, ctx.keyring, version, {
|
|
7720
|
+
reason,
|
|
7721
|
+
record: false
|
|
7722
|
+
});
|
|
7723
|
+
return keyStatus(storage, ctx.keyring);
|
|
7724
|
+
}
|
|
7725
|
+
}
|
|
7726
|
+
});
|
|
7747
7727
|
}
|
|
7748
7728
|
//#endregion
|
|
7749
|
-
//#region ../../packages/auth/src/
|
|
7750
|
-
const
|
|
7751
|
-
const
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
return {
|
|
7761
|
-
tokenCiphertext: sealed.ciphertext,
|
|
7762
|
-
tokenIv: sealed.iv,
|
|
7763
|
-
keyVersion: sealed.keyVersion,
|
|
7764
|
-
tokenHint: tokenHint(token)
|
|
7765
|
-
};
|
|
7766
|
-
}
|
|
7767
|
-
const toSealed = (c) => ({
|
|
7768
|
-
ciphertext: c.tokenCiphertext,
|
|
7769
|
-
iv: c.tokenIv,
|
|
7770
|
-
keyVersion: c.keyVersion
|
|
7729
|
+
//#region ../../packages/auth/src/access/policy.ts
|
|
7730
|
+
const SCOPE_OID = /^Scope:\d{1,12}$/;
|
|
7731
|
+
const TYPE_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
7732
|
+
const TOOL_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
|
|
7733
|
+
const scopeIds = z.array(z.string().regex(SCOPE_OID)).max(1e3);
|
|
7734
|
+
const toolNames = z.array(z.string().regex(TOOL_NAME).max(64)).max(200);
|
|
7735
|
+
const typeNames = z.array(z.string().regex(TYPE_NAME)).max(500);
|
|
7736
|
+
const assetTypePolicySchema = z.strictObject({
|
|
7737
|
+
/** Only these types (absent = every type the instance exposes). */
|
|
7738
|
+
allow: typeNames.optional(),
|
|
7739
|
+
deny: typeNames.optional()
|
|
7771
7740
|
});
|
|
7772
|
-
/**
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7741
|
+
/** Numeric limits with their hard bounds, and feature switches (tool conventions, ADR-0010). */
|
|
7742
|
+
const limitsPolicySchema = z.strictObject({
|
|
7743
|
+
defaultRows: z.number().int().min(1).max(1e3).optional(),
|
|
7744
|
+
maxRows: z.number().int().min(1).max(1e3).optional(),
|
|
7745
|
+
maxFields: z.number().int().min(1).max(200).optional(),
|
|
7746
|
+
maxNestingDepth: z.number().int().min(0).max(5).optional(),
|
|
7747
|
+
maxAggregations: z.number().int().min(0).max(50).optional(),
|
|
7748
|
+
maxAnyOfGroups: z.number().int().min(0).max(20).optional(),
|
|
7749
|
+
maxResponseBytes: z.number().int().min(65536).max(67108864).optional(),
|
|
7750
|
+
maxAsOfAgeDays: z.number().int().min(0).max(3650).optional(),
|
|
7751
|
+
maxTimeRangeDays: z.number().int().min(1).max(3660).optional(),
|
|
7752
|
+
/** Agility requests per tool call (ADR-0010). */
|
|
7753
|
+
callBudget: z.number().int().min(1).max(1e4).optional(),
|
|
7754
|
+
/** Member emails in output (D-06). */
|
|
7755
|
+
memberEmails: z.boolean().optional(),
|
|
7756
|
+
/** The generic `query_assets` / `get_assets` tools. */
|
|
7757
|
+
genericQuery: z.boolean().optional()
|
|
7758
|
+
});
|
|
7759
|
+
/** Writes per workspace (ADR-0013); write tools also need the server switch and an explicit listing (Phase 7). */
|
|
7760
|
+
const writePolicySchema = z.strictObject({ enabled: z.boolean().optional() });
|
|
7761
|
+
/** Per-grant rate limit (P2-07). */
|
|
7762
|
+
const rateLimitPolicySchema = z.strictObject({ perMinute: z.number().int().min(1).max(1e5).optional() });
|
|
7763
|
+
const workspacePolicySchema = z.strictObject({
|
|
7764
|
+
allowedScopeIds: scopeIds.min(1),
|
|
7765
|
+
includeChildScopes: z.boolean(),
|
|
7766
|
+
assetTypes: assetTypePolicySchema.nullable(),
|
|
7767
|
+
enabledTools: toolNames.nullable(),
|
|
7768
|
+
limits: limitsPolicySchema,
|
|
7769
|
+
writePolicy: writePolicySchema
|
|
7770
|
+
});
|
|
7771
|
+
const grantPolicySchema = z.strictObject({
|
|
7772
|
+
tools: toolNames.nullable(),
|
|
7773
|
+
scopeIds: scopeIds.min(1).nullable(),
|
|
7774
|
+
assetTypes: assetTypePolicySchema.nullable(),
|
|
7775
|
+
limits: limitsPolicySchema,
|
|
7776
|
+
rateLimit: rateLimitPolicySchema.nullable()
|
|
7777
|
+
});
|
|
7778
|
+
/** The numbers and switches of `limitsPolicySchema` and how a grant tightens each. */
|
|
7779
|
+
const NUMBER_LIMITS = [
|
|
7780
|
+
"defaultRows",
|
|
7781
|
+
"maxRows",
|
|
7782
|
+
"maxFields",
|
|
7783
|
+
"maxNestingDepth",
|
|
7784
|
+
"maxAggregations",
|
|
7785
|
+
"maxAnyOfGroups",
|
|
7786
|
+
"maxResponseBytes",
|
|
7787
|
+
"maxAsOfAgeDays",
|
|
7788
|
+
"maxTimeRangeDays",
|
|
7789
|
+
"callBudget"
|
|
7790
|
+
];
|
|
7791
|
+
const SWITCHES = ["memberEmails", "genericQuery"];
|
|
7776
7792
|
/**
|
|
7777
|
-
*
|
|
7778
|
-
*
|
|
7779
|
-
* other key material. The current version must not be retired.
|
|
7793
|
+
* Workspace limits over the deployment defaults (capped by its ceilings), tightened by the grant: numbers take the
|
|
7794
|
+
* minimum, switches stay on only when both allow them. `warnings` name grant values that tried to loosen a limit.
|
|
7780
7795
|
*/
|
|
7781
|
-
|
|
7796
|
+
function effectiveLimits(deployment, workspace, grant) {
|
|
7782
7797
|
const warnings = [];
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7798
|
+
const out = { ...deployment.defaults };
|
|
7799
|
+
for (const key of NUMBER_LIMITS) {
|
|
7800
|
+
let value = workspace[key] ?? deployment.defaults[key];
|
|
7801
|
+
const ceiling = deployment.ceilings[key];
|
|
7802
|
+
if (ceiling !== void 0) value = Math.min(value, ceiling);
|
|
7803
|
+
const g = grant[key];
|
|
7804
|
+
if (g !== void 0) {
|
|
7805
|
+
if (g > value) warnings.push(`The grant's ${key} (${g}) is above the workspace's (${value}); ${value} applies.`);
|
|
7806
|
+
value = Math.min(value, g);
|
|
7788
7807
|
}
|
|
7808
|
+
out[key] = value;
|
|
7789
7809
|
}
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
}
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
const out = [];
|
|
7802
|
-
for (const [version, credentials] of [...counts].sort((a, b) => a[0] - b[0])) out.push({
|
|
7803
|
-
version,
|
|
7804
|
-
credentials,
|
|
7805
|
-
loaded: keyring.has(version),
|
|
7806
|
-
current: version === keyring.currentVersion,
|
|
7807
|
-
retired: await storage.kv.get(RETIRED_KEY(version)) !== null
|
|
7808
|
-
});
|
|
7809
|
-
return out;
|
|
7810
|
+
for (const key of SWITCHES) {
|
|
7811
|
+
const ws = workspace[key] ?? deployment.defaults[key];
|
|
7812
|
+
const g = grant[key];
|
|
7813
|
+
if (g === true && !ws) warnings.push(`The grant turns ${key} on, but the workspace has it off.`);
|
|
7814
|
+
out[key] = ws && g !== false;
|
|
7815
|
+
}
|
|
7816
|
+
out["defaultRows"] = Math.min(out["defaultRows"], out["maxRows"]);
|
|
7817
|
+
return {
|
|
7818
|
+
limits: Object.freeze(out),
|
|
7819
|
+
warnings
|
|
7820
|
+
};
|
|
7810
7821
|
}
|
|
7811
|
-
/**
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
|
|
7816
|
-
|
|
7817
|
-
|
|
7818
|
-
|
|
7819
|
-
|
|
7820
|
-
const now = options.now ?? Date.now;
|
|
7821
|
-
const result = {
|
|
7822
|
-
rotated: 0,
|
|
7823
|
-
skipped: 0,
|
|
7824
|
-
failed: [],
|
|
7825
|
-
remaining: 0
|
|
7822
|
+
/** Allow lists intersect (absent = no restriction), deny lists union. */
|
|
7823
|
+
function effectiveAssetTypes(workspace, grant) {
|
|
7824
|
+
const wa = workspace?.allow;
|
|
7825
|
+
const ga = grant?.allow;
|
|
7826
|
+
const allow = wa === void 0 ? ga : ga === void 0 ? wa : wa.filter((t) => ga.includes(t));
|
|
7827
|
+
const deny = [.../* @__PURE__ */ new Set([...workspace?.deny ?? [], ...grant?.deny ?? []])];
|
|
7828
|
+
return allow === void 0 ? { deny } : {
|
|
7829
|
+
allow: [...new Set(allow)],
|
|
7830
|
+
deny
|
|
7826
7831
|
};
|
|
7827
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7832
|
+
}
|
|
7833
|
+
//#endregion
|
|
7834
|
+
//#region ../../packages/auth/src/access/resolve.ts
|
|
7835
|
+
const deny = (reason) => ({
|
|
7836
|
+
ok: false,
|
|
7837
|
+
reason
|
|
7838
|
+
});
|
|
7839
|
+
const expired = (at, now) => at !== null && at <= now;
|
|
7840
|
+
function parseWorkspace(w) {
|
|
7841
|
+
const r = workspacePolicySchema.safeParse({
|
|
7842
|
+
allowedScopeIds: w.allowedScopeIds,
|
|
7843
|
+
includeChildScopes: w.includeChildScopes,
|
|
7844
|
+
assetTypes: w.assetTypes,
|
|
7845
|
+
enabledTools: w.enabledTools,
|
|
7846
|
+
limits: w.limits,
|
|
7847
|
+
writePolicy: w.writePolicy
|
|
7848
|
+
});
|
|
7849
|
+
return r.success ? r.data : null;
|
|
7850
|
+
}
|
|
7851
|
+
function parseGrant(g) {
|
|
7852
|
+
const r = grantPolicySchema.safeParse({
|
|
7853
|
+
tools: g.tools,
|
|
7854
|
+
scopeIds: g.scopeIds,
|
|
7855
|
+
assetTypes: g.assetTypes,
|
|
7856
|
+
limits: g.limits,
|
|
7857
|
+
rateLimit: g.rateLimit
|
|
7858
|
+
});
|
|
7859
|
+
return r.success ? r.data : null;
|
|
7860
|
+
}
|
|
7861
|
+
/**
|
|
7862
|
+
* Decides what one request may do in the workspace `slug`. Pure: the same records, settings and time give the same
|
|
7863
|
+
* answer, so it can be cached (≤ 60 s, never past `expiresAt`, L-068) and tested exhaustively.
|
|
7864
|
+
*/
|
|
7865
|
+
function resolveAccess(principal, slug, records, settings, now) {
|
|
7866
|
+
const { grant, workspace, instance, credential, user } = records;
|
|
7867
|
+
if (workspace === null) return deny("workspace_missing");
|
|
7868
|
+
if (workspace.slug !== slug) return deny("wrong_workspace");
|
|
7869
|
+
if (principal.kind === "oauth" && principal.workspaceId !== workspace.id) return deny("wrong_workspace");
|
|
7870
|
+
if (principal.kind === "device_token") {
|
|
7871
|
+
if (principal.token.revokedAt !== null) return deny("token_revoked");
|
|
7872
|
+
if (expired(principal.token.expiresAt, now)) return deny("token_expired");
|
|
7873
|
+
} else if (expired(principal.expiresAt, now)) return deny("token_expired");
|
|
7874
|
+
if (grant === null) return deny("grant_missing");
|
|
7875
|
+
const grantId = principal.kind === "device_token" ? principal.token.grantId : principal.grantId;
|
|
7876
|
+
if (grant.id !== grantId || grant.workspaceId !== workspace.id) return deny("grant_missing");
|
|
7877
|
+
if (grant.revokedAt !== null) return deny("grant_revoked");
|
|
7878
|
+
if (expired(grant.expiresAt, now)) return deny("grant_expired");
|
|
7879
|
+
if (workspace.disabledAt !== null) return deny("workspace_disabled");
|
|
7880
|
+
if (instance === null || instance.id !== workspace.instanceId) return deny("instance_missing");
|
|
7881
|
+
if (instance.disabledAt !== null) return deny("instance_disabled");
|
|
7882
|
+
if (grant.userId !== null) {
|
|
7883
|
+
if (user === null || user.id !== grant.userId) return deny("user_missing");
|
|
7884
|
+
if (user.disabledAt !== null) return deny("user_disabled");
|
|
7885
|
+
}
|
|
7886
|
+
const credentialId = grant.credentialId ?? workspace.defaultCredentialId;
|
|
7887
|
+
if (credentialId === null || credential === null || credential.id !== credentialId) return deny("credential_missing");
|
|
7888
|
+
if (credential.revokedAt !== null) return deny("credential_revoked");
|
|
7889
|
+
if (credential.validationStatus !== "valid") return deny("credential_unvalidated");
|
|
7890
|
+
if (credential.instanceId !== workspace.instanceId) return deny("credential_mismatch");
|
|
7891
|
+
const kind = credential.ownerUserId === null ? "shared" : "personal";
|
|
7892
|
+
if (kind === "personal" && (grant.userId === null || credential.ownerUserId !== grant.userId)) return deny("credential_mismatch");
|
|
7893
|
+
if (grant.credentialId === null && kind !== "shared") return deny("credential_mismatch");
|
|
7894
|
+
const ws = parseWorkspace(workspace);
|
|
7895
|
+
const gp = parseGrant(grant);
|
|
7896
|
+
if (ws === null || gp === null) return deny("malformed_policy");
|
|
7897
|
+
const warnings = [];
|
|
7898
|
+
let projects = ws.allowedScopeIds;
|
|
7899
|
+
if (gp.scopeIds !== null) {
|
|
7900
|
+
const outside = gp.scopeIds.filter((s) => !ws.allowedScopeIds.includes(s));
|
|
7901
|
+
if (outside.length > 0) warnings.push(`The grant names projects outside the workspace: ${outside.join(", ")}.`);
|
|
7902
|
+
projects = ws.allowedScopeIds.filter((s) => gp.scopeIds.includes(s));
|
|
7903
|
+
}
|
|
7904
|
+
if (credential.reachableScopeIds !== null) {
|
|
7905
|
+
const unreachable = projects.filter((s) => !credential.reachableScopeIds.includes(s));
|
|
7906
|
+
if (unreachable.length > 0) warnings.push(`The credential's member could not see ${unreachable.join(", ")} when it was last validated.`);
|
|
7907
|
+
}
|
|
7908
|
+
if (projects.length === 0) return deny("no_projects");
|
|
7909
|
+
const { limits, warnings: limitWarnings } = effectiveLimits(settings.limits, ws.limits, gp.limits);
|
|
7910
|
+
warnings.push(...limitWarnings);
|
|
7911
|
+
const known = settings.tools;
|
|
7912
|
+
const workspaceTools = ws.enabledTools ?? [...known.keys()].filter((n) => known.get(n)?.kind === "read");
|
|
7913
|
+
const unknown = [...ws.enabledTools ?? [], ...gp.tools ?? []].filter((n) => !known.has(n));
|
|
7914
|
+
if (unknown.length > 0) warnings.push(`Unknown tools ignored: ${[...new Set(unknown)].join(", ")}.`);
|
|
7915
|
+
const notEnabled = (gp.tools ?? []).filter((n) => known.has(n) && !workspaceTools.includes(n));
|
|
7916
|
+
if (notEnabled.length > 0) warnings.push(`The grant names tools the workspace does not enable: ${notEnabled.join(", ")}.`);
|
|
7917
|
+
const writesPossible = settings.writesEnabled && ws.writePolicy.enabled === true && kind === "personal" && (principal.kind === "device_token" || principal.writeScope);
|
|
7918
|
+
const tools = [];
|
|
7919
|
+
const writeTools = [];
|
|
7920
|
+
for (const name of workspaceTools) {
|
|
7921
|
+
const info = known.get(name);
|
|
7922
|
+
if (info === void 0) continue;
|
|
7923
|
+
if (gp.tools !== null && !gp.tools.includes(name)) continue;
|
|
7924
|
+
if (!limits.genericQuery && info.group === "query") continue;
|
|
7925
|
+
if (info.kind !== "read") {
|
|
7926
|
+
if (!(ws.enabledTools?.includes(name) === true && gp.tools?.includes(name) === true) || !writesPossible) {
|
|
7927
|
+
if (gp.tools?.includes(name)) warnings.push(kind === "shared" ? `${name} needs the user's own credential: shared credentials are read-only.` : `${name} is a write tool and writes are not enabled for this request.`);
|
|
7867
7928
|
continue;
|
|
7868
7929
|
}
|
|
7869
|
-
|
|
7870
|
-
const next = {
|
|
7871
|
-
tokenCiphertext: sealed.tokenCiphertext,
|
|
7872
|
-
tokenIv: sealed.tokenIv,
|
|
7873
|
-
keyVersion: sealed.keyVersion
|
|
7874
|
-
};
|
|
7875
|
-
if (await storage.credentials.replaceSecret(c.id, expected, next)) {
|
|
7876
|
-
result.rotated++;
|
|
7877
|
-
await storage.audit.append({
|
|
7878
|
-
id: crypto.randomUUID(),
|
|
7879
|
-
ts: now(),
|
|
7880
|
-
kind: "admin",
|
|
7881
|
-
action: "credential.rekey",
|
|
7882
|
-
status: "ok",
|
|
7883
|
-
userId: options.actor?.userId ?? null,
|
|
7884
|
-
instanceId: c.instanceId,
|
|
7885
|
-
targetType: "credential",
|
|
7886
|
-
targetId: c.id,
|
|
7887
|
-
details: details(c.keyVersion)
|
|
7888
|
-
});
|
|
7889
|
-
} else {
|
|
7890
|
-
doneWith.add(c.id);
|
|
7891
|
-
result.skipped++;
|
|
7892
|
-
}
|
|
7930
|
+
writeTools.push(name);
|
|
7893
7931
|
}
|
|
7932
|
+
tools.push(name);
|
|
7894
7933
|
}
|
|
7895
|
-
|
|
7896
|
-
|
|
7934
|
+
if (tools.length === 0) return deny("no_tools");
|
|
7935
|
+
const ends = [principal.kind === "device_token" ? principal.token.expiresAt : principal.expiresAt, grant.expiresAt].filter((t) => t !== null);
|
|
7936
|
+
const assetTypes = effectiveAssetTypes(ws.assetTypes, gp.assetTypes);
|
|
7937
|
+
return {
|
|
7938
|
+
ok: true,
|
|
7939
|
+
access: Object.freeze({
|
|
7940
|
+
workspace: {
|
|
7941
|
+
id: workspace.id,
|
|
7942
|
+
slug: workspace.slug,
|
|
7943
|
+
name: workspace.name
|
|
7944
|
+
},
|
|
7945
|
+
instance: {
|
|
7946
|
+
id: instance.id,
|
|
7947
|
+
baseUrl: instance.baseUrl,
|
|
7948
|
+
tls: instance.tls
|
|
7949
|
+
},
|
|
7950
|
+
grant: {
|
|
7951
|
+
id: grant.id,
|
|
7952
|
+
userId: grant.userId,
|
|
7953
|
+
label: grant.label
|
|
7954
|
+
},
|
|
7955
|
+
tokenId: principal.kind === "device_token" ? principal.token.id : null,
|
|
7956
|
+
credential: {
|
|
7957
|
+
id: credential.id,
|
|
7958
|
+
kind,
|
|
7959
|
+
sealed: {
|
|
7960
|
+
tokenCiphertext: credential.tokenCiphertext,
|
|
7961
|
+
tokenIv: credential.tokenIv,
|
|
7962
|
+
keyVersion: credential.keyVersion
|
|
7963
|
+
}
|
|
7964
|
+
},
|
|
7965
|
+
tools: Object.freeze(tools),
|
|
7966
|
+
writeTools: Object.freeze(writeTools),
|
|
7967
|
+
projects: {
|
|
7968
|
+
allowed: Object.freeze([...projects]),
|
|
7969
|
+
includeChildren: ws.includeChildScopes
|
|
7970
|
+
},
|
|
7971
|
+
assetTypes,
|
|
7972
|
+
limits,
|
|
7973
|
+
rateLimit: gp.rateLimit,
|
|
7974
|
+
expiresAt: ends.length === 0 ? null : Math.min(...ends),
|
|
7975
|
+
warnings: Object.freeze(warnings)
|
|
7976
|
+
})
|
|
7977
|
+
};
|
|
7897
7978
|
}
|
|
7898
7979
|
/**
|
|
7899
|
-
*
|
|
7900
|
-
*
|
|
7980
|
+
* Access on a pass-through workspace (P2-14, D-04): the caller authenticated with its own Agility token, already
|
|
7981
|
+
* validated against the workspace's instance and known here only by its HMAC fingerprint. Every workspace control
|
|
7982
|
+
* applies unchanged (projects, asset types, tools, limits); there is no grant, so nothing can widen or narrow them, and
|
|
7983
|
+
* write tools are never granted (read-only in 1.0).
|
|
7901
7984
|
*/
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
if (
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
targetId: String(version),
|
|
7917
|
-
details: {
|
|
7918
|
-
reason: String(options.reason ?? "").slice(0, 200),
|
|
7919
|
-
...options.actor?.via !== void 0 ? { via: options.actor.via } : {}
|
|
7920
|
-
}
|
|
7985
|
+
function resolvePassthroughAccess(slug, records, fingerprint, settings) {
|
|
7986
|
+
const { workspace, instance } = records;
|
|
7987
|
+
if (workspace === null || workspace.slug !== slug) return deny("wrong_workspace");
|
|
7988
|
+
if (workspace.credentialSource !== "passthrough") return deny("not_passthrough");
|
|
7989
|
+
if (workspace.disabledAt !== null) return deny("workspace_disabled");
|
|
7990
|
+
if (instance === null || instance.id !== workspace.instanceId) return deny("instance_missing");
|
|
7991
|
+
if (instance.disabledAt !== null) return deny("instance_disabled");
|
|
7992
|
+
const ws = parseWorkspace(workspace);
|
|
7993
|
+
if (ws === null) return deny("malformed_policy");
|
|
7994
|
+
const { limits } = effectiveLimits(settings.limits, ws.limits, {});
|
|
7995
|
+
const known = settings.tools;
|
|
7996
|
+
const tools = (ws.enabledTools ?? [...known.keys()]).filter((name) => {
|
|
7997
|
+
const info = known.get(name);
|
|
7998
|
+
return info !== void 0 && info.kind === "read" && (limits.genericQuery || info.group !== "query");
|
|
7921
7999
|
});
|
|
8000
|
+
if (tools.length === 0) return deny("no_tools");
|
|
8001
|
+
const id = fingerprint.slice(0, 32);
|
|
8002
|
+
return {
|
|
8003
|
+
ok: true,
|
|
8004
|
+
access: Object.freeze({
|
|
8005
|
+
workspace: {
|
|
8006
|
+
id: workspace.id,
|
|
8007
|
+
slug: workspace.slug,
|
|
8008
|
+
name: workspace.name
|
|
8009
|
+
},
|
|
8010
|
+
instance: {
|
|
8011
|
+
id: instance.id,
|
|
8012
|
+
baseUrl: instance.baseUrl,
|
|
8013
|
+
tls: instance.tls
|
|
8014
|
+
},
|
|
8015
|
+
grant: {
|
|
8016
|
+
id: `passthrough:${id}`,
|
|
8017
|
+
userId: null,
|
|
8018
|
+
label: "pass-through"
|
|
8019
|
+
},
|
|
8020
|
+
tokenId: `pt:${id}`,
|
|
8021
|
+
credential: {
|
|
8022
|
+
id: `pt:${id}`,
|
|
8023
|
+
kind: "personal",
|
|
8024
|
+
sealed: null
|
|
8025
|
+
},
|
|
8026
|
+
tools: Object.freeze(tools),
|
|
8027
|
+
writeTools: Object.freeze([]),
|
|
8028
|
+
projects: {
|
|
8029
|
+
allowed: Object.freeze([...ws.allowedScopeIds]),
|
|
8030
|
+
includeChildren: ws.includeChildScopes
|
|
8031
|
+
},
|
|
8032
|
+
assetTypes: effectiveAssetTypes(ws.assetTypes, null),
|
|
8033
|
+
limits,
|
|
8034
|
+
rateLimit: null,
|
|
8035
|
+
expiresAt: null,
|
|
8036
|
+
warnings: Object.freeze([])
|
|
8037
|
+
})
|
|
8038
|
+
};
|
|
7922
8039
|
}
|
|
7923
8040
|
//#endregion
|
|
7924
8041
|
//#region ../../packages/auth/src/tokens/device.ts
|
|
@@ -8010,7 +8127,7 @@ async function authenticateDeviceToken(storage, presented, slug, settings, now)
|
|
|
8010
8127
|
reason: "token_malformed"
|
|
8011
8128
|
};
|
|
8012
8129
|
const hash = await hashDeviceToken(presented);
|
|
8013
|
-
const token = await storage.accessTokens.
|
|
8130
|
+
const { token, ...records } = await storage.accessTokens.bundleByHash(hash);
|
|
8014
8131
|
if (token === null || !timingSafeEqual(token.tokenHash, hash)) return {
|
|
8015
8132
|
ok: false,
|
|
8016
8133
|
reason: "token_unknown"
|
|
@@ -8018,7 +8135,7 @@ async function authenticateDeviceToken(storage, presented, slug, settings, now)
|
|
|
8018
8135
|
const decision = resolveAccess({
|
|
8019
8136
|
kind: "device_token",
|
|
8020
8137
|
token
|
|
8021
|
-
}, slug,
|
|
8138
|
+
}, slug, records, settings, now);
|
|
8022
8139
|
if (!decision.ok) return decision;
|
|
8023
8140
|
try {
|
|
8024
8141
|
await storage.accessTokens.touch(token.id, TOUCH_INTERVAL_MS);
|
|
@@ -8292,30 +8409,37 @@ function accessServices(options) {
|
|
|
8292
8409
|
const token = await openCredentialToken(keyring, c);
|
|
8293
8410
|
return record(id, await options.validator.validate(instance, id, token));
|
|
8294
8411
|
}
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
});
|
|
8311
|
-
},
|
|
8312
|
-
disable: (id) => storage.users.update(id, { disabledAt: now() })
|
|
8412
|
+
const users = {
|
|
8413
|
+
async create(input) {
|
|
8414
|
+
const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : input.email;
|
|
8415
|
+
return storage.users.create({
|
|
8416
|
+
...input,
|
|
8417
|
+
id: newId("usr"),
|
|
8418
|
+
email
|
|
8419
|
+
});
|
|
8420
|
+
},
|
|
8421
|
+
async update(id, patch) {
|
|
8422
|
+
const email = typeof patch.email === "string" ? patch.email.trim().toLowerCase() : void 0;
|
|
8423
|
+
return storage.users.update(id, email === void 0 ? patch : {
|
|
8424
|
+
...patch,
|
|
8425
|
+
email
|
|
8426
|
+
});
|
|
8313
8427
|
},
|
|
8428
|
+
disable: (id) => storage.users.update(id, { disabledAt: now() })
|
|
8429
|
+
};
|
|
8430
|
+
/** Canonical, and allowed by this deployment's policy (P2-19). */
|
|
8431
|
+
const acceptedInstanceUrl = async (input) => {
|
|
8432
|
+
const url = canonicalInstanceUrl(input, { httpLoopback: options.instanceUrls?.httpLoopback ?? true });
|
|
8433
|
+
await options.instanceUrls?.checkHost?.(new URL(url).hostname);
|
|
8434
|
+
return url;
|
|
8435
|
+
};
|
|
8436
|
+
return Object.freeze({
|
|
8437
|
+
users,
|
|
8314
8438
|
instances: {
|
|
8315
8439
|
async create(input) {
|
|
8316
8440
|
let baseUrl;
|
|
8317
8441
|
try {
|
|
8318
|
-
baseUrl =
|
|
8442
|
+
baseUrl = await acceptedInstanceUrl(input.baseUrl);
|
|
8319
8443
|
} catch (error) {
|
|
8320
8444
|
throw new InvalidInputError(`baseUrl: ${error.message}`);
|
|
8321
8445
|
}
|
|
@@ -8352,7 +8476,7 @@ function accessServices(options) {
|
|
|
8352
8476
|
const current = await mustGet(storage.instances.get(id), "instance");
|
|
8353
8477
|
let baseUrl;
|
|
8354
8478
|
try {
|
|
8355
|
-
baseUrl =
|
|
8479
|
+
baseUrl = await acceptedInstanceUrl(target.baseUrl);
|
|
8356
8480
|
} catch (error) {
|
|
8357
8481
|
throw new InvalidInputError(`baseUrl: ${error.message}`);
|
|
8358
8482
|
}
|
|
@@ -8434,10 +8558,14 @@ function accessServices(options) {
|
|
|
8434
8558
|
* Revalidates every unrevoked credential last validated before `olderThan` (or never), one at a time. Returns how
|
|
8435
8559
|
* many were checked; failures are recorded on the credential, never thrown.
|
|
8436
8560
|
*/
|
|
8437
|
-
|
|
8561
|
+
/**
|
|
8562
|
+
* Revalidates live credentials not validated since `olderThan`, never-validated and oldest first, at most `limit`
|
|
8563
|
+
* (Workers revalidate one per scheduled run: each costs Agility requests, P3-08).
|
|
8564
|
+
*/
|
|
8565
|
+
async revalidate(olderThan, limit = Number.POSITIVE_INFINITY) {
|
|
8566
|
+
const due = (await storage.credentials.list()).filter((c) => c.revokedAt === null && (c.validatedAt === null || c.validatedAt < olderThan)).sort((a, b) => (a.validatedAt ?? -1) - (b.validatedAt ?? -1));
|
|
8438
8567
|
let n = 0;
|
|
8439
|
-
for (const c of
|
|
8440
|
-
if (c.revokedAt !== null || c.validatedAt !== null && c.validatedAt >= olderThan) continue;
|
|
8568
|
+
for (const c of due.slice(0, Math.max(0, limit))) {
|
|
8441
8569
|
await validateCredential(c.id).catch(() => void 0);
|
|
8442
8570
|
n++;
|
|
8443
8571
|
}
|
|
@@ -10317,6 +10445,9 @@ function toAdminError(error) {
|
|
|
10317
10445
|
} else if (error instanceof UpstreamCheckError) {
|
|
10318
10446
|
code = "upstream";
|
|
10319
10447
|
message = error.message;
|
|
10448
|
+
} else if (error instanceof SecretsError && error.code === "in_use") {
|
|
10449
|
+
code = "conflict";
|
|
10450
|
+
message = error.message;
|
|
10320
10451
|
} else if (error instanceof SecretsError) {
|
|
10321
10452
|
code = "unavailable";
|
|
10322
10453
|
message = "The server cannot use its master key for this.";
|
|
@@ -10563,13 +10694,19 @@ const ERROR_RESPONSES = Object.fromEntries([
|
|
|
10563
10694
|
description: "Error"
|
|
10564
10695
|
}]));
|
|
10565
10696
|
function createAdminApi(options) {
|
|
10566
|
-
const
|
|
10567
|
-
if (
|
|
10568
|
-
|
|
10569
|
-
|
|
10570
|
-
|
|
10697
|
+
const keep = (c, work) => {
|
|
10698
|
+
if (work !== void 0) options.background?.(c, work);
|
|
10699
|
+
};
|
|
10700
|
+
const api = new OpenAPIHono({
|
|
10701
|
+
...options.lightweightRouter === true ? { router: new LinearRouter() } : {},
|
|
10702
|
+
defaultHook: (result, c) => {
|
|
10703
|
+
if (!result.success) {
|
|
10704
|
+
const issue = result.error.issues[0];
|
|
10705
|
+
const field = issue?.path.map(String).join(".") || void 0;
|
|
10706
|
+
return c.json(errorBody("invalid_input", `${field ?? "request"}: ${issue?.message ?? "invalid"}.`, field), 422);
|
|
10707
|
+
}
|
|
10571
10708
|
}
|
|
10572
|
-
}
|
|
10709
|
+
});
|
|
10573
10710
|
api.use("*", async (c, next) => {
|
|
10574
10711
|
const ip = options.clientIp(c);
|
|
10575
10712
|
const now = options.clock.now();
|
|
@@ -10579,7 +10716,7 @@ function createAdminApi(options) {
|
|
|
10579
10716
|
if (actor === null) {
|
|
10580
10717
|
if (await options.limits?.failuresExhausted?.(ip, now) === true) return c.json(errorBody("rate_limited", "Too many failed attempts."), 429, { "retry-after": "300" });
|
|
10581
10718
|
await options.limits?.onAuthFailure?.(ip, now);
|
|
10582
|
-
options.audit.request({
|
|
10719
|
+
keep(c, options.audit.request({
|
|
10583
10720
|
requestId: c.get("requestId") ?? "",
|
|
10584
10721
|
ip,
|
|
10585
10722
|
slug: "(admin)",
|
|
@@ -10588,7 +10725,7 @@ function createAdminApi(options) {
|
|
|
10588
10725
|
mcp: null,
|
|
10589
10726
|
access: null,
|
|
10590
10727
|
refusal: "admin_unauthenticated"
|
|
10591
|
-
});
|
|
10728
|
+
}));
|
|
10592
10729
|
return c.json(errorBody("unauthorized", "Authentication required."), 401, { "www-authenticate": "Bearer" });
|
|
10593
10730
|
}
|
|
10594
10731
|
c.set("actor", actor);
|
|
@@ -10615,12 +10752,13 @@ function createAdminApi(options) {
|
|
|
10615
10752
|
});
|
|
10616
10753
|
const services = (c) => guardedServices(options.services, {
|
|
10617
10754
|
storage: options.storage,
|
|
10618
|
-
tools: options.tools
|
|
10755
|
+
tools: options.tools,
|
|
10756
|
+
...options.keyring === void 0 ? {} : { keyring: options.keyring }
|
|
10619
10757
|
}, c.get("actor"));
|
|
10620
10758
|
function auditRow(c, spec, status, targetId, extra = {}) {
|
|
10621
10759
|
if (spec.audit === void 0) return;
|
|
10622
10760
|
const actor = c.get("actor");
|
|
10623
|
-
options.audit.admin({
|
|
10761
|
+
keep(c, options.audit.admin({
|
|
10624
10762
|
action: spec.audit.action,
|
|
10625
10763
|
status,
|
|
10626
10764
|
userId: actor.kind === "user" ? actor.userId : null,
|
|
@@ -10632,7 +10770,7 @@ function createAdminApi(options) {
|
|
|
10632
10770
|
actor: actor.kind === "user" ? actor.role : "break_glass",
|
|
10633
10771
|
...extra
|
|
10634
10772
|
}
|
|
10635
|
-
});
|
|
10773
|
+
}));
|
|
10636
10774
|
}
|
|
10637
10775
|
function add(spec, handler) {
|
|
10638
10776
|
const route = createRoute({
|
|
@@ -10669,7 +10807,10 @@ function createAdminApi(options) {
|
|
|
10669
10807
|
query
|
|
10670
10808
|
});
|
|
10671
10809
|
if (spec.audit !== void 0) {
|
|
10672
|
-
auditRow(c, spec, "ok", id || (result?.record?.id ?? result?.id ?? ""), {
|
|
10810
|
+
auditRow(c, spec, "ok", id || (result?.record?.id ?? result?.id ?? ""), {
|
|
10811
|
+
fields: Object.keys(body).filter((k) => k !== "token"),
|
|
10812
|
+
...Object.fromEntries((spec.auditBody ?? []).map((k) => [k, String(body[k] ?? "").slice(0, 200)]))
|
|
10813
|
+
});
|
|
10673
10814
|
options.onChange();
|
|
10674
10815
|
}
|
|
10675
10816
|
return c.json(result, spec.status ?? 200);
|
|
@@ -11124,6 +11265,39 @@ function createAdminApi(options) {
|
|
|
11124
11265
|
} } : {}
|
|
11125
11266
|
});
|
|
11126
11267
|
});
|
|
11268
|
+
const KeyVersionOut = z$1.object({
|
|
11269
|
+
version: z$1.number(),
|
|
11270
|
+
credentials: z$1.number(),
|
|
11271
|
+
loaded: z$1.boolean(),
|
|
11272
|
+
current: z$1.boolean(),
|
|
11273
|
+
retired: z$1.boolean()
|
|
11274
|
+
}).openapi("KeyVersion");
|
|
11275
|
+
const keys = { tag: "keys" };
|
|
11276
|
+
add({
|
|
11277
|
+
method: "get",
|
|
11278
|
+
path: "/keys",
|
|
11279
|
+
...keys,
|
|
11280
|
+
summary: "Master-key versions: credentials on each, loaded, current, retired (owner)",
|
|
11281
|
+
out: z$1.array(KeyVersionOut)
|
|
11282
|
+
}, (c) => services(c).keys.status());
|
|
11283
|
+
add({
|
|
11284
|
+
method: "post",
|
|
11285
|
+
path: "/keys/{id}/retire",
|
|
11286
|
+
...keys,
|
|
11287
|
+
params: true,
|
|
11288
|
+
summary: "Retire a master-key version no credential uses any more (owner; recorded in the audit log)",
|
|
11289
|
+
body: z$1.object({ reason: z$1.string().min(1).max(200) }),
|
|
11290
|
+
out: z$1.array(KeyVersionOut),
|
|
11291
|
+
audit: {
|
|
11292
|
+
action: "crypto.retire_key",
|
|
11293
|
+
targetType: "key_version"
|
|
11294
|
+
},
|
|
11295
|
+
auditBody: ["reason"]
|
|
11296
|
+
}, (c, { id, body }) => {
|
|
11297
|
+
const version = Number(id);
|
|
11298
|
+
if (!Number.isInteger(version) || version < 1) throw new AdminError("invalid_input", "The key version is a whole number ≥ 1.", "id");
|
|
11299
|
+
return services(c).keys.retire(version, body.reason);
|
|
11300
|
+
});
|
|
11127
11301
|
api.openAPIRegistry.registerComponent("securitySchemes", "bearer", {
|
|
11128
11302
|
type: "http",
|
|
11129
11303
|
scheme: "bearer"
|
|
@@ -11384,6 +11558,8 @@ function jsonRpcError(code, message, id = null) {
|
|
|
11384
11558
|
});
|
|
11385
11559
|
}
|
|
11386
11560
|
const UNAUTHORIZED = JSON.stringify({ error: "unauthorized" });
|
|
11561
|
+
/** Carries the request id into the lazily built admin app (Workers); set by the server, never trusted from clients. */
|
|
11562
|
+
const INTERNAL_REQUEST_ID = "x-agility-bridge-internal-request-id";
|
|
11387
11563
|
/** Pass-through identities kept at most (P2-14). */
|
|
11388
11564
|
const MAX_IDENTITIES = 1e4;
|
|
11389
11565
|
/** Ends a streamed body at the deadline. */
|
|
@@ -11444,12 +11620,20 @@ function createHttpApp(options) {
|
|
|
11444
11620
|
identities.set(key, until);
|
|
11445
11621
|
};
|
|
11446
11622
|
const { services } = options;
|
|
11447
|
-
const registry = new ToolRegistry();
|
|
11448
|
-
for (const tool of options.tools) registry.register(tool);
|
|
11623
|
+
const registry = options.registry ?? new ToolRegistry();
|
|
11624
|
+
if (options.registry === void 0) for (const tool of options.tools) registry.register(tool);
|
|
11449
11625
|
const clientIp = options.clientIp ?? (() => "unknown");
|
|
11450
|
-
const
|
|
11626
|
+
const keepAlive = (c, work) => {
|
|
11627
|
+
const settled = work.catch((error) => {
|
|
11628
|
+
services.logger.warn("background work failed", { error: error.name });
|
|
11629
|
+
});
|
|
11630
|
+
options.background?.(c, settled);
|
|
11631
|
+
};
|
|
11632
|
+
const perRequest = options.isolation === "request";
|
|
11633
|
+
const observe = (c, kind, event) => {
|
|
11451
11634
|
try {
|
|
11452
|
-
(options.observer?.[kind])?.(event);
|
|
11635
|
+
const work = (options.observer?.[kind])?.(event);
|
|
11636
|
+
if (work instanceof Promise) keepAlive(c, work);
|
|
11453
11637
|
} catch (error) {
|
|
11454
11638
|
services.logger.warn("observer failed", {
|
|
11455
11639
|
kind,
|
|
@@ -11466,7 +11650,7 @@ function createHttpApp(options) {
|
|
|
11466
11650
|
onToolCall: prepared.onToolCall
|
|
11467
11651
|
});
|
|
11468
11652
|
}, { legacy: "stateless" });
|
|
11469
|
-
const app = new Hono();
|
|
11653
|
+
const app = perRequest ? new Hono({ router: new LinearRouter() }) : new Hono();
|
|
11470
11654
|
app.use("*", async (c, next) => {
|
|
11471
11655
|
const requestId = services.random.uuid();
|
|
11472
11656
|
c.set("requestId", requestId);
|
|
@@ -11487,16 +11671,52 @@ function createHttpApp(options) {
|
|
|
11487
11671
|
return c.json({ error: "internal error" }, 500);
|
|
11488
11672
|
});
|
|
11489
11673
|
app.notFound((c) => c.json({ error: "not found" }, 404));
|
|
11490
|
-
if (options.admin !== void 0)
|
|
11491
|
-
|
|
11492
|
-
|
|
11493
|
-
|
|
11494
|
-
|
|
11495
|
-
|
|
11496
|
-
|
|
11497
|
-
|
|
11498
|
-
|
|
11499
|
-
|
|
11674
|
+
if (options.admin !== void 0) {
|
|
11675
|
+
const admin = options.admin;
|
|
11676
|
+
const build = () => createAdminApi({
|
|
11677
|
+
...admin,
|
|
11678
|
+
storage: options.storage,
|
|
11679
|
+
tools: options.access.tools,
|
|
11680
|
+
onChange: () => cache.clear(),
|
|
11681
|
+
...options.limits === void 0 ? {} : { limits: options.limits },
|
|
11682
|
+
clientIp,
|
|
11683
|
+
logger: services.logger,
|
|
11684
|
+
clock: services.clock,
|
|
11685
|
+
background: keepAlive,
|
|
11686
|
+
keyring: options.keyring,
|
|
11687
|
+
...perRequest ? { lightweightRouter: true } : {}
|
|
11688
|
+
});
|
|
11689
|
+
if (!perRequest) app.route(ADMIN_BASE, build());
|
|
11690
|
+
else {
|
|
11691
|
+
let adminApp;
|
|
11692
|
+
app.all(`${ADMIN_BASE}/*`, (c) => {
|
|
11693
|
+
if (adminApp === void 0) {
|
|
11694
|
+
const inner = new Hono({ router: new LinearRouter() });
|
|
11695
|
+
inner.use(async (ic, next) => {
|
|
11696
|
+
ic.set("requestId", ic.req.header(INTERNAL_REQUEST_ID) ?? "");
|
|
11697
|
+
await next();
|
|
11698
|
+
});
|
|
11699
|
+
inner.route(ADMIN_BASE, build());
|
|
11700
|
+
inner.onError((error, ic) => {
|
|
11701
|
+
services.logger.error("unhandled error", { error: error.name });
|
|
11702
|
+
return ic.json({ error: "internal error" }, 500);
|
|
11703
|
+
});
|
|
11704
|
+
inner.notFound((ic) => ic.json({ error: "not found" }, 404));
|
|
11705
|
+
adminApp = inner;
|
|
11706
|
+
}
|
|
11707
|
+
const headers = new Headers(c.req.raw.headers);
|
|
11708
|
+
headers.set(INTERNAL_REQUEST_ID, c.get("requestId"));
|
|
11709
|
+
const forwarded = new Request(c.req.raw, { headers });
|
|
11710
|
+
let ctx;
|
|
11711
|
+
try {
|
|
11712
|
+
ctx = c.executionCtx;
|
|
11713
|
+
} catch {
|
|
11714
|
+
ctx = void 0;
|
|
11715
|
+
}
|
|
11716
|
+
return ctx === void 0 ? adminApp.fetch(forwarded, c.env) : adminApp.fetch(forwarded, c.env, ctx);
|
|
11717
|
+
});
|
|
11718
|
+
}
|
|
11719
|
+
}
|
|
11500
11720
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
11501
11721
|
app.get("/version", (c) => c.json({ version: options.version }));
|
|
11502
11722
|
app.all("/w/:slug/mcp", async (c) => {
|
|
@@ -11507,7 +11727,7 @@ function createHttpApp(options) {
|
|
|
11507
11727
|
let mcp = null;
|
|
11508
11728
|
let access = null;
|
|
11509
11729
|
const finish = (res, refusal) => {
|
|
11510
|
-
observe("request", {
|
|
11730
|
+
observe(c, "request", {
|
|
11511
11731
|
requestId,
|
|
11512
11732
|
ip,
|
|
11513
11733
|
slug: slug.slice(0, 63),
|
|
@@ -11567,7 +11787,7 @@ function createHttpApp(options) {
|
|
|
11567
11787
|
if (cap !== void 0 && !cap.ok) return tooMany(cap.retryAfterSeconds);
|
|
11568
11788
|
const credentialId = decision.access.credential.id;
|
|
11569
11789
|
pending = pt.validator.validate(instance, credentialId, agilityToken).finally(() => validating.delete(key));
|
|
11570
|
-
validating.set(key, pending);
|
|
11790
|
+
if (!perRequest) validating.set(key, pending);
|
|
11571
11791
|
}
|
|
11572
11792
|
const result = await pending;
|
|
11573
11793
|
if (result.status === "unavailable") return finish(c.json({ error: "temporarily unavailable" }, 503, { "retry-after": "5" }), "unavailable");
|
|
@@ -11663,7 +11883,7 @@ function createHttpApp(options) {
|
|
|
11663
11883
|
},
|
|
11664
11884
|
limits: resolved.limits,
|
|
11665
11885
|
budget: createBudget(resolved.limits.callBudget),
|
|
11666
|
-
limiter: services.limiters.for(resolved.instance.id, resolved.credential.id),
|
|
11886
|
+
limiter: (perRequest ? new CredentialLimiters() : services.limiters).for(resolved.instance.id, resolved.credential.id),
|
|
11667
11887
|
breaker: services.breakers.for(resolved.instance.id, resolved.credential.id),
|
|
11668
11888
|
fetch: services.fetchFor(resolved.instance),
|
|
11669
11889
|
metadata: services.metadata,
|
|
@@ -11671,7 +11891,7 @@ function createHttpApp(options) {
|
|
|
11671
11891
|
clock: services.clock,
|
|
11672
11892
|
random: services.random
|
|
11673
11893
|
},
|
|
11674
|
-
onToolCall: (event) => observe("toolCall", {
|
|
11894
|
+
onToolCall: (event) => observe(c, "toolCall", {
|
|
11675
11895
|
...event,
|
|
11676
11896
|
requestId,
|
|
11677
11897
|
access: resolved
|
|
@@ -11705,7 +11925,7 @@ function createHttpApp(options) {
|
|
|
11705
11925
|
});
|
|
11706
11926
|
return {
|
|
11707
11927
|
app,
|
|
11708
|
-
fetch: (request, env) => app.fetch(request, env),
|
|
11928
|
+
fetch: (request, env, executionCtx) => executionCtx === void 0 ? app.fetch(request, env) : app.fetch(request, env, executionCtx),
|
|
11709
11929
|
clearAccessCache: () => cache.clear()
|
|
11710
11930
|
};
|
|
11711
11931
|
}
|
|
@@ -11720,6 +11940,7 @@ var AuditWriter = class {
|
|
|
11720
11940
|
#pending = 0;
|
|
11721
11941
|
#dropped = 0;
|
|
11722
11942
|
#tail = Promise.resolve();
|
|
11943
|
+
#inflight = /* @__PURE__ */ new Set();
|
|
11723
11944
|
#window = {
|
|
11724
11945
|
start: 0,
|
|
11725
11946
|
written: 0,
|
|
@@ -11731,7 +11952,10 @@ var AuditWriter = class {
|
|
|
11731
11952
|
this.#perMinute = options.refusalsPerMinute ?? 60;
|
|
11732
11953
|
this.#maxPending = options.maxPending ?? 1e4;
|
|
11733
11954
|
}
|
|
11734
|
-
/**
|
|
11955
|
+
/**
|
|
11956
|
+
* Queues a row; never throws, never blocks the request. Detached, it returns the write (which never rejects) for the
|
|
11957
|
+
* host to keep alive; queued, it returns nothing.
|
|
11958
|
+
*/
|
|
11735
11959
|
enqueue(row) {
|
|
11736
11960
|
if (this.#pending >= this.#maxPending) {
|
|
11737
11961
|
this.#dropped++;
|
|
@@ -11744,7 +11968,7 @@ var AuditWriter = class {
|
|
|
11744
11968
|
ts: this.#options.clock.now()
|
|
11745
11969
|
};
|
|
11746
11970
|
this.#pending++;
|
|
11747
|
-
|
|
11971
|
+
const write = () => this.#options.storage.audit.append(entry).catch((error) => {
|
|
11748
11972
|
this.#options.logger.error("audit write failed", {
|
|
11749
11973
|
action: entry.action,
|
|
11750
11974
|
error: error.name
|
|
@@ -11752,11 +11976,19 @@ var AuditWriter = class {
|
|
|
11752
11976
|
}).finally(() => {
|
|
11753
11977
|
this.#pending--;
|
|
11754
11978
|
});
|
|
11979
|
+
if (this.#options.detached === true) {
|
|
11980
|
+
const written = write();
|
|
11981
|
+
this.#inflight.add(written);
|
|
11982
|
+
written.finally(() => this.#inflight.delete(written));
|
|
11983
|
+
return written;
|
|
11984
|
+
}
|
|
11985
|
+
this.#tail = this.#tail.then(write);
|
|
11755
11986
|
}
|
|
11756
11987
|
/** Resolves when every queued row has been written (or failed). For shutdown and tests. */
|
|
11757
11988
|
async flush() {
|
|
11758
11989
|
this.#flushWindow(Number.POSITIVE_INFINITY);
|
|
11759
11990
|
await this.#tail;
|
|
11991
|
+
await Promise.all([...this.#inflight]);
|
|
11760
11992
|
}
|
|
11761
11993
|
/**
|
|
11762
11994
|
* Writes a row now and fails if it cannot be stored: for the intent of a write, recorded before the mutation (L-109,
|
|
@@ -11772,7 +12004,7 @@ var AuditWriter = class {
|
|
|
11772
12004
|
return id;
|
|
11773
12005
|
}
|
|
11774
12006
|
toolCall(e) {
|
|
11775
|
-
this.enqueue({
|
|
12007
|
+
return this.enqueue({
|
|
11776
12008
|
kind: "tool_call",
|
|
11777
12009
|
action: e.tool,
|
|
11778
12010
|
status: e.status,
|
|
@@ -11792,16 +12024,16 @@ var AuditWriter = class {
|
|
|
11792
12024
|
}
|
|
11793
12025
|
/** A refused MCP request (authentication or limits), capped per minute. */
|
|
11794
12026
|
request(e) {
|
|
11795
|
-
if (e.refusal === null || !AUDITED_REFUSALS.has(e.status)) return;
|
|
12027
|
+
if (e.refusal === null || !AUDITED_REFUSALS.has(e.status)) return void 0;
|
|
11796
12028
|
const now = this.#options.clock.now();
|
|
11797
|
-
this.#flushWindow(now);
|
|
12029
|
+
const summary = this.#flushWindow(now);
|
|
11798
12030
|
if (this.#window.written >= this.#perMinute) {
|
|
11799
12031
|
if (this.#window.suppressed === 0) this.#window.firstSuppressed = now;
|
|
11800
12032
|
this.#window.suppressed++;
|
|
11801
|
-
return;
|
|
12033
|
+
return summary;
|
|
11802
12034
|
}
|
|
11803
12035
|
this.#window.written++;
|
|
11804
|
-
this.enqueue({
|
|
12036
|
+
return both(summary, this.enqueue({
|
|
11805
12037
|
kind: "auth",
|
|
11806
12038
|
action: e.status === 429 ? "mcp.rate_limited" : "mcp.unauthenticated",
|
|
11807
12039
|
status: "denied",
|
|
@@ -11819,10 +12051,10 @@ var AuditWriter = class {
|
|
|
11819
12051
|
ip: e.ip.slice(0, 64),
|
|
11820
12052
|
...e.mcp?.method ? { method: e.mcp.method } : {}
|
|
11821
12053
|
}
|
|
11822
|
-
});
|
|
12054
|
+
}));
|
|
11823
12055
|
}
|
|
11824
12056
|
admin(change) {
|
|
11825
|
-
this.enqueue(this.#adminRow(change));
|
|
12057
|
+
return this.enqueue(this.#adminRow(change));
|
|
11826
12058
|
}
|
|
11827
12059
|
#adminRow(change) {
|
|
11828
12060
|
return {
|
|
@@ -11845,8 +12077,9 @@ var AuditWriter = class {
|
|
|
11845
12077
|
/** Ends the refusal window once a minute has passed, writing a summary of what was suppressed. */
|
|
11846
12078
|
#flushWindow(now) {
|
|
11847
12079
|
const w = this.#window;
|
|
11848
|
-
if (now - w.start < 6e4) return;
|
|
11849
|
-
|
|
12080
|
+
if (now - w.start < 6e4) return void 0;
|
|
12081
|
+
let summary;
|
|
12082
|
+
if (w.suppressed > 0) summary = this.enqueue({
|
|
11850
12083
|
kind: "auth",
|
|
11851
12084
|
action: "audit.refusals_suppressed",
|
|
11852
12085
|
status: "denied",
|
|
@@ -11862,29 +12095,31 @@ var AuditWriter = class {
|
|
|
11862
12095
|
suppressed: 0,
|
|
11863
12096
|
firstSuppressed: 0
|
|
11864
12097
|
};
|
|
12098
|
+
return summary;
|
|
11865
12099
|
}
|
|
11866
|
-
/** The observer to give `createHttpApp
|
|
12100
|
+
/** The observer to give `createHttpApp`; it returns the writes it started (detached) for the host to keep alive. */
|
|
11867
12101
|
observer(next) {
|
|
11868
12102
|
return {
|
|
11869
|
-
request: (e) =>
|
|
11870
|
-
|
|
11871
|
-
next?.request?.(e);
|
|
11872
|
-
},
|
|
11873
|
-
toolCall: (e) => {
|
|
11874
|
-
this.toolCall(e);
|
|
11875
|
-
next?.toolCall?.(e);
|
|
11876
|
-
}
|
|
12103
|
+
request: (e) => both(this.request(e), next?.request?.(e)),
|
|
12104
|
+
toolCall: (e) => both(this.toolCall(e), next?.toolCall?.(e))
|
|
11877
12105
|
};
|
|
11878
12106
|
}
|
|
11879
12107
|
};
|
|
12108
|
+
/** One promise for the work two steps started, or nothing when neither started any. */
|
|
12109
|
+
function both(a, b) {
|
|
12110
|
+
const work = [a, b].filter((w) => w instanceof Promise);
|
|
12111
|
+
if (work.length === 0) return void 0;
|
|
12112
|
+
return Promise.all(work).then(() => void 0);
|
|
12113
|
+
}
|
|
11880
12114
|
/**
|
|
11881
|
-
* Deletes audit rows older than `retentionDays` (0 keeps them forever), in
|
|
12115
|
+
* Deletes audit rows older than `retentionDays` (0 keeps them forever), in batches of 1,000, at most `maxBatches`
|
|
12116
|
+
* (default 1,000; Workers use a few per scheduled run, P3-08). Returns the number deleted.
|
|
11882
12117
|
*/
|
|
11883
|
-
async function purgeAudit(storage, retentionDays, now) {
|
|
12118
|
+
async function purgeAudit(storage, retentionDays, now, maxBatches = 1e3) {
|
|
11884
12119
|
if (retentionDays <= 0) return 0;
|
|
11885
12120
|
const before = now - retentionDays * 864e5;
|
|
11886
12121
|
let total = 0;
|
|
11887
|
-
for (let i = 0; i <
|
|
12122
|
+
for (let i = 0; i < maxBatches; i++) {
|
|
11888
12123
|
const n = await storage.audit.purge(before, 1e3);
|
|
11889
12124
|
total += n;
|
|
11890
12125
|
if (n < 1e3) break;
|
|
@@ -12058,6 +12293,215 @@ function clientAddress(peer, forwardedFor, trustedHops) {
|
|
|
12058
12293
|
return candidate !== void 0 && /^[0-9A-Fa-f:.[\]]{2,45}$/.test(candidate) ? candidate : socket;
|
|
12059
12294
|
}
|
|
12060
12295
|
//#endregion
|
|
12296
|
+
//#region ../../packages/http-app/src/server.ts
|
|
12297
|
+
/** A setting is missing or malformed; the message names the setting, never its value. */
|
|
12298
|
+
var SettingsError = class extends Error {
|
|
12299
|
+
name = "SettingsError";
|
|
12300
|
+
};
|
|
12301
|
+
Object.freeze([
|
|
12302
|
+
"AB_LOG_LEVEL",
|
|
12303
|
+
"AB_ENVIRONMENT",
|
|
12304
|
+
"AB_PUBLIC_URL",
|
|
12305
|
+
"AB_ALLOWED_HOSTS",
|
|
12306
|
+
"AB_ALLOWED_ORIGINS",
|
|
12307
|
+
"AB_ACCESS_CACHE_TTL",
|
|
12308
|
+
"AB_CALL_BUDGET",
|
|
12309
|
+
"AB_LIMITS_MAX_RESPONSE_BYTES",
|
|
12310
|
+
"AB_RATE_LIMIT",
|
|
12311
|
+
"AB_RATE_LIMIT_IP_PER_MINUTE",
|
|
12312
|
+
"AB_RATE_LIMIT_AUTH_FAILURES",
|
|
12313
|
+
"AB_RATE_LIMIT_TOKEN_PER_MINUTE",
|
|
12314
|
+
"AB_RATE_LIMIT_GRANT_PER_MINUTE",
|
|
12315
|
+
"AB_RATE_LIMIT_VALIDATIONS_PER_MINUTE",
|
|
12316
|
+
"AB_AUDIT_RETENTION_DAYS",
|
|
12317
|
+
"AB_CREDENTIAL_REVALIDATION_HOURS",
|
|
12318
|
+
"AB_PASSTHROUGH",
|
|
12319
|
+
"AB_WRITE_TOOLS",
|
|
12320
|
+
"AB_ALLOW_HTTP_INSTANCES"
|
|
12321
|
+
]);
|
|
12322
|
+
function settingList(value) {
|
|
12323
|
+
return (value ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
12324
|
+
}
|
|
12325
|
+
function settingInteger(name, value, fallback, min, max) {
|
|
12326
|
+
if (value === void 0 || value === "") return fallback;
|
|
12327
|
+
const n = Number(value);
|
|
12328
|
+
if (!Number.isInteger(n) || n < min || n > max) throw new SettingsError(`${name} must be an integer from ${min} to ${max}.`);
|
|
12329
|
+
return n;
|
|
12330
|
+
}
|
|
12331
|
+
function onOff(name, value, fallback) {
|
|
12332
|
+
if (value === void 0 || value === "") return fallback;
|
|
12333
|
+
if (value === "on") return true;
|
|
12334
|
+
if (value === "off") return false;
|
|
12335
|
+
throw new SettingsError(`${name} must be on or off.`);
|
|
12336
|
+
}
|
|
12337
|
+
/** Parses and checks the shared settings. Throws `SettingsError` naming the first bad one. */
|
|
12338
|
+
function parseServerSettings(env, defaults) {
|
|
12339
|
+
const level = env["AB_LOG_LEVEL"] ?? "info";
|
|
12340
|
+
if (![
|
|
12341
|
+
"debug",
|
|
12342
|
+
"info",
|
|
12343
|
+
"warn",
|
|
12344
|
+
"error"
|
|
12345
|
+
].includes(level)) throw new SettingsError("AB_LOG_LEVEL must be debug, info, warn or error.");
|
|
12346
|
+
const environment = env["AB_ENVIRONMENT"] ?? "production";
|
|
12347
|
+
if (environment !== "production" && environment !== "test") throw new SettingsError("AB_ENVIRONMENT must be production (device tokens ab_live_…) or test (ab_test_…).");
|
|
12348
|
+
let publicUrl;
|
|
12349
|
+
if (env["AB_PUBLIC_URL"] !== void 0 && env["AB_PUBLIC_URL"] !== "") try {
|
|
12350
|
+
const u = new URL(env["AB_PUBLIC_URL"]);
|
|
12351
|
+
if (u.protocol !== "https:" && u.protocol !== "http:" || u.username !== "" || u.search !== "" || u.hash !== "") throw new Error("bad");
|
|
12352
|
+
publicUrl = u.origin;
|
|
12353
|
+
} catch {
|
|
12354
|
+
throw new SettingsError("AB_PUBLIC_URL must be the public origin, e.g. https://mcp.example.com.");
|
|
12355
|
+
}
|
|
12356
|
+
for (const o of settingList(env["AB_ALLOWED_ORIGINS"])) if (o === "null" || !/^https?:\/\/[^/\s]+$/.test(o)) throw new SettingsError(`AB_ALLOWED_ORIGINS has an invalid origin: "${o.slice(0, 60)}".`);
|
|
12357
|
+
const int = settingInteger;
|
|
12358
|
+
return {
|
|
12359
|
+
...publicUrl === void 0 ? {} : { publicUrl },
|
|
12360
|
+
allowedHosts: settingList(env["AB_ALLOWED_HOSTS"]),
|
|
12361
|
+
allowedOrigins: settingList(env["AB_ALLOWED_ORIGINS"]),
|
|
12362
|
+
accessCacheTtlSeconds: int("AB_ACCESS_CACHE_TTL", env["AB_ACCESS_CACHE_TTL"], 30, 0, 60),
|
|
12363
|
+
environment: environment === "production" ? "live" : "test",
|
|
12364
|
+
callBudget: int("AB_CALL_BUDGET", env["AB_CALL_BUDGET"], defaults.callBudget, 1, 1e4),
|
|
12365
|
+
maxResponseBytes: int("AB_LIMITS_MAX_RESPONSE_BYTES", env["AB_LIMITS_MAX_RESPONSE_BYTES"], defaults.maxResponseBytes, 65536, 67108864),
|
|
12366
|
+
logLevel: level,
|
|
12367
|
+
rateLimits: {
|
|
12368
|
+
enabled: onOff("AB_RATE_LIMIT", env["AB_RATE_LIMIT"], true),
|
|
12369
|
+
ipPerMinute: int("AB_RATE_LIMIT_IP_PER_MINUTE", env["AB_RATE_LIMIT_IP_PER_MINUTE"], DEFAULT_RATE_LIMITS.ipPerMinute, 1, 1e6),
|
|
12370
|
+
authFailuresPer5Minutes: int("AB_RATE_LIMIT_AUTH_FAILURES", env["AB_RATE_LIMIT_AUTH_FAILURES"], DEFAULT_RATE_LIMITS.authFailuresPer5Minutes, 1, 1e4),
|
|
12371
|
+
tokenPerMinute: int("AB_RATE_LIMIT_TOKEN_PER_MINUTE", env["AB_RATE_LIMIT_TOKEN_PER_MINUTE"], DEFAULT_RATE_LIMITS.tokenPerMinute, 1, 1e5),
|
|
12372
|
+
grantPerMinute: int("AB_RATE_LIMIT_GRANT_PER_MINUTE", env["AB_RATE_LIMIT_GRANT_PER_MINUTE"], DEFAULT_RATE_LIMITS.grantPerMinute, 1, 1e5),
|
|
12373
|
+
validationsPerMinute: int("AB_RATE_LIMIT_VALIDATIONS_PER_MINUTE", env["AB_RATE_LIMIT_VALIDATIONS_PER_MINUTE"], DEFAULT_RATE_LIMITS.validationsPerMinute, 1, 1e4)
|
|
12374
|
+
},
|
|
12375
|
+
auditRetentionDays: int("AB_AUDIT_RETENTION_DAYS", env["AB_AUDIT_RETENTION_DAYS"], 90, 0, 36500),
|
|
12376
|
+
revalidationHours: int("AB_CREDENTIAL_REVALIDATION_HOURS", env["AB_CREDENTIAL_REVALIDATION_HOURS"], 24, 0, 8760),
|
|
12377
|
+
passthrough: onOff("AB_PASSTHROUGH", env["AB_PASSTHROUGH"], false),
|
|
12378
|
+
allowHttpInstances: onOff("AB_ALLOW_HTTP_INSTANCES", env["AB_ALLOW_HTTP_INSTANCES"], false)
|
|
12379
|
+
};
|
|
12380
|
+
}
|
|
12381
|
+
/** The warnings both hosts log at start for risky but valid settings. */
|
|
12382
|
+
function settingWarnings(env, settings) {
|
|
12383
|
+
const warnings = [];
|
|
12384
|
+
if (env["AB_WRITE_TOOLS"] === "on") warnings.push("AB_WRITE_TOOLS is on, but this version has no write tools.");
|
|
12385
|
+
if (!settings.rateLimits.enabled) warnings.push("AB_RATE_LIMIT is off: no request, token or authentication-failure limits.");
|
|
12386
|
+
if (settings.allowHttpInstances) warnings.push("AB_ALLOW_HTTP_INSTANCES is on: plain-HTTP loopback instance URLs are accepted (tests only).");
|
|
12387
|
+
if (settings.passthrough) warnings.push("AB_PASSTHROUGH is on: pass-through workspaces accept any valid Agility token of their instance.");
|
|
12388
|
+
return warnings;
|
|
12389
|
+
}
|
|
12390
|
+
/**
|
|
12391
|
+
* Revalidates a credential after 3 consecutive `AB_AGILITY_AUTH` errors (a revoked token), at most once per 10 minutes
|
|
12392
|
+
* per credential, so a dead token is marked invalid instead of failing every call (P2-13, A10). Returns the
|
|
12393
|
+
* revalidation it started, for the host to keep alive.
|
|
12394
|
+
*/
|
|
12395
|
+
function revalidateOnAuthErrors(services, logger, now = Date.now) {
|
|
12396
|
+
const streaks = /* @__PURE__ */ new Map();
|
|
12397
|
+
const last = /* @__PURE__ */ new Map();
|
|
12398
|
+
return (credentialId, errorCode) => {
|
|
12399
|
+
if (errorCode !== "AB_AGILITY_AUTH") {
|
|
12400
|
+
streaks.delete(credentialId);
|
|
12401
|
+
return;
|
|
12402
|
+
}
|
|
12403
|
+
const n = (streaks.get(credentialId) ?? 0) + 1;
|
|
12404
|
+
streaks.set(credentialId, n);
|
|
12405
|
+
if (n < 3 || now() - (last.get(credentialId) ?? 0) < 6e5) return void 0;
|
|
12406
|
+
last.set(credentialId, now());
|
|
12407
|
+
streaks.delete(credentialId);
|
|
12408
|
+
return services.credentials.validate(credentialId).then((c) => {
|
|
12409
|
+
logger.warn("credential revalidated after Agility refused it", {
|
|
12410
|
+
credential: c.id,
|
|
12411
|
+
status: c.validationStatus
|
|
12412
|
+
});
|
|
12413
|
+
}).catch((error) => {
|
|
12414
|
+
logger.error("credential revalidation failed", { error: error.name });
|
|
12415
|
+
});
|
|
12416
|
+
};
|
|
12417
|
+
}
|
|
12418
|
+
/** The app both hosts serve, with audit, admin API, rate limits and pass-through as configured. */
|
|
12419
|
+
function assembleServer(parts) {
|
|
12420
|
+
const { settings, storage, keyring, host, logger } = parts;
|
|
12421
|
+
const tools = new Map(CORE_TOOLS.map((t) => [t.name, {
|
|
12422
|
+
kind: t.kind,
|
|
12423
|
+
group: t.group
|
|
12424
|
+
}]));
|
|
12425
|
+
const audit = new AuditWriter({
|
|
12426
|
+
storage,
|
|
12427
|
+
logger,
|
|
12428
|
+
clock: host.clock,
|
|
12429
|
+
random: host.random,
|
|
12430
|
+
...parts.isolation === "request" ? { detached: true } : {}
|
|
12431
|
+
});
|
|
12432
|
+
const validator = credentialValidator(host);
|
|
12433
|
+
const services = accessServices({
|
|
12434
|
+
storage,
|
|
12435
|
+
keyring,
|
|
12436
|
+
tools,
|
|
12437
|
+
environment: settings.environment,
|
|
12438
|
+
validator,
|
|
12439
|
+
instanceUrls: {
|
|
12440
|
+
httpLoopback: settings.allowHttpInstances,
|
|
12441
|
+
...parts.checkInstanceHost === void 0 ? {} : { checkHost: parts.checkInstanceHost }
|
|
12442
|
+
}
|
|
12443
|
+
});
|
|
12444
|
+
const authErrors = revalidateOnAuthErrors(services, logger, () => host.clock.now());
|
|
12445
|
+
return {
|
|
12446
|
+
app: createHttpApp({
|
|
12447
|
+
storage,
|
|
12448
|
+
keyring,
|
|
12449
|
+
access: {
|
|
12450
|
+
tools,
|
|
12451
|
+
writesEnabled: false,
|
|
12452
|
+
environment: settings.environment,
|
|
12453
|
+
limits: {
|
|
12454
|
+
defaults: {
|
|
12455
|
+
...DEFAULT_LIMITS,
|
|
12456
|
+
maxResponseBytes: settings.maxResponseBytes,
|
|
12457
|
+
callBudget: settings.callBudget,
|
|
12458
|
+
genericQuery: true
|
|
12459
|
+
},
|
|
12460
|
+
ceilings: parts.ceilings ?? {}
|
|
12461
|
+
}
|
|
12462
|
+
},
|
|
12463
|
+
tools: CORE_TOOLS,
|
|
12464
|
+
services: host,
|
|
12465
|
+
...settings.publicUrl === void 0 ? {} : { publicUrl: settings.publicUrl },
|
|
12466
|
+
allowedHosts: settings.allowedHosts,
|
|
12467
|
+
allowedOrigins: settings.allowedOrigins,
|
|
12468
|
+
accessCacheTtlSeconds: settings.accessCacheTtlSeconds,
|
|
12469
|
+
limits: createRequestLimits(parts.limiter, settings.rateLimits, logger),
|
|
12470
|
+
...settings.passthrough ? { passthrough: {
|
|
12471
|
+
validator,
|
|
12472
|
+
secure: parts.passthroughSecure
|
|
12473
|
+
} } : {},
|
|
12474
|
+
...parts.adminVerifier === void 0 ? {} : { admin: {
|
|
12475
|
+
services,
|
|
12476
|
+
audit,
|
|
12477
|
+
authenticate: adminTokenAuthenticator(parts.adminVerifier)
|
|
12478
|
+
} },
|
|
12479
|
+
clientIp: parts.clientIp,
|
|
12480
|
+
observer: audit.observer({
|
|
12481
|
+
toolCall: (e) => authErrors(e.access.credential.id, e.errorCode),
|
|
12482
|
+
request: (e) => {
|
|
12483
|
+
logger.info("request", {
|
|
12484
|
+
requestId: e.requestId,
|
|
12485
|
+
workspace: e.slug,
|
|
12486
|
+
status: e.status,
|
|
12487
|
+
method: e.mcp?.method ?? null,
|
|
12488
|
+
kind: e.mcp?.kind ?? null,
|
|
12489
|
+
tool: e.mcp?.tool ?? null,
|
|
12490
|
+
durationMs: e.durationMs,
|
|
12491
|
+
...e.refusal === null ? {} : { refusal: e.refusal }
|
|
12492
|
+
});
|
|
12493
|
+
}
|
|
12494
|
+
}),
|
|
12495
|
+
isolation: parts.isolation,
|
|
12496
|
+
...parts.registry === void 0 ? {} : { registry: parts.registry },
|
|
12497
|
+
...parts.background === void 0 ? {} : { background: parts.background },
|
|
12498
|
+
version: parts.version
|
|
12499
|
+
}),
|
|
12500
|
+
audit,
|
|
12501
|
+
services
|
|
12502
|
+
};
|
|
12503
|
+
}
|
|
12504
|
+
//#endregion
|
|
12061
12505
|
//#region ../../packages/storage-sqlite/src/driver.ts
|
|
12062
12506
|
const MAX_CACHED_STATEMENTS = 256;
|
|
12063
12507
|
function bindable(value) {
|
|
@@ -12319,6 +12763,56 @@ async function restoreSqliteBackup(source, target) {
|
|
|
12319
12763
|
copyPrivateFile(from, to);
|
|
12320
12764
|
}
|
|
12321
12765
|
//#endregion
|
|
12766
|
+
//#region src/agility-fetch.ts
|
|
12767
|
+
/** Thrown for an instance host that is not a public address; the message names the host, never a token. */
|
|
12768
|
+
var PrivateAddressError = class extends Error {
|
|
12769
|
+
name = "PrivateAddressError";
|
|
12770
|
+
};
|
|
12771
|
+
/** `dns.lookup` that fails when any address of the name is not public (so a rebinding answer is refused too). */
|
|
12772
|
+
function publicOnlyLookup(hostname, options, callback) {
|
|
12773
|
+
lookup(hostname, {
|
|
12774
|
+
...options,
|
|
12775
|
+
all: true
|
|
12776
|
+
}, ((error, addresses) => {
|
|
12777
|
+
if (error !== null) return callback(error, []);
|
|
12778
|
+
if (addresses.find((a) => addressClass(a.address) !== "public") !== void 0 || addresses.length === 0) return callback(Object.assign(new PrivateAddressError(`${hostname} resolves to a non-public address.`), { code: "EACCES" }), []);
|
|
12779
|
+
if (options.all === true) return callback(null, addresses);
|
|
12780
|
+
const first = addresses[0];
|
|
12781
|
+
callback(null, first.address, first.family);
|
|
12782
|
+
}));
|
|
12783
|
+
}
|
|
12784
|
+
/** Refuses a host that is, or resolves to, a non-public address (instance create and retarget, P2-19). */
|
|
12785
|
+
async function checkPublicHost(hostname) {
|
|
12786
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
12787
|
+
const literal = addressClass(host);
|
|
12788
|
+
if (literal !== "invalid") {
|
|
12789
|
+
if (literal !== "public") throw new PrivateAddressError(`${hostname} is a ${literal} address.`);
|
|
12790
|
+
return;
|
|
12791
|
+
}
|
|
12792
|
+
await new Promise((resolve, reject) => publicOnlyLookup(host, { all: true }, (error) => error === null ? resolve() : reject(error)));
|
|
12793
|
+
}
|
|
12794
|
+
function createNodeFetch(options = {}) {
|
|
12795
|
+
if (options.caFile === void 0 && options.publicOnly !== true) return (request) => fetch(request);
|
|
12796
|
+
let ca;
|
|
12797
|
+
if (options.caFile !== void 0) {
|
|
12798
|
+
const pem = readFileSync(options.caFile, "utf8");
|
|
12799
|
+
if (!pem.includes("-----BEGIN CERTIFICATE-----")) throw new Error(`AB_CA_FILE ${options.caFile} holds no PEM certificate.`);
|
|
12800
|
+
ca = [...rootCertificates, pem];
|
|
12801
|
+
}
|
|
12802
|
+
const dispatcher = new Agent({ connect: {
|
|
12803
|
+
...ca === void 0 ? {} : { ca },
|
|
12804
|
+
...options.publicOnly === true ? { lookup: publicOnlyLookup } : {}
|
|
12805
|
+
} });
|
|
12806
|
+
const send = (request) => fetch(request, { dispatcher });
|
|
12807
|
+
if (options.publicOnly !== true) return send;
|
|
12808
|
+
return async (request) => {
|
|
12809
|
+
const host = new URL(request.url).hostname.replace(/^\[|\]$/g, "");
|
|
12810
|
+
const kind = addressClass(host);
|
|
12811
|
+
if (kind !== "invalid" && kind !== "public") throw new PrivateAddressError(`${host} is a ${kind} address.`);
|
|
12812
|
+
return send(request);
|
|
12813
|
+
};
|
|
12814
|
+
}
|
|
12815
|
+
//#endregion
|
|
12322
12816
|
//#region src/master-key.ts
|
|
12323
12817
|
/** The user configuration directory of agility-bridge (`$XDG_CONFIG_HOME` or `~/.config`; `%APPDATA%` on Windows). */
|
|
12324
12818
|
function configDir(env, platform = process.platform, home = homedir()) {
|
|
@@ -12411,85 +12905,35 @@ function createMasterKeyFile(path, version = 1) {
|
|
|
12411
12905
|
return target;
|
|
12412
12906
|
}
|
|
12413
12907
|
//#endregion
|
|
12414
|
-
//#region src/agility-fetch.ts
|
|
12415
|
-
function createNodeFetch(options = {}) {
|
|
12416
|
-
if (options.caFile === void 0) return (request) => fetch(request);
|
|
12417
|
-
const pem = readFileSync(options.caFile, "utf8");
|
|
12418
|
-
if (!pem.includes("-----BEGIN CERTIFICATE-----")) throw new Error(`AB_CA_FILE ${options.caFile} holds no PEM certificate.`);
|
|
12419
|
-
const dispatcher = new Agent({ connect: { ca: [...rootCertificates, pem] } });
|
|
12420
|
-
return (request) => fetch(request, { dispatcher });
|
|
12421
|
-
}
|
|
12422
|
-
//#endregion
|
|
12423
12908
|
//#region src/serve.ts
|
|
12424
12909
|
/** The default data directory (`$XDG_DATA_HOME` or `~/.local/share`; `%LOCALAPPDATA%` on Windows). */
|
|
12425
12910
|
function dataDir(env, platform = process.platform, home = homedir()) {
|
|
12426
12911
|
const base = platform === "win32" ? env["LOCALAPPDATA"] ?? join(home, "AppData", "Local") : env["XDG_DATA_HOME"] ?? join(home, ".local", "share");
|
|
12427
12912
|
return join(base, "agility-bridge");
|
|
12428
12913
|
}
|
|
12429
|
-
function
|
|
12430
|
-
|
|
12431
|
-
|
|
12432
|
-
|
|
12433
|
-
if (value === void 0 || value === "") return fallback;
|
|
12434
|
-
const n = Number(value);
|
|
12435
|
-
if (!Number.isInteger(n) || n < min || n > max) throw new ConfigError(`${name} must be an integer from ${min} to ${max}.`);
|
|
12436
|
-
return n;
|
|
12914
|
+
function privateAddressPolicy(value) {
|
|
12915
|
+
if (value === void 0 || value === "" || value === "allow") return "allow";
|
|
12916
|
+
if (value === "refuse") return "refuse";
|
|
12917
|
+
throw new ConfigError("AB_INSTANCE_PRIVATE_ADDRESSES must be allow or refuse.");
|
|
12437
12918
|
}
|
|
12919
|
+
/** The shared settings (checked by `parseServerSettings`, P3-01) plus the Node-only ones. */
|
|
12438
12920
|
function loadServeConfig(env) {
|
|
12439
12921
|
checkEnvNames(env);
|
|
12440
|
-
|
|
12441
|
-
|
|
12442
|
-
"
|
|
12443
|
-
|
|
12444
|
-
|
|
12445
|
-
|
|
12446
|
-
|
|
12447
|
-
|
|
12448
|
-
|
|
12449
|
-
|
|
12450
|
-
|
|
12451
|
-
|
|
12452
|
-
if (
|
|
12453
|
-
|
|
12454
|
-
} catch {
|
|
12455
|
-
throw new ConfigError("AB_PUBLIC_URL must be the public origin, e.g. https://mcp.example.com.");
|
|
12922
|
+
try {
|
|
12923
|
+
const settings = parseServerSettings(env, DEFAULTS);
|
|
12924
|
+
const dbPath = env["AB_DB_PATH"] || join(env["AB_DATA_DIR"] || dataDir(env), "agility-bridge.db");
|
|
12925
|
+
return {
|
|
12926
|
+
...settings,
|
|
12927
|
+
dbPath,
|
|
12928
|
+
host: env["AB_HOST"] || "127.0.0.1",
|
|
12929
|
+
port: settingInteger("AB_PORT", env["AB_PORT"], 8787, 0, 65535),
|
|
12930
|
+
trustedProxyHops: settingInteger("AB_TRUSTED_PROXY_HOPS", env["AB_TRUSTED_PROXY_HOPS"], 0, 0, 10),
|
|
12931
|
+
privateAddresses: privateAddressPolicy(env["AB_INSTANCE_PRIVATE_ADDRESSES"])
|
|
12932
|
+
};
|
|
12933
|
+
} catch (error) {
|
|
12934
|
+
if (error instanceof SettingsError) throw new ConfigError(error.message);
|
|
12935
|
+
throw error;
|
|
12456
12936
|
}
|
|
12457
|
-
for (const o of list$1(env["AB_ALLOWED_ORIGINS"])) if (o === "null" || !/^https?:\/\/[^/\s]+$/.test(o)) throw new ConfigError(`AB_ALLOWED_ORIGINS has an invalid origin: "${o.slice(0, 60)}".`);
|
|
12458
|
-
return {
|
|
12459
|
-
dbPath: env["AB_DB_PATH"] || join(env["AB_DATA_DIR"] || dataDir(env), "agility-bridge.db"),
|
|
12460
|
-
host: env["AB_HOST"] || "127.0.0.1",
|
|
12461
|
-
port: integer$1("AB_PORT", env["AB_PORT"], 8787, 0, 65535),
|
|
12462
|
-
...publicUrl === void 0 ? {} : { publicUrl },
|
|
12463
|
-
allowedHosts: list$1(env["AB_ALLOWED_HOSTS"]),
|
|
12464
|
-
allowedOrigins: list$1(env["AB_ALLOWED_ORIGINS"]),
|
|
12465
|
-
accessCacheTtlSeconds: integer$1("AB_ACCESS_CACHE_TTL", env["AB_ACCESS_CACHE_TTL"], 30, 0, 60),
|
|
12466
|
-
environment: environment === "production" ? "live" : "test",
|
|
12467
|
-
callBudget: integer$1("AB_CALL_BUDGET", env["AB_CALL_BUDGET"], DEFAULTS.callBudget, 1, 1e4),
|
|
12468
|
-
maxResponseBytes: integer$1("AB_LIMITS_MAX_RESPONSE_BYTES", env["AB_LIMITS_MAX_RESPONSE_BYTES"], DEFAULTS.maxResponseBytes, 65536, 67108864),
|
|
12469
|
-
logLevel: level,
|
|
12470
|
-
rateLimits: {
|
|
12471
|
-
enabled: rateLimitSwitch(env["AB_RATE_LIMIT"]),
|
|
12472
|
-
ipPerMinute: integer$1("AB_RATE_LIMIT_IP_PER_MINUTE", env["AB_RATE_LIMIT_IP_PER_MINUTE"], DEFAULT_RATE_LIMITS.ipPerMinute, 1, 1e6),
|
|
12473
|
-
authFailuresPer5Minutes: integer$1("AB_RATE_LIMIT_AUTH_FAILURES", env["AB_RATE_LIMIT_AUTH_FAILURES"], DEFAULT_RATE_LIMITS.authFailuresPer5Minutes, 1, 1e4),
|
|
12474
|
-
tokenPerMinute: integer$1("AB_RATE_LIMIT_TOKEN_PER_MINUTE", env["AB_RATE_LIMIT_TOKEN_PER_MINUTE"], DEFAULT_RATE_LIMITS.tokenPerMinute, 1, 1e5),
|
|
12475
|
-
grantPerMinute: integer$1("AB_RATE_LIMIT_GRANT_PER_MINUTE", env["AB_RATE_LIMIT_GRANT_PER_MINUTE"], DEFAULT_RATE_LIMITS.grantPerMinute, 1, 1e5),
|
|
12476
|
-
validationsPerMinute: integer$1("AB_RATE_LIMIT_VALIDATIONS_PER_MINUTE", env["AB_RATE_LIMIT_VALIDATIONS_PER_MINUTE"], DEFAULT_RATE_LIMITS.validationsPerMinute, 1, 1e4)
|
|
12477
|
-
},
|
|
12478
|
-
trustedProxyHops: integer$1("AB_TRUSTED_PROXY_HOPS", env["AB_TRUSTED_PROXY_HOPS"], 0, 0, 10),
|
|
12479
|
-
auditRetentionDays: integer$1("AB_AUDIT_RETENTION_DAYS", env["AB_AUDIT_RETENTION_DAYS"], 90, 0, 36500),
|
|
12480
|
-
revalidationHours: integer$1("AB_CREDENTIAL_REVALIDATION_HOURS", env["AB_CREDENTIAL_REVALIDATION_HOURS"], 24, 0, 8760),
|
|
12481
|
-
passthrough: onOff("AB_PASSTHROUGH", env["AB_PASSTHROUGH"])
|
|
12482
|
-
};
|
|
12483
|
-
}
|
|
12484
|
-
function onOff(name, value) {
|
|
12485
|
-
if (value === void 0 || value === "" || value === "off") return false;
|
|
12486
|
-
if (value === "on") return true;
|
|
12487
|
-
throw new ConfigError(`${name} must be on or off.`);
|
|
12488
|
-
}
|
|
12489
|
-
function rateLimitSwitch(value) {
|
|
12490
|
-
if (value === void 0 || value === "" || value === "on") return true;
|
|
12491
|
-
if (value === "off") return false;
|
|
12492
|
-
throw new ConfigError("AB_RATE_LIMIT must be on or off.");
|
|
12493
12937
|
}
|
|
12494
12938
|
/** Opens storage, checks the master key, and listens. Throws ConfigError before listening on any problem. */
|
|
12495
12939
|
async function startServe(env, options = {}) {
|
|
@@ -12499,9 +12943,7 @@ async function startServe(env, options = {}) {
|
|
|
12499
12943
|
sink: options.sink ?? stderrSink,
|
|
12500
12944
|
fields: { mode: "serve" }
|
|
12501
12945
|
});
|
|
12502
|
-
|
|
12503
|
-
if (!config.rateLimits.enabled) logger.warn("AB_RATE_LIMIT is off: no request, token or authentication-failure limits.");
|
|
12504
|
-
if (config.passthrough) logger.warn("AB_PASSTHROUGH is on: pass-through workspaces accept any valid Agility token of their instance.");
|
|
12946
|
+
for (const warning of settingWarnings(env, config)) logger.warn(warning);
|
|
12505
12947
|
const keys = readMasterKeys(env, { dataDir: dirname(config.dbPath) });
|
|
12506
12948
|
const keyring = await Keyring.create(keys.current, keys.previous);
|
|
12507
12949
|
const storage = await openSqliteStorage({
|
|
@@ -12523,76 +12965,22 @@ async function startServe(env, options = {}) {
|
|
|
12523
12965
|
await storage.close();
|
|
12524
12966
|
throw error instanceof ConfigError ? error : new ConfigError(`AB_ADMIN_TOKEN: ${error.message}`);
|
|
12525
12967
|
}
|
|
12526
|
-
const
|
|
12527
|
-
|
|
12528
|
-
group: t.group
|
|
12529
|
-
}]));
|
|
12530
|
-
const audit = new AuditWriter({
|
|
12968
|
+
const { app, audit, services } = assembleServer({
|
|
12969
|
+
settings: config,
|
|
12531
12970
|
storage,
|
|
12971
|
+
keyring,
|
|
12972
|
+
host: nodeHostServices(logger, { publicOnly: config.privateAddresses === "refuse" }),
|
|
12532
12973
|
logger,
|
|
12533
|
-
|
|
12534
|
-
|
|
12974
|
+
limiter: new MemoryRateLimiter(),
|
|
12975
|
+
version,
|
|
12976
|
+
isolation: "process",
|
|
12977
|
+
...adminVerifier === void 0 ? {} : { adminVerifier },
|
|
12978
|
+
...config.privateAddresses === "refuse" ? { checkInstanceHost: checkPublicHost } : {},
|
|
12979
|
+
clientIp: (c) => clientAddress(c.env?.incoming?.socket?.remoteAddress, c.req.header("x-forwarded-for"), config.trustedProxyHops),
|
|
12980
|
+
passthroughSecure: (c) => config.trustedProxyHops > 0 && c.req.header("x-forwarded-proto") === "https"
|
|
12535
12981
|
});
|
|
12536
12982
|
const stopRetention = scheduleAuditRetention(storage, config.auditRetentionDays, systemClock, logger);
|
|
12537
|
-
const hostServices = nodeHostServices(logger);
|
|
12538
|
-
const services = accessServices({
|
|
12539
|
-
storage,
|
|
12540
|
-
keyring,
|
|
12541
|
-
tools,
|
|
12542
|
-
environment: config.environment,
|
|
12543
|
-
validator: credentialValidator(hostServices)
|
|
12544
|
-
});
|
|
12545
12983
|
const stopRevalidation = scheduleRevalidation(services, config.revalidationHours, logger);
|
|
12546
|
-
const authErrors = revalidateOnAuthErrors(services, logger);
|
|
12547
|
-
const app = createHttpApp({
|
|
12548
|
-
storage,
|
|
12549
|
-
keyring,
|
|
12550
|
-
access: {
|
|
12551
|
-
tools,
|
|
12552
|
-
writesEnabled: false,
|
|
12553
|
-
environment: config.environment,
|
|
12554
|
-
limits: {
|
|
12555
|
-
defaults: {
|
|
12556
|
-
...DEFAULT_LIMITS,
|
|
12557
|
-
maxResponseBytes: config.maxResponseBytes,
|
|
12558
|
-
callBudget: config.callBudget,
|
|
12559
|
-
genericQuery: true
|
|
12560
|
-
},
|
|
12561
|
-
ceilings: {}
|
|
12562
|
-
}
|
|
12563
|
-
},
|
|
12564
|
-
tools: CORE_TOOLS,
|
|
12565
|
-
services: hostServices,
|
|
12566
|
-
...config.publicUrl === void 0 ? {} : { publicUrl: config.publicUrl },
|
|
12567
|
-
allowedHosts: config.allowedHosts,
|
|
12568
|
-
allowedOrigins: config.allowedOrigins,
|
|
12569
|
-
accessCacheTtlSeconds: config.accessCacheTtlSeconds,
|
|
12570
|
-
limits: createRequestLimits(new MemoryRateLimiter(), config.rateLimits, logger),
|
|
12571
|
-
...config.passthrough ? { passthrough: {
|
|
12572
|
-
validator: credentialValidator(hostServices),
|
|
12573
|
-
secure: (c) => config.trustedProxyHops > 0 && c.req.header("x-forwarded-proto") === "https"
|
|
12574
|
-
} } : {},
|
|
12575
|
-
...adminVerifier === void 0 ? {} : { admin: {
|
|
12576
|
-
services,
|
|
12577
|
-
audit,
|
|
12578
|
-
authenticate: adminTokenAuthenticator(adminVerifier)
|
|
12579
|
-
} },
|
|
12580
|
-
clientIp: (c) => clientAddress(c.env?.incoming?.socket?.remoteAddress, c.req.header("x-forwarded-for"), config.trustedProxyHops),
|
|
12581
|
-
observer: audit.observer({
|
|
12582
|
-
toolCall: (e) => authErrors(e.access.credential.id, e.errorCode),
|
|
12583
|
-
request: (e) => logger.info("request", {
|
|
12584
|
-
requestId: e.requestId,
|
|
12585
|
-
workspace: e.slug,
|
|
12586
|
-
status: e.status,
|
|
12587
|
-
method: e.mcp?.method ?? null,
|
|
12588
|
-
kind: e.mcp?.kind ?? null,
|
|
12589
|
-
tool: e.mcp?.tool ?? null,
|
|
12590
|
-
durationMs: e.durationMs,
|
|
12591
|
-
...e.refusal === null ? {} : { refusal: e.refusal }
|
|
12592
|
-
})
|
|
12593
|
-
}),
|
|
12594
|
-
version
|
|
12595
|
-
});
|
|
12596
12984
|
const server = await new Promise((resolve, reject) => {
|
|
12597
12985
|
const s = serve({
|
|
12598
12986
|
fetch: originFormOnly(app.fetch),
|
|
@@ -12658,7 +13046,7 @@ function readAdminToken(env, dataDir) {
|
|
|
12658
13046
|
return value || void 0;
|
|
12659
13047
|
}
|
|
12660
13048
|
/** Per-process services for Agility calls: the `fetch` pinned to an instance's TLS (custom CA), limiters, breakers. */
|
|
12661
|
-
function nodeHostServices(logger) {
|
|
13049
|
+
function nodeHostServices(logger, options = {}) {
|
|
12662
13050
|
const fetches = /* @__PURE__ */ new Map();
|
|
12663
13051
|
return {
|
|
12664
13052
|
fetchFor(instance) {
|
|
@@ -12666,7 +13054,10 @@ function nodeHostServices(logger) {
|
|
|
12666
13054
|
const key = `${instance.id}|${caFile ?? ""}`;
|
|
12667
13055
|
let f = fetches.get(key);
|
|
12668
13056
|
if (f === void 0) {
|
|
12669
|
-
f = createNodeFetch(
|
|
13057
|
+
f = createNodeFetch({
|
|
13058
|
+
...caFile === void 0 ? {} : { caFile },
|
|
13059
|
+
...options.publicOnly === true ? { publicOnly: true } : {}
|
|
13060
|
+
});
|
|
12670
13061
|
fetches.set(key, f);
|
|
12671
13062
|
}
|
|
12672
13063
|
return f;
|
|
@@ -12695,29 +13086,6 @@ function scheduleRevalidation(services, hours, logger) {
|
|
|
12695
13086
|
clearInterval(timer);
|
|
12696
13087
|
};
|
|
12697
13088
|
}
|
|
12698
|
-
/**
|
|
12699
|
-
* Revalidates a credential after 3 consecutive `AB_AGILITY_AUTH` errors (a revoked token), at most once per 10 minutes
|
|
12700
|
-
* per credential, so a dead token is marked invalid instead of failing every call (P2-13, A10).
|
|
12701
|
-
*/
|
|
12702
|
-
function revalidateOnAuthErrors(services, logger, now = Date.now) {
|
|
12703
|
-
const streaks = /* @__PURE__ */ new Map();
|
|
12704
|
-
const last = /* @__PURE__ */ new Map();
|
|
12705
|
-
return (credentialId, errorCode) => {
|
|
12706
|
-
if (errorCode !== "AB_AGILITY_AUTH") {
|
|
12707
|
-
streaks.delete(credentialId);
|
|
12708
|
-
return;
|
|
12709
|
-
}
|
|
12710
|
-
const n = (streaks.get(credentialId) ?? 0) + 1;
|
|
12711
|
-
streaks.set(credentialId, n);
|
|
12712
|
-
if (n < 3 || now() - (last.get(credentialId) ?? 0) < 6e5) return;
|
|
12713
|
-
last.set(credentialId, now());
|
|
12714
|
-
streaks.delete(credentialId);
|
|
12715
|
-
services.credentials.validate(credentialId).then((c) => logger.warn("credential revalidated after Agility refused it", {
|
|
12716
|
-
credential: c.id,
|
|
12717
|
-
status: c.validationStatus
|
|
12718
|
-
})).catch((error) => logger.error("credential revalidation failed", { error: error.name }));
|
|
12719
|
-
};
|
|
12720
|
-
}
|
|
12721
13089
|
//#endregion
|
|
12722
13090
|
//#region src/manage/client.ts
|
|
12723
13091
|
/** The admin API in process over the local database (owner rights: the operator holds the database and the key). */
|
|
@@ -12751,7 +13119,11 @@ async function localClient(env, logger) {
|
|
|
12751
13119
|
keyring,
|
|
12752
13120
|
tools,
|
|
12753
13121
|
environment: config.environment,
|
|
12754
|
-
validator: credentialValidator(nodeHostServices(logger))
|
|
13122
|
+
validator: credentialValidator(nodeHostServices(logger, { publicOnly: config.privateAddresses === "refuse" })),
|
|
13123
|
+
instanceUrls: {
|
|
13124
|
+
httpLoopback: config.allowHttpInstances,
|
|
13125
|
+
...config.privateAddresses === "refuse" ? { checkHost: checkPublicHost } : {}
|
|
13126
|
+
}
|
|
12755
13127
|
}),
|
|
12756
13128
|
storage,
|
|
12757
13129
|
tools,
|
|
@@ -12761,7 +13133,8 @@ async function localClient(env, logger) {
|
|
|
12761
13133
|
clientIp: () => "local",
|
|
12762
13134
|
logger,
|
|
12763
13135
|
clock: systemClock,
|
|
12764
|
-
via: "cli"
|
|
13136
|
+
via: "cli",
|
|
13137
|
+
keyring
|
|
12765
13138
|
});
|
|
12766
13139
|
return {
|
|
12767
13140
|
mode: "local",
|
|
@@ -12958,9 +13331,15 @@ const COMMANDS = [
|
|
|
12958
13331
|
name: "key-file",
|
|
12959
13332
|
kind: "string",
|
|
12960
13333
|
help: "Where to write it (default ~/.config/agility-bridge/master.key)"
|
|
13334
|
+
}, {
|
|
13335
|
+
name: "version",
|
|
13336
|
+
kind: "string",
|
|
13337
|
+
help: "Key version (default 1); a rotation needs one higher than the current key"
|
|
12961
13338
|
}],
|
|
12962
13339
|
async run(r, p) {
|
|
12963
|
-
const
|
|
13340
|
+
const version = p.opts["version"] === void 0 ? 1 : Number(p.opts["version"]);
|
|
13341
|
+
if (!Number.isInteger(version) || version < 1 || version > 1e6) throw new ConfigError("--version is a whole number ≥ 1.");
|
|
13342
|
+
const path = createMasterKeyFile(typeof p.opts["key-file"] === "string" ? p.opts["key-file"] : defaultMasterKeyFile(r.env), version);
|
|
12964
13343
|
r.out(JSON.stringify({ masterKeyFile: path }));
|
|
12965
13344
|
r.err(`agility-bridge: wrote a new master key to ${path}. Start the server with AB_MASTER_KEY_FILE=${path}, and back the file up away from the database (docs/guide/secrets.md).`);
|
|
12966
13345
|
return 0;
|
|
@@ -13485,8 +13864,11 @@ const COMMANDS = [
|
|
|
13485
13864
|
},
|
|
13486
13865
|
{
|
|
13487
13866
|
words: ["keys", "status"],
|
|
13488
|
-
summary: "Credentials per master-key version (
|
|
13489
|
-
|
|
13867
|
+
summary: "Credentials per master-key version (owner)",
|
|
13868
|
+
api: async () => ({
|
|
13869
|
+
method: "GET",
|
|
13870
|
+
path: "/keys"
|
|
13871
|
+
})
|
|
13490
13872
|
},
|
|
13491
13873
|
{
|
|
13492
13874
|
words: ["keys", "rotate"],
|
|
@@ -13504,7 +13886,7 @@ const COMMANDS = [
|
|
|
13504
13886
|
},
|
|
13505
13887
|
{
|
|
13506
13888
|
words: ["keys", "retire"],
|
|
13507
|
-
summary: "Retire a master-key version no credential uses any more (
|
|
13889
|
+
summary: "Retire a master-key version no credential uses any more (owner)",
|
|
13508
13890
|
args: [id("version", "Key version, e.g. 1")],
|
|
13509
13891
|
options: [{
|
|
13510
13892
|
name: "reason",
|
|
@@ -13512,14 +13894,14 @@ const COMMANDS = [
|
|
|
13512
13894
|
help: "Why, for the audit log",
|
|
13513
13895
|
required: true
|
|
13514
13896
|
}],
|
|
13515
|
-
|
|
13897
|
+
api: async (_r, p) => {
|
|
13516
13898
|
const version = Number(p.args[0]);
|
|
13517
13899
|
if (!Number.isInteger(version) || version < 1) throw new ConfigError("The version is a whole number ≥ 1.");
|
|
13518
|
-
|
|
13519
|
-
|
|
13520
|
-
|
|
13521
|
-
|
|
13522
|
-
|
|
13900
|
+
return {
|
|
13901
|
+
method: "POST",
|
|
13902
|
+
path: `/keys/${version}/retire`,
|
|
13903
|
+
body: { reason: String(p.opts["reason"]) }
|
|
13904
|
+
};
|
|
13523
13905
|
}
|
|
13524
13906
|
}
|
|
13525
13907
|
];
|