Skip to content

feat(users): add external_id seed for JWT auth (Phase 1.d.1-PR1) - #11

Merged
InstaZDLL merged 3 commits into
mainfrom
feat/1-d-1-users-external-id
May 30, 2026
Merged

feat(users): add external_id seed for JWT auth (Phase 1.d.1-PR1)#11
InstaZDLL merged 3 commits into
mainfrom
feat/1-d-1-users-external-id

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented May 30, 2026

Copy link
Copy Markdown
Owner

Summary

First of 3 sub-PRs landing Phase 1.d.1 — JWT auth verification machinery. This PR is the staging step: schema + handler accept an external_id (the upstream auth provider's sub claim), but the JWT middleware that actually consumes it lands in PR2/PR3. The existing dev X-User-Id shim path keeps working untouched.

Why

Better Auth (or any other JWKS-issuing auth provider) hands the server a verified JWT whose sub claim is a stable per-user identifier. The server needs a column on users to resolve that sub back to our internal users.id BIGINT — choosing BIGINT id + TEXT external_id over migrating every FK to TEXT keeps the existing CRUD chain intact. The per-request indirection is a single indexed lookup (the UNIQUE constraint doubles as the index).

Changes

  • Migration 20260530000005_users_external_id.sqlALTER TABLE users ADD COLUMN external_id TEXT UNIQUE. Nullable on purpose so the dev shim keeps minting users without an upstream account during the 1.d transition; 1.d.2 will ALTER COLUMN … SET NOT NULL after a backfill once Better Auth is the only auth path.
  • db::users::create — signature widened to (pool, created_at, external_id: Option<&str>).
  • POST /api/v1/users — accepts CreateUserRequest { external_id: Option<String> }. Bare-bones POST with no body still works (Option<Json<T>> defaults to empty struct). Trims + rejects blank with 400. Postgres unique-violation (SQLSTATE 23505) surfaces as 409 so a duplicate sub gets a distinct error from a transient 500.
  • tests/profiles.rs — 4 new tests:
    • create_user_accepts_external_id — happy path
    • create_user_rejects_blank_external_id — 400 on "", " ", "\t\n "
    • create_user_rejects_duplicate_external_id — 2nd POST with same sub → 409
    • create_user_accepts_explicit_null_external_id — locks in the null = omitted contract
  • tests/ready.rsusers.external_id column canary.

OpenAPI annotations updated with request_body = CreateUserRequest + 400 / 409 responses.

What this is NOT

  • ❌ JWT verification — that's PR2.
  • ❌ Middleware wiring — that's PR3.
  • ❌ Retiring the X-User-Id shim — that's 1.d.2 (once Better Auth is deployed somewhere).

Test plan

  • cargo check --all-targets
  • cargo fmt --all --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --all (CI runs this against a Postgres service container)

Refs: RFC-001 §6.6, follows the boundary-validation + 4xx-discrimination patterns from Phase 1.b.5.

Summary by CodeRabbit

  • New Features

    • Possibilité d’indiquer un identifiant externe lors de la création d’un compte (optionnel).
  • Bug Fixes / Validation

    • Rejet des identifiants externes vides après trim (400) et gestion des doublons (409).
    • Schéma DB accepte NULL pour external_id et ajoute une contrainte interdisant les valeurs vides.
  • Tests

    • Tests ajoutés pour la migration, la contrainte non‑vide, les collisions et la documentation OpenAPI.

Review Change Stack

First sub-PR of Phase 1.d.1 — widens the users table + POST endpoint
to carry the `sub` claim that Better Auth-issued JWTs will reference.
The JWT verification machinery itself lands in PR2; wiring lands in
PR3. This PR is no-ops at runtime (the existing dev shim path keeps
working with NULL external_id) but stages the schema + handler so
the next two PRs are pure additions.

- Migration `20260530000005_users_external_id.sql`: ALTER TABLE
  users ADD COLUMN external_id TEXT UNIQUE. Nullable on purpose so
  the dev shim can keep minting users without an upstream account
  during the 1.d transition; once Better Auth is the only auth path
  (1.d.2) an ALTER COLUMN ... SET NOT NULL after a backfill closes
  the slot. UNIQUE doubles as a free lookup index for the JWT
  middleware's `SELECT id FROM users WHERE external_id = $1`.
- db::users::create signature gains `external_id: Option<&str>` and
  binds it into the INSERT.
- POST /api/v1/users accepts an optional `CreateUserRequest { external_id }`
  body; bare-bones POST with no body still works (Option<Json<T>>
  defaults to empty struct). Trims + rejects blank with 400.
  Postgres unique-violation (SQLSTATE 23505) surfaces as 409 so a
  duplicate sub gets a distinct error from a transient 500.
- tests/profiles.rs: 4 new tests covering external_id accept path,
  blank rejection, duplicate 409, explicit-null no-op. Existing
  "no body" test still passes.
- tests/ready.rs: column existence canary, same belt-and-braces
  pattern as the per-table canaries from 1.b.5.

OpenAPI doc updated with the new request body shape + 400 / 409
responses.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Cette PR ajoute users.external_id (TEXT UNIQUE, nullable) via migration, met à jour db::users::create pour accepter et insérer l'option external_id, étend POST /api/v1/users pour valider/normaliser ce champ et mappe les violations UNIQUE en 409; des tests E2E, OpenAPI et de migration ont été ajoutés.

Changes

Intégration external_id dans les users

Layer / File(s) Summary
Schéma et migration de la table users
migrations/20260530000005_users_external_id.sql
Migration SQL ajoutant users.external_id (TEXT UNIQUE, nullable) et une contrainte CHECK empêchant les valeurs blank après regexp_replace(..., '\s', '', 'g'), avec commentaire de migration.
Couche DB — signature et insertion
src/db.rs
db::users::create prend désormais external_id: Option<&str> et l'insère dans la requête SQL (RETURNING id).
Endpoint et validation API
src/api/users.rs
POST /api/v1/users accepte un corps optionnel CreateUserRequest { external_id: Option<String> }, normalise external_id (trim), rejette les blank (400), mappe SQLSTATE 23505 → 409, et appelle la couche DB avec la valeur normalisée.
Tests end-to-end et migration canary
tests/profiles.rs, tests/ready.rs, tests/openapi.rs
Tests E2E pour acceptation d'un external_id valide, rejet des whitespace-only, collision UNIQUE (409), équivalence null ≈ omission; canary vérifiant la colonne post-migration et la contrainte CHECK; test OpenAPI vérifiant que le requestBody n'est pas requis.

Sequence Diagram

sequenceDiagram
  participant Client
  participant CreateUserAPI
  participant Validation
  participant DB

  Client->>CreateUserAPI: POST /api/v1/users { external_id }
  CreateUserAPI->>Validation: trim/extract external_id
  alt external_id blank après trim
    Validation-->>Client: 400 Bad Request
  else external_id valide ou null
    CreateUserAPI->>DB: db::users::create(created_at, external_id)
    alt constraint UNIQUE violation (SQLSTATE 23505)
      DB-->>CreateUserAPI: SQLSTATE 23505
      CreateUserAPI-->>Client: 409 Conflict
    else succès
      DB-->>CreateUserAPI: returning user id
      CreateUserAPI-->>Client: 201 Created { id }
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • InstaZDLL/waveflow-server#6: Base initiale du endpoint POST /api/v1/users et de db::users::create que cette PR étend avec external_id.

Poem

✨ Une colonne naît pour lier le sub,
Trim vigile, check chasse le vide abrupt.
Migration, DB, API en cadence,
Tests qui verrouillent la danse.
Bientôt le backfill scellera la transe.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Le titre décrit précisément le changement principal : ajout d'un champ external_id pour supporter l'authentification JWT, phase 1 incluse.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1-d-1-users-external-id

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

@InstaZDLL InstaZDLL self-assigned this May 30, 2026

@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: 2

🤖 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 `@migrations/20260530000005_users_external_id.sql`:
- Around line 20-21: La colonne external_id ajoutée à la table users doit
interdire les valeurs vides/blanches au niveau de la BD ; modifie la migration
qui contient ALTER TABLE users ADD COLUMN external_id TEXT UNIQUE pour ajouter
une contrainte CHECK (par exemple nommée users_external_id_non_blank) qui
autorise NULL mais refuse les chaînes vides ou constituées uniquement d’espaces
(p.ex. en testant trim(external_id) <> '' ou équivalent), afin de verrouiller
l’invariant côté base et empêcher l’écriture d’external_id non exploitables.

In `@src/api/users.rs`:
- Line 57: L’annotation utoipa pour le handler create_user est incorrecte : le
handler accepte body: Option<Json<CreateUserRequest>> mais l’attribut currently
indique request_body = CreateUserRequest (corp requis). Mettez l’annotation en
accord avec le type optionnel en remplaçant request_body = CreateUserRequest par
une forme optionnelle (par ex. request_body = Option<CreateUserRequest> ou en
ajoutant required = false selon le style utilisé dans vos autres
#[utoipa::path(...)]), afin que la doc reflète correctement
Option<Json<CreateUserRequest>>; modifiez l’attribut #[utoipa::path(...
request_body = ...)] situé sur la fonction create_user et gardez le nom de type
CreateUserRequest.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1c7273c1-b239-4c83-baa5-3dec24cbe105

📥 Commits

Reviewing files that changed from the base of the PR and between c66478e and 948603e.

📒 Files selected for processing (5)
  • migrations/20260530000005_users_external_id.sql
  • src/api/users.rs
  • src/db.rs
  • tests/profiles.rs
  • tests/ready.rs

Comment thread migrations/20260530000005_users_external_id.sql Outdated
Comment thread src/api/users.rs Outdated
- Migration: add `CHECK (external_id IS NULL OR length(trim(external_id))
  > 0)` constraint named `users_external_id_non_blank`. Locks the
  boundary invariant at the storage layer — defense in depth on top
  of the handler's trim+reject. A future code path that bypasses the
  endpoint (manual SQL, internal job, backfill script) can't sit
  blank rows the JWT middleware could never match. NULL stays
  allowed for the dev shim path. Same pattern as
  `track.rating BETWEEN 0 AND 255` in 20260530000003_track.sql.
  Migration hasn't merged yet, so in-place edit doesn't violate
  the "immutable once merged" rule.
- OpenAPI: wrap as `request_body = Option<CreateUserRequest>` so
  utoipa emits `requestBody.required: false`. The bare `request_body
  = CreateUserRequest` shorthand would generate `required: true`
  and misrepresent the contract (handler extractor is
  `Option<Json<CreateUserRequest>>`). utoipa-axum 0.2 doesn't accept
  an explicit `required = false` attribute on `request_body(...)`
  — only `content`, `description`, `content_type`, `example`,
  `examples`, `extensions` — so the type-level Option<…> wrap is
  the only knob for optionality.
- tests/ready.rs: `users_external_id_check_rejects_blank` probe
  exercises a direct INSERT to prove the CHECK fires on `''` /
  whitespace and stays out of the way for NULL. SQLSTATE 23514
  (check_violation) verified explicitly so a future regression
  mapping the error to a different status is caught.
- tests/openapi.rs: assert the POST /api/v1/users requestBody is
  NOT `required: true`. Locks the contract so a future refactor
  dropping the `Option<…>` wrapper trips here instead of in prod.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

@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: 2

🤖 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 `@migrations/20260530000005_users_external_id.sql`:
- Around line 21-23: La contrainte users_external_id_non_blank laisse passer des
chaînes composées uniquement de tabulations/sauts de ligne car trim n’enlève que
les espaces ; remplace le CHECK actuel par un test qui retire tous les
caractères d’espacement via regexp_replace, par exemple: CHECK (external_id IS
NULL OR length(regexp_replace(external_id, '\s', '', 'g')) > 0), afin que values
comme "\t\n " soient considérés vides et déclenchent l'erreur attendue (SQLSTATE
23514).

In `@tests/openapi.rs`:
- Around line 66-84: Add an explicit check that the OpenAPI `paths` contains the
"/api/v1/users" path before indexing into it: ensure
`paths.get("/api/v1/users")` (or equivalent) is asserted like the existing
checks for "/health" and "/ready" so the test fails with a clear message if the
path is removed; then proceed to access `users_post` and the `requestBody` as
currently done (symbols to locate: the `paths` variable, the `users_post`
binding and the `request_body` retrieval in tests/openapi.rs).
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e6528bff-8ac0-4430-9062-7c6759b73294

📥 Commits

Reviewing files that changed from the base of the PR and between 948603e and 55923f9.

📒 Files selected for processing (4)
  • migrations/20260530000005_users_external_id.sql
  • src/api/users.rs
  • tests/openapi.rs
  • tests/ready.rs

Comment thread migrations/20260530000005_users_external_id.sql Outdated
Comment thread tests/openapi.rs
CI caught a latent bug in the previous CHECK: Postgres' bare trim(…)
only strips ASCII space by default, so '\t\n ' (tab + newline +
space) survived the predicate and the INSERT slipped through.
Rust's str::trim() strips every Unicode whitespace char by default
— the handler-side rejection still worked, but the storage canary
the constraint was supposed to be didn't fire.

Migration (still on this PR's branch — in-place edit OK per the
"immutable once merged" rule):
- Swap `length(trim(external_id)) > 0` for
  `length(regexp_replace(external_id, '\s', '', 'g')) > 0`. The
  '\s' character class strips space / tab / newline / CR / form-feed /
  vertical-tab, matching Rust's str::trim() definition so the
  storage CHECK and the handler stay in lockstep.
- NULL passing path is unchanged (explicit `IS NULL` branch).

tests/openapi.rs:
- Explicit `paths.contains_key("/api/v1/users")` assertion before
  indexing, matching the existing /health and /ready style. A
  future refactor that drops the path now fails with a clear
  "missing /api/v1/users in spec" message instead of an opaque
  panic on the index lookup.

users_external_id_check_rejects_blank now passes for all three
inputs (`""`, `"   "`, `"\t\n "`) — verified locally:
- cargo check --all-targets
- cargo clippy --all-targets -- -D warnings
- cargo fmt --all --check

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

@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: 1

🤖 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 `@migrations/20260530000005_users_external_id.sql`:
- Around line 23-26: The CHECK constraint using regexp_replace(external_id,
'\s', '', 'g') will work under default standard_conforming_strings, but to make
the escape explicit and avoid dependence on server string settings update the
CHECK to use a double-escaped backslash in the regex
(regexp_replace(external_id, '\\s', '', 'g')) so the regex engine reliably
receives \s; modify the CHECK around external_id accordingly.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e75ee327-29ec-415e-88ab-d93cb5218b25

📥 Commits

Reviewing files that changed from the base of the PR and between 55923f9 and 8440175.

📒 Files selected for processing (2)
  • migrations/20260530000005_users_external_id.sql
  • tests/openapi.rs

Comment thread migrations/20260530000005_users_external_id.sql
@InstaZDLL
InstaZDLL merged commit cc725a1 into main May 30, 2026
8 checks passed
@InstaZDLL
InstaZDLL deleted the feat/1-d-1-users-external-id branch May 30, 2026 17:24
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