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.
Files changed (2) hide show
  1. package/dist/index.mjs +1544 -1162
  2. 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 { homedir } from "node:os";
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.2.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
- if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) throw invalid$1("must use https://");
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.#cache.inflight.get(name);
1343
+ let pending = this.#inflight.get(name);
1252
1344
  if (pending === void 0) {
1253
- pending = this.#load(name).finally(() => this.#cache.inflight.delete(name));
1254
- this.#cache.inflight.set(name, pending);
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.2.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$2(name, value, pattern, what) {
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$2("AB_PROJECTS", value, SCOPE_OID$1, "Agility project (use Scope:1234)");
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$2("AB_TOOLS", env["AB_TOOLS"], TOOL_NAME$1, "tool name") === void 0 ? {} : { tools: list$2("AB_TOOLS", env["AB_TOOLS"], TOOL_NAME$1, "tool name") },
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$2("AB_ASSET_TYPES_ALLOW", env["AB_ASSET_TYPES_ALLOW"], TYPE_NAME$1, "asset type") === void 0 ? {} : { allow: list$2("AB_ASSET_TYPES_ALLOW", env["AB_ASSET_TYPES_ALLOW"], TYPE_NAME$1, "asset type") },
5823
- deny: list$2("AB_ASSET_TYPES_DENY", env["AB_ASSET_TYPES_DENY"], TYPE_NAME$1, "asset type") ?? []
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/access/guards.ts
6979
- /** The actor may not do this (HTTP 403). The message says which role or condition is needed. */
6980
- var ForbiddenError = class extends Error {
6981
- code = "forbidden";
6982
- constructor(message) {
6983
- super(message);
6984
- this.name = "ForbiddenError";
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 roleOf = (a) => a.kind === "break_glass" ? "owner" : a.role;
6988
- const userIdOf = (a) => a.kind === "user" ? a.userId : null;
6989
- const isOwner = (a) => roleOf(a) === "owner";
6990
- const isManager = (a) => roleOf(a) === "owner" || roleOf(a) === "admin";
6991
- function need(ok, message) {
6992
- if (!ok) throw new ForbiddenError(message);
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
- const needManager = (a) => need(isManager(a), "This needs an owner or an admin.");
6995
- const needOwner = (a, what) => need(isOwner(a), `Only an owner can ${what}.`);
6996
- /** The services, each call checked against the actor's role (and, for members, ownership). */
6997
- function guardedServices(services, ctx, actor) {
6998
- const { storage, tools } = ctx;
6999
- const writeTools = (names) => (names ?? []).filter((n) => tools.get(n) !== void 0 && tools.get(n)?.kind !== "read");
7000
- const self = userIdOf(actor);
7001
- async function load(row, what) {
7002
- const r = await row;
7003
- if (r === null) throw new NotFoundError(`No ${what} with this id.`);
7004
- return r;
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
- /** Members see only their own records: anything else is "not found", never "forbidden" (no existence oracle). */
7007
- function visible(row, mine) {
7008
- if (row === null || isManager(actor)) return row;
7009
- return mine(row) ? row : null;
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
- async function activeOwners() {
7012
- return (await storage.users.list()).filter((u) => u.role === "owner" && u.disabledAt === null);
7205
+ has(version) {
7206
+ return this.#roots.has(version);
7013
7207
  }
7014
- const users = {
7015
- async list() {
7016
- if (isManager(actor)) return storage.users.list();
7017
- const me = self === null ? null : await storage.users.get(self);
7018
- return me === null ? [] : [me];
7019
- },
7020
- async get(id) {
7021
- return visible(await storage.users.get(id), (u) => u.id === self);
7022
- },
7023
- async create(input) {
7024
- needManager(actor);
7025
- if (input.role !== "member") needOwner(actor, "create owners or admins");
7026
- return services.users.create(input);
7027
- },
7028
- async update(id, patch) {
7029
- needManager(actor);
7030
- const target = await load(storage.users.get(id), "user");
7031
- if (target.role !== "member" || patch.role !== void 0 && patch.role !== "member") needOwner(actor, "change owners, admins or roles above member");
7032
- 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.");
7033
- return services.users.update(id, patch);
7034
- },
7035
- async disable(id) {
7036
- needManager(actor);
7037
- const target = await load(storage.users.get(id), "user");
7038
- if (target.role !== "member") needOwner(actor, "disable owners or admins");
7039
- if (target.role === "owner") need((await activeOwners()).filter((u) => u.id !== id).length > 0, "The last active owner cannot be disabled.");
7040
- return services.users.disable(id);
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
- const mineCredential = (c) => c.ownerUserId !== null && c.ownerUserId === self;
7066
- const credentials = {
7067
- async list(filter = {}) {
7068
- if (isManager(actor)) return storage.credentials.list(filter);
7069
- if (self === null) return [];
7070
- return storage.credentials.list({
7071
- ...filter,
7072
- ownerUserId: self
7073
- });
7074
- },
7075
- async get(id) {
7076
- return visible(await storage.credentials.get(id), mineCredential);
7077
- },
7078
- /** Shared: owners and admins. Personal: only the user themselves (nobody stores a token for someone else). */
7079
- async create(input) {
7080
- if (input.ownerUserId === null) needManager(actor);
7081
- else need(input.ownerUserId === self, "Only the user themselves can store their personal Agility token.");
7082
- return services.credentials.create(input);
7083
- },
7084
- async replaceToken(id, token) {
7085
- const c = await load(storage.credentials.get(id), "credential");
7086
- if (c.ownerUserId === null) needManager(actor);
7087
- else if (!mineCredential(c)) throw new NotFoundError("No credential with this id.");
7088
- return services.credentials.replaceToken(id, token);
7089
- },
7090
- async update(id, patch) {
7091
- const c = await load(storage.credentials.get(id), "credential");
7092
- if (!mineCredential(c) && !isManager(actor)) throw new NotFoundError("No credential with this id.");
7093
- return services.credentials.update(id, patch);
7094
- },
7095
- async validate(id) {
7096
- const c = await load(storage.credentials.get(id), "credential");
7097
- if (!mineCredential(c) && !isManager(actor)) throw new NotFoundError("No credential with this id.");
7098
- return services.credentials.validate(id);
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
- const mineGrant = (g) => g.userId !== null && g.userId === self;
7146
- const grants = {
7147
- async list(filter = {}) {
7148
- if (isManager(actor)) return storage.grants.list(filter);
7149
- if (self === null) return [];
7150
- return storage.grants.list({
7151
- ...filter,
7152
- userId: self
7153
- });
7154
- },
7155
- async get(id) {
7156
- return visible(await storage.grants.get(id), mineGrant);
7157
- },
7158
- async create(input) {
7159
- needManager(actor);
7160
- if (writeTools(input.tools).length > 0) needOwner(actor, "grant write tools");
7161
- return services.grants.create(input);
7162
- },
7163
- async update(id, patch) {
7164
- needManager(actor);
7165
- if (writeTools(patch.tools).length > 0) needOwner(actor, "grant write tools");
7166
- const current = await load(storage.grants.get(id), "grant");
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
- return Object.freeze({
7195
- actor,
7196
- users,
7197
- instances,
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
- workspaces,
7200
- grants,
7201
- tokens: {
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
- * Loads the records `resolveAccess` needs for one grant. Reads only; whatever is missing comes back `null` and the
7221
- * resolver denies. (On D1 this becomes one `batch()`, P3-02.)
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 loadAccessRecords(storage, grantId) {
7224
- const grant = await storage.grants.get(grantId);
7225
- if (grant === null) return {
7226
- grant: null,
7227
- workspace: null,
7228
- instance: null,
7229
- credential: null,
7230
- user: null
7231
- };
7232
- const [workspace, user] = await Promise.all([storage.workspaces.get(grant.workspaceId), grant.userId === null ? Promise.resolve(null) : storage.users.get(grant.userId)]);
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
- //#endregion
7251
- //#region ../../packages/auth/src/access/policy.ts
7252
- const SCOPE_OID = /^Scope:\d{1,12}$/;
7253
- const TYPE_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
7254
- const TOOL_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
7255
- const scopeIds = z.array(z.string().regex(SCOPE_OID)).max(1e3);
7256
- const toolNames = z.array(z.string().regex(TOOL_NAME).max(64)).max(200);
7257
- const typeNames = z.array(z.string().regex(TYPE_NAME)).max(500);
7258
- const assetTypePolicySchema = z.strictObject({
7259
- /** Only these types (absent = every type the instance exposes). */
7260
- allow: typeNames.optional(),
7261
- deny: typeNames.optional()
7262
- });
7263
- /** Numeric limits with their hard bounds, and feature switches (tool conventions, ADR-0010). */
7264
- const limitsPolicySchema = z.strictObject({
7265
- defaultRows: z.number().int().min(1).max(1e3).optional(),
7266
- maxRows: z.number().int().min(1).max(1e3).optional(),
7267
- maxFields: z.number().int().min(1).max(200).optional(),
7268
- maxNestingDepth: z.number().int().min(0).max(5).optional(),
7269
- maxAggregations: z.number().int().min(0).max(50).optional(),
7270
- maxAnyOfGroups: z.number().int().min(0).max(20).optional(),
7271
- maxResponseBytes: z.number().int().min(65536).max(67108864).optional(),
7272
- maxAsOfAgeDays: z.number().int().min(0).max(3650).optional(),
7273
- maxTimeRangeDays: z.number().int().min(1).max(3660).optional(),
7274
- /** Agility requests per tool call (ADR-0010). */
7275
- callBudget: z.number().int().min(1).max(1e4).optional(),
7276
- /** Member emails in output (D-06). */
7277
- memberEmails: z.boolean().optional(),
7278
- /** The generic `query_assets` / `get_assets` tools. */
7279
- genericQuery: z.boolean().optional()
7280
- });
7281
- /** Writes per workspace (ADR-0013); write tools also need the server switch and an explicit listing (Phase 7). */
7282
- const writePolicySchema = z.strictObject({ enabled: z.boolean().optional() });
7283
- /** Per-grant rate limit (P2-07). */
7284
- const rateLimitPolicySchema = z.strictObject({ perMinute: z.number().int().min(1).max(1e5).optional() });
7285
- const workspacePolicySchema = z.strictObject({
7286
- allowedScopeIds: scopeIds.min(1),
7287
- includeChildScopes: z.boolean(),
7288
- assetTypes: assetTypePolicySchema.nullable(),
7289
- enabledTools: toolNames.nullable(),
7290
- limits: limitsPolicySchema,
7291
- writePolicy: writePolicySchema
7292
- });
7293
- const grantPolicySchema = z.strictObject({
7294
- tools: toolNames.nullable(),
7295
- scopeIds: scopeIds.min(1).nullable(),
7296
- assetTypes: assetTypePolicySchema.nullable(),
7297
- limits: limitsPolicySchema,
7298
- rateLimit: rateLimitPolicySchema.nullable()
7299
- });
7300
- /** The numbers and switches of `limitsPolicySchema` and how a grant tightens each. */
7301
- const NUMBER_LIMITS = [
7302
- "defaultRows",
7303
- "maxRows",
7304
- "maxFields",
7305
- "maxNestingDepth",
7306
- "maxAggregations",
7307
- "maxAnyOfGroups",
7308
- "maxResponseBytes",
7309
- "maxAsOfAgeDays",
7310
- "maxTimeRangeDays",
7311
- "callBudget"
7312
- ];
7313
- const SWITCHES = ["memberEmails", "genericQuery"];
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
- out["defaultRows"] = Math.min(out["defaultRows"], out["maxRows"]);
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
- * Decides what one request may do in the workspace `slug`. Pure: the same records, settings and time give the same
7385
- * answer, so it can be cached (≤ 60 s, never past `expiresAt`, L-068) and tested exhaustively.
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 resolveAccess(principal, slug, records, settings, now) {
7388
- const { grant, workspace, instance, credential, user } = records;
7389
- if (workspace === null) return deny("workspace_missing");
7390
- if (workspace.slug !== slug) return deny("wrong_workspace");
7391
- if (principal.kind === "oauth" && principal.workspaceId !== workspace.id) return deny("wrong_workspace");
7392
- if (principal.kind === "device_token") {
7393
- if (principal.token.revokedAt !== null) return deny("token_revoked");
7394
- if (expired(principal.token.expiresAt, now)) return deny("token_expired");
7395
- } else if (expired(principal.expiresAt, now)) return deny("token_expired");
7396
- if (grant === null) return deny("grant_missing");
7397
- const grantId = principal.kind === "device_token" ? principal.token.grantId : principal.grantId;
7398
- if (grant.id !== grantId || grant.workspaceId !== workspace.id) return deny("grant_missing");
7399
- if (grant.revokedAt !== null) return deny("grant_revoked");
7400
- if (expired(grant.expiresAt, now)) return deny("grant_expired");
7401
- if (workspace.disabledAt !== null) return deny("workspace_disabled");
7402
- if (instance === null || instance.id !== workspace.instanceId) return deny("instance_missing");
7403
- if (instance.disabledAt !== null) return deny("instance_disabled");
7404
- if (grant.userId !== null) {
7405
- if (user === null || user.id !== grant.userId) return deny("user_missing");
7406
- if (user.disabledAt !== null) return deny("user_disabled");
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
- const credentialId = grant.credentialId ?? workspace.defaultCredentialId;
7409
- if (credentialId === null || credential === null || credential.id !== credentialId) return deny("credential_missing");
7410
- if (credential.revokedAt !== null) return deny("credential_revoked");
7411
- if (credential.validationStatus !== "valid") return deny("credential_unvalidated");
7412
- if (credential.instanceId !== workspace.instanceId) return deny("credential_mismatch");
7413
- const kind = credential.ownerUserId === null ? "shared" : "personal";
7414
- if (kind === "personal" && (grant.userId === null || credential.ownerUserId !== grant.userId)) return deny("credential_mismatch");
7415
- if (grant.credentialId === null && kind !== "shared") return deny("credential_mismatch");
7416
- const ws = parseWorkspace(workspace);
7417
- const gp = parseGrant(grant);
7418
- if (ws === null || gp === null) return deny("malformed_policy");
7419
- const warnings = [];
7420
- let projects = ws.allowedScopeIds;
7421
- if (gp.scopeIds !== null) {
7422
- const outside = gp.scopeIds.filter((s) => !ws.allowedScopeIds.includes(s));
7423
- if (outside.length > 0) warnings.push(`The grant names projects outside the workspace: ${outside.join(", ")}.`);
7424
- projects = ws.allowedScopeIds.filter((s) => gp.scopeIds.includes(s));
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
- if (credential.reachableScopeIds !== null) {
7427
- const unreachable = projects.filter((s) => !credential.reachableScopeIds.includes(s));
7428
- if (unreachable.length > 0) warnings.push(`The credential's member could not see ${unreachable.join(", ")} when it was last validated.`);
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
- if (projects.length === 0) return deny("no_projects");
7431
- const { limits, warnings: limitWarnings } = effectiveLimits(settings.limits, ws.limits, gp.limits);
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
- if (tools.length === 0) return deny("no_tools");
7457
- const ends = [principal.kind === "device_token" ? principal.token.expiresAt : principal.expiresAt, grant.expiresAt].filter((t) => t !== null);
7458
- const assetTypes = effectiveAssetTypes(ws.assetTypes, gp.assetTypes);
7459
- return {
7460
- ok: true,
7461
- access: Object.freeze({
7462
- workspace: {
7463
- id: workspace.id,
7464
- slug: workspace.slug,
7465
- name: workspace.name
7466
- },
7467
- instance: {
7468
- id: instance.id,
7469
- baseUrl: instance.baseUrl,
7470
- tls: instance.tls
7471
- },
7472
- grant: {
7473
- id: grant.id,
7474
- userId: grant.userId,
7475
- label: grant.label
7476
- },
7477
- tokenId: principal.kind === "device_token" ? principal.token.id : null,
7478
- credential: {
7479
- id: credential.id,
7480
- kind,
7481
- sealed: {
7482
- tokenCiphertext: credential.tokenCiphertext,
7483
- tokenIv: credential.tokenIv,
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
- * Access on a pass-through workspace (P2-14, D-04): the caller authenticated with its own Agility token, already
7503
- * validated against the workspace's instance and known here only by its HMAC fingerprint. Every workspace control
7504
- * applies unchanged (projects, asset types, tools, limits); there is no grant, so nothing can widen or narrow them, and
7505
- * write tools are never granted (read-only in 1.0).
7506
- */
7507
- function resolvePassthroughAccess(slug, records, fingerprint, settings) {
7508
- const { workspace, instance } = records;
7509
- if (workspace === null || workspace.slug !== slug) return deny("wrong_workspace");
7510
- if (workspace.credentialSource !== "passthrough") return deny("not_passthrough");
7511
- if (workspace.disabledAt !== null) return deny("workspace_disabled");
7512
- if (instance === null || instance.id !== workspace.instanceId) return deny("instance_missing");
7513
- if (instance.disabledAt !== null) return deny("instance_disabled");
7514
- const ws = parseWorkspace(workspace);
7515
- if (ws === null) return deny("malformed_policy");
7516
- const { limits } = effectiveLimits(settings.limits, ws.limits, {});
7517
- const known = settings.tools;
7518
- const tools = (ws.enabledTools ?? [...known.keys()]).filter((name) => {
7519
- const info = known.get(name);
7520
- return info !== void 0 && info.kind === "read" && (limits.genericQuery || info.group !== "query");
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
- //#endregion
7563
- //#region ../../packages/auth/src/secrets/keyring.ts
7564
- /** Fixed purpose labels; a key derived for one purpose is useless for another. */
7565
- const KeyPurpose = {
7566
- /** Agility access tokens of credentials. */
7567
- Credentials: "credentials",
7568
- /** Key-check values that detect a wrong master key. */
7569
- KeyCheck: "key-check",
7570
- /** Keyed hashes of identities in the audit log (pass-through token fingerprints). */
7571
- AuditIdentity: "audit-identity",
7572
- /** Cache entry ids (P8-01). */
7573
- CacheId: "cache-id",
7574
- /** Write approval records (P7-01). */
7575
- Approval: "approval",
7576
- /** Task records (P8-10). */
7577
- Task: "task"
7578
- };
7579
- const ENCRYPTION = /* @__PURE__ */ new Set([
7580
- KeyPurpose.Credentials,
7581
- KeyPurpose.Approval,
7582
- KeyPurpose.Task
7583
- ]);
7584
- /** A failure of the secrets layer. Messages never contain key material, ciphertext or plaintext. */
7585
- var SecretsError = class extends Error {
7586
- code;
7587
- constructor(code, message, options) {
7588
- super(message, options);
7589
- this.name = "SecretsError";
7590
- this.code = code;
7591
- }
7592
- };
7593
- const KEY_BYTES = 32;
7594
- const IV_BYTES = 12;
7595
- const MASTER_KEY = /^(?:v([1-9][0-9]{0,2}):)?([A-Za-z0-9+/]{43}=)$/;
7596
- const HKDF_SALT = new TextEncoder().encode("agility-bridge/hkdf/v1");
7597
- function toBase64(bytes) {
7598
- let s = "";
7599
- for (const b of bytes) s += String.fromCharCode(b);
7600
- return btoa(s);
7601
- }
7602
- function fromBase64(text) {
7603
- const s = atob(text);
7604
- const out = new Uint8Array(s.length);
7605
- for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
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
- return new Keyring(cur.version, roots);
7665
- }
7666
- has(version) {
7667
- return this.#roots.has(version);
7668
- }
7669
- #key(purpose, version) {
7670
- const root = this.#roots.get(version);
7671
- if (root === void 0) return Promise.reject(new SecretsError("key_unavailable", `Master key version ${version} is not loaded (AB_MASTER_KEY_PREVIOUS?).`));
7672
- const id = `${version}/${purpose}`;
7673
- let key = this.#derived.get(id);
7674
- if (key === void 0) {
7675
- const info = new TextEncoder().encode(`agility-bridge/${purpose}/v${version}`);
7676
- const algorithm = {
7677
- name: "HKDF",
7678
- hash: "SHA-256",
7679
- salt: HKDF_SALT,
7680
- info
7681
- };
7682
- key = ENCRYPTION.has(purpose) ? crypto.subtle.deriveKey(algorithm, root, {
7683
- name: "AES-GCM",
7684
- length: 256
7685
- }, false, ["encrypt", "decrypt"]) : crypto.subtle.deriveKey(algorithm, root, {
7686
- name: "HMAC",
7687
- hash: "SHA-256",
7688
- length: 256
7689
- }, false, ["sign"]);
7690
- this.#derived.set(id, key);
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
- return key;
7693
- }
7694
- /** Encrypts with the current key; `context` (a record id) and the version are bound as additional data. */
7695
- async seal(purpose, context, plaintext) {
7696
- if (!ENCRYPTION.has(purpose)) throw new TypeError(`${purpose} is not an encryption purpose.`);
7697
- const version = this.currentVersion;
7698
- const key = await this.#key(purpose, version);
7699
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
7700
- const data = await crypto.subtle.encrypt({
7701
- name: "AES-GCM",
7702
- iv,
7703
- additionalData: additionalData(context, version),
7704
- tagLength: 128
7705
- }, key, new TextEncoder().encode(plaintext));
7706
- return Object.freeze({
7707
- ciphertext: toBase64Url(new Uint8Array(data)),
7708
- iv: toBase64Url(iv),
7709
- keyVersion: version
7710
- });
7711
- }
7712
- /** Decrypts a sealed value; a wrong key, context or version, or any tampering, fails with `decrypt_failed`. */
7713
- async open(purpose, context, sealed) {
7714
- if (!ENCRYPTION.has(purpose)) throw new TypeError(`${purpose} is not an encryption purpose.`);
7715
- const key = await this.#key(purpose, sealed.keyVersion);
7716
- const iv = fromBase64Url(sealed.iv);
7717
- if (iv.length !== IV_BYTES) throw new SecretsError("decrypt_failed", "The sealed value is malformed.");
7718
- try {
7719
- const data = await crypto.subtle.decrypt({
7720
- name: "AES-GCM",
7721
- iv,
7722
- additionalData: additionalData(context, sealed.keyVersion),
7723
- tagLength: 128
7724
- }, key, fromBase64Url(sealed.ciphertext));
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
- /** HMAC-SHA-256 (hex) with the purpose key of `version` (default current). */
7731
- async mac(purpose, data, version = this.currentVersion) {
7732
- if (ENCRYPTION.has(purpose)) throw new TypeError(`${purpose} is an encryption purpose.`);
7733
- const key = await this.#key(purpose, version);
7734
- return hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data)));
7735
- }
7736
- /** The key-check value of a version: proves the key without revealing it. */
7737
- keyCheckValue(version) {
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
- /** Constant-time comparison of two strings of equal length (false otherwise). */
7742
- function timingSafeEqual(a, b) {
7743
- if (a.length !== b.length) return false;
7744
- let diff = 0;
7745
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
7746
- return diff === 0;
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/secrets/credentials.ts
7750
- const KCV_KEY = (version) => `crypto:kcv:v${version}`;
7751
- const RETIRED_KEY = (version) => `crypto:retired:v${version}`;
7752
- /** At most the last 4 characters, and nothing for short values. */
7753
- function tokenHint(token) {
7754
- return token.length >= 16 ? token.slice(-4) : "";
7755
- }
7756
- /** Seals an Agility access token for the credential `credentialId` (additional data: `id | key version`). */
7757
- async function sealCredentialToken(keyring, credentialId, token) {
7758
- if (typeof token !== "string" || token.length === 0) throw new TypeError("An access token is required.");
7759
- const sealed = await keyring.seal(KeyPurpose.Credentials, credentialId, token);
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
- /** Decrypts a credential's token. Swapping ciphertexts between credentials or versions fails (`decrypt_failed`). */
7773
- function openCredentialToken(keyring, credential) {
7774
- return keyring.open(KeyPurpose.Credentials, credential.id, toSealed(credential));
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
- * Boot check: every loaded version's key-check value must match the one recorded the first time that version was
7778
- * used, so a wrong key is refused before it decrypts (or seals) anything, and a version number is never reused for
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
- async function verifyKeyring(storage, keyring) {
7796
+ function effectiveLimits(deployment, workspace, grant) {
7782
7797
  const warnings = [];
7783
- for (const version of keyring.versions) {
7784
- const kcv = await keyring.keyCheckValue(version);
7785
- if (!await storage.kv.setIfAbsent(KCV_KEY(version), kcv)) {
7786
- const recorded = await storage.kv.get(KCV_KEY(version));
7787
- 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.`);
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
- 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.`);
7791
- 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.`);
7792
- const stranded = (await keyStatus(storage, keyring)).filter((v) => !v.loaded && v.credentials > 0);
7793
- 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.`);
7794
- return { warnings };
7795
- }
7796
- /** Credentials per key version, and which versions are loaded, current or retired. */
7797
- async function keyStatus(storage, keyring) {
7798
- const counts = /* @__PURE__ */ new Map();
7799
- for (const c of await storage.credentials.list()) counts.set(c.keyVersion, (counts.get(c.keyVersion) ?? 0) + 1);
7800
- for (const v of keyring.versions) if (!counts.has(v)) counts.set(v, 0);
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
- * Re-encrypts every credential still on an older loaded version with the current key. Each row is replaced only if it
7813
- * still holds the ciphertext that was decrypted (compare-and-swap), so concurrent edits and interrupted runs are safe:
7814
- * run it again to resume.
7815
- */
7816
- async function rotateCredentialKeys(storage, keyring, options) {
7817
- const reason = String(options.reason ?? "").slice(0, 200);
7818
- if (reason.length === 0) throw new TypeError("A rotation reason is required for the audit log.");
7819
- const batchSize = Math.max(1, Math.min(options.batchSize ?? 100, 1e3));
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
- const doneWith = /* @__PURE__ */ new Set();
7828
- const details = (from) => ({
7829
- fromVersion: from,
7830
- toVersion: keyring.currentVersion,
7831
- reason,
7832
- ...options.actor?.via !== void 0 ? { via: options.actor.via } : {}
7833
- });
7834
- for (let batch = 0; options.maxBatches === void 0 || batch < options.maxBatches; batch++) {
7835
- const pending = (await storage.credentials.list()).filter((c) => c.keyVersion !== keyring.currentVersion && !doneWith.has(c.id)).slice(0, batchSize);
7836
- if (pending.length === 0) break;
7837
- for (const c of pending) {
7838
- const expected = {
7839
- tokenCiphertext: c.tokenCiphertext,
7840
- tokenIv: c.tokenIv,
7841
- keyVersion: c.keyVersion
7842
- };
7843
- let token;
7844
- try {
7845
- token = await openCredentialToken(keyring, c);
7846
- } catch (error) {
7847
- const reasonCode = error instanceof SecretsError && error.code === "key_unavailable" ? error.code : "decrypt_failed";
7848
- doneWith.add(c.id);
7849
- result.failed.push({
7850
- id: c.id,
7851
- keyVersion: c.keyVersion,
7852
- reason: reasonCode
7853
- });
7854
- await storage.audit.append({
7855
- id: crypto.randomUUID(),
7856
- ts: now(),
7857
- kind: "admin",
7858
- action: "credential.rekey",
7859
- status: "error",
7860
- errorCode: reasonCode,
7861
- userId: options.actor?.userId ?? null,
7862
- instanceId: c.instanceId,
7863
- targetType: "credential",
7864
- targetId: c.id,
7865
- details: details(c.keyVersion)
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
- const sealed = await sealCredentialToken(keyring, c.id, token);
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
- result.remaining = (await storage.credentials.list()).filter((c) => c.keyVersion !== keyring.currentVersion).length;
7896
- return result;
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
- * Marks a key version as retired once no credential uses it; a retired version is refused as the current key at
7900
- * boot. The previous key can then be removed from the configuration.
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
- async function retireKeyVersion(storage, keyring, version, options) {
7903
- if (version === keyring.currentVersion) throw new SecretsError("in_use", `Master key version ${version} is the current key.`);
7904
- const users = await storage.credentials.list({ keyVersion: version });
7905
- if (users.length > 0) throw new SecretsError("in_use", `${users.length} credential(s) still use master key version ${version}; rotate them first.`);
7906
- const now = options.now ?? Date.now;
7907
- await storage.kv.set(RETIRED_KEY(version), String(now()));
7908
- await storage.audit.append({
7909
- id: crypto.randomUUID(),
7910
- ts: now(),
7911
- kind: "admin",
7912
- action: "crypto.retire_key",
7913
- status: "ok",
7914
- userId: options.actor?.userId ?? null,
7915
- targetType: "key_version",
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.getByHash(hash);
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, await loadAccessRecords(storage, token.grantId), settings, now);
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
- return Object.freeze({
8296
- users: {
8297
- async create(input) {
8298
- const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : input.email;
8299
- return storage.users.create({
8300
- ...input,
8301
- id: newId("usr"),
8302
- email
8303
- });
8304
- },
8305
- async update(id, patch) {
8306
- const email = typeof patch.email === "string" ? patch.email.trim().toLowerCase() : void 0;
8307
- return storage.users.update(id, email === void 0 ? patch : {
8308
- ...patch,
8309
- email
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 = canonicalInstanceUrl(input.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 = canonicalInstanceUrl(target.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
- async revalidate(olderThan) {
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 await storage.credentials.list()) {
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 api = new OpenAPIHono({ defaultHook: (result, c) => {
10567
- if (!result.success) {
10568
- const issue = result.error.issues[0];
10569
- const field = issue?.path.map(String).join(".") || void 0;
10570
- return c.json(errorBody("invalid_input", `${field ?? "request"}: ${issue?.message ?? "invalid"}.`, field), 422);
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 ?? ""), { fields: Object.keys(body).filter((k) => k !== "token") });
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 observe = (kind, event) => {
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) app.route(ADMIN_BASE, createAdminApi({
11491
- ...options.admin,
11492
- storage: options.storage,
11493
- tools: options.access.tools,
11494
- onChange: () => cache.clear(),
11495
- ...options.limits === void 0 ? {} : { limits: options.limits },
11496
- clientIp,
11497
- logger: services.logger,
11498
- clock: services.clock
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
- /** Queues a row; never throws, never blocks the request. */
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
- this.#tail = this.#tail.then(() => this.#options.storage.audit.append(entry)).catch((error) => {
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
- if (w.suppressed > 0) this.enqueue({
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
- this.request(e);
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 bounded batches. Returns the number deleted.
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 < 1e3; 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 list$1(value) {
12430
- return (value ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
12431
- }
12432
- function integer$1(name, value, fallback, min, max) {
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
- const level = env["AB_LOG_LEVEL"] ?? "info";
12441
- if (![
12442
- "debug",
12443
- "info",
12444
- "warn",
12445
- "error"
12446
- ].includes(level)) throw new ConfigError("AB_LOG_LEVEL must be debug, info, warn or error.");
12447
- const environment = env["AB_ENVIRONMENT"] ?? "production";
12448
- if (environment !== "production" && environment !== "test") throw new ConfigError("AB_ENVIRONMENT must be production (device tokens ab_live_…) or test (ab_test_…).");
12449
- let publicUrl;
12450
- if (env["AB_PUBLIC_URL"] !== void 0 && env["AB_PUBLIC_URL"] !== "") try {
12451
- const u = new URL(env["AB_PUBLIC_URL"]);
12452
- if (u.protocol !== "https:" && u.protocol !== "http:" || u.username !== "" || u.search !== "" || u.hash !== "") throw new Error("bad");
12453
- publicUrl = u.origin;
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
- if (env["AB_WRITE_TOOLS"] === "on") logger.warn("AB_WRITE_TOOLS is on, but this version has no write tools.");
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 tools = new Map(CORE_TOOLS.map((t) => [t.name, {
12527
- kind: t.kind,
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
- clock: systemClock,
12534
- random: webCryptoRandom
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(caFile === void 0 ? {} : { caFile });
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 path = createMasterKeyFile(typeof p.opts["key-file"] === "string" ? p.opts["key-file"] : defaultMasterKeyFile(r.env));
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 (local)",
13489
- local: async ({ storage, keyring }) => keyStatus(storage, keyring)
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 (local)",
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
- local: async ({ storage, keyring }, p) => {
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
- await retireKeyVersion(storage, keyring, version, {
13519
- reason: String(p.opts["reason"]),
13520
- actor: { via: "cli" }
13521
- });
13522
- return { retired: version };
13900
+ return {
13901
+ method: "POST",
13902
+ path: `/keys/${version}/retire`,
13903
+ body: { reason: String(p.opts["reason"]) }
13904
+ };
13523
13905
  }
13524
13906
  }
13525
13907
  ];