@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.
- package/.bongos-core.json +46 -21
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/gds-db-backup.md +30 -2
- package/migrations/core_249_session_token_hash.sql +103 -0
- package/modules/agents/lib/validate.js +49 -2
- package/modules/agents/migrations/agents_005_cron_trigger.sql +78 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/db-backup-offsite.sh +105 -9
- package/scripts/gds/run-unit-tests.js +8 -0
- package/src/bongos/db-kernel.js +42 -6
- package/src/bongos/db.js +9 -2
- package/src/module-api.js +1 -1
- package/tests/agents_cron_trigger.mjs +133 -0
- package/tests/agents_validate.mjs +13 -5
- package/tests/auth_session_expiry_db.mjs +26 -21
- package/tests/government_live_proof.mjs +8 -2
- package/tests/own_session_revoke.mjs +13 -1
- package/tests/session_token_hash.mjs +139 -0
- package/tests/session_token_hash_db.mjs +193 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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="$
|
|
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
|
|
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
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
168
|
-
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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
|
|
118
|
-
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
168
|
-
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
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
|
|
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
|
-
|
|
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)
|