fix: 4 boot-time noise + correctness bugs surfaced by post-deploy smoke

All four were pre-existing; the audit smoke-test made them visible. Fixed
together because they share a "boot console-warn cleanup" theme.

1. streaks ensureSeeded race (DexieError2 ×2)
   - Two boot-time liveQuery callers passed the `count > 0` check before
     either had written, then the second's `.add()` hit a ConstraintError.
   - Fix: cache the seed promise per module, run the existence check +
     bulkAdd inside one Dexie RW transaction, and only insert MISSING
     defs (preserves existing currentStreak/longestStreak counts).

2. encryptRecord('agents', …) "wrong table name?" warning
   - The DEV-only check fired whenever a record carried none of the
     registered encrypted fields, regardless of whether anything could
     actually leak. `ensureDefaultAgent` writes a fresh agent row before
     `systemPrompt` / `memory` exist — pure noise.
   - Fix: drop the "no fields at all" branch. Keep the case-mismatch
     branch (the branch that actually catches silent plaintext leaks).

3. Passkey signInWithPasskey "Cannot read properties of undefined
   (reading 'allowCredentials')"
   - Client destructured `{ options, challengeId }` from the server's
     options response, but Better-Auth's `@better-auth/passkey` plugin
     returns the raw PublicKeyCredentialRequestOptionsJSON (no
     envelope) and tracks the challenge in a signed cookie. Both
     `options` and `challengeId` came back undefined; SimpleWebAuthn
     blew up the moment it tried to read the request shape. Verify body
     `{ challengeId, credential }` was likewise wrong — Better-Auth
     wants `{ response }`.
   - Fix: align both register and authenticate flows with Better-Auth's
     native shape on options + verify, and add `credentials: 'include'`
     on every fetch so the challenge cookie actually round-trips.
     Server's verify proxy now reads `parsed?.response?.id` for
     credentialID rate-limiting.

4. /api/v1/me/onboarding/ → 404
   - Hono's nested router (`app.route(prefix, sub)` + inner
     `app.get('/')`) matches the prefix-without-slash form only. The
     onboarding-status store sent the request with a trailing slash, so
     every login produced a 404 + a console warn.
   - Fix: client sends the path without trailing slash; mana-auth picks
     up `hono/trailing-slash` middleware as defense-in-depth so a future
     accidental trailing slash on any /me/* route 301-redirects instead
     of 404-ing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 14:56:24 +02:00
parent 44f9155ed3
commit 0c30a16eb5
6 changed files with 106 additions and 55 deletions

View file

@ -87,16 +87,20 @@ export class VaultLockedError extends Error {
* Dev-only registry-vs-record shape check.
*
* Called from encryptRecord when `import.meta.env.DEV` is truthy (Vite
* strips the call in production builds). Catches the most common silent
* failure mode: a registry entry names a field the record doesn't have,
* because of a case typo. Without this warning, the field stays plaintext
* forever and no error is ever thrown.
* strips the call in production builds). Catches the genuine silent
* failure mode: a registry entry names a field the record has only
* under a case-mismatched key. Without this warning, the typo'd field
* stays plaintext forever and no error is ever thrown.
*
* False-positive strategy:
* - We only warn on close matches (case-insensitive). An optional field
* that happens to be omitted from a given write won't light up.
* - A record that has NONE of the registered fields is also flagged,
* which catches wrong-table-name call sites.
* What the check explicitly does NOT flag:
* - Records that have NONE of the registered fields. Many call sites
* legitimately encrypt records before any optional encrypted field
* has been set (e.g. `ensureDefaultAgent` writes a fresh agent row
* without a `systemPrompt` or `memory` yet those are filled in
* later when the user customises the agent). Encrypting such a
* record is a no-op anyway, so warning is pure noise.
* - Optional fields that just happen to be undefined for this write.
* Same reason no leak possible without a value to leak.
*
* Throttled per (tableName, field) pair so liveQuery loops don't spam.
*/
@ -111,12 +115,8 @@ function devCheckRegistryShape(
const lcMap = new Map<string, string>();
for (const k of recordKeys) lcMap.set(k.toLowerCase(), k);
let exactHits = 0;
for (const field of fields) {
if (recordKeySet.has(field)) {
exactHits++;
continue;
}
if (recordKeySet.has(field)) continue;
// Case-insensitive near-miss → almost certainly a typo in the registry.
const near = lcMap.get(field.toLowerCase());
if (near && near !== field) {
@ -131,21 +131,6 @@ function devCheckRegistryShape(
}
}
}
// Record has no registered field at all — probably wrong tableName or
// a record shape that diverged from the type the registry was written for.
if (exactHits === 0 && recordKeys.length > 0) {
const key = `${tableName}:no-fields`;
if (!_registryWarnings.has(key)) {
_registryWarnings.add(key);
console.warn(
`[mana-crypto] DEV: encryptRecord('${tableName}', ...) called but the record ` +
`has none of the registered fields [${fields.join(', ')}]. ` +
`Keys on record: [${recordKeys.slice(0, 10).join(', ')}${recordKeys.length > 10 ? ', …' : ''}]. ` +
`Wrong table name?`
);
}
}
}
/**

View file

@ -170,22 +170,48 @@ export function stopStreakTracker(): void {
// ── Seed defaults ───────────────────────────────────
/**
* Cache the in-flight (or completed) seed so concurrent first-callers
* await the same operation instead of each starting their own race.
* `useStreaks()` is called from multiple components and the live-query
* machinery re-runs `buildAllStreaks` on every `_streakState` change
* event without this guard, two boot-time callers would each pass
* the `count > 0` check before either had written anything, and the
* second's `.add()` would throw a ConstraintError.
*/
let seedPromise: Promise<void> | null = null;
async function ensureSeeded(): Promise<void> {
const count = await db.table(TABLE).count();
if (count > 0) return;
// Seed empty states so useStreaks() returns all definitions. Same
// attribution reasoning as markActive — this is a subsystem write.
return (seedPromise ??= seedImpl());
}
async function seedImpl(): Promise<void> {
// Subsystem write — attribute to the projection actor, not to
// whoever triggered the upstream read.
await runAsAsync(PROJECTION_ACTOR, async () => {
for (const def of STREAK_DEFS) {
await db.table(TABLE).add({
id: def.id,
label: def.label,
moduleId: def.moduleId,
currentStreak: 0,
longestStreak: 0,
lastActiveDate: '',
});
}
// Single Dexie RW transaction: the existence check and the
// inserts share the same atomic scope, so even if two browser
// tabs / two component mounts hit this in the same microtask,
// only one transaction sees an empty table and writes the
// defaults. The other sees them and skips.
await db.transaction('rw', TABLE, async () => {
const existingIds = new Set((await db.table<StreakState>(TABLE).toArray()).map((r) => r.id));
const missing = STREAK_DEFS.filter((d) => !existingIds.has(d.id));
if (missing.length === 0) return;
// bulkAdd is faster than a per-row loop and atomic-on-failure
// inside the open transaction. Only the missing definitions
// land — existing rows keep their currentStreak / longestStreak.
await db.table(TABLE).bulkAdd(
missing.map((def) => ({
id: def.id,
label: def.label,
moduleId: def.moduleId,
currentStreak: 0,
longestStreak: 0,
lastActiveDate: '',
}))
);
});
});
}

View file

@ -68,7 +68,11 @@ function createOnboardingStatusStore() {
if (!browser || loading) return;
loading = true;
try {
const res = await authedFetch('/');
// Empty path — `${baseUrl}/api/v1/me/onboarding` without a
// trailing slash. Hono's nested router (`app.route(prefix,
// sub)` + inner `app.get('/')`) matches the prefix exactly,
// not the prefix-with-slash form, so a `/` here would 404.
const res = await authedFetch('');
if (!res.ok) throw new Error(`GET /onboarding → ${res.status}`);
const data = (await res.json()) as { completedAt: string | null };
({ completedAt } = parseStatus(data));