---
phase: 08-social-sign-in
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - backend/migrations/0006_social_identities.sql
  - backend/internal/db/queries/users.sql
  - backend/internal/db/queries/user_identities.sql
  - backend/internal/db/sqlc/
  - backend/internal/auth/types.go
  - backend/internal/auth/session.go
  - backend/internal/web/handlers_auth.go
  - backend/internal/web/handlers_auth_test.go
autonomous: true
requirements_addressed: [AUTH-09, AUTH-11, AUTH-12, AUTH-13]
requirements:
  - AUTH-09
  - AUTH-11
  - AUTH-12
  - AUTH-13

must_haves:
  truths:
    - "D-01: Verified provider email matching an existing Xtablo user automatically links the provider identity to that user."
    - "D-02: Missing or unverified provider email is rejected before account creation or linking."
    - "D-03: Once a provider subject is linked, provider identity wins over later email changes."
    - "D-05: users.password_hash is nullable and email/password login only applies when a password hash is present."
    - "D-06: Social-only users can exist without a password; add-password stays deferred."
    - "D-07: Email/password signup for an email owned by a social-only user is blocked with the social-only conflict message."
    - "D-08: Existing password users linked to a provider keep password login enabled."
    - "D-13: Provider display name and avatar URL are stored when supplied."
    - "D-14: Apple name is persisted immediately when present on the first callback payload."
    - "D-15: Apple private relay emails are accepted when Apple marks them verified."
    - "D-16: Provider email changes update user_identities.email while provider subject remains the durable key."
    - "D-17: Provider email changes update users.email when possible, with explicit unique-email conflict handling."
    - "AUTH-12: Social sign-in uses the same auth.Store/Create and SetSessionCookie path as email/password login."
    - "AUTH-13: Existing signup, login, logout, CSRF, and rate limiting continue to work."
  artifacts:
    - path: "backend/migrations/0006_social_identities.sql"
      provides: "nullable password_hash and user_identities schema"
    - path: "backend/internal/db/queries/user_identities.sql"
      provides: "identity lookup, insert, profile update, and linked-provider status queries"
    - path: "backend/internal/web/handlers_auth.go"
      provides: "email/password auth behavior compatible with nullable password_hash"
---

## Phase Goal

Create the local account and identity foundation that Google and Apple callbacks can use safely. This plan does not contact real providers; it establishes schema, generated queries, nullable password handling, and account-linking semantics that later vertical slices call.

<objective>
Implement the Phase 8 data model and local account-linking rules:

1. Make `users.password_hash` nullable for social-only users.
2. Add `user_identities` with unique `(provider, provider_subject)`, verified email, display name, avatar URL, and timestamps.
3. Add sqlc queries for identity lookup, insert, profile/email updates, and provider status.
4. Update auth/session user mapping so nullable password hashes do not panic or make social-only users password-login capable.
5. Update signup/login behavior for social-only conflicts and nullable password hashes.
6. Add tests for D-01, D-02, D-03, D-05, D-06, D-07, D-08, D-13, D-14, D-15, D-16, D-17.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.codex/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.codex/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/08-social-sign-in/08-PATTERNS.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add nullable-password and user_identities schema plus sqlc queries</name>
  <files>
    backend/migrations/0006_social_identities.sql
    backend/internal/db/queries/users.sql
    backend/internal/db/queries/user_identities.sql
    backend/internal/db/sqlc/
  </files>
  <read_first>
    - backend/migrations/0002_auth.sql
    - backend/migrations/0004_tasks.sql
    - backend/internal/db/queries/users.sql
    - backend/internal/db/queries/sessions.sql
    - backend/sqlc.yaml
    - backend/go.mod
  </read_first>
  <action>
    Add a goose migration `0006_social_identities.sql`. In `Up`, run `ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL`, create `user_identities` with `id uuid primary key default gen_random_uuid()`, `user_id uuid not null references users(id) on delete cascade`, `provider text not null check (provider in ('google','apple'))`, `provider_subject text not null`, `email citext not null`, `email_verified boolean not null default true`, nullable `display_name`, nullable `avatar_url`, `created_at`, `updated_at`, and nullable `last_login_at`. Add `unique(provider, provider_subject)` and an index on `user_id`. In `Down`, drop `user_identities` first and restore `users.password_hash SET NOT NULL` only after replacing NULLs with a sentinel impossible hash or blocking downgrade with an explicit SQL comment; do not silently make social-only rows password-login capable.

    Add `user_identities.sql` queries: get by provider+subject, list by user id, insert identity, update identity login/profile metadata, and update identity email. Update `users.sql` with queries needed for social users: insert user with nullable password hash, get by email including nullable password hash, update user email by id, and detect social-only conflict.

    Run sqlc generation using the repo's established command. Update generated files only through sqlc, not hand edits.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/db/... -count=1</automated>
  </verify>
  <acceptance_criteria>
    - `backend/migrations/0006_social_identities.sql` contains `ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL`.
    - `backend/migrations/0006_social_identities.sql` contains `UNIQUE (provider, provider_subject)`.
    - `backend/internal/db/queries/user_identities.sql` contains named queries for provider-subject lookup, insert, update, and list-by-user behavior.
    - Generated sqlc code compiles under `go test ./internal/db/... -count=1`.
  </acceptance_criteria>
  <done>
    Schema and generated query code support nullable password users and provider identities without manual generated-code edits.
  </done>
</task>

<task type="auto">
  <name>Task 2: Adapt local auth to nullable password hashes and social-only conflicts</name>
  <files>
    backend/internal/auth/types.go
    backend/internal/auth/session.go
    backend/internal/web/handlers_auth.go
    backend/internal/web/handlers_auth_test.go
  </files>
  <read_first>
    - backend/internal/auth/types.go
    - backend/internal/auth/session.go
    - backend/internal/auth/password.go
    - backend/internal/web/handlers_auth.go
    - backend/internal/web/handlers_auth_test.go
    - backend/templates/auth_forms.go
    - backend/templates/auth_signup.templ
  </read_first>
  <action>
    Update `auth.User.PasswordHash` and session-row mapping to represent nullable password hashes explicitly. Email/password login must return the existing generic `Invalid email or password` error when `password_hash` is NULL. Email/password signup must check for an existing user by email before insert; if the existing user has NULL password hash, render the exact UI copy from `08-UI-SPEC.md`: `An account already exists for this email. Sign in with your provider.` Existing password users keep password login unchanged after a social identity is linked.

    Add tests proving: social-only user cannot log in with email/password; social-only email/password signup is blocked with the social-only copy; existing password user login still succeeds; existing auth CSRF/rate-limit/logout tests still pass.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/auth ./internal/web -run 'Test.*(Login|Signup|Session|SocialOnly)' -count=1</automated>
  </verify>
  <acceptance_criteria>
    - Login with a NULL `password_hash` user returns the same generic invalid-credentials path as a bad password.
    - Signup for a NULL `password_hash` existing user renders `An account already exists for this email. Sign in with your provider.`
    - Existing password signup/login/logout/rate-limit tests still pass.
    - No provider token, auth code, or raw OAuth error is introduced into auth form errors.
  </acceptance_criteria>
  <done>
    Existing local auth works with nullable password hashes and social-only conflict handling.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Risk | Mitigation |
|----------|------|------------|
| Provider claims to local account | Account takeover by unverified email or wrong subject | D-01/D-02/D-03 enforced in transaction tests before callbacks can use the path |
| Nullable local password | Social-only row accidentally accepted by password login | Login requires present password hash and keeps generic error on NULL |
| Email update | Provider email collides with another local user | D-17 conflict path updates identity metadata, does not silently relink or overwrite another account |

## Threat Register

| Threat ID | Severity | Component | Mitigation |
|-----------|----------|-----------|------------|
| T-08-01 | high | `user_identities` linking | Unique `(provider, provider_subject)` and transaction lookup by subject first |
| T-08-02 | high | social-only signup conflict | Block email/password signup for NULL-password existing user |
| T-08-03 | medium | provider profile storage | Store display/avatar only as metadata, never as authorization source |
</threat_model>

<verification>
Run:

```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/db/... ./internal/auth ./internal/web -count=1
```
</verification>

<success_criteria>
1. `users.password_hash` is nullable after migrations.
2. `user_identities` supports Google and Apple subject-based linking.
3. Email/password auth behavior is unchanged for password users and generic for NULL-password users.
4. D-01, D-02, D-03, D-05, D-06, D-07, D-08, D-13, D-14, D-15, D-16, and D-17 are covered by tests or explicit assertions.
</success_criteria>

