@bongos/core 1.19.887 → 1.19.889

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.
@@ -17,6 +17,36 @@
17
17
  # ADR 0022. The systemd unit injects them via EnvironmentFile; a manual run
18
18
  # sources the same file. Required vars:
19
19
  # SPACES_KEY SPACES_SECRET SPACES_BUCKET SPACES_REGION SPACES_ENDPOINT
20
+ # GDS_OFFSITE_AGE_RECIPIENT
21
+ #
22
+ # ENCRYPTION IS MANDATORY (task 1003372, audit ref A1). Until that task this
23
+ # script uploaded the raw pg_dump to the Space. A database dump is not "a backup"
24
+ # from a security standpoint — it is every secret the platform holds, in one
25
+ # file, on someone else's disk: OAuth credentials, the GitHub App private key,
26
+ # and (before core_249) every builder's live session bearer in plaintext. A
27
+ # breach of the Space, a mis-set bucket ACL, or a leaked read-only S3 key was a
28
+ # total compromise of the instance with no trace in any log the platform keeps.
29
+ #
30
+ # The dump is encrypted to a PUBLIC key (`age`), so this box can produce backups
31
+ # it cannot itself read. That asymmetry is the point: an attacker who owns the
32
+ # droplet gets the live database anyway, but they do not also get the archive of
33
+ # every previous state, and they cannot decrypt what is already in the Space.
34
+ # Keep the private key OFF this machine.
35
+ #
36
+ # One-time setup, on a machine that is NOT the server:
37
+ # age-keygen -o bongos-backup.key # store this somewhere safe and offline
38
+ # # it prints: Public key: age1xxxxxxxx...
39
+ # Then on the server, add to $ENV_FILE:
40
+ # GDS_OFFSITE_AGE_RECIPIENT=age1xxxxxxxx...
41
+ #
42
+ # To restore:
43
+ # age -d -i bongos-backup.key <dump>.sql.gz.age | gunzip | psql <db>
44
+ #
45
+ # WITHOUT A RECIPIENT THIS SCRIPT REFUSES TO RUN. It does not fall back to a
46
+ # plaintext upload: that fallback is the vulnerability, and a silent one, since
47
+ # a backup that "worked" looks identical either way. A failing timer is visible
48
+ # in `systemctl status gds-db-offsite.timer`; an unencrypted dump in a bucket is
49
+ # visible to nobody until it matters.
20
50
  #
21
51
  # Manual one-shot:
22
52
  # <instance-root>/scripts/gds/db-backup-offsite.sh
@@ -72,13 +102,17 @@ ENV_FILE="${GDS_SPACES_ENV:-${CONFIG_DIR_NAME:+/etc/$CONFIG_DIR_NAME/spaces.env}
72
102
  # extract only the five expected KEY=VALUE lines. `read -r k v` with IFS='='
73
103
  # splits on the FIRST '=' only, so base64 secrets containing '/', '+' or '='
74
104
  # survive intact.
75
- if [ -z "${SPACES_KEY:-}" ] && [ -f "$ENV_FILE" ]; then
105
+ # Read the file when EITHER the credentials or the encryption recipient is still
106
+ # unset. Keying this on SPACES_KEY alone would skip the file on a box where
107
+ # systemd injects the S3 vars but the recipient was added to the file later —
108
+ # and the script would then refuse to run while the key it needs sat right there.
109
+ if { [ -z "${SPACES_KEY:-}" ] || [ -z "${GDS_OFFSITE_AGE_RECIPIENT:-}" ]; } && [ -f "$ENV_FILE" ]; then
76
110
  while IFS='=' read -r _k _v; do
77
111
  case "$_k" in
78
- SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT)
112
+ SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT|GDS_OFFSITE_AGE_RECIPIENT)
79
113
  export "$_k=$_v" ;;
80
114
  esac
81
- done < <(grep -E '^(SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT)=' "$ENV_FILE" || true)
115
+ done < <(grep -E '^(SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT|GDS_OFFSITE_AGE_RECIPIENT)=' "$ENV_FILE" || true)
82
116
  fi
83
117
 
84
118
  missing=()
@@ -91,8 +125,30 @@ if [ "${#missing[@]}" -gt 0 ]; then
91
125
  exit 2
92
126
  fi
93
127
 
128
+ # The encryption recipient is as required as the credentials — a dump this box
129
+ # can upload but nobody can decrypt is useless, and a dump it uploads in the
130
+ # clear is the finding this refusal exists to close (task 1003372, audit A1).
131
+ if [ -z "${GDS_OFFSITE_AGE_RECIPIENT:-}" ]; then
132
+ echo "ERROR: GDS_OFFSITE_AGE_RECIPIENT is not set — refusing to ship an unencrypted database dump offsite." >&2
133
+ echo " A dump is every secret this platform holds, in one file, on storage the platform does not own." >&2
134
+ echo "" >&2
135
+ echo " Fix, on a machine that is NOT this server:" >&2
136
+ echo " age-keygen -o bongos-backup.key # keep this file safe and OFFLINE — it is the only way back" >&2
137
+ echo " then add the age1... public key it prints to $ENV_FILE:" >&2
138
+ echo " GDS_OFFSITE_AGE_RECIPIENT=age1..." >&2
139
+ echo "" >&2
140
+ echo " This is deliberately not optional: falling back to a plaintext upload would look identical in the logs." >&2
141
+ exit 2
142
+ fi
143
+
144
+
94
145
  # ---- preconditions ----------------------------------------------------------
95
146
 
147
+ if ! command -v age >/dev/null 2>&1; then
148
+ echo "ERROR: 'age' not on PATH — it is what encrypts the dump before it leaves this box." >&2
149
+ echo " Install with: apt install age (or fetch the static binary from https://github.com/FiloSottile/age/releases)" >&2
150
+ exit 2
151
+ fi
96
152
  if ! command -v python3 >/dev/null 2>&1; then
97
153
  echo "ERROR: python3 not on PATH (needed for the boto3 S3 client)." >&2
98
154
  exit 2
@@ -113,14 +169,38 @@ if [ -z "$LATEST" ]; then
113
169
  fi
114
170
 
115
171
  BASENAME=$(basename "$LATEST")
116
- KEY="${PREFIX}${BASENAME}"
117
172
  BYTES=$(stat -c '%s' "$LATEST")
118
173
 
119
- echo "[$(date -u +%FT%TZ)] gds-db-offsite: archiving ${LATEST} (${BYTES} bytes) → s3://${SPACES_BUCKET}/${KEY}"
174
+ # ---- encrypt before it leaves the box ---------------------------------------
175
+ #
176
+ # To a temp file under the backup dir (same filesystem, so no cross-device copy
177
+ # of plaintext through /tmp) with a 0600 umask, and removed on every exit path
178
+ # including failure — the cleartext dump already lives beside it, so the
179
+ # ciphertext is not sensitive in itself, but leaving files behind on a failing
180
+ # weekly timer fills a disk nobody is watching.
181
+ umask 077
182
+ ENC="$(mktemp "${BACKUP_DIR}/.${BASENAME}.age.XXXXXX")"
183
+ trap 'rm -f "$ENC"' EXIT
184
+
185
+ if ! age -r "$GDS_OFFSITE_AGE_RECIPIENT" -o "$ENC" "$LATEST"; then
186
+ echo "ERROR: age refused to encrypt — is GDS_OFFSITE_AGE_RECIPIENT a valid age1... public key?" >&2
187
+ exit 2
188
+ fi
189
+
190
+ # A zero-byte or missing ciphertext would upload happily and restore to nothing.
191
+ # Checked here rather than trusted, because the failure is silent for a month.
192
+ ENC_BYTES=$(stat -c '%s' "$ENC")
193
+ if [ "$ENC_BYTES" -lt 1 ]; then
194
+ echo "ERROR: encryption produced an empty file — refusing to upload it as a backup." >&2
195
+ exit 2
196
+ fi
197
+
198
+ KEY="${PREFIX}${BASENAME}.age"
199
+ echo "[$(date -u +%FT%TZ)] gds-db-offsite: archiving ${LATEST} (${BYTES} bytes → ${ENC_BYTES} encrypted) → s3://${SPACES_BUCKET}/${KEY}"
120
200
 
121
201
  # ---- upload + prune (single boto3 session) ----------------------------------
122
202
 
123
- GDS_OFFSITE_LOCAL="$LATEST" \
203
+ GDS_OFFSITE_LOCAL="$ENC" \
124
204
  GDS_OFFSITE_KEY="$KEY" \
125
205
  GDS_OFFSITE_KEEP="$KEEP" \
126
206
  GDS_OFFSITE_PREFIX="$PREFIX" \
@@ -151,10 +231,25 @@ s3 = boto3.client(
151
231
  s3.upload_file(local, bucket, objkey)
152
232
  print(f"[gds-db-offsite] uploaded {objkey}")
153
233
 
154
- # Prune: keep the newest `keep` objects under our prefix, delete the rest.
155
234
  resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
235
+ contents = resp.get("Contents", [])
236
+
237
+ # PURGE THE PLAINTEXT ALREADY UP THERE (task 1003372, audit A1). Every `.sql.gz`
238
+ # under this prefix was uploaded unencrypted by an earlier version of this
239
+ # script, and each one is a live copy of every secret the platform held on the
240
+ # day it was taken — including, before core_249, directly replayable session
241
+ # bearers. Encrypting from now on does nothing about them. They are deleted
242
+ # OUTSIDE the retention count, and unconditionally: retaining four of them
243
+ # "for safety" would retain the finding. The local nightly dumps on the droplet
244
+ # are untouched, so this removes copies, never the last copy.
245
+ legacy = [o for o in contents if o["Key"].endswith(".sql.gz")]
246
+ for obj in legacy:
247
+ s3.delete_object(Bucket=bucket, Key=obj["Key"])
248
+ print(f"[gds-db-offsite] PURGED unencrypted legacy dump {obj['Key']}")
249
+
250
+ # Prune: keep the newest `keep` ENCRYPTED objects under our prefix.
156
251
  objects = sorted(
157
- [o for o in resp.get("Contents", []) if o["Key"].endswith(".sql.gz")],
252
+ [o for o in contents if o["Key"].endswith(".sql.gz.age")],
158
253
  key=lambda o: o["LastModified"],
159
254
  )
160
255
  to_delete = objects[:-keep] if len(objects) > keep else []
@@ -163,7 +258,8 @@ for obj in to_delete:
163
258
  print(f"[gds-db-offsite] pruned {obj['Key']}")
164
259
 
165
260
  kept = min(len(objects), keep)
166
- print(f"[gds-db-offsite] ok · {kept} offsite dump(s) retained · pruned {len(to_delete)}")
261
+ print(f"[gds-db-offsite] ok · {kept} offsite dump(s) retained · pruned {len(to_delete)}"
262
+ + (f" · purged {len(legacy)} unencrypted legacy dump(s)" if legacy else ""))
167
263
  PYEOF
168
264
 
169
265
  echo "[$(date -u +%FT%TZ)] gds-db-offsite: done"
@@ -171,6 +171,14 @@ const INTEGRATION = new Set([
171
171
  // real engine can show the CASE guard leaving a NULL alone on an actual use,
172
172
  // which is exactly where the removed timeout would come back.
173
173
  'auth_session_expiry_db',
174
+ // task 1003372 (audit A1, core_249): that hashing the bearer at rest does not
175
+ // sign anybody out. The DB-free sibling (session_token_hash) stands in front of
176
+ // the pool and proves the plaintext never reaches a query — the negative that
177
+ // matters — but it cannot show that Postgres accepts the migration, nor that
178
+ // the digest the backfill computes in SQL equals the one the kernel computes in
179
+ // Node. That mismatch would leave every new session working and every migrated
180
+ // one dead, which is the failure a source-reading test walks straight past.
181
+ 'session_token_hash_db',
174
182
  'scouting_rollup_fanout', 'session_records',
175
183
  // task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
176
184
  // SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
@@ -23,6 +23,7 @@
23
23
 
24
24
  'use strict';
25
25
 
26
+ const crypto = require('node:crypto');
26
27
  const { pool } = require('./pool');
27
28
  const seams = require('../module-seams');
28
29
 
@@ -277,8 +278,37 @@ async function accessStateByLogin(login) {
277
278
  // code agree on one number.
278
279
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
279
280
 
281
+ // sessionTokenHash — the ONE place a bearer becomes the value this table stores
282
+ // (task 1003372, audit A1, migration core_249). SHA-256 hex, matching the
283
+ // migration's `encode(sha256(token::bytea), 'hex')` byte for byte, so a row
284
+ // backfilled in SQL and a row minted here resolve identically.
285
+ //
286
+ // WHY A FAST HASH. A session token is 256 bits of CSPRNG, not a password: there
287
+ // is no dictionary to run, so a KDF would buy nothing and cost real latency on
288
+ // every authenticated request. Same reasoning, written out at length, in
289
+ // modules/platform-identity/platform-identity.js (`sha256hex`).
290
+ //
291
+ // WHY NO timingSafeEqual HERE, THOUGH THE TASK NAMED IT. The pattern the task
292
+ // points at compares a presented secret against one it already fetched by
293
+ // client_id, so the comparison happens in JS and must be constant-time. A session
294
+ // has no second identifier — the bearer IS the lookup key — so the comparison is
295
+ // the indexed `WHERE token_hash = $1` below, and there is no JS compare to make
296
+ // constant-time. Adding a timingSafeEqual after that lookup would compare the
297
+ // hash to itself: vacuously true, and worse than nothing, because it would read
298
+ // as a control that is doing something. The property the task wanted — a guessed
299
+ // token leaks no information about how close it was — holds here because the
300
+ // index is probed with a full digest, never a prefix.
301
+ function sessionTokenHash(token) {
302
+ return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
303
+ }
304
+
280
305
  // createSession — mint a builder_sessions row.
281
306
  //
307
+ // THE PLAINTEXT BEARER IS NEVER STORED (task 1003372). `token` is hashed on the
308
+ // way in and the column that held it is gone (core_249). The raw value is
309
+ // returned to the caller — it is their input, handed back so the mint response
310
+ // can carry it to the client — and after that it exists only in the client.
311
+ //
282
312
  // A null/undefined expiresAt means NO EXPIRY (expires_at NULL), which
283
313
  // lookupSession honours as never-expires. That is the CLI's contract (ADR 0294,
284
314
  // restoring 003_pms.sql's original one): a `bongos` sign-in lasts until it is
@@ -290,12 +320,15 @@ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
290
320
  async function createSession({ builderId, token, source, userAgent, expiresAt }) {
291
321
  const effectiveExpiresAt = expiresAt ?? null;
292
322
  const { rows } = await pool.query(
293
- `INSERT INTO builder_sessions (builder_id, token, source, user_agent, expires_at)
323
+ `INSERT INTO builder_sessions (builder_id, token_hash, source, user_agent, expires_at)
294
324
  VALUES ($1, $2, $3, $4, $5)
295
- RETURNING id, builder_id, token, source, created_at, expires_at`,
296
- [builderId, token, source, userAgent ?? null, effectiveExpiresAt]
325
+ RETURNING id, builder_id, source, created_at, expires_at`,
326
+ [builderId, sessionTokenHash(token), source, userAgent ?? null, effectiveExpiresAt]
297
327
  );
298
- return rows[0];
328
+ // `token` is re-attached from the ARGUMENT, never read back from the row — the
329
+ // row no longer holds it. Callers that pass a token and read one back are
330
+ // unchanged; what changed is that the database never saw the plaintext.
331
+ return { ...rows[0], token };
299
332
  }
300
333
 
301
334
  // lookupSession — resolve a live (unrevoked, unexpired) session token to its
@@ -346,8 +379,8 @@ async function lookupSession(token) {
346
379
  b.rank, b.status, b.system_role, b.anthropic_user_id${sessionPermsSelect()}
347
380
  FROM builder_sessions s
348
381
  JOIN builders b ON b.id = s.builder_id
349
- WHERE s.token = $1`,
350
- [token]
382
+ WHERE s.token_hash = $1`,
383
+ [sessionTokenHash(token)]
351
384
  );
352
385
  const row = rows[0];
353
386
  if (!row) return null;
@@ -376,6 +409,9 @@ async function lookupSession(token) {
376
409
  module.exports = {
377
410
  withTx,
378
411
  sessionPermsSelect,
412
+ // Exported for db.js's revokeSession (which is handed a bearer, not an id) and
413
+ // for the tests that assert the plaintext never reaches a query parameter.
414
+ sessionTokenHash,
379
415
  getBuilderById,
380
416
  getFoundingBuilder,
381
417
  listActiveBuilders,
package/src/bongos/db.js CHANGED
@@ -44,6 +44,9 @@ const {
44
44
  // internal uses (setBuilderRank's RANKS.indexOf, etc.) stay byte-identical.
45
45
  RANKS, SESSION_TTL_MS, upsertBuilder, seatFirstAdminIfUnclaimed,
46
46
  getBuilderByGithubId, hasInvitedAccessRequest, createSession, lookupSession,
47
+ // The bearer → stored-value transform (task 1003372, core_249). revokeSession
48
+ // below is the one helper here still handed a raw bearer rather than an id.
49
+ sessionTokenHash,
47
50
  } = require('./db-kernel');
48
51
 
49
52
  // gradePassCount removed with builderRollingAvgGrade (its only core user) — that
@@ -728,10 +731,14 @@ async function hasCliSession(builderId) {
728
731
  // lookupSession — carved to ./db-kernel (ADR 0102). Re-exported above.
729
732
 
730
733
 
734
+ // Takes the raw bearer (its callers are sign-out paths holding a cookie or an
735
+ // Authorization header, not a session id) and matches on the stored hash —
736
+ // core_249 dropped the plaintext column, so the bearer itself is no longer
737
+ // something this table can be searched by.
731
738
  async function revokeSession(token) {
732
739
  await pool.query(
733
- `UPDATE builder_sessions SET revoked_at = now() WHERE token = $1 AND revoked_at IS NULL`,
734
- [token]
740
+ `UPDATE builder_sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`,
741
+ [sessionTokenHash(token)]
735
742
  );
736
743
  }
737
744
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.887'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.889'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,133 @@
1
+ // tests/agents_cron_trigger.mjs — 'cron' is a trigger type that can EXIST
2
+ // (task 1004008, goal 1000095).
3
+ //
4
+ // Tasks 1002498 (cron-router poller) and 1002501 (cron roster) both select "due
5
+ // cron agents" from a set that was empty by construction: agents_001 pinned
6
+ // trigger_type to ('event','on-demand') on BOTH agents_definitions and
7
+ // agents_runs, and lib/validate.js mirrored it. A poller built against that is
8
+ // correct, tested, and unreachable — green tests over a branch nothing can enter.
9
+ //
10
+ // What this locks:
11
+ // * the THREE places that must agree (validator, definitions CHECK, runs CHECK)
12
+ // — the ledger constraint is the one that is easy to forget, and forgetting it
13
+ // yields the worst shape available: an agent that looks armed, fires, and
14
+ // then violates a CHECK when it tries to record the run.
15
+ // * the cadence VOCABULARY, including the sub-hourly divisor rule.
16
+ // * that validate.js's duplicated CADENCES really match scripts/gds/
17
+ // routine-schedule.js's, which is the copy this one was made from.
18
+ // * that widening the enum did not loosen it — an unwired name is still refused.
19
+
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { createRequire } from 'node:module';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const v = require('../modules/agents/lib/validate.js');
30
+ const schedule = require('../scripts/gds/routine-schedule.js');
31
+
32
+ const sql = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'agents', 'migrations', f), 'utf8');
33
+ const mig001 = sql('agents_001_definitions.sql');
34
+ const mig005 = sql('agents_005_cron_trigger.sql');
35
+
36
+ const def = (over = {}) => ({ name: 'ship-digest', persona: 'x'.repeat(40), ...over });
37
+ const cron = (cadence) => def({ trigger_type: 'cron', trigger_spec: { cadence } });
38
+
39
+ // ---- 1. the validator ------------------------------------------------------
40
+
41
+ test("'cron' is a wired trigger type", () => {
42
+ assert.ok(v.TRIGGER_TYPES.includes('cron'));
43
+ // Widening is not loosening: the enum is still closed.
44
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'webhook', trigger_spec: {} })).ok, false);
45
+ assert.deepEqual([...v.TRIGGER_TYPES].sort(), ['cron', 'event', 'on-demand']);
46
+ });
47
+
48
+ test('a cron definition must say WHEN, or it can never fire', () => {
49
+ const r = v.validateAgentDefinition(def({ trigger_type: 'cron', trigger_spec: {} }));
50
+ assert.equal(r.ok, false);
51
+ assert.ok(r.errors.some((e) => e.code === 'trigger_cadence_missing'),
52
+ 'the missing cadence is named as its own error, not folded into a generic spec failure');
53
+ });
54
+
55
+ test('the cadence vocabulary: named, and sub-hourly where N divides 60', () => {
56
+ for (const good of ['hourly', 'daily', 'weekly', 'monthly', '15min', '5min', '30min', '1min']) {
57
+ assert.equal(v.validateAgentDefinition(cron(good)).ok, true, `${good} is a cadence`);
58
+ }
59
+ // 7 and 45 do not divide 60: `*/7` fires :00…:56 then waits 4, so the declared
60
+ // period and the realized period would be different claims.
61
+ for (const bad of ['7min', '45min', '0min', '60min', 'fortnightly', 'often', '']) {
62
+ assert.equal(v.validateAgentDefinition(cron(bad)).ok, false, `${bad} is not a cadence`);
63
+ }
64
+ });
65
+
66
+ test('an accepted cron definition keeps its cadence through normalization', () => {
67
+ // The bug class task 1004007 documents one axis over: validate accepted a field
68
+ // and then DROPPED it, so a definition looked configured and was not.
69
+ const r = v.validateAgentDefinition(cron('daily'));
70
+ assert.equal(r.ok, true);
71
+ assert.equal(r.value.trigger_spec.cadence, 'daily', 'the cadence survives into the normalized value');
72
+ assert.equal(r.value.trigger_type, 'cron');
73
+ });
74
+
75
+ test('the event path is untouched', () => {
76
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: { event: 'task.shipped' } })).ok, true);
77
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: {} })).ok, false);
78
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'event', trigger_spec: { event: 'nope' } })).ok, false);
79
+ });
80
+
81
+ test('a VALID cadence on a non-cron definition is accepted, as a wired event is', () => {
82
+ // Pinned because the reach is a judgement call and the comment beside the code
83
+ // claims exactly this. An UNWIRED cadence is still refused wherever it appears.
84
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'on-demand', trigger_spec: { cadence: 'daily' } })).ok, true);
85
+ assert.equal(v.validateAgentDefinition(def({ trigger_type: 'on-demand', trigger_spec: { cadence: '7min' } })).ok, false);
86
+ });
87
+
88
+ // ---- 2. the copy that is free to drift -------------------------------------
89
+
90
+ test('validate.js CADENCES agrees with routine-schedule.js, the copy it came from', () => {
91
+ // A module may require only src/module-api.js (ADR 0083), so validate.js cannot
92
+ // import this list — it holds a duplicate. This test is the thing that keeps the
93
+ // duplicate honest; without it the two are one edit away from disagreeing about
94
+ // what a cadence is, and nothing would say so until a tick.
95
+ assert.deepEqual([...v.CADENCES].sort(), [...schedule.CADENCES].sort());
96
+ for (const n of [1, 5, 7, 15, 30, 45, 60, 0]) {
97
+ assert.equal(v.isCadence(`${n}min`), schedule.isCadence(`${n}min`),
98
+ `the two copies agree on ${n}min`);
99
+ }
100
+ });
101
+
102
+ // ---- 3. both CHECK constraints ---------------------------------------------
103
+
104
+ test('BOTH tables widen — the ledger as well as the definition', () => {
105
+ // agents_001 constrains trigger_type on agents_runs too. Widen only the
106
+ // definition and a cron agent stores, enables, fires, and THEN fails when the
107
+ // run insert violates its own CHECK.
108
+ for (const c of ['agents_definitions_trigger_type_chk', 'agents_runs_trigger_type_chk']) {
109
+ assert.ok(mig001.includes(c), `${c} exists in agents_001 — this test is about widening it`);
110
+ assert.match(mig005, new RegExp(`ADD CONSTRAINT ${c}[\\s\\S]{0,200}?'cron'`),
111
+ `${c} is re-added including 'cron'`);
112
+ assert.match(mig005, new RegExp(`DROP CONSTRAINT ${c}`),
113
+ `${c} is dropped before being re-added — ALTER cannot widen a CHECK in place`);
114
+ }
115
+ });
116
+
117
+ test('the migration pins the SHAPE of a cron spec, leaving the vocabulary to the validator', () => {
118
+ assert.match(mig005, /agents_definitions_trigger_spec_cadence_chk/);
119
+ assert.match(mig005, /trigger_type <> 'cron'/, 'the guard only binds cron rows');
120
+ assert.match(mig005, /trigger_spec \? 'cadence'/, 'and requires the key');
121
+ // The sub-hourly divisor rule must NOT be spelled in SQL — that would be a
122
+ // second copy of isCadence(), free to drift from the one the validator uses.
123
+ assert.ok(!/% *60|divid/i.test(mig005.replace(/^--.*$/gm, '')),
124
+ 'no arithmetic cadence rule in the executable SQL — the predicate lives in one place');
125
+ });
126
+
127
+ test('the migration is additive, namespaced and idempotent (ADR 0083 §5)', () => {
128
+ assert.match(mig005, /IF (NOT )?EXISTS/, 'guarded so a re-run is safe');
129
+ assert.ok(!/\bDROP TABLE\b|\bDROP COLUMN\b/i.test(mig005), 'nothing is destroyed');
130
+ for (const stmt of mig005.match(/(CREATE|ALTER)\s+TABLE\s+(\w+)/gi) || []) {
131
+ assert.match(stmt, /\s(agents_\w+)$/, `${stmt} touches only agents_-namespaced tables`);
132
+ }
133
+ });
@@ -164,12 +164,20 @@ await test('an on-demand definition carrying a WIRED event is accepted', () => {
164
164
 
165
165
  // --- THE TRIGGER TYPE -------------------------------------------------------
166
166
 
167
- await test('TRIGGER_TYPES is the v1 pair the DB CHECK also pins', () => {
168
- assert.deepEqual(TRIGGER_TYPES, ['event', 'on-demand']);
167
+ await test('TRIGGER_TYPES is the wired set the DB CHECK also pins', () => {
168
+ // Was the v1 PAIR; task 1004008 added 'cron' so tasks 1002498/1002501 select
169
+ // from a set that can be non-empty. Kept as an exact list on purpose: widening
170
+ // this vocabulary must be a deliberate edit here AND in agents_005's CHECK on
171
+ // both agents_definitions and agents_runs (tests/agents_cron_trigger.mjs pins
172
+ // that the three agree).
173
+ assert.deepEqual(TRIGGER_TYPES, ['event', 'on-demand', 'cron']);
169
174
  });
170
175
 
171
176
  await test('an unknown trigger type is refused', () => {
172
- for (const bad of ['cron', 'webhook', 'Event', 'on demand', '', null, undefined, 7, {}]) {
177
+ // 'cron' was this list's first entry until task 1004008 wired it. 'webhook' and
178
+ // 'schedule' are the plausible-but-unwired names now — the case exists to prove
179
+ // an unwired name is refused, so it needs names that are actually unwired.
180
+ for (const bad of ['webhook', 'schedule', 'Event', 'on demand', '', null, undefined, 7, {}]) {
173
181
  const r = validateAgentDefinition({ ...OK_DEF, trigger_type: bad }, OPTS);
174
182
  assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
175
183
  assert.ok(hasCode(r, 'trigger_type_unknown'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
@@ -471,7 +479,7 @@ await test('a hostile getter cannot escape the validator', () => {
471
479
 
472
480
  await test('EVERY error carries a code, a field and a human message', () => {
473
481
  const r = validateAgentDefinition(
474
- { name: 'BAD NAME', trigger_type: 'cron', trigger_spec: 'nope', scope_modules: ['government'] },
482
+ { name: 'BAD NAME', trigger_type: 'webhook', trigger_spec: 'nope', scope_modules: ['government'] },
475
483
  { authorRank: 'xenos', allowedModules: ['agents'] },
476
484
  );
477
485
  assert.equal(r.ok, false);
@@ -485,7 +493,7 @@ await test('EVERY error carries a code, a field and a human message', () => {
485
493
 
486
494
  await test('validation reports ALL faults at once, not just the first', () => {
487
495
  // An author fixing a definition file should not have to re-run four times.
488
- const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: 'cron' }, OPTS);
496
+ const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: 'webhook' }, OPTS);
489
497
  for (const code of ['name_invalid', 'persona_missing', 'trigger_type_unknown']) {
490
498
  assert.ok(hasCode(r, code), `${code} missing from ${JSON.stringify(codes(r))}`);
491
499
  }
@@ -67,7 +67,7 @@ await pool.query(`
67
67
  display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
68
68
  status text DEFAULT 'active', system_role text, anthropic_user_id text);
69
69
  CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
70
- token text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
70
+ token_hash text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
71
71
  last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
72
72
  INSERT INTO builders (github_login) VALUES ('probe');
73
73
  `);
@@ -86,6 +86,11 @@ const t = async (name, fn) => {
86
86
  catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
87
87
  };
88
88
  const row = async (sql, p) => (await client.query(sql, p)).rows[0];
89
+ // core_249: the bearer is no longer a column, so a probe that wants "the row for
90
+ // this token" has to ask by the stored digest. Uses the kernel's OWN helper, so a
91
+ // change to the hash cannot leave this file quietly matching nothing — a mismatch
92
+ // would make every assertion below read a missing row and fail loudly.
93
+ const h = (token) => kernel.sessionTokenHash(token);
89
94
  // refresh-on-use is fire-and-forget: let the UPDATE land before reading it back.
90
95
  const settle = () => new Promise((r) => setTimeout(r, 300));
91
96
 
@@ -110,67 +115,67 @@ await t('lookupSession resolves a CLI bearer to its builder', async () => {
110
115
  await t('after a real use, a CLI session STILL has no expiry (the CASE guard)', async () => {
111
116
  await kernel.lookupSession('cli-tok');
112
117
  await settle();
113
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='cli-tok'`)).expires_at, null);
118
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).expires_at, null);
114
119
  });
115
120
 
116
121
  await t('refresh-on-use still bumps last_used_at on that same row', async () => {
117
- const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
118
- await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token='cli-tok'`);
122
+ const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
123
+ await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token_hash='${h('cli-tok')}'`);
119
124
  await kernel.lookupSession('cli-tok');
120
125
  await settle();
121
- const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
126
+ const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
122
127
  assert.ok(after > new Date(Date.now() - 60_000), `last_used_at did not move: ${after} (was ${before})`);
123
128
  });
124
129
 
125
130
  await t('a web session still rolls its expiry forward (refresh-on-use intact)', async () => {
126
131
  await kernel.lookupSession('web-tok');
127
132
  await settle();
128
- const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token='web-tok'`)).expires_at;
133
+ const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('web-tok')}'`)).expires_at;
129
134
  assert.ok(got !== null && got < thirtyDays, `expected a rolled-forward date below 30d, got ${got}`);
130
135
  });
131
136
 
132
137
  await t('an expired session is still REFUSED (expiry honoured where present)', async () => {
133
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at)
134
- VALUES (1,'dead-tok','web', now() - interval '1 hour')`);
138
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at)
139
+ VALUES (1,'${h('dead-tok')}','web', now() - interval '1 hour')`);
135
140
  assert.equal(await kernel.lookupSession('dead-tok'), null);
136
141
  });
137
142
 
138
143
  await t('a revoked CLI session is still REFUSED', async () => {
139
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, revoked_at)
140
- VALUES (1,'revoked-tok','cli', now())`);
144
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, revoked_at)
145
+ VALUES (1,'${h('revoked-tok')}','cli', now())`);
141
146
  assert.equal(await kernel.lookupSession('revoked-tok'), null);
142
147
  });
143
148
 
144
149
  // The migration, against rows shaped like the ones live instances actually hold.
145
150
  const migrationSql = readFileSync(MIGRATION, 'utf8');
146
- await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at) VALUES
147
- (1,'old-cli','cli', now() + interval '24 hours'),
148
- (1,'old-web','web', now() + interval '30 days'),
149
- (1,'old-revoked','cli', now() + interval '24 hours')`);
150
- await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token='old-revoked'`);
151
+ await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at) VALUES
152
+ (1,'${h('old-cli')}','cli', now() + interval '24 hours'),
153
+ (1,'${h('old-web')}','web', now() + interval '30 days'),
154
+ (1,'${h('old-revoked')}','cli', now() + interval '24 hours')`);
155
+ await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token_hash='${h('old-revoked')}'`);
151
156
  await client.query(migrationSql);
152
157
 
153
158
  await t('core_243 clears a pre-existing CLI expiry', async () => {
154
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
159
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
155
160
  });
156
161
 
157
162
  await t('core_243 leaves a WEB row dated — the source filter is its safety property', async () => {
158
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
163
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
159
164
  });
160
165
 
161
166
  await t('core_243 leaves a revoked row alone (it is the audit trail)', async () => {
162
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-revoked'`)).expires_at, null);
167
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-revoked')}'`)).expires_at, null);
163
168
  });
164
169
 
165
170
  await t('core_243 is idempotent — a re-run changes nothing', async () => {
166
171
  await client.query(migrationSql);
167
- assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
168
- assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
172
+ assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
173
+ assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
169
174
  });
170
175
 
171
176
  // The whole point of the task, stated as a test.
172
177
  await t('a CLI session idle for 21 DAYS still authenticates', async () => {
173
- await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token='cli-tok'`);
178
+ await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token_hash='${h('cli-tok')}'`);
174
179
  assert.notEqual(await kernel.lookupSession('cli-tok'), null);
175
180
  });
176
181
 
@@ -42,6 +42,9 @@ if (!process.env.DATABASE_URL) {
42
42
  }
43
43
 
44
44
  const { Pool } = require('pg');
45
+ // The kernel's own bearer → stored-digest transform (core_249), so this fixture
46
+ // cannot drift from how production mints a session.
47
+ const { sessionTokenHash } = require('../src/bongos/db-kernel.js');
45
48
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
46
49
 
47
50
  // ── THE READINESS GUARD, AND WHY ITS SHAPE MATTERS (task 1003091) ────────────
@@ -137,9 +140,12 @@ async function seed() {
137
140
  [ARCHON, THETES]
138
141
  );
139
142
  await q(
140
- `INSERT INTO builder_sessions (builder_id, token, source)
143
+ // core_249: the bearer is stored as its SHA-256, so a fixture session has to
144
+ // be seeded the same way the kernel mints one — otherwise the HTTP calls
145
+ // below authenticate as nobody.
146
+ `INSERT INTO builder_sessions (builder_id, token_hash, source)
141
147
  VALUES ($1,$2,'web'), ($3,$4,'web')`,
142
- [ARCHON, ARCHON_TOKEN, THETES, THETES_TOKEN]
148
+ [ARCHON, sessionTokenHash(ARCHON_TOKEN), THETES, sessionTokenHash(THETES_TOKEN)]
143
149
  );
144
150
  const [idea] = await q(
145
151
  `INSERT INTO idea_inbox (title, body_md, captured_by)