toolroll 0.9.57 → 0.9.58

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 (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/action-ledger.js +0 -4
  3. package/dist/actor.d.ts +0 -10
  4. package/dist/actor.js +1 -18
  5. package/dist/api-tokens.d.ts +33 -2
  6. package/dist/api-tokens.js +39 -11
  7. package/dist/assignment-delivery.js +1 -1
  8. package/dist/assignment-status.js +1 -1
  9. package/dist/assignment.js +3 -2
  10. package/dist/cli-http.d.ts +7 -0
  11. package/dist/cli-http.js +30 -4
  12. package/dist/cli.js +2 -1
  13. package/dist/coordinator.d.ts +2 -1
  14. package/dist/coordinator.js +22 -31
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/export.js +2 -2
  17. package/dist/mcp-http.js +5 -5
  18. package/dist/operate.js +2 -1
  19. package/dist/password-bearer.d.ts +27 -0
  20. package/dist/password-bearer.js +59 -0
  21. package/dist/remote-command.js +0 -2
  22. package/dist/remote-exec.d.ts +19 -0
  23. package/dist/remote-exec.js +9 -8
  24. package/dist/request-budget.d.ts +1 -1
  25. package/dist/scope.js +2 -1
  26. package/dist/serve.js +15 -0
  27. package/dist/server/edge-refusal.d.ts +2 -0
  28. package/dist/server/edge-refusal.js +8 -6
  29. package/dist/server/guards.js +5 -4
  30. package/dist/server/http.d.ts +2 -0
  31. package/dist/server/remote.js +6 -13
  32. package/dist/server/route-table.js +0 -3
  33. package/dist/session-cli.d.ts +38 -9
  34. package/dist/session-cli.js +141 -154
  35. package/dist/session-contract.js +2 -2
  36. package/dist/session-remote.d.ts +19 -0
  37. package/dist/session-remote.js +86 -0
  38. package/dist/store.d.ts +28 -6
  39. package/dist/store.js +259 -93
  40. package/dist/team-cli.d.ts +3 -0
  41. package/dist/team-cli.js +23 -3
  42. package/dist/toolroll-update.js +3 -1
  43. package/package.json +1 -1
  44. package/dist/session-http.d.ts +0 -24
  45. package/dist/session-http.js +0 -170
  46. package/dist/session-server.d.ts +0 -17
  47. package/dist/session-server.js +0 -39
package/dist/store.js CHANGED
@@ -19,10 +19,10 @@ import { validateTaskText } from "./task-text.js";
19
19
  import { flowDefinitionForStore, flowDefinitionFromStore } from "./flows.js";
20
20
  import { cardOutputsForStore, cardOutputsFromStore } from "./contracts/stage-output.js";
21
21
  import { chatControlHref, chatResultHref } from "./chat-controls.js";
22
- import { actorLabel, currentActor, leadSecretMatches, mintLeadToken, parseLeadToken } from "./actor.js";
22
+ import { actorLabel, currentActor } from "./actor.js";
23
23
  import { scanForSecrets } from "./evidence.js";
24
24
  import { LIVE_ACTIVITY_KINDS } from "./live.js";
25
- import { tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
25
+ import { hashSecret, mintLeadToken, parseCredential, secretMatches, tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
26
26
  /**
27
27
  * The database: a small task store, and the operational overlay beside it.
28
28
  *
@@ -221,21 +221,11 @@ CREATE TABLE IF NOT EXISTS lead_config (
221
221
  about_json TEXT NOT NULL DEFAULT '[]'
222
222
  );
223
223
  `;
224
- /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
225
- * for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
224
+ /** Pings follow responsibility (no version bump: additive only). A lead token (v118: a `credential` row of kind
225
+ * 'lead') is an agent acting for its owner, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
226
226
  * (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
227
227
  * a fact. `task_replacement` is the task that replaced a cancelled one. `project_mute` is a person's muted projects. */
228
228
  const LEAD_QUIET_SCHEMA = `
229
- CREATE TABLE IF NOT EXISTS lead_credential (
230
- id TEXT PRIMARY KEY,
231
- owner TEXT NOT NULL,
232
- secret_hash TEXT NOT NULL,
233
- created_at TEXT NOT NULL,
234
- created_by TEXT NOT NULL,
235
- revoked_at TEXT,
236
- revoked_by TEXT
237
- );
238
- CREATE INDEX IF NOT EXISTS lead_credential_owner ON lead_credential (owner);
239
229
  CREATE TABLE IF NOT EXISTS task_act (
240
230
  id INTEGER PRIMARY KEY AUTOINCREMENT,
241
231
  task_ref INTEGER NOT NULL,
@@ -566,7 +556,59 @@ const REGISTERED_AT_FILING = "again, as it was filed";
566
556
  // the lead's subagents (teammate* → subagent*, their rows' teammate column → subagent), with every row, id and link kept.
567
557
  // mate_turn (and chat_turn.mate_turn) keep their names this release: the update gate the previous build's updater lifts
568
558
  // names them byte for byte (desktop-update-gate.ts), so they move once every updater reads either name.
569
- export const SCHEMA_VERSION = 117;
559
+ // v118 (D7) keeps every machine credential in one table, `credential`: a person's API tokens (so_…), the lead's tokens
560
+ // (lt_…) and coordinators, each with its kind, read/act access, project limit, expiry, revocation and generation. Every
561
+ // row moves with its id, hash and dates; the references to coordinators and API tokens point at it.
562
+ export const SCHEMA_VERSION = 118;
563
+ /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
564
+ export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
565
+ /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
566
+ const V118_REFERENCES = /REFERENCES (?:coordinator_credential\(cid\)|api_token\(id\))/g;
567
+ /** The columns that hold a coordinator's id: rewritten only when the merge had to give one a new canonical id. */
568
+ const V118_COORDINATOR_COLUMNS = [["task_ref", "coordinator_cid"], ["coordinator_event", "cid"], ["mcp_idempotency", "cid"], ["coordinator_proposal", "cid"]];
569
+ /**
570
+ * v118 (D7): one credential table for every machine credential. `id` is the canonical identity every reference holds;
571
+ * `public_id` is the id the secret presents (so_<id>_…, lt_<id>_…) or a coordinator's cid, unique per kind. They are
572
+ * equal unless the merge found two kinds sharing an id. `account` is the person a token acts as (a lead's owner; for
573
+ * a coordinator, who minted it); `projects_json` the project limit (a coordinator's projects); `generation` which
574
+ * issue it is (1, then one more per rotation, per lead token for the same owner, per coordinator of the same name).
575
+ * Lead tokens never expire; a coordinator made before v101 has no expiry.
576
+ */
577
+ export const CREDENTIAL_TABLE = `
578
+ CREATE TABLE IF NOT EXISTS credential (
579
+ id TEXT PRIMARY KEY,
580
+ kind TEXT NOT NULL CHECK (kind IN ('person','lead','coordinator')),
581
+ public_id TEXT NOT NULL,
582
+ account TEXT NOT NULL,
583
+ name TEXT NOT NULL,
584
+ secret_hash TEXT NOT NULL,
585
+ access TEXT NOT NULL CHECK (access IN ('read','act')),
586
+ projects_json TEXT,
587
+ per_hour INTEGER,
588
+ purpose TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp')),
589
+ generation INTEGER NOT NULL DEFAULT 1 CHECK (generation >= 1),
590
+ created_at TEXT NOT NULL,
591
+ created_by TEXT NOT NULL,
592
+ expires_at TEXT,
593
+ last_used_at TEXT,
594
+ revoked_at TEXT,
595
+ revoked_by TEXT,
596
+ replaces TEXT,
597
+ replaced_by TEXT,
598
+ overlap_until TEXT,
599
+ UNIQUE (kind, public_id)
600
+ );
601
+ `;
602
+ /** Created once the merged tables (and their index of the same name) are gone. */
603
+ const CREDENTIAL_INDEXES = `
604
+ CREATE INDEX IF NOT EXISTS credential_account ON credential (account, kind);
605
+ CREATE INDEX IF NOT EXISTS credential_coordinator_secret ON credential (secret_hash) WHERE kind = 'coordinator';
606
+ CREATE UNIQUE INDEX IF NOT EXISTS coordinator_live_name ON credential (name) WHERE kind = 'coordinator' AND revoked_at IS NULL;
607
+ CREATE TRIGGER IF NOT EXISTS ledger_coordinator_minted AFTER INSERT ON credential WHEN NEW.kind = 'coordinator' BEGIN
608
+ INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (NEW.created_at, NEW.created_by, NULL, NULL, NULL,
609
+ 'coordinator minted: ' || NEW.name, 'minted', 'access', 'projects: ' || NEW.projects_json);
610
+ END;
611
+ `;
570
612
  /** v117: each table renamed, by its old name. Rows, ids, foreign keys and indexes go with it (renameForV117). */
571
613
  export const V117_RENAMED_TABLES = Object.freeze({
572
614
  mate_session: "lead_session", mate_thread: "lead_thread", mate_message: "lead_message", mate_proposal: "lead_proposal",
@@ -587,10 +629,10 @@ function presentTable(db, name) {
587
629
  const old = V117_OLD_NAME[name];
588
630
  return old !== undefined && !tableExists(db, name) && tableExists(db, old) ? old : name;
589
631
  }
590
- /** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117 is the
591
- * same row after it, though RENAME now reports it (and what it points at) under the new names. */
632
+ /** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117 (or
633
+ * v118) is the same row after it, though RENAME (or the credential merge) now reports what it points at by the new names. */
592
634
  export function orphanKey(row) {
593
- const named = (value) => typeof value === "string" ? V117_RENAMED_TABLES[value] ?? value : value;
635
+ const named = (value) => typeof value === "string" ? V117_RENAMED_TABLES[value] ?? (Object.hasOwn(V118_MERGED_TABLES, value) ? "credential" : value) : value;
594
636
  return JSON.stringify({ table: named(row["table"]), rowid: row["rowid"], parent: named(row["parent"]), fkid: row["fkid"] });
595
637
  }
596
638
  /** Shapes compare by today's names: a pre-v117 table, index or reference names the same shape by its old one. */
@@ -636,9 +678,11 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
636
678
  * transaction that checks each count before the old tables are dropped; the
637
679
  * rehearsal checks each moved table's rows arrived (HISTORY_RULES). v117 renames the mate's tables to the lead's and the
638
680
  * teammates' to its subagents' (SQLite's RENAME, every row, id and link carried), renames their teammate column, and rewrites four internal kinds from 'teammate' to 'subagent' in place; the rehearsal checks
639
- * each renamed table's rows arrived under its new name.
681
+ * each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
682
+ * coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
683
+ * before the old tables drop, and the references that named them name credential in place, their rows untouched.
640
684
  */
641
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117]);
685
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118]);
642
686
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
643
687
  export function updateSafeSchema(version) {
644
688
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -673,22 +717,9 @@ CREATE TABLE IF NOT EXISTS scope_author (
673
717
  PRIMARY KEY (task_id, digest, author)
674
718
  );
675
719
  `;
676
- /** v101: API tokens and the browser sessions that survive a restart. Only hashes of their secrets are kept. */
720
+ /** v101: the browser sessions that survive a restart (API tokens are `credential` rows since v118). Only hashes of
721
+ * their secrets are kept. */
677
722
  const CREDENTIALS_SCHEMA = `
678
- CREATE TABLE IF NOT EXISTS api_token (
679
- id TEXT PRIMARY KEY,
680
- account TEXT NOT NULL REFERENCES approver(name),
681
- name TEXT NOT NULL,
682
- secret_hash TEXT NOT NULL,
683
- access TEXT NOT NULL CHECK (access IN ('read','act')),
684
- created_at TEXT NOT NULL,
685
- created_by TEXT NOT NULL,
686
- expires_at TEXT NOT NULL,
687
- last_used_at TEXT,
688
- revoked_at TEXT,
689
- revoked_by TEXT
690
- );
691
- CREATE INDEX IF NOT EXISTS api_token_account ON api_token (account);
692
723
  CREATE TABLE IF NOT EXISTS web_session (
693
724
  id_hash TEXT PRIMARY KEY,
694
725
  account TEXT NOT NULL,
@@ -707,7 +738,7 @@ CREATE INDEX IF NOT EXISTS web_session_account ON web_session (account);
707
738
  `;
708
739
  /**
709
740
  * v113: MCP sign-in (OAuth 2.1, mcp-oauth.ts). A registered client, a one-time code (its hash, a minute at most), and
710
- * each grant: the ordinary API token it signs in with (revoked like any other), the account generation and exact
741
+ * each grant: the ordinary API token it signs in with (a person's `credential` row, revoked like any other), the account generation and exact
711
742
  * projects the person chose, and every issued refresh-secret hash (any replay ends the whole family).
712
743
  */
713
744
  const OAUTH_SCHEMA = `
@@ -734,7 +765,7 @@ CREATE TABLE IF NOT EXISTS oauth_code (
734
765
  token TEXT
735
766
  );
736
767
  CREATE TABLE IF NOT EXISTS oauth_grant (
737
- token TEXT PRIMARY KEY REFERENCES api_token(id),
768
+ token TEXT PRIMARY KEY REFERENCES credential(id),
738
769
  client TEXT NOT NULL REFERENCES oauth_client(id),
739
770
  account TEXT NOT NULL,
740
771
  generation INTEGER NOT NULL,
@@ -1085,7 +1116,7 @@ CREATE TABLE IF NOT EXISTS task_ref (
1085
1116
  -- AUTHORITATIVE linkage — written only by the branded coordinator door,
1086
1117
  -- joined by exact cid, never parsed out of filed_via (which stays
1087
1118
  -- display-only). NULL is every other filer.
1088
- coordinator_cid TEXT REFERENCES coordinator_credential(cid),
1119
+ coordinator_cid TEXT REFERENCES credential(id),
1089
1120
  -- The deliverable (v34, scout tasks): 'branch' is every task until now;
1090
1121
  -- 'report' dispatches a scout — a read-only session whose whole output
1091
1122
  -- is one report artifact. Stamped at filing, never rewritten.
@@ -1093,29 +1124,15 @@ CREATE TABLE IF NOT EXISTS task_ref (
1093
1124
  UNIQUE (backend, external_id)
1094
1125
  );
1095
1126
 
1096
- -- v31 (MCP gateway): the coordinator principal (DESIGN.md 9b). Minted by
1097
- -- an operator password ceremony; the token is hashed at rest and shown
1098
- -- once. cid is the IMMUTABLE identity — names are unique only among the
1099
- -- living, so revoke-and-remint never conflates generations in audit or
1100
- -- cap accounting.
1101
- CREATE TABLE IF NOT EXISTS coordinator_credential (
1102
- cid TEXT PRIMARY KEY,
1103
- name TEXT NOT NULL,
1104
- credential_hash TEXT NOT NULL,
1105
- repos TEXT NOT NULL,
1106
- per_hour INTEGER NOT NULL,
1107
- created_by TEXT NOT NULL,
1108
- created_at TEXT NOT NULL,
1109
- revoked_at TEXT
1110
- );
1111
- CREATE UNIQUE INDEX IF NOT EXISTS coordinator_live_name
1112
- ON coordinator_credential (name) WHERE revoked_at IS NULL;
1127
+ -- v31 (MCP gateway): the coordinator principal (DESIGN.md 9b) is a row of
1128
+ -- credential (v118, kind 'coordinator'): its id is the IMMUTABLE identity,
1129
+ -- so revoke-and-remint never conflates generations in audit or cap accounting.
1113
1130
 
1114
1131
  -- The coordinator audit (v31): filing, dismissal, and revocation rows,
1115
1132
  -- inserted ATOMICALLY with their state change — never a separate write.
1116
1133
  CREATE TABLE IF NOT EXISTS coordinator_event (
1117
1134
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1118
- cid TEXT NOT NULL REFERENCES coordinator_credential(cid),
1135
+ cid TEXT NOT NULL REFERENCES credential(id),
1119
1136
  kind TEXT NOT NULL CHECK (kind IN ('filed','dismissed','revoked')),
1120
1137
  task_id TEXT,
1121
1138
  detail TEXT,
@@ -1127,7 +1144,7 @@ CREATE TABLE IF NOT EXISTS coordinator_event (
1127
1144
  -- the original answer without a rate charge; a different digest refuses.
1128
1145
  -- The global Store.replay is digest-blind and is NOT this.
1129
1146
  CREATE TABLE IF NOT EXISTS mcp_idempotency (
1130
- cid TEXT NOT NULL REFERENCES coordinator_credential(cid),
1147
+ cid TEXT NOT NULL REFERENCES credential(id),
1131
1148
  key TEXT NOT NULL,
1132
1149
  request_digest TEXT NOT NULL,
1133
1150
  task_id TEXT NOT NULL,
@@ -1789,7 +1806,7 @@ CREATE INDEX IF NOT EXISTS lead_proposal_thread ON lead_proposal (thread, state)
1789
1806
 
1790
1807
  CREATE TABLE IF NOT EXISTS coordinator_proposal (
1791
1808
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1792
- cid TEXT NOT NULL REFERENCES coordinator_credential(cid),
1809
+ cid TEXT NOT NULL REFERENCES credential(id),
1793
1810
  name TEXT NOT NULL,
1794
1811
  repo TEXT NOT NULL,
1795
1812
  kind TEXT NOT NULL CHECK (kind IN ('next','reserve','hold','unhold','scope','cancel','answer')),
@@ -3371,14 +3388,17 @@ function initializeStore(db, file) {
3371
3388
  db.exec(TEAM_SCHEMA);
3372
3389
  db.exec(SSO_SCHEMA);
3373
3390
  db.exec(CREDENTIALS_SCHEMA);
3374
- // v111: a token's project limit (null: every project its person may use) and its rotation.
3375
- addColumn(db, "api_token", "projects_json", "TEXT");
3376
- addColumn(db, "api_token", "replaces", "TEXT");
3377
- addColumn(db, "api_token", "replaced_by", "TEXT");
3378
- addColumn(db, "api_token", "overlap_until", "TEXT");
3391
+ // v111: a token's project limit (null: every project its person may use) and its rotation. (Until v118 moves it.)
3392
+ if (tableExists(db, "api_token")) {
3393
+ addColumn(db, "api_token", "projects_json", "TEXT");
3394
+ addColumn(db, "api_token", "replaces", "TEXT");
3395
+ addColumn(db, "api_token", "replaced_by", "TEXT");
3396
+ addColumn(db, "api_token", "overlap_until", "TEXT");
3397
+ }
3379
3398
  // v113: MCP sign-in (OAuth), and whether a token is an ordinary one or an MCP sign-in's.
3380
3399
  db.exec(OAUTH_SCHEMA);
3381
- addColumn(db, "api_token", "purpose", "TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp'))");
3400
+ if (tableExists(db, "api_token"))
3401
+ addColumn(db, "api_token", "purpose", "TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp'))");
3382
3402
  db.exec(APPROVAL_SCHEMA);
3383
3403
  db.exec(LEDGER_CHAIN_SCHEMA);
3384
3404
  db.exec(MONITORING_SCHEMA);
@@ -3531,6 +3551,8 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
3531
3551
  migrateToV115(db);
3532
3552
  // v116: every chat app onto the shared chat tables, after v115 and every older step gave the old tables their last shape.
3533
3553
  convertChatTables(db);
3554
+ // v118: every machine credential into the one credential table, after every older step gave the old tables their last shape.
3555
+ mergeCredentialsForV118(db);
3534
3556
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
3535
3557
  // nothing to reclaim.
3536
3558
  if (!fresh)
@@ -4180,6 +4202,128 @@ function renameForV117(db) {
4180
4202
  if (check.length !== 1 || check[0] !== "ok")
4181
4203
  throw new Error(`the v117 renames left the database damaged (${check.slice(0, 3).join("; ")})`);
4182
4204
  }
4205
+ /**
4206
+ * v118 (D7): the three credential tables become one. In one transaction every row moves with its id, hash, dates,
4207
+ * access, project limit, expiry and revocation; the counts are checked before the old tables drop; the foreign keys
4208
+ * that named them name `credential(id)` in place (rows untouched); and the coordinator ledger trigger follows. A
4209
+ * person's API token keeps its id. A coordinator or lead token whose id another kind already holds gets `<kind>:<id>`
4210
+ * as its canonical id (a coordinator's references follow it); its secret presents the same id as before either way.
4211
+ */
4212
+ function mergeCredentialsForV118(db) {
4213
+ db.exec(CREDENTIAL_TABLE);
4214
+ const present = Object.keys(V118_MERGED_TABLES).filter(table => tableExists(db, table));
4215
+ const referring = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL").all()
4216
+ .filter(row => !Object.hasOwn(V118_MERGED_TABLES, row.name) && new RegExp(V118_REFERENCES.source).test(row.sql));
4217
+ if (present.length === 0 && referring.length === 0) {
4218
+ db.exec(CREDENTIAL_INDEXES);
4219
+ return;
4220
+ }
4221
+ const orphans = () => new Set(db.prepare("PRAGMA foreign_key_check").all().map(orphanKey));
4222
+ db.exec("PRAGMA foreign_keys = OFF");
4223
+ try {
4224
+ const before = orphans();
4225
+ db.exec("BEGIN IMMEDIATE");
4226
+ try {
4227
+ const taken = new Set(db.prepare("SELECT id FROM credential").all().map(row => row.id));
4228
+ const already = Object.fromEntries(Object.values(V118_MERGED_TABLES).map(kind => [kind, Number(db.prepare("SELECT count(*) AS n FROM credential WHERE kind = ?").get(kind)["n"])]));
4229
+ const claim = (kind, legacy) => {
4230
+ let id = legacy;
4231
+ for (let n = 1; taken.has(id); n++)
4232
+ id = n === 1 ? `${kind}:${legacy}` : `${kind}:${legacy}:${n}`;
4233
+ taken.add(id);
4234
+ return id;
4235
+ };
4236
+ const insert = db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, projects_json, per_hour, purpose, generation,
4237
+ created_at, created_by, expires_at, last_used_at, revoked_at, revoked_by, replaces, replaced_by, overlap_until) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
4238
+ const text = (value) => value == null ? null : String(value);
4239
+ if (tableExists(db, "api_token")) {
4240
+ const rows = db.prepare("SELECT * FROM api_token ORDER BY rowid").all();
4241
+ const byId = new Map(rows.map(row => [String(row["id"]), row]));
4242
+ // A rotation's replacement is one generation on from the token it replaced.
4243
+ const generation = (row, seen = new Set()) => {
4244
+ const from = text(row["replaces"]), earlier = from === null ? undefined : byId.get(from);
4245
+ if (earlier === undefined || seen.has(from))
4246
+ return 1;
4247
+ seen.add(from);
4248
+ return generation(earlier, seen) + 1;
4249
+ };
4250
+ for (const row of rows) {
4251
+ const id = String(row["id"]);
4252
+ // Persons move first, into an empty table: an API token's id is never renamed (its grants and budgets name it).
4253
+ if (claim("person", id) !== id)
4254
+ throw new Error(`API token ${id} collides with another credential`);
4255
+ insert.run(id, "person", id, String(row["account"]), String(row["name"]), String(row["secret_hash"]), row["access"] === "act" ? "act" : "read", text(row["projects_json"]), null, row["purpose"] === "mcp" ? "mcp" : "api", generation(row), String(row["created_at"]), String(row["created_by"]), text(row["expires_at"]), text(row["last_used_at"]), text(row["revoked_at"]), text(row["revoked_by"]), text(row["replaces"]), text(row["replaced_by"]), text(row["overlap_until"]));
4256
+ }
4257
+ }
4258
+ if (tableExists(db, "coordinator_credential")) {
4259
+ const issued = new Map();
4260
+ for (const row of db.prepare("SELECT * FROM coordinator_credential ORDER BY created_at, rowid").all()) {
4261
+ const cid = String(row["cid"]), id = claim("coordinator", cid), name = String(row["name"]);
4262
+ const generation = (issued.get(name) ?? 0) + 1;
4263
+ issued.set(name, generation);
4264
+ insert.run(id, "coordinator", cid, String(row["created_by"]), name, String(row["credential_hash"]), "act", String(row["repos"]), Number(row["per_hour"]), "api", generation, String(row["created_at"]), String(row["created_by"]), text(row["expires_at"]), null, text(row["revoked_at"]), null, null, null, null);
4265
+ if (id !== cid)
4266
+ for (const [table, column] of V118_COORDINATOR_COLUMNS) {
4267
+ if (tableExists(db, table) && hasColumn(db, table, column))
4268
+ db.prepare(`UPDATE "${table}" SET "${column}" = ? WHERE "${column}" = ?`).run(id, cid);
4269
+ }
4270
+ }
4271
+ }
4272
+ if (tableExists(db, "lead_credential")) {
4273
+ const issued = new Map();
4274
+ for (const row of db.prepare("SELECT * FROM lead_credential ORDER BY created_at, rowid").all()) {
4275
+ const legacy = String(row["id"]), owner = String(row["owner"]);
4276
+ const generation = (issued.get(owner) ?? 0) + 1;
4277
+ issued.set(owner, generation);
4278
+ insert.run(claim("lead", legacy), "lead", legacy, owner, "lead token", String(row["secret_hash"]), "act", null, null, "api", generation, String(row["created_at"]), String(row["created_by"]), null, null, text(row["revoked_at"]), text(row["revoked_by"]), null, null, null);
4279
+ }
4280
+ }
4281
+ for (const [table, kind] of Object.entries(V118_MERGED_TABLES)) {
4282
+ if (!tableExists(db, table))
4283
+ continue;
4284
+ const had = Number(db.prepare(`SELECT count(*) AS n FROM "${table}"`).get()["n"]);
4285
+ const moved = Number(db.prepare("SELECT count(*) AS n FROM credential WHERE kind = ?").get(kind)["n"]);
4286
+ if (had !== moved - already[kind])
4287
+ throw new Error(`${table} kept ${had} rows but ${moved} arrived in credential`);
4288
+ }
4289
+ // The references keep their rows and point at the one table (SQLite's documented procedure, as v115 does).
4290
+ // Node 24's SQLite opens in defensive mode, which forbids it; it is lifted for the rewrite only.
4291
+ if (referring.length > 0) {
4292
+ const version = Number(db.prepare("PRAGMA schema_version").get()?.["schema_version"]);
4293
+ const defensive = db.enableDefensive?.bind(db);
4294
+ defensive?.(false);
4295
+ db.exec("PRAGMA writable_schema = ON");
4296
+ try {
4297
+ const rewrite = db.prepare("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?");
4298
+ for (const row of referring)
4299
+ rewrite.run(row.sql.replace(V118_REFERENCES, "REFERENCES credential(id)"), row.name);
4300
+ db.exec(`PRAGMA schema_version = ${version + 1}`);
4301
+ }
4302
+ finally {
4303
+ db.exec("PRAGMA writable_schema = OFF");
4304
+ defensive?.(true);
4305
+ }
4306
+ }
4307
+ // Their indexes and the coordinator ledger trigger go with them; the one table gets its own.
4308
+ for (const table of present)
4309
+ db.exec(`DROP TABLE "${table}"`);
4310
+ db.exec(CREDENTIAL_INDEXES);
4311
+ if ([...orphans()].some(row => !before.has(row)))
4312
+ throw new Error("foreign keys did not survive the v118 credential merge");
4313
+ db.exec("COMMIT");
4314
+ }
4315
+ catch (error) {
4316
+ db.exec("ROLLBACK");
4317
+ throw error;
4318
+ }
4319
+ }
4320
+ finally {
4321
+ db.exec("PRAGMA foreign_keys = ON");
4322
+ }
4323
+ const check = db.prepare("PRAGMA integrity_check").all().map(row => String(row["integrity_check"]));
4324
+ if (check.length !== 1 || check[0] !== "ok")
4325
+ throw new Error(`the v118 credential merge left the database damaged (${check.slice(0, 3).join("; ")})`);
4326
+ }
4183
4327
  function migrateToV115(db) {
4184
4328
  const present = V115_DROPPED_TABLES.filter(table => tableExists(db, table));
4185
4329
  const referring = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL").all()
@@ -6358,11 +6502,11 @@ export class Store {
6358
6502
  // with a revocation or an access change, not a password change.
6359
6503
  this.db.prepare("DELETE FROM web_session WHERE account = ?").run(approver);
6360
6504
  if (by !== "credential-rotation") {
6361
- for (const row of this.db.prepare("SELECT id FROM api_token WHERE account = ? AND revoked_at IS NULL").all(approver))
6505
+ for (const row of this.db.prepare("SELECT id FROM credential WHERE kind = 'person' AND account = ? AND revoked_at IS NULL").all(approver))
6362
6506
  this.revokeApiToken(String(row["id"]), by, now, "ended with the account's standing");
6363
- for (const row of this.db.prepare("SELECT cid, name FROM coordinator_credential WHERE created_by = ? AND revoked_at IS NULL").all(approver)) {
6507
+ for (const row of this.db.prepare("SELECT id AS cid, name FROM credential WHERE kind = 'coordinator' AND created_by = ? AND revoked_at IS NULL").all(approver)) {
6364
6508
  const cid = String(row["cid"]);
6365
- this.db.prepare("UPDATE coordinator_credential SET revoked_at = ? WHERE cid = ?").run(stamp, cid);
6509
+ this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'coordinator'").run(stamp, by, cid);
6366
6510
  this.db.prepare("INSERT INTO coordinator_event (cid, kind, detail, created_at) VALUES (?, 'revoked', ?, ?)").run(cid, `with ${approver}'s standing, by ${by}`, stamp);
6367
6511
  this.sweepCoordinatorProposals(now, cid);
6368
6512
  this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: `coordinator revoked: ${String(row["name"])}`, outcome: "revoked", source: "access", detail: `made by ${approver}` });
@@ -6977,8 +7121,8 @@ export class Store {
6977
7121
  createApiToken(token, now) {
6978
7122
  this.transact(() => {
6979
7123
  const projects = token.projects ?? null;
6980
- this.db.prepare("INSERT INTO api_token (id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, purpose) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
6981
- .run(token.id, token.account, token.name, token.secretHash, token.access, now.toISOString(), token.by, token.expiresAt, projects === null ? null : JSON.stringify(projects), token.purpose ?? "api");
7124
+ this.db.prepare("INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, purpose) VALUES (?, 'person', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
7125
+ .run(token.id, token.id, token.account, token.name, token.secretHash, token.access, now.toISOString(), token.by, token.expiresAt, projects === null ? null : JSON.stringify(projects), token.purpose ?? "api");
6982
7126
  this.recordAction({ at: now.toISOString(), actor: token.by, repo: null, taskId: null, runId: null, action: `API token created: ${token.name}`, outcome: token.access, source: "access",
6983
7127
  detail: `for ${token.account}, until ${token.expiresAt.slice(0, 10)}${projects === null ? "" : ` · ${projects.length === 0 ? "no projects" : projects.join(", ")}`} · id ${token.id}` });
6984
7128
  });
@@ -7000,9 +7144,11 @@ export class Store {
7000
7144
  return { ok: false, reason: "replaced" };
7001
7145
  const stamp = now.toISOString();
7002
7146
  const until = new Date(Math.min(at + Math.max(0, overlapMs), Date.parse(old.expiresAt))).toISOString();
7003
- this.db.prepare("INSERT INTO api_token (id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, replaces) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
7004
- .run(replacement.id, old.account, old.name, replacement.secretHash, old.access, stamp, by, old.expiresAt, old.projects === null ? null : JSON.stringify(old.projects), old.id);
7005
- this.db.prepare("UPDATE api_token SET replaced_by = ?, overlap_until = ? WHERE id = ? AND replaced_by IS NULL").run(replacement.id, until, old.id);
7147
+ // The replacement is the token's next generation; it keeps every term, so rotating never widens or extends it.
7148
+ this.db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, replaces, generation)
7149
+ VALUES (?, 'person', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT generation + 1 FROM credential WHERE id = ?))`)
7150
+ .run(replacement.id, replacement.id, old.account, old.name, replacement.secretHash, old.access, stamp, by, old.expiresAt, old.projects === null ? null : JSON.stringify(old.projects), old.id, old.id);
7151
+ this.db.prepare("UPDATE credential SET replaced_by = ?, overlap_until = ? WHERE id = ? AND kind = 'person' AND replaced_by IS NULL").run(replacement.id, until, old.id);
7006
7152
  this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: `API token rotated: ${old.name}`, outcome: old.access, source: "access",
7007
7153
  detail: `${old.account}'s · id ${old.id} → ${replacement.id}, the old one stops ${until.slice(0, 16).replace("T", " ")} UTC` });
7008
7154
  return { ok: true, row: this.apiTokenSecret(replacement.id).row };
@@ -7010,30 +7156,46 @@ export class Store {
7010
7156
  }
7011
7157
  /** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
7012
7158
  endRotatedApiTokens(now) {
7013
- const due = this.db.prepare("SELECT id, overlap_until FROM api_token WHERE revoked_at IS NULL AND overlap_until IS NOT NULL AND overlap_until <= ?").all(now.toISOString());
7159
+ const due = this.db.prepare("SELECT id, overlap_until FROM credential WHERE kind = 'person' AND revoked_at IS NULL AND overlap_until IS NOT NULL AND overlap_until <= ?").all(now.toISOString());
7014
7160
  for (const row of due)
7015
7161
  this.revokeApiToken(String(row["id"]), "system", new Date(String(row["overlap_until"])), "replaced; its overlap ended");
7016
7162
  return due.length;
7017
7163
  }
7018
- /** A token by id with its kept hash (for the check), or null. */
7164
+ /**
7165
+ * D7: the one check every machine credential takes. The credential a presented secret is (its kind and id), when
7166
+ * its kept hash matches in constant time; null otherwise. Whether it is live, and what its kind may do, stays with
7167
+ * each caller's own policy (a person's API token, a lead token, a coordinator).
7168
+ */
7169
+ presentedCredential(presented) {
7170
+ const parsed = parseCredential(presented);
7171
+ if (parsed === null)
7172
+ return null;
7173
+ const row = parsed.kind === "coordinator"
7174
+ ? this.db.prepare("SELECT id, secret_hash FROM credential WHERE kind = 'coordinator' AND secret_hash = ?").get(hashSecret(parsed.secret))
7175
+ : this.db.prepare("SELECT id, secret_hash FROM credential WHERE kind = ? AND public_id = ?").get(parsed.kind, parsed.id);
7176
+ if (row === undefined || !secretMatches(parsed.secret, String(row["secret_hash"])))
7177
+ return null;
7178
+ return { kind: parsed.kind, id: String(row["id"]) };
7179
+ }
7180
+ /** A person's API token by id with its kept hash (for the check), or null. */
7019
7181
  apiTokenSecret(id) {
7020
- const row = this.db.prepare("SELECT * FROM api_token WHERE id = ?").get(id);
7182
+ const row = this.db.prepare("SELECT * FROM credential WHERE id = ? AND kind = 'person'").get(id);
7021
7183
  return row === undefined ? null : { row: readApiToken(row), secretHash: String(row["secret_hash"]) };
7022
7184
  }
7023
7185
  /** Tokens, newest first: one person's, or everyone's (null). */
7024
7186
  apiTokens(account) {
7025
- return (account === null ? this.db.prepare("SELECT * FROM api_token ORDER BY created_at DESC").all() : this.db.prepare("SELECT * FROM api_token WHERE account = ? ORDER BY created_at DESC").all(account)).map(readApiToken);
7187
+ return (account === null ? this.db.prepare("SELECT * FROM credential WHERE kind = 'person' ORDER BY created_at DESC").all() : this.db.prepare("SELECT * FROM credential WHERE kind = 'person' AND account = ? ORDER BY created_at DESC").all(account)).map(readApiToken);
7026
7188
  }
7027
7189
  /** Kept at most once a minute: a busy script doesn't write on every request. */
7028
7190
  touchApiToken(id, now) {
7029
- this.db.prepare("UPDATE api_token SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)").run(now.toISOString(), id, new Date(now.getTime() - 60_000).toISOString());
7191
+ this.db.prepare("UPDATE credential SET last_used_at = ? WHERE id = ? AND kind = 'person' AND (last_used_at IS NULL OR last_used_at < ?)").run(now.toISOString(), id, new Date(now.getTime() - 60_000).toISOString());
7030
7192
  }
7031
7193
  revokeApiToken(id, by, now, why = "revoked") {
7032
7194
  return this.transact(() => {
7033
- const row = this.db.prepare("SELECT name, account FROM api_token WHERE id = ? AND revoked_at IS NULL").get(id);
7195
+ const row = this.db.prepare("SELECT name, account FROM credential WHERE id = ? AND kind = 'person' AND revoked_at IS NULL").get(id);
7034
7196
  if (row === undefined)
7035
7197
  return false;
7036
- this.db.prepare("UPDATE api_token SET revoked_at = ?, revoked_by = ? WHERE id = ?").run(now.toISOString(), by, id);
7198
+ this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'person'").run(now.toISOString(), by, id);
7037
7199
  this.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: `API token revoked: ${String(row["name"])}`, outcome: "revoked", source: "access", detail: `${String(row["account"])}'s · ${why}` });
7038
7200
  return true;
7039
7201
  });
@@ -7146,7 +7308,7 @@ export class Store {
7146
7308
  .run(next.refreshHash, next.accessExpiresAt, token, ask.refreshHash);
7147
7309
  if (Number(moved.changes) !== 1)
7148
7310
  return invalid;
7149
- this.db.prepare("UPDATE api_token SET secret_hash = ? WHERE id = ? AND revoked_at IS NULL").run(next.accessHash, token);
7311
+ this.db.prepare("UPDATE credential SET secret_hash = ? WHERE id = ? AND kind = 'person' AND revoked_at IS NULL").run(next.accessHash, token);
7150
7312
  this.db.prepare("INSERT INTO oauth_refresh (token, hash) VALUES (?, ?)").run(token, next.refreshHash);
7151
7313
  return { ok: true, access: kept.access };
7152
7314
  });
@@ -10912,10 +11074,10 @@ export class Store {
10912
11074
  * before signing). */
10913
11075
  coordinatorProvenanceOf(taskId) {
10914
11076
  const row = this.db
10915
- .prepare(`SELECT c.name AS name, c.cid AS cid,
10916
- (SELECT MIN(e.created_at) FROM coordinator_event e WHERE e.cid = c.cid AND e.task_id = task_ref.external_id AND e.kind = 'filed') AS filed_at
11077
+ .prepare(`SELECT c.name AS name, c.public_id AS cid,
11078
+ (SELECT MIN(e.created_at) FROM coordinator_event e WHERE e.cid = c.id AND e.task_id = task_ref.external_id AND e.kind = 'filed') AS filed_at
10917
11079
  FROM task_ref
10918
- JOIN coordinator_credential c ON c.cid = task_ref.coordinator_cid
11080
+ JOIN credential c ON c.id = task_ref.coordinator_cid AND c.kind = 'coordinator'
10919
11081
  WHERE task_ref.backend = ? AND task_ref.external_id = ?`)
10920
11082
  .get(BUILT_IN, taskId);
10921
11083
  if (row === undefined)
@@ -16692,9 +16854,13 @@ export class Store {
16692
16854
  mintLeadCredential(owner, by, now) {
16693
16855
  return this.transact(() => {
16694
16856
  const stamp = now.toISOString();
16695
- this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(stamp, by, owner);
16696
- const minted = mintLeadToken();
16697
- this.db.prepare("INSERT INTO lead_credential (id, owner, secret_hash, created_at, created_by) VALUES (?, ?, ?, ?, ?)").run(minted.id, owner, minted.hash, stamp, by);
16857
+ this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE kind = 'lead' AND account = ? AND revoked_at IS NULL").run(stamp, by, owner);
16858
+ let minted = mintLeadToken();
16859
+ // One id per credential across kinds; the canonical id is the one the token presents.
16860
+ while (this.db.prepare("SELECT 1 FROM credential WHERE id = ? OR (kind = 'lead' AND public_id = ?)").get(minted.id, minted.id) !== undefined)
16861
+ minted = mintLeadToken();
16862
+ this.db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, generation)
16863
+ VALUES (?, 'lead', ?, ?, 'lead token', ?, 'act', ?, ?, 1 + (SELECT count(*) FROM credential WHERE kind = 'lead' AND account = ?))`).run(minted.id, minted.id, owner, minted.hash, stamp, by, owner);
16698
16864
  this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: "lead token created", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
16699
16865
  return { id: minted.id, token: minted.token };
16700
16866
  });
@@ -16702,7 +16868,7 @@ export class Store {
16702
16868
  /** End every live lead token for one person. */
16703
16869
  revokeLeadCredentials(owner, by, now) {
16704
16870
  return this.transact(() => {
16705
- const ended = Number(this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(now.toISOString(), by, owner).changes);
16871
+ const ended = Number(this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE kind = 'lead' AND account = ? AND revoked_at IS NULL").run(now.toISOString(), by, owner).changes);
16706
16872
  if (ended > 0)
16707
16873
  this.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: "lead token revoked", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
16708
16874
  return ended;
@@ -16710,14 +16876,14 @@ export class Store {
16710
16876
  }
16711
16877
  /** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
16712
16878
  leadFor(presented) {
16713
- const parsed = parseLeadToken(presented);
16714
- if (parsed === null)
16879
+ const found = this.presentedCredential(presented);
16880
+ if (found?.kind !== "lead")
16715
16881
  return null;
16716
- const row = this.db.prepare("SELECT owner, secret_hash FROM lead_credential WHERE id = ? AND revoked_at IS NULL").get(parsed.id);
16717
- if (row === undefined || !leadSecretMatches(parsed.secret, String(row["secret_hash"])))
16882
+ const row = this.db.prepare("SELECT account, public_id FROM credential WHERE id = ? AND kind = 'lead' AND revoked_at IS NULL").get(found.id);
16883
+ if (row === undefined)
16718
16884
  return null;
16719
- const owner = String(row["owner"]), account = this.accountOf(owner);
16720
- return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: parsed.id };
16885
+ const owner = String(row["account"]), account = this.accountOf(owner);
16886
+ return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: String(row["public_id"]) };
16721
16887
  }
16722
16888
  /** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
16723
16889
  noteTaskAct(taskRef, act, now, handed, actor = currentActor()) {
@@ -14,6 +14,9 @@ export type TeamCliOptions = {
14
14
  export declare const TEAM_CLI_ACTIONS: readonly ['connect', 'lead list', 'lead create', 'lead update', 'lead member', 'lead transfer', 'conversation list', 'conversation create', 'conversation show', 'conversation member', 'conversation edit', 'conversation withdraw', 'conversation read', 'conversation follow', 'conversation stop'];
15
15
  export declare class UsageError extends Error {
16
16
  }
17
+ /** Credentials are never sent to a URL with embedded authority, an API path, query, fragment or an insecure remote
18
+ * host. Fetch cannot follow redirects. */
19
+ export declare function serviceOrigin(raw: string): string;
17
20
  type Profile = {
18
21
  origin: string;
19
22
  account: string;