Skip to content

fix(admin): admin migrate sends the bootstrap admin's invite, again once its link expires - #102

Merged
jayleaton merged 1 commit into
mainfrom
fix/admin-migrate-sends-bootstrap-invite
Oct 23, 2026
Merged

jayleaton merged 1 commit into
mainfrom
fix/admin-migrate-sends-bootstrap-invite

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

admin migrate seeded ADMIN_BOOTSTRAP_EMAIL without a password and said "send its set-password link with admin invite". The admin image is distroless, so a deployed service has no shell to run admin invite in, and the Admins page needs an admin already. A fresh deployment had no way to get its first admin.

Summary

 admin migrate                                   # Railway pre-deploy
   migrations (auth, audit log)
   svc.Bootstrap(system:bootstrap)               # seed the pending admin (unchanged)
+  svc.InviteBootstrapAdmin(..., inviteTTL)      # email the set-password link, if due
+  admins.Shutdown                               # wait for the queued email before exiting

AdminService.InviteBootstrapAdmin (operator only) sends the link as an audited admin.invite mutation while the admin is pending (no password, not disabled) and no invite is on record from the last link lifetime (72 h, auth.DefaultInviteTTL). The latest invite is found under the admin's id, then its email.

  • Deploys while the last link is still valid send nothing.
  • The first deploy after the link expires sends one fresh link, so a first send that failed recovers on a later deploy without a manual step. That covers both a placeholder RESEND_API_KEY (sends run in the background and a rejection is only logged) and an invite nobody opened in time.
  • cmd/admin sets the admin auth instance's InviteTTL to the same constant it passes, so the window and the link lifetime can't drift apart.

Docs (README, .env.example, CONTEXT, Makefile help, config comment) now describe the rule. Only the bootstrap invite is in scope: no password reset or 2FA recovery here.

Evidence

Before (main, sandbox Postgres, dev mailer): migrate seeds the admin and sends nothing, on every deploy.

level=INFO msg="admin migrations applied"
level=INFO msg="bootstrap admin created; send its set-password link with `admin invite`" email=admin@example.com

After, the built binary against a fresh admin database. Between deploys 2 and 3 the invite row was moved 73 h into the past (append-only trigger disabled for that UPDATE on the scratch database):

== deploy 1 (fresh)
msg="auth: set-password email (dev mode, not sent)" to=admin@example.com url="http://localhost:3001/set-password?token=…"
msg="bootstrap admin" email=admin@example.com created=true invite_sent=true set_password_page=…
== deploy 2 (inside 72h)                         # nothing sent
(backdated the invite by 73h)
== deploy 3 (last invite 73h old)
msg="auth: set-password email (dev mode, not sent)" to=admin@example.com …
msg="bootstrap admin" email=admin@example.com created=false invite_sent=true …
== deploy 4 (inside 72h of the resend)           # nothing sent
audit log: admin.bootstrap 10-23 09:02 · admin.invite 10-20 08:02 (backdated) · admin.invite 10-23 09:02

On an admin database seeded by main's migrate (pending, never invited), the next migrate sent its invite and the one after sent nothing (first revision of this PR, same rule for "never invited").

Tests: TestInviteBootstrapAdmin (mocked ports):

  • never invited → sends and audits.
  • invited 1 min ago or 71 h ago (by id), or 1 h ago (by email) → no send.
  • invited 73 h ago → exactly one send; invited 1 s ago (the resend) → no send.
  • active, disabled, or disabled-and-pending → no lookup, no send.
  • id or email lookup error → internal; admin removed mid-send → ErrAdminNotFound with the outcome recorded.

TestServicesRefuseUnauthorizedActors covers it as operator-only. Mutation check: forcing "never resend" fails last invite expired, and forcing "always resend" fails with an unexpected Begin. Before the fix the test does not build (svc.InviteBootstrapAdmin undefined).

Checks: make check-fast ✅, make generate lint ✅ (no generated diff), make test ✅ (-race, admin services 100 %, all coverage floors pass). The host OOM-killed some runs; the results above are full reruns of the same tree.

Merge Danger

Door: two-way. Revert restores the old migrate. Invites already sent stay in the append-only audit log, which is harmless.

Blast Radius: admin. It touches only admin migrate and one new operator-only service method. A deployment with ADMIN_BOOTSTRAP_EMAIL set whose admin is still pending gets at most one invite email per 72 h, sent from deploys. That includes existing deployments whose bootstrap admin was never invited, which get one on their next deploy.

🤖 Generated with Claude Code

@jayleaton jayleaton changed the title fix(admin): admin migrate sends the bootstrap admin's invite once fix(admin): admin migrate sends the bootstrap admin's invite, again once its link expires Oct 23, 2026
@jayleaton
jayleaton force-pushed the fix/admin-migrate-sends-bootstrap-invite branch 2 times, most recently from 8238e84 to 3589e49 Compare October 23, 2026 12:07
…nce its link expires

Rebuilt on main, where #113 already has admin migrate send the bootstrap
invite once. What this keeps from the original PR is the part main lacks:
InviteBootstrapAdmin resends once the last invite is older than the link
lifetime (inviteTTL = auth.DefaultInviteTTL, also set as the admin auth
instance's InviteTTL so the two cannot drift), so a first email that never
arrived or expired heals on a later deploy, and it never invites a disabled
pending admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton force-pushed the fix/admin-migrate-sends-bootstrap-invite branch from 3589e49 to 57ac5f3 Compare October 23, 2026 12:09
@jayleaton

jayleaton commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner Author

Rebuilt on main (now 4523a36). Most of this PR was already merged by #113 (db2867d "admin migrate sends the first-admin invite once": InviteBootstrapAdmin, the Shutdown drain in migrate, the bootstrap log line), so I dropped those parts and kept only what main lacked, which is this PR's title:

  • Resend once the link expires. InviteBootstrapAdmin(…, linkTTL) reads when the last invite was sent (lastInvite: by id, then by email) and sends again only when it's older than the link lifetime. Main stopped forever after the first send, so a first email that never arrived (e.g. a placeholder RESEND_API_KEY; sends run in the background) never healed on a deploy.
  • Never invite a disabled pending admin.
  • inviteTTL = auth.DefaultInviteTTL in cmd/admin, also set as the admin auth instance's InviteTTL.
  • Docs follow: docs/admin.md (after docs: simpler README; technical sections move to docs/ #107), .env.example, the config comment and the Makefile help. I left CONTEXT.md alone; main's wording still holds.

Proof on the rebased binary (scratch admin database, dev mailer): deploy 1 invite_sent=true; deploy 2 nothing; invite backdated 73 h; deploy 3 created=false invite_sent=true; deploy 4 nothing. Audit: admin.bootstrap 10-23 12:07 · admin.invite 10-20 11:07 (backdated) · admin.invite 10-23 12:07. TestInviteBootstrapAdmin covers never invited, inside the window (by id and by email), expired, accepted/disabled/disabled-pending, and both lookup errors; services coverage 100 %. make check-fast ok, make lint-layering ok, ci-ok success on 57ac5f3.

@jayleaton
jayleaton merged commit a6ad87f into main Oct 23, 2026
3 checks passed
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