From c1895713a21c87edbd016a46dc4ce06e3f552ffa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 16:47:06 +0000 Subject: [PATCH 1/5] fix(platform-objects): reveal 2FA backup codes on the reachable surface (#10681) --- .../bin/create-objectstack.js | 0 .../src/identity/sys-two-factor.object.ts | 10 +- .../src/identity/sys-user.object.ts | 51 ++++- .../two-factor-one-shot-reveal.test.ts | 193 ++++++++++++++++++ 4 files changed, 246 insertions(+), 8 deletions(-) mode change 100644 => 100755 packages/create-objectstack/bin/create-objectstack.js create mode 100644 packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts diff --git a/packages/create-objectstack/bin/create-objectstack.js b/packages/create-objectstack/bin/create-objectstack.js old mode 100644 new mode 100755 diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index 4354b2ef54..c135c83093 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -160,7 +160,15 @@ export const SysTwoFactor = ObjectSchema.create({ backup_codes: Field.textarea({ label: 'Backup Codes', required: false, - description: 'JSON-serialized backup recovery codes', + // NOT JSON at rest, despite what this said until #10681. better-auth's + // `twoFactor()` defaults to `storeBackupCodes: 'encrypted'` and we pass + // no `backupCodeOptions`, so `encodeBackupCodes` JSON-stringifies the + // codes and then `symmetricEncrypt`s that string with the auth secret: + // the column holds ONE opaque ciphertext, not a readable array. Reading + // the row back therefore reveals nothing — which is why the codes have + // to be shown at generation time (#10681) and why no re-reveal route + // exists to add. + description: 'Backup recovery codes, encrypted at rest (a single opaque ciphertext, not readable JSON)', }), verified: Field.boolean({ diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index aa398a0940..f54463fca0 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -393,11 +393,25 @@ export const SysUser = ObjectSchema.create({ ], }, // ── Two-factor authentication ───────────────────────────────── - // Enable flow returns { totpURI, backupCodes } — surfacing those - // safely needs a QR + verify UI that the generic action engine - // can't render yet. We still expose it so the API call works - // and the success toast displays the otpauth:// URI that users - // can manually add to an authenticator app as a fallback. + // These three are the ONLY 2FA surface a user can actually reach: the + // `sys_two_factor` variants that carry the same declarations are mounted + // in no app (`setup-nav.contributions.ts` has no `sys_two_factor` entry), + // so this record page — Setup → People & Organization → Users, Security + // tab, via `record:quick_actions { location: 'record_section' }` in + // `pages/sys-user.page.ts` — is where these run in production. + // + // Both `enable` and `generate-backup-codes` return values that CANNOT be + // retrieved afterwards: better-auth mints backup codes and stores only + // `symmetricEncrypt(JSON.stringify(codes))` (its `twoFactor()` default is + // `storeBackupCodes: 'encrypted'`, which we do not override), and it + // publishes no re-reveal route — `auth-route-ledger.ts` lists + // `generate-backup-codes` and nothing that reads them back. So the + // response is the user's ONE sight of these values, and a `successMessage` + // toast discards them: the old codes are already dead by then, which turns + // the reachable path into a lockout. `resultDialog` is what makes the + // one-shot reveal an actual reveal — the runtime suppresses the toast and + // opens an acknowledge-only dialog instead. Same shape as the + // `sys_two_factor` declarations; deliberately not a fourth spelling. { name: 'enable_two_factor', label: 'Enable Two-Factor Auth', @@ -408,11 +422,21 @@ export const SysUser = ObjectSchema.create({ target: '/api/v1/auth/two-factor/enable', visible: 'has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled != true', requiresFeature: 'twoFactor', - successMessage: 'Two-factor authentication enabled. Scan the QR code or paste the otpauth URI into your authenticator app, then verify a code to complete setup.', refreshAfter: true, params: [ { name: 'password', label: 'Current Password', type: 'text', required: true }, ], + // No `successMessage`: `resultDialog` suppresses it, so one declared + // here would be unreachable text that still ships to every translator. + resultDialog: { + title: 'Two-factor authentication enabled', + description: 'Scan the QR code with your authenticator app, then save the backup codes somewhere safe. The backup codes are shown only once.', + acknowledge: 'I have saved my backup codes', + fields: [ + { path: 'totpURI', label: 'Authenticator URI', format: 'qrcode' }, + { path: 'backupCodes', label: 'Backup Codes', format: 'code-list' }, + ], + }, }, { name: 'disable_two_factor', @@ -443,12 +467,25 @@ export const SysUser = ObjectSchema.create({ visible: 'has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled == true', requiresFeature: 'twoFactor', // Confirm question on `description` — one dialog, not two (#7278/#7309). + // The confirm dialog and the reveal dialog are NOT that pair: they are + // two different moments (decide, then save), which is exactly the shape + // `sys_two_factor.regenerate_backup_codes` already carries. description: 'Generate a new set of backup codes? Any previously generated codes will stop working.', - successMessage: 'New backup codes generated — save them somewhere safe.', refreshAfter: false, params: [ { name: 'password', label: 'Current Password', type: 'text', required: true }, ], + // No `successMessage` — see the note above `enable_two_factor`. The toast + // this replaces ("New backup codes generated — save them somewhere safe") + // told the user to save codes it never showed them. + resultDialog: { + title: 'New backup codes generated', + description: 'Previous backup codes are now invalid. Save these new codes somewhere safe — they are shown only once.', + acknowledge: 'I have saved the new codes', + fields: [ + { path: 'backupCodes', label: 'Backup Codes', format: 'code-list' }, + ], + }, }, ], diff --git a/packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts b/packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts new file mode 100644 index 0000000000..c7d37a901e --- /dev/null +++ b/packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10681 — the one-shot 2FA reveal, pinned as a CLASS on the surface a user + * can actually reach. + * + * ## What the defect was, and why the obvious test would not have caught it + * + * `sys_user.generate_backup_codes` toasted "New backup codes generated — save + * them somewhere safe", issued the request, and dropped the response. The + * previous code set dies the instant that request succeeds, so the reachable + * path was: old codes destroyed, new codes discarded into a toast, no way to + * get them back. `sys_two_factor` carried correct `resultDialog` declarations + * the whole time — and is mounted in NO app, which is exactly why nobody saw + * it. A per-action assertion that `generate_backup_codes.resultDialog` is + * defined would re-state the diff and would not have caught the original bug + * either, because the bug was never "this key is missing" — it was "the key is + * present on the copy nobody can reach". + * + * So this file pins the two facts that actually decide whether a user sees + * their codes, and pins them over a DERIVED set rather than a literal one: + * + * 1. REACHABILITY — the chain from the Setup navigation down to the action + * name is walked here, not assumed. If `nav_users` is dropped, or the + * Security tab stops naming these actions, or the page stops filtering at + * `record_section`, the chain breaks and this file reddens. + * 2. COVERAGE — every action ANYWHERE in the identity object set that targets + * a route known to return an unrecoverable secret must declare a + * `resultDialog` covering the secret-bearing keys. The route table below + * is the input; the actions are discovered. A fourth 2FA surface added + * tomorrow is held to the same rule with no edit here. + * + * ⚠️ What this file does NOT establish: that the dialog RENDERS. The renderer + * lives in the sibling `objectui` repo (`ActionRunner` → `ActionResultDialog`), + * so no test in this repo can drive that DOM. The data half of the render path + * — that the declared `path` resolves against the real HTTP response — is + * measured over a booted stack in + * `packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts`. + * Between them they cover "declared, reachable, and the value is really there"; + * the pixels are objectui's to pin. + */ + +import { describe, expect, it } from 'vitest'; +import type { Action } from '@objectstack/spec/ui'; +import * as identityObjects from './index.js'; +import { SETUP_NAV_CONTRIBUTIONS } from '../apps/setup-nav.contributions.js'; +import { SysUserDetailPage } from '../pages/sys-user.page.js'; + +/** + * Routes whose SUCCESS RESPONSE carries a value the user can never obtain + * again, mapped to the response keys that carry it. + * + * Derivation, re-measured for #10681 rather than quoted from the card: + * - better-auth's `twoFactor()` defaults to `storeBackupCodes: 'encrypted'` + * and `auth-manager.ts` passes no `backupCodeOptions`, so `backup_codes` + * holds `symmetricEncrypt(JSON.stringify(codes))` — one opaque ciphertext. + * - `auth-route-ledger.ts` publishes `generate-backup-codes` and NO route + * that reads codes back; there is no re-reveal endpoint to add. + * - `/two-factor/enable` returns the otpauth URI carrying the plaintext TOTP + * secret, which is likewise stored encrypted and never re-served. + * + * ⛔ Adding a row here without a `resultDialog` on the actions that target it + * is meant to fail. That is the point of the table. + */ +const ONE_SHOT_ROUTES: Record = { + '/api/v1/auth/two-factor/enable': ['totpURI', 'backupCodes'], + '/api/v1/auth/two-factor/generate-backup-codes': ['backupCodes'], +}; + +/** Every action declared on every exported identity object, tagged with its object. */ +function allIdentityActions(): { object: string; action: Action }[] { + const out: { object: string; action: Action }[] = []; + for (const [exportName, def] of Object.entries(identityObjects)) { + const actions = (def as { actions?: Action[] } | undefined)?.actions; + if (!Array.isArray(actions)) continue; + for (const action of actions) out.push({ object: exportName, action }); + } + return out; +} + +describe('#10681 — one-shot 2FA reveals on the navigable surface', () => { + // ── 1. Reachability: the chain, walked ──────────────────────────────── + describe('the Setup → People & Organization → Users chain reaches these actions', () => { + it('Setup navigation mounts sys_user (and still mounts no sys_two_factor)', () => { + const items = SETUP_NAV_CONTRIBUTIONS.flatMap((c) => c.items ?? []); + const users = items.find((i) => (i as { objectName?: string }).objectName === 'sys_user'); + expect(users, 'sys_user is not mounted in the Setup navigation').toBeDefined(); + + // The other half of the card's finding, kept live: `sys_two_factor` + // declares the same reveals and is reachable from nowhere. If someone + // mounts it later this flips, and the duplication becomes a real + // question to answer rather than a latent one. + const twoFactorMounted = items.some( + (i) => (i as { objectName?: string }).objectName === 'sys_two_factor', + ); + expect( + twoFactorMounted, + 'sys_two_factor is now navigable — the sys_user duplicates in this file are no longer the only reachable 2FA surface, so decide which one is canonical', + ).toBe(false); + }); + + it('the sys_user record page names the 2FA actions in a record_section quick-actions bar', () => { + // Walk the page tree for `record:quick_actions` nodes. Derived, not + // hard-coded to a tab index: the Security tab can move. + const quickActionNodes: { location?: string; actionNames?: string[] }[] = []; + const walk = (node: unknown): void => { + if (Array.isArray(node)) return void node.forEach(walk); + if (!node || typeof node !== 'object') return; + const n = node as Record; + if (n.type === 'record:quick_actions') { + quickActionNodes.push((n.properties ?? {}) as { location?: string; actionNames?: string[] }); + } + for (const value of Object.values(n)) walk(value); + }; + walk(SysUserDetailPage); + + const named = quickActionNodes + .filter((p) => p.location === 'record_section') + .flatMap((p) => p.actionNames ?? []); + + // Guard the guard: if the walker stopped finding nodes (page schema + // reshaped), `named` would be empty and every `toContain` below would + // fail — but assert it positively so the reason is legible. + expect(named.length, 'no record_section quick-actions found on the sys_user page').toBeGreaterThan(0); + expect(named).toContain('generate_backup_codes'); + expect(named).toContain('enable_two_factor'); + }); + + it('those names resolve to sys_user actions declared at record_section', () => { + const actions = (identityObjects.SysUser.actions ?? []) as Action[]; + for (const name of ['enable_two_factor', 'generate_backup_codes']) { + const action = actions.find((a) => a.name === name); + expect(action, `sys_user declares no action '${name}'`).toBeDefined(); + // The page filters by location; a declaration that stopped listing + // `record_section` would render nothing while still existing. + expect(action?.locations, `'${name}' is not declared at record_section`).toContain('record_section'); + } + }); + }); + + // ── 2. Coverage: the class, derived ─────────────────────────────────── + describe('every action targeting a one-shot-secret route reveals what it returns', () => { + it('the route table matches at least one action per row — the instrument is live', () => { + // Positive control, FIRST. If a target string is renamed, the coverage + // test below would iterate an empty set and pass while checking nothing. + for (const route of Object.keys(ONE_SHOT_ROUTES)) { + const matches = allIdentityActions().filter(({ action }) => action.target === route); + expect(matches.length, `no identity action targets ${route} — the table is stale`).toBeGreaterThan(0); + } + }); + + it('each one carries a resultDialog covering the secret-bearing response keys', () => { + for (const { object, action } of allIdentityActions()) { + const secretKeys = action.target ? ONE_SHOT_ROUTES[action.target] : undefined; + if (!secretKeys) continue; + + const where = `${object}.${action.name} (${action.target})`; + expect( + action.resultDialog, + `${where} returns values that cannot be retrieved again, but declares no resultDialog — the response is discarded and the user is locked out`, + ).toBeDefined(); + + const paths = (action.resultDialog?.fields ?? []).map((f) => f.path); + for (const key of secretKeys) { + expect(paths, `${where} does not reveal '${key}'`).toContain(key); + } + + // Paths address the INNER data payload — the console action runtime + // unwraps `{ success, data }` before resolving them, so a `data.` + // prefix double-nests and blanks the dialog. Same regression class as + // the SysSsoProvider guard in `platform-objects.test.ts`. + for (const path of paths) { + expect(path.startsWith('data.'), `${where} path '${path}' is double-nested`).toBe(false); + } + } + }); + + it('none of them also declares a successMessage — resultDialog suppresses it', () => { + // Not tidiness: the runtime shows the dialog INSTEAD of the toast, so a + // successMessage here is unreachable text that still ships to every + // translator and still reads, to the next author, like the thing the + // user sees. This is the exact string that made the original defect look + // handled ("save them somewhere safe" — for codes never shown). + for (const { object, action } of allIdentityActions()) { + if (!action.target || !ONE_SHOT_ROUTES[action.target]) continue; + expect( + action.successMessage, + `${object}.${action.name} declares both a resultDialog and a successMessage; the toast is suppressed, so the message is dead text`, + ).toBeUndefined(); + } + }); + }); +}); From c1665cd0358972a18f5c7adc0fbf48bda3e1d542 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 17:11:17 +0000 Subject: [PATCH 2/5] test(dogfood): resolve the declared 2FA reveal paths against the live response (#10681) --- .changeset/two-factor-backup-code-reveal.md | 59 +++++ packages/qa/dogfood/test/totp.ts | 61 +++++ ...-factor-backup-code-reveal.dogfood.test.ts | 224 ++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 .changeset/two-factor-backup-code-reveal.md create mode 100644 packages/qa/dogfood/test/totp.ts create mode 100644 packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts diff --git a/.changeset/two-factor-backup-code-reveal.md b/.changeset/two-factor-backup-code-reveal.md new file mode 100644 index 0000000000..d1591a036e --- /dev/null +++ b/.changeset/two-factor-backup-code-reveal.md @@ -0,0 +1,59 @@ +--- +"@objectstack/platform-objects": patch +--- + +Show 2FA backup codes on the surface a user can actually reach — the reachable +regeneration path was a lockout (#10681). + +`sys_user.generate_backup_codes` is mounted at Setup → People & Organization → +Users (Security tab, via `record:quick_actions { location: 'record_section' }` +in `pages/sys-user.page.ts`). It declared no `resultDialog`: it toasted "New +backup codes generated — save them somewhere safe", issued the request, and +dropped the response. The previous code set is invalidated wholesale the moment +that request succeeds, so the reachable path was *old codes destroyed, new codes +discarded* — with no way to get them back: + +- better-auth's `twoFactor()` defaults to `storeBackupCodes: 'encrypted'` and + `auth-manager.ts` passes no `backupCodeOptions`, so `sys_two_factor.backup_codes` + holds `symmetricEncrypt(JSON.stringify(codes))` — one opaque ciphertext. +- `auth-route-ledger.ts` publishes `generate-backup-codes` and **no** route that + reads codes back. There is no re-reveal endpoint, by design. + +So the API response is the user's one and only sight of those codes. +`generate_backup_codes` now declares the one-shot reveal +(`{ path: 'backupCodes', format: 'code-list' }`) and `enable_two_factor` the QR +equivalent (`totpURI` as `qrcode` + `backupCodes`), which suppresses the toast +and opens an acknowledge-only dialog instead. Both copy the shapes +`sys_two_factor.enable_two_factor` / `regenerate_backup_codes` already carried — +deliberately not a third and fourth spelling of the same declaration. + +**Why the correct declarations existed and still did not help.** `sys_two_factor` +carries them and is mounted in **no** app — it appears in no navigation +contribution — so the only 2FA surface a user can reach was the one missing them. +That is why the new pin +(`packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts`) +walks the Setup-navigation → page → quick-actions → action chain rather than +asserting a key is present, and holds coverage over a **derived** set: every +identity action targeting a route known to return an unrecoverable secret must +reveal it. A fifth 2FA surface added later is held to the same rule with no edit +to the test. It also fails a `successMessage` declared alongside a +`resultDialog` — the toast is suppressed, so such a message is dead text, and in +this case it was the very string that made the defect look handled. + +The declaration-to-response join is measured over a booted stack in +`packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts`: the +declared paths are resolved against the live route's real response, because a +path that stops matching better-auth's response shape opens an **empty** dialog +and loses the codes just as thoroughly, while every declaration-shape assertion +stays green. + +**Also corrected, same area:** `sys_two_factor.backup_codes` was described as +"JSON-serialized backup recovery codes". It is JSON *before* encryption; what the +column stores is the ciphertext above. The description now says so, since the +whole reason the reveal must happen at generation time is that this column +cannot be read back. + +**Not addressed here:** mounting `sys_two_factor` into navigation is a larger +product-surface decision and is only raised, not taken; `#10700` (re-enrolment +rotating the TOTP secret while keeping `verified=1`) is a separate defect and +remains open. diff --git a/packages/qa/dogfood/test/totp.ts b/packages/qa/dogfood/test/totp.ts new file mode 100644 index 0000000000..a8489ff2a6 --- /dev/null +++ b/packages/qa/dogfood/test/totp.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * RFC 6238 TOTP, for dogfood fixtures that must confirm a 2FA enrolment. + * + * Hand-rolled rather than imported, for the reason + * `two-factor-lockout.dogfood.test.ts` first wrote down: `@better-auth/utils/otp` + * is a TRANSITIVE dependency, and adding it as a direct one just to generate six + * digits would tie these tests to an internal package's resolution. better-auth's + * defaults are the RFC's (SHA-1, 6 digits, 30s), and `enable`'s own otpauth:// + * URI asserts them. + * + * ⚠️ `two-factor-lockout.dogfood.test.ts` still carries its own private copy of + * these two functions — this module was extracted while adding a second caller + * (#10681) and deliberately did NOT rewrite that file's internals, since it pins + * an unrelated card. Consolidating it is filed separately. + */ + +import { createHmac } from 'node:crypto'; + +/** Decode a base32 secret (as carried in an otpauth:// URI) to raw bytes. */ +export function base32Decode(input: string): Buffer { + const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const clean = input.replace(/=+$/, '').toUpperCase(); + let bits = 0; + let value = 0; + const out: number[] = []; + for (const char of clean) { + const idx = ALPHABET.indexOf(char); + if (idx === -1) throw new Error(`invalid base32 character: ${char}`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(out); +} + +/** The 6-digit TOTP for `secret` at the current 30-second step. */ +export function totp(secret: Buffer): string { + const counter = Math.floor(Date.now() / 30_000); + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac('sha1', secret).update(buf).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const code = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + return String(code % 1_000_000).padStart(6, '0'); +} + +/** Pull the plaintext base32 secret out of an `enable` response's otpauth:// URI. */ +export function secretFromTotpUri(totpURI: string): Buffer { + const secret = new URL(totpURI.replace('otpauth://', 'https://')).searchParams.get('secret'); + if (!secret) throw new Error('no secret in the otpauth URI'); + return base32Decode(secret); +} diff --git a/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts b/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts new file mode 100644 index 0000000000..2e0e358a56 --- /dev/null +++ b/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10681 — do the new backup codes actually reach the user? + * + * ## The question this file answers, and the one it does not + * + * `sys_user.generate_backup_codes` now declares a `resultDialog` with + * `{ path: 'backupCodes', format: 'code-list' }`. Asserting that the key is + * present would re-state the diff. The fact that decides whether a user is + * locked out is a JOIN between two things that live in different places and + * can drift apart silently: + * + * - the declared `path`, in `@objectstack/platform-objects`, and + * - the actual success-response shape of the live route, which is + * better-auth's (`disposition: 'sdk'` in `auth-route-ledger.ts`) and moves + * when better-auth is bumped. + * + * If those disagree the dialog opens and is EMPTY — the user still loses their + * codes, and every declaration-shape test in the repo stays green. So this + * file boots a real stack, calls the real route, and resolves the REAL declared + * paths against the REAL response body. + * + * ⚠️ NOT a render test. The dialog DOM is objectui's (`ActionRunner` → + * `ActionResultDialog`), so nothing here proves pixels appeared. What it proves + * is that the value the renderer is told to read is present, non-empty, and + * addressed correctly. The reachability half — that this action is on the + * screen the card names — is pinned in + * `packages/platform-objects/src/identity/two-factor-one-shot-reveal.test.ts`. + * + * ## Why the assertions are ordered the way they are + * + * The "codes are unrecoverable afterwards" leg greps the stored column for + * plaintext. A grep that finds nothing proves nothing until the instrument is + * shown to work, so the POSITIVE CONTROL runs first: the same codes, grepped + * with the same matcher, ARE found in the HTTP response body. Only then is + * their absence from storage evidence of encryption rather than evidence of a + * broken matcher. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SysUser } from '@objectstack/platform-objects'; +import { secretFromTotpUri, totp } from './totp.js'; + +const SYS = { context: { isSystem: true } }; +const ADMIN_PASSWORD = 'admin123'; + +/** Resolve a dot path the way the console action runtime does. */ +function resolvePath(payload: unknown, path: string): unknown { + return path.split('.').reduce( + (acc, key) => (acc && typeof acc === 'object' ? (acc as Record)[key] : undefined), + payload, + ); +} + +/** + * The payload `resultDialog` paths are resolved against: the runtime unwraps + * the `{ success, data }` envelope before resolving, so paths address the INNER + * object. Unwrap at most ONE level, and only when it looks like the envelope — + * a `?? `-style "try both" here would hide exactly the double-nesting bug the + * SysSsoProvider guard exists for. + */ +function actionResultData(body: unknown): unknown { + if (body && typeof body === 'object' && 'data' in (body as Record)) { + return (body as Record).data; + } + return body; +} + +/** The declared reveal for one sys_user action, read from the shipped definition. */ +function declaredRevealPaths(actionName: string): string[] { + const action = (SysUser.actions ?? []).find((a) => a.name === actionName); + expect(action, `sys_user declares no action '${actionName}'`).toBeDefined(); + const fields = action?.resultDialog?.fields ?? []; + expect( + fields.length, + `'${actionName}' declares no resultDialog fields — nothing would be revealed`, + ).toBeGreaterThan(0); + return fields.map((f) => f.path); +} + +describe('#10681 — the declared reveal resolves against the live response', () => { + let stack: VerifyStack; + let ql: any; + let priorTwoFactor: string | undefined; + let token: string; + let adminUserId: string; + /** The codes the live route handed back, as the user would see them. */ + let revealedCodes: string[]; + /** The raw response body of generate-backup-codes. */ + let generateBody: unknown; + + beforeAll(async () => { + // The two-factor plugin is opt-in and resolved when the auth manager is + // constructed — this must precede bootStack. + priorTwoFactor = process.env.OS_AUTH_TWO_FACTOR; + process.env.OS_AUTH_TWO_FACTOR = 'true'; + + stack = await bootStack(showcaseStack, {}); + ql = await stack.kernel.getServiceAsync('objectql'); + + token = await stack.signIn(); + const me = (await (await stack.apiAs(token, 'GET', '/auth/get-session')).json()) as any; + adminUserId = String(me?.user?.id ?? ''); + expect(adminUserId, 'could not resolve the seeded admin id').toBeTruthy(); + + // Enrol, so `generate-backup-codes` has an enrolment to regenerate for. + const enabled = await stack.apiAs(token, 'POST', '/auth/two-factor/enable', { + password: ADMIN_PASSWORD, + }); + expect(enabled.status, `two-factor/enable: ${await enabled.clone().text()}`).toBe(200); + const { totpURI } = (await enabled.json()) as { totpURI: string }; + + // better-auth enrols with `verified: false`, and `generate-backup-codes` + // refuses an unconfirmed enrolment with 400 TWO_FACTOR_NOT_ENABLED. This + // call is the session path (`isSignIn: false`), so it touches no lockout + // counter — it exists only to make the enrolment real, which is the state + // a user regenerating codes is actually in. + const confirmed = await stack.apiAs(token, 'POST', '/auth/two-factor/verify-totp', { + code: totp(secretFromTotpUri(totpURI)), + }); + expect(confirmed.status, `verify-totp (enrolment): ${await confirmed.clone().text()}`).toBe(200); + + // Take the token verify-totp hands back rather than reusing the pre-2FA + // one. In better-auth's session-present branch `valid()` returns + // `{ token, user }` for the now-two-factor-verified session; the bearer we + // signed in with is not that, and `generate-backup-codes` (which sits + // behind `sessionMiddleware`) answers it with 401. Re-signing in is not an + // option either — with 2FA on, `/sign-in/email` returns a two-factor + // redirect instead of a token, which is what `stack.signIn()` expects. + const { token: verifiedToken } = (await confirmed.json()) as { token?: string }; + expect( + verifiedToken, + 'verify-totp returned no session token — the enrolment handoff changed shape', + ).toBeTruthy(); + token = verifiedToken as string; + + const generated = await stack.apiAs(token, 'POST', '/auth/two-factor/generate-backup-codes', { + password: ADMIN_PASSWORD, + }); + expect( + generated.status, + `generate-backup-codes: ${await generated.clone().text()}`, + ).toBe(200); + generateBody = await generated.json(); + }, 180_000); + + afterAll(async () => { + if (priorTwoFactor === undefined) delete process.env.OS_AUTH_TWO_FACTOR; + else process.env.OS_AUTH_TWO_FACTOR = priorTwoFactor; + await stack?.stop?.(); + }); + + it('generate_backup_codes: the declared path resolves to a non-empty list of codes', () => { + const paths = declaredRevealPaths('generate_backup_codes'); + expect(paths).toContain('backupCodes'); + + const data = actionResultData(generateBody); + for (const path of paths) { + const value = resolvePath(data, path); + expect( + value, + `declared resultDialog path '${path}' resolves to nothing in the live response — the dialog would open EMPTY`, + ).toBeDefined(); + } + + const codes = resolvePath(data, 'backupCodes'); + expect(Array.isArray(codes), 'backupCodes is not an array').toBe(true); + // `format: 'code-list'` requires an array of strings; anything else renders + // as nothing useful even though the path resolved. + for (const code of codes as unknown[]) { + expect(typeof code, 'a backup code is not a string').toBe('string'); + expect((code as string).length, 'an empty backup code').toBeGreaterThan(0); + } + expect((codes as string[]).length).toBeGreaterThan(0); + revealedCodes = codes as string[]; + }); + + it('the declared paths need no `data.` prefix — one unwrap, not two', () => { + // The mirror of the above: if the runtime's single unwrap were wrong for + // this route, `data.backupCodes` would ALSO resolve, and the declaration + // would be ambiguous rather than correct. + const doubleNested = resolvePath(actionResultData(generateBody), 'data.backupCodes'); + expect( + doubleNested, + 'both `backupCodes` and `data.backupCodes` resolve — the envelope shape is not what the declaration assumes', + ).toBeUndefined(); + }); + + it('POSITIVE CONTROL: the matcher finds these codes in the response body', () => { + // Proves the instrument before its silence is read as evidence, below. + const responseText = JSON.stringify(generateBody); + for (const code of revealedCodes) { + expect( + responseText.includes(code), + `the matcher cannot find code ${code} in the response it came from — the instrument is broken`, + ).toBe(true); + } + }); + + it('and they are genuinely unrecoverable afterwards — storage holds no plaintext', async () => { + const rows = await ql.find( + 'sys_two_factor', + { where: { user_id: adminUserId }, limit: 1 }, + SYS, + ); + const stored = String(rows[0]?.backup_codes ?? ''); + expect(stored.length, 'no backup_codes column value was stored at all').toBeGreaterThan(0); + + // Same matcher as the positive control, now expected to find nothing: + // better-auth's twoFactor() defaults to `storeBackupCodes: 'encrypted'` + // and auth-manager passes no override, so this column is one opaque + // ciphertext. This is WHY the reveal has to happen at generation time — + // reading the row back later cannot recover the codes. + for (const code of revealedCodes) { + expect( + stored.includes(code), + `backup code ${code} is stored in PLAINTEXT — the reveal is not the only copy, and this column is readable`, + ).toBe(false); + } + }); +}); From d003b4c54cc3e78e78a3e347a84475d7e96f06b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 17:13:49 +0000 Subject: [PATCH 3/5] test(dogfood): decouple the reveal legs from the first assertion (#10681) --- ...-factor-backup-code-reveal.dogfood.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts b/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts index 2e0e358a56..ad1edf1c33 100644 --- a/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts +++ b/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts @@ -145,6 +145,18 @@ describe('#10681 — the declared reveal resolves against the live response', () `generate-backup-codes: ${await generated.clone().text()}`, ).toBe(200); generateBody = await generated.json(); + + // Extracted HERE, not inside the first `it`. When it was assigned at the + // end of that test, a failure there left `revealedCodes` undefined and + // cascaded into the two legs below — one broken declaration reported as + // three red tests, only one of which named the actual cause. Each test now + // stands on fixture state instead of on its predecessor having run. + const codes = resolvePath(actionResultData(generateBody), 'backupCodes'); + expect( + Array.isArray(codes), + `no backupCodes array in the live response: ${JSON.stringify(generateBody)}`, + ).toBe(true); + revealedCodes = codes as string[]; }, 180_000); afterAll(async () => { @@ -166,16 +178,13 @@ describe('#10681 — the declared reveal resolves against the live response', () ).toBeDefined(); } - const codes = resolvePath(data, 'backupCodes'); - expect(Array.isArray(codes), 'backupCodes is not an array').toBe(true); // `format: 'code-list'` requires an array of strings; anything else renders // as nothing useful even though the path resolved. - for (const code of codes as unknown[]) { + for (const code of revealedCodes) { expect(typeof code, 'a backup code is not a string').toBe('string'); - expect((code as string).length, 'an empty backup code').toBeGreaterThan(0); + expect(code.length, 'an empty backup code').toBeGreaterThan(0); } - expect((codes as string[]).length).toBeGreaterThan(0); - revealedCodes = codes as string[]; + expect(revealedCodes.length).toBeGreaterThan(0); }); it('the declared paths need no `data.` prefix — one unwrap, not two', () => { From a3a8dba61b62fd4b84c1e37ff59e742b22a14ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 17:17:48 +0000 Subject: [PATCH 4/5] i18n(platform-objects): translate the sys_user 2FA reveals (#10681) --- .../apps/translations/en.objects.generated.ts | 21 ++++++++++++++++--- .../translations/es-ES.objects.generated.ts | 19 +++++++++++++++-- .../translations/ja-JP.objects.generated.ts | 19 +++++++++++++++-- .../translations/zh-CN.objects.generated.ts | 19 +++++++++++++++-- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index e7799f450f..927ee20fdc 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -267,11 +267,19 @@ export const enObjects: NonNullable = { }, enable_two_factor: { label: "Enable Two-Factor Auth", - successMessage: "Two-factor authentication enabled. Scan the QR code or paste the otpauth URI into your authenticator app, then verify a code to complete setup.", params: { password: { label: "Current Password" } + }, + resultDialog: { + title: "Two-factor authentication enabled", + description: "Scan the QR code with your authenticator app, then save the backup codes somewhere safe. The backup codes are shown only once.", + acknowledge: "I have saved my backup codes", + fields: { + totpURI: "Authenticator URI", + backupCodes: "Backup Codes" + } } }, disable_two_factor: { @@ -287,11 +295,18 @@ export const enObjects: NonNullable = { generate_backup_codes: { label: "Regenerate Backup Codes", description: "Generate a new set of backup codes? Any previously generated codes will stop working.", - successMessage: "New backup codes generated — save them somewhere safe.", params: { password: { label: "Current Password" } + }, + resultDialog: { + title: "New backup codes generated", + description: "Previous backup codes are now invalid. Save these new codes somewhere safe — they are shown only once.", + acknowledge: "I have saved the new codes", + fields: { + backupCodes: "Backup Codes" + } } } } @@ -1030,7 +1045,7 @@ export const enObjects: NonNullable = { }, backup_codes: { label: "Backup Codes", - help: "JSON-serialized backup recovery codes" + help: "Backup recovery codes, encrypted at rest (a single opaque ciphertext, not readable JSON)" }, verified: { label: "Verified", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index a97709f92e..e67b5982fa 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -267,11 +267,19 @@ export const esESObjects: NonNullable = { }, enable_two_factor: { label: "Habilitar autenticación de dos factores", - successMessage: "Autenticación de dos factores habilitada. Escanea el código QR o pega el URI otpauth en tu aplicación de autenticación y verifica un código para completar la configuración.", params: { password: { label: "Contraseña actual" } + }, + resultDialog: { + title: "Autenticación de doble factor habilitada", + description: "Escanee el código QR con su aplicación de autenticación y guarde los códigos de respaldo en un lugar seguro. Los códigos de respaldo se muestran una sola vez.", + acknowledge: "He guardado mis códigos de respaldo", + fields: { + totpURI: "URI del autenticador", + backupCodes: "Códigos de respaldo" + } } }, disable_two_factor: { @@ -287,11 +295,18 @@ export const esESObjects: NonNullable = { generate_backup_codes: { label: "Regenerar códigos de respaldo", description: "¿Generar un nuevo juego de códigos de respaldo? Los códigos generados anteriormente dejarán de funcionar.", - successMessage: "Nuevos códigos de respaldo generados; guárdalos en un lugar seguro.", params: { password: { label: "Contraseña actual" } + }, + resultDialog: { + title: "Nuevos códigos de respaldo generados", + description: "Los códigos de respaldo anteriores ya no son válidos. Guarde estos nuevos códigos en un lugar seguro: se muestran una sola vez.", + acknowledge: "He guardado los nuevos códigos", + fields: { + backupCodes: "Códigos de respaldo" + } } } } diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index e43ce14b11..5e34cb9b39 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -267,11 +267,19 @@ export const jaJPObjects: NonNullable = { }, enable_two_factor: { label: "二要素認証を有効化", - successMessage: "二要素認証を有効にしました。認証アプリで QR コードをスキャンするか otpauth URI を貼り付け、コードを検証して設定を完了してください。", params: { password: { label: "現在のパスワード" } + }, + resultDialog: { + title: "二要素認証を有効化しました", + description: "認証アプリで QR コードをスキャンし、バックアップコードを安全な場所に保存してください。バックアップコードの表示は一度きりです。", + acknowledge: "バックアップコードを保存しました", + fields: { + totpURI: "認証アプリ URI", + backupCodes: "バックアップコード" + } } }, disable_two_factor: { @@ -287,11 +295,18 @@ export const jaJPObjects: NonNullable = { generate_backup_codes: { label: "バックアップコードを再生成", description: "新しいバックアップコードを生成しますか?以前に生成されたコードはすべて使用できなくなります。", - successMessage: "新しいバックアップコードを生成しました。安全な場所に保管してください。", params: { password: { label: "現在のパスワード" } + }, + resultDialog: { + title: "新しいバックアップコードを生成しました", + description: "以前のバックアップコードは無効になりました。新しいコードを安全な場所に保存してください。表示は一度きりです。", + acknowledge: "新しいコードを保存しました", + fields: { + backupCodes: "バックアップコード" + } } } } diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 7378653bc2..e9d93c4408 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -267,11 +267,19 @@ export const zhCNObjects: NonNullable = { }, enable_two_factor: { label: "启用双因素认证", - successMessage: "双因素认证已启用。用身份验证器 App 扫描二维码或粘贴 otpauth URI,然后验证一次动态码以完成设置。", params: { password: { label: "当前密码" } + }, + resultDialog: { + title: "双因素认证已启用", + description: "请用身份验证器应用扫描二维码,并将备用码妥善保存。备用码只显示一次。", + acknowledge: "我已保存备用码", + fields: { + totpURI: "身份验证器 URI", + backupCodes: "备用码" + } } }, disable_two_factor: { @@ -287,11 +295,18 @@ export const zhCNObjects: NonNullable = { generate_backup_codes: { label: "重新生成备用码", description: "要生成一组新的备用码吗?之前生成的备用码将全部失效。", - successMessage: "新备用码已生成——请妥善保存。", params: { password: { label: "当前密码" } + }, + resultDialog: { + title: "已生成新的备用码", + description: "之前的备用码已全部失效。请妥善保存这些新备用码——它们只显示一次。", + acknowledge: "我已保存新备用码", + fields: { + backupCodes: "备用码" + } } } } From ed96626e2986bc2147001306eb6aa8e4c4e49f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 17:18:27 +0000 Subject: [PATCH 5/5] chore: drop a stray exec-bit change picked up from the local install (#10681) --- packages/create-objectstack/bin/create-objectstack.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 packages/create-objectstack/bin/create-objectstack.js diff --git a/packages/create-objectstack/bin/create-objectstack.js b/packages/create-objectstack/bin/create-objectstack.js old mode 100755 new mode 100644