diff --git a/EMAIL_PASSWORD_DESIGN.md b/EMAIL_PASSWORD_DESIGN.md new file mode 100644 index 00000000..0e1fd007 --- /dev/null +++ b/EMAIL_PASSWORD_DESIGN.md @@ -0,0 +1,495 @@ +# Design sketch: optional email/password auth (no automated email) + +**Status:** design direction agreed on the core decisions; two sub-calls remain +(see "Open decisions"). Not yet an implementation. +**Scope:** add an *optional*, self-host-oriented email + password login method +alongside the existing GitHub/Google OAuth, **without sending any email**. + +**Decisions locked in so far:** + +- **D1 — `email_verified` semantics:** keep its current meaning ("an identity + provider vouched for this address"); do **not** corrupt it. Go with **Option A** + (credential accounts stay `email_verified = false`; only the *login* gate learns + to trust a credential session). +- **D4 — hosted policy:** memory.build stays **OAuth-only**. The feature is + self-host-only, achieved purely by leaving the flag off in the hosted deploy + config — no enforcement code. +- **D2 / D3 direction:** closed (admin-created) registration *and* password reset + both require a **server-operator surface**, not an in-app RPC — because the + product has no global/server admin and space-admin is the wrong authority for + account-level actions (see "The authority problem" below). Whether that surface + lands in v1 or as an immediate fast-follow is still open. + +## Goals & non-goals + +**Goals** + +- Let a self-hoster enable username(email) + password login with a single + config flag, with **zero email infrastructure required**. +- Keep the CLI working unchanged. +- Keep the hosted deployment (memory.build) exactly as it is today (flag off). +- Leave a clean seam so a future "optional SMTP" upgrade (real verification + + self-service reset) is additive, not a rewrite. + +**Non-goals (this pass)** + +- Sending verification or password-reset email. +- Magic-link email login. +- Changing the OAuth flows. + +## Guiding principle: reuse the "admin delivers the secret" model + +The product already has **no automated email anywhere**. Invitations (both +email-constrained and open "magic links") mint a token that the **admin shares +out-of-band** — the server never emails anyone (`me space invite` prints +`Share this link: `; the token is re-readable via `invite.list`). A +no-email email/password design is therefore *consistent* with the existing +model, not a workaround. Password reset follows the same pattern — a +server-minted token handed over out-of-band — though minted by the **deployment +operator**, not a space admin (see "The authority problem" for why account-level +actions can't be space-admin-gated). + +--- + +## What exists today (the two blockers) + +Human login funnels through better-auth → a web session. The CLI's `me login` +is OAuth 2.1 auth-code + PKCE + loopback, but the actual *authentication* happens +**in the browser** on the `/login` page (`LoginPage` → `SignInCard`). So adding a +password method to that page makes the CLI work with **no CLI changes**. + +Two concrete things block email/password today: + +1. **DB CHECK constraint.** `auth.accounts.provider_id` is constrained to + `in ('google','github')` (`incremental/002_accounts.sql:11`). Better-auth's + email/password writes an account row with `provider_id = 'credential'` and the + hashed password in the existing (currently unused) `accounts.password` column. + The constraint must be relaxed to allow `'credential'`. + +2. **The verified-email login gate.** `betterauth.ts` has a + `session.create.before` hook that throws `EMAIL_NOT_VERIFIED` unless + `users.email_verified` is true (`betterauth.ts:130`). Social providers assert a + verified email; a password sign-up defaults to `email_verified = false`, so + **without handling, every password login is rejected**. Invitation discovery + (`invite.pending`/`accept`) is also gated on `emailVerified`. + +The design below addresses both. + +--- + +## Configuration + +A single feature flag, env-var driven (matches the existing +`GITHUB_CLIENT_ID`/… style read in `packages/server/start.ts`): + +| Env var | Default | Meaning | +|---|---|---| +| `AUTH_EMAIL_PASSWORD` | `false` | Master switch for email/password login. | +| `AUTH_EMAIL_PASSWORD_SIGNUP` | `true` when enabled | Allow open self-service sign-up. `false` = closed registration; accounts are created only via the server-operator surface (see below). | +| `AUTH_EMAIL_PASSWORD_MIN_LENGTH` | `8` | Minimum password length (passthrough to better-auth). | + +Notes: + +- Flag **off** ⇒ the server behaves exactly as today. No new UI, no new routes, + `provider_id='credential'` never occurs. **This is exactly how hosted + (memory.build) stays OAuth-only (D4): the hosted deploy config simply never + sets `AUTH_EMAIL_PASSWORD=true`.** No enforcement code — config discipline, + consistent with how the OAuth provider vars already work. +- `AUTH_EMAIL_PASSWORD_SIGNUP` (the `disableSignUp` toggle) is an option *under* + better-auth's `emailAndPassword` block, which only exists when the master flag + is on. So it is inherently self-host-only and **inert on hosted** — no + interaction with the OAuth-only hosted deployment. +- The existing startup warning ("No OAuth providers configured") is relaxed: + having *either* a social provider *or* email/password enabled satisfies it; + the warning fires only when **no** login method is configured. +- Both social and email/password may be enabled at once — better-auth is happy + to expose both. + +--- + +## Server changes + +### 1. better-auth config (`packages/server/auth/betterauth.ts`) + +Add the (built-in, no plugin) `emailAndPassword` block, gated on the flag, and +plumb the option through `BetterAuthOptions` + `createBetterAuth` in +`start.ts`. + +```ts +emailAndPassword: opts.emailPassword?.enabled + ? { + enabled: true, + minPasswordLength: opts.emailPassword.minPasswordLength ?? 8, + // Open registration toggle. When false, sign-up is refused; accounts are + // provisioned only via the server-operator surface (see "The authority + // problem"). Self-host-only; inert on hosted (block absent when flag off). + disableSignUp: !opts.emailPassword.allowSignUp, + // No email sender is configured, so verification cannot be required. + // Credential accounts stay email_verified=false (Option A); the login gate + // trusts them instead of the row's flag. + requireEmailVerification: false, + // We do NOT wire sendResetPassword — there is no email sender. Reset is an + // operator-minted out-of-band link instead (see "Password reset" below). + autoSignIn: true, + } + : undefined, +``` + +### 2. The `email_verified` question (the crux) — DECIDED: Option A + +**Decision:** keep `email_verified` meaning exactly what it means today — "an +identity provider (GitHub/Google) vouched that this person controls this +address." We do **not** corrupt it for credential accounts. + +In the no-email design a credential address is **unproven** (no one clicked a +link), so the honest stored value is `false`. But `false` trips the +`session.create.before` login gate (`betterauth.ts:130`). So the gate — and only +the gate — learns to treat a credential-backed session as allowed: + +Expressed as a small helper the hook calls, e.g. `isLoginAllowed(user, accountProviderId)`: + +``` +allow if provider is credential // no-email: trust it at login +allow if user.email_verified // social, verified +else block EMAIL_NOT_VERIFIED // social, unverified +``` + +Since GitHub/Google only release verified emails, the social branch is +effectively unchanged; credential logins are admitted **without** flipping +`email_verified`. This keeps the flag semantically clean and makes a future SMTP +upgrade a pure addition (require a verification click → set `email_verified = +true` for credential accounts too — no data migration, no second column). + +**Rejected — Option B (`email_verified = true` on sign-up):** trivial (no gate +change), but it makes `email_verified` mean two different things and, worse, lets +a self-registered unproven address satisfy email-keyed authorization (invitation +discovery — see below). Explicitly not chosen. + +Implementation note: the hook receives the session; telling a credential session +from a social one needs a small `accounts` lookup for `provider_id='credential'` +(login is infrequent, so the cost is negligible). To verify against the exact +better-auth `databaseHooks.session.create.before` signature at build time. + +### 3. Invitation implications + +Invitation **discovery** (`invite.pending` / `invite.accept`) is gated on +`emailVerified` (`packages/server/rpc/user/invitation.ts:40`). Under Option A, +credential accounts have `emailVerified = false`, so: + +- They **cannot** use the "log in and see my pending invites" discovery path. +- They **can** join via a shared **open link** (`invite.redeem`, email ignored) — + which is exactly the "admin hands a link to a specific person out-of-band" + model. This is the intended onboarding path for credential users. +- They **cannot** redeem an *email-constrained* link (redeem passes + `emailVerified ? email : null`, so the SQL email check fails). + +This is a **deliberate, safe consequence** of Option A, not a bug: since a +credential email is unproven, honoring an email-addressed invitation for it would +let anyone who self-registers `alice@corp.com` claim invitations meant for the +real Alice. Open links avoid that because the token itself is the secret the +admin delivered to the right person. + +**Remaining sub-decision (open):** whether to let credential users redeem an +*email-constrained* **link** (the token path, not discovery). That path already +requires the token — a real secret shared with the intended person — so trusting +an unverified credential email *there* is low-risk. Options: (a) **open-links +only** for credential users (simplest, recommended for v1), or (b) also honor +the credential user's own (unverified) email for email-constrained *redeem* while +keeping *discovery* verified-only. Note the danger lives only in **discovery** +(no token), which stays verified-only either way. + +### 4. Closed registration & account provisioning — see "The authority problem" + +When `AUTH_EMAIL_PASSWORD_SIGNUP=false`, accounts can't be self-created, so they +must be provisioned some other way. Critically, **this is not an in-app +space-admin action** — see the dedicated section below for why, and for the +server-operator surface that owns both account creation and password reset. + +--- + +## The authority problem (drives D2 + D3) + +Both "admin-created accounts" (D2, closed registration) and "password reset" (D3) +need an authority to perform them. **The product has no such authority today**, +and this reshapes both decisions. + +**There is no global / server / instance admin.** Verified across the codebase: +no `superuser` / `global-admin` / `is_superuser` concept exists. Every "admin" is +**space-scoped** — `principal_space.admin`, structural authority over *one +space's* roster. First-login provisioning (`provision.ts`) makes no user special; +`core.createUser` is an internal store call, not an authorized RPC. + +**Space-admin is the *wrong* authority for these actions** — using it would be a +privilege-escalation hole: + +- Account creation and password reset are **account-level, cross-space** + operations. +- A space admin who could reset a shared member's password would take over that + user's **entire account** — including all of that user's *other* spaces (api + keys / PATs are global per-principal, working in any space the principal + belongs to). A space admin must never be able to do that. + +**Conclusion: these are server-operator actions.** The authority is "whoever +controls the deployment" — the same person who holds `BETTER_AUTH_SECRET`, runs +migrations, and can `docker compose exec`. Not an RPC gated by an app role. + +Two consequences for the implementation: + +1. **It must run inside the server process.** Account creation / reset-token + minting call `betterAuth.api.*` (e.g. `signUpEmail`, password/reset APIs), + which live on the server's better-auth instance + auth pool. The `me` CLI is a + separate *client* process and cannot call them directly — so this is a + **server-side** subcommand or route, not a `me` CLI command. +2. **No global-admin role is invented.** We deliberately avoid adding a new authz + axis or the better-auth `admin` plugin (see rejected options below). + +### Operator surface — shape options + +- **Server-side subcommand (recommended).** A command shipped with the server + image, run by the operator in the container, e.g. + `docker compose exec server me-admin create-user ` and + `… reset-link `. Calls `betterAuth.api.*` in-process. No network authz — + authority is shell/exec access to the deployment. Fits the Docker Compose + self-host story in `SELF_HOST.md`. **Zero hosted footprint.** +- **Localhost/secret-gated admin HTTP route.** An alternative if a subcommand is + awkward; more surface area (a route + a shared operator secret) and easier to + misconfigure. Not preferred. + +### Rejected: the better-auth `admin` plugin + +better-auth ships an `admin` plugin (`role='admin'` on users, plus +`createUser` / `listUsers` / `setPassword` / `ban` endpoints). Rejected because +it adds `role`/`banned` columns to `auth.users` and an admin surface that would +exist for **all** deployments **including hosted** (shared auth schema + shared +better-auth config), plus a "who is the first admin?" bootstrapping problem. That +violates D4's "self-host-only, no hosted footprint." A new global-admin concept +in `core` is even heavier and is likewise rejected. + +--- + +## Database changes + +One incremental migration (e.g. `auth/migrate/incremental/007_credential.sql`), +plus a bump of `AUTH_SCHEMA_VERSION`. + +```sql +-- 007_credential: allow better-auth email/password (provider_id='credential'). +-- The password hash lands in the existing accounts.password column (previously +-- always null under OAuth-only login). +alter table {{schema}}.accounts drop constraint accounts_provider_id_check; +alter table {{schema}}.accounts add constraint accounts_provider_id_check + check (provider_id in ('google', 'github', 'credential')); +``` + +Notes: + +- `accounts.password` already exists (`incremental/002_accounts.sql:19`) — no new + column needed. +- The legacy `upsert_account` / `get_account_by_provider` SQL helpers + (`idempotent/003_account.sql`) are not used by better-auth's Kysely adapter, so + they need no change; the constraint lives on the table. +- The migration-drift test (`auth/migrate/migrate.integration.test.ts`) will need + its expected-shape snapshot updated for the relaxed constraint. +- Migration footgun reminder (per AGENTS.md): this is a plain `alter table`, not a + function signature change, so no `{{fn …}}` wrapper is needed. + +--- + +## Web UI changes + +### 1. Tell the UI the method is enabled + +The server injects `window.__ME_BOOTSTRAP__` into `index.html` +(`packages/server/web/static.ts`). Extend the bootstrap object (currently just +`{ mode: "hosted" }` in `router.ts:132`) with the enabled auth methods: + +```ts +bootstrap: { + mode: "hosted", + auth: { + social: ["github", "google"].filter(configured), + emailPassword: emailPasswordEnabled, + emailPasswordSignup: emailPasswordSignupEnabled, + }, +} +``` + +`packages/web/src/api/bootstrap.ts` reads it into a typed `AUTH_METHODS` +constant. **CSP note:** the bootstrap is a single inline `