Skip to content

feat(accounts): absorb an accidental alt account, and stop auto-granting green (deploy 1 of 2) - #84

Merged
guarzo merged 19 commits into
mainfrom
worktree-account-merge-pending-tier
Aug 4, 2026
Merged

feat(accounts): absorb an accidental alt account, and stop auto-granting green (deploy 1 of 2)#84
guarzo merged 19 commits into
mainfrom
worktree-account-merge-pending-tier

Conversation

@guarzo

@guarzo guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner

⚠️ Deploy 1 of 2 — a second PR follows

Do not merge the follow-up PR until this one is live in production.

This is the expand half of an expand/migrate/contract rollout. It teaches the
whole system to handle a pending account without any code path creating
one. Deploy 2 is a one-line change making createAccountWithCharacter write
tier: "pending" instead of "green".

The split exists because Fly does a rolling replacement: during the overlap
window, old and new instances serve traffic simultaneously. If creation and
handling shipped together, an old instance could load an account whose tier is
a value its enum doesn't know. It also matters at the database level —
Postgres cannot use an enum value in the same transaction that adds it, which
is why the migration only appends the label.

What this fixes

Two defects, one branch, because they share the account state machine:

  1. Stranded alt account. Logging in with a second character created a
    separate account with no way to link it to the main one. A single-character
    account with no history is now absorbed into the account that already owns
    the character, rather than stranded.
  2. Automatic green tier. Anyone logging in from outside the corp was
    auto-granted green, which carries a Discord role. Green now has to be
    granted, not assumed — but see the deploy note above: nothing lands in
    pending until deploy 2.

Deploy-1 safety

Verified on the merged tree, not assumed:

  • No path writes pending. Both account tier writes still say "green"
    (src/services/accounts.ts:163, :207), and tests/accounts.test.ts pins
    the creation invariant so deploy 2 cannot land here by accident.
  • No existing account moves. The migration is exactly one line —
    ALTER TYPE "public"."tier" ADD VALUE 'pending';. No UPDATE, no
    SET DEFAULT, no backfill. The column default stays green. An account
    that is green when this ships stays green; a migration touching rows would
    strip a genuinely deroled ex-member's Discord access on deploy.
  • Nobody is deroled. All three state-machine edits are guarded by explicit
    pending comparisons, so existing accounts don't move.

The merge is the risky part — review it first

mergeAccountInto (src/services/accounts.ts:321-343) contains the only code
in the repo that deletes an account row. It is gated by isAbsorbable
(:265-295), which refuses an account that is admin, tier-locked, non-active,
annotated, or that carries a Discord link, payout participation, an operation
it created, or a payment it actioned — that last check exists so a formerly
flygd account can't satisfy the predicate and lose financial attribution.

Two things a reviewer should know:

  • isAbsorbable deliberately does not inspect tier. That is intentional,
    but it means an ordinary green single-character account is absorbable today,
    which is why the admin actions needed the not_found handling below.
  • The session delete at :335 is load-bearing and must precede the account
    delete: session.accountId is notNull with no onDelete.

Safety rests on EVE's ownerHash, which rotates on character transfer — a
transferred character cannot be used to absorb the previous owner's account.

Verification

All run on the merged tree (main was merged in; it had advanced by two commits
that touch services/accounts.ts):

Check Result
npm test 766 passed (67 files)
npm run test:e2e 132 passed
npm run typecheck clean
npm run lint 0 errors (3 pre-existing <img> warnings)
npm run format:check clean

Known follow-ups (deliberately not in scope)

  • account.merged and tier.approved aren't in the audit page's
    summarize.ts action map. They fall through to its documented generic
    key=value rendering, so this is cosmetic, not a break.
  • promoteAdmin's return type could be tightened to a proper discriminated
    union.

Summary by CodeRabbit

  • New Features

    • Added a pending account status requiring administrator approval before access standing is granted.
    • Administrators can review pending accounts and approve them as Green or Blue.
    • Added pending-status filters, counts, badges, and account-page messaging.
    • Safely merges eligible accidental single-character accounts during linking.
  • Bug Fixes

    • Pending accounts no longer receive automatic promotions or managed roles.
  • Tests

    • Added comprehensive coverage for approval workflows, account merging, pending status, and role handling.

guarzo added 17 commits August 4, 2026 13:28
Two defects from an accidental login with the wrong character: the stranded
account cannot be folded into the operator's real one, and every new account
lands on green, which carries a Discord role.

Design: linkCharacter absorbs a provably-same-owner source account when it is
empty of everything but the character; new accounts start on a fourth tier
value, pending, which grants no managed role until an admin approves it to
green (unlocked) or blue (locked). Derole and existing greens are unchanged.
…udit claim

Review found four defects:

- A single deploy is unsafe. Migrations run as a release command before the
  rolling replacement, so an old worker sees pending accounts written by new
  web code and transitions them to green, defeating the gate. Split into two
  deploys: readers learn pending first, account creation switches second.
- The absorbability predicate omitted status and status_note, so an admin's
  cryo and operational note could be silently destroyed by a merge.
- Adding pending to TIER_RANK does not surface the queue: the table defaults
  to name sort. Keep that default and add an explicit pending-count link.
- audit_log.actor is plain text with no FK, so it does not null on delete.
  The uuid survives and renders as actorKind unresolved.
Eleven tasks across two deploys. Tasks 1-10 teach every reader about pending
while creation still writes green; task 11 is the single creation line and must
ship as a separate release.
- pending is no longer assignable via setTierManual: TIERS (manual set) and
  TIER_FILTERS (query whitelist + chips) are separate arrays
- absorbability rejects payout_payment.actor, reachable by an ex-flygd account
- approveAction matches the file's guard contract and per-error handling
- the pending notice is an extractable leaf component, not a page render
- the queue count is computed independently of the active filters
Appends `pending` to the tier enum and widens every hand-written union that
mirrors it. Nothing creates or reads a pending account yet — this is the
"every reader learns pending" half of the two-deploy rollout.

diffRoles ships in the same commit rather than a follow-up: the enum widening
makes `row.tier` four-valued at the call site (src/jobs/discord-roles.ts), so
there is no typecheck-clean intermediate state where diffRoles accepts pending
but does not handle it. A pending account is one nobody has approved, so it
gets no managed role: add nothing, strip whatever it carries.
Green stays unlocked so a later alliance join still auto-promotes; blue locks
because an unlocked blue converges back to green on the next membership run.
Default sort stays name; the queue gets its own count link rather than
reordering every admin's table.
Linking a character whose owner hash matches but whose account is otherwise
empty now folds that account in instead of refusing. Anything with an admin
bit, a lock, cryo, a note, a Discord link, payout history, or a second
character still refuses.
…or boundary

The merge feature (mergeAccountInto) deletes a pending account's row, ignoring
tier in isAbsorbable, so an admin's Approve click can now legitimately race
against it. Treat not_found the same as the sibling not_pending race: a queue
notice, not a thrown error. Corrects two stale comments that predated the merge
feature and adds coverage for approveAccount's not_found return.
setTierAction, returnToAutoAction, setStatusAction, saveNoteAction, and
promoteAdminAction all threw new Error(result.error) on not_found, landing
the admin on the generic error boundary. mergeAccountInto deletes the
source account outright and isAbsorbable doesn't gate on tier, so an
ordinary account can vanish between page render and any of these clicks —
a race between two legitimate users, not a server fault.

Route all six actions (these five plus approveAction) through one
exhaustive redirectOnMutationError switch, mirroring admin-guard's
denyAdmin: a variant added to AdminMutationResult or ApproveResult without
a matching case now fails the build instead of silently reaching a throw.
Reword the shared not_found notice so it no longer reads as approval-only.
… cost

The admin accounts page computed its "N awaiting approval" count by
calling getAdminAccountsList a second time and taking .length. That
service issues five unbounded full-table scans and assembles a full
per-account row, including per-character token/scope/ACL work — ten
scans per page load instead of five, to obtain one integer.

Add countAccountsByTier, a single count(*) filtered by tier, and have
the page call that instead. The count stays independent of the page's
active status/tier filters and gated on > 0, as before.
…e copy

The redirectOnMutationError doc comment claimed a new error variant would
fail the build with "not every code path returns a value". A mutation
experiment showed the first error is actually TS2345 at the four call
sites, because the widened error type stops being assignable to the
helper's parameter; the end-point error only appears once the parameter
union is widened too. The guarantee is real, the stated mechanism was not.

Also trims "nothing left there to act on" to "nothing left to act on".
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 015ca144-0f0f-4312-ab97-2f5077bcc94e

📥 Commits

Reviewing files that changed from the base of the PR and between 14a8219 and 1d288b0.

📒 Files selected for processing (3)
  • docs/superpowers/plans/2026-08-04-account-merge-and-pending-tier.md
  • docs/superpowers/specs/2026-08-04-account-merge-and-pending-tier-design.md
  • tests/account-view.test.ts
📝 Walkthrough

Walkthrough

Adds a pending account tier with administrator approval, pending-specific role and member display behavior, admin queue controls, transactional absorption of eligible accidental accounts, database migration metadata, and unit and end-to-end coverage.

Changes

Account lifecycle changes

Layer / File(s) Summary
Pending tier state and schema
src/core/tier.ts, src/core/role-diff.ts, src/db/schema.ts, drizzle/*, tests/tier.test.ts, tests/role-diff.test.ts
Adds pending to tier types and database enums. Pending accounts retain their state outside the alliance and receive no managed Discord role.
Approval service and admin queue
src/services/admin-accounts.ts, src/services/account-view.ts, src/app/admin/accounts/*, tests/admin-accounts.test.ts, e2e/admin.spec.ts
Adds green and blue approval actions, locking rules, audit and synchronization writes, pending filtering, counts, sorting, redirects, and concurrency handling.
Member pending standing
src/app/account/standing.tsx, src/app/account/page.tsx, src/app/_components/ui.tsx, src/app/globals.css, tests/account-page.test.ts, e2e/account.spec.ts
Displays an awaiting-approval status for pending members and neutral pending styling.
Accidental account absorption
src/services/accounts.ts, tests/accounts.test.ts, src/app/account/page.tsx
Absorbs eligible inactive single-character accounts during same-owner linking, deletes source sessions and the source account, transfers the character, and records the merge audit event.
Deployment and verification gates
docs/superpowers/plans/*, docs/superpowers/specs/*
Defines the staged reader rollout, later pending-account creation, migration checks, and validation requirements.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • guarzo/authGD#1: Provides the account, tier, schema, and service foundations extended by these workflows.
  • guarzo/authGD#2: Shares tier decision and Discord role synchronization changes.
  • guarzo/authGD#4: Shares the admin account UI and account-view tier handling.

Poem

I’m a rabbit watching tiers turn bright,
Pending waits beneath the moonlit light.
Admins choose green or blue with care,
Stray little accounts find homes fair.
Audit trails hop neatly in line—
Approval and merging now combine.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies account absorption, the pending-tier rollout, and the prevention of automatic green assignment.
Description check ✅ Passed The description explains the changes, rollout order, safety constraints, verification results, deploy notes, and known follow-ups.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-account-merge-pending-tier
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch worktree-account-merge-pending-tier

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-08-04-account-merge-and-pending-tier.md`:
- Around line 320-331: Add blank lines immediately before the opening TypeScript
fence and after its closing fence in the approveAccount documentation section,
without changing the fenced code content.
- Around line 1217-1221: Update the Step 2 verification command in the
account-merge and pending-tier plan to search only src/services/accounts.ts and
the account-creation write path, excluding reader declarations in files such as
account-view.ts and role-diff.ts. Preserve the check’s purpose of detecting
premature pending-tier writes and keep its expected no-match result accurate.

In `@docs/superpowers/specs/2026-08-04-account-merge-and-pending-tier-design.md`:
- Around line 121-127: Update the migration/design statement around
createAccountWithCharacter to describe the deploy-1 target accurately: account
creation continues writing tier "green" until deploy 2. Mark the explicit tier:
"pending" behavior as a deploy-2 change, keeping the migration’s enum-only
update and rollout sequence consistent.
- Around line 21-26: Update the fenced code block in the account-merge design
document to declare the TypeScript language using the ts fence label, preserving
the block’s existing content.

In `@src/app/admin/accounts/actions.ts`:
- Around line 39-68: Update demoteAdminAction to preserve its existing
last_admin handling, then pass any remaining mutation errors, including
not_found, to the shared redirectOnMutationError helper instead of throwing.
Ensure stale revoke requests use the defined not_found redirect behavior.

In `@tests/account-view.test.ts`:
- Around line 327-335: Update the tier-sorting test around getAdminAccountsList
to seed an account with the blue tier and include blue in the expected sorted
tier sequence, ensuring pending is verified ahead of blue as well as green and
flygd.

In `@tests/accounts.test.ts`:
- Around line 344-371: Update the merge audit test around handleEveLogin and
linkCharacter to insert an auditLog row authored by stray.accountId before the
merge, then query auditLog by actor equal to stray.accountId instead of
filtering by target. Preserve the existing account.merged assertions and verify
the source-authored row remains unresolved after the merge.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9c4a2652-077b-498c-bd3b-6b992fefd66a

📥 Commits

Reviewing files that changed from the base of the PR and between 17e3c2a and 14a8219.

📒 Files selected for processing (28)
  • docs/superpowers/plans/2026-08-04-account-merge-and-pending-tier.md
  • docs/superpowers/specs/2026-08-04-account-merge-and-pending-tier-design.md
  • drizzle/0006_powerful_infant_terrible.sql
  • drizzle/meta/0006_snapshot.json
  • drizzle/meta/_journal.json
  • e2e/account.spec.ts
  • e2e/admin.spec.ts
  • e2e/helpers.ts
  • src/app/_components/ui.tsx
  • src/app/account/page.tsx
  • src/app/account/standing.tsx
  • src/app/admin/accounts/actions.ts
  • src/app/admin/accounts/page.tsx
  • src/app/globals.css
  • src/core/role-diff.ts
  • src/core/tier.ts
  • src/db/schema.ts
  • src/lib/admin-guard.ts
  • src/services/account-view.ts
  • src/services/accounts.ts
  • src/services/admin-accounts.ts
  • tests/account-page.test.ts
  • tests/account-view.test.ts
  • tests/accounts.test.ts
  • tests/admin-accounts.test.ts
  • tests/helpers/seed.ts
  • tests/role-diff.test.ts
  • tests/tier.test.ts

Comment thread docs/superpowers/plans/2026-08-04-account-merge-and-pending-tier.md
Comment thread docs/superpowers/plans/2026-08-04-account-merge-and-pending-tier.md Outdated
Comment on lines +21 to +26
```
src/services/accounts.ts:244
if (existing.ownerHash === ch.ownerHash) {
return { ok: false, error: "already_linked" };
}
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language to the fenced block.

markdownlint reports MD040 for this block. The block quotes TypeScript.

📝 Proposed fix
-```
+```ts
 src/services/accounts.ts:244
     if (existing.ownerHash === ch.ownerHash) {
       return { ok: false, error: "already_linked" };
     }
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 21-21: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-08-04-account-merge-and-pending-tier-design.md`
around lines 21 - 26, Update the fenced code block in the account-merge design
document to declare the TypeScript language using the ts fence label, preserving
the block’s existing content.

Source: Linters/SAST tools

Comment on lines +39 to +68
* `not_found` is reachable from every action here, not just approval:
* mergeAccountInto (services/accounts.ts) deletes the source account outright
* on merge, and isAbsorbable deliberately doesn't gate on tier, so any admin
* control targeting an account can find the row gone between page render and
* the click — a race between two legitimate users, not a server fault.
*
* `fromQueue` sends `not_found` back to the pending-tier filter rather than
* the unfiltered list: only approveAction's callers were looking at that
* filter when they clicked. `not_pending` always goes there regardless of the
* flag, since it can only ever be produced by approveAccount.
*/
function redirectOnMutationError(
error: "not_authorized" | "not_found" | "not_pending",
opts: { fromQueue?: boolean } = {},
): never {
switch (error) {
case "not_authorized":
return redirectNotAdmin();
case "not_pending":
// Two admins working the queue, or one with a stale tab: the account is
// approved, just not by them.
return redirect("/admin/accounts?tier=pending&error=not_pending");
case "not_found":
return redirect(
opts.fromQueue
? "/admin/accounts?tier=pending&error=not_found"
: "/admin/accounts?error=not_found",
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Route demoteAdminAction failures through the shared handler.

Line 39 states that a merge can delete the target of any admin action. demoteAdminAction still throws for not_found. A stale revoke request then returns a 500 instead of the defined not_found redirect. Preserve last_admin handling, then route the remaining failures through redirectOnMutationError.

Proposed fix
 export async function demoteAdminAction(accountId: string): Promise<void> {
   const { accountId: actor } = await requireAdminAction();
   const result = await getDb().transaction((tx) => demoteAdmin(tx, actor, accountId));
-  if (!result.ok && result.error === "last_admin") {
-    // Surface the service's protection instead of a 500 (carry-over).
-    redirect("/admin/accounts?error=last_admin");
-  }
-  if (!result.ok && result.error === "not_authorized") redirectNotAdmin();
-  if (!result.ok) throw new Error(result.error);
+  if (!result.ok) {
+    if (result.error === "last_admin") {
+      return redirect("/admin/accounts?error=last_admin");
+    }
+    if (result.error === undefined) {
+      throw new Error("demoteAdmin: ok:false without an error code");
+    }
+    return redirectOnMutationError(result.error);
+  }
   revalidatePath("/admin/accounts");
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/accounts/actions.ts` around lines 39 - 68, Update
demoteAdminAction to preserve its existing last_admin handling, then pass any
remaining mutation errors, including not_found, to the shared
redirectOnMutationError helper instead of throwing. Ensure stale revoke requests
use the defined not_found redirect behavior.

Comment thread tests/account-view.test.ts
Comment thread tests/accounts.test.ts
Comment on lines +344 to +371
it("audits the merge and leaves the source's own audit rows unresolved", async () => {
const main = await seedAccount(ctx.db, { tier: "flygd" });
await seedCharacter(ctx.db, cfg, { id: 90000331, accountId: main.id, main: true });
const stray = await ctx.db.transaction((tx) =>
handleEveLogin(tx, cfg, ch({ characterId: 90000332, ownerHash: "oh-332" })),
);

await ctx.db.transaction((tx) =>
linkCharacter(tx, cfg, main.id, ch({ characterId: 90000332, ownerHash: "oh-332" })),
);

const merged = await ctx.db
.select()
.from(auditLog)
.where(eq(auditLog.action, "account.merged"));
expect(merged).toHaveLength(1);
expect(merged[0].details).toEqual({
sourceAccountId: stray.accountId,
characterId: 90000332,
});
// audit_log.actor is plain text with no FK: rows the deleted account wrote
// survive with a uuid that resolves to nothing (actorKind "unresolved").
const orphaned = await ctx.db
.select()
.from(auditLog)
.where(eq(auditLog.target, stray.accountId));
expect(orphaned.length).toBeGreaterThan(0);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the audit rows written during account creation and character linking.
ast-grep run --pattern 'logAudit($$$)' --lang typescript src/services/accounts.ts

Repository: guarzo/authGD

Length of output: 3578


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- accounts service symbols ---'
ast-grep outline src/services/accounts.ts
printf '%s\n' '--- relevant service sections ---'
sed -n '80,230p' src/services/accounts.ts
sed -n '300,430p' src/services/accounts.ts
printf '%s\n' '--- test context and adjacent tests ---'
sed -n '250,380p' tests/accounts.test.ts
printf '%s\n' '--- audit schema and helper definitions ---'
rg -n -C 8 'auditLog|audit_log|function logAudit|const logAudit' src tests

Repository: guarzo/authGD

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re

source = Path("src/services/accounts.ts").read_text()
for name, end in [
    ("handleEveLogin", "async function isAbsorbable"),
    ("mergeAccountInto", "export async function linkCharacter"),
    ("maybeGrantBootstrapAdmin", "export async function reclaimTransferredCharacter"),
]:
    start = source.index(name)
    stop = source.index(end, start)
    block = source[start:stop]
    print(f"--- {name} ---")
    for match in re.finditer(r"logAudit\s*\(\s*[\w.]+,\s*\{(?P<body>.*?)\}\s*\)", block, re.S):
        body = " ".join(match.group("body").split())
        print(body)
PY

printf '%s\n' '--- test helpers that can create audit actors ---'
rg -n -C 5 'function seedAccount|const seedAccount|seedAccount|insert\(auditLog\)|wakeSelf|handleEveLogin|linkCharacter' tests/accounts.test.ts | head -180

Repository: guarzo/authGD

Length of output: 8339


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- handleEveLogin and absorbability flow ---'
sed -n '228,320p' src/services/accounts.ts
printf '%s\n' '--- test seed helpers ---'
fd -i 'seed' tests
rg -n -C 12 'export async function seedAccount|export const seedAccount|async function seedAccount|insert\(auditLog\)' tests
printf '%s\n' '--- direct audit inserts in account tests ---'
rg -n -C 4 'auditLog\)\.values|insert\(auditLog\)' tests/accounts.test.ts tests/helpers

Repository: guarzo/authGD

Length of output: 5389


Seed a source-authored audit row before the merge.

handleEveLogin records account.created with actor: "system", so filtering on auditLog.actor = stray.accountId finds no row in this scenario. Insert an audit row with actor: stray.accountId before linkCharacter, then query auditLog.actor to test unresolved source actors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/accounts.test.ts` around lines 344 - 371, Update the merge audit test
around handleEveLogin and linkCharacter to insert an auditLog row authored by
stray.accountId before the merge, then query auditLog by actor equal to
stray.accountId instead of filtering by target. Preserve the existing
account.merged assertions and verify the source-authored row remains unresolved
after the merge.

…eploy 1

CodeRabbit review of #84.

The tier-sort fixture seeded green/pending/flygd but never blue, so a
regression ranking blue ahead of pending passed. Verified by mutation:
with TIER_RANK blue:0 the strengthened assertion fails with
['blue','pending','flygd','green']; the old fixture passed unchanged.

The spec said createAccountWithCharacter writes tier: "pending", which
contradicts its own Rollout section and this PR — a reader following that
paragraph alone would make the deploy-2 change during deploy 1.

The plan's step-2 check grepped src/ for 'tier: "pending"' expecting no
match, but deploy 1 deliberately adds readers of the pending tier, so the
pattern matches type annotations and the queue-count filter arg on a
correct tree. Points at the two account write sites instead.
@guarzo

guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Thanks — five of these were real and are fixed in 1d288b0. Rejecting the demoteAdminAction one, with reasoning:

demoteAdminAction routing not_found through redirectOnMutationError — declining. The described failure can't occur, on two independent grounds:

  1. demoteAdmin cannot return not_found. Its union is "last_admin" | "not_authorized" (src/services/accounts.ts:560). It never selects the target row — it filters the already-locked admin set by id (:574). If the target were gone, otherAdmins is unaffected, the UPDATE matches zero rows, and it returns { ok: true }. So there is no 500 to prevent; throw new Error(result.error) is unreachable for a missing target.

  2. The merge can never delete a demote target. isAbsorbable refuses any account with isAdmin set — it's the first guard (src/services/accounts.ts:270). demoteAdminAction only ever targets an admin. The stale-target race this PR introduces therefore cannot reach it.

The contrast with promoteAdminAction is the tell: its target is a non-admin account, which is absorbable, promoteAdmin does return not_found (:602), and it is already routed through the shared handler (actions.ts:174). The asymmetry is deliberate, not an oversight.

Worth noting the one real (pre-existing, out of scope) wrinkle your comment surfaced: demoting an already-deleted account would write a spurious admin.demoted audit row for a nonexistent target. Ground 2 makes it unreachable today, so I'm not widening this PR for it.

@guarzo
guarzo merged commit f79479a into main Aug 4, 2026
6 checks passed
@guarzo

guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

Two follow-up commits since the last pass:

  • 1d288b0 — tier-sort fixture now includes blue; spec no longer claims createAccountWithCharacter writes tier: "pending" in deploy 1; plan's step-2 check points at the account write sites instead of a grep that matches on a correct tree; MD031 fixed. MD040 fixed with a text label rather than ts — the block's first line is a file path, so a ts label would invite Prettier to reformat invalid TypeScript and break format:check.
  • 057bb59 — the merge-audit test now covers the actor half. You were right that it was untested: handleEveLogin only writes actor: "system". It is reachable via a demoted ex-admin (still absorbable, since isAbsorbable checks current isAdmin, while its old admin.* rows carry its own uuid). Verified by mutation.

The demoteAdminAction thread remains open deliberately — see my reasoning above. Leaving it unresolved rather than closing it myself so a human can adjudicate the disagreement.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

@guarzo I will review commits 1d288b0 and 057bb59.

I will also re-evaluate the demoteAdminAction finding against the stated isAbsorbable and demoteAdmin invariants.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant