@bongos/core 1.21.135 → 1.21.137

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.
@@ -0,0 +1,53 @@
1
+ -- core_277 passkey foundation: credentials, per-builder user handle, challenge store
2
+ -- Applied under task 1004750 (ADR 0382 D1, D6).
3
+ --
4
+ -- WHY. A hardware card (a WebAuthn passkey) is a second sign-in step an Archon can
5
+ -- switch on per builder. Before any ceremony route can exist, the server needs three
6
+ -- things to keep: the public half of each registered card, an opaque handle that
7
+ -- stands in for the builder inside the card (never the email), and the challenges it
8
+ -- has issued and not yet seen answered.
9
+ --
10
+ -- SHAPE.
11
+ -- * passkey_credentials one row per registered card. credential_id is the
12
+ -- authenticator's own id (base64url) and is UNIQUE: a card can belong to one
13
+ -- builder. sign_count is kept so a counter that goes backwards can be caught.
14
+ -- * passkey_user_handles one random handle per builder, minted on first use.
15
+ -- * passkey_challenges short-lived, single-use. A row is DELETED when consumed;
16
+ -- expires_at lets a sweep remove the ones nobody answered. builder_id is nullable
17
+ -- because a sign-in ceremony starts before the builder is known.
18
+
19
+ CREATE TABLE IF NOT EXISTS passkey_credentials (
20
+ id bigserial PRIMARY KEY,
21
+ credential_id text NOT NULL UNIQUE,
22
+ builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
23
+ public_key bytea NOT NULL,
24
+ sign_count bigint NOT NULL DEFAULT 0,
25
+ transports text[] NOT NULL DEFAULT '{}',
26
+ aaguid text,
27
+ nickname text,
28
+ created_at timestamptz NOT NULL DEFAULT now(),
29
+ last_used_at timestamptz
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_passkey_credentials_builder
33
+ ON passkey_credentials (builder_id);
34
+
35
+ CREATE TABLE IF NOT EXISTS passkey_user_handles (
36
+ builder_id bigint PRIMARY KEY REFERENCES builders(id) ON DELETE CASCADE,
37
+ user_handle text NOT NULL UNIQUE,
38
+ created_at timestamptz NOT NULL DEFAULT now()
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS passkey_challenges (
42
+ challenge text PRIMARY KEY,
43
+ purpose text NOT NULL CHECK (purpose IN ('register', 'authenticate')),
44
+ builder_id bigint REFERENCES builders(id) ON DELETE CASCADE,
45
+ created_at timestamptz NOT NULL DEFAULT now(),
46
+ expires_at timestamptz NOT NULL
47
+ );
48
+
49
+ CREATE INDEX IF NOT EXISTS idx_passkey_challenges_expires
50
+ ON passkey_challenges (expires_at);
51
+
52
+ INSERT INTO schema_migrations (version) VALUES ('core_277_passkey_foundation')
53
+ ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,113 @@
1
+ // modules/hall-ui/public/settings-passkeys.js — the "Security cards" panel on
2
+ // /builders/settings (task 1004746, ADR 0382). List, add, rename, remove the
3
+ // signed-in builder's FIDO cards. All identity is the server's session; this file
4
+ // only drives the ceremony the browser library performs.
5
+ (() => {
6
+ 'use strict';
7
+
8
+ const API = '/api/bongos';
9
+ const { escapeHtml, $, fmtDate } = window.OTB;
10
+ const toast = (msg) => window.OTB.toast(msg);
11
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
12
+ const toAuth = () => window.location.assign(`${API}/auth/web/start`);
13
+
14
+ const fail = (res) => new Error((res.data && res.data.message) || `HTTP ${res.status}`);
15
+
16
+ function rowHtml(c) {
17
+ const name = c.nickname || 'Unnamed card';
18
+ const used = c.last_used_at ? `last used ${escapeHtml(fmtDate(c.last_used_at))}` : 'never used';
19
+ return `<div class="sessions-row" data-id="${escapeHtml(c.credential_id)}">
20
+ <div class="sessions-row__main">
21
+ <strong>${escapeHtml(name)}</strong>
22
+ <span class="sessions-row__when">added ${escapeHtml(fmtDate(c.created_at))} · ${used}</span>
23
+ </div>
24
+ <button type="button" class="settings-btn settings-btn--quiet passkeys-rename">Rename</button>
25
+ <button type="button" class="settings-btn settings-btn--quiet passkeys-remove">Remove</button>
26
+ </div>`;
27
+ }
28
+
29
+ async function render() {
30
+ const res = await api.request('GET', `${API}/me/passkeys`);
31
+ if (res.status === 401) { toAuth(); return; }
32
+ if (!res.ok) throw fail(res);
33
+ const cards = (res.data && res.data.passkeys) || [];
34
+ const list = $('#passkeys-list');
35
+ list.innerHTML = cards.length ? cards.map(rowHtml).join('') : '<p class="settings-card__hint">No cards registered yet.</p>';
36
+ for (const row of list.querySelectorAll('.sessions-row')) {
37
+ const id = row.getAttribute('data-id');
38
+ row.querySelector('.passkeys-rename').addEventListener('click', () => rename(id, row));
39
+ row.querySelector('.passkeys-remove').addEventListener('click', () => remove(id));
40
+ }
41
+ }
42
+
43
+ async function rename(id, row) {
44
+ const current = row.querySelector('strong').textContent;
45
+ const next = window.prompt('Name this card', current === 'Unnamed card' ? '' : current);
46
+ if (next === null) return;
47
+ try {
48
+ const res = await api.request('POST', `${API}/me/passkeys/rename`, { body: { credential_id: id, nickname: next } });
49
+ if (!res.ok) throw fail(res);
50
+ await render();
51
+ } catch (err) { toast(`Could not rename that card: ${err.message}`); }
52
+ }
53
+
54
+ async function remove(id) {
55
+ if (!window.confirm('Remove this card? You can register it again later.')) return;
56
+ try {
57
+ const res = await api.request('POST', `${API}/me/passkeys/remove`, { body: { credential_id: id } });
58
+ if (!res.ok) throw fail(res);
59
+ toast('Card removed.');
60
+ await render();
61
+ } catch (err) { toast(`Could not remove that card: ${err.message}`); }
62
+ }
63
+
64
+ function loadBrowserLib() {
65
+ if (window.SimpleWebAuthnBrowser) return Promise.resolve(window.SimpleWebAuthnBrowser);
66
+ return new Promise((resolve, reject) => {
67
+ const s = document.createElement('script');
68
+ s.src = `${API}/me/passkeys/browser.js`;
69
+ s.onload = () => resolve(window.SimpleWebAuthnBrowser);
70
+ s.onerror = () => reject(new Error('could not load the card library'));
71
+ document.head.appendChild(s);
72
+ });
73
+ }
74
+
75
+ async function add() {
76
+ const btn = $('#passkeys-add');
77
+ const status = $('#passkeys-status');
78
+ btn.disabled = true;
79
+ status.textContent = 'Waiting for your card…';
80
+ try {
81
+ const lib = await loadBrowserLib();
82
+ if (!lib.browserSupportsWebAuthn()) throw new Error('this browser does not support security cards');
83
+ const opt = await api.request('POST', `${API}/me/passkeys/register/options`, { body: {} });
84
+ if (opt.status === 401) { toAuth(); return; }
85
+ if (!opt.ok) throw fail(opt);
86
+ const attestation = await lib.startRegistration({ optionsJSON: opt.data.options });
87
+ const nickname = $('#passkeys-nickname').value;
88
+ const ver = await api.request('POST', `${API}/me/passkeys/register/verify`, { body: { response: attestation, nickname } });
89
+ if (!ver.ok) throw fail(ver);
90
+ $('#passkeys-nickname').value = '';
91
+ status.textContent = '';
92
+ toast('Card added.');
93
+ await render();
94
+ } catch (err) {
95
+ status.textContent = '';
96
+ toast(`Could not add a card: ${err.name === 'NotAllowedError' ? 'cancelled or timed out' : err.message}`);
97
+ } finally {
98
+ btn.disabled = false;
99
+ }
100
+ }
101
+
102
+ async function setup() {
103
+ try {
104
+ await render();
105
+ $('#passkeys-scroll').hidden = false;
106
+ $('#passkeys-add').addEventListener('click', add);
107
+ } catch (err) {
108
+ toast(`Could not load your security cards: ${err.message}`);
109
+ }
110
+ }
111
+
112
+ setup();
113
+ })();
@@ -171,6 +171,25 @@
171
171
  <p class="section-note">Ending a session takes effect immediately; the token stops working on its very next request.</p>
172
172
  </section>
173
173
 
174
+ <!-- SECURITY CARDS - register, rename and remove your FIDO hardware
175
+ cards (task 1004746, ADR 0382). Rendered by settings-passkeys.js
176
+ against /api/bongos/me/passkeys. -->
177
+ <section class="scroll" id="passkeys-scroll" aria-labelledby="passkeys-h" data-group="access" hidden>
178
+ <h2 id="passkeys-h" class="scroll__h">Security cards</h2>
179
+ <p class="scroll__lede">
180
+ Register a hardware security card (or a phone or laptop passkey) so it can
181
+ be asked for as a second check on sensitive actions. Registering a card
182
+ does not change how you sign in.
183
+ </p>
184
+ <div id="passkeys-list" class="sessions-list" aria-live="polite"></div>
185
+ <div class="settings-card">
186
+ <input type="text" id="passkeys-nickname" maxlength="60" placeholder="Name this card (optional)" aria-label="Card name">
187
+ <button type="button" class="settings-btn" id="passkeys-add">Add a card</button>
188
+ <span class="settings-status" id="passkeys-status" role="status" aria-live="polite"></span>
189
+ </div>
190
+ <p class="section-note">Hold the card to your reader or touch it when your browser asks. Keep more than one registered in case one is lost.</p>
191
+ </section>
192
+
174
193
  <!-- YOUR AUTOBONGOS RUNNER — which goals your own unattended runner
175
194
  works (task 1004501). Rendered by settings-autobongos.js against
176
195
  GET /autonomy/fence (your effective fence) and PUT
@@ -467,6 +486,7 @@
467
486
  <!-- task 1003341: the Active sessions panel. Its own file rather than more of
468
487
  settings.js, which sits close to the 1500-line size ratchet. -->
469
488
  <script src="/builders/settings-sessions.js?v=2026-08-29-sessions"></script>
489
+ <script src="/builders/settings-passkeys.js?v=2026-10-15-passkeys"></script>
470
490
  <!-- task 1002514: the interaction-profile panel ("How Claude talks to you"), on
471
491
  the same precedent and for the same reason — settings.js is against the
472
492
  1500-line ratchet, and this panel shares nothing with the rest of the page. -->
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.135",
3
+ "version": "1.21.137",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.135",
9
+ "version": "1.21.137",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.135",
3
+ "version": "1.21.137",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9812,5 +9812,17 @@
9812
9812
  "id": "1003462",
9813
9813
  "text": "Adds an Add-a-task form to the Goals page: pick an open goal, name the task, optional description. Sends POST /tasks unchanged; the server's rank floor, goal/version and closed-goal refusals render as sentences. States that a"
9814
9814
  }
9815
+ ],
9816
+ "1.21.136": [
9817
+ {
9818
+ "id": "1004750",
9819
+ "text": "Groundwork for hardware-card sign-in: the database tables and storage code that remember registered cards and the short-lived one-time challenges. Nothing is visible to builders yet."
9820
+ }
9821
+ ],
9822
+ "1.21.137": [
9823
+ {
9824
+ "id": "1004746",
9825
+ "text": "Builders can now register, list, rename and remove a hardware security card from Settings."
9826
+ }
9815
9827
  ]
9816
9828
  }
@@ -232,6 +232,9 @@ const INTEGRATION = new Set([
232
232
  // admission reads' statements run on Postgres, and the hub's notice restamp and
233
233
  // listing. The DB-free sibling (application_lifecycle) interprets the same WHEREs.
234
234
  'invite_expiry_db',
235
+ // task 1004750 (ADR 0382 D6): passkey tables from the real migration — single-use
236
+ // challenges, TTL sweep, unique credential id, sign-count regression.
237
+ 'passkey_foundation_db',
235
238
  // task 1004408: migration platform_identity_028 deletes the old hub invite's
236
239
  // pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
237
240
  // engine runs the DO block, the numeric version compare and the one-shot guard.
@@ -0,0 +1,153 @@
1
+ // src/bongos/passkeys.js — passkey (WebAuthn) storage: credentials, the opaque user
2
+ // handle, and the single-use challenge store (task 1004750, ADR 0382 D1/D6).
3
+ //
4
+ // This file is the foundation only: it keeps and hands back state. The registration
5
+ // and sign-in ceremonies, and the routes that drive them, are their own tasks and
6
+ // build on these functions with @simplewebauthn/server.
7
+ //
8
+ // A challenge is single-use: consumeChallenge DELETEs the row and returns it, so a
9
+ // replay finds nothing. A challenge past its TTL is refused even before the sweep
10
+ // has removed it.
11
+ 'use strict';
12
+
13
+ const crypto = require('node:crypto');
14
+
15
+ const CHALLENGE_TTL_SECONDS = 300;
16
+ const MAX_CHALLENGE_TTL_SECONDS = 600;
17
+ const PURPOSES = Object.freeze(['register', 'authenticate']);
18
+
19
+ function defaultPool() { return require('./pool').pool; }
20
+
21
+ function randomToken(bytes) { return crypto.randomBytes(bytes).toString('base64url'); }
22
+
23
+ // A TTL is a positive whole number of seconds no longer than the ceiling, so a caller
24
+ // forwarding an untrusted value cannot mint a long-lived (or already-dead) challenge.
25
+ function clampTtlSeconds(ttl) {
26
+ const n = Math.floor(Number(ttl));
27
+ if (!Number.isFinite(n) || n <= 0) return CHALLENGE_TTL_SECONDS;
28
+ return Math.min(n, MAX_CHALLENGE_TTL_SECONDS);
29
+ }
30
+
31
+ // The clone check. A counter that does not advance is suspect, unless the card
32
+ // does not keep one at all (stored and reported both zero).
33
+ function counterAdvances(stored, reported) {
34
+ if (stored === 0 && reported === 0) return true;
35
+ return reported > stored;
36
+ }
37
+
38
+ // Mint and store a challenge. Returns { challenge, expiresAt }.
39
+ async function createChallenge({ purpose, builderId = null, ttlSeconds = CHALLENGE_TTL_SECONDS, pool = defaultPool() } = {}) {
40
+ if (!PURPOSES.includes(purpose)) throw new Error(`passkeys: bad challenge purpose ${JSON.stringify(purpose)}`);
41
+ const challenge = randomToken(32);
42
+ // Housekeeping rides on issuing: expired rows are removed here, so the table is
43
+ // swept by use and needs no separate scheduler.
44
+ await sweepExpiredChallenges({ pool });
45
+ const { rows } = await pool.query(
46
+ `INSERT INTO passkey_challenges (challenge, purpose, builder_id, expires_at)
47
+ VALUES ($1, $2, $3, now() + make_interval(secs => $4::int))
48
+ RETURNING expires_at`,
49
+ [challenge, purpose, builderId, String(clampTtlSeconds(ttlSeconds))],
50
+ );
51
+ return { challenge, expiresAt: rows[0].expires_at };
52
+ }
53
+
54
+ // Take a challenge: delete it and return { purpose, builderId } if it was live, else
55
+ // null. Pass expectPurpose (and expectBuilderId for a signed-in registration) and a
56
+ // challenge issued for something else is refused. It is still spent: a wrong-purpose
57
+ // presentation burns it rather than leaving it to be tried again.
58
+ async function consumeChallenge(challenge, { expectPurpose = null, expectBuilderId = null, pool = defaultPool() } = {}) {
59
+ if (typeof challenge !== 'string' || !challenge) return null;
60
+ const { rows } = await pool.query(
61
+ `DELETE FROM passkey_challenges WHERE challenge = $1
62
+ RETURNING purpose, builder_id, (expires_at > now()) AS live`,
63
+ [challenge],
64
+ );
65
+ const r = rows[0];
66
+ if (!r || !r.live) return null;
67
+ if (expectPurpose != null && r.purpose !== expectPurpose) return null;
68
+ if (expectBuilderId != null && String(r.builder_id) !== String(expectBuilderId)) return null;
69
+ return { purpose: r.purpose, builderId: r.builder_id == null ? null : String(r.builder_id) };
70
+ }
71
+
72
+ // Remove every challenge past its TTL. Returns the number removed.
73
+ async function sweepExpiredChallenges({ pool = defaultPool() } = {}) {
74
+ const r = await pool.query('DELETE FROM passkey_challenges WHERE expires_at <= now()');
75
+ return r.rowCount;
76
+ }
77
+
78
+ // The builder's opaque WebAuthn user handle, minted on first use. Never the email.
79
+ async function getOrCreateUserHandle(builderId, { pool = defaultPool() } = {}) {
80
+ const { rows } = await pool.query(
81
+ `INSERT INTO passkey_user_handles (builder_id, user_handle) VALUES ($1, $2)
82
+ ON CONFLICT (builder_id) DO UPDATE SET builder_id = EXCLUDED.builder_id
83
+ RETURNING user_handle`,
84
+ [builderId, randomToken(32)],
85
+ );
86
+ return rows[0].user_handle;
87
+ }
88
+
89
+ async function addCredential({ builderId, credentialId, publicKey, signCount = 0, transports = [], aaguid = null, nickname = null }, { pool = defaultPool() } = {}) {
90
+ const { rows } = await pool.query(
91
+ `INSERT INTO passkey_credentials (credential_id, builder_id, public_key, sign_count, transports, aaguid, nickname)
92
+ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at`,
93
+ [credentialId, builderId, Buffer.from(publicKey), signCount, transports, aaguid, nickname],
94
+ );
95
+ return rows[0];
96
+ }
97
+
98
+ async function getCredentialById(credentialId, { pool = defaultPool() } = {}) {
99
+ const { rows } = await pool.query(
100
+ `SELECT id, credential_id, builder_id, public_key, sign_count, transports, aaguid, nickname, created_at, last_used_at
101
+ FROM passkey_credentials WHERE credential_id = $1`,
102
+ [credentialId],
103
+ );
104
+ return rows[0] || null;
105
+ }
106
+
107
+ async function listCredentials(builderId, { pool = defaultPool() } = {}) {
108
+ const { rows } = await pool.query(
109
+ `SELECT id, credential_id, transports, aaguid, nickname, created_at, last_used_at
110
+ FROM passkey_credentials WHERE builder_id = $1 ORDER BY created_at, id`,
111
+ [builderId],
112
+ );
113
+ return rows;
114
+ }
115
+
116
+ // Record a successful use. One guarded UPDATE both checks and writes, so two
117
+ // concurrent assertions carrying the same counter cannot both pass and the stored
118
+ // counter can never be lowered. A counter that does not advance (unless the card keeps
119
+ // none) is a possible clone: nothing is written and the reason says why.
120
+ async function recordUse(credentialId, newSignCount, { pool = defaultPool() } = {}) {
121
+ const r = await pool.query(
122
+ `UPDATE passkey_credentials SET sign_count = $2, last_used_at = now()
123
+ WHERE credential_id = $1 AND (($2 = 0 AND sign_count = 0) OR $2 > sign_count)`,
124
+ [credentialId, newSignCount],
125
+ );
126
+ if (r.rowCount > 0) return { ok: true };
127
+ const cred = await getCredentialById(credentialId, { pool });
128
+ return { ok: false, reason: cred ? 'sign_count_regressed' : 'unknown_credential' };
129
+ }
130
+
131
+ async function removeCredential(builderId, credentialId, { pool = defaultPool() } = {}) {
132
+ const r = await pool.query(
133
+ 'DELETE FROM passkey_credentials WHERE builder_id = $1 AND credential_id = $2',
134
+ [builderId, credentialId],
135
+ );
136
+ return r.rowCount > 0;
137
+ }
138
+
139
+ // Rename a card. Scoped to the builder in the WHERE clause, so another builder's
140
+ // credential id matches no row.
141
+ async function renameCredential(builderId, credentialId, nickname, { pool = defaultPool() } = {}) {
142
+ const r = await pool.query(
143
+ 'UPDATE passkey_credentials SET nickname = $3 WHERE builder_id = $1 AND credential_id = $2',
144
+ [builderId, credentialId, nickname],
145
+ );
146
+ return r.rowCount > 0;
147
+ }
148
+
149
+ module.exports = {
150
+ CHALLENGE_TTL_SECONDS, MAX_CHALLENGE_TTL_SECONDS, PURPOSES, clampTtlSeconds, counterAdvances,
151
+ createChallenge, consumeChallenge, sweepExpiredChallenges,
152
+ getOrCreateUserHandle, addCredential, getCredentialById, listCredentials, recordUse, removeCredential, renameCredential,
153
+ };
@@ -0,0 +1,249 @@
1
+ // src/bongos/routes/my-passkeys.js — a signed-in builder's own hardware cards:
2
+ // register one, list them, rename one, remove one (task 1004746, ADR 0382 D3/D6).
3
+ //
4
+ // THE INVARIANT, as in my-sessions.js: the account is ALWAYS req.builder.id, taken
5
+ // from the session the caller presented. It is never read from the body, query or
6
+ // path, and every store call is keyed on it, so a credential id belonging to
7
+ // somebody else matches nothing.
8
+ //
9
+ // A card is a second factor added AFTER normal sign-in (D2/D3); this file only
10
+ // registers and manages cards. Nothing here switches a requirement on.
11
+ //
12
+ // Registration is two steps: `register/options` mints a single-use challenge bound
13
+ // to the builder, `register/verify` consumes it (a replay or someone else's
14
+ // challenge finds nothing) and stores the public half of the card. The private key
15
+ // never leaves the card.
16
+
17
+ const express = require('express');
18
+ const path = require('node:path');
19
+ const fs = require('node:fs');
20
+ const auth = require('../auth');
21
+ const { validateOrRespond, publicOrigin } = require('./_helpers');
22
+ const log = require('../logger').logger.child({ src: 'my-passkeys' });
23
+
24
+ const NICKNAME_MAX = 60;
25
+ const CREDENTIAL_ID_RE = /^[A-Za-z0-9_-]{1,1024}$/;
26
+ const MAX_CARDS_PER_BUILDER = 10;
27
+
28
+ // The RP ID and origin are configuration, set once by the owner (ADR 0382 D7):
29
+ // PASSKEY_ORIGIN and PASSKEY_RP_ID. Request headers are attacker-influenceable and
30
+ // origin pinning is WebAuthn's whole protection, so the request is trusted ONLY for a
31
+ // local development host; anywhere else, unset config means refuse, not guess.
32
+ const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\]|[a-z0-9-]+\.localhost)$/i;
33
+ function relyingParty(req) {
34
+ const rpName = process.env.PASSKEY_RP_NAME || 'Cloud Bongos';
35
+ let origin = process.env.PASSKEY_ORIGIN || null;
36
+ let rpID = process.env.PASSKEY_RP_ID || null;
37
+ if (!origin) {
38
+ const fromReq = publicOrigin(req);
39
+ let host = null;
40
+ try { host = new URL(fromReq).hostname; } catch { host = null; }
41
+ if (host && LOCAL_HOST_RE.test(host)) origin = fromReq;
42
+ }
43
+ if (!rpID && origin) { try { rpID = new URL(origin).hostname.replace(/^\[|\]$/g, ''); } catch { rpID = null; } }
44
+ return origin && rpID ? { origin, rpID, rpName } : null;
45
+ }
46
+
47
+ // The vendored browser bundle, resolved once. The package's exports map hides its
48
+ // package.json, so walk up from the resolved entry to the package root instead.
49
+ let bundleFile;
50
+ function browserBundleFile() {
51
+ if (bundleFile !== undefined) return bundleFile;
52
+ bundleFile = null;
53
+ try {
54
+ let dir = path.dirname(require.resolve('@simplewebauthn/browser'));
55
+ for (let i = 0; i < 6 && !fs.existsSync(path.join(dir, 'dist', 'bundle')); i++) dir = path.dirname(dir);
56
+ const file = path.join(dir, 'dist', 'bundle', 'index.umd.min.js');
57
+ if (fs.existsSync(file)) bundleFile = file;
58
+ } catch { /* leave null */ }
59
+ return bundleFile;
60
+ }
61
+
62
+ function cleanNickname(raw) {
63
+ if (raw === undefined || raw === null) return { value: null };
64
+ if (typeof raw !== 'string') return { error: 'nickname must be text' };
65
+ const v = raw.replace(/\s+/g, ' ').trim();
66
+ if (v.length > NICKNAME_MAX) return { error: `nickname must be ${NICKNAME_MAX} characters or fewer` };
67
+ return { value: v || null };
68
+ }
69
+
70
+ function presentCard(row) {
71
+ return {
72
+ credential_id: row.credential_id,
73
+ nickname: row.nickname ?? null,
74
+ transports: row.transports || [],
75
+ created_at: row.created_at,
76
+ last_used_at: row.last_used_at ?? null,
77
+ };
78
+ }
79
+
80
+ // Dependencies are injectable so the route is testable without Postgres or a real
81
+ // authenticator; production passes none.
82
+ module.exports = function buildMyPasskeysRouter({
83
+ store = require('../passkeys'),
84
+ webauthn = null,
85
+ requireBuilder = auth.requireBuilder,
86
+ } = {}) {
87
+ const router = express.Router();
88
+ const lib = () => webauthn || require('@simplewebauthn/server');
89
+
90
+ // GET /api/bongos/me/passkeys — the caller's registered cards.
91
+ // rank: any-builder (own identity only).
92
+ router.get('/me/passkeys', requireBuilder, async (req, res) => {
93
+ try {
94
+ const rows = await store.listCredentials(req.builder.id);
95
+ res.json({ passkeys: rows.map(presentCard) });
96
+ } catch (err) {
97
+ log.error({ err: err && err.message }, 'GET /me/passkeys failed');
98
+ res.fail('list_failed', { status: 500, message: 'internal error' });
99
+ }
100
+ });
101
+
102
+ // GET /api/bongos/me/passkeys/browser.js — the vetted @simplewebauthn/browser
103
+ // bundle, served same-origin so the page needs no CDN and no build step.
104
+ // rank: any-builder (own identity only) — a vendored static file at a fixed
105
+ // path, served only to a signed-in builder like the rest of /me.
106
+ router.get('/me/passkeys/browser.js', requireBuilder, (_req, res) => {
107
+ const file = browserBundleFile();
108
+ if (!file) {
109
+ log.error('passkeys browser bundle missing');
110
+ return res.fail('bundle_missing', { status: 500, message: 'internal error' });
111
+ }
112
+ res.type('application/javascript').set('Cache-Control', 'public, max-age=3600').sendFile(file, (err) => {
113
+ if (err && !res.headersSent) res.fail('bundle_missing', { status: 500, message: 'internal error' });
114
+ });
115
+ });
116
+
117
+ // POST /api/bongos/me/passkeys/register/options — start adding a card.
118
+ // rank: any-builder (own identity only).
119
+ router.post('/me/passkeys/register/options', requireBuilder, async (req, res) => {
120
+ if (validateOrRespond(req, res, {})) return;
121
+ try {
122
+ const rp = relyingParty(req);
123
+ if (!rp) return res.fail('passkeys_not_configured', { status: 503, message: 'security cards are not configured on this site yet' });
124
+ const { rpID, rpName } = rp;
125
+ const existing = await store.listCredentials(req.builder.id);
126
+ if (existing.length >= MAX_CARDS_PER_BUILDER) {
127
+ return res.fail('too_many_cards', { status: 409, message: `at most ${MAX_CARDS_PER_BUILDER} cards per account — remove one first` });
128
+ }
129
+ const handle = await store.getOrCreateUserHandle(req.builder.id);
130
+ const { challenge } = await store.createChallenge({ purpose: 'register', builderId: req.builder.id });
131
+ const options = await lib().generateRegistrationOptions({
132
+ rpName,
133
+ rpID,
134
+ userName: req.builder.github_login || req.builder.display_name || String(req.builder.id),
135
+ userDisplayName: req.builder.display_name || req.builder.github_login || undefined,
136
+ userID: Buffer.from(handle, 'base64url'),
137
+ challenge: Buffer.from(challenge, 'base64url'),
138
+ attestationType: 'none',
139
+ excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: c.transports })),
140
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
141
+ });
142
+ res.json({ options });
143
+ } catch (err) {
144
+ log.error({ err: err && err.message }, 'register/options failed');
145
+ res.fail('options_failed', { status: 500, message: 'internal error' });
146
+ }
147
+ });
148
+
149
+ // POST /api/bongos/me/passkeys/register/verify — finish adding a card.
150
+ // Body: { response, nickname? } where response is the browser's attestation.
151
+ // rank: any-builder (own identity only).
152
+ router.post('/me/passkeys/register/verify', requireBuilder, async (req, res) => {
153
+ if (validateOrRespond(req, res, { response: {}, nickname: {} })) return;
154
+ const response = req.body && req.body.response;
155
+ if (!response || typeof response !== 'object') {
156
+ return res.fail('bad_response', { status: 400, message: 'response is required' });
157
+ }
158
+ const nick = cleanNickname(req.body.nickname);
159
+ if (nick.error) return res.fail('bad_nickname', { status: 400, message: nick.error });
160
+ try {
161
+ const rp = relyingParty(req);
162
+ if (!rp) return res.fail('passkeys_not_configured', { status: 503, message: 'security cards are not configured on this site yet' });
163
+ const { origin, rpID } = rp;
164
+ let verification;
165
+ try {
166
+ verification = await lib().verifyRegistrationResponse({
167
+ response,
168
+ // The library hands back the challenge the card signed. Consuming it here
169
+ // is what makes it single-use and bound to THIS builder and purpose.
170
+ expectedChallenge: async (c) => !!(await store.consumeChallenge(c, { expectPurpose: 'register', expectBuilderId: req.builder.id })),
171
+ expectedOrigin: origin,
172
+ expectedRPID: rpID,
173
+ requireUserVerification: true,
174
+ });
175
+ } catch (err) {
176
+ log.info({ err: err && err.message }, 'register/verify refused');
177
+ return res.fail('verification_failed', { status: 400, message: 'the card could not be verified' });
178
+ }
179
+ if (!verification.verified || !verification.registrationInfo) {
180
+ return res.fail('verification_failed', { status: 400, message: 'the card could not be verified' });
181
+ }
182
+ const { credential, aaguid } = verification.registrationInfo;
183
+ // Same answer whoever holds it: a global lookup must not reveal that a card
184
+ // belongs to another account.
185
+ if (await store.getCredentialById(credential.id)) {
186
+ return res.fail('already_registered', { status: 409, message: 'that card cannot be registered' });
187
+ }
188
+ // The cap is enforced here too: a challenge minted before a removal must not
189
+ // let a builder end up over it.
190
+ if ((await store.listCredentials(req.builder.id)).length >= MAX_CARDS_PER_BUILDER) {
191
+ return res.fail('too_many_cards', { status: 409, message: `at most ${MAX_CARDS_PER_BUILDER} cards per account — remove one first` });
192
+ }
193
+ await store.addCredential({
194
+ builderId: req.builder.id,
195
+ credentialId: credential.id,
196
+ publicKey: credential.publicKey,
197
+ signCount: credential.counter,
198
+ transports: credential.transports || response.response?.transports || [],
199
+ aaguid: aaguid || null,
200
+ nickname: nick.value,
201
+ });
202
+ res.json({ ok: true, credential_id: credential.id });
203
+ } catch (err) {
204
+ log.error({ err: err && err.message }, 'register/verify failed');
205
+ res.fail('register_failed', { status: 500, message: 'internal error' });
206
+ }
207
+ });
208
+
209
+ // POST /api/bongos/me/passkeys/rename — Body: { credential_id, nickname }.
210
+ // rank: any-builder (own identity only) — the WHERE clause is the boundary.
211
+ router.post('/me/passkeys/rename', requireBuilder, async (req, res) => {
212
+ if (validateOrRespond(req, res, { credential_id: {}, nickname: {} })) return;
213
+ const id = req.body && req.body.credential_id;
214
+ if (typeof id !== 'string' || !CREDENTIAL_ID_RE.test(id)) {
215
+ return res.fail('bad_credential_id', { status: 400, message: 'credential_id must come from GET /me/passkeys' });
216
+ }
217
+ if (req.body.nickname === undefined) return res.fail('bad_nickname', { status: 400, message: 'nickname is required (send an empty string to clear it)' });
218
+ const nick = cleanNickname(req.body.nickname);
219
+ if (nick.error) return res.fail('bad_nickname', { status: 400, message: nick.error });
220
+ try {
221
+ const ok = await store.renameCredential(req.builder.id, id, nick.value);
222
+ if (!ok) return res.fail('not_found', { status: 404, message: 'no such card on your account' });
223
+ res.json({ ok: true });
224
+ } catch (err) {
225
+ log.error({ err: err && err.message }, 'rename failed');
226
+ res.fail('rename_failed', { status: 500, message: 'internal error' });
227
+ }
228
+ });
229
+
230
+ // POST /api/bongos/me/passkeys/remove — Body: { credential_id }.
231
+ // rank: any-builder (own identity only) — the WHERE clause is the boundary.
232
+ router.post('/me/passkeys/remove', requireBuilder, async (req, res) => {
233
+ if (validateOrRespond(req, res, { credential_id: {} })) return;
234
+ const id = req.body && req.body.credential_id;
235
+ if (typeof id !== 'string' || !CREDENTIAL_ID_RE.test(id)) {
236
+ return res.fail('bad_credential_id', { status: 400, message: 'credential_id must come from GET /me/passkeys' });
237
+ }
238
+ try {
239
+ const ok = await store.removeCredential(req.builder.id, id);
240
+ if (!ok) return res.fail('not_found', { status: 404, message: 'no such card on your account' });
241
+ res.json({ ok: true });
242
+ } catch (err) {
243
+ log.error({ err: err && err.message }, 'remove failed');
244
+ res.fail('remove_failed', { status: 500, message: 'internal error' });
245
+ }
246
+ });
247
+
248
+ return router;
249
+ };