28 KiB
Plan — Auth simplification (PR 16–21)
Context
The magic-link work (PRs 6–12) shipped external users as a second principal kind with username = email. PR 13 closed the route-level lockouts. Across a design conversation on 2026-06-02 we agreed the resulting model is needlessly two-tier and can be simplified by making email the identity, username an optional handle, and credentials (password / OIDC) truly optional and orthogonal. The proximate motivation: avoid the username-vs-email cross-collision class of bugs we just spent time guarding against, and reduce password-hash density in the DB by letting users sign up email-only with magic-link as their bootstrap path.
The end state: every user is identified by email; username, password_hash, and oidc_subject are all Option<…> columns whose presence is observable but never required. Login is a one-line dispatch on @-in-input. Magic-link eligibility is a three-branch rule (OIDC always rejected, password rejected by default, no-credential allowed) with one env knob to flip the middle branch. No new auth methods land here — TOTP / WebAuthn / passkey enrolment stays out of scope. The whole work is reorganisation of existing primitives, not new ones.
Design decisions (locked in by conversation)
Identity model
| Slot | Type | Meaning |
|---|---|---|
email |
String (NOT NULL UNIQUE) |
The identity. Every login path ultimately resolves here. |
username |
Option<String> (UNIQUE, NULL allowed) |
Optional handle. 2-64 chars inclusive, [A-Za-z0-9._-]+ (no @). Claimable post-creation. Multiple NULLs coexist under the existing UNIQUE index. |
password_hash |
Option<String> |
An Argon2 hash if the user chose one. NULL otherwise. NO placeholder strings. |
oidc_subject |
Option<String> |
The IdP subject claim if the user linked one. NULL otherwise. |
is_external |
bool |
Provisioning origin marker. true when created via email-invite from a sharer. Future "promote to internal" flow (separate TODO) flips this. |
Eligibility predicates derive from the slots:
fn has_password(&self) -> bool { self.password_hash.is_some() }
fn has_oidc(&self) -> bool { self.oidc_subject.is_some() }
fn has_login_credential(&self) -> bool {
self.has_password() || self.has_oidc()
}
No more sentinel strings (__EXTERNAL_NO_PASSWORD__, __OIDC_NO_PASSWORD__). The schema migration NULLs them out as part of PR 16.
Login dispatch
input contains '@' → lookup by email, verify password
input does not → lookup by username, verify password
Single DB hit. Unambiguous because @ is forbidden in username. The same dispatcher serves the magic-link send endpoint, but that endpoint only takes email — input without @ is a 400.
Magic-link eligibility ladder
pub fn magic_link_eligibility(user: &User, open_to_password_users: bool) -> Eligibility {
if user.has_oidc() { return Reject("oidc_user"); } // unconditional; IdP is the security boundary
if user.has_password() {
return if open_to_password_users { Allow } else { Reject("has_password") };
}
Allow // no credentials at all
}
| User state | Magic-link eligible? |
|---|---|
| No password, no OIDC | Yes — always |
| Has password, no OIDC | Default no; OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true opens it |
| Has OIDC (with or without password) | No — always. Flag has no effect. |
The oidc_user reject is unconditional because OIDC is the only path to MFA today (delegated to the IdP), and magic-link must never bypass it. Future native 2FA (TOTP, WebAuthn) lands a third reject branch behind the existing patterns.
Registration paths
| Path | Pre-condition | Effect |
|---|---|---|
POST /api/auth/register with email + password |
Public registration enabled | User row with password_hash set. JWT issued. |
POST /api/auth/register with email only |
Public registration enabled | User row with password_hash = None. Magic-link mailed to the address for first-session bootstrap. JWT NOT issued by the register call. |
POST /api/grants { subject.type: "email" } |
Sharer has Share permission | External user lazily provisioned. Magic-link invitation mailed. Existing PR 9 flow. |
| OIDC JIT | First IdP-mediated login | User row with oidc_subject set, password_hash = None. JWT issued. |
All four return the same uniform shape regardless of email existence (PR 20 closes the register oracle that survives from the original schema). Real reason recorded in audit.
Anti-enumeration is preserved everywhere
POST /api/auth/register→ 200 uniform, audit reasonscreated/email_taken/username_taken/disabledPOST /api/auth/magic-link/send→ 200 uniform, audit reasonssent/no_account/has_password/oidc_user/account_deactivated/malformed_email/rate_limited_email/rate_limited_ipPOST /api/auth/login→ 403 uniformInvalid credentials, audit reasonsunknown_user/bad_password/account_deactivated
Migration discipline
Forward-only migrations. The previous …000003_users_username_email_login.sql widening to 254 chars has already been applied to dev / CI environments — squashing with the new shrink-and-NULL migration would break _sqlx_migrations checksum tracking. Add a fresh migration file; history records the two-step story honestly.
What stays the same
- JWT structure, session lifetimes, refresh-token rotation
is_externalflag and its DB CHECK constraints (users_external_not_admin,users_external_no_storage)- All PR 13 route-level lockouts (external users still can't reach CalDAV/CardDAV/WebDAV/NC, can't mint app passwords, can't enumerate groups)
- ReBAC
access_grantstable and all permission semantics - SMTP-unconfigured ⟹ magic-link unavailable ⟹ external invitations unavailable (existing 503 paths)
- The three rate limiters from PR 12 — caps and keys unchanged
PR sequence
| PR | Subject | Why land separately |
|---|---|---|
| 16 | Schema + entity: nullable username / password_hash, format CHECK, sentinel cleanup, User::new collapse, audit-log Option handling |
Foundational — every later PR consumes Option<String> columns. Verifiable in isolation by re-running the existing Hurl suite. |
| 17 | Login dispatcher: input contains('@') decides email vs. username path. Pure refactor of AuthApplicationService::login. |
One behavioural axis; isolated test surface. |
| 18 | Optional password at registration: RegisterDto.password: Option<String>. Password-less path mints a magic-link to the supplied email. |
The "email-only signup" UX. Depends on 16. |
| 19 | OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS env knob + magic_link_eligibility() refactor with three audit reasons. |
Single env-driven policy switch with observability. |
| 20 | Anti-enumeration register: uniform 200 regardless of outcome, real reasons in audit channel. |
Closes the username/email enumeration oracle that survives from the original schema. |
| 21 | docs/architecture/auth-model.md (~250 lines) + sidebar + cross-references. Acceptance gate. |
Big-picture documentation of the final identity / credential / login surface. |
| 22 | Device-bound magic-link redemption (challenge cookie) + asymmetric TTLs: login-via-email tokens live 10 minutes, invitation tokens live 24 hours. | Closes the mailbox-as-bearer-token attack class on the login flow. Invitations stay cross-device (recipient has no prior browser context with the server). |
| 23 | auth.users.email_verified_at column + flip-to-verified on magic-link redemption / OIDC-with-verified-claim. Read-only API surface for now (future PRs gate features on it). |
Establishes a verified-control-of-inbox signal so later policy can require it (e.g. block uploads / shares for unverified users). |
Critical files
New
migrations/20260603000000_username_optional_no_email_shape.sql— username + password_hash schema cleanuptests/api/auth_login.hurl— dispatcher coverage (PR 17)tests/api/registration.hurl— email-only signup + anti-enumeration (PR 18 + PR 20)docs/architecture/auth-model.md— final architecture page (PR 21)
Modified — by PR
PR 16:
src/domain/entities/user.rs—username: Option<String>,password_hash: Option<String>,oidc_subjectconfirmedOption<String>. Collapsenew_password/new_oidc/new_externalinto oneUser::new(or keep three named factories if readability outweighs the duplication). Drop sentinel string comparisons;has_login_credentialbecomes a two-lineOption::is_somecheck.src/infrastructure/repositories/pg/user_pg_repository.rs— Option binding in all 8 SELECT/INSERT/UPDATE sitessrc/application/dtos/user_dto.rs—username: Option<String>src/application/services/magic_link_invite_service.rs::resolve_or_create_recipient— passNonefor username when minting an externalsrc/application/services/auth_application_service.rs— every audit line emittingusername = %user.username()switches to adisplay_for_audit(&user)helper that falls back to user_id whenNonesrc/interfaces/api/handlers/contacts_handler.rs::user_to_contact— already handles None fallback (from yesterday's given/family work), but verify after the type changesrc/interfaces/nextcloud/routes.rs::verify_url_user— whenauth_user.usernameis None, return 403 instead of comparing to the URL segment (externals are already PR-13-blocked but the type change forces an explicit branch)- Every
auditlog line inauth_handler.rs,auth_application_service.rs,magic_link_invite_service.rs— review forusername =interpolations
PR 17:
src/application/services/auth_application_service.rs::login— dispatch on@src/application/dtos/user_dto.rs::LoginDto— renameusernamefield tousername_or_emailwith a serde alias for backward compat, or leave the name and document the new semantics- Frontend
static/login.html— change the input placeholder from "Username" to "Username or email"
PR 18:
src/application/dtos/user_dto.rs::RegisterDto.password: Option<String>+ validation: when present, enforce length minimumsrc/application/services/auth_application_service.rs::register— branch ondto.password.as_ref(). When None: create user withpassword_hash = None, then callMagicLinkInviteService::send_login_link(&dto.email)as a best-effort post-actionsrc/interfaces/api/handlers/auth_handler.rs::register— uniform 201 either way
PR 19:
src/common/config.rs::MagicLinkConfig.open_to_password_users: bool(default false) + env loadersrc/application/services/magic_link_invite_service.rs— newmagic_link_eligibility()function; replace the singleif user.has_login_credential()check insend_login_linkandissue_invitationexample.env— new entry with explanation
PR 20:
src/application/services/auth_application_service.rs::register— silence the descriptive error strings; return Ok with the same uniform shape on collision; audit-log the truthsrc/interfaces/api/handlers/auth_handler.rs::register— response body becomes the uniform "If the email is available, a confirmation link has been sent."- Hurl regression: the existing register test in
tests/api/setup.hurletc. needs to assert the new uniform shape
PR 21:
docs/architecture/auth-model.md(new file, ~250 lines)docs/.vitepress/config.mts— sidebar entry aftermagic-link-authdocs/architecture/magic-link-auth.md— § "Identity model" links toauth-model.mddocs/architecture/share-integration.md— already referencesmagic-link-auth; chain stays one hop deep
Existing patterns to reuse (with paths)
- MockEmailSender at
src/infrastructure/services/mock_email_sender.rs— every PR 18 / 20 Hurl test uses it viaGET /api/admin/smtp/test/captured?to=…. Already configured intests/common/server.env(OXICLOUD_SMTP_MOCK=true). - Audit-log convention from
CLAUDE.md§ Authorization — every reject emitstracing::info!(target: "audit", event = "<domain>.<verb>", reason = "<key>", …)with structured fields. New reasons in this work:auth.register_rejectedwithemail_taken,username_taken,disabledauth.magic_link_sendgainshas_password,oidc_user(replacing singlehas_credential)auth.login_rejectedreasons unchanged
- Migration mirroring —
migrations/20260612000003_users_username_email_login.sql(or the actual existing file) is the precedent for username-column changes. New migration sits beside it. - Three rate limiters from PR 12 — caps and keys unchanged. No new limiters in this work.
- Eligibility split pattern —
Eligibility::Allow / Reject(reason)enum is new; canonical home isapplication/services/magic_link_invite_service.rsnext to the existingMagicLinkResourceKind.
Verification
Per-PR (mandatory, every PR):
cargo fmt --all
cargo clippy --all-features --all-targets -- -D warnings
cargo test --workspace --lib
bash tests/api/run.sh
Frontend checks when touching static/:
biome check --fix static/
stylelint static/css/
tsc -p jsconfig.json --noEmit
End-to-end gates
After PR 16 (smoke): the existing 14 Hurl files still pass. Bob's flow in external_users.hurl works with bob's username now NULL (assertion updates in Step 11c and Step 12).
After PR 17:
- Hurl: alice (internal, picked username "alice") logs in via
username = "alice"→ 200 - Hurl: alice logs in via
username = "alice@oxicloud.local"(her email) → 200 - Hurl: bob (external, NULL username) logs in via
username = "bob@externalcompany.com"→ 403 (no password); via magic-link path → still works as before - Hurl: wrong password on either path → uniform
403 Invalid credentials
After PR 18:
- Hurl:
POST /api/auth/registerwith{email, password}→ 201, JWT returned (existing behaviour) - Hurl:
POST /api/auth/registerwith{email}only → 200 with uniform body;GET /api/admin/smtp/test/captured?to=…returns the welcome magic-link - Hurl: follow the captured URL → session cookies set, redirect to
/#/ - Hurl: user now has
password_hash = NULLin DB; subsequent magic-link send is allowed (eligible) - Hurl: user sets a password via
PUT /api/auth/change-password→ next magic-link send to same email returns 200 but NO new mail captured (strict mode +has_passwordaudit)
After PR 19:
- Hurl in default mode: password user gets no mail on magic-link send (existing strict-mode test)
- Hurl in lenient mode (
OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=trueintests/common/server.env, or a separate run): mail IS captured for the same scenario - Hurl: OIDC user → no mail regardless of flag value (the
oidc_useraudit reason fires unconditionally) - Unit test: 16-row matrix of
(has_password, has_oidc, is_external, flag)× eligibility outcome
After PR 20:
- Hurl:
POST /api/auth/registerwith an already-used email → 200 uniform; no new user row in DB; audit log lineauth.register_rejected reason=email_taken - Hurl:
POST /api/auth/registerwith an already-used username → 200 uniform; same shape - Hurl: timing comparison (informal) — collision path returns within ±10ms of the success path
After PR 21 (acceptance):
auth-model.mdrenders correctly in VitePress dev (cd docs && npm run dev)- Sidebar shows the new entry under Architecture
- Every audit reason listed in the doc is grep-able to a real
tracing::info!call insrc/
Out of scope (do NOT bundle)
Items the conversation explicitly deferred. Each has a clear future trigger.
- Native 2FA (TOTP, WebAuthn enrolment) for password users. Today OIDC delegation is the only MFA path; native enrolment requires UI, recovery codes, and a third reject branch in
magic_link_eligibility()(mfa_enrolled). Listed inauth-model.md§ "What is deliberately out of scope". login_strategyper-user policy enum. The conversation surfaced this as a future architectural direction. Captured inauth-model.md§ "Future direction — per-user login strategy" with the matrix (passwordless,password,password_or_magic_link,password_and_magic_link,oidc,password_and_totp,password_and_webauthn). No implementation in this work.- External-user promotes to internal. Triggered when an external sets a credential. Today
is_externalstays TRUE post-credential-set; the upgrade flips it to FALSE and provisions a home folder + Internal-group membership + DAV access. Depends on registration UX direction (Design A "claim username when needed" vs. Design B "auto-generate handle") being decided. Separate work. session_kindon sessions emitted from magic-link. A magic-link session today is indistinguishable from a password session. Enables scoped sessions later (Option-B style "magic-link session can only access granted resources"). Not load-bearing for v1.- Differentiated session TTL for externals. Uniform refresh-token expiry today. Future env
OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS. - Open Cloud Mesh (OCM) federation. Third source for external provisioning. The
ExternalIdentityLifecycleHook::on_user_createddesign accommodates thesourcediscriminator (magic_link/oidc/ocm). - Recovery codes / passkey enrolment. Tied to native 2FA above.
- Per-user opt-out of magic-link when lenient mode is on. Today the env flag is instance-wide. A future per-account toggle (e.g. high-privilege admins disabling magic-link for themselves) would need an
auth.users.magic_link_disabled BOOLEANcolumn and one extra branch in eligibility. Listed inauth-model.md.
PR 23 — Email-verified signal (design recap)
Goal: track whether the user has demonstrated control of their email address. The signal is data-only in PR 23 — future PRs will gate features (uploads, shares, sensitive operations) on it via an env switch.
Schema migration:
ALTER TABLE auth.users
ADD COLUMN email_verified_at TIMESTAMPTZ NULL;
-- Backfill: anyone who has successfully been through a flow that
-- proves email control gets stamped retroactively. OIDC implies the
-- IdP confirmed the email; external users who've logged in at least
-- once must have clicked an invitation link.
UPDATE auth.users
SET email_verified_at = COALESCE(last_login_at, created_at)
WHERE oidc_subject IS NOT NULL
OR (is_external = TRUE AND last_login_at IS NOT NULL);
COMMENT ON COLUMN auth.users.email_verified_at IS
'When the user demonstrated control of their email. NULL = unverified
(password-only signup whose user never clicked a verification link, or
admin-created user who hasn''t logged in via magic-link). Set on
successful magic-link redemption OR OIDC JIT with email_verified=true claim.';
Entity additions (domain/entities/user.rs):
pub email_verified_at: Option<DateTime<Utc>>,
impl User {
pub fn is_email_verified(&self) -> bool {
self.email_verified_at.is_some()
}
/// Stamp the verification time. Idempotent — keeps the first
/// verification timestamp on re-verification to preserve the
/// "first proof of control" semantics.
pub fn mark_email_verified(&mut self) {
if self.email_verified_at.is_none() {
self.email_verified_at = Some(Utc::now());
self.updated_at = Utc::now();
}
}
}
Trigger points (one call per flow):
magic_link_invite_service::redeem— after successful token consumption (clicking the link IS the proof). Set on every magic-link redemption regardless of whether it's an invitation or a login-via-email.auth_application_service::oidc_callbackJIT-create branch — when claims includeemail_verified=true. The existingemail_verifiedcheck (around line 1666) already enforces this for OIDC; we just persist the timestamp.auth_application_service::oidc_callbackexisting-user branch — if the IdP STILL says verified AND our column is NULL (e.g. user predates this PR), upgrade them retroactively.
Trigger points that do NOT set it (worth being explicit):
- Classic password registration via
/api/auth/registerwith both email + password — the user gave us an email but hasn't proven it works. - Admin-create-user via the admin panel — admin asserts the email; user hasn't.
- The email-only signup welcome path: the FIRST magic-link redemption stamps the flag (PR 23 hook). So between "user signed up email-only" and "user clicked welcome link", they're unverified.
API surface:
UserDto.email_verified_at: Option<DateTime<Utc>>(with#[serde(skip_serializing_if = "Option::is_none")]to match the existing optional fields).GET /api/auth/meandGET /api/users/{id}carry it through transparently. No new endpoints in PR 23.
Hurl coverage:
- After bob redeems his invitation:
GET /api/users/{bob_user_id}returnsemail_verified_atset. - After charlie (classic password registration):
GET /api/auth/mereturns noemail_verified_at(omitted from JSON). - After charlie later runs through magic-link (lenient mode): flag flips to non-NULL.
What PR 23 does NOT do (deferred):
- The env switch
OXICLOUD_REQUIRE_EMAIL_VERIFICATIONthat gates uploads/shares/etc. — that's a future feature PR. PR 23 just establishes the signal. - UI affordances ("verify your email" banner, resend button) — future frontend PR.
- Per-feature thresholds (e.g. "verified users can share publicly, unverified can only share with internal users") — future policy PRs.
PR 22 — Device-bound magic-link redemption (design recap)
Threat closed: today a magic-link URL is a bearer token — anyone who reads the recipient's email can redeem it. PR 22 binds the login-via-email path to the originating browser so mailbox compromise alone no longer grants a session.
Asymmetric scope — binding applies to login-via-email only, not invitations:
| Flow | Initiator | Bound to browser? | TTL |
|---|---|---|---|
POST /api/auth/magic-link/send (login-via-email) |
The user themselves, in a browser | Yes (challenge cookie) | 10 minutes |
POST /api/grants with subject.type=email (invitation) |
A sharer, recipient has no prior browser context | No (inherently cross-device) | 24 hours (existing) |
Mechanism (cookie-only, no UX overhaul):
POST /api/auth/magic-link/sendmints the token AND setsoxicloud_magic_request=<random>cookie on the requesting browser (HttpOnly, SameSite=Strict, TTL matches token TTL). The cookie value is mirrored onto a newauth.magic_link_tokens.request_challengecolumn.GET /magic/v1/{token}for login-bound tokens checks the cookie:- Cookie present and matches → redeem instantly (common case; zero UX change).
- Cookie absent or mismatched → show a small confirmation page: "You opened this link in a different browser than you requested it from. If you trust this device, click Continue to sign in." On click → redeem and audit-log
auth.magic_link_redeem reason="cross_browser_confirmed".
- Invitation tokens (
resource_id IS NOT NULL) bypass the check — they have norequest_challengeto compare against and are cross-device by design.
Config additions (replacing single OXICLOUD_MAGIC_LINK_TTL_HOURS):
OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES— default 10. Applies to tokens minted bysend_login_link(no resource target, browser-bound).OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS— default 24. Applies to tokens minted byissue_invitation(resource target, recipient is the audience).- The old
OXICLOUD_MAGIC_LINK_TTL_HOURSbecomes a deprecated alias for_INVITE_TTL_HOURSto avoid breaking existing deployments.
Schema migration:
ALTER TABLE auth.magic_link_tokens
ADD COLUMN request_challenge TEXT NULL;
COMMENT ON COLUMN auth.magic_link_tokens.request_challenge IS
'Random per-request value mirrored into the oxicloud_magic_request cookie. NULL for invitation tokens (cross-device by design); set for login-via-email tokens (browser-bound).';
Hurl coverage:
- Login-via-email: send → capture cookie + mail → redeem with cookie → 302 + session. Same flow without cookie → confirmation HTML page; submit the confirmation form → 302 + session +
cross_browser_confirmedaudit entry. - Invitations: send via grants → recipient (different browser, no cookie) → 302 + session (no challenge requested).
- Login-bound token at 11 minutes → expired; invitation token at 11 minutes → still valid.
Why this slots in here: hardens the most attack-prone surface in the auth model. PR 21's auth-model.md should describe the bound + asymmetric-TTL model from the start — so PR 22 either lands before PR 21, or PR 21's doc explicitly flags the upcoming work.
Locked-in design decisions (confirmed before PR 16)
- Single
User::newconstructor. Signature:User::new(email, password_hash: Option<String>, oidc_subject: Option<String>, is_external: bool, …). The three named factories (new_password/new_oidc/new_external) collapse into call-site helpers if needed, but the canonical API is one constructor. OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERSis symmetric. Whentrue, both/api/auth/magic-link/sendAND/api/grantsemail-invitations mail through to password users. Singlemagic_link_eligibility()predicate, same audit reasonhas_passwordin both code paths.- Email-only signup welcome redemption lands on
/#/files. Redemption logic adjustment: NULLresource_id+is_external = false→/#/files; NULL +is_external = true→/#/sharedwithme(existing rule). One extra branch inmagic_link_handler::redeem. LoginDto.usernamefield name kept with docstring updated to "accepts email or username". Frontend already sends the input asusernameregardless of what the user typed. Mentioned inauth-model.mdas an intentional ambiguity in the API shape.
Recommended future event triggers (DON'T ship in this work)
Same convention as previous plans — a future event ships only when there's a concrete consumer.
| Future event | What would force it |
|---|---|
on_password_set |
When a user first sets a password — useful for audit ("alice is no longer magic-link-eligible" or "alice is now lenient-mode-eligible") and for invalidating any outstanding magic-link tokens she has. Today the new tokens are simply unused; reaping them via this event would be cleaner. |
on_oidc_linked |
When a user first links OIDC — useful for the same audit purpose, and especially load-bearing because OIDC linkage permanently disables magic-link. |
on_username_claimed |
When a NULL-username user picks one. Audit visibility for the share-modal autocomplete suddenly showing a new entry. |
on_credential_revoked |
When a user removes their password or unlinks OIDC. Reverses the eligibility decision. |
These are doc-only; their absence doesn't block anything.
Doc skeleton — auth-model.md
The PR 21 deliverable. Headings only:
- Why this page
- Identity model (email, username, credentials)
- Credential slots and the eligibility derivation
- Login paths
- Username + password
- Email + password
- Email + magic-link (with the 3-branch table)
- OIDC redirect
- Login dispatcher — how the input is interpreted (the
@-in-input rule) - Registration paths
- Email + password
- Email-only
- OIDC JIT
- Email invitation
- Anti-enumeration — what each endpoint returns
- Security trade-offs
- Mailbox-as-bypass when
OPEN_TO_PASSWORD_USERS=true - Why OIDC is unconditionally excluded
- No native 2FA today; how OIDC delegation provides MFA via IdPs
- Rate-limit caps
- Mailbox-as-bypass when
- Audit events — table
- Migration path for existing instances
- Future direction — per-user
login_strategy(sketch with the 7-row matrix) - What is deliberately out of scope
- Related documents (cross-refs)
Target: ~250 lines, big-picture, no code walkthroughs (in line with magic-link-auth.md).
Status: ready to start at PR 16 on confirmation. No code written yet. No tasks created in the TaskCreate system — those happen per-PR when implementation begins.