Skip to content

QA run · identity-auth (FULL area) · 92f26f75 · 2026-08-11 · 5 PASS / 4 PARTIAL / 3 FAIL / 2 BLOCKED #7663

Description

@baozhoutao

Full identity-auth area run of the checklist-test skill — all 14 items (12 runnable + 2 ledger-blocked) driven against a live showcase across 3 opus subagents, each on its own port + file DB. Text-only per RUNNER.md.

Result: 5 PASS · 4 PARTIAL · 3 FAIL · 2 BLOCKED.

Environment — framework 92f26f75/f268b2b2 (branch claude/platform-test-checklist-ocwugl) · console bundle 09987b680 (packages/console/dist/.objectui-sha) · showcase app · isolated port + file DB per batch · personas provisioned as real sign-ups and real in-product role writes, not stubs.

Environment fact that shaped this whole area, stated up front: the seeded dev admin is promoted to the ObjectStack platform admin, but its better-auth sys_user.role stays 'user' — so the entire better-auth-native admin family (list-users, ban/unban, set-role, remove-user, revoke-user-sessions, impersonate-user) 403s out of the box. Framework-overridden endpoints (create-user, set-user-password, import-users) work regardless. Rather than record six blocked, the runner provisioned a genuine admin with one direct write to its own throwaway DB — the same system-context write the pinned dogfood harness performs — and judged the clauses on merit.

✅ PASS — 5 items

  • self-service-password-reset (5/5 + all four negatives) — known and unknown addresses return byte-identical bodies (asserted by string comparison, not by eye) and sys_email holds 1 artifact for the known address / 0 for the unknown, so nothing is sent for a non-account while the HTTP response stays constant. Token reuse → 400 and the password it tried to set never became valid.
  • self-service-profile-password (6/6 + four negatives) — name and avatar persist across a full sign-out/sign-in into a new browser context; the avatar is a genuine presign/PUT/commit cycle, not a data: URL. The item's knownGap was closed by provisioning a passwordless persona (deleting the credential sys_account row), proving the set-initial-password branch works and 409s in both directions.
  • sso-enforced-first-paint (5/5) — no password flash: 64 samples across two independent fresh contexts (24 @100 ms from domcontentloaded, 40 @50 ms from commit) all showed input[type=password] = 0, with a CDP screencast confirming the page was genuinely painting. The sampling is proven sensitive because the unenforced boot shows the password field at the very first sample. The hung-IdP timeout was driven by intercepting the handshake and never fulfilling it — button disabled to ~20 s, then re-enabled with the running bundle's own watchdog message. Caveat: exercises ssoEnforced with a social (Google) provider; features.sso stays false because no oidcProviders[] lever exists on the dev path.
  • phone-signin-surfaces (4/4 + both negatives) — the fix(showcase): stop passing a non-existent org column when seeding the phone demo user #3408 seed regression has not returned (checked by API before the browser). The create-user dialog is not a client echo — the network shows the phoneNumber going out and an independent re-read confirms it persisted. The feat(spec,platform-objects): action param 'visible' predicate; gate create-user phone on features.phoneNumber #2871 gate was proven both ways by cold-booting twice: with features.phoneNumber:false the dialog has no phone field at all, and /meta/object/sys_user shows the param carries visible:{dialect:'cel', source:'features.phoneNumber == true'} — so the console is evaluating the CEL gate against the served flag.
  • org-membership-team-management (6/6 + four negatives) — {current_org_id} resolves to the active org id (the nav href is the record route, not the raw list); role changes bite in both directions; team membership keys on the (team,user) pair (a duplicate add returns the same row id, count unchanged); remove-member flips org-scoped reads 200→403 on both the live token and a fresh sign-in. All seven forged management calls refused server-side with a byte-identical before/after state snapshot.

🔴 FAIL — 3 items

1. admin-lifecycle-operationsremove-user is non-atomic and can never succeed

Reproduction rule — as a genuine better-auth admin: POST /auth/admin/create-user {email:…} → 200; create a record owned by that user; then POST /api/v1/auth/admin/remove-user {userId}.
Expected 2xx, sys_user gone, the owned record surviving with owner_id null (engine FK clear).
Actual HTTP 500 with an empty body; sys_user still readable; the record still carries owner_id; and the user's credential sys_account rows have already been deleted — so that email can no longer sign in (401) while still occupying the org roster. Reproduced twice; verified across three victims (accounts=0 for all three vs accounts=1 for untouched users).

Server log gives the real cause: Cannot delete sys_user (<id>): 1 dependent sys_member record(s) reference it via user_id (user_id is required, so it cannot be cleared)… set deleteBehavior:cascade on sys_member.user_id with {code:DELETE_RESTRICTED, status:409}a 409 leaking to the client as a bodyless 500.

Three compounding problems: (i) every user is auto-bound to the default org at sign-up/create, so remove-user can never succeed on this deployment; (ii) the operator cannot clear the blocker either — DELETE /data/sys_member → 405 with allowed:[get,list,aggregate,history,export]; (iii) the account/session deletions are not rolled back when the sys_user delete is refused, leaving an orphaned, un-authenticatable identity on the roster.

This is not the #3023 owner-anchor regression — the veto comes from the engine's referential restrict on sys_member.user_id, not from the owner-anchor transfer guard.

Also failing in this item — impersonation is invisible in the console. With an impersonation server-confirmed in that very browser context (an in-page get-session returns the target's email and session.impersonatedBy=<admin id>), /_console/home renders "Good morning, Dev Admin." with avatar DA — the admin's identity — and the DOM contains no banner, no "Stop impersonating" control, and the impersonated user's name nowhere. Reproduced twice in fresh contexts with hard reload + networkidle + 3 s settle. The console also offers no Impersonate entry point at all. Support sessions are silent in this build. Not compared against objectui origin/main, so this may already be fixed upstream.

Everything else in this item passes: ban/unban is reversible and enforced at sign-in; set-role is authoritative in both directions (probed as the target, bracketing the write); revoke-user-sessions kills the live session immediately, disproving the "only stops new logins" negative; create-user's explicit password wins over the generated one (#3031/#3033 holds); all 8 forged non-admin operations 403 with state byte-identical.

2. invitation-scope-gatespending → accepted is unreachable

The delegated-admin scope itself is correct: a real in-product delegated_admin can invite a member (200, exactly one row attributed to the delegate) but cannot invite an admin (403 with the ADR-0090 D12 message) and leaves zero orphan rows; a plain member is refused entirely.

(a) accept-invitation → HTTP 500 with an empty body, and the row stays pending forever. Reproduced twice with fresh emails. Server log: insert into sys_member … UNIQUE constraint failed: sys_member.organization_id, sys_member.user_id — acceptance re-inserts a membership the sign-up reconciler already created. Suspected fix: acceptance should upsert/adopt the existing sys_member row (or skip the insert when the invitee is already a member of the target org). Same bodyless-500 statusCode-leak class as the remove-user failure above.

(b) cancel-invitation writes status: 'canceled', a value absent from the spec enum. InvitationStatus = z.enum(['pending','accepted','rejected','expired']) (organization.zod.ts:105) is what this item's source names as the authority, while the platform object declares a divergent 5-value select including canceled (sys-invitation.object.ts:214). Anything validating an invitation against InvitationSchema rejects a canceled row.

Resend behaves correctly (expiry extended, still exactly one row, a second sys_email artifact). Clause 4 is blocked(fixture): no org-role persona can reach the Setup app at all ("App not available", 0 nav entries) because of the everyone-anchor baseline, so the entitled and unentitled UI halves cannot be discriminated.

3. api-key-ui-lifecycle — API keys cannot be revoked through any product route

Reproduction rulePOST /api/v1/keys {name} → 201, then PATCH /api/v1/data/sys_api_key/<id> {revoked:true} as admin.
Expected 200 and the key stops authenticating. Actual 405 OBJECT_API_METHOD_NOT_ALLOWED, allowed:[get,list,aggregate,history,export]. Same 405 for {revoked:false} (restore). Reproduced 3×, including through the real UI — Setup → API Keys → Open menu → Revoke API Key → Continue produces an error toast, the key keeps authenticating, and the row still reads revoked=false.

Root cause. packages/platform-objects/src/identity/sys-api-key.object.ts declares actions revoke_api_key / restore_api_key as PATCH /api/v1/data/sys_api_key/{id} (lines 41-72) while the same file sets enable.apiMethods = ['get','list'] (line 219) with the comment "#1591 — reads only: writes are refused… HTTP answers 405 before the 403". The two halves of the file contradict each other. No alternative route exists: route-ledger.ts carries only POST /keys; /api/v1/auth/api-key/* and DELETE /api/v1/keys/{id} all 404.

Enforcement of the flag is fine — setting revoked=1 out-of-band makes the very next x-api-key call 401 UNAUTHENTICATED — so the fix is either to allow update scoped to the revoked column, or to give revoke/restore a dedicated auth route.

⚠️ Security consequence worth naming: a leaked API key cannot be revoked without direct database access. Filed publicly because this is a dead feature rather than a bypassable gate — knowing about it grants an attacker nothing they don't already have — but say the word if you'd prefer it moved to the private channel.

Nothing pins this today: the unit tests (http-dispatcher.keys.test.ts, resolve-execution-context.test.ts) exercise key resolution with a pre-revoked row and never call the PATCH route the action declares.

The rest of the item is clean: show-once holds (get-by-id, list and a reloaded DOM never carry the raw secret), and a key carries exactly its owner's permissions — an org_member-owned key reproduces its owner's 403, and get-session via x-api-key returns null. Secondary finding: the key column (a SHA-256 hash) does serialize over the data API on both get-by-id and list, despite being declared Field.text({hidden:true, description:'Hashed API key value - never exposed to clients'}). The spec defines hidden as "Hidden from default UI", so this is not a hidden-contract violation — but the item's parenthetical claim is false on this build. Admin-readable only on this app.

🟡 PARTIAL — 4 items

teams-bu-membership — the BU tree is load-bearing, but revocation is lazy

⚠️ Security-relevant. Subtree expansion resolves correctly through three levels and is symmetric (0→1 on placement inside the shared subtree, 1→0 on re-parenting out), so the tree is genuinely load-bearing. The qualification is timing: the placement or re-parent alone does not move the read — the share only materializes when the shared record is next written.

Measured matrix: inSubtree=1, reparentedOut(no touch)=1, reparentedOut(touched)=0, restored(no touch)=0, restored(touched)=1.

The risky direction: after a business unit is moved out of a shared subtree, its members keep read access until the record is next written.

Root cause (located). The sharing-rule rebind triggers are bound to data changes on the rule's object (the boot log binds them for showcase_project, showcase_inquiry, showcase_task) but not to sys_business_unit_member or sys_business_unit changes — so tree and membership edits never enqueue a recompute for rules with unit_and_subordinates recipients.

The item's knownGap can be retired: it anticipated that no showcase geometry might consume BU membership, but share_new_inquiries_with_field_ops does, so the clause ran. Seed note for the next sweep: a fresh boot seeds the BU tree but zero sys_business_unit_member rows, so the rule materializes nothing until a tester places someone — that is the seed-data-thin trap for this item.

auth-method-matrix

Every flag with a boot switch matched the config that booted it; 13 forged disabled-method endpoints all 404 across two boots with no silent 200; the 2FA challenge interrupts before a session is granted (the accompanying token is provably unusable); both .well-known documents are byte-identical and every advertised endpoint was probed and resolves.

Three partials: (i) /auth/config still advertises passkeys:false/magicLink:false — a stale prebuilt plugin-auth/dist/index.js, not a regression (src dropped them in 86f7a20e); (ii) the phone-OTP NOT_SUPPORTED branch is unreachable because isPhoneOtpDeliverable() admits LogSmsTransport outside production — proven not a silent success (the code appears in the transport log, a resend is 429, and the captured code completes a real verify); (iii) change-email and delete-user are booked as mounted in auth-route-ledger.ts (lines 118/120) but plugin-auth never configures better-auth's user.changeEmail/user.deleteUser — 0 grep hits in both src and dist — so they answer 400 CHANGE_EMAIL_DISABLED / 404 with no switch to enable them.

session-list-revoke

Revocation is immediate, token-scoped and server-enforced on both bearer and cookie transports (401 on a protected route, not merely absence from the list); revoke-other-sessions leaves exactly the caller alive and never touches another user; sys_session writes are refused as a method gate (405, same for admin — not a permission accident).

Two gaps: (i) no ordinary persona can read sys_session at all (403 for [org_member, everyone]), so raw-table RLS for a non-admin is unobserved — the admin mine view (12 of 50 rows) was substituted; (ii) sys_session.revoked_at/revoke_reason are declared readonly and "System-managed" but have no writer anywhere in the tree and appear in no listView — better-auth deletes the row rather than tombstoning — so the ADR-0069 D4 revoke-audit trail is inert, an enforce-or-remove candidate. Also: Setup → Sessions lands on mine rather than all_sessions because nav_sessions carries no viewName, so an admin sees only their own sessions until they notice the "All" tab.

identity-import-wizard

Both invite channels proven in one batch (delivery {emailInvite:1, smsInvite:1}, the reset link captured from sys_email and the invite SMS from the transport log); the temporary policy gives every row a distinct one-time password via API and through the real wizard; both credential routes then authenticate; upsert is idempotent on the same ids with no duplicates; non-admin 403 / anonymous 401 with zero users created.

Strongest oracle of the run: a byte-level grep for all four plaintext passwords across data.db, data.db-wal, data.telemetry.db, data.telemetry.db-wal and boot.log returned 0 matches — against a positive control that matched 25 times, proving the search reached the stored bytes.

The one unproven half is the auto policy's temporary-fallback branch, which is structurally unreachable on objectstack dev: both transports always register, and dev.ts hard-sets NODE_ENV='development' so isPhoneOtpDeliverable() cannot be made false. Suggest recording this as a fixture note on the item so the next sweep does not re-derive it.

⛔ BLOCKED — 2 items

oauth-app-consent-loop and linked-accounts-social — both carry a ledgered blocked:{by,ref} and remain unrunnable on stock fixtures (no outbound egress for a real IdP round trip).

Checklist maintenance falling out of this run

  1. org-membership-team-management clause 1 and its negative name the wrong vocabulary. The enforced builtin set is {owner, admin, delegated_admin, member} per ADR-0108 (BUILTIN_MEMBERSHIP_ROLE_OPTIONS, "nothing widens these at boot any more") — not {owner, admin, member, guest}. guest is actually rejected (400 ROLE_NOT_FOUND), and delegated_admin is legitimate. The spec doc-comment on MemberSchema.role (organization.zod.ts:84-87, "Common roles: owner, admin, member, guest") is itself stale and is probably where the checklist text came from — worth fixing both.
  2. invitation-scope-gates clause 6 conflates two axes. DelegableScope models ObjectStack positions / permission sets / business units and has no field that could carry a better-auth org invitation role, so it can never "contain member". The delegate demonstrably can mint a member invitation while the endpoint reports an empty assignable set — it under-reports, never over-reports, which is the safe direction. Needs a revision, not a product fix.
  3. admin-lifecycle-operations clause 4 should name the authed-request oracle, not get-session's status code — better-auth's no-session convention is 200-with-null-body, so the clause's literal 401 expectation misdescribes a correct implementation. The session was provably gone.
  4. teams-bu-membership — retire the knownGap (a BU-consuming geometry does ship) and add the zero-seeded-placements note.
  5. identity-import-wizard — add the fixture note that the auto temporary-fallback branch cannot occur on objectstack dev.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingqa-run

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions