Skip to content

feat(db): add the database schema, migrations and auth storage - #3

Merged
stephane-segning merged 1 commit into
masterfrom
claude/dash-auth-authkestra-op
Aug 9, 2026
Merged

feat(db): add the database schema, migrations and auth storage#3
stephane-segning merged 1 commit into
masterfrom
claude/dash-auth-authkestra-op

Conversation

@stephane-segning

Copy link
Copy Markdown
Contributor

Stacked on #2 (kebab-case rename). Review that first; this branch contains its commit.

Summary

vpay had no SQL at alldocs/status.md listed "Database schema + migrations" as the first thing that must be true before this could be called an MVP. This adds it, plus the storage the dashboard OIDC provider and the merchant API keys need.

Nothing here is reachable from a shipping binary. There is still no connection pool, no repository layer and no query in vpay-server or vpay-worker-bin; the router remains /healthz plus a fallback 404, and authkestra-op is a dev-dependency of the integration test crate only. The schema is real and proven. The auth is not built. docs/status.md is written to make that unmissable.

Source of truth: a maintainer decision to adopt Authkestra as the centralized auth engine for /dash/v1, recorded in the new ADR-0009, together with the existing ADR-0008 (OIDC sessions, never an API key), ADR-0003 (a profile selects a config file, never a code path) and ADR-0006. The advisory decision is RUSTSEC-2023-0071.

Intent

schemas/vpay.cstack described the intended shape but is excluded from the build graph and generates nothing. Every invariant the flow docs promised at the database level was in fact enforced nowhere, or only in Rust.

Scope

Core payment schema — 00010005

currencies, providers, payment_intents, charges, ledger_transactions/ledger_entries, mirroring the Rust types field-for-field (enums transcribed variant-for-variant from vpay-core). one_charge_per_intent is a plain unique index, exactly as AGENTS.md describes.

Two cross-column CHECKs that schemas/vpay.cstack could not express — its grammar promotes only single-field validators — are now real: partial_refunds_imply_refunds and no_over_refund. The flow docs were corrected earlier in this effort to say these were Rust-only; raw SQL has no such limitation, so they are corrected back, with one distinction preserved: the CHECK guarantees no committed row is ever over-refunded, but no application-level SELECT … FOR UPDATE exists, because nothing persists refunds yet.

Ledger balancing is deliberately not a constraint. SUM(debit) = SUM(credit) is an aggregate over sibling rows, which a row-level CHECK cannot see. It stays in vpay_ledger::Transaction::validate(). A deferred constraint trigger would have been new, unexercised logic duplicating a tested check — documented as a gap instead of faked.

Auth storage — 00060008

  • 0006 transcribes authkestra-op 0.3.4's SqlxOpStore DDL verbatim. That SQL is hardcoded in the crate — schema name (authkestra.), table names and column types are not configurable — so a transcription that is off by one column type compiles fine and fails at runtime. This file must move in lockstep with the =0.3.4 pin, and says so. oauth_device_codes is created despite the PKCE flow never using the device grant, because SqlxOpStore implements DeviceCodeStore unconditionally.
  • 0007 adds oauth_signing_keys, which vpay must own: authkestra has no signing-key type, store or rotation at any published version (verified by grep across authkestra-op-0.3.4 and authkestra-engine-0.3.4; attestation.rs's own module doc claims otherwise and is stale). Partial unique index one_active_signing_key, plus active_key_has_no_expiry and expiry_after_creation — two CHECKs the only known precedent (~/dev/vsms) lacks, because an accidentally-expired active key locks every staff member out with no fallback IdP.
  • 0008 adds merchant_api_keys for Stripe-shaped sk_live_/sk_test_ keys, deliberately not routed through Authkestra: it has no opaque-key primitive, its verify_secret() is argon2 (correct for low-entropy passwords, wrong for a payment API's hot path), and client_credentials would add a token-exchange round trip that breaks Stripe SDK compatibility. Indexed key_prefix + unique SHA-256 key_digest, livemode, and revoked_at — the instant revocation ADR-0008 says bearer keys lack. Only the digest is stored.

Verification

Against real postgres:16-alpine containers, per CLAUDE.md's "apply it to a real Postgres and prove the constraint fires" — not by reading SQL.

Check Before After
cargo nextest run --workspace 64 passed / 5 ignored 78 passed / 3 ignored
just ci exit 0 exit 0
cargo deny check clean clean

The ignored count fell because two placeholder tests whose bodies were unreachable!() became real implementations. Every CHECK, unique index and FK claimed above has a test that inserts a violating row and asserts db_err.constraint() names that specific constraint — so a test cannot pass by tripping a different one.

The test that matters most is sqlx_op_store_round_trips_a_client_and_enforces_single_use_codes: it drives the real SqlxOpStore against our transcribed DDL — find_client (proving the JSONB columns decode), store_code, consume_code, then a second consume_code asserted None. That, not resemblance, is what proves 0006 correct.

Screenshots / Evidence

Real Postgres rejections, captured from the test run:

duplicate key value violates unique constraint "one_charge_per_intent"
violates check constraint "partial_refunds_imply_refunds"
violates check constraint "no_over_refund"
violates foreign key constraint "oauth_codes_client_id_fkey"
duplicate key value violates unique constraint "one_active_signing_key"
violates check constraint "active_key_has_no_expiry"

Independent transcription check: 30 columns in the crate's migrate() literal, 30 in 0006, none missing.

Risk Assessment

Moderate, and concentrated in three places.

  1. The signing-key PEM is stored unencrypted. Encryption at rest is not implemented. This is private key material for the tokens guarding an admin console that acts on payment records — aes-gcm sits unused in [workspace.dependencies]. Documented in the migration and a COMMENT ON COLUMN; it needs a decision before anything issues a token.
  2. 0006 is coupled to a pinned crate version. SqlxOpStore's DDL is string literals; bumping authkestra-op without re-transcribing produces runtime failures, not compile errors. The round-trip test is the guard.
  3. RUSTSEC-2023-0071 is now ignored. The Marvin Attack timing side-channel in rsa, no patched release, an unconditional dependency of authkestra-engine. Accepted deliberately by the maintainer on 2026-08-09 with scope and revisit conditions recorded inline in deny.toml. It was preemptive when added and now genuinely firescargo deny -L info reports note[advisory-ignored] against rsa v0.9.10. What limits it: the attack targets PKCS#1 v1.5 decryption oracles, vpay's use is JWT signing, and /dash/v1 is staff-only and off the merchant payment path.

Unresolved and inherited from ADR-0009: authkestra-op has no revocation endpoint and no /token rate limiting. Mitigation (short TTLs, deny-list) is available, not decided.

AI Usage Declaration

AI (Claude Opus 5 via Claude Code) performed this work, delegating implementation to parallel Sonnet sub-agents on disjoint file sets. The orchestrating session independently re-ran every gate and verified the load-bearing claims rather than accepting agent reports — including diffing the transcribed DDL against the crate source column by column, running the SqlxOpStore round-trip directly, and confirming the advisory ignore actually matches. Two agent claims were caught and corrected: one reported the local authkestra checkout as ahead of the published crate when it is 0.2.4 and behind, which would have grounded the schema in stale source; and an earlier "Docker unavailable" note in docs/status.md was wrong — Docker works here, only Docker Hub is unreachable.

  • A human is accountable for this change and has reviewed it.
  • Every claim here was verified by running the command, not inferred.
  • Limitations stated explicitly — see Risk Assessment.
  • No functionality was fabricated. Unimplemented paths still return NotImplemented; the 8 declared tokens are unchanged.

Reviewer Focus

  1. 0007's unencrypted PEM — the most consequential open decision here.
  2. 0006 vs. the crate. Diff it against authkestra-op-0.3.4/src/sqlx_store.rs yourself; a wrong column type is invisible until runtime.
  3. 0008's shape — does prefix + SHA-256 digest + revoked_at match how you want sk_ keys issued and rotated?
  4. docs/status.md — is anything ✅ that would not fail a test if it broke? Signing keys and merchant API keys were held at 🟡 precisely because only their storage exists.

@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d24593f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

vpay had no SQL at all — docs/status.md listed the schema as the first
thing that must be true before this can be called an MVP. This adds it,
plus the storage the dashboard OIDC provider (ADR-0009) and the merchant
API keys will need.

Nothing here is reachable from a shipping binary. There is still no
connection pool, no repository layer and no query in vpay-server or
vpay-worker-bin; the router remains /healthz plus a fallback 404, and
authkestra-op is a dev-dependency of the integration test crate only.
The schema is real and proven; the auth is not built.

Core payment schema (0001-0005):

* currencies, providers, payment_intents, charges, ledger, mirroring the
  Rust types field-for-field.
* `one_charge_per_intent` as a plain unique index, exactly as AGENTS.md
  and docs/flows/payment-lifecycle.md describe.
* Two cross-column CHECKs that schemas/vpay.cstack could NOT express
  (its grammar promotes only single-field validators):
  `partial_refunds_imply_refunds` and `no_over_refund`. The flow docs
  were corrected earlier to say these were Rust-only; raw SQL has no
  such limitation, so they are corrected back — with the distinction
  that the CHECK bounds committed rows, not that SELECT ... FOR UPDATE
  exists, because nothing persists refunds yet.
* Ledger balancing is deliberately NOT a constraint: it is an aggregate
  over sibling rows, which a row-level CHECK cannot see. It stays in
  vpay_ledger::Transaction::validate().

Auth storage (0006-0008):

* 0006 transcribes authkestra-op 0.3.4's SqlxOpStore DDL verbatim. That
  SQL is hardcoded in the crate — schema name, table names and column
  types are not configurable — so this file must move in lockstep with
  the `=0.3.4` pin. oauth_device_codes is created despite the PKCE flow
  never using the device grant, because SqlxOpStore implements
  DeviceCodeStore unconditionally.
* 0007 adds oauth_signing_keys, which vpay must own: authkestra has no
  signing-key type, store or rotation at any published version. The
  private key PEM is stored UNENCRYPTED; encryption at rest is not
  implemented and both the migration and a COMMENT ON COLUMN say so.
* 0008 adds merchant_api_keys for Stripe-shaped sk_live_/sk_test_ keys,
  deliberately not routed through Authkestra: it has no opaque-key
  primitive, its verify_secret() is argon2 (wrong for a hot path), and
  client_credentials would add a token exchange that breaks Stripe SDK
  compatibility. Indexed prefix plus unique SHA-256 digest, livemode,
  and revoked_at — the instant revocation ADR-0008 says bearer keys
  lack. Only the digest is stored.

Verification, against real postgres:16-alpine containers rather than by
reading the SQL, per CLAUDE.md:

* 78 passed / 3 skipped, up from 64 / 5. The ignored count fell because
  two placeholder tests with `unreachable!()` bodies became real.
* Every CHECK, unique index and FK claimed above has a test that inserts
  a violating row and asserts `db_err.constraint()` names that specific
  constraint — so a test cannot pass by tripping a different one.
* SqlxOpStore is driven against our transcribed DDL end to end
  (find_client, store_code, consume_code, then a second consume_code
  asserted None). That, not resemblance, is what proves 0006 correct.

deny.toml ignores RUSTSEC-2023-0071 (Marvin Attack in `rsa`): no patched
release exists and it is an unconditional dependency of
authkestra-engine. Accepted deliberately, with the scope and the
revisit conditions recorded inline. It now genuinely fires — confirmed
via `cargo deny -L info`.

`just ci` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stephane-segning
stephane-segning force-pushed the claude/dash-auth-authkestra-op branch from f92f45c to d24593f Compare August 9, 2026 16:21
@stephane-segning
stephane-segning merged commit 879e2cb into master Aug 9, 2026
4 of 6 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