From e832d9905b39dc884db89f1306b95fd3ac6a61f4 Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Sun, 26 Jul 2026 21:10:19 +0300
Subject: [PATCH 01/14] Specify Phase 4: Provider Keys
---
.opencode/commands/speckit.converge.md | 11 +-
.opencode/commands/speckit.specify.md | 4 +-
.opencode/commands/speckit.tasks.md | 2 +-
.specify/feature.json | 2 +-
.specify/integrations/opencode.manifest.json | 2 +-
.specify/integrations/speckit.manifest.json | 10 +-
.specify/memory/constitution.md | 80 +++--
.specify/scripts/bash/check-prerequisites.sh | 6 +-
.specify/scripts/bash/setup-tasks.sh | 4 +-
.specify/templates/checklist-template.md | 4 +-
.specify/templates/plan-template.md | 19 +-
.specify/templates/spec-template.md | 1 +
.specify/templates/tasks-template.md | 12 +-
CLAUDE.md | 9 +-
README.md | 8 +-
docs/implementation-plan.md | 33 +-
.../checklists/requirements.md | 37 ++
.../contracts/provider-keys.md | 268 +++++++++++++++
specs/004-provider-keys/data-model.md | 249 ++++++++++++++
specs/004-provider-keys/plan.md | 117 +++++++
specs/004-provider-keys/quickstart.md | 125 +++++++
specs/004-provider-keys/research.md | 146 ++++++++
specs/004-provider-keys/spec.md | 200 +++++++++++
specs/004-provider-keys/tasks.md | 324 ++++++++++++++++++
24 files changed, 1586 insertions(+), 87 deletions(-)
create mode 100644 specs/004-provider-keys/checklists/requirements.md
create mode 100644 specs/004-provider-keys/contracts/provider-keys.md
create mode 100644 specs/004-provider-keys/data-model.md
create mode 100644 specs/004-provider-keys/plan.md
create mode 100644 specs/004-provider-keys/quickstart.md
create mode 100644 specs/004-provider-keys/research.md
create mode 100644 specs/004-provider-keys/spec.md
create mode 100644 specs/004-provider-keys/tasks.md
diff --git a/.opencode/commands/speckit.converge.md b/.opencode/commands/speckit.converge.md
index 8d882c6..7a7eae2 100644
--- a/.opencode/commands/speckit.converge.md
+++ b/.opencode/commands/speckit.converge.md
@@ -199,19 +199,12 @@ Append to the **end** of `tasks.md`, per the append contract:
(highest existing phase + 1).
2. Write a single new section header `## Phase N: Convergence`.
3. Emit one checklist item per actionable finding, ordered CRITICAL/HIGH first, assigning
- zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`, following the same checkbox/ID/marker
- format as `/speckit.tasks` (`- [ ] [TaskID] [P?] [Story?] Description with file path`):
+ zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`:
```markdown
- - [ ] T042 [P?] [Story?] per ()
+ - [ ] T042 per ()
```
- Include `[P]` only when the task is parallelizable (different file, no dependency on an
- incomplete task). Include `[Story]` (e.g. `[US1]`) only when `` identifies a
- specific user story (e.g. `US1/AC2`); omit it for plan-level or constitution-level
- findings, matching `/speckit.tasks`'s rule that Setup/Foundational/Polish tasks carry no
- story label.
-
`` traces the task to its origin: e.g. `FR-003`, `SC-002`,
`US1/AC2`, `plan: storage decision`, `Constitution II`.
diff --git a/.opencode/commands/speckit.specify.md b/.opencode/commands/speckit.specify.md
index 98e0bea..e5b3663 100644
--- a/.opencode/commands/speckit.specify.md
+++ b/.opencode/commands/speckit.specify.md
@@ -139,9 +139,9 @@ Given that feature description, do this:
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
-7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
+6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
-8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
+7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
diff --git a/.opencode/commands/speckit.tasks.md b/.opencode/commands/speckit.tasks.md
index 087dc8c..5d637d4 100644
--- a/.opencode/commands/speckit.tasks.md
+++ b/.opencode/commands/speckit.tasks.md
@@ -165,7 +165,7 @@ Every task MUST strictly follow this format:
**Examples**:
-- ✅ CORRECT: `- [ ] T001 Create project structure (src/, tests/) per implementation plan`
+- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
diff --git a/.specify/feature.json b/.specify/feature.json
index a503348..388001e 100644
--- a/.specify/feature.json
+++ b/.specify/feature.json
@@ -1,3 +1,3 @@
{
- "feature_directory": "specs/003-brand-crud"
+ "feature_directory": "specs/004-provider-keys"
}
diff --git a/.specify/integrations/opencode.manifest.json b/.specify/integrations/opencode.manifest.json
index afc644f..c6a082e 100644
--- a/.specify/integrations/opencode.manifest.json
+++ b/.specify/integrations/opencode.manifest.json
@@ -1,7 +1,7 @@
{
"integration": "opencode",
"version": "0.12.14",
- "installed_at": "2026-07-19T16:37:19.006397+00:00",
+ "installed_at": "2026-07-26T16:19:25.218431+00:00",
"files": {
".opencode/commands/speckit.analyze.md": "07e1e9f997bce9e06d3823ecf1ed315bb62381b81e7d6a7aef19c5087c320e70",
".opencode/commands/speckit.clarify.md": "595ae8988179a91780ce10e258afcccaa313b0924a79e720c66226e31af7ec00",
diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json
index 9b649cd..24d9156 100644
--- a/.specify/integrations/speckit.manifest.json
+++ b/.specify/integrations/speckit.manifest.json
@@ -5,13 +5,13 @@
"files": {
".specify/scripts/bash/common.sh": "6ff86bf39f6b4684b0f80927dc7a1dadec26b4671988a3fe4d6c2523cbd3aa22",
".specify/scripts/bash/setup-plan.sh": "4469b22960f43c07c33dca00de6dedb252145e9a9ce8fbb0e63be82e02b082ab",
- ".specify/scripts/bash/setup-tasks.sh": "cf21ba2212b4dd5b435c5ea8527500cfd27768b86c0bbc7ebc3207759f118d27",
- ".specify/scripts/bash/check-prerequisites.sh": "a7d8a14ecf87332b600cd966b5d0e7cb9d594abce7e4d1ee4372b2b5b3efff06",
+ ".specify/scripts/bash/setup-tasks.sh": "1d4bcebe93f3e4e778964978cfe9bfb67ee94e7dc688f2ff3be224f647f61f1f",
+ ".specify/scripts/bash/check-prerequisites.sh": "ac3e96258a05d029d048076393a03aadff5c7c3a55a26d0a9f5c17886a1c659d",
".specify/scripts/bash/create-new-feature.sh": "dd531f9ba47c9ce9975b597947377be9542b7236681d6dc033513c4e3cfc50f2",
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
- ".specify/templates/checklist-template.md": "709d8ab8384a3a49f5e0f64479f71553ef6d6f8bb4f00281b05f47837993b536",
- ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842",
+ ".specify/templates/checklist-template.md": "0ad704b60af2df817aee1c0a2ecc0e0304b271d2de34047df1c891735967033e",
+ ".specify/templates/tasks-template.md": "c731575d8099b3f871861186fbd1a592b51b2ba57fb99e1a0dab439ff6d5608f",
".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
- ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4"
+ ".specify/templates/plan-template.md": "5ef0e4c97b36e9f91372dc6eb8e5a7e515af8958cf1a9286e43a1ebd9bd48540"
}
}
diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md
index 4f8f5ec..387cee6 100644
--- a/.specify/memory/constitution.md
+++ b/.specify/memory/constitution.md
@@ -1,29 +1,30 @@
@@ -61,7 +62,7 @@ The technology stack is fixed for MVP:
| Layer | Technology |
|-------|------------|
-| Frontend | Next.js 14 monolith |
+| Frontend | Next.js 15 monolith |
| Backend | FastAPI |
| Auth, DB, Vault, Storage | Supabase |
| Hosting | Bunny Magic Containers |
@@ -98,14 +99,39 @@ Security implementation MUST follow these requirements:
### VII. Definition of Done
-A feature is complete only when ALL of the following are verified:
-
-- [ ] Works correctly for a brand with no brand kit (0 answers)
-- [ ] Works correctly for a brand with a completed brand kit
-- [ ] Works with OpenAI provider
-- [ ] Works with Gemini provider
-- [ ] RLS policies tested OR explicit integration checks documented
-- [ ] Hard delete verified: database rows removed AND storage assets removed
+A feature is complete only when all universal checks and every capability check
+applicable to the current implementation phase are verified.
+
+Universal checks:
+
+- [ ] Acceptance scenarios for the feature pass at the API, data, and user-facing
+ layers it changes
+- [ ] Every table introduced or changed has RLS, forced RLS, policies, and required
+ privileges verified by direct integration tests; backend-only tables also require
+ tests proving client roles are denied
+- [ ] Server-side ownership and secret/logging rules are tested for every affected
+ read and write operation
+- [ ] Every deletable database row, secret, or stored asset affected by the feature
+ is physically removed on successful deletion; soft delete is forbidden
+
+Capability checks become mandatory only when their prerequisite capability exists:
+
+- [ ] Brand-kit zero-answer and completed-kit scenarios are required beginning with
+ the feature that implements Brand Kit, and for later brand-scoped features whose
+ behavior reads or depends on Brand Kit. Before Brand Kit exists, these checks are
+ not applicable.
+- [ ] OpenAI behavior is required beginning with the feature that first integrates
+ OpenAI, and for every later feature that calls or changes that integration.
+- [ ] Gemini behavior is required beginning with the feature that first integrates
+ Gemini, and for every later feature that calls or changes that integration.
+- [ ] Generation lifecycle, platform preset, and PNG-output checks are required
+ beginning with the feature that implements image generation, and for later
+ features that change generation behavior.
+
+A plan MAY mark a capability check not applicable only when its prerequisite has not
+been implemented or the feature cannot affect that capability. The plan MUST record
+that rationale. Once a prerequisite capability exists, applicable checks MUST NOT be
+deferred merely because they belong to an earlier or later roadmap phase.
## Governance
@@ -128,4 +154,4 @@ A feature is complete only when ALL of the following are verified:
- Plan documents MUST include a Constitution Check section
- Definition of Done checklist MUST be completed before feature merge
-**Version**: 1.1.0 | **Ratified**: 2025-01-28 | **Last Amended**: 2026-07-19
\ No newline at end of file
+**Version**: 2.0.0 | **Ratified**: 2025-01-28 | **Last Amended**: 2026-07-26
diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh
index bf75140..55575db 100755
--- a/.specify/scripts/bash/check-prerequisites.sh
+++ b/.specify/scripts/bash/check-prerequisites.sh
@@ -120,20 +120,20 @@ fi
# Validate required directories and files
if [[ ! -d "$FEATURE_DIR" ]]; then
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
- echo "Run /speckit-specify first to create the feature structure." >&2
+ echo "Run /speckit.specify first to create the feature structure." >&2
exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
- echo "Run /speckit-plan first to create the implementation plan." >&2
+ echo "Run /speckit.plan first to create the implementation plan." >&2
exit 1
fi
# Check for tasks.md if required
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
- echo "Run /speckit-tasks first to create the task list." >&2
+ echo "Run /speckit.tasks first to create the task list." >&2
exit 1
fi
diff --git a/.specify/scripts/bash/setup-tasks.sh b/.specify/scripts/bash/setup-tasks.sh
index ae0d7bd..15cc618 100755
--- a/.specify/scripts/bash/setup-tasks.sh
+++ b/.specify/scripts/bash/setup-tasks.sh
@@ -30,13 +30,13 @@ unset _paths_output
# Validate required files
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
- echo "Run /speckit-plan first to create the implementation plan." >&2
+ echo "Run /speckit.plan first to create the implementation plan." >&2
exit 1
fi
if [[ ! -f "$FEATURE_SPEC" ]]; then
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
- echo "Run /speckit-specify first to create the feature structure." >&2
+ echo "Run /speckit.specify first to create the feature structure." >&2
exit 1
fi
diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md
index e64065d..0caeacf 100644
--- a/.specify/templates/checklist-template.md
+++ b/.specify/templates/checklist-template.md
@@ -4,13 +4,13 @@
**Created**: [DATE]
**Feature**: [Link to spec.md or relevant documentation]
-**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
+**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
US2 Validate ----+
+ | |
+ +------> US3 Activate ----+----> US4 Delete/Cleanup
+ |
+ v
+ Polish and full DoD
+```
+
+### Within Each User Story
+
+- Write the listed tests first and confirm they fail for the missing behavior
+- Add boundary models before stores, stores before routes, and routes before frontend integration
+- Never hold a database connection or row lock across provider or Storage I/O
+- Always acquire the brand lock before a provider-key lock
+- Preserve opaque ownership resolution before lifecycle/conflict errors
+- Stop at each checkpoint and run that story's independent tests before continuing
+
+### Parallel Opportunities
+
+- T001, T002, and T005 can be authored in parallel before T003 because they touch separate test concerns
+- T006 and T008 can proceed in parallel after the migration contract is understood
+- T014-T016 can be authored in parallel; T017 can proceed separately before T018 consumes its models
+- T024-T026 can be authored in parallel; T027 can proceed independently of the database lease implementation in T028
+- T035 and T036 can be authored in parallel after US1
+- T041-T044 can be authored in parallel before deletion/storage implementation starts
+- T051 and T053 can proceed in parallel because they modify different frontend pages
+- T057, T058, T060, and T061 can run in parallel after implementation stabilizes
+
+---
+
+## Parallel Examples
+
+### User Story 1
+
+```bash
+Task: "Write list/add contract tests in backend/tests/contract/test_provider_keys.py"
+Task: "Write Vault/idempotency integration tests in backend/tests/integration/test_provider_keys.py"
+Task: "Write owner/RLS/Vault denial tests in backend/tests/integration/test_provider_key_rls.py"
+```
+
+### User Story 2
+
+```bash
+Task: "Write provider classification tests in backend/tests/unit/test_provider_validation.py"
+Task: "Write validation API contract tests in backend/tests/contract/test_provider_keys.py"
+Task: "Write validation lease tests in backend/tests/integration/test_provider_keys.py"
+```
+
+### User Story 3
+
+```bash
+Task: "Write activation API tests in backend/tests/contract/test_provider_keys.py"
+Task: "Write activation concurrency tests in backend/tests/integration/test_provider_key_cleanup.py"
+```
+
+### User Story 4
+
+```bash
+Task: "Write individual cleanup contract tests in backend/tests/contract/test_provider_keys.py"
+Task: "Write brand cleanup contract tests in backend/tests/contract/test_brands.py"
+Task: "Write cleanup concurrency tests in backend/tests/integration/test_provider_key_cleanup.py"
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First
+
+1. Complete T001-T013 to establish the security and lifecycle foundation.
+2. Complete T014-T023 for User Story 1.
+3. Stop and independently validate atomic Vault storage, idempotent add, safe listing,
+ RLS, Vault denial, and browser opacity.
+4. Deploy only if a brand owner can configure both providers without any reveal path.
+
+### Incremental Delivery
+
+1. Foundational: schema, privilege boundary, brand fence, deadline, and safe logs.
+2. US1: add/list keys as the minimum useful BYOK setup.
+3. US2: validate both providers without generation or false invalidation.
+4. US3: rotate active credentials atomically under concurrency.
+5. US4: complete individual and brand-wide retryable physical cleanup.
+6. Polish: real-provider verification, full regression, secrecy audit, and DoD.
+
+## Notes
+
+- `[P]` means separate files or non-overlapping work with no dependency on an incomplete task.
+- Raw keys may exist only in the add request, request-local backend memory/SQL bind,
+ Supabase Vault, and provider authentication header; never add them to fixtures that
+ can print on assertion failure.
+- A cleanup-required row is a failed-operation retry anchor, not a successful soft delete.
+- Missing Vault secrets are idempotent cleanup success; unknown Storage writes remain
+ blocked until definitive reconciliation and never expire based only on time.
+- No Brand Kit, image generation, provider SDK, key reveal/edit, background cleanup
+ worker, pagination for key lists, or new runtime dependency belongs in this feature.
From 7d49c894ab4bc71a982535d93bc7be32aea4778e Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Mon, 27 Jul 2026 20:17:43 +0300
Subject: [PATCH 02/14] Address Provider Keys spec review findings
---
.opencode/commands/speckit.converge.md | 3 +-
.opencode/commands/speckit.tasks.md | 2 +-
.specify/integrations/opencode.manifest.json | 4 +--
.specify/integrations/speckit.manifest.json | 6 ++--
.specify/memory/constitution.md | 2 +-
.specify/templates/plan-template.md | 10 ++++---
specs/004-provider-keys/data-model.md | 31 ++++++++++++++------
specs/004-provider-keys/quickstart.md | 4 +--
specs/004-provider-keys/research.md | 2 +-
specs/004-provider-keys/tasks.md | 6 ++--
10 files changed, 43 insertions(+), 27 deletions(-)
diff --git a/.opencode/commands/speckit.converge.md b/.opencode/commands/speckit.converge.md
index 7a7eae2..fac7ec8 100644
--- a/.opencode/commands/speckit.converge.md
+++ b/.opencode/commands/speckit.converge.md
@@ -202,9 +202,10 @@ Append to the **end** of `tasks.md`, per the append contract:
zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`:
```markdown
- - [ ] T042 per ()
+ - [ ] T042 per ()
```
+ The imperative description MUST name the exact affected file path.
`` traces the task to its origin: e.g. `FR-003`, `SC-002`,
`US1/AC2`, `plan: storage decision`, `Constitution II`.
diff --git a/.opencode/commands/speckit.tasks.md b/.opencode/commands/speckit.tasks.md
index 5d637d4..01f50aa 100644
--- a/.opencode/commands/speckit.tasks.md
+++ b/.opencode/commands/speckit.tasks.md
@@ -165,7 +165,7 @@ Every task MUST strictly follow this format:
**Examples**:
-- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
+- ✅ CORRECT: `- [ ] T001 Create project entry points in src/main.py and tests/conftest.py per implementation plan`
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
diff --git a/.specify/integrations/opencode.manifest.json b/.specify/integrations/opencode.manifest.json
index c6a082e..a76bfad 100644
--- a/.specify/integrations/opencode.manifest.json
+++ b/.specify/integrations/opencode.manifest.json
@@ -7,11 +7,11 @@
".opencode/commands/speckit.clarify.md": "595ae8988179a91780ce10e258afcccaa313b0924a79e720c66226e31af7ec00",
".opencode/commands/speckit.constitution.md": "e0d1d7c237d4657914dcccb6ea0c3a3a10d8d1d9a36b198282e22b3d0b9a55e5",
".opencode/commands/speckit.implement.md": "6a5713a4db8d48dd7c93e38b9957c6e54b6b7dc899ebab66f9ccb3bce58fdfeb",
- ".opencode/commands/speckit.converge.md": "e585bc94a3f01cb45eb4f4f6003268a7bc7e1f1685d343c25ad5dc3c484b4816",
+ ".opencode/commands/speckit.converge.md": "fb8dcfb3e9c5df97793081825c9be11d8fe1b0d4bdc5478629c73608f3e5f60b",
".opencode/commands/speckit.plan.md": "ff1809d5d72b399ec364414a150c1a20e782677972d11f0bdf8c65c73f898a71",
".opencode/commands/speckit.checklist.md": "2124a817bddbff7f469fd47b08e43dd9a39b769499d367f6888a74e1e821412d",
".opencode/commands/speckit.specify.md": "e24c01f27d269dab73c4178ce5bb1c31198dbfc36b9198cc6e407ef1122f4745",
- ".opencode/commands/speckit.tasks.md": "e3569f2fbdb99704b81e3647ce9e52fb812764d6b2590fc497a2c2130b43ba72",
+ ".opencode/commands/speckit.tasks.md": "201668645aa013b8e4dab7ef89325559071e6f57e99500112fdba837780ea81a",
".opencode/commands/speckit.taskstoissues.md": "0a436a42ceca2db309df88985d1939ee7f176c4a7a2d7c7a8d2253006cfe6870"
}
}
diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json
index 24d9156..10d3a14 100644
--- a/.specify/integrations/speckit.manifest.json
+++ b/.specify/integrations/speckit.manifest.json
@@ -10,8 +10,8 @@
".specify/scripts/bash/create-new-feature.sh": "dd531f9ba47c9ce9975b597947377be9542b7236681d6dc033513c4e3cfc50f2",
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
".specify/templates/checklist-template.md": "0ad704b60af2df817aee1c0a2ecc0e0304b271d2de34047df1c891735967033e",
- ".specify/templates/tasks-template.md": "c731575d8099b3f871861186fbd1a592b51b2ba57fb99e1a0dab439ff6d5608f",
- ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
- ".specify/templates/plan-template.md": "5ef0e4c97b36e9f91372dc6eb8e5a7e515af8958cf1a9286e43a1ebd9bd48540"
+ ".specify/templates/tasks-template.md": "61f9bf0d34c75f97e91486037ff35cdde35915d8d9f9b2fa755882c35eb497f9",
+ ".specify/templates/spec-template.md": "8bc198906589c75c3490d1e1edd09194bc94348b3ae8ee46657a62a7f9ebd4d8",
+ ".specify/templates/plan-template.md": "bbe9f9504a26f7400b2d92015b858a30dbc92e5069cf02840cec19c45b6abc95"
}
}
diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md
index 387cee6..3c3953e 100644
--- a/.specify/memory/constitution.md
+++ b/.specify/memory/constitution.md
@@ -18,7 +18,7 @@ Templates and guidance status:
- .specify/templates/plan-template.md ✅ Updated phase-aware Constitution Check guidance
- .specify/templates/spec-template.md ✅ Updated phase dependency/applicability guidance
- .specify/templates/tasks-template.md ✅ Updated phase-aware DoD task guidance
- - .specify/templates/checklist-template.md ✅ Reviewed; generic template needs no change
+ - .specify/templates/checklist-template.md ✅ Updated /speckit.checklist command references
- .specify/templates/agent-file-template.md ✅ Not present in this initialized template set
- .opencode/commands/speckit.*.md ✅ Reviewed; no version-specific or unconditional DoD rules
- README.md ✅ Updated runtime version and implemented-feature status
diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md
index bdf033f..6ec0c8b 100644
--- a/.specify/templates/plan-template.md
+++ b/.specify/templates/plan-template.md
@@ -40,10 +40,12 @@
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
-[Gates determined based on constitution file. Evaluate all universal Definition of
-Done checks and each capability check whose prerequisite exists in the current
-implementation phase. Mark a capability check N/A only with a concrete rationale
-that its prerequisite is not implemented or this feature cannot affect it.]
+[Determine gates from the constitution file. Before Phase 0, identify all universal
+Definition of Done checks and each capability check whose prerequisite is already
+implemented, regardless of roadmap phase. For every applicable check, document how it
+will be verified during implementation or runtime validation; do not claim it has
+already passed. Mark a check N/A only with a concrete rationale that its prerequisite
+is not implemented or the feature's scope cannot affect that capability.]
## Project Structure
diff --git a/specs/004-provider-keys/data-model.md b/specs/004-provider-keys/data-model.md
index 60ad3ba..91eb1cd 100644
--- a/specs/004-provider-keys/data-model.md
+++ b/specs/004-provider-keys/data-model.md
@@ -1,6 +1,6 @@
# Phase 1 Data Model: Provider Keys
-## Supporting Types and Ownership Helper
+## Supporting Types and Private Ownership Helper
The migration first ensures `supabase_vault` is available (`CREATE EXTENSION IF NOT EXISTS supabase_vault CASCADE`) and introduces types shared by provider-key records and future generation records:
@@ -10,10 +10,15 @@ CREATE TYPE provider_key_lifecycle_t AS ENUM ('normal', 'cleanup_required');
CREATE TYPE brand_deletion_state_t AS ENUM ('active', 'cleanup_required');
```
-It also introduces the deferred ownership helper anticipated by the Brand CRUD research:
+It also introduces the deferred ownership helper anticipated by the Brand CRUD research
+in a schema that is not listed in Supabase's exposed API schemas:
```sql
-CREATE FUNCTION is_brand_owner(p_brand_id UUID)
+CREATE SCHEMA IF NOT EXISTS private;
+REVOKE ALL ON SCHEMA private FROM PUBLIC, anon, authenticated;
+GRANT USAGE ON SCHEMA private TO authenticated, service_role;
+
+CREATE FUNCTION private.is_brand_owner(p_brand_id UUID)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
@@ -27,9 +32,17 @@ AS $$
AND owner_user_id = (SELECT auth.uid())
);
$$;
+
+REVOKE ALL ON FUNCTION private.is_brand_owner(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION private.is_brand_owner(UUID) TO authenticated, service_role;
```
-Execution is revoked from `PUBLIC` and granted only to `authenticated` and `service_role`. The empty search path and fully qualified names prevent object-shadowing attacks in a `SECURITY DEFINER` function.
+The `private` schema is deliberately absent from `supabase/config.toml`'s exposed API
+schemas, so PostgREST cannot publish the helper as an authenticated RPC. `authenticated`
+and `service_role` receive schema `USAGE` plus exact function `EXECUTE` because RLS
+policies run under the querying role; they receive no schema `CREATE`. Execution remains
+revoked from `PUBLIC`. The empty search path and fully qualified names prevent
+object-shadowing attacks in a `SECURITY DEFINER` function.
## Existing Entity Change: Brand
@@ -206,18 +219,18 @@ ALTER TABLE provider_keys ENABLE ROW LEVEL SECURITY;
ALTER TABLE provider_keys FORCE ROW LEVEL SECURITY;
CREATE POLICY provider_keys_select ON provider_keys
- FOR SELECT USING (is_brand_owner(brand_id));
+ FOR SELECT USING (private.is_brand_owner(brand_id));
CREATE POLICY provider_keys_insert ON provider_keys
- FOR INSERT WITH CHECK (is_brand_owner(brand_id));
+ FOR INSERT WITH CHECK (private.is_brand_owner(brand_id));
CREATE POLICY provider_keys_update ON provider_keys
FOR UPDATE
- USING (is_brand_owner(brand_id))
- WITH CHECK (is_brand_owner(brand_id));
+ USING (private.is_brand_owner(brand_id))
+ WITH CHECK (private.is_brand_owner(brand_id));
CREATE POLICY provider_keys_delete ON provider_keys
- FOR DELETE USING (is_brand_owner(brand_id));
+ FOR DELETE USING (private.is_brand_owner(brand_id));
GRANT SELECT, INSERT, UPDATE, DELETE ON provider_keys TO service_role;
diff --git a/specs/004-provider-keys/quickstart.md b/specs/004-provider-keys/quickstart.md
index bedf8d7..88c34c6 100644
--- a/specs/004-provider-keys/quickstart.md
+++ b/specs/004-provider-keys/quickstart.md
@@ -77,10 +77,10 @@ Then validate real disposable keys through the UI:
2. Repeat each operation with nonexistent UUIDs. Confirm status, error code, and message match the non-owned case; only request IDs differ.
3. Execute direct SQL as User A's `authenticated` role. Confirm safe-column SELECT can see only User A's records, internal Vault/lease columns are permission-denied, and INSERT/UPDATE/DELETE are permission-denied even for owned rows.
4. Repeat as User B. Confirm safe-column SELECT returns no User A rows and all writes remain permission-denied.
-5. Verify catalog privileges show no Vault schema/table/view/function access for `anon` or `authenticated`, and verify the configured backend database role has exactly the required Vault/application privileges.
+5. Verify catalog privileges show no Vault schema/table/view/function access for `anon` or `authenticated`. In hosted deployments, verify the configured backend database role has exactly the required Vault/application privileges. In local development, the documented `postgres`/superuser exception may have broader privileges, but it must still provide every required Vault/application capability.
6. As `authenticated`, attempt direct reads from `vault.secrets` and `vault.decrypted_secrets`, secret creation, update, and deletion. Require SQLSTATE `42501` rather than an empty result.
7. As both `anon` and `authenticated`, attempt direct SELECT/INSERT/UPDATE/DELETE against `provider_key_idempotency` and `brand_asset_operations`. Require permission denial for every operation, including when the referenced brand is owned by the authenticated user.
-8. With a real authenticated JWT, attempt Data API access to both backend-only tables, a `vault` profile, and attempted Vault RPCs. Confirm all are unavailable for both User A and User B.
+8. With a real authenticated JWT, attempt Data API access to both backend-only tables, a `vault` profile, Vault RPCs, and the `private.is_brand_owner` helper RPC. Confirm all are unavailable for both User A and User B while normal owner-scoped metadata reads still exercise the helper through RLS.
Expected: clients cannot retrieve even their own raw key; all Vault access is backend-only, and provider-key metadata remains brand-isolated at API and database layers.
diff --git a/specs/004-provider-keys/research.md b/specs/004-provider-keys/research.md
index dd6e3b0..f875a6a 100644
--- a/specs/004-provider-keys/research.md
+++ b/specs/004-provider-keys/research.md
@@ -18,7 +18,7 @@ An already-absent Vault row counts as successful idempotent cleanup. After any a
## Decision 2: Deny all client access to Vault; apply RLS to application records
-**Decision**: Keep the `vault` schema outside PostgREST's exposed schemas and explicitly revoke schema, table/view, and exact-signature function privileges from `PUBLIC`, `anon`, and `authenticated`. Only the backend's privileged database role may create, decrypt, or delete Vault secrets. Apply `ENABLE` and `FORCE ROW LEVEL SECURITY` to `provider_keys`; grant authenticated users safe-column SELECT only and no direct DML. The actual `DATABASE_URL` role must pass startup/integration privilege assertions for required Vault/application operations.
+**Decision**: Keep the `vault` schema outside PostgREST's exposed schemas and explicitly revoke schema, table/view, and exact-signature function privileges from `PUBLIC`, `anon`, and `authenticated`. Place the `SECURITY DEFINER` ownership helper in a separate non-exposed `private` schema; grant authenticated callers only schema `USAGE` and exact helper `EXECUTE` so RLS can invoke it without publishing a Data API RPC. Only the backend's privileged database role may create, decrypt, or delete Vault secrets. Apply `ENABLE` and `FORCE ROW LEVEL SECURITY` to `provider_keys`; grant authenticated users safe-column SELECT only and no direct DML. The actual `DATABASE_URL` role must pass startup/integration privilege assertions for required Vault/application operations.
**Rationale**: Vault's decrypted view is global and is protected by object privileges rather than tenant RLS. Granting an authenticated client access would expose every secret. Full table DML would also let an owner read `vault_secret_id`, forge lifecycle state, or delete the only retry reference without Vault cleanup. Safe owner metadata reads exercise RLS, while all writes remain backend-mediated. Integration tests prove safe-column visibility, same-owner and cross-owner write denial, SQLSTATE `42501` for Vault, and Data API rejection.
diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md
index 44f01fd..488c1c3 100644
--- a/specs/004-provider-keys/tasks.md
+++ b/specs/004-provider-keys/tasks.md
@@ -48,9 +48,9 @@ brand cleanup state, request deadline, and safe logging rules required by every
**CRITICAL**: No user story implementation starts until this phase passes against a
real local Supabase instance.
-- [ ] T001 Create failing schema/security integration tests in `backend/tests/integration/test_provider_key_rls.py` that assert the planned enums, helper, constraints, indexes, restrictive foreign keys, `ENABLE`/`FORCE RLS`, owner-only safe-column reads, denied authenticated DML and internal-column reads, complete denial of `provider_key_idempotency` and `brand_asset_operations`, exact Vault denial with SQLSTATE `42501`, and required backend-role `BYPASSRLS`/application/Vault privileges from `data-model.md`
+- [ ] T001 Create failing schema/security integration tests in `backend/tests/integration/test_provider_key_rls.py` that assert the planned enums, non-exposed `private.is_brand_owner(uuid)` helper and denied authenticated RPC, constraints, indexes, restrictive foreign keys, `ENABLE`/`FORCE RLS`, owner-only safe-column reads, denied authenticated DML and internal-column reads, complete denial of `provider_key_idempotency` and `brand_asset_operations`, exact Vault denial with SQLSTATE `42501`, and required backend-role `BYPASSRLS`/application/Vault privileges from `data-model.md`
- [ ] T002 Update failing Brand CRUD/RLS regressions in `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` so test cleanup hard-deletes brands before auth users, and direct authenticated brand `INSERT`/`UPDATE`/`DELETE` expects permission denial for owned and non-owned rows after authenticated DML is revoked
-- [ ] T003 Create `supabase/migrations/00016_create_provider_keys.sql` with `supabase_vault`, `provider_t`, `provider_key_lifecycle_t`, `brand_deletion_state_t`, hardened `is_brand_owner(uuid)`, irreversible cleanup triggers, the `brands` lifecycle/logo-path/FK/grant changes, `provider_keys`, `provider_key_idempotency`, `brand_asset_operations`, all checks/indexes/triggers/restrictive FKs, `ENABLE`/`FORCE RLS`, safe-column grants, backend-only grants, and exact Vault privilege revocations/grants specified in `data-model.md`
+- [ ] T003 Create `supabase/migrations/00016_create_provider_keys.sql` with `supabase_vault`, `provider_t`, `provider_key_lifecycle_t`, `brand_deletion_state_t`, hardened `private.is_brand_owner(uuid)` in a non-exposed schema with RLS-only caller privileges, irreversible cleanup triggers, the `brands` lifecycle/logo-path/FK/grant changes, `provider_keys`, `provider_key_idempotency`, `brand_asset_operations`, all checks/indexes/triggers/restrictive FKs, `ENABLE`/`FORCE RLS`, safe-column grants, backend-only grants, and exact Vault privilege revocations/grants specified in `data-model.md`
- [ ] T004 Apply and statically validate `supabase/migrations/00016_create_provider_keys.sql` with `supabase migration up` and `supabase db lint --level warning`, then run `backend/tests/integration/test_provider_key_rls.py` and confirm its real-Supabase tests execute rather than skip
- [ ] T005 [P] Add failing shared contract tests in `backend/tests/contract/test_provider_keys.py` for the 15-second validation deadline context, fixed provider-key/brand cleanup errors, `X-Request-Id` parity, and JSON log allowlisting that excludes submitted bodies, labels, hints, Vault UUIDs, SQL parameters, provider bodies, authorization headers, exception strings, user IDs, and email addresses
- [ ] T006 [P] Extend `backend/app/models/brand.py` so every Brand response maps database `deletion_state` to contract field `cleanup_state: normal | cleanup_required` without exposing internal lifecycle fields
@@ -198,7 +198,7 @@ secrecy review, and the phase-aware constitutional Definition of Done.
- [ ] T057 [P] Document in `backend/.env.example` that `DATABASE_URL` must use a private non-client role with forced-RLS bypass and only the required application/Vault privileges, without placing credentials or full example secrets in the file
- [ ] T058 [P] Update `docs/docker.md` with hosted backend-role creation/verification expectations, startup failure troubleshooting, official provider outbound-host requirements, and safe handling of disposable validation keys
- [ ] T059 Run the complete backend suite with `backend/.venv/bin/python -m pytest backend/tests -q` and use `specs/004-provider-keys/quickstart.md` to confirm all provider-key real-Supabase integration files execute rather than skip
-- [ ] T060 [P] Run `npm run lint`, `npx tsc --noEmit`, `npm run build`, and `npx playwright test frontend/tests/e2e/provider-keys.spec.ts` from `frontend/`; resolve feature-related failures in the changed frontend files
+- [ ] T060 [P] Run `npm run lint`, `npx tsc --noEmit`, `npm run build`, and `npx playwright test tests/e2e/provider-keys.spec.ts` from `frontend/`; resolve feature-related failures in the changed frontend files
- [ ] T061 [P] Run `supabase db lint --level warning` against `supabase/migrations/00016_create_provider_keys.sql` and re-run the catalog, SQLSTATE, RLS, backend-only table, Data API, and Vault privilege assertions in `backend/tests/integration/test_provider_key_rls.py`
- [ ] T062 Perform the real disposable OpenAI and Gemini checks in Quickstart Scenario 2 steps 8-10 from `specs/004-provider-keys/quickstart.md`; require `valid` from each official model-list endpoint, confirm no generation occurs, and treat provider outage/temporary classification as blocked rather than passed
- [ ] T063 Audit `backend/app`, `backend/tests`, and captured logs using Quickstart Scenario 6 in `specs/004-provider-keys/quickstart.md`; confirm no raw key, label, hint, Vault UUID, decrypted value, authorization header, provider body, SQL bind, token, PII, or secret-bearing exception can cross response/log/test-output boundaries
From 9dfc05c109351f4fd27c0cb35a9c50a0b848b1ae Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Mon, 27 Jul 2026 21:04:49 +0300
Subject: [PATCH 03/14] Complete Phase 1 setup verification
---
.gitignore | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.gitignore b/.gitignore
index c13206a..b1debe0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,6 +44,7 @@ coverage/
.nyc_output/
# Logs
+*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
@@ -109,6 +110,7 @@ Thumbs.db
# ---------------------------------------------------------------------------
# Misc
# ---------------------------------------------------------------------------
+*.tmp
*.tsbuildinfo
.cache/
tmp/
From 820a1bf797f35e87638806fa30c1caecefb0d06c Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 00:01:29 +0300
Subject: [PATCH 04/14] Complete provider keys foundation
---
backend/app/auth.py | 77 +-
backend/app/config.py | 98 ++-
backend/app/main.py | 43 +-
backend/app/models/brand.py | 2 +
backend/app/routes/brands.py | 100 ++-
backend/app/services/brand_store.py | 175 ++++-
backend/tests/contract/conftest.py | 15 +
backend/tests/contract/test_brands.py | 214 +++++-
backend/tests/contract/test_provider_keys.py | 400 +++++++++++
backend/tests/integration/test_brand_crud.py | 279 ++++++++
backend/tests/integration/test_brand_rls.py | 91 +--
.../integration/test_provider_key_rls.py | 666 ++++++++++++++++++
specs/004-provider-keys/data-model.md | 2 +-
specs/004-provider-keys/tasks.md | 26 +-
.../migrations/00016_create_provider_keys.sql | 237 +++++++
15 files changed, 2289 insertions(+), 136 deletions(-)
create mode 100644 backend/tests/contract/conftest.py
create mode 100644 backend/tests/contract/test_provider_keys.py
create mode 100644 backend/tests/integration/test_provider_key_rls.py
create mode 100644 supabase/migrations/00016_create_provider_keys.sql
diff --git a/backend/app/auth.py b/backend/app/auth.py
index fa43cae..1c825ab 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -1,9 +1,11 @@
from dataclasses import dataclass
from functools import lru_cache
from typing import Annotated
+import asyncio
+import time
import jwt
-from fastapi import Depends, Header, HTTPException, status
+from fastapi import Depends, Header, HTTPException, Request, status
from jwt import PyJWKClient
from .config import load_settings
@@ -24,12 +26,51 @@ def _unauthorized(message: str = "Sign in required.") -> HTTPException:
@lru_cache(maxsize=1)
-def _get_jwks_client() -> PyJWKClient:
+def _get_cached_jwks_client() -> PyJWKClient:
settings = load_settings()
return PyJWKClient(f"{settings.supabase_url}/auth/v1/.well-known/jwks.json")
+def _get_jwks_client(request: Request) -> PyJWKClient:
+ deadline = getattr(request.state, "validation_deadline", None)
+ if deadline is None:
+ return _get_cached_jwks_client()
+
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise _unauthorized()
+ settings = load_settings()
+ return PyJWKClient(
+ f"{settings.supabase_url}/auth/v1/.well-known/jwks.json",
+ timeout=min(2, remaining),
+ )
+
+
+def _decode_access_token(request: Request, token: str, settings) -> dict:
+ algorithm = jwt.get_unverified_header(token).get("alg", "HS256")
+
+ if algorithm == "HS256":
+ # Legacy shared-secret signing, still used by some projects.
+ return jwt.decode(
+ token,
+ settings.supabase_jwt_secret,
+ algorithms=["HS256"],
+ audience="authenticated",
+ )
+
+ # Newer Supabase projects sign access tokens with an asymmetric
+ # key (e.g. ES256), published via the project's JWKS endpoint.
+ signing_key = _get_jwks_client(request).get_signing_key_from_jwt(token)
+ return jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=[algorithm],
+ audience="authenticated",
+ )
+
+
async def get_current_user(
+ request: Request,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> CurrentUser:
if not authorization or not authorization.startswith("Bearer "):
@@ -42,26 +83,20 @@ async def get_current_user(
settings = load_settings()
try:
- algorithm = jwt.get_unverified_header(token).get("alg", "HS256")
-
- if algorithm == "HS256":
- # Legacy shared-secret signing, still used by some projects.
- payload = jwt.decode(
- token,
- settings.supabase_jwt_secret,
- algorithms=["HS256"],
- audience="authenticated",
- )
+ deadline = getattr(request.state, "validation_deadline", None)
+ if deadline is None:
+ payload = _decode_access_token(request, token, settings)
else:
- # Newer Supabase projects sign access tokens with an asymmetric
- # key (e.g. ES256), published via the project's JWKS endpoint.
- signing_key = _get_jwks_client().get_signing_key_from_jwt(token)
- payload = jwt.decode(
- token,
- signing_key.key,
- algorithms=[algorithm],
- audience="authenticated",
- )
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise _unauthorized()
+ try:
+ payload = await asyncio.wait_for(
+ asyncio.to_thread(_decode_access_token, request, token, settings),
+ timeout=remaining,
+ )
+ except TimeoutError:
+ raise _unauthorized() from None
except jwt.PyJWTError:
raise _unauthorized() from None
diff --git a/backend/app/config.py b/backend/app/config.py
index a722a42..791c7a4 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -1,12 +1,15 @@
from dataclasses import dataclass
from functools import lru_cache
import os
+from urllib.parse import urlparse
-from sqlalchemy import create_engine
+from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
_DEFAULT_ALLOWED_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000"
+_DATABASE_TIMEOUT_SECONDS = 2
+_DATABASE_TIMEOUT_MILLISECONDS = _DATABASE_TIMEOUT_SECONDS * 1000
@dataclass(frozen=True, slots=True)
@@ -35,4 +38,95 @@ def get_engine() -> Engine:
if not settings.database_url:
raise RuntimeError("DATABASE_URL is required")
- return create_engine(settings.database_url, future=True, pool_pre_ping=True)
+ return create_engine(
+ settings.database_url,
+ future=True,
+ pool_pre_ping=True,
+ hide_parameters=True,
+ pool_timeout=_DATABASE_TIMEOUT_SECONDS,
+ connect_args={
+ "connect_timeout": _DATABASE_TIMEOUT_SECONDS,
+ "options": (
+ f"-c statement_timeout={_DATABASE_TIMEOUT_MILLISECONDS} "
+ f"-c lock_timeout={_DATABASE_TIMEOUT_MILLISECONDS}"
+ ),
+ },
+ )
+
+
+_DATABASE_ROLE_PRIVILEGES = text(
+ """
+ SELECT
+ current_user::text AS role_name,
+ roles.rolsuper AS is_superuser,
+ current_user NOT IN ('anon', 'authenticated', 'service_role') AS role_is_private,
+ roles.rolsuper OR roles.rolbypassrls AS can_bypass_forced_rls,
+ has_table_privilege(current_user, 'public.brands', 'SELECT')
+ AND has_table_privilege(current_user, 'public.brands', 'INSERT')
+ AND has_table_privilege(current_user, 'public.brands', 'UPDATE')
+ AND has_table_privilege(current_user, 'public.brands', 'DELETE')
+ AND has_table_privilege(current_user, 'public.provider_keys', 'SELECT')
+ AND has_table_privilege(current_user, 'public.provider_keys', 'INSERT')
+ AND has_table_privilege(current_user, 'public.provider_keys', 'UPDATE')
+ AND has_table_privilege(current_user, 'public.provider_keys', 'DELETE')
+ AND has_table_privilege(current_user, 'public.provider_key_idempotency', 'SELECT')
+ AND has_table_privilege(current_user, 'public.provider_key_idempotency', 'INSERT')
+ AND has_table_privilege(current_user, 'public.provider_key_idempotency', 'UPDATE')
+ AND has_table_privilege(current_user, 'public.provider_key_idempotency', 'DELETE')
+ AND has_table_privilege(current_user, 'public.brand_asset_operations', 'SELECT')
+ AND has_table_privilege(current_user, 'public.brand_asset_operations', 'INSERT')
+ AND has_table_privilege(current_user, 'public.brand_asset_operations', 'UPDATE')
+ AND has_table_privilege(current_user, 'public.brand_asset_operations', 'DELETE')
+ AS application_dml,
+ has_schema_privilege(current_user, 'vault', 'USAGE') AS vault_schema_usage,
+ has_function_privilege(
+ current_user,
+ to_regprocedure('vault.create_secret(text,text,text,uuid)'),
+ 'EXECUTE'
+ ) AS vault_create,
+ has_column_privilege(current_user, 'vault.decrypted_secrets', 'id', 'SELECT')
+ AND has_column_privilege(
+ current_user, 'vault.decrypted_secrets', 'decrypted_secret', 'SELECT'
+ ) AS vault_decrypt,
+ has_column_privilege(current_user, 'vault.secrets', 'id', 'SELECT')
+ AS vault_secret_select,
+ has_table_privilege(current_user, 'vault.secrets', 'DELETE')
+ AS vault_secret_delete,
+ (
+ NOT has_schema_privilege(current_user, 'vault', 'CREATE')
+ AND NOT has_function_privilege(
+ current_user,
+ to_regprocedure('vault.update_secret(uuid,text,text,text,uuid)'),
+ 'EXECUTE'
+ )
+ AND NOT has_table_privilege(
+ current_user, 'vault.decrypted_secrets', 'SELECT'
+ )
+ AND NOT has_table_privilege(current_user, 'vault.secrets', 'SELECT')
+ ) AS vault_least_privilege
+ FROM pg_roles AS roles
+ WHERE roles.rolname = current_user
+ """
+)
+
+
+def assert_database_role_privileges() -> None:
+ settings = load_settings()
+ with get_engine().connect() as connection:
+ privileges = dict(connection.execute(_DATABASE_ROLE_PRIVILEGES).mappings().one())
+
+ role_name = privileges.pop("role_name")
+ is_superuser = privileges.pop("is_superuser")
+ vault_least_privilege = privileges.pop("vault_least_privilege")
+ hostname = urlparse(settings.supabase_url).hostname
+ local_postgres = role_name == "postgres" and hostname in {
+ "127.0.0.1",
+ "localhost",
+ "::1",
+ }
+ role_is_acceptable = local_postgres or (
+ not is_superuser and vault_least_privilege
+ )
+
+ if not role_is_acceptable or not all(privileges.values()):
+ raise RuntimeError("database role lacks required backend privileges")
diff --git a/backend/app/main.py b/backend/app/main.py
index a2188f1..bc66787 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,5 +1,7 @@
import json
import logging
+import re
+import time
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from uuid import uuid4
@@ -10,21 +12,39 @@
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
-from .config import load_settings
+from .config import assert_database_role_privileges, load_settings
from .routes.auth import router as auth_router
from .routes.brands import router as brands_router
from .routes.health import router as health_router
from .routes.me import router as me_router
+_VALIDATION_DEADLINE_SECONDS = 15
+_VALIDATION_PATH = re.compile(
+ r"^/api/v1/brands/[^/]+/keys/[^/]+/validate/?$"
+)
+_SAFE_ERROR_MESSAGES = {
+ (409, "KEY_CLEANUP_REQUIRED"): "Key cleanup is required. Retry deletion.",
+ (409, "BRAND_CLEANUP_REQUIRED"): "Brand cleanup is required. Retry deletion.",
+ (503, "KEY_CLEANUP_REQUIRED"): "Key cleanup did not complete. Retry deletion.",
+ (503, "BRAND_CLEANUP_REQUIRED"): "Brand cleanup did not complete. Retry deletion.",
+}
+
+
class _JsonLogFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"level": record.levelname,
"logger": record.name,
- "message": record.getMessage(),
}
- for key in ("event", "request_id"):
+ for key in (
+ "event",
+ "request_id",
+ "provider",
+ "code",
+ "duration_ms",
+ "provider_request_id",
+ ):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
@@ -42,6 +62,7 @@ def _configure_logging() -> None:
@asynccontextmanager
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
_configure_logging()
+ assert_database_role_privileges()
yield
@@ -70,10 +91,24 @@ def _error_response(request_id: str, code: str, message: str, status_code: int)
)
+def safe_error_response(request: Request, status_code: int, code: str) -> JSONResponse:
+ message = _SAFE_ERROR_MESSAGES[(status_code, code)]
+ request_id = getattr(request.state, "request_id", str(uuid4()))
+ return _error_response(request_id, code, message, status_code)
+
+
+def get_validation_deadline(request: Request) -> float | None:
+ return getattr(request.state, "validation_deadline", None)
+
+
@app.middleware("http")
-async def request_id_middleware(request: Request, call_next: Callable):
+async def request_context_middleware(request: Request, call_next: Callable):
request_id = str(uuid4())
request.state.request_id = request_id
+ if _VALIDATION_PATH.fullmatch(request.url.path):
+ request.state.validation_deadline = (
+ time.monotonic() + _VALIDATION_DEADLINE_SECONDS
+ )
response = await call_next(request)
response.headers["X-Request-Id"] = request_id
return response
diff --git a/backend/app/models/brand.py b/backend/app/models/brand.py
index 7677deb..cce3e93 100644
--- a/backend/app/models/brand.py
+++ b/backend/app/models/brand.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime
+from typing import Literal
from uuid import UUID
from pydantic import BaseModel, field_validator
@@ -26,6 +27,7 @@ class Brand(BaseModel):
id: UUID
name: str
logo_url: str | None
+ cleanup_state: Literal["normal", "cleanup_required"] = "normal"
created_at: datetime
diff --git a/backend/app/routes/brands.py b/backend/app/routes/brands.py
index 0efd9c4..8d30e45 100644
--- a/backend/app/routes/brands.py
+++ b/backend/app/routes/brands.py
@@ -13,7 +13,13 @@
BrandStorageError,
get_brand_storage,
)
-from ..services.brand_store import BrandNameTakenError, BrandStore, get_brand_store
+from ..services.brand_store import (
+ BrandCleanupRequiredError,
+ BrandMutationInProgressError,
+ BrandNameTakenError,
+ BrandStore,
+ get_brand_store,
+)
router = APIRouter(prefix="/api/v1/brands", tags=["brands"])
@@ -57,6 +63,44 @@ def _confirmation_mismatch() -> HTTPException:
)
+def _cleanup_required() -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail={
+ "code": "BRAND_CLEANUP_REQUIRED",
+ "message": "Brand cleanup is required. Retry deletion.",
+ },
+ )
+
+
+def _cleanup_failed() -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail={
+ "code": "BRAND_CLEANUP_REQUIRED",
+ "message": "Brand cleanup did not complete. Retry deletion.",
+ },
+ )
+
+
+def _mutation_in_progress() -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail={
+ "code": "BRAND_MUTATION_IN_PROGRESS",
+ "message": "A brand update is in progress. Retry shortly.",
+ },
+ )
+
+
+def _mutation_error(exc: Exception) -> HTTPException:
+ if isinstance(exc, BrandCleanupRequiredError):
+ return _cleanup_required()
+ if isinstance(exc, BrandMutationInProgressError):
+ return _mutation_in_progress()
+ return _not_found()
+
+
def _unsupported_media_type() -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -176,14 +220,18 @@ async def delete_brand(
raise _confirmation_mismatch()
try:
- logo_path = brand_store.get_logo_path(current_user.user_id, brand_id)
- except LookupError as exc:
- raise _not_found() from exc
+ logo_path = brand_store.begin_brand_cleanup(current_user.user_id, brand_id)
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ BrandMutationInProgressError,
+ ) as exc:
+ raise _mutation_error(exc) from exc
if logo_path:
try:
await brand_storage.delete_logo(logo_path)
- except BrandStorageError:
+ except BrandStorageError as exc:
logger.warning(
"brands.delete_logo_cleanup_failed",
extra={
@@ -191,11 +239,16 @@ async def delete_brand(
"request_id": getattr(request.state, "request_id", "unknown"),
},
)
+ raise _cleanup_failed() from exc
try:
- brand_store.delete_brand(current_user.user_id, brand_id)
- except LookupError as exc:
- raise _not_found() from exc
+ brand_store.delete_brand_after_cleanup(current_user.user_id, brand_id)
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ BrandMutationInProgressError,
+ ) as exc:
+ raise _mutation_error(exc) from exc
logger.info(
"brands.delete_success",
@@ -218,8 +271,12 @@ async def upload_brand_logo(
) -> Brand:
try:
old_path = brand_store.get_logo_path(current_user.user_id, brand_id)
- except LookupError as exc:
- raise _not_found() from exc
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ BrandMutationInProgressError,
+ ) as exc:
+ raise _mutation_error(exc) from exc
content_type = file.content_type or ""
extension = LOGO_TYPES.get(content_type)
@@ -261,8 +318,11 @@ async def upload_brand_logo(
"request_id": getattr(request.state, "request_id", "unknown"),
},
)
- if isinstance(exc, LookupError):
- raise _not_found() from exc
+ if isinstance(
+ exc,
+ (LookupError, BrandCleanupRequiredError, BrandMutationInProgressError),
+ ):
+ raise _mutation_error(exc) from exc
raise
if old_path and old_path != new_path:
@@ -297,16 +357,24 @@ async def delete_brand_logo(
) -> Response:
try:
old_path = brand_store.get_logo_path(current_user.user_id, brand_id)
- except LookupError as exc:
- raise _not_found() from exc
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ BrandMutationInProgressError,
+ ) as exc:
+ raise _mutation_error(exc) from exc
if old_path is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
try:
brand_store.update_logo_path(current_user.user_id, brand_id, None)
- except LookupError as exc:
- raise _not_found() from exc
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ BrandMutationInProgressError,
+ ) as exc:
+ raise _mutation_error(exc) from exc
try:
await brand_storage.delete_logo(old_path)
except BrandStorageError:
diff --git a/backend/app/services/brand_store.py b/backend/app/services/brand_store.py
index faf31f1..c9419dd 100644
--- a/backend/app/services/brand_store.py
+++ b/backend/app/services/brand_store.py
@@ -7,7 +7,7 @@
from uuid import UUID
from sqlalchemy import text
-from sqlalchemy.engine import Engine
+from sqlalchemy.engine import Connection, Engine
from sqlalchemy.exc import IntegrityError
from ..config import get_engine, load_settings
@@ -18,6 +18,14 @@ class BrandNameTakenError(Exception):
pass
+class BrandCleanupRequiredError(Exception):
+ pass
+
+
+class BrandMutationInProgressError(Exception):
+ pass
+
+
@dataclass(frozen=True, slots=True)
class BrandStore:
engine: Engine
@@ -29,7 +37,81 @@ def _to_brand(row: Mapping[str, Any]) -> Brand:
if logo_path:
base_url = load_settings().supabase_url.rstrip("/")
logo_url = f"{base_url}/storage/v1/object/public/brand-assets/{logo_path}"
- return Brand.model_validate({"logo_url": logo_url, **row})
+ cleanup_state = {
+ "active": "normal",
+ "cleanup_required": "cleanup_required",
+ }[row["deletion_state"]]
+ return Brand.model_validate(
+ {"logo_url": logo_url, "cleanup_state": cleanup_state, **row}
+ )
+
+ @staticmethod
+ def lock_owned_brand(
+ connection: Connection,
+ user_id: str,
+ brand_id: UUID,
+ ) -> Mapping[str, Any]:
+ row = connection.execute(
+ text(
+ """
+ SELECT id, name, logo_path, deletion_state, created_at
+ FROM brands
+ WHERE id = :brand_id AND owner_user_id = :owner_user_id
+ FOR UPDATE
+ """
+ ),
+ {"brand_id": brand_id, "owner_user_id": user_id},
+ ).mappings().one_or_none()
+ if row is None:
+ raise LookupError("Brand not found.")
+ return row
+
+ @staticmethod
+ def require_normal_brand(row: Mapping[str, Any]) -> None:
+ if row["deletion_state"] != "active":
+ raise BrandCleanupRequiredError
+
+ @staticmethod
+ def require_no_asset_operation(connection: Connection, brand_id: UUID) -> None:
+ operation_exists = connection.execute(
+ text(
+ """
+ SELECT EXISTS (
+ SELECT 1 FROM brand_asset_operations WHERE brand_id = :brand_id
+ )
+ """
+ ),
+ {"brand_id": brand_id},
+ ).scalar_one()
+ if operation_exists:
+ raise BrandMutationInProgressError
+
+ def lock_owned_brand_for_mutation(
+ self,
+ connection: Connection,
+ user_id: str,
+ brand_id: UUID,
+ ) -> Mapping[str, Any]:
+ row = self.lock_owned_brand(connection, user_id, brand_id)
+ self.require_normal_brand(row)
+ return row
+
+ def lock_owned_brand_without_asset_operation(
+ self,
+ connection: Connection,
+ user_id: str,
+ brand_id: UUID,
+ ) -> Mapping[str, Any]:
+ row = self.lock_owned_brand_for_mutation(connection, user_id, brand_id)
+ self.require_no_asset_operation(connection, brand_id)
+ return row
+
+ def get_brand_for_mutation(self, user_id: str, brand_id: UUID) -> Brand:
+ with self.engine.begin() as connection:
+ row = self.lock_owned_brand_without_asset_operation(
+ connection, user_id, brand_id
+ )
+ return self._to_brand(row)
def create_brand(self, user_id: str, payload: BrandCreate) -> Brand:
try:
@@ -39,7 +121,7 @@ def create_brand(self, user_id: str, payload: BrandCreate) -> Brand:
"""
INSERT INTO brands (owner_user_id, name)
VALUES (:owner_user_id, :name)
- RETURNING id, name, logo_path, created_at
+ RETURNING id, name, logo_path, deletion_state, created_at
"""
),
{"owner_user_id": user_id, "name": payload.name},
@@ -58,7 +140,7 @@ def list_brands(self, user_id: str) -> list[Brand]:
rows = connection.execute(
text(
"""
- SELECT id, name, logo_path, created_at
+ SELECT id, name, logo_path, deletion_state, created_at
FROM brands
WHERE owner_user_id = :owner_user_id
ORDER BY created_at DESC, id DESC
@@ -74,7 +156,7 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand:
row = connection.execute(
text(
"""
- SELECT id, name, logo_path, created_at
+ SELECT id, name, logo_path, deletion_state, created_at
FROM brands
WHERE id = :brand_id AND owner_user_id = :owner_user_id
"""
@@ -88,21 +170,32 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand:
return self._to_brand(row)
def get_logo_path(self, user_id: str, brand_id: UUID) -> str | None:
- with self.engine.connect() as connection:
- logo_path = connection.execute(
- text(
- """
- SELECT logo_path
- FROM brands
- WHERE id = :brand_id AND owner_user_id = :owner_user_id
- """
- ),
- {"brand_id": brand_id, "owner_user_id": user_id},
- ).scalar_one_or_none()
+ with self.engine.begin() as connection:
+ row = self.lock_owned_brand_without_asset_operation(
+ connection, user_id, brand_id
+ )
+ return row["logo_path"]
+
+ def begin_brand_cleanup(self, user_id: str, brand_id: UUID) -> str | None:
+ with self.engine.begin() as connection:
+ row = self.lock_owned_brand(connection, user_id, brand_id)
+ self.require_no_asset_operation(connection, brand_id)
+ if row["deletion_state"] == "active":
+ row = connection.execute(
+ text(
+ """
+ UPDATE brands
+ SET deletion_state = 'cleanup_required'
+ WHERE id = :brand_id AND owner_user_id = :owner_user_id
+ RETURNING id, name, logo_path, deletion_state, created_at
+ """
+ ),
+ {"brand_id": brand_id, "owner_user_id": user_id},
+ ).mappings().one_or_none()
- if logo_path is None:
- self.get_brand(user_id, brand_id)
- return logo_path
+ if row is None:
+ raise LookupError("Brand not found.")
+ return row["logo_path"]
def update_logo_path(
self,
@@ -111,13 +204,16 @@ def update_logo_path(
logo_path: str | None,
) -> Brand:
with self.engine.begin() as connection:
+ self.lock_owned_brand_without_asset_operation(
+ connection, user_id, brand_id
+ )
row = connection.execute(
text(
"""
UPDATE brands
SET logo_path = :logo_path
WHERE id = :brand_id AND owner_user_id = :owner_user_id
- RETURNING id, name, logo_path, created_at
+ RETURNING id, name, logo_path, deletion_state, created_at
"""
),
{
@@ -131,8 +227,44 @@ def update_logo_path(
raise LookupError("Brand not found.")
return self._to_brand(row)
- def delete_brand(self, user_id: str, brand_id: UUID) -> None:
+ def mark_cleanup_required(self, user_id: str, brand_id: UUID) -> Brand:
with self.engine.begin() as connection:
+ current = self.lock_owned_brand(connection, user_id, brand_id)
+ self.require_no_asset_operation(connection, brand_id)
+ if current["deletion_state"] == "cleanup_required":
+ return self._to_brand(current)
+ row = connection.execute(
+ text(
+ """
+ UPDATE brands
+ SET deletion_state = 'cleanup_required'
+ WHERE id = :brand_id AND owner_user_id = :owner_user_id
+ RETURNING id, name, logo_path, deletion_state, created_at
+ """
+ ),
+ {"brand_id": brand_id, "owner_user_id": user_id},
+ ).mappings().one_or_none()
+
+ if row is None:
+ raise LookupError("Brand not found.")
+ return self._to_brand(row)
+
+ def delete_brand_after_cleanup(self, user_id: str, brand_id: UUID) -> None:
+ with self.engine.begin() as connection:
+ current = self.lock_owned_brand(connection, user_id, brand_id)
+ self.require_no_asset_operation(connection, brand_id)
+ if current["deletion_state"] != "cleanup_required":
+ raise BrandCleanupRequiredError
+ has_provider_keys = connection.execute(
+ text(
+ "SELECT EXISTS ("
+ "SELECT 1 FROM provider_keys WHERE brand_id = :brand_id"
+ ")"
+ ),
+ {"brand_id": brand_id},
+ ).scalar_one()
+ if has_provider_keys:
+ raise BrandCleanupRequiredError
deleted_id = connection.execute(
text(
"""
@@ -147,7 +279,6 @@ def delete_brand(self, user_id: str, brand_id: UUID) -> None:
if deleted_id is None:
raise LookupError("Brand not found.")
-
@lru_cache(maxsize=1)
def get_brand_store() -> BrandStore:
return BrandStore(get_engine())
diff --git a/backend/tests/contract/conftest.py b/backend/tests/contract/conftest.py
new file mode 100644
index 0000000..928673f
--- /dev/null
+++ b/backend/tests/contract/conftest.py
@@ -0,0 +1,15 @@
+import os
+
+import pytest
+
+
+os.environ.setdefault("SUPABASE_URL", "https://example.supabase.co")
+os.environ.setdefault("SUPABASE_SECRET_KEY", "test-secret-key")
+os.environ.setdefault("SUPABASE_JWT_SECRET", "test-jwt-secret")
+
+
+@pytest.fixture(autouse=True)
+def bypass_database_privilege_check_for_contract_tests(monkeypatch: pytest.MonkeyPatch):
+ from backend.app import main
+
+ monkeypatch.setattr(main, "assert_database_role_privileges", lambda: None)
diff --git a/backend/tests/contract/test_brands.py b/backend/tests/contract/test_brands.py
index 63a6062..f17f9a6 100644
--- a/backend/tests/contract/test_brands.py
+++ b/backend/tests/contract/test_brands.py
@@ -11,7 +11,11 @@
from backend.app.main import app
from backend.app.models.brand import Brand, BrandCreate
from backend.app.routes.brands import get_brand_storage, get_brand_store
-from backend.app.services.brand_store import BrandNameTakenError
+from backend.app.services.brand_store import (
+ BrandCleanupRequiredError,
+ BrandMutationInProgressError,
+ BrandNameTakenError,
+)
@dataclass
@@ -20,6 +24,8 @@ class FakeBrandStore:
brands: list[Brand] = field(default_factory=list)
logo_paths: dict[UUID, str | None] = field(default_factory=dict)
owners: dict[UUID, str] = field(default_factory=dict)
+ cleanup_required: set[UUID] = field(default_factory=set)
+ asset_operations: set[UUID] = field(default_factory=set)
def create_brand(self, user_id: str, payload: BrandCreate) -> Brand:
normalized_name = payload.name.casefold()
@@ -48,8 +54,25 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand:
def get_logo_path(self, user_id: str, brand_id: UUID) -> str | None:
self.get_brand(user_id, brand_id)
+ self._check_mutation(brand_id)
return self.logo_paths.get(brand_id)
+ def begin_brand_cleanup(self, user_id: str, brand_id: UUID) -> str | None:
+ self.get_brand(user_id, brand_id)
+ if brand_id in self.asset_operations:
+ raise BrandMutationInProgressError
+ self.cleanup_required.add(brand_id)
+ brand = next(brand for brand in self.brands if brand.id == brand_id)
+ updated_brand = brand.model_copy(update={"cleanup_state": "cleanup_required"})
+ self.brands = [updated_brand if item.id == brand_id else item for item in self.brands]
+ return self.logo_paths.get(brand_id)
+
+ def _check_mutation(self, brand_id: UUID) -> None:
+ if brand_id in self.cleanup_required:
+ raise BrandCleanupRequiredError
+ if brand_id in self.asset_operations:
+ raise BrandMutationInProgressError
+
def update_logo_path(
self,
user_id: str,
@@ -57,6 +80,7 @@ def update_logo_path(
logo_path: str | None,
) -> Brand:
brand = self.get_brand(user_id, brand_id)
+ self._check_mutation(brand_id)
self.logo_paths[brand_id] = logo_path
logo_url = (
f"https://example.supabase.co/storage/v1/object/public/brand-assets/{logo_path}"
@@ -69,21 +93,44 @@ def update_logo_path(
def delete_brand(self, user_id: str, brand_id: UUID) -> None:
self.get_brand(user_id, brand_id)
+ self._check_mutation(brand_id)
+ self.brands = [brand for brand in self.brands if brand.id != brand_id]
+ self.logo_paths.pop(brand_id, None)
+ self.owners.pop(brand_id, None)
+
+ def delete_brand_after_cleanup(self, user_id: str, brand_id: UUID) -> None:
+ self.get_brand(user_id, brand_id)
+ if brand_id in self.asset_operations:
+ raise BrandMutationInProgressError
+ if brand_id not in self.cleanup_required:
+ raise BrandCleanupRequiredError
self.brands = [brand for brand in self.brands if brand.id != brand_id]
self.logo_paths.pop(brand_id, None)
self.owners.pop(brand_id, None)
+ def mark_cleanup_required(self, user_id: str, brand_id: UUID) -> Brand:
+ brand = self.get_brand(user_id, brand_id)
+ self.cleanup_required.add(brand_id)
+ updated_brand = brand.model_copy(update={"cleanup_state": "cleanup_required"})
+ self.brands = [updated_brand if item.id == brand_id else item for item in self.brands]
+ return updated_brand
+
@dataclass
class FakeBrandStorage:
uploads: list[tuple[str, bytes, str]] = field(default_factory=list)
deletes: list[str] = field(default_factory=list)
+ fail_delete: bool = False
async def upload_logo(self, path: str, data: bytes, content_type: str) -> None:
self.uploads.append((path, data, content_type))
async def delete_logo(self, path: str) -> None:
self.deletes.append(path)
+ if self.fail_delete:
+ from backend.app.services.brand_storage import BrandStorageError
+
+ raise BrandStorageError
@pytest.mark.parametrize("name", ["", " ", "A", "A" * 121])
@@ -149,6 +196,7 @@ def test_create_brand_returns_public_contract_shape():
"id": "22222222-2222-2222-2222-222222222222",
"name": "Acme Coffee",
"logo_url": None,
+ "cleanup_state": "normal",
"created_at": "2026-07-25T00:00:00Z",
}
finally:
@@ -193,12 +241,14 @@ def test_list_brands_returns_empty_and_populated_contract_shapes():
"id": "33333333-3333-3333-3333-333333333333",
"name": "New Brand",
"logo_url": None,
+ "cleanup_state": "normal",
"created_at": "2026-07-26T00:00:00Z",
},
{
"id": "22222222-2222-2222-2222-222222222222",
"name": "First Brand",
"logo_url": None,
+ "cleanup_state": "normal",
"created_at": "2026-07-25T00:00:00Z",
},
]
@@ -250,6 +300,92 @@ def test_get_brand_returns_owned_brand_and_opaque_not_found_errors():
app.dependency_overrides.clear()
+def test_cleanup_required_brand_remains_visible_in_list_and_detail():
+ brand = Brand(
+ id=UUID("22222222-2222-2222-2222-222222222222"),
+ name="Cleanup Brand",
+ logo_url=None,
+ cleanup_state="cleanup_required",
+ created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ )
+ store = FakeBrandStore(brands=[brand], cleanup_required={brand.id})
+ app.dependency_overrides[get_current_user] = lambda: CurrentUser(
+ user_id="11111111-1111-1111-1111-111111111111",
+ email="owner@example.com",
+ access_token="eyJ...",
+ )
+ app.dependency_overrides[get_brand_store] = lambda: store
+
+ try:
+ with TestClient(app) as client:
+ list_response = client.get("/api/v1/brands")
+ detail_response = client.get(f"/api/v1/brands/{brand.id}")
+
+ assert list_response.status_code == 200
+ assert list_response.json()["brands"][0]["cleanup_state"] == "cleanup_required"
+ assert detail_response.status_code == 200
+ assert detail_response.json()["cleanup_state"] == "cleanup_required"
+ assert "deletion_state" not in detail_response.json()
+ finally:
+ app.dependency_overrides.clear()
+
+
+@pytest.mark.parametrize(
+ ("fence_field", "expected_code"),
+ [
+ ("cleanup_required", "BRAND_CLEANUP_REQUIRED"),
+ ("asset_operations", "BRAND_MUTATION_IN_PROGRESS"),
+ ],
+)
+def test_logo_and_brand_mutations_respect_brand_fences(
+ fence_field: str,
+ expected_code: str,
+):
+ brand = Brand(
+ id=UUID("22222222-2222-2222-2222-222222222222"),
+ name="Acme Coffee",
+ logo_url=None,
+ created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ )
+ store = FakeBrandStore(brands=[brand])
+ getattr(store, fence_field).add(brand.id)
+ storage = FakeBrandStorage()
+ app.dependency_overrides[get_current_user] = lambda: CurrentUser(
+ user_id="11111111-1111-1111-1111-111111111111",
+ email="owner@example.com",
+ access_token="eyJ...",
+ )
+ app.dependency_overrides[get_brand_store] = lambda: store
+ app.dependency_overrides[get_brand_storage] = lambda: storage
+
+ try:
+ with TestClient(app) as client:
+ upload_response = client.post(
+ f"/api/v1/brands/{brand.id}/logo",
+ files={"file": ("logo.png", b"\x89PNG\r\n\x1a\nvalid", "image/png")},
+ )
+ remove_response = client.delete(f"/api/v1/brands/{brand.id}/logo")
+ delete_response = client.request(
+ "DELETE",
+ f"/api/v1/brands/{brand.id}",
+ json={"confirm_name": brand.name},
+ )
+
+ for response in (upload_response, remove_response):
+ assert response.status_code == 409
+ assert response.json()["error"]["code"] == expected_code
+ if fence_field == "cleanup_required":
+ assert delete_response.status_code == 204
+ else:
+ assert delete_response.status_code == 409
+ assert delete_response.json()["error"]["code"] == expected_code
+ assert storage.uploads == []
+ assert storage.deletes == []
+ assert store.brands == ([] if fence_field == "cleanup_required" else [brand])
+ finally:
+ app.dependency_overrides.clear()
+
+
@pytest.mark.parametrize(
("content", "content_type"),
[
@@ -423,6 +559,59 @@ def test_delete_brand_with_exact_confirmation_removes_brand_and_logo():
app.dependency_overrides.clear()
+def test_delete_brand_storage_failure_retains_cleanup_required_brand():
+ brand = Brand(
+ id=UUID("22222222-2222-2222-2222-222222222222"),
+ name="Acme Coffee",
+ logo_url="https://example.supabase.co/logo.png",
+ created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ )
+ logo_path = f"brands/{brand.id}/logo.png"
+ store = FakeBrandStore(brands=[brand], logo_paths={brand.id: logo_path})
+ storage = FakeBrandStorage(fail_delete=True)
+ app.dependency_overrides[get_current_user] = lambda: CurrentUser(
+ user_id="11111111-1111-1111-1111-111111111111",
+ email="owner@example.com",
+ access_token="eyJ...",
+ )
+ app.dependency_overrides[get_brand_store] = lambda: store
+ app.dependency_overrides[get_brand_storage] = lambda: storage
+
+ try:
+ with TestClient(app) as client:
+ response = client.request(
+ "DELETE",
+ f"/api/v1/brands/{brand.id}",
+ json={"confirm_name": brand.name},
+ )
+
+ assert response.status_code == 503
+ assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+ assert response.json()["error"]["message"] == (
+ "Brand cleanup did not complete. Retry deletion."
+ )
+ assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
+ assert len(store.brands) == 1
+ assert store.brands[0].cleanup_state == "cleanup_required"
+ assert store.logo_paths[brand.id] == logo_path
+ assert storage.deletes == [logo_path]
+
+ storage.fail_delete = False
+ with TestClient(app) as client:
+ retry = client.request(
+ "DELETE",
+ f"/api/v1/brands/{brand.id}",
+ json={"confirm_name": brand.name},
+ )
+
+ assert retry.status_code == 204
+ assert retry.content == b""
+ assert store.brands == []
+ assert storage.deletes == [logo_path, logo_path]
+ finally:
+ app.dependency_overrides.clear()
+
+
@pytest.mark.parametrize("payload", [{}, {"confirm_name": "acme coffee"}])
def test_delete_brand_rejects_missing_or_wrong_confirmation_without_mutation(
payload: dict[str, str],
@@ -470,6 +659,8 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra
store = FakeBrandStore(
brands=[brand],
owners={brand.id: "99999999-9999-9999-9999-999999999999"},
+ cleanup_required={brand.id},
+ asset_operations={brand.id},
)
storage = FakeBrandStorage()
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
@@ -482,6 +673,19 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra
try:
with TestClient(app) as client:
+ non_owner_upload_response = client.post(
+ f"/api/v1/brands/{brand.id}/logo",
+ files={
+ "file": (
+ "logo.png",
+ b"\x89PNG\r\n\x1a\nvalid",
+ "image/png",
+ )
+ },
+ )
+ non_owner_remove_response = client.delete(
+ f"/api/v1/brands/{brand.id}/logo"
+ )
non_owner_response = client.request(
"DELETE",
f"/api/v1/brands/{brand.id}",
@@ -494,7 +698,13 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra
)
assert non_owner_response.status_code == nonexistent_response.status_code == 404
- for response in (non_owner_response, nonexistent_response):
+ for response in (
+ non_owner_upload_response,
+ non_owner_remove_response,
+ non_owner_response,
+ nonexistent_response,
+ ):
+ assert response.status_code == 404
error = response.json()["error"]
assert error["code"] == "BRAND_NOT_FOUND"
assert error["message"] == "Brand not found."
diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py
new file mode 100644
index 0000000..84d0c2c
--- /dev/null
+++ b/backend/tests/contract/test_provider_keys.py
@@ -0,0 +1,400 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from types import SimpleNamespace
+from unittest.mock import MagicMock, Mock
+
+import pytest
+from fastapi import HTTPException, Request
+from starlette.responses import Response
+
+from backend.app import auth, config, main
+
+
+def _request(path: str = "/api/v1/brands/brand-id/keys/key-id/validate") -> Request:
+ return Request({"type": "http", "method": "POST", "path": path, "headers": []})
+
+
+def test_validation_route_gets_absolute_deadline_before_request_handling():
+ request = _request()
+ observed: dict[str, float | None] = {}
+ before = time.monotonic()
+
+ async def call_next(incoming: Request) -> Response:
+ observed["deadline"] = main.get_validation_deadline(incoming)
+ return Response(status_code=204)
+
+ response = asyncio.run(main.request_context_middleware(request, call_next))
+
+ assert response.status_code == 204
+ assert observed["deadline"] is not None
+ assert before + 14.9 <= observed["deadline"] <= before + 15.1
+
+
+def test_non_validation_route_has_no_validation_deadline():
+ request = _request("/api/v1/brands/brand-id/keys")
+
+ async def call_next(incoming: Request) -> Response:
+ assert main.get_validation_deadline(incoming) is None
+ return Response(status_code=204)
+
+ asyncio.run(main.request_context_middleware(request, call_next))
+
+
+@pytest.mark.parametrize(
+ ("status_code", "code", "message"),
+ [
+ (409, "KEY_CLEANUP_REQUIRED", "Key cleanup is required. Retry deletion."),
+ (409, "BRAND_CLEANUP_REQUIRED", "Brand cleanup is required. Retry deletion."),
+ (503, "KEY_CLEANUP_REQUIRED", "Key cleanup did not complete. Retry deletion."),
+ (503, "BRAND_CLEANUP_REQUIRED", "Brand cleanup did not complete. Retry deletion."),
+ ],
+)
+def test_fixed_cleanup_errors_have_safe_message_and_request_id_parity(
+ status_code: int,
+ code: str,
+ message: str,
+):
+ request = _request()
+ request.state.request_id = "11111111-1111-1111-1111-111111111111"
+
+ response = main.safe_error_response(request, status_code, code)
+ body = json.loads(response.body)
+
+ assert response.status_code == status_code
+ assert body == {
+ "error": {
+ "code": code,
+ "message": message,
+ "request_id": "11111111-1111-1111-1111-111111111111",
+ }
+ }
+ assert response.headers["X-Request-Id"] == body["error"]["request_id"]
+
+
+def test_json_log_formatter_allows_only_audited_safe_fields():
+ raw_key = "provider-secret-A1B2"
+ record = logging.LogRecord(
+ "provider_keys",
+ logging.INFO,
+ __file__,
+ 1,
+ "provider validation failed: %s",
+ (raw_key,),
+ None,
+ )
+ record.event = "provider_key_validation"
+ record.request_id = "request-id"
+ record.provider = "openai"
+ record.code = "VALID"
+ record.duration_ms = 42
+ record.provider_request_id = "provider-request-id"
+ record.body = {"key": raw_key}
+ record.label = "Production Key"
+ record.key_hint = "***A1B2"
+ record.vault_secret_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
+ record.sql_parameters = {"secret": raw_key}
+ record.provider_body = f'{{"key":"{raw_key}"}}'
+ record.authorization = f"Bearer {raw_key}"
+ record.exception = f"failed with {raw_key}"
+ record.user_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
+ record.email = "owner@example.com"
+
+ rendered = main._JsonLogFormatter().format(record)
+
+ assert json.loads(rendered) == {
+ "level": "INFO",
+ "logger": "provider_keys",
+ "event": "provider_key_validation",
+ "request_id": "request-id",
+ "provider": "openai",
+ "code": "VALID",
+ "duration_ms": 42,
+ "provider_request_id": "provider-request-id",
+ }
+ for secret in (
+ raw_key,
+ "Production Key",
+ "***A1B2",
+ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ "Bearer",
+ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+ "owner@example.com",
+ ):
+ assert secret not in rendered
+
+
+def test_engine_hides_parameters_and_bounds_database_waits(monkeypatch: pytest.MonkeyPatch):
+ create_engine = Mock(return_value=Mock())
+ monkeypatch.setattr(config, "create_engine", create_engine)
+ monkeypatch.setattr(
+ config,
+ "load_settings",
+ lambda: SimpleNamespace(database_url="postgresql://backend:secret@db/postgres"),
+ )
+ config.get_engine.cache_clear()
+
+ try:
+ config.get_engine()
+ finally:
+ config.get_engine.cache_clear()
+
+ _, kwargs = create_engine.call_args
+ assert kwargs["hide_parameters"] is True
+ assert kwargs["pool_timeout"] == 2
+ assert kwargs["connect_args"]["connect_timeout"] == 2
+ assert "statement_timeout=2000" in kwargs["connect_args"]["options"]
+ assert "lock_timeout=2000" in kwargs["connect_args"]["options"]
+
+
+def test_database_privilege_assertion_fails_closed_without_database_url(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setattr(
+ config,
+ "load_settings",
+ lambda: SimpleNamespace(database_url=None),
+ )
+ get_engine = Mock(side_effect=RuntimeError("DATABASE_URL is required"))
+ monkeypatch.setattr(config, "get_engine", get_engine)
+
+ with pytest.raises(RuntimeError, match="DATABASE_URL is required"):
+ config.assert_database_role_privileges()
+
+ get_engine.assert_called_once_with()
+
+
+def test_startup_invokes_database_privilege_assertion(monkeypatch: pytest.MonkeyPatch):
+ assertion = Mock()
+ monkeypatch.setattr(main, "assert_database_role_privileges", assertion)
+
+ async def start_and_stop() -> None:
+ async with main._lifespan(main.app):
+ pass
+
+ asyncio.run(start_and_stop())
+
+ assertion.assert_called_once_with()
+
+
+def test_database_privilege_assertion_rejects_client_or_underprivileged_role(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ connection = Mock()
+ connection.execute.return_value.mappings.return_value.one.return_value = {
+ "role_name": "authenticated",
+ "is_superuser": False,
+ "role_is_private": False,
+ "can_bypass_forced_rls": False,
+ "application_dml": False,
+ "vault_schema_usage": False,
+ "vault_create": False,
+ "vault_decrypt": False,
+ "vault_secret_select": False,
+ "vault_secret_delete": False,
+ "vault_least_privilege": False,
+ }
+ engine = MagicMock()
+ engine.connect.return_value.__enter__.return_value = connection
+ monkeypatch.setattr(
+ config,
+ "load_settings",
+ lambda: SimpleNamespace(
+ database_url="postgresql://db/postgres",
+ supabase_url="https://hosted.supabase.co",
+ ),
+ )
+ monkeypatch.setattr(config, "get_engine", lambda: engine)
+
+ with pytest.raises(RuntimeError, match="database role lacks required backend privileges"):
+ config.assert_database_role_privileges()
+
+
+@pytest.mark.parametrize(
+ ("role_name", "is_superuser"),
+ [("postgres", True), ("service_role", False)],
+)
+def test_hosted_database_privilege_assertion_rejects_broad_or_client_roles(
+ monkeypatch: pytest.MonkeyPatch,
+ role_name: str,
+ is_superuser: bool,
+):
+ privileges = {
+ "role_name": role_name,
+ "is_superuser": is_superuser,
+ "role_is_private": role_name != "service_role",
+ "can_bypass_forced_rls": True,
+ "application_dml": True,
+ "vault_schema_usage": True,
+ "vault_create": True,
+ "vault_decrypt": True,
+ "vault_secret_select": True,
+ "vault_secret_delete": True,
+ "vault_least_privilege": False,
+ }
+ connection = Mock()
+ connection.execute.return_value.mappings.return_value.one.return_value = privileges
+ engine = MagicMock()
+ engine.connect.return_value.__enter__.return_value = connection
+ monkeypatch.setattr(
+ config,
+ "load_settings",
+ lambda: SimpleNamespace(
+ database_url="postgresql://db/postgres",
+ supabase_url="https://hosted.supabase.co",
+ ),
+ )
+ monkeypatch.setattr(config, "get_engine", lambda: engine)
+
+ with pytest.raises(RuntimeError, match="database role lacks required backend privileges"):
+ config.assert_database_role_privileges()
+
+
+def test_local_postgres_privilege_assertion_allows_documented_exception(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ privileges = {
+ "role_name": "postgres",
+ "is_superuser": False,
+ "role_is_private": True,
+ "can_bypass_forced_rls": True,
+ "application_dml": True,
+ "vault_schema_usage": True,
+ "vault_create": True,
+ "vault_decrypt": True,
+ "vault_secret_select": True,
+ "vault_secret_delete": True,
+ "vault_least_privilege": False,
+ }
+ connection = Mock()
+ connection.execute.return_value.mappings.return_value.one.return_value = privileges
+ engine = MagicMock()
+ engine.connect.return_value.__enter__.return_value = connection
+ monkeypatch.setattr(
+ config,
+ "load_settings",
+ lambda: SimpleNamespace(
+ database_url="postgresql://localhost/postgres",
+ supabase_url="http://127.0.0.1:54321",
+ ),
+ )
+ monkeypatch.setattr(config, "get_engine", lambda: engine)
+
+ config.assert_database_role_privileges()
+
+
+def test_jwks_timeout_uses_remaining_validation_budget_only(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ created_timeouts: list[float] = []
+
+ class FakeJWKClient:
+ def __init__(self, uri: str, **kwargs):
+ created_timeouts.append(kwargs.get("timeout", 30))
+
+ def get_signing_key_from_jwt(self, token: str):
+ raise auth.jwt.PyJWTError
+
+ monkeypatch.setattr(auth, "PyJWKClient", FakeJWKClient)
+ monkeypatch.setattr(
+ auth,
+ "load_settings",
+ lambda: SimpleNamespace(
+ supabase_url="https://example.supabase.co",
+ supabase_jwt_secret="unused",
+ ),
+ )
+ monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "ES256"})
+ auth._get_cached_jwks_client.cache_clear()
+
+ validation_request = _request()
+ validation_request.state.validation_deadline = time.monotonic() + 0.5
+ with pytest.raises(HTTPException):
+ asyncio.run(auth.get_current_user(validation_request, "Bearer token"))
+
+ normal_request = _request("/api/v1/me")
+ with pytest.raises(HTTPException):
+ asyncio.run(auth.get_current_user(normal_request, "Bearer token"))
+
+ assert 0 < created_timeouts[0] <= 0.5
+ assert created_timeouts[1] == 30
+ auth._get_cached_jwks_client.cache_clear()
+
+
+def test_expired_validation_budget_skips_jwks_fetch(monkeypatch: pytest.MonkeyPatch):
+ client = Mock()
+ monkeypatch.setattr(auth, "PyJWKClient", client)
+ monkeypatch.setattr(
+ auth,
+ "load_settings",
+ lambda: SimpleNamespace(
+ supabase_url="https://example.supabase.co",
+ supabase_jwt_secret="unused",
+ ),
+ )
+ monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "ES256"})
+ request = _request()
+ request.state.validation_deadline = time.monotonic() - 0.01
+
+ with pytest.raises(HTTPException):
+ asyncio.run(auth.get_current_user(request, "Bearer token"))
+
+ client.assert_not_called()
+
+
+def test_expired_validation_budget_skips_hs256_decode(monkeypatch: pytest.MonkeyPatch):
+ decode = Mock()
+ monkeypatch.setattr(auth.jwt, "decode", decode)
+ monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "HS256"})
+ monkeypatch.setattr(
+ auth,
+ "load_settings",
+ lambda: SimpleNamespace(
+ supabase_url="https://example.supabase.co",
+ supabase_jwt_secret="unused",
+ ),
+ )
+ request = _request()
+ request.state.validation_deadline = time.monotonic() - 0.01
+
+ with pytest.raises(HTTPException):
+ asyncio.run(auth.get_current_user(request, "Bearer token"))
+
+ decode.assert_not_called()
+
+
+def test_validation_budget_bounds_total_jwks_work(monkeypatch: pytest.MonkeyPatch):
+ class SlowJWKClient:
+ def __init__(self, uri: str, **kwargs):
+ pass
+
+ def get_signing_key_from_jwt(self, token: str):
+ time.sleep(0.2)
+ raise auth.jwt.PyJWTError
+
+ async def slow_to_thread(function, *args):
+ await asyncio.sleep(0.2)
+ return function(*args)
+
+ monkeypatch.setattr(auth, "PyJWKClient", SlowJWKClient)
+ monkeypatch.setattr(asyncio, "to_thread", slow_to_thread)
+ monkeypatch.setattr(
+ auth,
+ "load_settings",
+ lambda: SimpleNamespace(
+ supabase_url="https://example.supabase.co",
+ supabase_jwt_secret="unused",
+ ),
+ )
+ monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "ES256"})
+ request = _request()
+ request.state.validation_deadline = time.monotonic() + 0.03
+ started = time.monotonic()
+
+ with pytest.raises(HTTPException):
+ asyncio.run(auth.get_current_user(request, "Bearer token"))
+
+ assert time.monotonic() - started < 0.15
diff --git a/backend/tests/integration/test_brand_crud.py b/backend/tests/integration/test_brand_crud.py
index 9af1b97..83d99e5 100644
--- a/backend/tests/integration/test_brand_crud.py
+++ b/backend/tests/integration/test_brand_crud.py
@@ -8,6 +8,7 @@
import httpx
import pytest
from fastapi.testclient import TestClient
+from sqlalchemy import text
PNG_A = base64.b64decode(
@@ -50,6 +51,16 @@ def _signup_and_login(
return user_id, token_response.json()["access_token"]
+def _hard_delete_owned_brands(user_id: str) -> None:
+ from backend.app.config import get_engine
+
+ with get_engine().begin() as connection:
+ connection.execute(
+ text("DELETE FROM brands WHERE owner_user_id = :owner_user_id"),
+ {"owner_user_id": user_id},
+ )
+
+
def test_create_brand_against_real_supabase():
supabase_url = _required_env("SUPABASE_URL")
supabase_key = _required_env("SUPABASE_SECRET_KEY")
@@ -223,6 +234,7 @@ def test_create_brand_against_real_supabase():
assert empty_list_response.json() == {"brands": []}
assert created_brand["name"] == "Acme Coffee"
assert created_brand["logo_url"] is None
+ assert created_brand["cleanup_state"] == "normal"
assert single_list_response.status_code == 200
assert [brand["name"] for brand in single_list_response.json()["brands"]] == [
"Acme Coffee"
@@ -235,6 +247,10 @@ def test_create_brand_against_real_supabase():
]
assert detail_response.status_code == 200
assert detail_response.json() == created_brand
+ assert all(
+ brand["cleanup_state"] == "normal"
+ for brand in multi_list_response.json()["brands"]
+ )
assert duplicate_response.status_code == 409
assert duplicate_response.json()["error"]["code"] == "BRAND_NAME_TAKEN"
assert empty_response.status_code == 400
@@ -278,6 +294,7 @@ def test_create_brand_against_real_supabase():
)
assert cleanup_response.is_success
if user_id:
+ _hard_delete_owned_brands(user_id)
supabase_client.delete(
f"{supabase_url}/auth/v1/admin/users/{user_id}",
headers={
@@ -385,6 +402,7 @@ def test_delete_brands_with_and_without_logo_against_real_supabase():
)
assert cleanup_response.is_success
if user_id:
+ _hard_delete_owned_brands(user_id)
supabase_client.delete(
f"{supabase_url}/auth/v1/admin/users/{user_id}",
headers={
@@ -441,6 +459,267 @@ def test_list_and_open_fifty_brands_within_target_time():
assert elapsed < 10
finally:
if user_id:
+ _hard_delete_owned_brands(user_id)
+ supabase_client.delete(
+ f"{supabase_url}/auth/v1/admin/users/{user_id}",
+ headers={
+ "apikey": supabase_key,
+ "Authorization": f"Bearer {supabase_key}",
+ },
+ )
+
+
+def test_cleanup_state_mutation_fences_and_authenticated_delete_restriction():
+ supabase_url = _required_env("SUPABASE_URL")
+ supabase_key = _required_env("SUPABASE_SECRET_KEY")
+ _required_env("SUPABASE_JWT_SECRET")
+ _required_env("DATABASE_URL")
+
+ from backend.app.config import get_engine
+ from backend.app.main import app
+
+ user_id: str | None = None
+ active_brand_id: str | None = None
+ cleanup_brand_id: str | None = None
+ operation_id = str(uuid4())
+
+ with httpx.Client(timeout=30.0) as supabase_client:
+ try:
+ user_id, access_token = _signup_and_login(
+ supabase_client,
+ supabase_url,
+ supabase_key,
+ f"brand-fences-{uuid4().hex[:12]}@example.com",
+ "12345678",
+ )
+ headers = {"Authorization": f"Bearer {access_token}"}
+
+ with TestClient(app) as api_client:
+ active_response = api_client.post(
+ "/api/v1/brands",
+ headers=headers,
+ json={"name": "Active Brand"},
+ )
+ cleanup_response = api_client.post(
+ "/api/v1/brands",
+ headers=headers,
+ json={"name": "Cleanup Brand"},
+ )
+ assert (
+ active_response.status_code
+ == cleanup_response.status_code
+ == 201
+ )
+ active_brand_id = active_response.json()["id"]
+ cleanup_brand_id = cleanup_response.json()["id"]
+ assert active_response.json()["cleanup_state"] == "normal"
+
+ engine = get_engine()
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ """
+ UPDATE brands
+ SET deletion_state = 'cleanup_required'
+ WHERE id = :brand_id
+ """
+ ),
+ {"brand_id": cleanup_brand_id},
+ )
+ connection.execute(
+ text(
+ """
+ INSERT INTO brand_asset_operations (
+ id, brand_id, operation, state, remote_status
+ )
+ VALUES (
+ :id, :brand_id, 'upload', 'in_progress', 'pending'
+ )
+ """
+ ),
+ {"id": operation_id, "brand_id": active_brand_id},
+ )
+
+ list_response = api_client.get("/api/v1/brands", headers=headers)
+ cleanup_detail = api_client.get(
+ f"/api/v1/brands/{cleanup_brand_id}", headers=headers
+ )
+ cleanup_upload = api_client.post(
+ f"/api/v1/brands/{cleanup_brand_id}/logo",
+ headers=headers,
+ files={"file": ("logo.png", PNG_A, "image/png")},
+ )
+ cleanup_remove = api_client.delete(
+ f"/api/v1/brands/{cleanup_brand_id}/logo", headers=headers
+ )
+ cleanup_delete = api_client.request(
+ "DELETE",
+ f"/api/v1/brands/{cleanup_brand_id}",
+ headers=headers,
+ json={"confirm_name": "Cleanup Brand"},
+ )
+ active_upload = api_client.post(
+ f"/api/v1/brands/{active_brand_id}/logo",
+ headers=headers,
+ files={"file": ("logo.png", PNG_A, "image/png")},
+ )
+ active_delete = api_client.request(
+ "DELETE",
+ f"/api/v1/brands/{active_brand_id}",
+ headers=headers,
+ json={"confirm_name": "Active Brand"},
+ )
+
+ assert list_response.status_code == 200
+ states = {
+ brand["id"]: brand["cleanup_state"]
+ for brand in list_response.json()["brands"]
+ }
+ assert states == {
+ active_brand_id: "normal",
+ cleanup_brand_id: "cleanup_required",
+ }
+ assert cleanup_detail.status_code == 200
+ assert cleanup_detail.json()["cleanup_state"] == "cleanup_required"
+ assert "deletion_state" not in cleanup_detail.json()
+ for response in (cleanup_upload, cleanup_remove):
+ assert response.status_code == 409
+ assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+ assert cleanup_delete.status_code == 204
+ for response in (active_upload, active_delete):
+ assert response.status_code == 409
+ assert (
+ response.json()["error"]["code"]
+ == "BRAND_MUTATION_IN_PROGRESS"
+ )
+
+ authenticated_delete = supabase_client.delete(
+ f"{supabase_url}/rest/v1/brands",
+ params={"id": f"eq.{active_brand_id}"},
+ headers={
+ "apikey": supabase_key,
+ "Authorization": f"Bearer {access_token}",
+ },
+ )
+ assert authenticated_delete.status_code in {401, 403}
+ assert api_client.get(
+ f"/api/v1/brands/{active_brand_id}", headers=headers
+ ).status_code == 200
+
+ auth_user_delete = supabase_client.delete(
+ f"{supabase_url}/auth/v1/admin/users/{user_id}",
+ headers={
+ "apikey": supabase_key,
+ "Authorization": f"Bearer {supabase_key}",
+ },
+ )
+ assert not auth_user_delete.is_success
+ with engine.connect() as connection:
+ assert connection.execute(
+ text("SELECT count(*) FROM brands WHERE owner_user_id = :user_id"),
+ {"user_id": user_id},
+ ).scalar_one() == 1
+ finally:
+ if active_brand_id:
+ with get_engine().begin() as connection:
+ connection.execute(
+ text("DELETE FROM brand_asset_operations WHERE id = :id"),
+ {"id": operation_id},
+ )
+ if user_id:
+ _hard_delete_owned_brands(user_id)
+ supabase_client.delete(
+ f"{supabase_url}/auth/v1/admin/users/{user_id}",
+ headers={
+ "apikey": supabase_key,
+ "Authorization": f"Bearer {supabase_key}",
+ },
+ )
+
+
+def test_brand_storage_failure_persists_cleanup_required_retry_anchor():
+ supabase_url = _required_env("SUPABASE_URL")
+ supabase_key = _required_env("SUPABASE_SECRET_KEY")
+ _required_env("SUPABASE_JWT_SECRET")
+ _required_env("DATABASE_URL")
+
+ from backend.app.config import get_engine
+ from backend.app.main import app
+ from backend.app.routes.brands import get_brand_storage
+ from backend.app.services.brand_storage import BrandStorageError
+
+ class ControllableStorage:
+ fail_delete = True
+
+ async def delete_logo(self, path: str) -> None:
+ if self.fail_delete:
+ raise BrandStorageError
+
+ user_id: str | None = None
+ brand_id = str(uuid4())
+ logo_path = f"brands/{brand_id}/logo.png"
+ with httpx.Client(timeout=30.0) as supabase_client:
+ try:
+ user_id, access_token = _signup_and_login(
+ supabase_client,
+ supabase_url,
+ supabase_key,
+ f"brand-cleanup-anchor-{uuid4().hex[:12]}@example.com",
+ "12345678",
+ )
+ with get_engine().begin() as connection:
+ connection.execute(
+ text(
+ "INSERT INTO brands (id, owner_user_id, name, logo_path) "
+ "VALUES (:id, :owner_user_id, 'Cleanup Anchor', :logo_path)"
+ ),
+ {
+ "id": brand_id,
+ "owner_user_id": user_id,
+ "logo_path": logo_path,
+ },
+ )
+
+ storage = ControllableStorage()
+ app.dependency_overrides[get_brand_storage] = lambda: storage
+ with TestClient(app) as api_client:
+ response = api_client.request(
+ "DELETE",
+ f"/api/v1/brands/{brand_id}",
+ headers={"Authorization": f"Bearer {access_token}"},
+ json={"confirm_name": "Cleanup Anchor"},
+ )
+
+ assert response.status_code == 503
+ assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+ with get_engine().connect() as connection:
+ retained = connection.execute(
+ text(
+ "SELECT deletion_state, logo_path FROM brands WHERE id = :id"
+ ),
+ {"id": brand_id},
+ ).one()
+ assert retained.deletion_state == "cleanup_required"
+ assert retained.logo_path == logo_path
+
+ storage.fail_delete = False
+ with TestClient(app) as api_client:
+ retry = api_client.request(
+ "DELETE",
+ f"/api/v1/brands/{brand_id}",
+ headers={"Authorization": f"Bearer {access_token}"},
+ json={"confirm_name": "Cleanup Anchor"},
+ )
+ assert retry.status_code == 204
+ with get_engine().connect() as connection:
+ assert connection.execute(
+ text("SELECT count(*) FROM brands WHERE id = :id"),
+ {"id": brand_id},
+ ).scalar_one() == 0
+ finally:
+ app.dependency_overrides.clear()
+ if user_id:
+ _hard_delete_owned_brands(user_id)
supabase_client.delete(
f"{supabase_url}/auth/v1/admin/users/{user_id}",
headers={
diff --git a/backend/tests/integration/test_brand_rls.py b/backend/tests/integration/test_brand_rls.py
index 607b792..7c3450c 100644
--- a/backend/tests/integration/test_brand_rls.py
+++ b/backend/tests/integration/test_brand_rls.py
@@ -65,65 +65,39 @@ def _visible_brand_ids(engine: Engine, access_token: str, brand_id: str) -> list
return [str(row) for row in rows]
-def _cross_owner_mutation_results(
+def _assert_brand_mutations_are_blocked(
engine: Engine,
access_token: str,
brand_id: str,
-) -> tuple[list[str], list[str]]:
- claims = jwt.decode(access_token, options={"verify_signature": False})
-
- with engine.begin() as connection:
- connection.execute(text("SET LOCAL ROLE authenticated"))
- connection.execute(
- text("SELECT set_config('request.jwt.claims', :claims, true)"),
- {"claims": json.dumps(claims)},
- )
- updated = connection.execute(
- text(
- """
- UPDATE brands
- SET name = 'Unauthorized Update'
- WHERE id = :brand_id
- RETURNING id
- """
- ),
- {"brand_id": brand_id},
- ).scalars().all()
- deleted = connection.execute(
- text("DELETE FROM brands WHERE id = :brand_id RETURNING id"),
- {"brand_id": brand_id},
- ).scalars().all()
-
- return [str(row) for row in updated], [str(row) for row in deleted]
-
-
-def _assert_cross_owner_insert_is_blocked(
- engine: Engine,
- access_token: str,
owner_user_id: str,
) -> None:
claims = jwt.decode(access_token, options={"verify_signature": False})
-
- with pytest.raises(DBAPIError) as exc_info:
- with engine.begin() as connection:
- connection.execute(text("SET LOCAL ROLE authenticated"))
- connection.execute(
- text("SELECT set_config('request.jwt.claims', :claims, true)"),
- {"claims": json.dumps(claims)},
- )
- connection.execute(
- text(
- """
- INSERT INTO brands (owner_user_id, name)
- VALUES (:owner_user_id, 'Unauthorized Insert')
- """
- ),
- {"owner_user_id": owner_user_id},
- )
-
- original = exc_info.value.orig
- sqlstate = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)
- assert sqlstate == "42501"
+ statements = (
+ (
+ "INSERT INTO brands (owner_user_id, name) "
+ "VALUES (:owner_user_id, 'Unauthorized Insert')",
+ {"owner_user_id": owner_user_id},
+ ),
+ (
+ "UPDATE brands SET name = 'Unauthorized Update' WHERE id = :brand_id",
+ {"brand_id": brand_id},
+ ),
+ ("DELETE FROM brands WHERE id = :brand_id", {"brand_id": brand_id}),
+ )
+ for statement, parameters in statements:
+ with pytest.raises(DBAPIError) as exc_info:
+ with engine.begin() as connection:
+ connection.execute(text("SET LOCAL ROLE authenticated"))
+ connection.execute(
+ text("SELECT set_config('request.jwt.claims', :claims, true)"),
+ {"claims": json.dumps(claims)},
+ )
+ connection.execute(text(statement), parameters)
+ original = exc_info.value.orig
+ sqlstate = getattr(original, "sqlstate", None) or getattr(
+ original, "pgcode", None
+ )
+ assert sqlstate == "42501"
def test_brand_operations_are_owner_scoped_at_api_and_database_layers():
@@ -250,8 +224,8 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers():
engine = get_engine()
assert _visible_brand_ids(engine, token_a, brand_id) == [brand_id]
assert _visible_brand_ids(engine, token_b, brand_id) == []
- assert _cross_owner_mutation_results(engine, token_b, brand_id) == ([], [])
- _assert_cross_owner_insert_is_blocked(engine, token_b, user_a_id)
+ _assert_brand_mutations_are_blocked(engine, token_a, brand_id, user_a_id)
+ _assert_brand_mutations_are_blocked(engine, token_b, brand_id, user_a_id)
with TestClient(app) as api_client:
post_rls_owner_response = api_client.get(
@@ -279,6 +253,13 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers():
},
)
assert cleanup_response.is_success
+ from backend.app.config import get_engine
+
+ with get_engine().begin() as connection:
+ connection.execute(
+ text("DELETE FROM brands WHERE id = :brand_id"),
+ {"brand_id": brand_id},
+ )
for user_id in (user_a_id, user_b_id):
if user_id:
supabase_client.delete(
diff --git a/backend/tests/integration/test_provider_key_rls.py b/backend/tests/integration/test_provider_key_rls.py
new file mode 100644
index 0000000..629cdb0
--- /dev/null
+++ b/backend/tests/integration/test_provider_key_rls.py
@@ -0,0 +1,666 @@
+from __future__ import annotations
+
+import json
+import os
+from uuid import uuid4
+
+import httpx
+import jwt
+import pytest
+from sqlalchemy import text
+from sqlalchemy.engine import Engine
+from sqlalchemy.exc import DBAPIError
+
+
+SAFE_COLUMNS = (
+ "id, provider, label, key_hint, lifecycle, is_active, is_valid, "
+ "last_validated_at, last_validation_error, created_at"
+)
+
+
+class SecurityFixture(dict):
+ def __repr__(self) -> str:
+ return ""
+
+
+def _required_env(name: str) -> str:
+ value = os.getenv(name)
+ if not value:
+ pytest.skip(f"{name} is required for integration tests")
+ return value
+
+
+def _signup_and_login(
+ client: httpx.Client,
+ supabase_url: str,
+ supabase_key: str,
+ email: str,
+) -> tuple[str, str]:
+ password = "12345678"
+ signup = client.post(
+ f"{supabase_url}/auth/v1/signup",
+ headers={"apikey": supabase_key, "Content-Type": "application/json"},
+ json={"email": email, "password": password},
+ )
+ assert signup.status_code in {200, 201}
+ token = client.post(
+ f"{supabase_url}/auth/v1/token?grant_type=password",
+ headers={"apikey": supabase_key, "Content-Type": "application/json"},
+ json={"email": email, "password": password},
+ )
+ assert token.status_code == 200
+ return signup.json()["user"]["id"], token.json()["access_token"]
+
+
+def _execute_as_authenticated(
+ engine: Engine,
+ access_token: str,
+ statement: str,
+ parameters: dict | None = None,
+):
+ claims = jwt.decode(access_token, options={"verify_signature": False})
+ with engine.begin() as connection:
+ connection.execute(text("SET LOCAL ROLE authenticated"))
+ connection.execute(
+ text("SELECT set_config('request.jwt.claims', :claims, true)"),
+ {"claims": json.dumps(claims)},
+ )
+ return connection.execute(text(statement), parameters or {}).mappings().all()
+
+
+def _assert_permission_denied(operation) -> None:
+ with pytest.raises(DBAPIError) as exc_info:
+ operation()
+ original = exc_info.value.orig
+ sqlstate = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)
+ assert sqlstate == "42501"
+
+
+@pytest.fixture
+def security_fixture():
+ supabase_url = _required_env("SUPABASE_URL")
+ supabase_key = _required_env("SUPABASE_SECRET_KEY")
+ _required_env("DATABASE_URL")
+
+ from backend.app.config import get_engine
+
+ engine = get_engine()
+ user_ids: list[str] = []
+ brand_ids: list[str] = []
+ key_ids: list[str] = []
+ with httpx.Client(timeout=30.0) as client:
+ try:
+ user_a, token_a = _signup_and_login(
+ client,
+ supabase_url,
+ supabase_key,
+ f"provider-rls-a-{uuid4().hex[:10]}@example.com",
+ )
+ user_b, token_b = _signup_and_login(
+ client,
+ supabase_url,
+ supabase_key,
+ f"provider-rls-b-{uuid4().hex[:10]}@example.com",
+ )
+ user_ids.extend((user_a, user_b))
+ brand_a, brand_b = str(uuid4()), str(uuid4())
+ key_a, key_b = str(uuid4()), str(uuid4())
+ brand_ids.extend((brand_a, brand_b))
+ key_ids.extend((key_a, key_b))
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ "INSERT INTO brands (id, owner_user_id, name) VALUES "
+ "(:brand_a, :user_a, 'Provider RLS A'), "
+ "(:brand_b, :user_b, 'Provider RLS B')"
+ ),
+ {
+ "brand_a": brand_a,
+ "user_a": user_a,
+ "brand_b": brand_b,
+ "user_b": user_b,
+ },
+ )
+ connection.execute(
+ text(
+ "INSERT INTO provider_keys "
+ "(id, brand_id, provider, vault_secret_id, label, key_hint) "
+ "VALUES (:key_a, :brand_a, 'openai', :vault_a, 'A', '***a-_1'), "
+ "(:key_b, :brand_b, 'gemini', :vault_b, 'B', '***B_2-')"
+ ),
+ {
+ "key_a": key_a,
+ "brand_a": brand_a,
+ "vault_a": str(uuid4()),
+ "key_b": key_b,
+ "brand_b": brand_b,
+ "vault_b": str(uuid4()),
+ },
+ )
+ yield SecurityFixture({
+ "engine": engine,
+ "client": client,
+ "supabase_url": supabase_url,
+ "supabase_key": supabase_key,
+ "user_a": user_a,
+ "token_a": token_a,
+ "token_b": token_b,
+ "brand_a": brand_a,
+ "key_a": key_a,
+ })
+ finally:
+ if key_ids:
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ "DELETE FROM provider_keys "
+ "WHERE id = ANY(CAST(:ids AS uuid[]))"
+ ),
+ {"ids": key_ids},
+ )
+ if brand_ids:
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ "DELETE FROM brands WHERE id = ANY(CAST(:ids AS uuid[]))"
+ ),
+ {"ids": brand_ids},
+ )
+ for user_id in user_ids:
+ client.delete(
+ f"{supabase_url}/auth/v1/admin/users/{user_id}",
+ headers={
+ "apikey": supabase_key,
+ "Authorization": f"Bearer {supabase_key}",
+ },
+ )
+
+
+def test_schema_constraints_indexes_triggers_and_forced_rls(security_fixture):
+ engine = security_fixture["engine"]
+ with engine.connect() as connection:
+ enums = connection.execute(
+ text(
+ "SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) "
+ "FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid "
+ "WHERE t.typname = ANY(:names) GROUP BY t.typname"
+ ),
+ {
+ "names": [
+ "provider_t",
+ "provider_key_lifecycle_t",
+ "brand_deletion_state_t",
+ ]
+ },
+ ).all()
+ assert {name: list(values) for name, values in enums} == {
+ "provider_t": ["openai", "gemini"],
+ "provider_key_lifecycle_t": ["normal", "cleanup_required"],
+ "brand_deletion_state_t": ["active", "cleanup_required"],
+ }
+
+ helper = connection.execute(
+ text(
+ "SELECT p.prosecdef, p.provolatile, p.proconfig, "
+ "has_function_privilege('public', p.oid, 'EXECUTE') "
+ "FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
+ "WHERE n.nspname = 'private' AND p.proname = 'is_brand_owner' "
+ "AND pg_get_function_identity_arguments(p.oid) = 'p_brand_id uuid'"
+ )
+ ).one()
+ assert helper == (True, "s", ["search_path=\"\""], False)
+ assert connection.execute(
+ text(
+ "SELECT has_schema_privilege('authenticated', 'private', 'USAGE') "
+ "AND NOT has_schema_privilege('authenticated', 'private', 'CREATE') "
+ "AND has_function_privilege("
+ "'authenticated', 'private.is_brand_owner(uuid)', 'EXECUTE')"
+ )
+ ).scalar_one()
+
+ table_security = connection.execute(
+ text(
+ "SELECT relname, relrowsecurity, relforcerowsecurity "
+ "FROM pg_class WHERE relname = ANY(:tables)"
+ ),
+ {
+ "tables": [
+ "brands",
+ "provider_keys",
+ "provider_key_idempotency",
+ "brand_asset_operations",
+ ]
+ },
+ ).all()
+ assert set(table_security) == {
+ ("brands", True, True),
+ ("provider_keys", True, True),
+ ("provider_key_idempotency", True, True),
+ ("brand_asset_operations", True, True),
+ }
+
+ policies = connection.execute(
+ text(
+ "SELECT tablename, cmd FROM pg_policies "
+ "WHERE tablename = ANY(:tables)"
+ ),
+ {
+ "tables": [
+ "provider_keys",
+ "provider_key_idempotency",
+ "brand_asset_operations",
+ ]
+ },
+ ).all()
+ assert set(policies) == {
+ ("provider_keys", "SELECT"),
+ ("provider_keys", "INSERT"),
+ ("provider_keys", "UPDATE"),
+ ("provider_keys", "DELETE"),
+ ("provider_key_idempotency", "ALL"),
+ ("brand_asset_operations", "ALL"),
+ }
+
+ indexes = set(
+ connection.execute(
+ text("SELECT indexname FROM pg_indexes WHERE tablename = 'provider_keys'")
+ ).scalars()
+ )
+ assert {
+ "uq_provider_keys_vault_secret",
+ "uq_provider_keys_one_active",
+ "idx_provider_keys_brand_provider_created",
+ "idx_provider_keys_cleanup",
+ } <= indexes
+
+ constraint_names = set(
+ connection.execute(
+ text(
+ "SELECT conname FROM pg_constraint "
+ "WHERE conrelid = ANY(ARRAY["
+ "'brands'::regclass, 'provider_keys'::regclass, "
+ "'provider_key_idempotency'::regclass, "
+ "'brand_asset_operations'::regclass])"
+ )
+ ).scalars()
+ )
+ assert {
+ "brands_logo_path_check",
+ "brands_owner_user_id_fkey",
+ "provider_keys_brand_id_fkey",
+ "provider_keys_label_check",
+ "provider_keys_key_hint_check",
+ "provider_keys_cleanup_inactive",
+ "provider_keys_invalid_inactive",
+ "provider_keys_safe_validation_error",
+ "provider_keys_validation_lease_pair",
+ "provider_keys_cleanup_without_validation",
+ "provider_keys_validation_result",
+ "provider_key_idempotency_brand_id_fkey",
+ "provider_key_idempotency_provider_key_id_fkey",
+ "provider_key_idempotency_state_check",
+ "uq_provider_key_idempotency_brand_request",
+ "brand_asset_operations_brand_id_fkey",
+ "brand_asset_operations_operation_check",
+ "brand_asset_operations_state_check",
+ "brand_asset_operations_remote_status_check",
+ "uq_brand_asset_operations_brand",
+ } <= constraint_names
+
+ definitions = "\n".join(
+ connection.execute(
+ text(
+ "SELECT pg_get_constraintdef(oid) FROM pg_constraint "
+ "WHERE conrelid = ANY(ARRAY["
+ "'brands'::regclass, 'provider_keys'::regclass, "
+ "'provider_key_idempotency'::regclass, "
+ "'brand_asset_operations'::regclass])"
+ )
+ ).scalars()
+ )
+ for expected in (
+ "ON DELETE RESTRICT",
+ "ON DELETE CASCADE",
+ "ON DELETE SET NULL",
+ "INVALID_CREDENTIAL",
+ "validation_token",
+ "validation_lease_expires_at",
+ "cleanup_required",
+ "in_progress",
+ "unknown",
+ ):
+ assert expected in definitions
+
+ foreign_keys = dict(
+ connection.execute(
+ text(
+ "SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint "
+ "WHERE conname = ANY(:names)"
+ ),
+ {
+ "names": [
+ "brands_owner_user_id_fkey",
+ "provider_keys_brand_id_fkey",
+ "provider_key_idempotency_brand_id_fkey",
+ "provider_key_idempotency_provider_key_id_fkey",
+ "brand_asset_operations_brand_id_fkey",
+ ]
+ },
+ ).all()
+ )
+ assert "FOREIGN KEY (owner_user_id)" in foreign_keys["brands_owner_user_id_fkey"]
+ assert "REFERENCES auth.users(id) ON DELETE RESTRICT" in foreign_keys[
+ "brands_owner_user_id_fkey"
+ ]
+ assert "ON DELETE RESTRICT" in foreign_keys["provider_keys_brand_id_fkey"]
+ assert "ON DELETE CASCADE" in foreign_keys[
+ "provider_key_idempotency_brand_id_fkey"
+ ]
+ assert "ON DELETE SET NULL" in foreign_keys[
+ "provider_key_idempotency_provider_key_id_fkey"
+ ]
+ assert "ON DELETE RESTRICT" in foreign_keys[
+ "brand_asset_operations_brand_id_fkey"
+ ]
+
+ triggers = set(
+ connection.execute(
+ text(
+ "SELECT trigger_name FROM information_schema.triggers "
+ "WHERE event_object_table = ANY(:tables)"
+ ),
+ {"tables": ["brands", "provider_keys", "brand_asset_operations"]},
+ ).scalars()
+ )
+ assert {
+ "trg_brands_cleanup_irreversible",
+ "trg_provider_keys_cleanup_irreversible",
+ "trg_provider_keys_updated_at",
+ "trg_brand_asset_operations_updated_at",
+ } <= triggers
+
+ safe_grants = set(
+ connection.execute(
+ text(
+ "SELECT table_name, column_name FROM information_schema.column_privileges "
+ "WHERE grantee = 'authenticated' AND privilege_type = 'SELECT' "
+ "AND table_name IN ('brands', 'provider_keys')"
+ )
+ ).all()
+ )
+ assert safe_grants == {
+ ("brands", column)
+ for column in (
+ "id",
+ "name",
+ "logo_path",
+ "deletion_state",
+ "created_at",
+ "updated_at",
+ )
+ } | {
+ ("provider_keys", column)
+ for column in (
+ "id",
+ "provider",
+ "label",
+ "key_hint",
+ "lifecycle",
+ "is_active",
+ "is_valid",
+ "last_validated_at",
+ "last_validation_error",
+ "created_at",
+ )
+ }
+
+ for role in ("anon", "authenticated"):
+ for table in ("provider_key_idempotency", "brand_asset_operations"):
+ for privilege in ("SELECT", "INSERT", "UPDATE", "DELETE"):
+ assert not connection.execute(
+ text(
+ "SELECT has_table_privilege(:role, :table, :privilege)"
+ ),
+ {"role": role, "table": table, "privilege": privilege},
+ ).scalar_one()
+
+ for role in ("anon", "authenticated"):
+ assert not connection.execute(
+ text("SELECT has_schema_privilege(:role, 'vault', 'USAGE')"),
+ {"role": role},
+ ).scalar_one()
+ for relation, privilege in (
+ ("vault.secrets", "SELECT"),
+ ("vault.secrets", "DELETE"),
+ ("vault.decrypted_secrets", "SELECT"),
+ ):
+ assert not connection.execute(
+ text("SELECT has_table_privilege(:role, :table, :privilege)"),
+ {"role": role, "table": relation, "privilege": privilege},
+ ).scalar_one()
+ for function in (
+ "vault.create_secret(text,text,text,uuid)",
+ "vault.update_secret(uuid,text,text,text,uuid)",
+ ):
+ assert not connection.execute(
+ text(
+ "SELECT has_function_privilege("
+ ":role, :function, 'EXECUTE')"
+ ),
+ {"role": role, "function": function},
+ ).scalar_one()
+
+ assert connection.execute(
+ text("SELECT has_schema_privilege('service_role', 'vault', 'USAGE')")
+ ).scalar_one()
+ assert not connection.execute(
+ text("SELECT has_schema_privilege('service_role', 'vault', 'CREATE')")
+ ).scalar_one()
+ assert connection.execute(
+ text(
+ "SELECT has_function_privilege('service_role', "
+ "'vault.create_secret(text,text,text,uuid)', 'EXECUTE')"
+ )
+ ).scalar_one()
+ for relation, column in (
+ ("vault.decrypted_secrets", "id"),
+ ("vault.decrypted_secrets", "decrypted_secret"),
+ ("vault.secrets", "id"),
+ ):
+ assert connection.execute(
+ text(
+ "SELECT has_column_privilege('service_role', :table, :column, 'SELECT')"
+ ),
+ {"table": relation, "column": column},
+ ).scalar_one()
+ assert connection.execute(
+ text("SELECT has_table_privilege('service_role', 'vault.secrets', 'DELETE')")
+ ).scalar_one()
+
+
+def test_owner_safe_reads_and_authenticated_dml_or_internal_reads_are_denied(
+ security_fixture,
+):
+ fixture = security_fixture
+ engine = fixture["engine"]
+ own_rows = _execute_as_authenticated(
+ engine,
+ fixture["token_a"],
+ f"SELECT {SAFE_COLUMNS} FROM provider_keys ORDER BY id",
+ )
+ assert [str(row["id"]) for row in own_rows] == [fixture["key_a"]]
+ assert _execute_as_authenticated(
+ engine,
+ fixture["token_b"],
+ f"SELECT {SAFE_COLUMNS} FROM provider_keys WHERE id = :key_id",
+ {"key_id": fixture["key_a"]},
+ ) == []
+
+ for column in (
+ "brand_id",
+ "vault_secret_id",
+ "validation_token",
+ "validation_lease_expires_at",
+ "last_used_at",
+ "updated_at",
+ ):
+ _assert_permission_denied(
+ lambda column=column: _execute_as_authenticated(
+ engine, fixture["token_a"], f"SELECT {column} FROM provider_keys"
+ )
+ )
+
+ statements = (
+ (
+ "INSERT INTO provider_keys "
+ "(id, brand_id, provider, vault_secret_id, key_hint) "
+ "VALUES (:id, :brand_id, 'openai', :vault_id, '***abcd')",
+ {
+ "id": str(uuid4()),
+ "brand_id": fixture["brand_a"],
+ "vault_id": str(uuid4()),
+ },
+ ),
+ (
+ "UPDATE provider_keys SET label = 'changed' WHERE id = :id",
+ {"id": fixture["key_a"]},
+ ),
+ ("DELETE FROM provider_keys WHERE id = :id", {"id": fixture["key_a"]}),
+ )
+ for token in (fixture["token_a"], fixture["token_b"]):
+ for statement, parameters in statements:
+ _assert_permission_denied(
+ lambda statement=statement, parameters=parameters, token=token: (
+ _execute_as_authenticated(engine, token, statement, parameters)
+ )
+ )
+
+
+def test_backend_only_tables_vault_and_private_rpc_are_denied(security_fixture):
+ fixture = security_fixture
+ engine = fixture["engine"]
+ for table in ("provider_key_idempotency", "brand_asset_operations"):
+ statements = (
+ f"SELECT * FROM {table}",
+ f"INSERT INTO {table} (id, brand_id) VALUES (:id, :brand_id)",
+ f"UPDATE {table} SET brand_id = :brand_id WHERE false",
+ f"DELETE FROM {table} WHERE false",
+ )
+ for statement in statements:
+ _assert_permission_denied(
+ lambda statement=statement: _execute_as_authenticated(
+ engine,
+ fixture["token_a"],
+ statement,
+ {"id": str(uuid4()), "brand_id": fixture["brand_a"]},
+ )
+ )
+
+ for statement in (
+ "SELECT id FROM vault.secrets",
+ "SELECT id, decrypted_secret FROM vault.decrypted_secrets",
+ "SELECT vault.create_secret('denied', NULL, NULL, gen_random_uuid())",
+ "SELECT vault.update_secret(gen_random_uuid(), 'denied', NULL, NULL, NULL)",
+ "DELETE FROM vault.secrets WHERE false",
+ ):
+ _assert_permission_denied(
+ lambda statement=statement: _execute_as_authenticated(
+ engine, fixture["token_a"], statement
+ )
+ )
+
+ client = fixture["client"]
+ headers = {
+ "apikey": fixture["supabase_key"],
+ "Authorization": f"Bearer {fixture['token_a']}",
+ }
+ for table in ("provider_key_idempotency", "brand_asset_operations"):
+ response = client.get(
+ f"{fixture['supabase_url']}/rest/v1/{table}", headers=headers
+ )
+ assert response.status_code in {401, 403}
+ helper_rpc = client.post(
+ f"{fixture['supabase_url']}/rest/v1/rpc/is_brand_owner",
+ headers={**headers, "Content-Type": "application/json"},
+ json={"p_brand_id": fixture["brand_a"]},
+ )
+ assert helper_rpc.status_code == 404
+ vault_profile = client.get(
+ f"{fixture['supabase_url']}/rest/v1/secrets",
+ headers={**headers, "Accept-Profile": "vault"},
+ )
+ assert vault_profile.status_code in {400, 401, 403, 404, 406}
+ vault_rpc = client.post(
+ f"{fixture['supabase_url']}/rest/v1/rpc/create_secret",
+ headers={**headers, "Content-Type": "application/json"},
+ json={"new_secret": "denied"},
+ )
+ assert vault_rpc.status_code == 404
+
+
+def test_backend_role_has_required_application_and_vault_privileges(
+ security_fixture,
+):
+ engine = security_fixture["engine"]
+ with engine.connect() as connection:
+ role = connection.execute(
+ text(
+ "SELECT rolname, rolsuper, rolbypassrls FROM pg_roles "
+ "WHERE rolname = current_user"
+ )
+ ).one()
+ assert role.rolname not in {"anon", "authenticated"}
+ assert role.rolsuper or role.rolbypassrls
+
+ for table in (
+ "brands",
+ "provider_keys",
+ "provider_key_idempotency",
+ "brand_asset_operations",
+ ):
+ for privilege in ("SELECT", "INSERT", "UPDATE", "DELETE"):
+ assert connection.execute(
+ text(
+ "SELECT has_table_privilege(current_user, :table, :privilege)"
+ ),
+ {"table": table, "privilege": privilege},
+ ).scalar_one()
+
+ assert connection.execute(
+ text("SELECT has_schema_privilege(current_user, 'vault', 'USAGE')")
+ ).scalar_one()
+ assert connection.execute(
+ text(
+ "SELECT has_function_privilege(current_user, "
+ "'vault.create_secret(text,text,text,uuid)', 'EXECUTE')"
+ )
+ ).scalar_one()
+ for relation, column in (
+ ("vault.decrypted_secrets", "id"),
+ ("vault.decrypted_secrets", "decrypted_secret"),
+ ("vault.secrets", "id"),
+ ):
+ assert connection.execute(
+ text(
+ "SELECT has_column_privilege(current_user, :table, :column, 'SELECT')"
+ ),
+ {"table": relation, "column": column},
+ ).scalar_one()
+ assert connection.execute(
+ text("SELECT has_table_privilege(current_user, 'vault.secrets', 'DELETE')")
+ ).scalar_one()
+ if role.rolname != "postgres" and not role.rolsuper:
+ assert not connection.execute(
+ text("SELECT has_schema_privilege(current_user, 'vault', 'CREATE')")
+ ).scalar_one()
+ assert not connection.execute(
+ text(
+ "SELECT has_function_privilege(current_user, "
+ "'vault.update_secret(uuid,text,text,text,uuid)', 'EXECUTE')"
+ )
+ ).scalar_one()
+ for relation in ("vault.decrypted_secrets", "vault.secrets"):
+ assert not connection.execute(
+ text("SELECT has_table_privilege(current_user, :table, 'SELECT')"),
+ {"table": relation},
+ ).scalar_one()
diff --git a/specs/004-provider-keys/data-model.md b/specs/004-provider-keys/data-model.md
index 91eb1cd..1ca84f3 100644
--- a/specs/004-provider-keys/data-model.md
+++ b/specs/004-provider-keys/data-model.md
@@ -253,7 +253,7 @@ The `DATABASE_URL` role must be private and have `rolbypassrls = true` (or be su
- Raw values live only in `vault.secrets` and are read through `vault.decrypted_secrets` by the backend database role.
- `vault` is not an exposed Data API schema.
- `PUBLIC`, `anon`, and `authenticated` receive no `USAGE`, `SELECT`, `DELETE`, or Vault function execution privileges. Revokes use exact installed function identities: `vault.create_secret(text,text,text,uuid)` and `vault.update_secret(uuid,text,text,text,uuid)`.
-- The backend role receives only the Vault privileges this feature uses: schema `USAGE`; `EXECUTE` on `vault.create_secret(text,text,text,uuid)`; `SELECT (id, decrypted_secret)` on `vault.decrypted_secrets`; and `SELECT (id), DELETE` on `vault.secrets`. It does not receive `update_secret` execution for this feature.
+- A hosted `DATABASE_URL` role receives only the Vault privileges this feature uses: schema `USAGE`; `EXECUTE` on `vault.create_secret(text,text,text,uuid)`; `SELECT (id, decrypted_secret)` on `vault.decrypted_secrets`; and `SELECT (id), DELETE` on `vault.secrets`. It does not receive `update_secret` execution. The documented local `postgres` exception may retain broader extension-owned privileges; Supabase's built-in `service_role` may also retain extension-owner grants and is never accepted as the hosted database login.
- Startup/integration checks fail closed if the configured backend role lacks required Vault/application privileges or `BYPASSRLS`/superuser behavior required by forced RLS.
- Tests verify catalog privileges, direct-role denial with SQLSTATE `42501`, and Data API rejection even for the owner of the corresponding provider-key record.
diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md
index 488c1c3..d79213f 100644
--- a/specs/004-provider-keys/tasks.md
+++ b/specs/004-provider-keys/tasks.md
@@ -48,19 +48,19 @@ brand cleanup state, request deadline, and safe logging rules required by every
**CRITICAL**: No user story implementation starts until this phase passes against a
real local Supabase instance.
-- [ ] T001 Create failing schema/security integration tests in `backend/tests/integration/test_provider_key_rls.py` that assert the planned enums, non-exposed `private.is_brand_owner(uuid)` helper and denied authenticated RPC, constraints, indexes, restrictive foreign keys, `ENABLE`/`FORCE RLS`, owner-only safe-column reads, denied authenticated DML and internal-column reads, complete denial of `provider_key_idempotency` and `brand_asset_operations`, exact Vault denial with SQLSTATE `42501`, and required backend-role `BYPASSRLS`/application/Vault privileges from `data-model.md`
-- [ ] T002 Update failing Brand CRUD/RLS regressions in `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` so test cleanup hard-deletes brands before auth users, and direct authenticated brand `INSERT`/`UPDATE`/`DELETE` expects permission denial for owned and non-owned rows after authenticated DML is revoked
-- [ ] T003 Create `supabase/migrations/00016_create_provider_keys.sql` with `supabase_vault`, `provider_t`, `provider_key_lifecycle_t`, `brand_deletion_state_t`, hardened `private.is_brand_owner(uuid)` in a non-exposed schema with RLS-only caller privileges, irreversible cleanup triggers, the `brands` lifecycle/logo-path/FK/grant changes, `provider_keys`, `provider_key_idempotency`, `brand_asset_operations`, all checks/indexes/triggers/restrictive FKs, `ENABLE`/`FORCE RLS`, safe-column grants, backend-only grants, and exact Vault privilege revocations/grants specified in `data-model.md`
-- [ ] T004 Apply and statically validate `supabase/migrations/00016_create_provider_keys.sql` with `supabase migration up` and `supabase db lint --level warning`, then run `backend/tests/integration/test_provider_key_rls.py` and confirm its real-Supabase tests execute rather than skip
-- [ ] T005 [P] Add failing shared contract tests in `backend/tests/contract/test_provider_keys.py` for the 15-second validation deadline context, fixed provider-key/brand cleanup errors, `X-Request-Id` parity, and JSON log allowlisting that excludes submitted bodies, labels, hints, Vault UUIDs, SQL parameters, provider bodies, authorization headers, exception strings, user IDs, and email addresses
-- [ ] T006 [P] Extend `backend/app/models/brand.py` so every Brand response maps database `deletion_state` to contract field `cleanup_state: normal | cleanup_required` without exposing internal lifecycle fields
-- [ ] T007 Extend `backend/app/services/brand_store.py` to select/return brand deletion state, lock the owned brand as the canonical first lock, distinguish hidden brands with `LookupError`, reject normal mutations after cleanup starts, detect active `brand_asset_operations`, and expose short transaction primitives used by provider-key and deletion services
-- [ ] T008 [P] Harden `backend/app/config.py` with `hide_parameters=True`, bounded pool/connect/statement/lock settings, and a startup assertion that rejects client database roles and verifies `BYPASSRLS` or superuser plus the exact application and Vault privileges required by `00016_create_provider_keys.sql`
-- [ ] T009 Extend `backend/app/main.py` to establish an absolute monotonic deadline at backend entry for the provider-key validation route, invoke the backend database privilege assertion during startup, and allowlist only `provider`, fixed outcome `code`, `duration_ms`, and `provider_request_id` in addition to existing safe log fields
-- [ ] T010 Extend `backend/app/auth.py` so JWT/JWKS verification consumes only the remaining validation-route budget and uses a bounded JWKS retrieval timeout without changing behavior for non-validation routes
-- [ ] T011 Extend `backend/app/routes/brands.py` to return the safe Brand cleanup shape, resolve ownership before cleanup conflicts, reject logo/brand mutations with fixed `BRAND_CLEANUP_REQUIRED` or `BRAND_MUTATION_IN_PROGRESS` errors, and preserve existing opaque 404 and exact confirmation-name behavior
-- [ ] T012 Add real-Supabase regression coverage in `backend/tests/integration/test_brand_crud.py` for active-brand create/list/detail/logo behavior, visible cleanup-required brands, mutation fences, and restrictive auth-user deletion while owned brands remain
-- [ ] T013 Run the foundational files `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_brand_crud.py`, `backend/tests/integration/test_brand_rls.py`, and `backend/tests/integration/test_provider_key_rls.py`; resolve only feature-related failures before starting User Story 1
+- [X] T001 Create failing schema/security integration tests in `backend/tests/integration/test_provider_key_rls.py` that assert the planned enums, non-exposed `private.is_brand_owner(uuid)` helper and denied authenticated RPC, constraints, indexes, restrictive foreign keys, `ENABLE`/`FORCE RLS`, owner-only safe-column reads, denied authenticated DML and internal-column reads, complete denial of `provider_key_idempotency` and `brand_asset_operations`, exact Vault denial with SQLSTATE `42501`, and required backend-role `BYPASSRLS`/application/Vault privileges from `data-model.md`
+- [X] T002 Update failing Brand CRUD/RLS regressions in `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` so test cleanup hard-deletes brands before auth users, and direct authenticated brand `INSERT`/`UPDATE`/`DELETE` expects permission denial for owned and non-owned rows after authenticated DML is revoked
+- [X] T003 Create `supabase/migrations/00016_create_provider_keys.sql` with `supabase_vault`, `provider_t`, `provider_key_lifecycle_t`, `brand_deletion_state_t`, hardened `private.is_brand_owner(uuid)` in a non-exposed schema with RLS-only caller privileges, irreversible cleanup triggers, the `brands` lifecycle/logo-path/FK/grant changes, `provider_keys`, `provider_key_idempotency`, `brand_asset_operations`, all checks/indexes/triggers/restrictive FKs, `ENABLE`/`FORCE RLS`, safe-column grants, backend-only grants, and exact Vault privilege revocations/grants specified in `data-model.md`
+- [X] T004 Apply and statically validate `supabase/migrations/00016_create_provider_keys.sql` with `supabase migration up` and `supabase db lint --level warning`, then run `backend/tests/integration/test_provider_key_rls.py` and confirm its real-Supabase tests execute rather than skip
+- [X] T005 [P] Add failing shared contract tests in `backend/tests/contract/test_provider_keys.py` for the 15-second validation deadline context, fixed provider-key/brand cleanup errors, `X-Request-Id` parity, and JSON log allowlisting that excludes submitted bodies, labels, hints, Vault UUIDs, SQL parameters, provider bodies, authorization headers, exception strings, user IDs, and email addresses
+- [X] T006 [P] Extend `backend/app/models/brand.py` so every Brand response maps database `deletion_state` to contract field `cleanup_state: normal | cleanup_required` without exposing internal lifecycle fields
+- [X] T007 Extend `backend/app/services/brand_store.py` to select/return brand deletion state, lock the owned brand as the canonical first lock, distinguish hidden brands with `LookupError`, reject normal mutations after cleanup starts, detect active `brand_asset_operations`, and expose short transaction primitives used by provider-key and deletion services
+- [X] T008 [P] Harden `backend/app/config.py` with `hide_parameters=True`, bounded pool/connect/statement/lock settings, and a startup assertion that rejects client database roles and verifies `BYPASSRLS` or superuser plus the exact application and Vault privileges required by `00016_create_provider_keys.sql`
+- [X] T009 Extend `backend/app/main.py` to establish an absolute monotonic deadline at backend entry for the provider-key validation route, invoke the backend database privilege assertion during startup, and allowlist only `provider`, fixed outcome `code`, `duration_ms`, and `provider_request_id` in addition to existing safe log fields
+- [X] T010 Extend `backend/app/auth.py` so JWT/JWKS verification consumes only the remaining validation-route budget and uses a bounded JWKS retrieval timeout without changing behavior for non-validation routes
+- [X] T011 Extend `backend/app/routes/brands.py` to return the safe Brand cleanup shape, resolve ownership before cleanup conflicts, reject logo/brand mutations with fixed `BRAND_CLEANUP_REQUIRED` or `BRAND_MUTATION_IN_PROGRESS` errors, and preserve existing opaque 404 and exact confirmation-name behavior
+- [X] T012 Add real-Supabase regression coverage in `backend/tests/integration/test_brand_crud.py` for active-brand create/list/detail/logo behavior, visible cleanup-required brands, mutation fences, and restrictive auth-user deletion while owned brands remain
+- [X] T013 Run the foundational files `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_brand_crud.py`, `backend/tests/integration/test_brand_rls.py`, and `backend/tests/integration/test_provider_key_rls.py`; resolve only feature-related failures before starting User Story 1
**Checkpoint**: The schema, backend privilege boundary, brand fence, validation deadline,
and logging boundary are enforceable before raw credentials enter the system.
diff --git a/supabase/migrations/00016_create_provider_keys.sql b/supabase/migrations/00016_create_provider_keys.sql
new file mode 100644
index 0000000..a37734d
--- /dev/null
+++ b/supabase/migrations/00016_create_provider_keys.sql
@@ -0,0 +1,237 @@
+CREATE EXTENSION IF NOT EXISTS supabase_vault CASCADE;
+
+CREATE TYPE provider_t AS ENUM ('openai', 'gemini');
+CREATE TYPE provider_key_lifecycle_t AS ENUM ('normal', 'cleanup_required');
+CREATE TYPE brand_deletion_state_t AS ENUM ('active', 'cleanup_required');
+
+CREATE SCHEMA IF NOT EXISTS private;
+REVOKE ALL ON SCHEMA private FROM PUBLIC, anon, authenticated;
+GRANT USAGE ON SCHEMA private TO authenticated, service_role;
+
+CREATE FUNCTION private.is_brand_owner(p_brand_id UUID)
+RETURNS BOOLEAN
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+ SELECT EXISTS (
+ SELECT 1
+ FROM public.brands
+ WHERE id = p_brand_id
+ AND owner_user_id = (SELECT auth.uid())
+ );
+$$;
+
+REVOKE ALL ON FUNCTION private.is_brand_owner(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION private.is_brand_owner(UUID) TO authenticated, service_role;
+
+ALTER TABLE brands
+ ADD COLUMN deletion_state brand_deletion_state_t NOT NULL DEFAULT 'active';
+
+ALTER TABLE brands DROP CONSTRAINT brands_logo_path_check;
+ALTER TABLE brands ADD CONSTRAINT brands_logo_path_check CHECK (
+ logo_path IS NULL
+ OR logo_path ~ '^brands/[0-9a-f-]+/logo\.[A-Za-z0-9]+$'
+ OR logo_path ~ '^brands/[0-9a-f-]+/logos/[0-9a-f-]+\.[A-Za-z0-9]+$'
+);
+
+ALTER TABLE brands DROP CONSTRAINT brands_owner_user_id_fkey;
+ALTER TABLE brands ADD CONSTRAINT brands_owner_user_id_fkey
+ FOREIGN KEY (owner_user_id) REFERENCES auth.users(id) ON DELETE RESTRICT;
+
+CREATE FUNCTION reject_brand_cleanup_reversal()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = ''
+AS $$
+BEGIN
+ IF OLD.deletion_state = 'cleanup_required'
+ AND NEW.deletion_state = 'active' THEN
+ RAISE EXCEPTION 'brand cleanup state is irreversible'
+ USING ERRCODE = '23514';
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER trg_brands_cleanup_irreversible
+ BEFORE UPDATE ON brands
+ FOR EACH ROW
+ EXECUTE FUNCTION reject_brand_cleanup_reversal();
+
+CREATE TABLE provider_keys (
+ id UUID PRIMARY KEY,
+ brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE RESTRICT,
+ provider provider_t NOT NULL,
+ vault_secret_id UUID NOT NULL,
+ label TEXT CHECK (label IS NULL OR char_length(label) <= 100),
+ key_hint TEXT NOT NULL CHECK (key_hint ~ '^\*\*\*[A-Za-z0-9_-]{4}$'),
+ lifecycle provider_key_lifecycle_t NOT NULL DEFAULT 'normal',
+ is_active BOOLEAN NOT NULL DEFAULT false,
+ is_valid BOOLEAN,
+ last_validated_at TIMESTAMPTZ,
+ last_validation_error TEXT,
+ validation_token UUID,
+ validation_lease_expires_at TIMESTAMPTZ,
+ last_used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT provider_keys_cleanup_inactive CHECK (
+ lifecycle <> 'cleanup_required' OR NOT is_active
+ ),
+ CONSTRAINT provider_keys_invalid_inactive CHECK (
+ is_valid IS DISTINCT FROM false OR NOT is_active
+ ),
+ CONSTRAINT provider_keys_safe_validation_error CHECK (
+ last_validation_error IS NULL
+ OR last_validation_error = 'INVALID_CREDENTIAL'
+ ),
+ CONSTRAINT provider_keys_validation_lease_pair CHECK (
+ (validation_token IS NULL AND validation_lease_expires_at IS NULL)
+ OR (validation_token IS NOT NULL AND validation_lease_expires_at IS NOT NULL)
+ ),
+ CONSTRAINT provider_keys_cleanup_without_validation CHECK (
+ lifecycle = 'normal' OR validation_token IS NULL
+ ),
+ CONSTRAINT provider_keys_validation_result CHECK (
+ (is_valid IS NULL AND last_validated_at IS NULL AND last_validation_error IS NULL)
+ OR (is_valid IS TRUE AND last_validated_at IS NOT NULL AND last_validation_error IS NULL)
+ OR (
+ is_valid IS FALSE
+ AND last_validated_at IS NOT NULL
+ AND last_validation_error = 'INVALID_CREDENTIAL'
+ )
+ )
+);
+
+CREATE UNIQUE INDEX uq_provider_keys_vault_secret
+ ON provider_keys(vault_secret_id);
+CREATE UNIQUE INDEX uq_provider_keys_one_active
+ ON provider_keys(brand_id, provider)
+ WHERE is_active;
+CREATE INDEX idx_provider_keys_brand_provider_created
+ ON provider_keys(brand_id, provider, created_at DESC, id DESC);
+CREATE INDEX idx_provider_keys_cleanup
+ ON provider_keys(brand_id, lifecycle)
+ WHERE lifecycle = 'cleanup_required';
+
+CREATE TRIGGER trg_provider_keys_updated_at
+ BEFORE UPDATE ON provider_keys
+ FOR EACH ROW
+ EXECUTE FUNCTION set_updated_at();
+
+CREATE FUNCTION reject_provider_key_cleanup_reversal()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = ''
+AS $$
+BEGIN
+ IF OLD.lifecycle = 'cleanup_required' AND NEW.lifecycle = 'normal' THEN
+ RAISE EXCEPTION 'provider key cleanup state is irreversible'
+ USING ERRCODE = '23514';
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER trg_provider_keys_cleanup_irreversible
+ BEFORE UPDATE ON provider_keys
+ FOR EACH ROW
+ EXECUTE FUNCTION reject_provider_key_cleanup_reversal();
+
+CREATE TABLE provider_key_idempotency (
+ id UUID PRIMARY KEY,
+ brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
+ request_id UUID NOT NULL,
+ provider_key_id UUID REFERENCES provider_keys(id) ON DELETE SET NULL,
+ state TEXT NOT NULL CHECK (state IN ('active', 'deleted')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT uq_provider_key_idempotency_brand_request UNIQUE (brand_id, request_id)
+);
+
+CREATE TABLE brand_asset_operations (
+ id UUID PRIMARY KEY,
+ brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE RESTRICT,
+ operation TEXT NOT NULL CHECK (operation IN ('upload', 'remove')),
+ object_path TEXT,
+ previous_path TEXT,
+ state TEXT NOT NULL CHECK (state IN ('in_progress', 'cleanup_required')),
+ remote_status TEXT NOT NULL CHECK (
+ remote_status IN ('pending', 'succeeded', 'failed', 'unknown')
+ ),
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT uq_brand_asset_operations_brand UNIQUE (brand_id)
+);
+
+CREATE TRIGGER trg_brand_asset_operations_updated_at
+ BEFORE UPDATE ON brand_asset_operations
+ FOR EACH ROW
+ EXECUTE FUNCTION set_updated_at();
+
+ALTER TABLE provider_keys ENABLE ROW LEVEL SECURITY;
+ALTER TABLE provider_keys FORCE ROW LEVEL SECURITY;
+ALTER TABLE provider_key_idempotency ENABLE ROW LEVEL SECURITY;
+ALTER TABLE provider_key_idempotency FORCE ROW LEVEL SECURITY;
+ALTER TABLE brand_asset_operations ENABLE ROW LEVEL SECURITY;
+ALTER TABLE brand_asset_operations FORCE ROW LEVEL SECURITY;
+
+CREATE POLICY provider_keys_select ON provider_keys
+ FOR SELECT USING (private.is_brand_owner(brand_id));
+CREATE POLICY provider_keys_insert ON provider_keys
+ FOR INSERT WITH CHECK (private.is_brand_owner(brand_id));
+CREATE POLICY provider_keys_update ON provider_keys
+ FOR UPDATE
+ USING (private.is_brand_owner(brand_id))
+ WITH CHECK (private.is_brand_owner(brand_id));
+CREATE POLICY provider_keys_delete ON provider_keys
+ FOR DELETE USING (private.is_brand_owner(brand_id));
+
+CREATE POLICY provider_key_idempotency_owner ON provider_key_idempotency
+ FOR ALL
+ USING (private.is_brand_owner(brand_id))
+ WITH CHECK (private.is_brand_owner(brand_id));
+CREATE POLICY brand_asset_operations_owner ON brand_asset_operations
+ FOR ALL
+ USING (private.is_brand_owner(brand_id))
+ WITH CHECK (private.is_brand_owner(brand_id));
+
+REVOKE ALL ON provider_keys FROM PUBLIC, anon, authenticated;
+REVOKE ALL ON provider_key_idempotency FROM PUBLIC, anon, authenticated;
+REVOKE ALL ON brand_asset_operations FROM PUBLIC, anon, authenticated;
+REVOKE ALL ON brands FROM authenticated;
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON provider_keys TO service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON provider_key_idempotency TO service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON brand_asset_operations TO service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON brands TO service_role;
+
+GRANT SELECT (
+ id, provider, label, key_hint, lifecycle, is_active, is_valid,
+ last_validated_at, last_validation_error, created_at
+) ON provider_keys TO authenticated;
+GRANT SELECT (
+ id, name, logo_path, deletion_state, created_at, updated_at
+) ON brands TO authenticated;
+
+REVOKE ALL ON SCHEMA vault FROM PUBLIC, anon, authenticated;
+REVOKE ALL ON ALL TABLES IN SCHEMA vault FROM PUBLIC, anon, authenticated;
+REVOKE ALL ON FUNCTION vault.create_secret(TEXT, TEXT, TEXT, UUID)
+ FROM PUBLIC, anon, authenticated;
+REVOKE ALL ON FUNCTION vault.update_secret(UUID, TEXT, TEXT, TEXT, UUID)
+ FROM PUBLIC, anon, authenticated;
+
+REVOKE ALL ON SCHEMA vault FROM service_role;
+REVOKE ALL ON ALL TABLES IN SCHEMA vault FROM service_role;
+REVOKE ALL ON FUNCTION vault.create_secret(TEXT, TEXT, TEXT, UUID)
+ FROM service_role;
+REVOKE ALL ON FUNCTION vault.update_secret(UUID, TEXT, TEXT, TEXT, UUID)
+ FROM service_role;
+
+GRANT USAGE ON SCHEMA vault TO service_role;
+GRANT EXECUTE ON FUNCTION vault.create_secret(TEXT, TEXT, TEXT, UUID)
+ TO service_role;
+GRANT SELECT (id, decrypted_secret) ON vault.decrypted_secrets TO service_role;
+GRANT SELECT (id) ON vault.secrets TO service_role;
+GRANT DELETE ON vault.secrets TO service_role;
From 4645e7c8a000189f753d7ca4184e4f4663ea3e0a Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 00:19:56 +0300
Subject: [PATCH 05/14] Fix database validation and RLS test issues
---
backend/app/config.py | 2 +-
backend/tests/contract/test_brands.py | 30 +++++++++----------
backend/tests/contract/test_provider_keys.py | 6 ++--
backend/tests/integration/test_brand_rls.py | 4 +--
.../migrations/00016_create_provider_keys.sql | 2 ++
5 files changed, 22 insertions(+), 22 deletions(-)
diff --git a/backend/app/config.py b/backend/app/config.py
index 791c7a4..51b1742 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -118,7 +118,7 @@ def assert_database_role_privileges() -> None:
role_name = privileges.pop("role_name")
is_superuser = privileges.pop("is_superuser")
vault_least_privilege = privileges.pop("vault_least_privilege")
- hostname = urlparse(settings.supabase_url).hostname
+ hostname = urlparse(settings.database_url).hostname
local_postgres = role_name == "postgres" and hostname in {
"127.0.0.1",
"localhost",
diff --git a/backend/tests/contract/test_brands.py b/backend/tests/contract/test_brands.py
index f17f9a6..b1b20c6 100644
--- a/backend/tests/contract/test_brands.py
+++ b/backend/tests/contract/test_brands.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from uuid import UUID
import pytest
@@ -37,7 +37,7 @@ def create_brand(self, user_id: str, payload: BrandCreate) -> Brand:
id=UUID("22222222-2222-2222-2222-222222222222"),
name=payload.name,
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
self.brands.insert(0, brand)
self.owners[brand.id] = user_id
@@ -221,13 +221,13 @@ def test_list_brands_returns_empty_and_populated_contract_shapes():
id=UUID("33333333-3333-3333-3333-333333333333"),
name="New Brand",
logo_url=None,
- created_at=datetime(2026, 7, 26, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 26, tzinfo=UTC),
),
Brand(
id=UUID("22222222-2222-2222-2222-222222222222"),
name="First Brand",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
),
]
populated_response = client.get("/api/v1/brands")
@@ -262,7 +262,7 @@ def test_get_brand_returns_owned_brand_and_opaque_not_found_errors():
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(brands=[owned_brand])
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
@@ -306,7 +306,7 @@ def test_cleanup_required_brand_remains_visible_in_list_and_detail():
name="Cleanup Brand",
logo_url=None,
cleanup_state="cleanup_required",
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(brands=[brand], cleanup_required={brand.id})
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
@@ -345,7 +345,7 @@ def test_logo_and_brand_mutations_respect_brand_fences(
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(brands=[brand])
getattr(store, fence_field).add(brand.id)
@@ -401,7 +401,7 @@ def test_upload_logo_rejects_unsupported_or_spoofed_content(
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(brands=[brand])
storage = FakeBrandStorage()
@@ -433,7 +433,7 @@ def test_upload_logo_rejects_files_over_five_megabytes():
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(brands=[brand])
storage = FakeBrandStorage()
@@ -466,7 +466,7 @@ def test_upload_logo_returns_updated_brand():
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(brands=[brand])
storage = FakeBrandStorage()
@@ -501,7 +501,7 @@ def test_delete_logo_without_existing_logo_is_idempotent():
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(brands=[brand])
storage = FakeBrandStorage()
@@ -530,7 +530,7 @@ def test_delete_brand_with_exact_confirmation_removes_brand_and_logo():
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url="https://example.supabase.co/logo.png",
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
logo_path = f"brands/{brand.id}/logo.png"
store = FakeBrandStore(brands=[brand], logo_paths={brand.id: logo_path})
@@ -564,7 +564,7 @@ def test_delete_brand_storage_failure_retains_cleanup_required_brand():
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url="https://example.supabase.co/logo.png",
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
logo_path = f"brands/{brand.id}/logo.png"
store = FakeBrandStore(brands=[brand], logo_paths={brand.id: logo_path})
@@ -620,7 +620,7 @@ def test_delete_brand_rejects_missing_or_wrong_confirmation_without_mutation(
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Acme Coffee",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(brands=[brand])
storage = FakeBrandStorage()
@@ -654,7 +654,7 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra
id=UUID("22222222-2222-2222-2222-222222222222"),
name="Another Owner Brand",
logo_url=None,
- created_at=datetime(2026, 7, 25, tzinfo=timezone.utc),
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
)
store = FakeBrandStore(
brands=[brand],
diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py
index 84d0c2c..e38d838 100644
--- a/backend/tests/contract/test_provider_keys.py
+++ b/backend/tests/contract/test_provider_keys.py
@@ -278,7 +278,7 @@ def test_local_postgres_privilege_assertion_allows_documented_exception(
"load_settings",
lambda: SimpleNamespace(
database_url="postgresql://localhost/postgres",
- supabase_url="http://127.0.0.1:54321",
+ supabase_url="https://hosted.supabase.co",
),
)
monkeypatch.setattr(config, "get_engine", lambda: engine)
@@ -391,10 +391,10 @@ async def slow_to_thread(function, *args):
)
monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda token: {"alg": "ES256"})
request = _request()
- request.state.validation_deadline = time.monotonic() + 0.03
+ request.state.validation_deadline = time.monotonic() + 0.1
started = time.monotonic()
with pytest.raises(HTTPException):
asyncio.run(auth.get_current_user(request, "Bearer token"))
- assert time.monotonic() - started < 0.15
+ assert time.monotonic() - started < 0.5
diff --git a/backend/tests/integration/test_brand_rls.py b/backend/tests/integration/test_brand_rls.py
index 7c3450c..f1afcb2 100644
--- a/backend/tests/integration/test_brand_rls.py
+++ b/backend/tests/integration/test_brand_rls.py
@@ -252,14 +252,12 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers():
]
},
)
- assert cleanup_response.is_success
- from backend.app.config import get_engine
-
with get_engine().begin() as connection:
connection.execute(
text("DELETE FROM brands WHERE id = :brand_id"),
{"brand_id": brand_id},
)
+ assert cleanup_response.is_success
for user_id in (user_a_id, user_b_id):
if user_id:
supabase_client.delete(
diff --git a/supabase/migrations/00016_create_provider_keys.sql b/supabase/migrations/00016_create_provider_keys.sql
index a37734d..81838f1 100644
--- a/supabase/migrations/00016_create_provider_keys.sql
+++ b/supabase/migrations/00016_create_provider_keys.sql
@@ -203,7 +203,9 @@ REVOKE ALL ON brand_asset_operations FROM PUBLIC, anon, authenticated;
REVOKE ALL ON brands FROM authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON provider_keys TO service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON provider_key_idempotency TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON provider_key_idempotency TO service_role;
+GRANT SELECT, INSERT, UPDATE, DELETE ON brand_asset_operations TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON brand_asset_operations TO service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON brands TO service_role;
From 3e9a7ae53339e7a93cd7c796c866abe08d79e53f Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 14:41:47 +0300
Subject: [PATCH 06/14] Implement provider key management MVP
---
backend/app/main.py | 2 +
backend/app/models/provider_key.py | 72 ++++
backend/app/routes/provider_keys.py | 110 ++++++
backend/app/services/provider_key_store.py | 170 ++++++++
backend/tests/contract/test_provider_keys.py | 255 ++++++++++++
.../integration/test_provider_key_rls.py | 137 ++++++-
.../tests/integration/test_provider_keys.py | 343 ++++++++++++++++
.../brands/[brandId]/keys/page.tsx | 368 ++++++++++++++++++
.../app/(dashboard)/brands/[brandId]/page.tsx | 39 +-
frontend/app/(dashboard)/layout.tsx | 2 +-
frontend/tests/e2e/provider-keys.spec.ts | 152 ++++++++
specs/004-provider-keys/tasks.md | 18 +-
.../migrations/00016_create_provider_keys.sql | 2 -
13 files changed, 1647 insertions(+), 23 deletions(-)
create mode 100644 backend/app/models/provider_key.py
create mode 100644 backend/app/routes/provider_keys.py
create mode 100644 backend/app/services/provider_key_store.py
create mode 100644 backend/tests/integration/test_provider_keys.py
create mode 100644 frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
create mode 100644 frontend/tests/e2e/provider-keys.spec.ts
diff --git a/backend/app/main.py b/backend/app/main.py
index bc66787..f3c7857 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -17,6 +17,7 @@
from .routes.brands import router as brands_router
from .routes.health import router as health_router
from .routes.me import router as me_router
+from .routes.provider_keys import router as provider_keys_router
_VALIDATION_DEADLINE_SECONDS = 15
@@ -152,3 +153,4 @@ async def root() -> dict[str, str]:
app.include_router(brands_router)
app.include_router(health_router)
app.include_router(me_router)
+app.include_router(provider_keys_router)
diff --git a/backend/app/models/provider_key.py b/backend/app/models/provider_key.py
new file mode 100644
index 0000000..9e4be2f
--- /dev/null
+++ b/backend/app/models/provider_key.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+import re
+from datetime import datetime
+from enum import Enum
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, field_validator, model_validator
+
+
+_KEY_SUFFIX = re.compile(r"[A-Za-z0-9_-]{4}$")
+
+
+class Provider(str, Enum):
+ OPENAI = "openai"
+ GEMINI = "gemini"
+
+
+class ProviderKeyLifecycle(str, Enum):
+ NORMAL = "normal"
+ CLEANUP_REQUIRED = "cleanup_required"
+
+
+class ProviderKeyAdd(BaseModel):
+ provider: Provider
+ key: str
+ label: str | None = None
+ make_active: bool = True
+
+ @field_validator("key")
+ @classmethod
+ def validate_key(cls, value: str) -> str:
+ if len(value) < 5 or not value.strip() or _KEY_SUFFIX.search(value) is None:
+ raise ValueError(
+ "Key must be at least 5 characters and end in four letters, "
+ "numbers, underscores, or hyphens."
+ )
+ return value
+
+ @field_validator("label")
+ @classmethod
+ def validate_label_length(cls, value: str | None) -> str | None:
+ if value is not None and len(value) > 100:
+ raise ValueError("Label must be 100 characters or fewer.")
+ return value
+
+ @model_validator(mode="after")
+ def reject_key_in_label(self) -> ProviderKeyAdd:
+ if self.label is not None and self.key in self.label:
+ raise ValueError("Label must not contain the provider key.")
+ return self
+
+
+class ProviderKey(BaseModel):
+ id: UUID
+ provider: Provider
+ label: str | None
+ key_hint: str
+ is_active: bool
+ is_valid: bool | None
+ last_validated_at: datetime | None
+ last_validation_error: Literal["INVALID_CREDENTIAL"] | None
+ cleanup_state: ProviderKeyLifecycle
+ created_at: datetime
+
+
+class ProviderKeyListResponse(BaseModel):
+ keys: list[ProviderKey]
+
+
+IdempotencyKey = UUID
diff --git a/backend/app/routes/provider_keys.py b/backend/app/routes/provider_keys.py
new file mode 100644
index 0000000..64e5983
--- /dev/null
+++ b/backend/app/routes/provider_keys.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import logging
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
+
+from ..auth import CurrentUserDep
+from ..models.provider_key import (
+ IdempotencyKey,
+ ProviderKey,
+ ProviderKeyAdd,
+ ProviderKeyListResponse,
+)
+from ..services.brand_store import BrandCleanupRequiredError
+from ..services.provider_key_store import (
+ IdempotencyKeyRetiredError,
+ ProviderKeyStore,
+ VaultUnavailableError,
+ get_provider_key_store,
+)
+
+
+router = APIRouter(prefix="/api/v1/brands", tags=["provider-keys"])
+logger = logging.getLogger(__name__)
+
+ProviderKeyStoreDep = Annotated[ProviderKeyStore, Depends(get_provider_key_store)]
+IdempotencyKeyHeader = Annotated[IdempotencyKey, Header(alias="Idempotency-Key")]
+
+
+def _error(status_code: int, code: str, message: str) -> HTTPException:
+ return HTTPException(
+ status_code=status_code,
+ detail={"code": code, "message": message},
+ )
+
+
+def _not_found() -> HTTPException:
+ return _error(status.HTTP_404_NOT_FOUND, "BRAND_NOT_FOUND", "Brand not found.")
+
+
+@router.get("/{brand_id}/keys", response_model=ProviderKeyListResponse)
+def list_provider_keys(
+ request: Request,
+ brand_id: UUID,
+ current_user: CurrentUserDep,
+ provider_key_store: ProviderKeyStoreDep,
+) -> ProviderKeyListResponse:
+ try:
+ keys = provider_key_store.list_keys(current_user.user_id, brand_id)
+ except LookupError as exc:
+ raise _not_found() from exc
+ logger.info(
+ "provider_keys.list_success",
+ extra={
+ "event": "provider_keys.list_success",
+ "request_id": getattr(request.state, "request_id", "unknown"),
+ },
+ )
+ return ProviderKeyListResponse(keys=keys)
+
+
+@router.post(
+ "/{brand_id}/keys",
+ response_model=ProviderKey,
+ status_code=status.HTTP_201_CREATED,
+)
+def add_provider_key(
+ request: Request,
+ brand_id: UUID,
+ payload: ProviderKeyAdd,
+ idempotency_key: IdempotencyKeyHeader,
+ current_user: CurrentUserDep,
+ provider_key_store: ProviderKeyStoreDep,
+) -> ProviderKey:
+ try:
+ key = provider_key_store.add_key(
+ current_user.user_id, brand_id, payload, idempotency_key
+ )
+ except LookupError as exc:
+ raise _not_found() from exc
+ except BrandCleanupRequiredError as exc:
+ raise _error(
+ status.HTTP_409_CONFLICT,
+ "BRAND_CLEANUP_REQUIRED",
+ "Brand cleanup is required. Retry deletion.",
+ ) from exc
+ except IdempotencyKeyRetiredError as exc:
+ raise _error(
+ status.HTTP_409_CONFLICT,
+ "IDEMPOTENCY_KEY_RETIRED",
+ "This add request was already completed and deleted. Use a new request ID.",
+ ) from exc
+ except VaultUnavailableError as exc:
+ raise _error(
+ status.HTTP_502_BAD_GATEWAY,
+ "VAULT_UNAVAILABLE",
+ "Secure key storage is unavailable right now.",
+ ) from exc
+
+ logger.info(
+ "provider_keys.add_success",
+ extra={
+ "event": "provider_keys.add_success",
+ "request_id": getattr(request.state, "request_id", "unknown"),
+ "provider": key.provider.value,
+ },
+ )
+ return key
diff --git a/backend/app/services/provider_key_store.py b/backend/app/services/provider_key_store.py
new file mode 100644
index 0000000..c5fb5f9
--- /dev/null
+++ b/backend/app/services/provider_key_store.py
@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from functools import lru_cache
+from typing import Any
+from uuid import UUID, uuid4
+
+from sqlalchemy import text
+from sqlalchemy.engine import Engine
+from sqlalchemy.exc import SQLAlchemyError
+
+from ..config import get_engine
+from ..models.provider_key import ProviderKey, ProviderKeyAdd
+from .brand_store import BrandCleanupRequiredError, BrandStore
+
+
+class ProviderKeyStoreError(Exception):
+ pass
+
+
+class IdempotencyKeyRetiredError(ProviderKeyStoreError):
+ pass
+
+
+class VaultUnavailableError(ProviderKeyStoreError):
+ pass
+
+
+_SAFE_COLUMNS = """
+ id, provider, label, key_hint, is_active, is_valid,
+ last_validated_at, last_validation_error, lifecycle, created_at
+"""
+
+
+@dataclass(frozen=True, slots=True)
+class ProviderKeyStore:
+ engine: Engine
+
+ @staticmethod
+ def _to_provider_key(row: Mapping[str, Any]) -> ProviderKey:
+ return ProviderKey.model_validate(
+ {**row, "cleanup_state": row["lifecycle"]}
+ )
+
+ def list_keys(self, user_id: str, brand_id: UUID) -> list[ProviderKey]:
+ with self.engine.begin() as connection:
+ BrandStore.lock_owned_brand(connection, user_id, brand_id)
+ rows = connection.execute(
+ text(
+ f"""
+ SELECT {_SAFE_COLUMNS}
+ FROM provider_keys
+ WHERE brand_id = :brand_id
+ ORDER BY provider, created_at DESC, id DESC
+ """
+ ),
+ {"brand_id": brand_id},
+ ).mappings().all()
+ return [self._to_provider_key(row) for row in rows]
+
+ def add_key(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ payload: ProviderKeyAdd,
+ idempotency_key: UUID,
+ ) -> ProviderKey:
+ try:
+ with self.engine.begin() as connection:
+ brand = BrandStore.lock_owned_brand(connection, user_id, brand_id)
+ receipt = connection.execute(
+ text(
+ """
+ SELECT r.state, pk.id, pk.provider, pk.label, pk.key_hint,
+ pk.is_active, pk.is_valid, pk.last_validated_at,
+ pk.last_validation_error, pk.lifecycle, pk.created_at
+ FROM provider_key_idempotency r
+ LEFT JOIN provider_keys pk ON pk.id = r.provider_key_id
+ WHERE r.brand_id = :brand_id AND r.request_id = :request_id
+ """
+ ),
+ {"brand_id": brand_id, "request_id": idempotency_key},
+ ).mappings().one_or_none()
+ if receipt is not None:
+ if receipt["state"] != "active" or receipt["id"] is None:
+ raise IdempotencyKeyRetiredError
+ return self._to_provider_key(receipt)
+
+ BrandStore.require_normal_brand(brand)
+ cleanup_pending = connection.execute(
+ text(
+ "SELECT EXISTS ("
+ "SELECT 1 FROM provider_keys "
+ "WHERE brand_id = :brand_id AND lifecycle = 'cleanup_required'"
+ ")"
+ ),
+ {"brand_id": brand_id},
+ ).scalar_one()
+ if cleanup_pending:
+ raise BrandCleanupRequiredError
+
+ key_id = uuid4()
+ if payload.make_active:
+ connection.execute(
+ text(
+ "UPDATE provider_keys SET is_active = false "
+ "WHERE brand_id = :brand_id "
+ "AND provider = CAST(:provider AS provider_t) "
+ "AND is_active"
+ ),
+ {"brand_id": brand_id, "provider": payload.provider.value},
+ )
+
+ vault_secret_id = connection.execute(
+ text(
+ "SELECT vault.create_secret(:secret, NULL, '', NULL)"
+ ),
+ {"secret": payload.key},
+ ).scalar_one()
+ row = connection.execute(
+ text(
+ f"""
+ INSERT INTO provider_keys
+ (id, brand_id, provider, vault_secret_id, label,
+ key_hint, is_active)
+ VALUES
+ (:id, :brand_id, CAST(:provider AS provider_t),
+ :vault_secret_id, :label,
+ :key_hint, :is_active)
+ RETURNING {_SAFE_COLUMNS}
+ """
+ ),
+ {
+ "id": key_id,
+ "brand_id": brand_id,
+ "provider": payload.provider.value,
+ "vault_secret_id": vault_secret_id,
+ "label": payload.label,
+ "key_hint": f"***{payload.key[-4:]}",
+ "is_active": payload.make_active,
+ },
+ ).mappings().one()
+ connection.execute(
+ text(
+ "INSERT INTO provider_key_idempotency "
+ "(id, brand_id, request_id, provider_key_id, state) "
+ "VALUES (:id, :brand_id, :request_id, :provider_key_id, 'active')"
+ ),
+ {
+ "id": uuid4(),
+ "brand_id": brand_id,
+ "request_id": idempotency_key,
+ "provider_key_id": key_id,
+ },
+ )
+ return self._to_provider_key(row)
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ IdempotencyKeyRetiredError,
+ ):
+ raise
+ except SQLAlchemyError as exc:
+ raise VaultUnavailableError from exc
+
+
+@lru_cache(maxsize=1)
+def get_provider_key_store() -> ProviderKeyStore:
+ return ProviderKeyStore(get_engine())
diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py
index e38d838..228370a 100644
--- a/backend/tests/contract/test_provider_keys.py
+++ b/backend/tests/contract/test_provider_keys.py
@@ -4,14 +4,26 @@
import json
import logging
import time
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock
+from uuid import UUID
import pytest
from fastapi import HTTPException, Request
+from fastapi.testclient import TestClient
from starlette.responses import Response
from backend.app import auth, config, main
+from backend.app.auth import CurrentUser, get_current_user
+from backend.app.models.provider_key import ProviderKey, ProviderKeyAdd
+from backend.app.routes.provider_keys import get_provider_key_store
+from backend.app.services.brand_store import BrandCleanupRequiredError
+from backend.app.services.provider_key_store import (
+ IdempotencyKeyRetiredError,
+ VaultUnavailableError,
+)
def _request(path: str = "/api/v1/brands/brand-id/keys/key-id/validate") -> Request:
@@ -398,3 +410,246 @@ async def slow_to_thread(function, *args):
asyncio.run(auth.get_current_user(request, "Bearer token"))
assert time.monotonic() - started < 0.5
+
+
+BRAND_ID = UUID("22222222-2222-2222-2222-222222222222")
+KEY_ID = UUID("33333333-3333-3333-3333-333333333333")
+REQUEST_ID = UUID("44444444-4444-4444-4444-444444444444")
+RAW_KEY = "contract-provider-secret-A1B2"
+
+
+def _safe_key(**updates) -> ProviderKey:
+ values = {
+ "id": KEY_ID,
+ "provider": "openai",
+ "label": "Production Key",
+ "key_hint": "***A1B2",
+ "is_active": True,
+ "is_valid": None,
+ "last_validated_at": None,
+ "last_validation_error": None,
+ "cleanup_state": "normal",
+ "created_at": datetime(2026, 7, 26, 11, tzinfo=UTC),
+ }
+ values.update(updates)
+ return ProviderKey.model_validate(values)
+
+
+@dataclass
+class FakeProviderKeyStore:
+ keys: list[ProviderKey] = field(default_factory=list)
+ error: Exception | None = None
+ add_calls: list[tuple[str, UUID, ProviderKeyAdd, UUID]] = field(default_factory=list)
+
+ def list_keys(self, user_id: str, brand_id: UUID) -> list[ProviderKey]:
+ if self.error:
+ raise self.error
+ return self.keys
+
+ def add_key(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ payload: ProviderKeyAdd,
+ idempotency_key: UUID,
+ ) -> ProviderKey:
+ self.add_calls.append((user_id, brand_id, payload, idempotency_key))
+ if self.error:
+ raise self.error
+ return self.keys[0] if self.keys else _safe_key(is_active=payload.make_active)
+
+
+@pytest.fixture
+def provider_key_client():
+ store = FakeProviderKeyStore()
+ main.app.dependency_overrides[get_current_user] = lambda: CurrentUser(
+ user_id="11111111-1111-1111-1111-111111111111",
+ email="owner@example.com",
+ access_token="redacted",
+ )
+ main.app.dependency_overrides[get_provider_key_store] = lambda: store
+ try:
+ with TestClient(main.app) as client:
+ yield client, store
+ finally:
+ main.app.dependency_overrides.clear()
+
+
+def test_list_keys_returns_exact_empty_and_populated_safe_shapes(provider_key_client):
+ client, store = provider_key_client
+ empty = client.get(f"/api/v1/brands/{BRAND_ID}/keys")
+ store.keys = [_safe_key()]
+ populated = client.get(f"/api/v1/brands/{BRAND_ID}/keys")
+
+ assert empty.status_code == 200
+ assert empty.json() == {"keys": []}
+ assert populated.status_code == 200
+ assert populated.json() == {
+ "keys": [
+ {
+ "id": str(KEY_ID),
+ "provider": "openai",
+ "label": "Production Key",
+ "key_hint": "***A1B2",
+ "is_active": True,
+ "is_valid": None,
+ "last_validated_at": None,
+ "last_validation_error": None,
+ "cleanup_state": "normal",
+ "created_at": "2026-07-26T11:00:00Z",
+ }
+ ]
+ }
+ forbidden = {"brand_id", "vault_secret_id", "lifecycle", "validation_token", "updated_at"}
+ assert forbidden.isdisjoint(populated.json()["keys"][0])
+
+
+@pytest.mark.parametrize("make_active", [None, True, False])
+def test_add_key_defaults_active_and_returns_only_safe_shape(provider_key_client, make_active):
+ client, store = provider_key_client
+ body = {"provider": "openai", "key": RAW_KEY, "label": "Production Key"}
+ if make_active is not None:
+ body["make_active"] = make_active
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys",
+ headers={"Idempotency-Key": str(REQUEST_ID)},
+ json=body,
+ )
+
+ expected_active = True if make_active is None else make_active
+ assert response.status_code == 201
+ assert response.json()["is_active"] is expected_active
+ assert response.json()["key_hint"] == "***A1B2"
+ assert RAW_KEY not in response.text
+ assert store.add_calls[0][2].key == RAW_KEY
+ assert store.add_calls[0][2].make_active is expected_active
+ assert store.add_calls[0][3] == REQUEST_ID
+
+
+@pytest.mark.parametrize("header", [None, "not-a-uuid"])
+def test_add_key_requires_uuid_idempotency_header(provider_key_client, header):
+ client, store = provider_key_client
+ headers = {} if header is None else {"Idempotency-Key": header}
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys",
+ headers=headers,
+ json={"provider": "openai", "key": RAW_KEY},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["error"]["code"] == "VALIDATION_ERROR"
+ assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
+ assert store.add_calls == []
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ {"provider": "unsupported", "key": RAW_KEY},
+ {"provider": "openai", "key": ""},
+ {"provider": "openai", "key": " "},
+ {"provider": "openai", "key": "abcd"},
+ {"provider": "openai", "key": "secret-ab!?"},
+ {"provider": "openai", "key": RAW_KEY, "label": "x" * 101},
+ {"provider": "openai", "key": RAW_KEY, "label": f"contains {RAW_KEY}"},
+ ],
+)
+def test_add_key_rejects_invalid_raw_request_before_store(provider_key_client, body):
+ client, store = provider_key_client
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys",
+ headers={"Idempotency-Key": str(REQUEST_ID)},
+ json=body,
+ )
+
+ assert response.status_code == 400
+ assert response.json()["error"]["code"] == "VALIDATION_ERROR"
+ assert RAW_KEY not in response.text
+ assert store.add_calls == []
+
+
+@pytest.mark.parametrize(
+ ("error", "status_code", "code", "message"),
+ [
+ (LookupError(), 404, "BRAND_NOT_FOUND", "Brand not found."),
+ (
+ BrandCleanupRequiredError(),
+ 409,
+ "BRAND_CLEANUP_REQUIRED",
+ "Brand cleanup is required. Retry deletion.",
+ ),
+ (
+ IdempotencyKeyRetiredError(),
+ 409,
+ "IDEMPOTENCY_KEY_RETIRED",
+ "This add request was already completed and deleted. Use a new request ID.",
+ ),
+ (
+ VaultUnavailableError(),
+ 502,
+ "VAULT_UNAVAILABLE",
+ "Secure key storage is unavailable right now.",
+ ),
+ ],
+)
+def test_provider_key_errors_use_exact_safe_envelopes(
+ provider_key_client, error, status_code, code, message
+):
+ client, store = provider_key_client
+ store.error = error
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys",
+ headers={"Idempotency-Key": str(REQUEST_ID)},
+ json={"provider": "gemini", "key": RAW_KEY},
+ )
+
+ assert response.status_code == status_code
+ assert response.json()["error"]["code"] == code
+ assert response.json()["error"]["message"] == message
+ assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
+ assert RAW_KEY not in response.text
+
+
+def test_opaque_brand_error_is_identical_for_list_and_add(provider_key_client):
+ client, store = provider_key_client
+ store.error = LookupError()
+ list_response = client.get(f"/api/v1/brands/{BRAND_ID}/keys")
+ add_response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys",
+ headers={"Idempotency-Key": str(REQUEST_ID)},
+ json={"provider": "openai", "key": RAW_KEY},
+ )
+
+ for response in (list_response, add_response):
+ assert response.status_code == 404
+ assert response.json()["error"]["code"] == "BRAND_NOT_FOUND"
+ assert response.json()["error"]["message"] == "Brand not found."
+
+
+def test_response_model_filters_internal_store_fields(provider_key_client):
+ client, store = provider_key_client
+ store.keys = [
+ {
+ **_safe_key().model_dump(),
+ "brand_id": BRAND_ID,
+ "vault_secret_id": UUID("55555555-5555-5555-5555-555555555555"),
+ "lifecycle": "normal",
+ "raw_key": RAW_KEY,
+ }
+ ]
+ response = client.get(f"/api/v1/brands/{BRAND_ID}/keys")
+
+ assert response.status_code == 200
+ assert RAW_KEY not in response.text
+ assert "vault_secret_id" not in response.text
+
+
+def test_add_uses_no_provider_client_dependency(provider_key_client):
+ client, _ = provider_key_client
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys",
+ headers={"Idempotency-Key": str(REQUEST_ID)},
+ json={"provider": "openai", "key": RAW_KEY},
+ )
+ assert response.status_code == 201
diff --git a/backend/tests/integration/test_provider_key_rls.py b/backend/tests/integration/test_provider_key_rls.py
index 629cdb0..36189ed 100644
--- a/backend/tests/integration/test_provider_key_rls.py
+++ b/backend/tests/integration/test_provider_key_rls.py
@@ -7,6 +7,7 @@
import httpx
import jwt
import pytest
+from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import DBAPIError
@@ -87,7 +88,6 @@ def security_fixture():
engine = get_engine()
user_ids: list[str] = []
brand_ids: list[str] = []
- key_ids: list[str] = []
with httpx.Client(timeout=30.0) as client:
try:
user_a, token_a = _signup_and_login(
@@ -106,7 +106,6 @@ def security_fixture():
brand_a, brand_b = str(uuid4()), str(uuid4())
key_a, key_b = str(uuid4()), str(uuid4())
brand_ids.extend((brand_a, brand_b))
- key_ids.extend((key_a, key_b))
with engine.begin() as connection:
connection.execute(
text(
@@ -149,14 +148,29 @@ def security_fixture():
"key_a": key_a,
})
finally:
- if key_ids:
+ if brand_ids:
with engine.begin() as connection:
+ vault_ids = connection.execute(
+ text(
+ "SELECT vault_secret_id FROM provider_keys "
+ "WHERE brand_id = ANY(CAST(:ids AS uuid[]))"
+ ),
+ {"ids": brand_ids},
+ ).scalars().all()
+ if vault_ids:
+ connection.execute(
+ text(
+ "DELETE FROM vault.secrets "
+ "WHERE id = ANY(CAST(:ids AS uuid[]))"
+ ),
+ {"ids": vault_ids},
+ )
connection.execute(
text(
"DELETE FROM provider_keys "
- "WHERE id = ANY(CAST(:ids AS uuid[]))"
+ "WHERE brand_id = ANY(CAST(:ids AS uuid[]))"
),
- {"ids": key_ids},
+ {"ids": brand_ids},
)
if brand_ids:
with engine.begin() as connection:
@@ -664,3 +678,116 @@ def test_backend_role_has_required_application_and_vault_privileges(
text("SELECT has_table_privilege(current_user, :table, 'SELECT')"),
{"table": relation},
).scalar_one()
+
+
+def test_provider_key_api_owner_and_hidden_brand_parity(security_fixture):
+ from backend.app.main import app
+
+ fixture = security_fixture
+ nonexistent_brand = str(uuid4())
+ raw_key = f"rls-api-secret-{uuid4().hex}-Q7_W"
+ with TestClient(app) as client:
+ owner_list = client.get(
+ f"/api/v1/brands/{fixture['brand_a']}/keys",
+ headers={"Authorization": f"Bearer {fixture['token_a']}"},
+ )
+ hidden_list = client.get(
+ f"/api/v1/brands/{fixture['brand_a']}/keys",
+ headers={"Authorization": f"Bearer {fixture['token_b']}"},
+ )
+ missing_list = client.get(
+ f"/api/v1/brands/{nonexistent_brand}/keys",
+ headers={"Authorization": f"Bearer {fixture['token_b']}"},
+ )
+ owner_add = client.post(
+ f"/api/v1/brands/{fixture['brand_a']}/keys",
+ headers={
+ "Authorization": f"Bearer {fixture['token_a']}",
+ "Idempotency-Key": str(uuid4()),
+ },
+ json={"provider": "gemini", "key": raw_key, "make_active": False},
+ )
+ hidden_add = client.post(
+ f"/api/v1/brands/{fixture['brand_a']}/keys",
+ headers={
+ "Authorization": f"Bearer {fixture['token_b']}",
+ "Idempotency-Key": str(uuid4()),
+ },
+ json={"provider": "openai", "key": raw_key},
+ )
+ missing_add = client.post(
+ f"/api/v1/brands/{nonexistent_brand}/keys",
+ headers={
+ "Authorization": f"Bearer {fixture['token_b']}",
+ "Idempotency-Key": str(uuid4()),
+ },
+ json={"provider": "openai", "key": raw_key},
+ )
+
+ assert owner_list.status_code == 200
+ assert owner_list.json() == {
+ "keys": [
+ {
+ "id": fixture["key_a"],
+ "provider": "openai",
+ "label": "A",
+ "key_hint": "***a-_1",
+ "is_active": False,
+ "is_valid": None,
+ "last_validated_at": None,
+ "last_validation_error": None,
+ "cleanup_state": "normal",
+ "created_at": owner_list.json()["keys"][0]["created_at"],
+ }
+ ]
+ }
+ assert owner_add.status_code == 201
+ assert owner_add.json()["key_hint"] == "***Q7_W"
+ assert raw_key not in owner_add.text
+ for hidden, missing in ((hidden_list, missing_list), (hidden_add, missing_add)):
+ assert hidden.status_code == missing.status_code == 404
+ assert hidden.json()["error"]["code"] == missing.json()["error"]["code"] == "BRAND_NOT_FOUND"
+ assert hidden.json()["error"]["message"] == missing.json()["error"]["message"] == "Brand not found."
+ assert raw_key not in hidden_add.text + missing_add.text
+
+
+def test_provider_key_data_api_exposes_only_owner_safe_columns(security_fixture):
+ fixture = security_fixture
+ client = fixture["client"]
+
+ def headers(token: str) -> dict[str, str]:
+ return {
+ "apikey": fixture["supabase_key"],
+ "Authorization": f"Bearer {token}",
+ }
+
+ safe_select = (
+ "id,provider,label,key_hint,lifecycle,is_active,is_valid,"
+ "last_validated_at,last_validation_error,created_at"
+ )
+ owner = client.get(
+ f"{fixture['supabase_url']}/rest/v1/provider_keys",
+ headers=headers(fixture["token_a"]),
+ params={"select": safe_select},
+ )
+ non_owner = client.get(
+ f"{fixture['supabase_url']}/rest/v1/provider_keys",
+ headers=headers(fixture["token_b"]),
+ params={"select": safe_select},
+ )
+ internal = client.get(
+ f"{fixture['supabase_url']}/rest/v1/provider_keys",
+ headers=headers(fixture["token_a"]),
+ params={"select": "brand_id,vault_secret_id,validation_token,last_used_at"},
+ )
+ vault = client.get(
+ f"{fixture['supabase_url']}/rest/v1/decrypted_secrets",
+ headers={**headers(fixture["token_a"]), "Accept-Profile": "vault"},
+ )
+
+ assert owner.status_code == 200
+ assert [row["id"] for row in owner.json()] == [fixture["key_a"]]
+ assert non_owner.status_code == 200
+ assert all(row["id"] != fixture["key_a"] for row in non_owner.json())
+ assert internal.status_code in {400, 401, 403}
+ assert vault.status_code in {400, 401, 403, 404, 406}
diff --git a/backend/tests/integration/test_provider_keys.py b/backend/tests/integration/test_provider_keys.py
new file mode 100644
index 0000000..535f41f
--- /dev/null
+++ b/backend/tests/integration/test_provider_keys.py
@@ -0,0 +1,343 @@
+from __future__ import annotations
+
+import os
+from uuid import UUID, uuid4
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from sqlalchemy.exc import DBAPIError
+
+
+class SecretFixture(str):
+ def __repr__(self) -> str:
+ return ""
+
+
+class ProviderFixture(dict):
+ def __repr__(self) -> str:
+ return ""
+
+
+def _required_env(name: str) -> str:
+ value = os.getenv(name)
+ if not value:
+ pytest.fail(f"{name} is required; provider-key integration tests must execute")
+ return value
+
+
+def _signup_and_login(
+ client: httpx.Client, supabase_url: str, supabase_key: str
+) -> tuple[str, str]:
+ email = f"provider-keys-{uuid4().hex[:12]}@example.com"
+ password = "12345678"
+ signup = client.post(
+ f"{supabase_url}/auth/v1/signup",
+ headers={"apikey": supabase_key, "Content-Type": "application/json"},
+ json={"email": email, "password": password},
+ )
+ assert signup.status_code in {200, 201}
+ token = client.post(
+ f"{supabase_url}/auth/v1/token?grant_type=password",
+ headers={"apikey": supabase_key, "Content-Type": "application/json"},
+ json={"email": email, "password": password},
+ )
+ assert token.status_code == 200
+ return signup.json()["user"]["id"], token.json()["access_token"]
+
+
+@pytest.fixture
+def provider_fixture():
+ supabase_url = _required_env("SUPABASE_URL")
+ supabase_key = _required_env("SUPABASE_SECRET_KEY")
+ _required_env("DATABASE_URL")
+
+ from backend.app.config import get_engine
+
+ engine = get_engine()
+ user_id: str | None = None
+ brand_id = uuid4()
+ with httpx.Client(timeout=30.0) as supabase_client:
+ try:
+ user_id, access_token = _signup_and_login(
+ supabase_client, supabase_url, supabase_key
+ )
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ "INSERT INTO brands (id, owner_user_id, name) "
+ "VALUES (:id, :owner, 'Provider Keys Integration')"
+ ),
+ {"id": brand_id, "owner": user_id},
+ )
+ yield ProviderFixture({
+ "engine": engine,
+ "brand_id": brand_id,
+ "headers": {"Authorization": f"Bearer {access_token}"},
+ })
+ finally:
+ with engine.begin() as connection:
+ vault_ids = connection.execute(
+ text(
+ "SELECT vault_secret_id FROM provider_keys "
+ "WHERE brand_id = :brand_id"
+ ),
+ {"brand_id": brand_id},
+ ).scalars().all()
+ if vault_ids:
+ connection.execute(
+ text("DELETE FROM vault.secrets WHERE id = ANY(:ids)"),
+ {"ids": vault_ids},
+ )
+ connection.execute(
+ text("DELETE FROM provider_keys WHERE brand_id = :brand_id"),
+ {"brand_id": brand_id},
+ )
+ connection.execute(
+ text(
+ "DELETE FROM provider_key_idempotency "
+ "WHERE brand_id = :brand_id"
+ ),
+ {"brand_id": brand_id},
+ )
+ connection.execute(
+ text("DELETE FROM brands WHERE id = :brand_id"),
+ {"brand_id": brand_id},
+ )
+ if user_id:
+ supabase_client.delete(
+ f"{supabase_url}/auth/v1/admin/users/{user_id}",
+ headers={
+ "apikey": supabase_key,
+ "Authorization": f"Bearer {supabase_key}",
+ },
+ )
+
+
+def test_real_vault_add_list_activation_idempotency_and_retired_receipt(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ brand_id = fixture["brand_id"]
+ openai_request = uuid4()
+ gemini_request = uuid4()
+ replacement_request = uuid4()
+ openai_key = SecretFixture(f"openai-integration-{uuid4().hex}-A1B2")
+ gemini_key = SecretFixture(f"gemini-integration-{uuid4().hex}-C3_D")
+ replacement_key = SecretFixture(f"openai-replacement-{uuid4().hex}-E5-F")
+
+ with TestClient(app) as client:
+ empty = client.get(
+ f"/api/v1/brands/{brand_id}/keys", headers=fixture["headers"]
+ )
+ first = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(openai_request)},
+ json={
+ "provider": "openai",
+ "key": str(openai_key),
+ "label": "Inactive OpenAI",
+ "make_active": False,
+ },
+ )
+ gemini = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(gemini_request)},
+ json={"provider": "gemini", "key": str(gemini_key)},
+ )
+ replacement = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={
+ **fixture["headers"],
+ "Idempotency-Key": str(replacement_request),
+ },
+ json={"provider": "openai", "key": str(replacement_key)},
+ )
+ retry = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(openai_request)},
+ json={"provider": "gemini", "key": str(replacement_key)},
+ )
+ listed = client.get(
+ f"/api/v1/brands/{brand_id}/keys", headers=fixture["headers"]
+ )
+
+ assert empty.status_code == 200 and empty.json() == {"keys": []}
+ assert first.status_code == gemini.status_code == replacement.status_code == 201
+ assert first.json()["key_hint"] == "***A1B2"
+ assert gemini.json()["key_hint"] == "***C3_D"
+ assert replacement.json()["key_hint"] == "***E5-F"
+ assert first.json()["is_active"] is False
+ assert gemini.json()["is_active"] is True
+ assert replacement.json()["is_active"] is True
+ assert retry.status_code == 201
+ assert retry.json() == first.json()
+ assert listed.status_code == 200
+ keys = listed.json()["keys"]
+ assert [(key["provider"], key["id"]) for key in keys] == [
+ ("openai", replacement.json()["id"]),
+ ("openai", first.json()["id"]),
+ ("gemini", gemini.json()["id"]),
+ ]
+ serialized = "".join(response.text for response in (first, gemini, replacement, retry, listed))
+ for raw_key in (openai_key, gemini_key, replacement_key):
+ assert raw_key not in serialized
+
+ engine = fixture["engine"]
+ with engine.connect() as connection:
+ rows = connection.execute(
+ text(
+ "SELECT id, provider, vault_secret_id, key_hint, is_active "
+ "FROM provider_keys WHERE brand_id = :brand_id "
+ "ORDER BY provider, created_at DESC, id DESC"
+ ),
+ {"brand_id": brand_id},
+ ).mappings().all()
+ assert len(rows) == 3
+ assert sum(row["is_active"] for row in rows if row["provider"] == "openai") == 1
+ assert sum(row["is_active"] for row in rows if row["provider"] == "gemini") == 1
+ assert connection.execute(
+ text(
+ "SELECT count(*) FROM provider_key_idempotency "
+ "WHERE brand_id = :brand_id"
+ ),
+ {"brand_id": brand_id},
+ ).scalar_one() == 3
+ decrypted_matches = connection.execute(
+ text(
+ "SELECT count(*) FROM provider_keys pk "
+ "JOIN vault.decrypted_secrets ds ON ds.id = pk.vault_secret_id "
+ "WHERE pk.brand_id = :brand_id AND ds.decrypted_secret = ANY(:values)"
+ ),
+ {
+ "brand_id": brand_id,
+ "values": [str(openai_key), str(gemini_key), str(replacement_key)],
+ },
+ ).scalar_one()
+ assert decrypted_matches == 3
+
+ retired_id = UUID(first.json()["id"])
+ with engine.begin() as connection:
+ vault_id = connection.execute(
+ text("SELECT vault_secret_id FROM provider_keys WHERE id = :id"),
+ {"id": retired_id},
+ ).scalar_one()
+ connection.execute(text("DELETE FROM vault.secrets WHERE id = :id"), {"id": vault_id})
+ connection.execute(text("DELETE FROM provider_keys WHERE id = :id"), {"id": retired_id})
+ receipt = connection.execute(
+ text(
+ "SELECT state, provider_key_id FROM provider_key_idempotency "
+ "WHERE brand_id = :brand_id AND request_id = :request_id"
+ ),
+ {"brand_id": brand_id, "request_id": openai_request},
+ ).one()
+ assert receipt.state == "active"
+ assert receipt.provider_key_id is None
+
+ with TestClient(app) as client:
+ retired = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(openai_request)},
+ json={"provider": "openai", "key": str(openai_key)},
+ )
+ assert retired.status_code == 409
+ assert retired.json()["error"]["code"] == "IDEMPOTENCY_KEY_RETIRED"
+
+
+def test_real_add_validation_and_cleanup_fences_create_no_secret(provider_fixture):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ brand_id = fixture["brand_id"]
+ raw_key = SecretFixture(f"fenced-provider-{uuid4().hex}-Z9_Y")
+ engine = fixture["engine"]
+ invalid_bodies = [
+ {"provider": "openai", "key": ""},
+ {"provider": "openai", "key": "abcd"},
+ {"provider": "openai", "key": "invalid!?"},
+ {"provider": "unknown", "key": str(raw_key)},
+ {"provider": "gemini", "key": str(raw_key), "label": "x" * 101},
+ ]
+ with engine.connect() as connection:
+ before = connection.execute(text("SELECT count(*) FROM vault.secrets")).scalar_one()
+ with TestClient(app) as client:
+ for body in invalid_bodies:
+ response = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(uuid4())},
+ json=body,
+ )
+ assert response.status_code == 400
+ cleanup_key_id = uuid4()
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ "INSERT INTO provider_keys "
+ "(id, brand_id, provider, vault_secret_id, key_hint, lifecycle) "
+ "VALUES (:id, :brand_id, 'openai', :vault_id, '***K3_Y', "
+ "'cleanup_required')"
+ ),
+ {
+ "id": cleanup_key_id,
+ "brand_id": brand_id,
+ "vault_id": uuid4(),
+ },
+ )
+ pending_key_cleanup = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(uuid4())},
+ json={"provider": "openai", "key": str(raw_key)},
+ )
+ with engine.begin() as connection:
+ connection.execute(
+ text("DELETE FROM provider_keys WHERE id = :id"),
+ {"id": cleanup_key_id},
+ )
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ "UPDATE brands SET deletion_state = 'cleanup_required' "
+ "WHERE id = :brand_id"
+ ),
+ {"brand_id": brand_id},
+ )
+ fenced = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(uuid4())},
+ json={"provider": "openai", "key": str(raw_key)},
+ )
+ assert pending_key_cleanup.status_code == 409
+ assert pending_key_cleanup.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+ assert fenced.status_code == 409
+ assert fenced.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+ with engine.connect() as connection:
+ assert connection.execute(text("SELECT count(*) FROM vault.secrets")).scalar_one() == before
+ assert connection.execute(
+ text("SELECT count(*) FROM provider_keys WHERE brand_id = :brand_id"),
+ {"brand_id": brand_id},
+ ).scalar_one() == 0
+
+
+def test_real_vault_and_metadata_work_roll_back_together(provider_fixture):
+ fixture = provider_fixture
+ engine = fixture["engine"]
+ vault_id = None
+ raw_key = SecretFixture(f"rollback-provider-{uuid4().hex}-R0_L")
+
+ with pytest.raises(DBAPIError):
+ with engine.begin() as connection:
+ vault_id = connection.execute(
+ text("SELECT vault.create_secret(:secret, NULL, '')"),
+ {"secret": str(raw_key)},
+ ).scalar_one()
+ connection.execute(text("SELECT 1 / 0"))
+
+ assert vault_id is not None
+ with engine.connect() as connection:
+ assert connection.execute(
+ text("SELECT count(*) FROM vault.secrets WHERE id = :id"),
+ {"id": vault_id},
+ ).scalar_one() == 0
diff --git a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
new file mode 100644
index 0000000..a50220d
--- /dev/null
+++ b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
@@ -0,0 +1,368 @@
+"use client";
+
+import Link from "next/link";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useRef, useState, type FormEvent } from "react";
+
+import { getPublicEnv } from "@/lib/runtime-env";
+import { supabase } from "@/lib/supabase/client";
+
+type Provider = "openai" | "gemini";
+type CleanupState = "normal" | "cleanup_required";
+
+type Brand = {
+ id: string;
+ name: string;
+ cleanup_state: CleanupState;
+};
+
+type ProviderKey = {
+ id: string;
+ provider: Provider;
+ label: string | null;
+ key_hint: string;
+ is_active: boolean;
+ is_valid: boolean | null;
+ last_validated_at: string | null;
+ last_validation_error: string | null;
+ cleanup_state: CleanupState;
+ created_at: string;
+};
+
+type ErrorResponse = { error?: { code?: string } };
+
+const PROVIDERS: Array<{ id: Provider; name: string }> = [
+ { id: "openai", name: "OpenAI" },
+ { id: "gemini", name: "Gemini" },
+];
+
+function addErrorMessage(code?: string) {
+ switch (code) {
+ case "VALIDATION_ERROR":
+ return "Check the key format and label, then try again.";
+ case "BRAND_CLEANUP_REQUIRED":
+ return "Brand cleanup is required before another key can be added.";
+ case "IDEMPOTENCY_KEY_RETIRED":
+ return "This request was already completed and deleted. Submit again to create a new key.";
+ case "VAULT_UNAVAILABLE":
+ return "Secure key storage is unavailable right now.";
+ default:
+ return "Unable to add the provider key.";
+ }
+}
+
+function validationLabel(key: ProviderKey) {
+ if (key.is_valid === true) return "Valid";
+ if (key.is_valid === false) return "Invalid";
+ return "Unvalidated";
+}
+
+export default function ProviderKeysPage() {
+ const { brandId } = useParams<{ brandId: string }>();
+ const router = useRouter();
+ const apiBase = getPublicEnv("NEXT_PUBLIC_API_URL");
+ const keyInputRef = useRef(null);
+ const retryIdRef = useRef(null);
+ const [brand, setBrand] = useState(null);
+ const [keys, setKeys] = useState([]);
+ const [provider, setProvider] = useState("openai");
+ const [label, setLabel] = useState("");
+ const [makeActive, setMakeActive] = useState(true);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isNotFound, setIsNotFound] = useState(false);
+ const [isAdding, setIsAdding] = useState(false);
+ const [error, setError] = useState(null);
+ const [addError, setAddError] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+
+ async function loadKeys() {
+ setIsLoading(true);
+ setError(null);
+ setIsNotFound(false);
+
+ try {
+ const { data } = await supabase.auth.getSession();
+ const session = data.session;
+ if (!session) {
+ router.push("/login");
+ return;
+ }
+
+ const path = `${apiBase}/v1/brands/${encodeURIComponent(brandId)}`;
+ const headers = { Authorization: `Bearer ${session.access_token}` };
+ const [brandResponse, keysResponse] = await Promise.all([
+ fetch(path, { headers }),
+ fetch(`${path}/keys`, { headers }),
+ ]);
+
+ if (brandResponse.status === 404 || keysResponse.status === 404) {
+ if (active) setIsNotFound(true);
+ return;
+ }
+ if (!brandResponse.ok || !keysResponse.ok) {
+ throw new Error("Provider key metadata was unavailable.");
+ }
+
+ const [brandBody, keysBody] = (await Promise.all([
+ brandResponse.json(),
+ keysResponse.json(),
+ ])) as [Brand, { keys: ProviderKey[] }];
+ if (active) {
+ setBrand(brandBody);
+ setKeys(keysBody.keys);
+ }
+ } catch {
+ if (active) setError("Unable to load provider keys.");
+ } finally {
+ if (active) setIsLoading(false);
+ }
+ }
+
+ void loadKeys();
+ return () => {
+ active = false;
+ };
+ }, [apiBase, brandId, router]);
+
+ function beginNewAttempt() {
+ retryIdRef.current = null;
+ setAddError(null);
+ }
+
+ async function addKey(event: FormEvent) {
+ event.preventDefault();
+ const rawKey = keyInputRef.current?.value ?? "";
+ if (!rawKey) {
+ setAddError("Enter a provider key.");
+ return;
+ }
+
+ const idempotencyKey = retryIdRef.current ?? crypto.randomUUID();
+ setAddError(null);
+ setIsAdding(true);
+
+ try {
+ const { data } = await supabase.auth.getSession();
+ const session = data.session;
+ if (!session) {
+ router.push("/login");
+ return;
+ }
+
+ const response = await fetch(
+ `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/keys`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${session.access_token}`,
+ "Content-Type": "application/json",
+ "Idempotency-Key": idempotencyKey,
+ },
+ body: JSON.stringify({
+ provider,
+ key: rawKey,
+ label: label || null,
+ make_active: makeActive,
+ }),
+ }
+ );
+
+ const body = (await response.json().catch(() => null)) as
+ | ProviderKey
+ | ErrorResponse
+ | null;
+ if (!response.ok) {
+ retryIdRef.current = response.status >= 500 ? idempotencyKey : null;
+ setAddError(addErrorMessage((body as ErrorResponse | null)?.error?.code));
+ return;
+ }
+
+ retryIdRef.current = null;
+ const addedKey = body as ProviderKey;
+ setKeys((current) => [
+ addedKey,
+ ...current.map((key) =>
+ addedKey.is_active && key.provider === addedKey.provider
+ ? { ...key, is_active: false }
+ : key
+ ),
+ ]);
+ if (keyInputRef.current) keyInputRef.current.value = "";
+ setLabel("");
+ setMakeActive(true);
+ } catch {
+ retryIdRef.current = idempotencyKey;
+ setAddError(
+ "The request outcome is unknown. Retry this unchanged submission to reconcile it."
+ );
+ } finally {
+ setIsAdding(false);
+ }
+ }
+
+ if (isLoading) {
+ return Loading provider keys...
;
+ }
+
+ if (isNotFound) {
+ return (
+
+ Brand not found
+
+ This brand does not exist or is not available to you.
+
+
+ Back to brands
+
+
+ );
+ }
+
+ if (error || !brand) {
+ return {error ?? "Unable to load provider keys."}
;
+ }
+
+ const providerName = PROVIDERS.find((item) => item.id === provider)?.name ?? provider;
+ const providerKeys = keys.filter((key) => key.provider === provider);
+ const cleanupRequired = brand.cleanup_state === "cleanup_required";
+
+ return (
+
+
+
+ Back to {brand.name}
+
+
+
+
Provider setup
+
Provider keys
+
+ Keys are stored securely. Only labels and masked hints are shown after submission.
+
+
+ {cleanupRequired ? (
+
+ Brand cleanup required
+
+ ) : null}
+
+
+
+
+ {PROVIDERS.map((item) => (
+ {
+ setProvider(item.id);
+ beginNewAttempt();
+ }}
+ >
+ {item.name}
+
+ ))}
+
+
+
+
+
+
{providerName} keys
+ {providerKeys.length} saved
+
+ {providerKeys.length === 0 ? (
+
+ No {providerName} keys have been added.
+
+ ) : (
+ providerKeys.map((key) => (
+
+
+
+
{key.label || `${providerName} key`}
+
{key.key_hint}
+
+
+
+ {key.is_active ? "Active" : "Inactive"}
+
+
+ {validationLabel(key)}
+
+ {key.cleanup_state === "cleanup_required" ? (
+
+ Cleanup required
+
+ ) : null}
+
+
+
+ Added {new Date(key.created_at).toLocaleDateString()}
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(dashboard)/brands/[brandId]/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/page.tsx
index 580e080..f70572e 100644
--- a/frontend/app/(dashboard)/brands/[brandId]/page.tsx
+++ b/frontend/app/(dashboard)/brands/[brandId]/page.tsx
@@ -17,6 +17,7 @@ type Brand = {
id: string;
name: string;
logo_url: string | null;
+ cleanup_state: "normal" | "cleanup_required";
created_at: string;
};
@@ -282,6 +283,8 @@ export default function BrandDetailPage() {
);
}
+ const cleanupRequired = brand.cleanup_state === "cleanup_required";
+
return (
@@ -291,9 +294,14 @@ export default function BrandDetailPage() {
Brand
-
- {brand.name}
-
+
+
{brand.name}
+ {cleanupRequired ? (
+
+ Cleanup required
+
+ ) : null}
+
Created
@@ -304,6 +312,25 @@ export default function BrandDetailPage() {
+
+
Provider keys
+
+ Configure brand-scoped OpenAI and Gemini credentials without exposing them after submission.
+
+ {cleanupRequired ? (
+
+ Provider setup is unavailable until brand cleanup completes.
+
+ ) : (
+
+ Manage provider keys
+
+ )}
+
+
Brand logo
@@ -331,7 +358,7 @@ export default function BrandDetailPage() {
className="block w-full text-sm file:mr-4 file:rounded-md file:border file:bg-white file:px-3 file:py-2 file:text-sm"
type="file"
accept="image/png,image/jpeg,image/webp"
- disabled={isUploading || isRemoving || isDeleting}
+ disabled={cleanupRequired || isUploading || isRemoving || isDeleting}
onChange={selectLogo}
/>
{logoError ? (
@@ -343,7 +370,7 @@ export default function BrandDetailPage() {
{isUploading
? "Uploading..."
@@ -355,7 +382,7 @@ export default function BrandDetailPage() {
{isRemoving ? "Removing..." : "Remove logo"}
diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx
index 916d09d..274d7cc 100644
--- a/frontend/app/(dashboard)/layout.tsx
+++ b/frontend/app/(dashboard)/layout.tsx
@@ -70,7 +70,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
};
}, [apiBase]);
- const pathBrandId = pathname.match(/^\/brands\/([^/]+)$/)?.[1];
+ const pathBrandId = pathname.match(/^\/brands\/([^/]+)(?:\/.*)?$/)?.[1];
const selectedBrandId = pathBrandId === "new" ? "" : (pathBrandId ?? "");
async function handleLogout() {
diff --git a/frontend/tests/e2e/provider-keys.spec.ts b/frontend/tests/e2e/provider-keys.spec.ts
new file mode 100644
index 0000000..5d0f416
--- /dev/null
+++ b/frontend/tests/e2e/provider-keys.spec.ts
@@ -0,0 +1,152 @@
+import { expect, test } from "@playwright/test";
+
+type SafeKey = {
+ id: string;
+ provider: "openai" | "gemini";
+ label: string | null;
+ key_hint: string;
+ is_active: boolean;
+ is_valid: null;
+ last_validated_at: null;
+ last_validation_error: null;
+ cleanup_state: "normal";
+ created_at: string;
+};
+
+test("adds, groups, and reloads safe provider-key metadata", async ({ page }) => {
+ const brandId = crypto.randomUUID();
+ const rawKeys = {
+ openai: `dummy-${crypto.randomUUID()}-A1B2`,
+ gemini: `dummy-${crypto.randomUUID()}-G3D4`,
+ };
+ const savedKeys: SafeKey[] = [];
+ let submittedKeyBodies = 0;
+ let requestWasOpaque = true;
+ let responseWasOpaque = true;
+
+ await page.route("**/api/v1/**", async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ const requestText = `${request.url()} ${JSON.stringify(request.headers())}`;
+ requestWasOpaque &&= !Object.values(rawKeys).some((key) => requestText.includes(key));
+
+ if (url.pathname === "/api/v1/brands" && request.method() === "GET") {
+ const body = JSON.stringify({
+ brands: [
+ {
+ id: brandId,
+ name: "Provider Key Test Brand",
+ logo_url: null,
+ cleanup_state: "normal",
+ created_at: "2026-07-28T10:00:00Z",
+ },
+ ],
+ });
+ responseWasOpaque &&= !Object.values(rawKeys).some((key) => body.includes(key));
+ await route.fulfill({ status: 200, contentType: "application/json", body });
+ return;
+ }
+
+ if (url.pathname === `/api/v1/brands/${brandId}` && request.method() === "GET") {
+ const body = JSON.stringify({
+ id: brandId,
+ name: "Provider Key Test Brand",
+ logo_url: null,
+ cleanup_state: "normal",
+ created_at: "2026-07-28T10:00:00Z",
+ });
+ responseWasOpaque &&= !Object.values(rawKeys).some((key) => body.includes(key));
+ await route.fulfill({ status: 200, contentType: "application/json", body });
+ return;
+ }
+
+ if (url.pathname === `/api/v1/brands/${brandId}/keys` && request.method() === "GET") {
+ const body = JSON.stringify({ keys: savedKeys });
+ responseWasOpaque &&= !Object.values(rawKeys).some((key) => body.includes(key));
+ await route.fulfill({ status: 200, contentType: "application/json", body });
+ return;
+ }
+
+ if (url.pathname === `/api/v1/brands/${brandId}/keys` && request.method() === "POST") {
+ const payload = request.postDataJSON() as {
+ provider: "openai" | "gemini";
+ key: string;
+ label: string | null;
+ make_active: boolean;
+ };
+ const expectedKey = rawKeys[payload.provider];
+ if (payload.key === expectedKey) submittedKeyBodies += 1;
+ requestWasOpaque &&=
+ Boolean(request.headers()["idempotency-key"]?.match(/^[0-9a-f-]{36}$/i)) &&
+ Object.values(rawKeys).filter((key) => request.postData()?.includes(key)).length === 1;
+
+ if (payload.make_active) {
+ savedKeys.forEach((key) => {
+ if (key.provider === payload.provider) key.is_active = false;
+ });
+ }
+ const added: SafeKey = {
+ id: crypto.randomUUID(),
+ provider: payload.provider,
+ label: payload.label,
+ key_hint: `***${payload.key.slice(-4)}`,
+ is_active: payload.make_active,
+ is_valid: null,
+ last_validated_at: null,
+ last_validation_error: null,
+ cleanup_state: "normal",
+ created_at: new Date().toISOString(),
+ };
+ savedKeys.unshift(added);
+ const body = JSON.stringify(added);
+ responseWasOpaque &&= !Object.values(rawKeys).some((key) => body.includes(key));
+ await route.fulfill({ status: 201, contentType: "application/json", body });
+ return;
+ }
+
+ await route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "NOT_FOUND", message: "Not found." } }),
+ });
+ });
+
+ const email = `provider-keys-${Date.now()}@example.com`;
+ await page.goto("/signup");
+ await page.getByLabel("Email").fill(email);
+ await page.getByLabel("Password").fill("password123");
+ await page.getByRole("button", { name: "Sign up" }).click();
+ await expect(page.getByText(/Account created/i)).toBeVisible();
+
+ await page.goto(`/brands/${brandId}/keys`);
+ await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "OpenAI" })).toHaveAttribute("aria-selected", "true");
+
+ await page.getByLabel(/Label/).fill("Inactive OpenAI");
+ await page.getByLabel("API key").fill(rawKeys.openai);
+ await page.getByLabel("Make active").uncheck();
+ await page.getByRole("button", { name: "Add OpenAI key" }).click();
+ await expect(page.getByText("***A1B2")).toBeVisible();
+ await expect(page.getByText("Inactive", { exact: true })).toBeVisible();
+ await expect(page.getByText("Unvalidated", { exact: true })).toBeVisible();
+ await expect(page.getByLabel("API key")).toHaveValue("");
+
+ await page.getByRole("tab", { name: "Gemini" }).click();
+ await page.getByLabel(/Label/).fill("Active Gemini");
+ await page.getByLabel("API key").fill(rawKeys.gemini);
+ await page.getByRole("button", { name: "Add Gemini key" }).click();
+ await expect(page.getByText("***G3D4")).toBeVisible();
+ await expect(page.getByText("Active", { exact: true })).toBeVisible();
+
+ await page.reload();
+ await expect(page.getByText("***A1B2")).toBeVisible();
+ await page.getByRole("tab", { name: "Gemini" }).click();
+ await expect(page.getByText("***G3D4")).toBeVisible();
+ const visibleText = (await page.locator("body").textContent()) ?? "";
+ const rawKeyIsVisible = Object.values(rawKeys).some((key) => visibleText.includes(key));
+
+ expect(submittedKeyBodies === 2).toBeTruthy();
+ expect(requestWasOpaque).toBeTruthy();
+ expect(responseWasOpaque).toBeTruthy();
+ expect(rawKeyIsVisible).toBeFalsy();
+});
diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md
index d79213f..79259f9 100644
--- a/specs/004-provider-keys/tasks.md
+++ b/specs/004-provider-keys/tasks.md
@@ -78,18 +78,18 @@ ordinary rows, browser state, and logs never contain a raw key.
### Tests for User Story 1
-- [ ] T014 [P] [US1] Extend `backend/tests/contract/test_provider_keys.py` with failing exact-shape tests for `GET /api/v1/brands/{brand_id}/keys` and `POST /api/v1/brands/{brand_id}/keys`: empty/populated lists, default and explicit `make_active`, malformed/missing `Idempotency-Key`, invalid provider/key suffix/length/whitespace/label, label containing the raw key, retired receipt, Vault failure, opaque brand ownership, no provider call, and absence of raw/internal fields
-- [ ] T015 [P] [US1] Create failing real-Supabase add/list tests in `backend/tests/integration/test_provider_keys.py` for atomic Vault plus metadata creation, exact `***` final-four hints, inactive and per-provider active additions, one-active replacement, persisted safe list ordering, same-idempotency retry returning one row/secret, known rollback, ambiguous commit reconciliation, and retired receipt rejection after fixture-level deletion
-- [ ] T016 [P] [US1] Extend `backend/tests/integration/test_provider_key_rls.py` with failing API and direct-data tests for owner/non-owner/nonexistent brand list/add parity, safe owner metadata visibility, hidden `brand_id`/Vault/lease/last-used fields, denied same-owner and cross-owner writes, and raw Vault isolation through SQL and Data API
+- [X] T014 [P] [US1] Extend `backend/tests/contract/test_provider_keys.py` with failing exact-shape tests for `GET /api/v1/brands/{brand_id}/keys` and `POST /api/v1/brands/{brand_id}/keys`: empty/populated lists, default and explicit `make_active`, malformed/missing `Idempotency-Key`, invalid provider/key suffix/length/whitespace/label, label containing the raw key, retired receipt, Vault failure, opaque brand ownership, no provider call, and absence of raw/internal fields
+- [X] T015 [P] [US1] Create failing real-Supabase add/list tests in `backend/tests/integration/test_provider_keys.py` for atomic Vault plus metadata creation, exact `***` final-four hints, inactive and per-provider active additions, one-active replacement, persisted safe list ordering, same-idempotency retry returning one row/secret, known rollback, ambiguous commit reconciliation, and retired receipt rejection after fixture-level deletion
+- [X] T016 [P] [US1] Extend `backend/tests/integration/test_provider_key_rls.py` with failing API and direct-data tests for owner/non-owner/nonexistent brand list/add parity, safe owner metadata visibility, hidden `brand_id`/Vault/lease/last-used fields, denied same-owner and cross-owner writes, and raw Vault isolation through SQL and Data API
### Implementation for User Story 1
-- [ ] T017 [P] [US1] Create `backend/app/models/provider_key.py` with provider/lifecycle enums, verbatim raw-key add request validation, optional 100-character label validation that rejects the submitted key, UUID idempotency-header parsing support, and safe key/list response models containing only fields permitted by `contracts/provider-keys.md`
-- [ ] T018 [US1] Create `backend/app/services/provider_key_store.py` with safe row conversion and list/add methods that lock brand first, reject fenced brands or pending key cleanup, reconcile `provider_key_idempotency`, create the application UUID before `vault.create_secret`, atomically insert Vault secret/receipt/key metadata, atomically replace only the same provider's active key, and map known/ambiguous database outcomes without exposing binds or decrypted values
-- [ ] T019 [US1] Create `backend/app/routes/provider_keys.py` with dependency aliases and the list/add handlers, fixed safe error mapping, ownership-before-lifecycle ordering, required UUID `Idempotency-Key`, response-model filtering, and fixed safe structured events; register its router in `backend/app/main.py`
-- [ ] T020 [US1] Create `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with authenticated defensive fetches, OpenAI/Gemini groups, safe key cards, add form, active/inactive/unvalidated/cleanup badges, a client UUID retained for retry of one ambiguous submission, raw-key clearing after success, and no raw key in URLs, errors, or retained visible state
-- [ ] T021 [P] [US1] Add provider-keys navigation and cleanup-state disabling to `frontend/app/(dashboard)/brands/[brandId]/page.tsx`, and update current-brand path recognition for nested `/brands/{brandId}/keys` routes in `frontend/app/(dashboard)/layout.tsx`
-- [ ] T022 [US1] Create `frontend/tests/e2e/provider-keys.spec.ts` with a focused add/list/reload flow using disposable test fixtures, assertions for provider grouping and masked hints, and browser request/response checks that the submitted fixture key never reappears
+- [X] T017 [P] [US1] Create `backend/app/models/provider_key.py` with provider/lifecycle enums, verbatim raw-key add request validation, optional 100-character label validation that rejects the submitted key, UUID idempotency-header parsing support, and safe key/list response models containing only fields permitted by `contracts/provider-keys.md`
+- [X] T018 [US1] Create `backend/app/services/provider_key_store.py` with safe row conversion and list/add methods that lock brand first, reject fenced brands or pending key cleanup, reconcile `provider_key_idempotency`, create the application UUID before `vault.create_secret`, atomically insert Vault secret/receipt/key metadata, atomically replace only the same provider's active key, and map known/ambiguous database outcomes without exposing binds or decrypted values
+- [X] T019 [US1] Create `backend/app/routes/provider_keys.py` with dependency aliases and the list/add handlers, fixed safe error mapping, ownership-before-lifecycle ordering, required UUID `Idempotency-Key`, response-model filtering, and fixed safe structured events; register its router in `backend/app/main.py`
+- [X] T020 [US1] Create `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with authenticated defensive fetches, OpenAI/Gemini groups, safe key cards, add form, active/inactive/unvalidated/cleanup badges, a client UUID retained for retry of one ambiguous submission, raw-key clearing after success, and no raw key in URLs, errors, or retained visible state
+- [X] T021 [P] [US1] Add provider-keys navigation and cleanup-state disabling to `frontend/app/(dashboard)/brands/[brandId]/page.tsx`, and update current-brand path recognition for nested `/brands/{brandId}/keys` routes in `frontend/app/(dashboard)/layout.tsx`
+- [X] T022 [US1] Create `frontend/tests/e2e/provider-keys.spec.ts` with a focused add/list/reload flow using disposable test fixtures, assertions for provider grouping and masked hints, and browser request/response checks that the submitted fixture key never reappears
- [ ] T023 [US1] Execute Quickstart Scenario 1 from `specs/004-provider-keys/quickstart.md` plus `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, `backend/tests/integration/test_provider_key_rls.py`, and `frontend/tests/e2e/provider-keys.spec.ts`; confirm exactly one Vault secret and key row per idempotency UUID and no skipped security integration tests
**Checkpoint**: User Story 1 is a deployable BYOK setup MVP with no reveal path.
diff --git a/supabase/migrations/00016_create_provider_keys.sql b/supabase/migrations/00016_create_provider_keys.sql
index 81838f1..a37734d 100644
--- a/supabase/migrations/00016_create_provider_keys.sql
+++ b/supabase/migrations/00016_create_provider_keys.sql
@@ -203,9 +203,7 @@ REVOKE ALL ON brand_asset_operations FROM PUBLIC, anon, authenticated;
REVOKE ALL ON brands FROM authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON provider_keys TO service_role;
-GRANT SELECT, INSERT, UPDATE, DELETE ON provider_key_idempotency TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON provider_key_idempotency TO service_role;
-GRANT SELECT, INSERT, UPDATE, DELETE ON brand_asset_operations TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON brand_asset_operations TO service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON brands TO service_role;
From ea90484d669466b7afef6e04cfd5d6526bf124f0 Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 14:43:11 +0300
Subject: [PATCH 07/14] Configure Codex Spec Kit integration
---
.agents/skills/speckit-analyze/SKILL.md | 3 ---
.agents/skills/speckit-checklist/SKILL.md | 3 ---
.agents/skills/speckit-clarify/SKILL.md | 3 ---
.agents/skills/speckit-constitution/SKILL.md | 5 +----
.agents/skills/speckit-converge/SKILL.md | 2 --
.agents/skills/speckit-implement/SKILL.md | 3 ---
.agents/skills/speckit-plan/SKILL.md | 3 ---
.agents/skills/speckit-specify/SKILL.md | 3 ---
.agents/skills/speckit-tasks/SKILL.md | 3 ---
.agents/skills/speckit-taskstoissues/SKILL.md | 3 ---
.specify/init-options.json | 5 +++--
.specify/integration.json | 10 +++++-----
.specify/integrations/codex.manifest.json | 17 +++++++++++++++++
13 files changed, 26 insertions(+), 37 deletions(-)
create mode 100644 .specify/integrations/codex.manifest.json
diff --git a/.agents/skills/speckit-analyze/SKILL.md b/.agents/skills/speckit-analyze/SKILL.md
index 83cd91b..54110a1 100644
--- a/.agents/skills/speckit-analyze/SKILL.md
+++ b/.agents/skills/speckit-analyze/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-analyze"
description: "Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation."
-argument-hint: "Optional focus areas for analysis"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/analyze.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.agents/skills/speckit-checklist/SKILL.md b/.agents/skills/speckit-checklist/SKILL.md
index 927ef4a..35dadff 100644
--- a/.agents/skills/speckit-checklist/SKILL.md
+++ b/.agents/skills/speckit-checklist/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-checklist"
description: "Generate a custom checklist for the current feature based on user requirements."
-argument-hint: "Domain or focus area for the checklist"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/checklist.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.agents/skills/speckit-clarify/SKILL.md b/.agents/skills/speckit-clarify/SKILL.md
index 1715752..36e82e0 100644
--- a/.agents/skills/speckit-clarify/SKILL.md
+++ b/.agents/skills/speckit-clarify/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-clarify"
description: "Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec."
-argument-hint: "Optional areas to clarify in the spec"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/clarify.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.agents/skills/speckit-constitution/SKILL.md b/.agents/skills/speckit-constitution/SKILL.md
index 79a7b24..502ae8d 100644
--- a/.agents/skills/speckit-constitution/SKILL.md
+++ b/.agents/skills/speckit-constitution/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-constitution"
description: "Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync."
-argument-hint: "Principles or values for the project constitution"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/constitution.md"
-user-invocable: true
-disable-model-invocation: false
---
@@ -87,7 +84,7 @@ Follow this execution flow:
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- - Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.Codex/skills/`, or your agent's equivalent commands directory — to verify no outdated references (Codex-only or other agent-specific names) remain when generic guidance is required.
+ - Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
diff --git a/.agents/skills/speckit-converge/SKILL.md b/.agents/skills/speckit-converge/SKILL.md
index 61c8241..0c7a095 100644
--- a/.agents/skills/speckit-converge/SKILL.md
+++ b/.agents/skills/speckit-converge/SKILL.md
@@ -5,8 +5,6 @@ compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/converge.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.agents/skills/speckit-implement/SKILL.md b/.agents/skills/speckit-implement/SKILL.md
index 747783f..8fc24a8 100644
--- a/.agents/skills/speckit-implement/SKILL.md
+++ b/.agents/skills/speckit-implement/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-implement"
description: "Execute the implementation plan by processing and executing all tasks defined in tasks.md"
-argument-hint: "Optional implementation guidance or task filter"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/implement.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.agents/skills/speckit-plan/SKILL.md b/.agents/skills/speckit-plan/SKILL.md
index 7408c61..0e54d61 100644
--- a/.agents/skills/speckit-plan/SKILL.md
+++ b/.agents/skills/speckit-plan/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-plan"
description: "Execute the implementation planning workflow using the plan template to generate design artifacts."
-argument-hint: "Optional guidance for the planning phase"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/plan.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.agents/skills/speckit-specify/SKILL.md b/.agents/skills/speckit-specify/SKILL.md
index 992a6c4..5d641ea 100644
--- a/.agents/skills/speckit-specify/SKILL.md
+++ b/.agents/skills/speckit-specify/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-specify"
description: "Create or update the feature specification from a natural language feature description."
-argument-hint: "Describe the feature you want to specify"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/specify.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.agents/skills/speckit-tasks/SKILL.md b/.agents/skills/speckit-tasks/SKILL.md
index aa488b7..b254aec 100644
--- a/.agents/skills/speckit-tasks/SKILL.md
+++ b/.agents/skills/speckit-tasks/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-tasks"
description: "Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts."
-argument-hint: "Optional task generation constraints"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/tasks.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.agents/skills/speckit-taskstoissues/SKILL.md b/.agents/skills/speckit-taskstoissues/SKILL.md
index ce9e1a2..dc81526 100644
--- a/.agents/skills/speckit-taskstoissues/SKILL.md
+++ b/.agents/skills/speckit-taskstoissues/SKILL.md
@@ -1,13 +1,10 @@
---
name: "speckit-taskstoissues"
description: "Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts."
-argument-hint: "Optional filter or label for GitHub issues"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/taskstoissues.md"
-user-invocable: true
-disable-model-invocation: false
---
diff --git a/.specify/init-options.json b/.specify/init-options.json
index d5d98f7..642e6a5 100644
--- a/.specify/init-options.json
+++ b/.specify/init-options.json
@@ -1,8 +1,9 @@
{
- "ai": "opencode",
+ "ai": "codex",
+ "ai_skills": true,
"feature_numbering": "sequential",
"here": true,
- "integration": "opencode",
+ "integration": "codex",
"script": "sh",
"speckit_version": "0.12.14"
}
\ No newline at end of file
diff --git a/.specify/integration.json b/.specify/integration.json
index c5f2531..8580f99 100644
--- a/.specify/integration.json
+++ b/.specify/integration.json
@@ -2,14 +2,14 @@
"version": "0.12.14",
"integration_state_schema": 1,
"installed_integrations": [
- "opencode"
+ "codex"
],
"integration_settings": {
- "opencode": {
+ "codex": {
"script": "sh",
- "invoke_separator": "."
+ "invoke_separator": "-"
}
},
- "integration": "opencode",
- "default_integration": "opencode"
+ "integration": "codex",
+ "default_integration": "codex"
}
diff --git a/.specify/integrations/codex.manifest.json b/.specify/integrations/codex.manifest.json
new file mode 100644
index 0000000..845097c
--- /dev/null
+++ b/.specify/integrations/codex.manifest.json
@@ -0,0 +1,17 @@
+{
+ "integration": "codex",
+ "version": "0.12.14",
+ "installed_at": "2026-07-28T11:21:21.220147+00:00",
+ "files": {
+ ".agents/skills/speckit-analyze/SKILL.md": "2e89ffed73764eb9d3ee2d9fba7e245a5e73229b809ddec52409ca74c44e585d",
+ ".agents/skills/speckit-clarify/SKILL.md": "5095627c89a249d402ad2d0fad425ea0c0bd5e8559e9d6f5b682dea4f513ed54",
+ ".agents/skills/speckit-constitution/SKILL.md": "c57bf65daa9c23e24c97071bc0e4f930b3f458549b52792c4a65830de6b28f6b",
+ ".agents/skills/speckit-implement/SKILL.md": "248c2fa4be8a969ea771c88c38546d6735d7d4994bbe698bbabd18f53f88f0cd",
+ ".agents/skills/speckit-converge/SKILL.md": "1355b748919c7750cc87be94534192e5ff227229c14cbd34ca993a5f977a4ce2",
+ ".agents/skills/speckit-plan/SKILL.md": "c5522489fd177028e02ff4f1588a06bde811a0146cdc1d3495812694d6ea41b3",
+ ".agents/skills/speckit-checklist/SKILL.md": "4eb7e030d04014e6bbf5177f84aeef19b11adfa8470a14dd18506f65c7cf9dcb",
+ ".agents/skills/speckit-specify/SKILL.md": "6313b902b49bf2ae3582421be21c2453ff98a863974acca3e6a83cdad9d4b70b",
+ ".agents/skills/speckit-tasks/SKILL.md": "2dceed4345af65dbc6625157be8817dc35f2b0943b5896815ef1bc4d3421efba",
+ ".agents/skills/speckit-taskstoissues/SKILL.md": "2c00fdc774cb9b435ea8566c7efa8b208568d533d57529e61c35f18aa52d6c67"
+ }
+}
From 241d36c7da1afaa11fc5d72d5a7b6788592a75cf Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 16:05:44 +0300
Subject: [PATCH 08/14] Complete provider keys MVP validation
---
.../tests/integration/test_provider_keys.py | 86 ++++++++++++++++++-
specs/004-provider-keys/tasks.md | 2 +-
2 files changed, 86 insertions(+), 2 deletions(-)
diff --git a/backend/tests/integration/test_provider_keys.py b/backend/tests/integration/test_provider_keys.py
index 535f41f..281bc9b 100644
--- a/backend/tests/integration/test_provider_keys.py
+++ b/backend/tests/integration/test_provider_keys.py
@@ -7,7 +7,7 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
-from sqlalchemy.exc import DBAPIError
+from sqlalchemy.exc import DBAPIError, SQLAlchemyError
class SecretFixture(str):
@@ -247,6 +247,90 @@ def test_real_vault_add_list_activation_idempotency_and_retired_receipt(
assert retired.json()["error"]["code"] == "IDEMPOTENCY_KEY_RETIRED"
+class _CommitOutcomeUnknownContext:
+ def __init__(self, engine, state):
+ self._context = engine.begin()
+ self._state = state
+
+ def __enter__(self):
+ return self._context.__enter__()
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ result = self._context.__exit__(exc_type, exc_value, traceback)
+ if exc_type is None and not self._state["raised"]:
+ self._state["raised"] = True
+ raise SQLAlchemyError("commit result unavailable to client")
+ return result
+
+
+class _CommitOutcomeUnknownEngine:
+ def __init__(self, engine):
+ self._engine = engine
+ self._state = {"raised": False}
+
+ def begin(self):
+ return _CommitOutcomeUnknownContext(self._engine, self._state)
+
+
+def test_ambiguous_commit_retry_reconciles_one_vault_secret_and_row(provider_fixture):
+ from backend.app.main import app
+ from backend.app.routes.provider_keys import get_provider_key_store
+ from backend.app.services.provider_key_store import ProviderKeyStore
+
+ fixture = provider_fixture
+ brand_id = fixture["brand_id"]
+ request_id = uuid4()
+ raw_key = SecretFixture(f"ambiguous-commit-{uuid4().hex}-A1B2")
+ store = ProviderKeyStore(_CommitOutcomeUnknownEngine(fixture["engine"]))
+ app.dependency_overrides[get_provider_key_store] = lambda: store
+
+ try:
+ with TestClient(app) as client:
+ first = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={
+ **fixture["headers"],
+ "Idempotency-Key": str(request_id),
+ },
+ json={"provider": "openai", "key": str(raw_key)},
+ )
+ retry = client.post(
+ f"/api/v1/brands/{brand_id}/keys",
+ headers={
+ **fixture["headers"],
+ "Idempotency-Key": str(request_id),
+ },
+ json={"provider": "gemini", "key": "different-A1B2"},
+ )
+ finally:
+ app.dependency_overrides.pop(get_provider_key_store, None)
+
+ assert first.status_code == 502
+ assert first.json()["error"]["code"] == "VAULT_UNAVAILABLE"
+ assert retry.status_code == 201
+ assert retry.json()["key_hint"] == "***A1B2"
+
+ with fixture["engine"].connect() as connection:
+ row = connection.execute(
+ text(
+ "SELECT id, vault_secret_id FROM provider_keys "
+ "WHERE brand_id = :brand_id"
+ ),
+ {"brand_id": brand_id},
+ ).one()
+ assert connection.execute(
+ text(
+ "SELECT count(*) FROM provider_key_idempotency "
+ "WHERE brand_id = :brand_id AND request_id = :request_id"
+ ),
+ {"brand_id": brand_id, "request_id": request_id},
+ ).scalar_one() == 1
+ assert connection.execute(
+ text("SELECT count(*) FROM vault.secrets WHERE id = :id"),
+ {"id": row.vault_secret_id},
+ ).scalar_one() == 1
+
+
def test_real_add_validation_and_cleanup_fences_create_no_secret(provider_fixture):
from backend.app.main import app
diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md
index 79259f9..5277627 100644
--- a/specs/004-provider-keys/tasks.md
+++ b/specs/004-provider-keys/tasks.md
@@ -90,7 +90,7 @@ ordinary rows, browser state, and logs never contain a raw key.
- [X] T020 [US1] Create `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with authenticated defensive fetches, OpenAI/Gemini groups, safe key cards, add form, active/inactive/unvalidated/cleanup badges, a client UUID retained for retry of one ambiguous submission, raw-key clearing after success, and no raw key in URLs, errors, or retained visible state
- [X] T021 [P] [US1] Add provider-keys navigation and cleanup-state disabling to `frontend/app/(dashboard)/brands/[brandId]/page.tsx`, and update current-brand path recognition for nested `/brands/{brandId}/keys` routes in `frontend/app/(dashboard)/layout.tsx`
- [X] T022 [US1] Create `frontend/tests/e2e/provider-keys.spec.ts` with a focused add/list/reload flow using disposable test fixtures, assertions for provider grouping and masked hints, and browser request/response checks that the submitted fixture key never reappears
-- [ ] T023 [US1] Execute Quickstart Scenario 1 from `specs/004-provider-keys/quickstart.md` plus `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, `backend/tests/integration/test_provider_key_rls.py`, and `frontend/tests/e2e/provider-keys.spec.ts`; confirm exactly one Vault secret and key row per idempotency UUID and no skipped security integration tests
+- [X] T023 [US1] Execute Quickstart Scenario 1 from `specs/004-provider-keys/quickstart.md` plus `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, `backend/tests/integration/test_provider_key_rls.py`, and `frontend/tests/e2e/provider-keys.spec.ts`; confirm exactly one Vault secret and key row per idempotency UUID and no skipped security integration tests
**Checkpoint**: User Story 1 is a deployable BYOK setup MVP with no reveal path.
From 479a0e86e5258b22bb3527113aa57f2803ab095d Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 16:12:30 +0300
Subject: [PATCH 09/14] Fix combined pytest collection
---
backend/pyproject.toml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 19d7e8e..b7857b8 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -12,3 +12,6 @@ select = ["E", "F", "I", "B", "UP"]
[tool.ruff.format]
quote-style = "double"
+
+[tool.pytest.ini_options]
+addopts = "--import-mode=importlib"
From b29ab9990161f1b90daa67721cce8f918bace884 Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 16:33:49 +0300
Subject: [PATCH 10/14] Complete provider key validation phase
---
.prettierignore | 8 +
backend/app/models/provider_key.py | 26 +
backend/app/routes/provider_keys.py | 213 ++++++-
backend/app/services/provider_key_store.py | 238 +++++++-
backend/app/services/provider_validation.py | 228 ++++++++
backend/tests/contract/test_provider_keys.py | 542 ++++++++++++++++++
.../integration/test_provider_key_rls.py | 195 ++++++-
.../tests/integration/test_provider_keys.py | 497 +++++++++++++++-
.../tests/unit/test_provider_validation.py | 504 ++++++++++++++++
.../brands/[brandId]/keys/page.tsx | 219 ++++++-
frontend/tests/e2e/provider-keys.spec.ts | 162 +++++-
specs/004-provider-keys/tasks.md | 20 +-
12 files changed, 2805 insertions(+), 47 deletions(-)
create mode 100644 .prettierignore
create mode 100644 backend/app/services/provider_validation.py
create mode 100644 backend/tests/unit/test_provider_validation.py
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..ec45bdb
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+build/
+coverage/
+.next/
+package-lock.json
+yarn.lock
+pnpm-lock.yaml
diff --git a/backend/app/models/provider_key.py b/backend/app/models/provider_key.py
index 9e4be2f..f2e8758 100644
--- a/backend/app/models/provider_key.py
+++ b/backend/app/models/provider_key.py
@@ -22,6 +22,24 @@ class ProviderKeyLifecycle(str, Enum):
CLEANUP_REQUIRED = "cleanup_required"
+class ProviderValidationOutcome(str, Enum):
+ VALID = "valid"
+ INVALID = "invalid"
+ TEMPORARY = "temporary"
+
+
+class ProviderValidationCode(str, Enum):
+ VALID = "VALID"
+ INVALID_CREDENTIAL = "INVALID_CREDENTIAL"
+ PROVIDER_TIMEOUT = "PROVIDER_TIMEOUT"
+ PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE"
+ PROVIDER_RATE_LIMITED = "PROVIDER_RATE_LIMITED"
+ PROVIDER_PERMISSION = "PROVIDER_PERMISSION"
+ VALIDATION_UNDETERMINED = "VALIDATION_UNDETERMINED"
+ VALIDATION_IN_PROGRESS = "VALIDATION_IN_PROGRESS"
+ VALIDATION_SUPERSEDED = "VALIDATION_SUPERSEDED"
+
+
class ProviderKeyAdd(BaseModel):
provider: Provider
key: str
@@ -69,4 +87,12 @@ class ProviderKeyListResponse(BaseModel):
keys: list[ProviderKey]
+class ProviderKeyValidationResponse(BaseModel):
+ outcome: ProviderValidationOutcome
+ attempted_at: datetime
+ code: ProviderValidationCode
+ message: str
+ key: ProviderKey
+
+
IdempotencyKey = UUID
diff --git a/backend/app/routes/provider_keys.py b/backend/app/routes/provider_keys.py
index 64e5983..683df86 100644
--- a/backend/app/routes/provider_keys.py
+++ b/backend/app/routes/provider_keys.py
@@ -1,32 +1,44 @@
from __future__ import annotations
+import asyncio
import logging
-from typing import Annotated
+import time
+from collections.abc import Awaitable
+from typing import Annotated, TypeVar
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
+from fastapi.concurrency import run_in_threadpool
from ..auth import CurrentUserDep
from ..models.provider_key import (
IdempotencyKey,
+ Provider,
ProviderKey,
ProviderKeyAdd,
ProviderKeyListResponse,
+ ProviderKeyValidationResponse,
+ ProviderValidationCode,
+ ProviderValidationOutcome,
)
from ..services.brand_store import BrandCleanupRequiredError
from ..services.provider_key_store import (
IdempotencyKeyRetiredError,
+ KeyCleanupRequiredError,
ProviderKeyStore,
VaultUnavailableError,
get_provider_key_store,
)
+from ..services.provider_validation import ProviderValidator, get_provider_validator
router = APIRouter(prefix="/api/v1/brands", tags=["provider-keys"])
logger = logging.getLogger(__name__)
ProviderKeyStoreDep = Annotated[ProviderKeyStore, Depends(get_provider_key_store)]
+ProviderValidatorDep = Annotated[ProviderValidator, Depends(get_provider_validator)]
IdempotencyKeyHeader = Annotated[IdempotencyKey, Header(alias="Idempotency-Key")]
+_T = TypeVar("_T")
def _error(status_code: int, code: str, message: str) -> HTTPException:
@@ -40,6 +52,84 @@ def _not_found() -> HTTPException:
return _error(status.HTTP_404_NOT_FOUND, "BRAND_NOT_FOUND", "Brand not found.")
+def _validation_error(exc: Exception) -> HTTPException:
+ if isinstance(exc, LookupError):
+ return _error(
+ status.HTTP_404_NOT_FOUND,
+ "PROVIDER_KEY_NOT_FOUND",
+ "Provider key not found.",
+ )
+ if isinstance(exc, BrandCleanupRequiredError):
+ return _error(
+ status.HTTP_409_CONFLICT,
+ "BRAND_CLEANUP_REQUIRED",
+ "Brand cleanup is required. Retry deletion.",
+ )
+ if isinstance(exc, KeyCleanupRequiredError):
+ return _error(
+ status.HTTP_409_CONFLICT,
+ "KEY_CLEANUP_REQUIRED",
+ "Key cleanup is required. Retry deletion.",
+ )
+ return _error(
+ status.HTTP_502_BAD_GATEWAY,
+ "VAULT_UNAVAILABLE",
+ "Secure key storage is unavailable right now.",
+ )
+
+
+async def _within_deadline(awaitable: Awaitable[_T], deadline: float) -> _T:
+ remaining = max(0.0, deadline - time.monotonic())
+ return await asyncio.wait_for(awaitable, timeout=remaining)
+
+
+def _validation_message(provider: Provider | str, outcome: str) -> str:
+ provider_name = "OpenAI" if Provider(provider) is Provider.OPENAI else "Gemini"
+ if outcome == ProviderValidationOutcome.VALID.value:
+ return f"{provider_name} accepted this API key."
+ if outcome == ProviderValidationOutcome.INVALID.value:
+ return f"{provider_name} rejected this API key."
+ return f"{provider_name} could not validate the key right now."
+
+
+def _validation_response(
+ *,
+ claim,
+ outcome: ProviderValidationOutcome | str,
+ code: ProviderValidationCode | str,
+ key: ProviderKey,
+) -> ProviderKeyValidationResponse:
+ outcome_value = (
+ outcome.value if isinstance(outcome, ProviderValidationOutcome) else outcome
+ )
+ return ProviderKeyValidationResponse(
+ outcome=outcome,
+ attempted_at=claim.attempted_at,
+ code=code,
+ message=_validation_message(claim.provider, outcome_value),
+ key=key,
+ )
+
+
+def _log_validation(
+ request: Request,
+ provider: Provider | str,
+ code: ProviderValidationCode | str,
+ started: float,
+ provider_request_id: str | None = None,
+) -> None:
+ extra = {
+ "event": "provider_keys.validation_complete",
+ "request_id": getattr(request.state, "request_id", "unknown"),
+ "provider": provider.value if isinstance(provider, Provider) else provider,
+ "code": code.value if isinstance(code, ProviderValidationCode) else code,
+ "duration_ms": max(0, int((time.monotonic() - started) * 1000)),
+ }
+ if provider_request_id is not None:
+ extra["provider_request_id"] = provider_request_id
+ logger.info("provider_keys.validation_complete", extra=extra)
+
+
@router.get("/{brand_id}/keys", response_model=ProviderKeyListResponse)
def list_provider_keys(
request: Request,
@@ -108,3 +198,124 @@ def add_provider_key(
},
)
return key
+
+
+@router.post(
+ "/{brand_id}/keys/{key_id}/validate",
+ response_model=ProviderKeyValidationResponse,
+)
+async def validate_provider_key(
+ request: Request,
+ brand_id: UUID,
+ key_id: UUID,
+ current_user: CurrentUserDep,
+ provider_key_store: ProviderKeyStoreDep,
+ provider_validator: ProviderValidatorDep,
+) -> ProviderKeyValidationResponse:
+ started = time.monotonic()
+ deadline = getattr(request.state, "validation_deadline", started + 15.0)
+
+ try:
+ claim = await _within_deadline(
+ run_in_threadpool(
+ provider_key_store.claim_validation,
+ current_user.user_id,
+ brand_id,
+ key_id,
+ deadline,
+ ),
+ deadline,
+ )
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ KeyCleanupRequiredError,
+ VaultUnavailableError,
+ ) as exc:
+ raise _validation_error(exc) from exc
+ except TimeoutError as exc:
+ raise _validation_error(VaultUnavailableError()) from exc
+
+ if claim.in_progress:
+ code = ProviderValidationCode.VALIDATION_IN_PROGRESS
+ response = _validation_response(
+ claim=claim,
+ outcome=ProviderValidationOutcome.TEMPORARY,
+ code=code,
+ key=claim.key,
+ )
+ _log_validation(request, claim.provider, code, started)
+ return response
+
+ assert claim.secret is not None
+ assert claim.token is not None
+ try:
+ result = await _within_deadline(
+ provider_validator.validate(claim.provider, claim.secret, deadline),
+ deadline,
+ )
+ except TimeoutError:
+ code = ProviderValidationCode.PROVIDER_TIMEOUT
+ response = _validation_response(
+ claim=claim,
+ outcome=ProviderValidationOutcome.TEMPORARY,
+ code=code,
+ key=claim.key,
+ )
+ _log_validation(request, claim.provider, code, started)
+ return response
+
+ try:
+ completion = await _within_deadline(
+ run_in_threadpool(
+ provider_key_store.complete_validation,
+ current_user.user_id,
+ brand_id,
+ key_id,
+ claim.token,
+ result,
+ deadline,
+ ),
+ deadline,
+ )
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ KeyCleanupRequiredError,
+ VaultUnavailableError,
+ ) as exc:
+ raise _validation_error(exc) from exc
+ except TimeoutError:
+ code = ProviderValidationCode.PROVIDER_TIMEOUT
+ response = _validation_response(
+ claim=claim,
+ outcome=ProviderValidationOutcome.TEMPORARY,
+ code=code,
+ key=claim.key,
+ )
+ _log_validation(request, claim.provider, code, started)
+ return response
+
+ if completion.superseded:
+ outcome = ProviderValidationOutcome.TEMPORARY
+ code = ProviderValidationCode.VALIDATION_SUPERSEDED
+ provider_request_id = None
+ else:
+ outcome = result.outcome
+ code = result.code
+ provider_request_id = result.provider_request_id
+
+ response = _validation_response(
+ claim=claim,
+ outcome=outcome,
+ code=code,
+ key=completion.key,
+ )
+ _log_validation(
+ request,
+ claim.provider,
+ code,
+ started,
+ provider_request_id,
+ )
+ return response
diff --git a/backend/app/services/provider_key_store.py b/backend/app/services/provider_key_store.py
index c5fb5f9..a2e87d4 100644
--- a/backend/app/services/provider_key_store.py
+++ b/backend/app/services/provider_key_store.py
@@ -1,9 +1,11 @@
from __future__ import annotations
+import time
from collections.abc import Mapping
-from dataclasses import dataclass
+from dataclasses import dataclass, field
+from datetime import datetime
from functools import lru_cache
-from typing import Any
+from typing import Any, Protocol
from uuid import UUID, uuid4
from sqlalchemy import text
@@ -27,11 +29,43 @@ class VaultUnavailableError(ProviderKeyStoreError):
pass
+class KeyCleanupRequiredError(ProviderKeyStoreError):
+ pass
+
+
+class ValidationResult(Protocol):
+ outcome: str
+
+
+@dataclass(frozen=True, slots=True)
+class ValidationClaim:
+ attempted_at: datetime
+ key: ProviderKey = field(repr=False)
+ provider: str
+ secret: str | None = field(repr=False)
+ token: UUID | None = field(repr=False)
+ in_progress: bool
+
+
+@dataclass(frozen=True, slots=True)
+class ValidationCompletion:
+ key: ProviderKey = field(repr=False)
+ superseded: bool
+
+
_SAFE_COLUMNS = """
id, provider, label, key_hint, is_active, is_valid,
last_validated_at, last_validation_error, lifecycle, created_at
"""
+_SAFE_PK_COLUMNS = """
+ pk.id AS id, pk.provider AS provider, pk.label AS label,
+ pk.key_hint AS key_hint, pk.is_active AS is_active,
+ pk.is_valid AS is_valid, pk.last_validated_at AS last_validated_at,
+ pk.last_validation_error AS last_validation_error,
+ pk.lifecycle AS lifecycle, pk.created_at AS created_at
+"""
+
@dataclass(frozen=True, slots=True)
class ProviderKeyStore:
@@ -164,6 +198,206 @@ def add_key(
except SQLAlchemyError as exc:
raise VaultUnavailableError from exc
+ def claim_validation(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ key_id: UUID,
+ deadline: float,
+ ) -> ValidationClaim:
+ cleanup_required = False
+ claim: ValidationClaim | None = None
+ try:
+ with self.engine.begin() as connection:
+ brand = BrandStore.lock_owned_brand(connection, user_id, brand_id)
+ row = connection.execute(
+ text(
+ f"""
+ SELECT {_SAFE_PK_COLUMNS}, pk.validation_token,
+ pk.validation_lease_expires_at,
+ ds.decrypted_secret,
+ clock_timestamp() AS attempted_at
+ FROM provider_keys pk
+ LEFT JOIN vault.decrypted_secrets ds
+ ON ds.id = pk.vault_secret_id
+ WHERE pk.brand_id = :brand_id AND pk.id = :key_id
+ FOR UPDATE OF pk
+ """
+ ),
+ {"brand_id": brand_id, "key_id": key_id},
+ ).mappings().one_or_none()
+ if row is None:
+ raise LookupError("Provider key not found.")
+
+ # Resolve ownership and path membership before exposing either fence.
+ if brand["deletion_state"] != "active":
+ raise BrandCleanupRequiredError
+ if row["lifecycle"] != "normal":
+ raise KeyCleanupRequiredError
+
+ if row["decrypted_secret"] is None:
+ connection.execute(
+ text(
+ """
+ UPDATE provider_keys
+ SET lifecycle = 'cleanup_required', is_active = false,
+ is_valid = NULL, last_validated_at = NULL,
+ last_validation_error = NULL,
+ validation_token = NULL,
+ validation_lease_expires_at = NULL
+ WHERE id = :key_id
+ """
+ ),
+ {"key_id": key_id},
+ )
+ cleanup_required = True
+ elif (
+ row["validation_token"] is not None
+ and row["validation_lease_expires_at"] > row["attempted_at"]
+ ):
+ claim = ValidationClaim(
+ attempted_at=row["attempted_at"],
+ key=self._to_provider_key(row),
+ provider=row["provider"],
+ secret=None,
+ token=None,
+ in_progress=True,
+ )
+ else:
+ lease_seconds = deadline - time.monotonic()
+ if lease_seconds <= 0:
+ raise VaultUnavailableError
+ token = uuid4()
+ connection.execute(
+ text(
+ """
+ UPDATE provider_keys
+ SET validation_token = :token,
+ validation_lease_expires_at =
+ clock_timestamp()
+ + make_interval(secs => :lease_seconds)
+ WHERE id = :key_id
+ """
+ ),
+ {
+ "key_id": key_id,
+ "token": token,
+ "lease_seconds": lease_seconds,
+ },
+ )
+ claim = ValidationClaim(
+ attempted_at=row["attempted_at"],
+ key=self._to_provider_key(row),
+ provider=row["provider"],
+ secret=row["decrypted_secret"],
+ token=token,
+ in_progress=False,
+ )
+ except (LookupError, BrandCleanupRequiredError, KeyCleanupRequiredError):
+ raise
+ except SQLAlchemyError as exc:
+ raise VaultUnavailableError from exc
+
+ if cleanup_required:
+ # Raising outside the transaction preserves the irreversible cleanup fence.
+ raise KeyCleanupRequiredError
+ assert claim is not None
+ return claim
+
+ def complete_validation(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ key_id: UUID,
+ token: UUID,
+ result: ValidationResult,
+ deadline: float,
+ ) -> ValidationCompletion:
+ del deadline # Database pool, lock, and statement waits are bounded by the engine.
+ try:
+ with self.engine.begin() as connection:
+ brand = BrandStore.lock_owned_brand(connection, user_id, brand_id)
+ row = connection.execute(
+ text(
+ f"""
+ SELECT {_SAFE_COLUMNS}, validation_token,
+ validation_lease_expires_at,
+ clock_timestamp() AS database_now
+ FROM provider_keys
+ WHERE brand_id = :brand_id AND id = :key_id
+ FOR UPDATE
+ """
+ ),
+ {"brand_id": brand_id, "key_id": key_id},
+ ).mappings().one_or_none()
+ if row is None:
+ raise LookupError("Provider key not found.")
+
+ token_matches = row["validation_token"] == token
+ lease_is_live = (
+ row["validation_lease_expires_at"] is not None
+ and row["validation_lease_expires_at"] > row["database_now"]
+ )
+ if not token_matches or not lease_is_live:
+ if token_matches:
+ connection.execute(
+ text(
+ "UPDATE provider_keys "
+ "SET validation_token = NULL, "
+ "validation_lease_expires_at = NULL "
+ "WHERE id = :key_id AND validation_token = :token"
+ ),
+ {"key_id": key_id, "token": token},
+ )
+ return ValidationCompletion(
+ key=self._to_provider_key(row), superseded=True
+ )
+
+ BrandStore.require_normal_brand(brand)
+ if row["lifecycle"] != "normal":
+ raise KeyCleanupRequiredError
+
+ if result.outcome == "valid":
+ assignments = """
+ is_valid = true,
+ last_validated_at = clock_timestamp(),
+ last_validation_error = NULL,
+ """
+ elif result.outcome == "invalid":
+ assignments = """
+ is_active = false,
+ is_valid = false,
+ last_validated_at = clock_timestamp(),
+ last_validation_error = 'INVALID_CREDENTIAL',
+ """
+ else:
+ assignments = ""
+
+ completed = connection.execute(
+ text(
+ f"""
+ UPDATE provider_keys
+ SET {assignments}
+ validation_token = NULL,
+ validation_lease_expires_at = NULL
+ WHERE id = :key_id AND validation_token = :token
+ RETURNING {_SAFE_COLUMNS}
+ """
+ ),
+ {"key_id": key_id, "token": token},
+ ).mappings().one()
+ return ValidationCompletion(
+ key=self._to_provider_key(completed), superseded=False
+ )
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ KeyCleanupRequiredError,
+ ):
+ raise
+ except SQLAlchemyError as exc:
+ raise VaultUnavailableError from exc
+
@lru_cache(maxsize=1)
def get_provider_key_store() -> ProviderKeyStore:
diff --git a/backend/app/services/provider_validation.py b/backend/app/services/provider_validation.py
new file mode 100644
index 0000000..fc99737
--- /dev/null
+++ b/backend/app/services/provider_validation.py
@@ -0,0 +1,228 @@
+from __future__ import annotations
+
+import re
+import time
+from dataclasses import dataclass
+from functools import lru_cache
+
+import httpx
+
+from ..models.provider_key import (
+ Provider,
+ ProviderValidationCode,
+ ProviderValidationOutcome,
+)
+
+
+_OPENAI_URL = "https://api.openai.com/v1/models"
+_GEMINI_URL = (
+ "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1"
+)
+_ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo"
+_GEMINI_INVALID_REASONS = frozenset(
+ {
+ "API_KEY_INVALID",
+ "API_KEY_EXPIRED",
+ "API_KEY_NOT_FOUND",
+ "INVALID_CREDENTIAL",
+ }
+)
+_SAFE_REQUEST_ID = re.compile(r"[A-Za-z0-9._:/-]{1,200}\Z")
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class ProviderValidationResult:
+ outcome: ProviderValidationOutcome
+ code: ProviderValidationCode
+ message: str
+ provider_request_id: str | None = None
+
+ def __repr__(self) -> str:
+ return (
+ "ProviderValidationResult("
+ f"outcome={self.outcome.value!r}, code={self.code.value!r}, "
+ f"message={self.message!r}, "
+ f"provider_request_id={self.provider_request_id!r})"
+ )
+
+
+def _provider_details(provider: Provider) -> tuple[str, str, dict[str, str], str]:
+ if provider is Provider.OPENAI:
+ return "OpenAI", _OPENAI_URL, {}, "data"
+ return "Gemini", _GEMINI_URL, {}, "models"
+
+
+def _result(
+ provider_name: str,
+ outcome: ProviderValidationOutcome,
+ code: ProviderValidationCode,
+ provider_request_id: str | None = None,
+) -> ProviderValidationResult:
+ if outcome is ProviderValidationOutcome.VALID:
+ message = f"{provider_name} accepted this API key."
+ elif outcome is ProviderValidationOutcome.INVALID:
+ message = f"{provider_name} rejected this API key."
+ else:
+ message = f"{provider_name} could not validate the key right now."
+ return ProviderValidationResult(outcome, code, message, provider_request_id)
+
+
+def _request_id(response: httpx.Response, raw_key: str) -> str | None:
+ value = response.headers.get("x-request-id")
+ if value is None or raw_key in value or _SAFE_REQUEST_ID.fullmatch(value) is None:
+ return None
+ return value
+
+
+def _is_openai_invalid(body: object) -> bool:
+ if not isinstance(body, dict):
+ return False
+ error = body.get("error")
+ return isinstance(error, dict) and error.get("code") == "invalid_api_key"
+
+
+def _is_gemini_invalid(body: object) -> bool:
+ if not isinstance(body, dict):
+ return False
+ error = body.get("error")
+ if not isinstance(error, dict):
+ return False
+ details = error.get("details")
+ if not isinstance(details, list):
+ return False
+ return any(
+ isinstance(detail, dict)
+ and detail.get("@type") == _ERROR_INFO_TYPE
+ and detail.get("reason") in _GEMINI_INVALID_REASONS
+ for detail in details
+ )
+
+
+async def validate_provider_key(
+ provider: Provider | str,
+ raw_key: str,
+ transport: httpx.AsyncBaseTransport | None = None,
+ timeout: float = 10.0,
+) -> ProviderValidationResult:
+ selected_provider = Provider(provider)
+ provider_name, url, headers, list_field = _provider_details(selected_provider)
+ if selected_provider is Provider.OPENAI:
+ headers["Authorization"] = f"Bearer {raw_key}"
+ else:
+ headers["x-goog-api-key"] = raw_key
+
+ request_timeout = min(10.0, timeout)
+ if request_timeout <= 0:
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.TEMPORARY,
+ ProviderValidationCode.PROVIDER_TIMEOUT,
+ )
+
+ try:
+ async with httpx.AsyncClient(
+ transport=transport,
+ follow_redirects=False,
+ trust_env=False,
+ timeout=request_timeout,
+ ) as client:
+ response = await client.get(url, headers=headers)
+ except httpx.TimeoutException:
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.TEMPORARY,
+ ProviderValidationCode.PROVIDER_TIMEOUT,
+ )
+ except httpx.RequestError:
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.TEMPORARY,
+ ProviderValidationCode.PROVIDER_UNAVAILABLE,
+ )
+
+ provider_request_id = _request_id(response, raw_key)
+ try:
+ body = response.json()
+ except ValueError:
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.TEMPORARY,
+ ProviderValidationCode.VALIDATION_UNDETERMINED,
+ provider_request_id,
+ )
+
+ if 200 <= response.status_code < 300:
+ if isinstance(body, dict) and isinstance(body.get(list_field), list):
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.VALID,
+ ProviderValidationCode.VALID,
+ provider_request_id,
+ )
+ elif selected_provider is Provider.OPENAI and _is_openai_invalid(body):
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.INVALID,
+ ProviderValidationCode.INVALID_CREDENTIAL,
+ provider_request_id,
+ )
+ elif selected_provider is Provider.GEMINI and _is_gemini_invalid(body):
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.INVALID,
+ ProviderValidationCode.INVALID_CREDENTIAL,
+ provider_request_id,
+ )
+ elif response.status_code in {403, 412}:
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.TEMPORARY,
+ ProviderValidationCode.PROVIDER_PERMISSION,
+ provider_request_id,
+ )
+ elif response.status_code == 429:
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.TEMPORARY,
+ ProviderValidationCode.PROVIDER_RATE_LIMITED,
+ provider_request_id,
+ )
+ elif response.status_code in {500, 502, 503, 504}:
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.TEMPORARY,
+ ProviderValidationCode.PROVIDER_UNAVAILABLE,
+ provider_request_id,
+ )
+
+ return _result(
+ provider_name,
+ ProviderValidationOutcome.TEMPORARY,
+ ProviderValidationCode.VALIDATION_UNDETERMINED,
+ provider_request_id,
+ )
+
+
+@dataclass(frozen=True, slots=True, repr=False)
+class ProviderValidator:
+ transport: httpx.AsyncBaseTransport | None = None
+
+ def __repr__(self) -> str:
+ return ""
+
+ async def validate(
+ self, provider: Provider | str, secret: str, deadline: float
+ ) -> ProviderValidationResult:
+ remaining = deadline - time.monotonic()
+ return await validate_provider_key(
+ provider,
+ secret,
+ transport=self.transport,
+ # Leave time for the fenced database completion and serialization.
+ timeout=min(10.0, remaining - 2.25),
+ )
+
+
+@lru_cache(maxsize=1)
+def get_provider_validator() -> ProviderValidator:
+ return ProviderValidator()
diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py
index 228370a..40e06c9 100644
--- a/backend/tests/contract/test_provider_keys.py
+++ b/backend/tests/contract/test_provider_keys.py
@@ -1,12 +1,14 @@
from __future__ import annotations
import asyncio
+import io
import json
import logging
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from types import SimpleNamespace
+from typing import Any
from unittest.mock import MagicMock, Mock
from uuid import UUID
@@ -18,12 +20,14 @@
from backend.app import auth, config, main
from backend.app.auth import CurrentUser, get_current_user
from backend.app.models.provider_key import ProviderKey, ProviderKeyAdd
+from backend.app.routes import provider_keys as provider_key_routes
from backend.app.routes.provider_keys import get_provider_key_store
from backend.app.services.brand_store import BrandCleanupRequiredError
from backend.app.services.provider_key_store import (
IdempotencyKeyRetiredError,
VaultUnavailableError,
)
+from backend.app.services import provider_key_store as provider_key_store_module
def _request(path: str = "/api/v1/brands/brand-id/keys/key-id/validate") -> Request:
@@ -435,11 +439,118 @@ def _safe_key(**updates) -> ProviderKey:
return ProviderKey.model_validate(values)
+@dataclass(frozen=True)
+class FakeValidationClaim:
+ attempted_at: datetime
+ key: ProviderKey
+ provider: str
+ secret: str
+ token: UUID
+ in_progress: bool = False
+
+
+@dataclass(frozen=True)
+class FakeProviderResult:
+ outcome: str
+ code: str
+ provider_request_id: str | None = None
+
+
+@dataclass(frozen=True)
+class FakeValidationCompletion:
+ key: ProviderKey
+ superseded: bool
+
+
+@dataclass
+class FakeClock:
+ current: float = 10_000.0
+
+ def monotonic(self) -> float:
+ return self.current
+
+ def advance(self, seconds: float) -> None:
+ self.current += seconds
+
+
+@dataclass
+class FakeValidationScenario:
+ before: ProviderKey = field(default_factory=_safe_key)
+ outcome: str = "valid"
+ code: str = "VALID"
+ attempted_at: datetime = datetime(2026, 7, 26, 12, tzinfo=UTC)
+ raw_key: str = RAW_KEY
+ token: UUID = UUID("66666666-6666-6666-6666-666666666666")
+ provider_request_id: str | None = "provider-request-id"
+ in_progress: bool = False
+ superseded: bool = False
+ claim_error: Exception | None = None
+ clock: FakeClock | None = None
+ pool_delay: float = 0
+ lock_delay: float = 0
+ vault_delay: float = 0
+ provider_delay: float = 0
+ completion_delay: float = 0
+ consumed_stages: list[tuple[str, float]] = field(default_factory=list)
+
+ def consume(self, stage: str, delay: float, deadline: float) -> None:
+ self.consumed_stages.append((stage, deadline))
+ if self.clock is not None:
+ self.clock.advance(delay)
+
+ def consume_claim_budget(self, deadline: float) -> None:
+ self.consume("pool", self.pool_delay, deadline)
+ self.consume("lock", self.lock_delay, deadline)
+ self.consume("vault", self.vault_delay, deadline)
+
+ def completed_key(self, result: FakeProviderResult) -> ProviderKey:
+ if result.outcome == "valid":
+ return self.before.model_copy(
+ update={
+ "is_valid": True,
+ "last_validated_at": self.attempted_at,
+ "last_validation_error": None,
+ }
+ )
+ if result.outcome == "invalid":
+ return self.before.model_copy(
+ update={
+ "is_active": False,
+ "is_valid": False,
+ "last_validated_at": self.attempted_at,
+ "last_validation_error": "INVALID_CREDENTIAL",
+ }
+ )
+ return self.before
+
+
+@dataclass
+class FakeProviderValidator:
+ scenario: FakeValidationScenario
+ calls: list[tuple[str, str, float]] = field(default_factory=list)
+
+ async def validate(
+ self, provider: str, secret: str, deadline: float
+ ) -> FakeProviderResult:
+ self.calls.append((provider, secret, deadline))
+ self.scenario.consume("provider", self.scenario.provider_delay, deadline)
+ return FakeProviderResult(
+ outcome=self.scenario.outcome,
+ code=self.scenario.code,
+ provider_request_id=self.scenario.provider_request_id,
+ )
+
+
@dataclass
class FakeProviderKeyStore:
keys: list[ProviderKey] = field(default_factory=list)
error: Exception | None = None
add_calls: list[tuple[str, UUID, ProviderKeyAdd, UUID]] = field(default_factory=list)
+ validation: FakeValidationScenario | None = None
+ claim_calls: list[tuple[str, UUID, UUID, float]] = field(default_factory=list)
+ complete_calls: list[tuple[str, UUID, UUID, UUID, Any, float]] = field(
+ default_factory=list
+ )
def list_keys(self, user_id: str, brand_id: UUID) -> list[ProviderKey]:
if self.error:
@@ -458,16 +569,64 @@ def add_key(
raise self.error
return self.keys[0] if self.keys else _safe_key(is_active=payload.make_active)
+ def claim_validation(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ key_id: UUID,
+ deadline: float,
+ ) -> FakeValidationClaim:
+ self.claim_calls.append((user_id, brand_id, key_id, deadline))
+ assert self.validation is not None
+ self.validation.consume_claim_budget(deadline)
+ if self.validation.claim_error:
+ raise self.validation.claim_error
+ return FakeValidationClaim(
+ attempted_at=self.validation.attempted_at,
+ key=self.validation.before,
+ provider=self.validation.before.provider.value,
+ secret=self.validation.raw_key,
+ token=self.validation.token,
+ in_progress=self.validation.in_progress,
+ )
+
+ def complete_validation(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ key_id: UUID,
+ token: UUID,
+ result: FakeProviderResult,
+ deadline: float,
+ ) -> FakeValidationCompletion:
+ self.complete_calls.append(
+ (user_id, brand_id, key_id, token, result, deadline)
+ )
+ assert self.validation is not None
+ self.validation.consume("completion", self.validation.completion_delay, deadline)
+ if self.validation.superseded:
+ return FakeValidationCompletion(self.validation.before, superseded=True)
+ return FakeValidationCompletion(self.validation.completed_key(result), superseded=False)
+
@pytest.fixture
def provider_key_client():
store = FakeProviderKeyStore()
+ scenario = FakeValidationScenario()
+ validator = FakeProviderValidator(scenario)
+ store.validation = scenario
+ store.validator = validator
main.app.dependency_overrides[get_current_user] = lambda: CurrentUser(
user_id="11111111-1111-1111-1111-111111111111",
email="owner@example.com",
access_token="redacted",
)
main.app.dependency_overrides[get_provider_key_store] = lambda: store
+ provider_dependency = getattr(
+ provider_key_routes, "get_provider_validator", None
+ )
+ if provider_dependency is not None:
+ main.app.dependency_overrides[provider_dependency] = lambda: store.validator
try:
with TestClient(main.app) as client:
yield client, store
@@ -653,3 +812,386 @@ def test_add_uses_no_provider_client_dependency(provider_key_client):
json={"provider": "openai", "key": RAW_KEY},
)
assert response.status_code == 201
+
+
+def _safe_key_json(key: ProviderKey) -> dict[str, Any]:
+ return {
+ "id": str(key.id),
+ "provider": key.provider.value,
+ "label": key.label,
+ "key_hint": key.key_hint,
+ "is_active": key.is_active,
+ "is_valid": key.is_valid,
+ "last_validated_at": (
+ key.last_validated_at.isoformat().replace("+00:00", "Z")
+ if key.last_validated_at
+ else None
+ ),
+ "last_validation_error": key.last_validation_error,
+ "cleanup_state": key.cleanup_state.value,
+ "created_at": key.created_at.isoformat().replace("+00:00", "Z"),
+ }
+
+
+VALIDATION_MATRIX = [
+ ("valid", "VALID"),
+ ("invalid", "INVALID_CREDENTIAL"),
+ ("temporary", "PROVIDER_TIMEOUT"),
+ ("temporary", "PROVIDER_UNAVAILABLE"),
+ ("temporary", "PROVIDER_RATE_LIMITED"),
+ ("temporary", "PROVIDER_PERMISSION"),
+ ("temporary", "VALIDATION_UNDETERMINED"),
+]
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+@pytest.mark.parametrize(("outcome", "code"), VALIDATION_MATRIX)
+def test_validate_returns_exact_outcome_matrix_and_complete_snapshot(
+ provider_key_client,
+ provider: str,
+ outcome: str,
+ code: str,
+):
+ client, store = provider_key_client
+ display_name = "OpenAI" if provider == "openai" else "Gemini"
+ before = _safe_key(
+ provider=provider,
+ is_active=True,
+ is_valid=False,
+ last_validated_at=datetime(2026, 7, 25, 9, tzinfo=UTC),
+ last_validation_error="INVALID_CREDENTIAL",
+ )
+ store.validation = FakeValidationScenario(
+ before=before,
+ outcome=outcome,
+ code=code,
+ )
+ store.validator.scenario = store.validation
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate"
+ )
+
+ expected_message = {
+ "valid": f"{display_name} accepted this API key.",
+ "invalid": f"{display_name} rejected this API key.",
+ "temporary": f"{display_name} could not validate the key right now.",
+ }[outcome]
+ result = FakeProviderResult(outcome, code)
+ expected_key = store.validation.completed_key(result)
+ assert response.status_code == 200
+ assert response.json() == {
+ "outcome": outcome,
+ "attempted_at": "2026-07-26T12:00:00Z",
+ "code": code,
+ "message": expected_message,
+ "key": _safe_key_json(expected_key),
+ }
+ assert store.claim_calls == [
+ (
+ "11111111-1111-1111-1111-111111111111",
+ BRAND_ID,
+ KEY_ID,
+ store.claim_calls[0][3],
+ )
+ ]
+ assert store.validator.calls[0][:2] == (provider, RAW_KEY)
+ assert len(store.complete_calls) == 1
+ assert RAW_KEY not in response.text
+
+
+def test_invalid_validation_deactivates_without_replacement(provider_key_client):
+ client, store = provider_key_client
+ store.validation = FakeValidationScenario(
+ before=_safe_key(is_active=True),
+ outcome="invalid",
+ code="INVALID_CREDENTIAL",
+ )
+ store.validator.scenario = store.validation
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate"
+ )
+
+ assert response.status_code == 200
+ assert response.json()["key"] == _safe_key_json(
+ store.validation.completed_key(
+ FakeProviderResult("invalid", "INVALID_CREDENTIAL")
+ )
+ )
+ assert response.json()["key"]["is_active"] is False
+
+
+@pytest.mark.parametrize(
+ "code",
+ [
+ "PROVIDER_TIMEOUT",
+ "PROVIDER_UNAVAILABLE",
+ "PROVIDER_RATE_LIMITED",
+ "PROVIDER_PERMISSION",
+ "VALIDATION_UNDETERMINED",
+ ],
+)
+def test_temporary_validation_preserves_complete_snapshot_byte_for_byte(
+ provider_key_client, code: str
+):
+ client, store = provider_key_client
+ before = _safe_key(
+ is_active=True,
+ is_valid=True,
+ last_validated_at=datetime(2026, 7, 20, 8, 30, tzinfo=UTC),
+ last_validation_error=None,
+ )
+ store.validation = FakeValidationScenario(
+ before=before, outcome="temporary", code=code
+ )
+ store.validator.scenario = store.validation
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate"
+ )
+
+ assert response.status_code == 200
+ assert response.json()["key"] == _safe_key_json(before)
+ assert store.complete_calls[0][4].code == code
+
+
+@pytest.mark.parametrize("hidden_case", ["missing", "wrong_brand", "not_owned"])
+def test_validate_path_membership_is_opaque(provider_key_client, hidden_case: str):
+ client, store = provider_key_client
+ store.validation.claim_error = LookupError(hidden_case)
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate"
+ )
+
+ assert response.status_code == 404
+ assert response.json()["error"]["code"] == "PROVIDER_KEY_NOT_FOUND"
+ assert response.json()["error"]["message"] == "Provider key not found."
+ assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
+ assert store.validator.calls == []
+ assert store.complete_calls == []
+
+
+@pytest.mark.parametrize(
+ ("error_name", "fallback", "status_code", "code", "message"),
+ [
+ (
+ "BrandCleanupRequiredError",
+ BrandCleanupRequiredError,
+ 409,
+ "BRAND_CLEANUP_REQUIRED",
+ "Brand cleanup is required. Retry deletion.",
+ ),
+ (
+ "KeyCleanupRequiredError",
+ RuntimeError,
+ 409,
+ "KEY_CLEANUP_REQUIRED",
+ "Key cleanup is required. Retry deletion.",
+ ),
+ (
+ "VaultUnavailableError",
+ VaultUnavailableError,
+ 502,
+ "VAULT_UNAVAILABLE",
+ "Secure key storage is unavailable right now.",
+ ),
+ ],
+)
+def test_validate_prelease_failures_use_fixed_safe_envelopes(
+ provider_key_client,
+ error_name: str,
+ fallback: type[Exception],
+ status_code: int,
+ code: str,
+ message: str,
+):
+ client, store = provider_key_client
+ error_type = getattr(provider_key_store_module, error_name, fallback)
+ store.validation.claim_error = error_type()
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate"
+ )
+
+ assert response.status_code == status_code
+ assert response.json()["error"]["code"] == code
+ assert response.json()["error"]["message"] == message
+ assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
+ assert store.validator.calls == []
+ assert store.complete_calls == []
+
+
+def test_overlapping_validation_returns_in_progress_without_provider_call(
+ provider_key_client,
+):
+ client, store = provider_key_client
+ before = _safe_key(
+ is_valid=True,
+ last_validated_at=datetime(2026, 7, 24, 10, tzinfo=UTC),
+ )
+ store.validation = FakeValidationScenario(before=before, in_progress=True)
+ store.validator.scenario = store.validation
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate"
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "outcome": "temporary",
+ "attempted_at": "2026-07-26T12:00:00Z",
+ "code": "VALIDATION_IN_PROGRESS",
+ "message": "OpenAI could not validate the key right now.",
+ "key": _safe_key_json(before),
+ }
+ assert store.validator.calls == []
+ assert store.complete_calls == []
+
+
+def test_stale_completion_returns_superseded_latest_snapshot(provider_key_client):
+ client, store = provider_key_client
+ latest = _safe_key(
+ is_active=False,
+ is_valid=False,
+ last_validated_at=datetime(2026, 7, 26, 11, 59, tzinfo=UTC),
+ last_validation_error="INVALID_CREDENTIAL",
+ )
+ store.validation = FakeValidationScenario(
+ before=latest,
+ outcome="valid",
+ code="VALID",
+ superseded=True,
+ )
+ store.validator.scenario = store.validation
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate"
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "outcome": "temporary",
+ "attempted_at": "2026-07-26T12:00:00Z",
+ "code": "VALIDATION_SUPERSEDED",
+ "message": "OpenAI could not validate the key right now.",
+ "key": _safe_key_json(latest),
+ }
+ assert len(store.validator.calls) == 1
+ assert len(store.complete_calls) == 1
+
+
+def test_validation_consumes_one_absolute_budget_across_all_route_stages(
+ provider_key_client, monkeypatch: pytest.MonkeyPatch
+):
+ client, store = provider_key_client
+ clock = FakeClock()
+ scenario = FakeValidationScenario(
+ clock=clock,
+ pool_delay=0.7,
+ lock_delay=0.8,
+ vault_delay=0.9,
+ provider_delay=8.5,
+ completion_delay=0.8,
+ )
+ store.validation = scenario
+ store.validator.scenario = scenario
+ monkeypatch.setattr(main.time, "monotonic", clock.monotonic)
+
+ async def consume_auth_budget(request: Request) -> CurrentUser:
+ clock.advance(0.6)
+ return CurrentUser(
+ user_id="11111111-1111-1111-1111-111111111111",
+ email="owner@example.com",
+ access_token="redacted",
+ )
+
+ main.app.dependency_overrides[get_current_user] = consume_auth_budget
+ started = clock.monotonic()
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate"
+ )
+
+ assert response.status_code == 200
+ assert clock.monotonic() - started < 15
+ deadlines = [deadline for _, deadline in scenario.consumed_stages]
+ assert deadlines == [started + 15] * 5
+ assert store.claim_calls[0][3] == started + 15
+ assert store.validator.calls[0][2] == started + 15
+ assert store.complete_calls[0][5] == started + 15
+
+
+def test_validation_response_and_logs_exclude_every_sensitive_stage_value(
+ provider_key_client, caplog: pytest.LogCaptureFixture
+):
+ client, store = provider_key_client
+ sensitive = {
+ RAW_KEY,
+ "Production Key",
+ "***A1B2",
+ "77777777-7777-7777-7777-777777777777",
+ "secret SQL bind",
+ "provider body secret",
+ "Bearer secret-token",
+ "secret exception text",
+ "66666666-6666-6666-6666-666666666666",
+ "11111111-1111-1111-1111-111111111111",
+ "owner@example.com",
+ }
+ store.validation.claim_error = VaultUnavailableError(
+ "secret exception text; secret SQL bind; provider body secret"
+ )
+ caplog.set_level(logging.INFO)
+
+ response = client.post(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/validate",
+ headers={"Authorization": "Bearer secret-token"},
+ )
+
+ assert response.status_code == 502
+ rendered_logs = "\n".join(
+ main._JsonLogFormatter().format(record) for record in caplog.records
+ )
+ observable = response.text + rendered_logs + caplog.text
+ for value in sensitive:
+ assert value not in observable
+
+
+def test_safe_validation_log_contains_only_fixed_allowlisted_metadata():
+ stream = io.StringIO()
+ handler = logging.StreamHandler(stream)
+ handler.setFormatter(main._JsonLogFormatter())
+ logger = logging.getLogger("provider-validation-contract")
+ logger.handlers = [handler]
+ logger.propagate = False
+ logger.info(
+ "unsafe %s",
+ RAW_KEY,
+ extra={
+ "event": "provider_keys.validation_complete",
+ "request_id": "request-id",
+ "provider": "gemini",
+ "code": "PROVIDER_TIMEOUT",
+ "duration_ms": 14900,
+ "provider_request_id": "safe-provider-request-id",
+ "label": "Production Key",
+ "key_hint": "***A1B2",
+ "vault_secret_id": "77777777-7777-7777-7777-777777777777",
+ "authorization": "Bearer secret-token",
+ "exception": "secret exception text",
+ "user_id": "11111111-1111-1111-1111-111111111111",
+ "email": "owner@example.com",
+ },
+ )
+
+ assert json.loads(stream.getvalue()) == {
+ "level": "INFO",
+ "logger": "provider-validation-contract",
+ "event": "provider_keys.validation_complete",
+ "request_id": "request-id",
+ "provider": "gemini",
+ "code": "PROVIDER_TIMEOUT",
+ "duration_ms": 14900,
+ "provider_request_id": "safe-provider-request-id",
+ }
diff --git a/backend/tests/integration/test_provider_key_rls.py b/backend/tests/integration/test_provider_key_rls.py
index 36189ed..0b6820b 100644
--- a/backend/tests/integration/test_provider_key_rls.py
+++ b/backend/tests/integration/test_provider_key_rls.py
@@ -2,6 +2,7 @@
import json
import os
+from types import SimpleNamespace
from uuid import uuid4
import httpx
@@ -24,6 +25,46 @@ def __repr__(self) -> str:
return ""
+class SecretFixture(str):
+ def __repr__(self) -> str:
+ return ""
+
+
+class MockProviderValidator:
+ def __init__(self, expected_secret: SecretFixture) -> None:
+ self.expected_secret = expected_secret
+ self.calls = 0
+
+ def __repr__(self) -> str:
+ return ""
+
+ async def _validate(self, *args, **kwargs):
+ assert any(
+ value == self.expected_secret for value in [*args, *kwargs.values()]
+ )
+ self.calls += 1
+ return SimpleNamespace(
+ outcome="valid", code="VALID", provider_request_id="mock-request"
+ )
+
+ async def validate(self, *args, **kwargs):
+ return await self._validate(*args, **kwargs)
+
+ async def validate_key(self, *args, **kwargs):
+ return await self._validate(*args, **kwargs)
+
+ async def __call__(self, *args, **kwargs):
+ return await self._validate(*args, **kwargs)
+
+
+def _provider_validator_dependency():
+ from backend.app.routes import provider_keys as provider_key_routes
+
+ dependency = getattr(provider_key_routes, "get_provider_validator", None)
+ assert dependency is not None, "validation provider dependency is not implemented"
+ return dependency
+
+
def _required_env(name: str) -> str:
value = os.getenv(name)
if not value:
@@ -105,6 +146,8 @@ def security_fixture():
user_ids.extend((user_a, user_b))
brand_a, brand_b = str(uuid4()), str(uuid4())
key_a, key_b = str(uuid4()), str(uuid4())
+ secret_a = SecretFixture(f"rls-validation-{uuid4().hex}-A1B2")
+ secret_b = SecretFixture(f"rls-validation-{uuid4().hex}-C3_D")
brand_ids.extend((brand_a, brand_b))
with engine.begin() as connection:
connection.execute(
@@ -120,6 +163,14 @@ def security_fixture():
"user_b": user_b,
},
)
+ vault_a = connection.execute(
+ text("SELECT vault.create_secret(:secret, NULL, '', NULL)"),
+ {"secret": str(secret_a)},
+ ).scalar_one()
+ vault_b = connection.execute(
+ text("SELECT vault.create_secret(:secret, NULL, '', NULL)"),
+ {"secret": str(secret_b)},
+ ).scalar_one()
connection.execute(
text(
"INSERT INTO provider_keys "
@@ -130,10 +181,10 @@ def security_fixture():
{
"key_a": key_a,
"brand_a": brand_a,
- "vault_a": str(uuid4()),
+ "vault_a": vault_a,
"key_b": key_b,
"brand_b": brand_b,
- "vault_b": str(uuid4()),
+ "vault_b": vault_b,
},
)
yield SecurityFixture({
@@ -145,7 +196,10 @@ def security_fixture():
"token_a": token_a,
"token_b": token_b,
"brand_a": brand_a,
+ "brand_b": brand_b,
"key_a": key_a,
+ "key_b": key_b,
+ "secret_a": secret_a,
})
finally:
if brand_ids:
@@ -791,3 +845,140 @@ def headers(token: str) -> dict[str, str]:
assert all(row["id"] != fixture["key_a"] for row in non_owner.json())
assert internal.status_code in {400, 401, 403}
assert vault.status_code in {400, 401, 403, 404, 406}
+
+
+def test_validate_api_owner_and_hidden_key_paths_have_opaque_parity(security_fixture):
+ from backend.app.main import app
+
+ fixture = security_fixture
+ validator = MockProviderValidator(fixture["secret_a"])
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+ owner_headers = {"Authorization": f"Bearer {fixture['token_a']}"}
+ hidden_headers = {"Authorization": f"Bearer {fixture['token_b']}"}
+ missing_key = str(uuid4())
+ missing_brand = str(uuid4())
+
+ try:
+ with TestClient(app) as client:
+ owner = client.post(
+ f"/api/v1/brands/{fixture['brand_a']}/keys/{fixture['key_a']}/validate",
+ headers=owner_headers,
+ )
+ hidden_owner = client.post(
+ f"/api/v1/brands/{fixture['brand_a']}/keys/{fixture['key_a']}/validate",
+ headers=hidden_headers,
+ )
+ wrong_path_brand = client.post(
+ f"/api/v1/brands/{fixture['brand_a']}/keys/{fixture['key_b']}/validate",
+ headers=owner_headers,
+ )
+ missing_key_response = client.post(
+ f"/api/v1/brands/{fixture['brand_a']}/keys/{missing_key}/validate",
+ headers=owner_headers,
+ )
+ missing_both = client.post(
+ f"/api/v1/brands/{missing_brand}/keys/{missing_key}/validate",
+ headers=owner_headers,
+ )
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert owner.status_code == 200
+ assert owner.json()["code"] == "VALID"
+ assert validator.calls == 1
+ hidden_responses = (
+ hidden_owner,
+ wrong_path_brand,
+ missing_key_response,
+ missing_both,
+ )
+ for response in hidden_responses:
+ assert response.status_code == 404
+ assert response.json()["error"]["code"] == "PROVIDER_KEY_NOT_FOUND"
+ assert response.json()["error"]["message"] == "Provider key not found."
+
+
+def test_validity_and_active_fields_are_owner_readable_but_never_client_writable(
+ security_fixture,
+):
+ fixture = security_fixture
+ engine = fixture["engine"]
+ readable = _execute_as_authenticated(
+ engine,
+ fixture["token_a"],
+ "SELECT is_active, is_valid, last_validated_at, last_validation_error "
+ "FROM provider_keys WHERE id = :key_id",
+ {"key_id": fixture["key_a"]},
+ )
+ assert readable == [
+ {
+ "is_active": False,
+ "is_valid": None,
+ "last_validated_at": None,
+ "last_validation_error": None,
+ }
+ ]
+
+ mutations = (
+ "UPDATE provider_keys SET is_active = true WHERE id = :key_id",
+ "UPDATE provider_keys SET is_valid = true, "
+ "last_validated_at = clock_timestamp() WHERE id = :key_id",
+ "UPDATE provider_keys SET is_valid = false, "
+ "last_validated_at = clock_timestamp(), "
+ "last_validation_error = 'INVALID_CREDENTIAL' WHERE id = :key_id",
+ "UPDATE provider_keys SET validation_token = gen_random_uuid(), "
+ "validation_lease_expires_at = clock_timestamp() + interval '1 minute' "
+ "WHERE id = :key_id",
+ )
+ for token in (fixture["token_a"], fixture["token_b"]):
+ for statement in mutations:
+ _assert_permission_denied(
+ lambda statement=statement, token=token: _execute_as_authenticated(
+ engine,
+ token,
+ statement,
+ {"key_id": fixture["key_a"]},
+ )
+ )
+
+
+def test_data_api_hides_tokens_vault_and_decrypted_values_and_denies_state_patch(
+ security_fixture,
+):
+ fixture = security_fixture
+ client = fixture["client"]
+
+ def headers(token: str) -> dict[str, str]:
+ return {
+ "apikey": fixture["supabase_key"],
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+ for token in (fixture["token_a"], fixture["token_b"]):
+ internal = client.get(
+ f"{fixture['supabase_url']}/rest/v1/provider_keys",
+ headers=headers(token),
+ params={
+ "select": (
+ "vault_secret_id,validation_token,"
+ "validation_lease_expires_at,last_used_at"
+ )
+ },
+ )
+ decrypted = client.get(
+ f"{fixture['supabase_url']}/rest/v1/decrypted_secrets",
+ headers={**headers(token), "Accept-Profile": "vault"},
+ params={"select": "id,decrypted_secret"},
+ )
+ patch = client.patch(
+ f"{fixture['supabase_url']}/rest/v1/provider_keys",
+ headers=headers(token),
+ params={"id": f"eq.{fixture['key_a']}"},
+ json={"is_active": True, "is_valid": True},
+ )
+
+ assert internal.status_code in {400, 401, 403}
+ assert decrypted.status_code in {400, 401, 403, 404, 406}
+ assert patch.status_code in {401, 403}
diff --git a/backend/tests/integration/test_provider_keys.py b/backend/tests/integration/test_provider_keys.py
index 281bc9b..aa45a0c 100644
--- a/backend/tests/integration/test_provider_keys.py
+++ b/backend/tests/integration/test_provider_keys.py
@@ -1,13 +1,22 @@
from __future__ import annotations
+import asyncio
import os
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
+from types import SimpleNamespace
from uuid import UUID, uuid4
import httpx
import pytest
from fastapi.testclient import TestClient
-from sqlalchemy import text
-from sqlalchemy.exc import DBAPIError, SQLAlchemyError
+from sqlalchemy import create_engine, text
+from sqlalchemy.exc import (
+ DBAPIError,
+ SQLAlchemyError,
+ TimeoutError as SQLAlchemyTimeoutError,
+)
class SecretFixture(str):
@@ -20,6 +29,93 @@ def __repr__(self) -> str:
return ""
+class MockProviderValidator:
+ def __init__(
+ self,
+ *results: tuple[str, str],
+ expected_secret: SecretFixture,
+ block_first: bool = False,
+ ) -> None:
+ self._results = list(results)
+ self._expected_secret = expected_secret
+ self._block_first = block_first
+ self.entered = threading.Event()
+ self.release = threading.Event()
+ self.calls = 0
+ self.connection_counts: list[int] = []
+ self.engine = None
+
+ def __repr__(self) -> str:
+ return ""
+
+ async def _validate(self, *args, **kwargs):
+ values = [*args, *kwargs.values()]
+ assert any(value == self._expected_secret for value in values)
+ call_index = self.calls
+ self.calls += 1
+ if self.engine is not None:
+ self.connection_counts.append(self.engine.pool.checkedout())
+ self.entered.set()
+ if self._block_first and call_index == 0:
+ released = await asyncio.to_thread(self.release.wait, 10)
+ assert released
+ outcome, code = self._results[min(call_index, len(self._results) - 1)]
+ return SimpleNamespace(
+ outcome=outcome,
+ code=code,
+ provider_request_id="mock-provider-request",
+ )
+
+ async def validate(self, *args, **kwargs):
+ return await self._validate(*args, **kwargs)
+
+ async def validate_key(self, *args, **kwargs):
+ return await self._validate(*args, **kwargs)
+
+ async def __call__(self, *args, **kwargs):
+ return await self._validate(*args, **kwargs)
+
+
+def _provider_validator_dependency():
+ from backend.app.routes import provider_keys as provider_key_routes
+
+ dependency = getattr(provider_key_routes, "get_provider_validator", None)
+ assert dependency is not None, "validation provider dependency is not implemented"
+ return dependency
+
+
+def _add_real_key(
+ client: TestClient,
+ fixture: ProviderFixture,
+ raw_key: SecretFixture,
+ *,
+ make_active: bool = True,
+) -> UUID:
+ response = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(uuid4())},
+ json={
+ "provider": "openai",
+ "key": str(raw_key),
+ "make_active": make_active,
+ },
+ )
+ assert response.status_code == 201
+ return UUID(response.json()["id"])
+
+
+def _validation_state(engine, key_id: UUID):
+ with engine.connect() as connection:
+ return connection.execute(
+ text(
+ "SELECT is_valid, last_validated_at, last_validation_error, "
+ "is_active, validation_token, validation_lease_expires_at "
+ "FROM provider_keys WHERE id = :key_id"
+ ),
+ {"key_id": key_id},
+ ).one()
+
+
def _required_env(name: str) -> str:
value = os.getenv(name)
if not value:
@@ -405,6 +501,64 @@ def test_real_add_validation_and_cleanup_fences_create_no_secret(provider_fixtur
).scalar_one() == 0
+def test_validation_cleanup_conflicts_return_fixed_errors_without_provider_call(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ engine = fixture["engine"]
+ key_cleanup_secret = SecretFixture(f"validation-key-cleanup-{uuid4().hex}-A1B2")
+ brand_cleanup_secret = SecretFixture(f"validation-brand-cleanup-{uuid4().hex}-C3_D")
+ validator = MockProviderValidator(
+ expected_secret=key_cleanup_secret,
+ )
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+
+ try:
+ with TestClient(app) as client:
+ key_cleanup_id = _add_real_key(
+ client, fixture, key_cleanup_secret, make_active=False
+ )
+ brand_cleanup_id = _add_real_key(
+ client, fixture, brand_cleanup_secret, make_active=False
+ )
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ "UPDATE provider_keys SET lifecycle = 'cleanup_required' "
+ "WHERE id = :key_id"
+ ),
+ {"key_id": key_cleanup_id},
+ )
+ key_cleanup = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_cleanup_id}/validate",
+ headers=fixture["headers"],
+ )
+
+ with engine.begin() as connection:
+ connection.execute(
+ text(
+ "UPDATE brands SET deletion_state = 'cleanup_required' "
+ "WHERE id = :brand_id"
+ ),
+ {"brand_id": fixture["brand_id"]},
+ )
+ brand_cleanup = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{brand_cleanup_id}/validate",
+ headers=fixture["headers"],
+ )
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert key_cleanup.status_code == 409
+ assert key_cleanup.json()["error"]["code"] == "KEY_CLEANUP_REQUIRED"
+ assert brand_cleanup.status_code == 409
+ assert brand_cleanup.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+ assert validator.calls == 0
+
+
def test_real_vault_and_metadata_work_roll_back_together(provider_fixture):
fixture = provider_fixture
engine = fixture["engine"]
@@ -425,3 +579,342 @@ def test_real_vault_and_metadata_work_roll_back_together(provider_fixture):
text("SELECT count(*) FROM vault.secrets WHERE id = :id"),
{"id": vault_id},
).scalar_one() == 0
+
+
+def test_validation_claim_atomically_decrypts_and_releases_connection_for_provider_await(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ raw_key = SecretFixture(f"validation-claim-{uuid4().hex}-A1B2")
+ validator = MockProviderValidator(
+ ("valid", "VALID"), expected_secret=raw_key, block_first=True
+ )
+ validator.engine = fixture["engine"]
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+
+ try:
+ with TestClient(app) as client:
+ key_id = _add_real_key(client, fixture, raw_key, make_active=False)
+ with ThreadPoolExecutor(max_workers=1) as executor:
+ pending = executor.submit(
+ client.post,
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ assert validator.entered.wait(5)
+ with fixture["engine"].connect() as connection:
+ claim = connection.execute(
+ text(
+ "SELECT pk.validation_token IS NOT NULL AS has_token, "
+ "pk.validation_lease_expires_at > clock_timestamp() AS live_lease, "
+ "EXISTS (SELECT 1 FROM vault.decrypted_secrets ds "
+ "WHERE ds.id = pk.vault_secret_id "
+ "AND ds.decrypted_secret = :secret) AS decrypted "
+ "FROM provider_keys pk WHERE pk.id = :key_id"
+ ),
+ {"key_id": key_id, "secret": str(raw_key)},
+ ).one()
+ assert claim == (True, True, True)
+ assert validator.connection_counts == [0]
+ validator.release.set()
+ response = pending.result(timeout=5)
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert response.status_code == 200
+ assert response.json()["code"] == "VALID"
+ state = _validation_state(fixture["engine"], key_id)
+ assert state.is_valid is True
+ assert state.last_validated_at is not None
+ assert state.last_validation_error is None
+ assert state.validation_token is None
+ assert state.validation_lease_expires_at is None
+
+
+def test_overlapping_validation_makes_one_provider_request(provider_fixture):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ raw_key = SecretFixture(f"validation-overlap-{uuid4().hex}-C3_D")
+ validator = MockProviderValidator(
+ ("valid", "VALID"), expected_secret=raw_key, block_first=True
+ )
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+
+ try:
+ with TestClient(app) as first_client, TestClient(app) as second_client:
+ key_id = _add_real_key(first_client, fixture, raw_key)
+ with ThreadPoolExecutor(max_workers=1) as executor:
+ pending = executor.submit(
+ first_client.post,
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ assert validator.entered.wait(5)
+ overlap = second_client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ assert validator.calls == 1
+ validator.release.set()
+ completed = pending.result(timeout=5)
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert overlap.status_code == completed.status_code == 200
+ assert overlap.json()["outcome"] == "temporary"
+ assert overlap.json()["code"] == "VALIDATION_IN_PROGRESS"
+ assert completed.json()["code"] == "VALID"
+
+
+def test_database_clock_expiry_allows_new_claim_and_stale_token_cannot_overwrite(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ raw_key = SecretFixture(f"validation-stale-{uuid4().hex}-E5-F")
+ validator = MockProviderValidator(
+ ("invalid", "INVALID_CREDENTIAL"),
+ ("valid", "VALID"),
+ expected_secret=raw_key,
+ block_first=True,
+ )
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+
+ try:
+ with TestClient(app) as first_client, TestClient(app) as second_client:
+ key_id = _add_real_key(first_client, fixture, raw_key)
+ with ThreadPoolExecutor(max_workers=1) as executor:
+ stale_request = executor.submit(
+ first_client.post,
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ assert validator.entered.wait(5)
+ with fixture["engine"].begin() as connection:
+ first_token = connection.execute(
+ text(
+ "UPDATE provider_keys "
+ "SET validation_lease_expires_at = clock_timestamp() - interval '1 second' "
+ "WHERE id = :key_id RETURNING validation_token"
+ ),
+ {"key_id": key_id},
+ ).scalar_one()
+ fresh = second_client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ assert fresh.status_code == 200
+ assert fresh.json()["code"] == "VALID"
+ assert validator.calls == 2
+ with fixture["engine"].connect() as connection:
+ assert connection.execute(
+ text(
+ "SELECT validation_token IS DISTINCT FROM :first_token "
+ "FROM provider_keys WHERE id = :key_id"
+ ),
+ {"key_id": key_id, "first_token": first_token},
+ ).scalar_one()
+ validator.release.set()
+ stale = stale_request.result(timeout=5)
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert stale.status_code == 200
+ assert stale.json()["outcome"] == "temporary"
+ assert stale.json()["code"] == "VALIDATION_SUPERSEDED"
+ state = _validation_state(fixture["engine"], key_id)
+ assert state.is_valid is True
+ assert state.is_active is True
+ assert state.last_validation_error is None
+
+
+@pytest.mark.parametrize(
+ ("outcome", "code", "expected_valid", "expected_active", "expected_error"),
+ [
+ ("valid", "VALID", True, True, None),
+ ("invalid", "INVALID_CREDENTIAL", False, False, "INVALID_CREDENTIAL"),
+ ],
+)
+def test_completed_validation_persists_only_valid_or_invalid_result(
+ provider_fixture,
+ outcome,
+ code,
+ expected_valid,
+ expected_active,
+ expected_error,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ raw_key = SecretFixture(f"validation-result-{uuid4().hex}-G7_H")
+ validator = MockProviderValidator((outcome, code), expected_secret=raw_key)
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+ try:
+ with TestClient(app) as client:
+ key_id = _add_real_key(client, fixture, raw_key)
+ response = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert response.status_code == 200
+ assert response.json()["outcome"] == outcome
+ state = _validation_state(fixture["engine"], key_id)
+ assert state.is_valid is expected_valid
+ assert state.is_active is expected_active
+ assert state.last_validation_error == expected_error
+ assert state.last_validated_at is not None
+ assert state.validation_token is None
+
+
+def test_temporary_validation_is_a_persistence_noop(provider_fixture):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ raw_key = SecretFixture(f"validation-temporary-{uuid4().hex}-J8_K")
+ validator = MockProviderValidator(
+ ("temporary", "PROVIDER_UNAVAILABLE"), expected_secret=raw_key
+ )
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+ try:
+ with TestClient(app) as client:
+ key_id = _add_real_key(client, fixture, raw_key)
+ with fixture["engine"].begin() as connection:
+ connection.execute(
+ text(
+ "UPDATE provider_keys SET is_valid = true, "
+ "last_validated_at = clock_timestamp() - interval '1 day' "
+ "WHERE id = :key_id"
+ ),
+ {"key_id": key_id},
+ )
+ before = _validation_state(fixture["engine"], key_id)[:4]
+ response = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ after = _validation_state(fixture["engine"], key_id)
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert response.status_code == 200
+ assert response.json()["outcome"] == "temporary"
+ assert after[:4] == before
+ assert after.validation_token is None
+ assert after.validation_lease_expires_at is None
+
+
+def test_absent_vault_secret_fences_key_without_provider_call(provider_fixture):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ raw_key = SecretFixture(f"validation-missing-{uuid4().hex}-L9_M")
+ validator = MockProviderValidator(("valid", "VALID"), expected_secret=raw_key)
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+ try:
+ with TestClient(app) as client:
+ key_id = _add_real_key(client, fixture, raw_key)
+ with fixture["engine"].begin() as connection:
+ connection.execute(
+ text(
+ "DELETE FROM vault.secrets WHERE id = ("
+ "SELECT vault_secret_id FROM provider_keys WHERE id = :key_id)"
+ ),
+ {"key_id": key_id},
+ )
+ response = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert response.status_code == 409
+ assert response.json()["error"]["code"] == "KEY_CLEANUP_REQUIRED"
+ assert validator.calls == 0
+ with fixture["engine"].connect() as connection:
+ row = connection.execute(
+ text(
+ "SELECT lifecycle, is_active, validation_token, "
+ "validation_lease_expires_at FROM provider_keys WHERE id = :key_id"
+ ),
+ {"key_id": key_id},
+ ).one()
+ assert row == ("cleanup_required", False, None, None)
+
+
+def test_database_pool_lock_and_statement_waits_are_bounded(provider_fixture):
+ from backend.app.main import app
+ from backend.app.config import load_settings
+
+ fixture = provider_fixture
+ engine = fixture["engine"]
+ assert engine.pool._timeout <= 2
+ with engine.connect() as connection:
+ assert connection.execute(text("SHOW lock_timeout")).scalar_one() == "2s"
+ assert connection.execute(text("SHOW statement_timeout")).scalar_one() == "2s"
+
+ started = time.monotonic()
+ with pytest.raises(DBAPIError):
+ with engine.connect() as connection:
+ connection.execute(text("SELECT pg_sleep(3)"))
+ assert time.monotonic() - started < 3
+
+ pool_engine = create_engine(
+ load_settings().database_url,
+ pool_size=1,
+ max_overflow=0,
+ pool_timeout=0.1,
+ hide_parameters=True,
+ )
+ try:
+ with pool_engine.connect():
+ started = time.monotonic()
+ with pytest.raises(SQLAlchemyTimeoutError):
+ pool_engine.connect()
+ assert time.monotonic() - started < 1
+ finally:
+ pool_engine.dispose()
+
+ raw_key = SecretFixture(f"validation-lock-{uuid4().hex}-N0_P")
+ validator = MockProviderValidator(("valid", "VALID"), expected_secret=raw_key)
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+ try:
+ with TestClient(app) as client:
+ key_id = _add_real_key(client, fixture, raw_key)
+ with engine.connect() as blocker:
+ transaction = blocker.begin()
+ blocker.execute(
+ text("SELECT id FROM brands WHERE id = :brand_id FOR UPDATE"),
+ {"brand_id": fixture["brand_id"]},
+ )
+ started = time.monotonic()
+ response = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ elapsed = time.monotonic() - started
+ transaction.rollback()
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert elapsed < 4
+ assert response.status_code == 502
+ assert response.json()["error"]["code"] == "VAULT_UNAVAILABLE"
+ assert validator.calls == 0
+ state = _validation_state(engine, key_id)
+ assert state.validation_token is None
+ assert state.validation_lease_expires_at is None
diff --git a/backend/tests/unit/test_provider_validation.py b/backend/tests/unit/test_provider_validation.py
new file mode 100644
index 0000000..edc6bb3
--- /dev/null
+++ b/backend/tests/unit/test_provider_validation.py
@@ -0,0 +1,504 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Callable
+
+import httpx
+import pytest
+
+from backend.app.services.provider_validation import validate_provider_key
+
+
+OPENAI_URL = "https://api.openai.com/v1/models"
+GEMINI_URL = (
+ "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1"
+)
+RAW_KEY = "unit-test-provider-secret-A1B2"
+
+
+def _validate(
+ provider: str,
+ handler: Callable[[httpx.Request], httpx.Response],
+ *,
+ timeout: float = 10.0,
+):
+ return asyncio.run(
+ validate_provider_key(
+ provider,
+ RAW_KEY,
+ transport=httpx.MockTransport(handler),
+ timeout=timeout,
+ )
+ )
+
+
+def _assert_result(
+ result,
+ outcome: str,
+ code: str,
+ message: str,
+ provider_request_id: str | None = None,
+) -> None:
+ assert result.outcome == outcome
+ assert result.code == code
+ assert result.message == message
+ assert result.provider_request_id == provider_request_id
+
+
+def _provider_name(provider: str) -> str:
+ return "OpenAI" if provider == "openai" else "Gemini"
+
+
+def _temporary_message(provider: str) -> str:
+ return f"{_provider_name(provider)} could not validate the key right now."
+
+
+@pytest.mark.parametrize(
+ ("provider", "expected_url", "auth_header", "success_body", "provider_name"),
+ [
+ (
+ "openai",
+ OPENAI_URL,
+ ("authorization", f"Bearer {RAW_KEY}"),
+ {"data": []},
+ "OpenAI",
+ ),
+ (
+ "gemini",
+ GEMINI_URL,
+ ("x-goog-api-key", RAW_KEY),
+ {"models": []},
+ "Gemini",
+ ),
+ ],
+)
+def test_uses_exact_official_models_url_and_only_provider_auth_header(
+ provider: str,
+ expected_url: str,
+ auth_header: tuple[str, str],
+ success_body: dict[str, list],
+ provider_name: str,
+):
+ requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ return httpx.Response(200, json=success_body)
+
+ result = _validate(provider, handler)
+
+ assert len(requests) == 1
+ request = requests[0]
+ assert request.method == "GET"
+ assert str(request.url) == expected_url
+ assert request.headers[auth_header[0]] == auth_header[1]
+ assert ("x-goog-api-key" in request.headers) is (provider == "gemini")
+ assert ("authorization" in request.headers) is (provider == "openai")
+ assert "openai-organization" not in request.headers
+ assert "openai-project" not in request.headers
+ assert RAW_KEY not in str(request.url)
+ _assert_result(
+ result,
+ "valid",
+ "VALID",
+ f"{provider_name} accepted this API key.",
+ )
+
+
+@pytest.mark.parametrize(
+ ("provider", "body"),
+ [
+ ("openai", {"data": {}}),
+ ("openai", {"object": "list"}),
+ ("gemini", {"models": {}}),
+ ("gemini", {"nextPageToken": "next"}),
+ ],
+)
+def test_200_requires_the_provider_specific_list_structure(
+ provider: str, body: dict[str, object]
+):
+ result = _validate(provider, lambda _: httpx.Response(200, json=body))
+
+ _assert_result(
+ result,
+ "temporary",
+ "VALIDATION_UNDETERMINED",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("status_code", [200, 401, 500])
+def test_malformed_json_is_temporary(status_code: int):
+ result = _validate(
+ "openai",
+ lambda _: httpx.Response(
+ status_code,
+ content=b"not-json-provider-secret",
+ headers={"content-type": "application/json"},
+ ),
+ )
+
+ _assert_result(
+ result,
+ "temporary",
+ "VALIDATION_UNDETERMINED",
+ "OpenAI could not validate the key right now.",
+ )
+
+
+def test_openai_invalid_requires_exact_nested_error_code_predicate():
+ result = _validate(
+ "openai",
+ lambda _: httpx.Response(
+ 401,
+ json={
+ "error": {
+ "message": "Never expose this provider response.",
+ "type": "invalid_request_error",
+ "code": "invalid_api_key",
+ }
+ },
+ ),
+ )
+
+ _assert_result(
+ result,
+ "invalid",
+ "INVALID_CREDENTIAL",
+ "OpenAI rejected this API key.",
+ )
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ {"error": {"message": "Incorrect API key provided"}},
+ {"error": {"type": "invalid_api_key"}},
+ {"error": {"code": "INVALID_API_KEY"}},
+ {"error": {"code": "invalid_api_key "}},
+ {"code": "invalid_api_key"},
+ {"error": "invalid_api_key"},
+ ],
+)
+def test_openai_near_misses_never_invalidate(body: object):
+ result = _validate("openai", lambda _: httpx.Response(401, json=body))
+
+ _assert_result(
+ result,
+ "temporary",
+ "VALIDATION_UNDETERMINED",
+ "OpenAI could not validate the key right now.",
+ )
+
+
+@pytest.mark.parametrize(
+ "reason",
+ [
+ "API_KEY_INVALID",
+ "API_KEY_EXPIRED",
+ "API_KEY_NOT_FOUND",
+ "INVALID_CREDENTIAL",
+ ],
+)
+def test_gemini_invalid_accepts_every_exact_documented_error_info_reason(reason: str):
+ result = _validate(
+ "gemini",
+ lambda _: httpx.Response(
+ 400,
+ json={
+ "error": {
+ "details": [
+ {"@type": "type.googleapis.com/google.rpc.Help"},
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "reason": reason,
+ },
+ ]
+ }
+ },
+ ),
+ )
+
+ _assert_result(
+ result,
+ "invalid",
+ "INVALID_CREDENTIAL",
+ "Gemini rejected this API key.",
+ )
+
+
+@pytest.mark.parametrize(
+ "detail",
+ [
+ {"reason": "API_KEY_INVALID"},
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "reason": "api_key_invalid",
+ },
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "reason": "API_KEY_INVALID ",
+ },
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "reason": "ACCESS_TOKEN_EXPIRED",
+ },
+ {
+ "@type": "type.googleapis.com/google.rpc.Help",
+ "reason": "API_KEY_INVALID",
+ },
+ ],
+)
+def test_gemini_near_misses_never_invalidate(detail: dict[str, str]):
+ result = _validate(
+ "gemini",
+ lambda _: httpx.Response(
+ 400,
+ json={"error": {"message": "API key not valid", "details": [detail]}},
+ ),
+ )
+
+ _assert_result(
+ result,
+ "temporary",
+ "VALIDATION_UNDETERMINED",
+ "Gemini could not validate the key right now.",
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+@pytest.mark.parametrize("status_code", [400, 401])
+def test_ambiguous_client_statuses_are_undetermined(
+ provider: str, status_code: int
+):
+ result = _validate(
+ provider,
+ lambda _: httpx.Response(
+ status_code,
+ json={"error": {"message": "ambiguous provider response"}},
+ ),
+ )
+
+ _assert_result(
+ result,
+ "temporary",
+ "VALIDATION_UNDETERMINED",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+@pytest.mark.parametrize("status_code", [403, 412])
+def test_permission_and_precondition_are_not_invalid(
+ provider: str, status_code: int
+):
+ result = _validate(
+ provider,
+ lambda _: httpx.Response(
+ status_code,
+ json={"error": {"message": "forbidden", "status": "PERMISSION_DENIED"}},
+ ),
+ )
+
+ _assert_result(
+ result,
+ "temporary",
+ "PROVIDER_PERMISSION",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+@pytest.mark.parametrize("status_code", [429])
+def test_quota_and_rate_limit_status_is_temporary_rate_limited(
+ provider: str, status_code: int
+):
+ result = _validate(
+ provider,
+ lambda _: httpx.Response(
+ status_code,
+ json={"error": {"code": "insufficient_quota"}},
+ ),
+ )
+
+ _assert_result(
+ result,
+ "temporary",
+ "PROVIDER_RATE_LIMITED",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+@pytest.mark.parametrize("status_code", [500, 502, 503, 504])
+def test_service_statuses_are_temporary_unavailable(
+ provider: str, status_code: int
+):
+ result = _validate(
+ provider,
+ lambda _: httpx.Response(status_code, json={"error": "service failure"}),
+ )
+
+ _assert_result(
+ result,
+ "temporary",
+ "PROVIDER_UNAVAILABLE",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+def test_unknown_status_is_temporary_undetermined(provider: str):
+ result = _validate(
+ provider,
+ lambda _: httpx.Response(418, json={"error": "unknown"}),
+ )
+
+ _assert_result(
+ result,
+ "temporary",
+ "VALIDATION_UNDETERMINED",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+def test_redirect_is_not_followed_or_retried(provider: str):
+ requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ return httpx.Response(
+ 307,
+ headers={"location": "https://attacker.invalid/collect"},
+ )
+
+ result = _validate(provider, handler)
+
+ assert len(requests) == 1
+ _assert_result(
+ result,
+ "temporary",
+ "VALIDATION_UNDETERMINED",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+def test_network_error_is_unavailable_and_is_not_retried(provider: str):
+ request_count = 0
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ nonlocal request_count
+ request_count += 1
+ raise httpx.ConnectError(
+ f"connection failed with {RAW_KEY}", request=request
+ )
+
+ result = _validate(provider, handler)
+
+ assert request_count == 1
+ _assert_result(
+ result,
+ "temporary",
+ "PROVIDER_UNAVAILABLE",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+def test_timeout_is_classified_separately_and_receives_requested_budget(provider: str):
+ observed_timeout: dict[str, float] = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ observed_timeout.update(request.extensions["timeout"])
+ raise httpx.ReadTimeout(
+ f"timed out while using {RAW_KEY}", request=request
+ )
+
+ result = _validate(provider, handler, timeout=3.25)
+
+ assert observed_timeout == {
+ "connect": 3.25,
+ "read": 3.25,
+ "write": 3.25,
+ "pool": 3.25,
+ }
+ _assert_result(
+ result,
+ "temporary",
+ "PROVIDER_TIMEOUT",
+ _temporary_message(provider),
+ )
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+def test_extracts_provider_request_id_only_from_response_header(provider: str):
+ result = _validate(
+ provider,
+ lambda _: httpx.Response(
+ 200,
+ headers={"x-request-id": "provider-request-123"},
+ json={
+ "data" if provider == "openai" else "models": [],
+ "request_id": "body-request-id-must-be-ignored",
+ },
+ ),
+ )
+
+ _assert_result(
+ result,
+ "valid",
+ "VALID",
+ f"{'OpenAI' if provider == 'openai' else 'Gemini'} accepted this API key.",
+ "provider-request-123",
+ )
+
+
+def test_provider_request_id_cannot_echo_the_raw_key():
+ result = _validate(
+ "openai",
+ lambda _: httpx.Response(
+ 200,
+ headers={"x-request-id": RAW_KEY},
+ json={"data": []},
+ ),
+ )
+
+ assert result.provider_request_id is None
+ assert RAW_KEY not in repr(result)
+
+
+@pytest.mark.parametrize("provider", ["openai", "gemini"])
+def test_provider_body_headers_key_and_exception_text_are_not_logged_or_returned(
+ provider: str, caplog: pytest.LogCaptureFixture
+):
+ provider_body_secret = "provider-body-secret-Z9Y8"
+ authorization_value = f"Bearer {RAW_KEY}"
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert request.headers.get("authorization") in (None, authorization_value)
+ return httpx.Response(
+ 401,
+ headers={
+ "x-provider-debug": provider_body_secret,
+ "x-request-id": "safe-request-id",
+ },
+ json={
+ "error": {
+ "message": provider_body_secret,
+ "debug_authorization": authorization_value,
+ }
+ },
+ )
+
+ with caplog.at_level(logging.DEBUG):
+ result = _validate(provider, handler)
+
+ rendered_logs = "\n".join(caplog.messages)
+ rendered_result = repr(result)
+ for secret in (RAW_KEY, authorization_value, provider_body_secret):
+ assert secret not in rendered_logs
+ assert secret not in rendered_result
+ assert result.provider_request_id == "safe-request-id"
diff --git a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
index a50220d..7d4eb1f 100644
--- a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
+++ b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
@@ -30,6 +30,18 @@ type ProviderKey = {
};
type ErrorResponse = { error?: { code?: string } };
+type ValidationOutcome = {
+ outcome: "valid" | "invalid" | "temporary";
+ attempted_at: string;
+ code: string;
+ message: string;
+ key: ProviderKey;
+};
+
+type ValidationFeedback = {
+ message: string;
+ tone: "success" | "error" | "temporary";
+};
const PROVIDERS: Array<{ id: Provider; name: string }> = [
{ id: "openai", name: "OpenAI" },
@@ -57,12 +69,58 @@ function validationLabel(key: ProviderKey) {
return "Unvalidated";
}
+function validationFeedback(outcome: ValidationOutcome): ValidationFeedback {
+ if (outcome.outcome === "valid") {
+ return { message: "The provider accepted this key.", tone: "success" };
+ }
+ if (outcome.outcome === "invalid") {
+ return { message: "The provider rejected this key.", tone: "error" };
+ }
+
+ switch (outcome.code) {
+ case "PROVIDER_TIMEOUT":
+ return {
+ message: "Validation timed out. The saved key status was not changed.",
+ tone: "temporary",
+ };
+ case "VALIDATION_IN_PROGRESS":
+ return {
+ message: "Validation is already in progress. The saved key status was not changed.",
+ tone: "temporary",
+ };
+ case "VALIDATION_SUPERSEDED":
+ return {
+ message: "A newer validation replaced this attempt. The latest saved status is shown.",
+ tone: "temporary",
+ };
+ default:
+ return {
+ message: "The provider could not validate this key right now. The saved key status was not changed.",
+ tone: "temporary",
+ };
+ }
+}
+
+function validationErrorMessage(code?: string) {
+ switch (code) {
+ case "BRAND_CLEANUP_REQUIRED":
+ return "Brand cleanup is required before this key can be validated.";
+ case "KEY_CLEANUP_REQUIRED":
+ return "Key cleanup is required before this key can be validated.";
+ case "VAULT_UNAVAILABLE":
+ return "Secure key storage is unavailable right now.";
+ default:
+ return "Unable to validate this key.";
+ }
+}
+
export default function ProviderKeysPage() {
const { brandId } = useParams<{ brandId: string }>();
const router = useRouter();
const apiBase = getPublicEnv("NEXT_PUBLIC_API_URL");
const keyInputRef = useRef(null);
const retryIdRef = useRef(null);
+ const validatingKeyIdsRef = useRef(new Set());
const [brand, setBrand] = useState(null);
const [keys, setKeys] = useState([]);
const [provider, setProvider] = useState("openai");
@@ -73,6 +131,12 @@ export default function ProviderKeysPage() {
const [isAdding, setIsAdding] = useState(false);
const [error, setError] = useState(null);
const [addError, setAddError] = useState(null);
+ const [validatingKeyIds, setValidatingKeyIds] = useState>(
+ () => new Set()
+ );
+ const [validationFeedbackByKey, setValidationFeedbackByKey] = useState<
+ Record
+ >({});
useEffect(() => {
let active = true;
@@ -202,6 +266,75 @@ export default function ProviderKeysPage() {
}
}
+ async function validateKey(keyId: string) {
+ if (validatingKeyIdsRef.current.has(keyId)) return;
+
+ validatingKeyIdsRef.current.add(keyId);
+ setValidatingKeyIds((current) => new Set(current).add(keyId));
+ setValidationFeedbackByKey((current) => {
+ const next = { ...current };
+ delete next[keyId];
+ return next;
+ });
+
+ try {
+ const { data } = await supabase.auth.getSession();
+ const session = data.session;
+ if (!session) {
+ router.push("/login");
+ return;
+ }
+
+ const response = await fetch(
+ `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/keys/${encodeURIComponent(keyId)}/validate`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${session.access_token}` },
+ }
+ );
+ const body = (await response.json().catch(() => null)) as
+ | ValidationOutcome
+ | ErrorResponse
+ | null;
+ if (!response.ok) {
+ setValidationFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: {
+ message: validationErrorMessage(
+ (body as ErrorResponse | null)?.error?.code
+ ),
+ tone: "error",
+ },
+ }));
+ return;
+ }
+
+ const outcome = body as ValidationOutcome;
+ setKeys((current) =>
+ current.map((key) => (key.id === keyId ? outcome.key : key))
+ );
+ setValidationFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: validationFeedback(outcome),
+ }));
+ } catch {
+ setValidationFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: {
+ message: "Unable to validate this key. Refresh its status before trying again.",
+ tone: "error",
+ },
+ }));
+ } finally {
+ validatingKeyIdsRef.current.delete(keyId);
+ setValidatingKeyIds((current) => {
+ const next = new Set(current);
+ next.delete(keyId);
+ return next;
+ });
+ }
+ }
+
if (isLoading) {
return Loading provider keys...
;
}
@@ -281,32 +414,70 @@ export default function ProviderKeysPage() {
No {providerName} keys have been added.
) : (
- providerKeys.map((key) => (
-
-
-
-
{key.label || `${providerName} key`}
-
{key.key_hint}
-
-
-
- {key.is_active ? "Active" : "Inactive"}
-
-
- {validationLabel(key)}
-
- {key.cleanup_state === "cleanup_required" ? (
-
- Cleanup required
+ providerKeys.map((key) => {
+ const isValidating = validatingKeyIds.has(key.id);
+ const feedback = validationFeedbackByKey[key.id];
+ const validationDisabled =
+ isValidating ||
+ cleanupRequired ||
+ key.cleanup_state === "cleanup_required";
+
+ return (
+
+
+
+
{key.label || `${providerName} key`}
+
{key.key_hint}
+
+
+
+ {key.is_active ? "Active" : "Inactive"}
+
+
+ {validationLabel(key)}
- ) : null}
+ {key.cleanup_state === "cleanup_required" ? (
+
+ Cleanup required
+
+ ) : null}
+
+
+
+
+
Added {new Date(key.created_at).toLocaleDateString()}
+
+ {key.last_validated_at
+ ? `Last validated ${new Date(key.last_validated_at).toLocaleString()}`
+ : "Not yet validated"}
+
+
+
void validateKey(key.id)}
+ >
+ {isValidating ? "Validating..." : "Validate key"}
+
-
-
- Added {new Date(key.created_at).toLocaleDateString()}
-
-
- ))
+ {feedback ? (
+
+ {feedback.message}
+
+ ) : null}
+
+ );
+ })
)}
diff --git a/frontend/tests/e2e/provider-keys.spec.ts b/frontend/tests/e2e/provider-keys.spec.ts
index 5d0f416..96f2052 100644
--- a/frontend/tests/e2e/provider-keys.spec.ts
+++ b/frontend/tests/e2e/provider-keys.spec.ts
@@ -1,15 +1,17 @@
import { expect, test } from "@playwright/test";
+test.setTimeout(60_000);
+
type SafeKey = {
id: string;
provider: "openai" | "gemini";
label: string | null;
key_hint: string;
is_active: boolean;
- is_valid: null;
- last_validated_at: null;
- last_validation_error: null;
- cleanup_state: "normal";
+ is_valid: boolean | null;
+ last_validated_at: string | null;
+ last_validation_error: "INVALID_CREDENTIAL" | null;
+ cleanup_state: "normal" | "cleanup_required";
created_at: string;
};
@@ -23,6 +25,16 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
let submittedKeyBodies = 0;
let requestWasOpaque = true;
let responseWasOpaque = true;
+ let validationRequests = 0;
+ let brandCleanupState: "normal" | "cleanup_required" = "normal";
+ let validationMode:
+ | "valid"
+ | "invalid"
+ | "unavailable"
+ | "timeout"
+ | "in_progress"
+ | "delayed" = "valid";
+ let releaseDelayedValidation = () => {};
await page.route("**/api/v1/**", async (route) => {
const request = route.request();
@@ -37,7 +49,7 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
id: brandId,
name: "Provider Key Test Brand",
logo_url: null,
- cleanup_state: "normal",
+ cleanup_state: brandCleanupState,
created_at: "2026-07-28T10:00:00Z",
},
],
@@ -52,7 +64,7 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
id: brandId,
name: "Provider Key Test Brand",
logo_url: null,
- cleanup_state: "normal",
+ cleanup_state: brandCleanupState,
created_at: "2026-07-28T10:00:00Z",
});
responseWasOpaque &&= !Object.values(rawKeys).some((key) => body.includes(key));
@@ -104,6 +116,71 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
return;
}
+ const validationMatch = url.pathname.match(
+ new RegExp(`^/api/v1/brands/${brandId}/keys/([^/]+)/validate$`)
+ );
+ if (validationMatch && request.method() === "POST") {
+ validationRequests += 1;
+ const key = savedKeys.find((item) => item.id === validationMatch[1]);
+ if (!key) {
+ await route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "PROVIDER_KEY_NOT_FOUND" } }),
+ });
+ return;
+ }
+
+ if (validationMode === "delayed") {
+ await new Promise((resolve) => {
+ releaseDelayedValidation = resolve;
+ });
+ }
+
+ const attemptedAt = new Date().toISOString();
+ if (validationMode === "valid" || validationMode === "delayed") {
+ key.is_valid = true;
+ key.last_validated_at = attemptedAt;
+ key.last_validation_error = null;
+ } else if (validationMode === "invalid") {
+ key.is_active = false;
+ key.is_valid = false;
+ key.last_validated_at = attemptedAt;
+ key.last_validation_error = "INVALID_CREDENTIAL";
+ }
+
+ const temporaryCodes = {
+ unavailable: "PROVIDER_UNAVAILABLE",
+ timeout: "PROVIDER_TIMEOUT",
+ in_progress: "VALIDATION_IN_PROGRESS",
+ } as const;
+ const isTemporary =
+ validationMode === "unavailable" ||
+ validationMode === "timeout" ||
+ validationMode === "in_progress";
+ const body = JSON.stringify({
+ outcome: isTemporary
+ ? "temporary"
+ : validationMode === "invalid"
+ ? "invalid"
+ : "valid",
+ attempted_at: attemptedAt,
+ code:
+ validationMode === "unavailable" ||
+ validationMode === "timeout" ||
+ validationMode === "in_progress"
+ ? temporaryCodes[validationMode]
+ : validationMode === "invalid"
+ ? "INVALID_CREDENTIAL"
+ : "VALID",
+ message: "Fixed safe validation result.",
+ key: { ...key },
+ });
+ responseWasOpaque &&= !Object.values(rawKeys).some((rawKey) => body.includes(rawKey));
+ await route.fulfill({ status: 200, contentType: "application/json", body });
+ return;
+ }
+
await route.fulfill({
status: 404,
contentType: "application/json",
@@ -131,21 +208,94 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
await expect(page.getByText("Unvalidated", { exact: true })).toBeVisible();
await expect(page.getByLabel("API key")).toHaveValue("");
+ const openAIKey = page.getByRole("article").filter({ hasText: "Inactive OpenAI" });
+ validationMode = "valid";
+ await openAIKey.getByRole("button", { name: "Validate key" }).click();
+ await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible();
+ await expect(openAIKey.getByText(/^Last validated /)).toBeVisible();
+ await expect(openAIKey.getByRole("status")).toHaveText("The provider accepted this key.");
+
+ const validatedText = await openAIKey.getByText(/^Last validated /).textContent();
+ validationMode = "unavailable";
+ await openAIKey.getByRole("button", { name: "Validate key" }).click();
+ await expect(openAIKey.getByRole("status")).toContainText("saved key status was not changed");
+ await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible();
+ await expect(openAIKey.getByText(/^Last validated /)).toHaveText(validatedText ?? "");
+
+ validationMode = "timeout";
+ await openAIKey.getByRole("button", { name: "Validate key" }).click();
+ await expect(openAIKey.getByRole("status")).toContainText("Validation timed out");
+ await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible();
+
+ validationMode = "in_progress";
+ await openAIKey.getByRole("button", { name: "Validate key" }).click();
+ await expect(openAIKey.getByRole("status")).toContainText("already in progress");
+ await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible();
+
+ validationMode = "delayed";
+ const requestsBeforeDedupe = validationRequests;
+ await openAIKey
+ .getByRole("button", { name: "Validate key" })
+ .evaluate((button) => {
+ (button as HTMLButtonElement).click();
+ (button as HTMLButtonElement).click();
+ });
+ await expect(openAIKey.getByRole("button", { name: "Validating..." })).toBeDisabled();
+ await expect.poll(() => validationRequests).toBe(requestsBeforeDedupe + 1);
+ releaseDelayedValidation();
+ await expect(openAIKey.getByRole("button", { name: "Validate key" })).toBeEnabled();
+
await page.getByRole("tab", { name: "Gemini" }).click();
await page.getByLabel(/Label/).fill("Active Gemini");
await page.getByLabel("API key").fill(rawKeys.gemini);
await page.getByRole("button", { name: "Add Gemini key" }).click();
await expect(page.getByText("***G3D4")).toBeVisible();
await expect(page.getByText("Active", { exact: true })).toBeVisible();
+ const geminiKey = page.getByRole("article").filter({ hasText: "Active Gemini" });
+ validationMode = "invalid";
+ await geminiKey.getByRole("button", { name: "Validate key" }).click();
+ await expect(geminiKey.getByText("Invalid", { exact: true })).toBeVisible();
+ await expect(geminiKey.getByText("Inactive", { exact: true })).toBeVisible();
+ await expect(geminiKey.getByRole("status")).toHaveText("The provider rejected this key.");
await page.reload();
+ await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible();
await expect(page.getByText("***A1B2")).toBeVisible();
await page.getByRole("tab", { name: "Gemini" }).click();
await expect(page.getByText("***G3D4")).toBeVisible();
const visibleText = (await page.locator("body").textContent()) ?? "";
const rawKeyIsVisible = Object.values(rawKeys).some((key) => visibleText.includes(key));
+ const storedOpenAIKey = savedKeys.find((key) => key.provider === "openai");
+ if (!storedOpenAIKey) throw new Error("Expected the OpenAI fixture key.");
+ storedOpenAIKey.cleanup_state = "cleanup_required";
+ storedOpenAIKey.is_active = false;
+ storedOpenAIKey.is_valid = null;
+ storedOpenAIKey.last_validated_at = null;
+ storedOpenAIKey.last_validation_error = null;
+ await page.reload();
+ await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible();
+ await expect(
+ page
+ .getByRole("article")
+ .filter({ hasText: "Inactive OpenAI" })
+ .getByRole("button", { name: "Validate key" })
+ ).toBeDisabled();
+
+ storedOpenAIKey.cleanup_state = "normal";
+ brandCleanupState = "cleanup_required";
+ await page.reload();
+ await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible();
+ await expect(
+ page
+ .getByRole("article")
+ .filter({ hasText: "Inactive OpenAI" })
+ .getByRole("button", { name: "Validate key" })
+ ).toBeDisabled();
+ await expect(page.getByLabel("API key")).toBeDisabled();
+
expect(submittedKeyBodies === 2).toBeTruthy();
+ expect(validationRequests).toBe(6);
expect(requestWasOpaque).toBeTruthy();
expect(responseWasOpaque).toBeTruthy();
expect(rawKeyIsVisible).toBeFalsy();
diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md
index 5277627..a7a59ac 100644
--- a/specs/004-provider-keys/tasks.md
+++ b/specs/004-provider-keys/tasks.md
@@ -108,19 +108,19 @@ for both providers and verify only accepted/explicit-invalid outcomes persist ch
### Tests for User Story 2
-- [ ] T024 [P] [US2] Create failing `httpx.MockTransport` unit tests in `backend/tests/unit/test_provider_validation.py` for exact official URLs/headers, redirects and retries disabled, structurally valid success, OpenAI `error.code == invalid_api_key`, every allowed Gemini `google.rpc.ErrorInfo.reason`, ambiguous 400/401/403, permission/quota/rate-limit/service/unknown/malformed responses, provider request IDs, network errors, and timeout classification without logging body/header/key data
-- [ ] T025 [P] [US2] Extend `backend/tests/contract/test_provider_keys.py` with failing exact-shape tests for `POST /api/v1/brands/{brand_id}/keys/{key_id}/validate`: valid/invalid/temporary code matrix, complete safe key snapshot, fixed provider messages, active invalid deactivation, temporary byte-for-byte state preservation, opaque path membership, cleanup conflicts, Vault failure, `VALIDATION_IN_PROGRESS`, `VALIDATION_SUPERSEDED`, and response within the route deadline
-- [ ] T026 [P] [US2] Extend `backend/tests/integration/test_provider_keys.py` with failing real-Supabase validation lease tests for atomic claim plus Vault decrypt, no connection held during provider I/O, one provider request under overlap, database-clock lease expiry, stale fencing-token rejection, valid persistence, invalid persistence plus deactivation, temporary preservation, absent-secret fencing, and bounded pool/lock/statement failures
+- [X] T024 [P] [US2] Create failing `httpx.MockTransport` unit tests in `backend/tests/unit/test_provider_validation.py` for exact official URLs/headers, redirects and retries disabled, structurally valid success, OpenAI `error.code == invalid_api_key`, every allowed Gemini `google.rpc.ErrorInfo.reason`, ambiguous 400/401/403, permission/quota/rate-limit/service/unknown/malformed responses, provider request IDs, network errors, and timeout classification without logging body/header/key data
+- [X] T025 [P] [US2] Extend `backend/tests/contract/test_provider_keys.py` with failing exact-shape tests for `POST /api/v1/brands/{brand_id}/keys/{key_id}/validate`: valid/invalid/temporary code matrix, complete safe key snapshot, fixed provider messages, active invalid deactivation, temporary byte-for-byte state preservation, opaque path membership, cleanup conflicts, Vault failure, `VALIDATION_IN_PROGRESS`, `VALIDATION_SUPERSEDED`, and response within the route deadline
+- [X] T026 [P] [US2] Extend `backend/tests/integration/test_provider_keys.py` with failing real-Supabase validation lease tests for atomic claim plus Vault decrypt, no connection held during provider I/O, one provider request under overlap, database-clock lease expiry, stale fencing-token rejection, valid persistence, invalid persistence plus deactivation, temporary preservation, absent-secret fencing, and bounded pool/lock/statement failures
### Implementation for User Story 2
-- [ ] T027 [P] [US2] Create `backend/app/services/provider_validation.py` with injectable async `httpx` transport, official OpenAI and Gemini model-list probes, no redirects/retries, structured provider-specific invalid predicates, fixed valid/invalid/temporary code/message mapping, response-body disposal, provider request-ID extraction, and provider I/O bounded by ten seconds and the remaining route deadline
-- [ ] T028 [US2] Extend `backend/app/services/provider_key_store.py` with short claim/complete validation transactions: brand-first/key-second locking, lifecycle and brand fences, database-clock lease/token assignment, request-local Vault decryption, `VALIDATION_IN_PROGRESS`, matching-token completion, accepted/invalid persistence, atomic invalid deactivation, stale `VALIDATION_SUPERSEDED`, temporary no-op semantics, and best-effort lease clearing without holding a connection during HTTP
-- [ ] T029 [US2] Add the validate handler to `backend/app/routes/provider_keys.py`, budget auth/pool/lock/Vault/provider/completion/serialization against the entry deadline, return all completed provider classifications as HTTP 200 outcomes, preserve fixed pre-lease error envelopes, and emit only safe provider/code/duration/request-ID logs
-- [ ] T030 [US2] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with explicit validate actions, pending-state deduplication, valid/invalid/unvalidated status and timestamp rendering, fixed temporary outcome feedback, active-key deactivation refresh, and `finally` cleanup for every network path
-- [ ] T031 [US2] Extend `backend/tests/integration/test_provider_key_rls.py` with owner/non-owner/nonexistent validate parity and assertions that direct clients cannot read or mutate validation tokens, lease expiries, Vault IDs, decrypted values, validity fields, or active state
-- [ ] T032 [US2] Add deadline and secrecy regression cases to `backend/tests/contract/test_provider_keys.py` that consume budget during authentication, pool checkout, lock/Vault access, provider I/O, and completion; assert every leased request settles within 15 seconds and captured logs/errors omit keys, binds, labels, hints, Vault IDs, provider content, headers, exception text, tokens, and PII
-- [ ] T033 [US2] Extend `frontend/tests/e2e/provider-keys.spec.ts` with mocked valid, explicit-invalid, temporary, timeout, and already-in-progress UI outcomes, including invalid active-key deactivation and preservation of prior status on temporary outcomes
+- [X] T027 [P] [US2] Create `backend/app/services/provider_validation.py` with injectable async `httpx` transport, official OpenAI and Gemini model-list probes, no redirects/retries, structured provider-specific invalid predicates, fixed valid/invalid/temporary code/message mapping, response-body disposal, provider request-ID extraction, and provider I/O bounded by ten seconds and the remaining route deadline
+- [X] T028 [US2] Extend `backend/app/services/provider_key_store.py` with short claim/complete validation transactions: brand-first/key-second locking, lifecycle and brand fences, database-clock lease/token assignment, request-local Vault decryption, `VALIDATION_IN_PROGRESS`, matching-token completion, accepted/invalid persistence, atomic invalid deactivation, stale `VALIDATION_SUPERSEDED`, temporary no-op semantics, and best-effort lease clearing without holding a connection during HTTP
+- [X] T029 [US2] Add the validate handler to `backend/app/routes/provider_keys.py`, budget auth/pool/lock/Vault/provider/completion/serialization against the entry deadline, return all completed provider classifications as HTTP 200 outcomes, preserve fixed pre-lease error envelopes, and emit only safe provider/code/duration/request-ID logs
+- [X] T030 [US2] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with explicit validate actions, pending-state deduplication, valid/invalid/unvalidated status and timestamp rendering, fixed temporary outcome feedback, active-key deactivation refresh, and `finally` cleanup for every network path
+- [X] T031 [US2] Extend `backend/tests/integration/test_provider_key_rls.py` with owner/non-owner/nonexistent validate parity and assertions that direct clients cannot read or mutate validation tokens, lease expiries, Vault IDs, decrypted values, validity fields, or active state
+- [X] T032 [US2] Add deadline and secrecy regression cases to `backend/tests/contract/test_provider_keys.py` that consume budget during authentication, pool checkout, lock/Vault access, provider I/O, and completion; assert every leased request settles within 15 seconds and captured logs/errors omit keys, binds, labels, hints, Vault IDs, provider content, headers, exception text, tokens, and PII
+- [X] T033 [US2] Extend `frontend/tests/e2e/provider-keys.spec.ts` with mocked valid, explicit-invalid, temporary, timeout, and already-in-progress UI outcomes, including invalid active-key deactivation and preservation of prior status on temporary outcomes
- [ ] T034 [US2] Execute Quickstart Scenario 2's deterministic steps from `specs/004-provider-keys/quickstart.md` and run `backend/tests/unit/test_provider_validation.py`, the validation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, and `frontend/tests/e2e/provider-keys.spec.ts`
**Checkpoint**: Both providers validate safely without generation, false invalidation, or
From a13987e917fda915001de2e081685ce9bdacafef Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 20:46:19 +0300
Subject: [PATCH 11/14] Complete provider key activation
---
backend/app/routes/provider_keys.py | 69 ++++++
backend/app/services/provider_key_store.py | 80 ++++++-
backend/tests/contract/test_provider_keys.py | 151 ++++++++++++
.../integration/test_provider_key_cleanup.py | 216 ++++++++++++++++++
.../brands/[brandId]/keys/page.tsx | 144 +++++++++++-
frontend/tests/e2e/provider-keys.spec.ts | 50 ++++
specs/004-provider-keys/tasks.md | 14 +-
7 files changed, 707 insertions(+), 17 deletions(-)
create mode 100644 backend/tests/integration/test_provider_key_cleanup.py
diff --git a/backend/app/routes/provider_keys.py b/backend/app/routes/provider_keys.py
index 683df86..7633048 100644
--- a/backend/app/routes/provider_keys.py
+++ b/backend/app/routes/provider_keys.py
@@ -24,7 +24,9 @@
from ..services.brand_store import BrandCleanupRequiredError
from ..services.provider_key_store import (
IdempotencyKeyRetiredError,
+ KeyActivationConflictError,
KeyCleanupRequiredError,
+ KeyInvalidError,
ProviderKeyStore,
VaultUnavailableError,
get_provider_key_store,
@@ -78,6 +80,38 @@ def _validation_error(exc: Exception) -> HTTPException:
)
+def _activation_error(exc: Exception) -> HTTPException:
+ if isinstance(exc, LookupError):
+ return _error(
+ status.HTTP_404_NOT_FOUND,
+ "PROVIDER_KEY_NOT_FOUND",
+ "Provider key not found.",
+ )
+ if isinstance(exc, BrandCleanupRequiredError):
+ return _error(
+ status.HTTP_409_CONFLICT,
+ "BRAND_CLEANUP_REQUIRED",
+ "Brand cleanup is required. Retry deletion.",
+ )
+ if isinstance(exc, KeyCleanupRequiredError):
+ return _error(
+ status.HTTP_409_CONFLICT,
+ "KEY_CLEANUP_REQUIRED",
+ "Key cleanup is required. Retry deletion.",
+ )
+ if isinstance(exc, KeyInvalidError):
+ return _error(
+ status.HTTP_409_CONFLICT,
+ "KEY_INVALID",
+ "Validate this key successfully before activating it.",
+ )
+ return _error(
+ status.HTTP_409_CONFLICT,
+ "BRAND_MUTATION_IN_PROGRESS",
+ "A brand update is in progress. Retry shortly.",
+ )
+
+
async def _within_deadline(awaitable: Awaitable[_T], deadline: float) -> _T:
remaining = max(0.0, deadline - time.monotonic())
return await asyncio.wait_for(awaitable, timeout=remaining)
@@ -200,6 +234,41 @@ def add_provider_key(
return key
+@router.patch(
+ "/{brand_id}/keys/{key_id}/activate",
+ response_model=ProviderKey,
+)
+def activate_provider_key(
+ request: Request,
+ brand_id: UUID,
+ key_id: UUID,
+ current_user: CurrentUserDep,
+ provider_key_store: ProviderKeyStoreDep,
+) -> ProviderKey:
+ try:
+ key = provider_key_store.activate_key(
+ current_user.user_id, brand_id, key_id
+ )
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ KeyCleanupRequiredError,
+ KeyInvalidError,
+ KeyActivationConflictError,
+ ) as exc:
+ raise _activation_error(exc) from exc
+
+ logger.info(
+ "provider_keys.activate_success",
+ extra={
+ "event": "provider_keys.activate_success",
+ "request_id": getattr(request.state, "request_id", "unknown"),
+ "provider": key.provider.value,
+ },
+ )
+ return key
+
+
@router.post(
"/{brand_id}/keys/{key_id}/validate",
response_model=ProviderKeyValidationResponse,
diff --git a/backend/app/services/provider_key_store.py b/backend/app/services/provider_key_store.py
index a2e87d4..c875934 100644
--- a/backend/app/services/provider_key_store.py
+++ b/backend/app/services/provider_key_store.py
@@ -10,7 +10,7 @@
from sqlalchemy import text
from sqlalchemy.engine import Engine
-from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from ..config import get_engine
from ..models.provider_key import ProviderKey, ProviderKeyAdd
@@ -33,6 +33,14 @@ class KeyCleanupRequiredError(ProviderKeyStoreError):
pass
+class KeyInvalidError(ProviderKeyStoreError):
+ pass
+
+
+class KeyActivationConflictError(ProviderKeyStoreError):
+ pass
+
+
class ValidationResult(Protocol):
outcome: str
@@ -198,6 +206,76 @@ def add_key(
except SQLAlchemyError as exc:
raise VaultUnavailableError from exc
+ def activate_key(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ key_id: UUID,
+ ) -> ProviderKey:
+ try:
+ with self.engine.begin() as connection:
+ brand = BrandStore.lock_owned_brand(connection, user_id, brand_id)
+ row = connection.execute(
+ text(
+ f"""
+ SELECT {_SAFE_COLUMNS}
+ FROM provider_keys
+ WHERE brand_id = :brand_id AND id = :key_id
+ FOR UPDATE
+ """
+ ),
+ {"brand_id": brand_id, "key_id": key_id},
+ ).mappings().one_or_none()
+ if row is None:
+ raise LookupError("Provider key not found.")
+
+ # Path membership is resolved before lifecycle conflicts are exposed.
+ BrandStore.require_normal_brand(brand)
+ if row["lifecycle"] != "normal":
+ raise KeyCleanupRequiredError
+ if row["is_valid"] is False:
+ raise KeyInvalidError
+
+ connection.execute(
+ text(
+ "UPDATE provider_keys SET is_active = false "
+ "WHERE brand_id = :brand_id "
+ "AND provider = CAST(:provider AS provider_t) "
+ "AND is_active"
+ ),
+ {"brand_id": brand_id, "provider": row["provider"]},
+ )
+ activated = connection.execute(
+ text(
+ f"""
+ UPDATE provider_keys
+ SET is_active = true
+ WHERE id = :key_id
+ RETURNING {_SAFE_COLUMNS}
+ """
+ ),
+ {"key_id": key_id},
+ ).mappings().one()
+ return self._to_provider_key(activated)
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ KeyCleanupRequiredError,
+ KeyInvalidError,
+ ):
+ raise
+ except IntegrityError as exc:
+ original = exc.orig
+ constraint_name = getattr(
+ getattr(original, "diag", None), "constraint_name", None
+ )
+ if (
+ getattr(original, "pgcode", None) == "23505"
+ and constraint_name == "uq_provider_keys_one_active"
+ ):
+ raise KeyActivationConflictError from exc
+ raise
+
def claim_validation(
self,
user_id: str,
diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py
index 40e06c9..efad9c3 100644
--- a/backend/tests/contract/test_provider_keys.py
+++ b/backend/tests/contract/test_provider_keys.py
@@ -546,6 +546,7 @@ class FakeProviderKeyStore:
keys: list[ProviderKey] = field(default_factory=list)
error: Exception | None = None
add_calls: list[tuple[str, UUID, ProviderKeyAdd, UUID]] = field(default_factory=list)
+ activate_calls: list[tuple[str, UUID, UUID]] = field(default_factory=list)
validation: FakeValidationScenario | None = None
claim_calls: list[tuple[str, UUID, UUID, float]] = field(default_factory=list)
complete_calls: list[tuple[str, UUID, UUID, UUID, Any, float]] = field(
@@ -569,6 +570,29 @@ def add_key(
raise self.error
return self.keys[0] if self.keys else _safe_key(is_active=payload.make_active)
+ def activate_key(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ key_id: UUID,
+ ) -> ProviderKey:
+ self.activate_calls.append((user_id, brand_id, key_id))
+ if self.error:
+ raise self.error
+ target = next((key for key in self.keys if key.id == key_id), None)
+ if target is None:
+ target = _safe_key(id=key_id, is_active=False)
+ activated = target.model_copy(update={"is_active": True})
+ self.keys = [
+ activated
+ if key.id == key_id
+ else key.model_copy(update={"is_active": False})
+ if key.provider == target.provider
+ else key
+ for key in self.keys
+ ]
+ return activated
+
def claim_validation(
self,
user_id: str,
@@ -814,6 +838,133 @@ def test_add_uses_no_provider_client_dependency(provider_key_client):
assert response.status_code == 201
+def test_activate_returns_exact_safe_shape_and_accepts_unvalidated_key(
+ provider_key_client,
+):
+ client, store = provider_key_client
+ store.keys = [_safe_key(is_active=False, is_valid=None)]
+
+ response = client.patch(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/activate"
+ )
+
+ assert response.status_code == 200
+ assert response.json() == _safe_key_json(_safe_key(is_active=True, is_valid=None))
+ assert store.activate_calls == [
+ ("11111111-1111-1111-1111-111111111111", BRAND_ID, KEY_ID)
+ ]
+ assert store.validator.calls == []
+ assert {
+ "brand_id",
+ "vault_secret_id",
+ "lifecycle",
+ "validation_token",
+ "updated_at",
+ }.isdisjoint(response.json())
+
+
+def test_activate_deactivates_only_prior_key_for_same_provider(provider_key_client):
+ client, store = provider_key_client
+ prior_id = UUID("77777777-7777-7777-7777-777777777777")
+ gemini_id = UUID("88888888-8888-8888-8888-888888888888")
+ store.keys = [
+ _safe_key(id=KEY_ID, is_active=False),
+ _safe_key(id=prior_id, is_active=True),
+ _safe_key(id=gemini_id, provider="gemini", is_active=True),
+ ]
+
+ response = client.patch(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/activate"
+ )
+
+ assert response.status_code == 200
+ states = {key.id: key.is_active for key in store.keys}
+ assert states == {KEY_ID: True, prior_id: False, gemini_id: True}
+
+
+@pytest.mark.parametrize(
+ ("error_name", "fallback", "code", "message"),
+ [
+ (
+ "KeyInvalidError",
+ RuntimeError,
+ "KEY_INVALID",
+ "Validate this key successfully before activating it.",
+ ),
+ (
+ "KeyCleanupRequiredError",
+ RuntimeError,
+ "KEY_CLEANUP_REQUIRED",
+ "Key cleanup is required. Retry deletion.",
+ ),
+ (
+ "BrandCleanupRequiredError",
+ BrandCleanupRequiredError,
+ "BRAND_CLEANUP_REQUIRED",
+ "Brand cleanup is required. Retry deletion.",
+ ),
+ (
+ "KeyActivationConflictError",
+ RuntimeError,
+ "BRAND_MUTATION_IN_PROGRESS",
+ "A brand update is in progress. Retry shortly.",
+ ),
+ ],
+)
+def test_activate_conflicts_use_fixed_safe_envelopes(
+ provider_key_client,
+ error_name: str,
+ fallback: type[Exception],
+ code: str,
+ message: str,
+):
+ client, store = provider_key_client
+ error_type = (
+ BrandCleanupRequiredError
+ if error_name == "BrandCleanupRequiredError"
+ else getattr(provider_key_store_module, error_name, fallback)
+ )
+ store.error = error_type()
+
+ response = client.patch(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/activate"
+ )
+
+ assert response.status_code == 409
+ assert response.json()["error"]["code"] == code
+ assert response.json()["error"]["message"] == message
+ assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
+ assert store.validator.calls == []
+
+
+@pytest.mark.parametrize("hidden_case", ["missing", "wrong_brand", "not_owned"])
+def test_activate_path_membership_is_opaque(provider_key_client, hidden_case: str):
+ client, store = provider_key_client
+ store.error = LookupError(hidden_case)
+
+ response = client.patch(
+ f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}/activate"
+ )
+
+ assert response.status_code == 404
+ assert response.json()["error"]["code"] == "PROVIDER_KEY_NOT_FOUND"
+ assert response.json()["error"]["message"] == "Provider key not found."
+ assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
+ assert store.validator.calls == []
+
+
+def test_activate_rejects_malformed_path_before_store(provider_key_client):
+ client, store = provider_key_client
+
+ response = client.patch(
+ f"/api/v1/brands/{BRAND_ID}/keys/not-a-uuid/activate"
+ )
+
+ assert response.status_code == 400
+ assert response.json()["error"]["code"] == "VALIDATION_ERROR"
+ assert store.activate_calls == []
+
+
def _safe_key_json(key: ProviderKey) -> dict[str, Any]:
return {
"id": str(key.id),
diff --git a/backend/tests/integration/test_provider_key_cleanup.py b/backend/tests/integration/test_provider_key_cleanup.py
new file mode 100644
index 0000000..645ea87
--- /dev/null
+++ b/backend/tests/integration/test_provider_key_cleanup.py
@@ -0,0 +1,216 @@
+from __future__ import annotations
+
+from concurrent.futures import ThreadPoolExecutor
+from uuid import UUID, uuid4
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from sqlalchemy.exc import DBAPIError
+
+from backend.tests.integration.test_provider_keys import (
+ MockProviderValidator,
+ ProviderFixture,
+ SecretFixture,
+ _add_real_key,
+ _provider_validator_dependency,
+ provider_fixture,
+)
+
+
+def _add_provider_key(
+ client: TestClient,
+ fixture: ProviderFixture,
+ provider: str,
+ suffix: str,
+) -> UUID:
+ response = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys",
+ headers={**fixture["headers"], "Idempotency-Key": str(uuid4())},
+ json={
+ "provider": provider,
+ "key": f"activation-{uuid4().hex}-{suffix}",
+ "make_active": False,
+ },
+ )
+ assert response.status_code == 201
+ return UUID(response.json()["id"])
+
+
+def _active_states(fixture: ProviderFixture) -> dict[UUID, bool]:
+ with fixture["engine"].connect() as connection:
+ rows = connection.execute(
+ text(
+ "SELECT id, is_active FROM provider_keys "
+ "WHERE brand_id = :brand_id"
+ ),
+ {"brand_id": fixture["brand_id"]},
+ ).all()
+ return {row.id: row.is_active for row in rows}
+
+
+def test_simultaneous_same_provider_activation_keeps_exactly_one_active(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ with TestClient(app) as setup_client:
+ first_id = _add_provider_key(setup_client, fixture, "openai", "A1B2")
+ second_id = _add_provider_key(setup_client, fixture, "openai", "C3_D")
+
+ with TestClient(app) as first_client, TestClient(app) as second_client:
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ responses = [
+ executor.submit(
+ client.patch,
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/activate",
+ headers=fixture["headers"],
+ )
+ for client, key_id in (
+ (first_client, first_id),
+ (second_client, second_id),
+ )
+ ]
+ completed = [future.result(timeout=10) for future in responses]
+
+ assert [response.status_code for response in completed] == [200, 200]
+ states = _active_states(fixture)
+ assert sum(states[key_id] for key_id in (first_id, second_id)) == 1
+ observable = "".join(response.text for response in completed)
+ for unsafe in ("uq_provider_keys_one_active", "provider_keys_invalid_inactive", "UPDATE provider_keys"):
+ assert unsafe not in observable
+
+
+def test_activation_is_independent_per_provider(provider_fixture):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ with TestClient(app) as client:
+ openai_id = _add_provider_key(client, fixture, "openai", "E5-F")
+ gemini_id = _add_provider_key(client, fixture, "gemini", "G7_H")
+ openai = client.patch(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{openai_id}/activate",
+ headers=fixture["headers"],
+ )
+ gemini = client.patch(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{gemini_id}/activate",
+ headers=fixture["headers"],
+ )
+
+ assert openai.status_code == gemini.status_code == 200
+ states = _active_states(fixture)
+ assert states[openai_id] is True
+ assert states[gemini_id] is True
+
+
+def test_invalid_validation_completion_wins_after_activation(provider_fixture):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ raw_key = SecretFixture(f"activation-validation-{uuid4().hex}-J8_K")
+ validator = MockProviderValidator(
+ ("invalid", "INVALID_CREDENTIAL"),
+ expected_secret=raw_key,
+ block_first=True,
+ )
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+ try:
+ with TestClient(app) as validation_client, TestClient(app) as activation_client:
+ key_id = _add_real_key(
+ validation_client, fixture, raw_key, make_active=False
+ )
+ with ThreadPoolExecutor(max_workers=1) as executor:
+ pending = executor.submit(
+ validation_client.post,
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ assert validator.entered.wait(5)
+ activated = activation_client.patch(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/activate",
+ headers=fixture["headers"],
+ )
+ validator.release.set()
+ invalidated = pending.result(timeout=10)
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert activated.status_code == 200
+ assert invalidated.status_code == 200
+ assert invalidated.json()["outcome"] == "invalid"
+ with fixture["engine"].connect() as connection:
+ state = connection.execute(
+ text("SELECT is_valid, is_active FROM provider_keys WHERE id = :key_id"),
+ {"key_id": key_id},
+ ).one()
+ assert state == (False, False)
+
+
+def test_activation_is_blocked_after_invalid_validation(provider_fixture):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ raw_key = SecretFixture(f"invalid-before-activation-{uuid4().hex}-L9_M")
+ validator = MockProviderValidator(
+ ("invalid", "INVALID_CREDENTIAL"), expected_secret=raw_key
+ )
+ dependency = _provider_validator_dependency()
+ app.dependency_overrides[dependency] = lambda: validator
+ try:
+ with TestClient(app) as client:
+ key_id = _add_real_key(client, fixture, raw_key, make_active=False)
+ invalidated = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ activated = client.patch(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/activate",
+ headers=fixture["headers"],
+ )
+ finally:
+ app.dependency_overrides.pop(dependency, None)
+
+ assert invalidated.status_code == 200
+ assert activated.status_code == 409
+ assert activated.json()["error"]["code"] == "KEY_INVALID"
+ assert activated.json()["error"]["message"] == (
+ "Validate this key successfully before activating it."
+ )
+ assert activated.headers["X-Request-Id"] == activated.json()["error"]["request_id"]
+
+
+def test_database_rejects_multiple_active_invalid_and_cleanup_active_rows(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ with TestClient(app) as client:
+ first_id = _add_provider_key(client, fixture, "openai", "N0_P")
+ second_id = _add_provider_key(client, fixture, "openai", "Q1_R")
+
+ engine = fixture["engine"]
+ with engine.begin() as connection:
+ connection.execute(
+ text("UPDATE provider_keys SET is_active = true WHERE id = :key_id"),
+ {"key_id": first_id},
+ )
+ with pytest.raises(DBAPIError):
+ with engine.begin() as connection:
+ connection.execute(
+ text("UPDATE provider_keys SET is_active = true WHERE id = :key_id"),
+ {"key_id": second_id},
+ )
+ for assignment in (
+ "is_valid = false, last_validated_at = clock_timestamp(), "
+ "last_validation_error = 'INVALID_CREDENTIAL', is_active = true",
+ "lifecycle = 'cleanup_required', is_active = true",
+ ):
+ with pytest.raises(DBAPIError):
+ with engine.begin() as connection:
+ connection.execute(
+ text(f"UPDATE provider_keys SET {assignment} WHERE id = :key_id"),
+ {"key_id": second_id},
+ )
diff --git a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
index 7d4eb1f..942831a 100644
--- a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
+++ b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
@@ -114,6 +114,21 @@ function validationErrorMessage(code?: string) {
}
}
+function activationErrorMessage(code?: string) {
+ switch (code) {
+ case "KEY_INVALID":
+ return "Validate this key successfully before activating it.";
+ case "KEY_CLEANUP_REQUIRED":
+ return "Key cleanup is required before this key can be activated.";
+ case "BRAND_CLEANUP_REQUIRED":
+ return "Brand cleanup is required before a key can be activated.";
+ case "BRAND_MUTATION_IN_PROGRESS":
+ return "Another brand update is in progress. Retry shortly.";
+ default:
+ return "Unable to activate this key.";
+ }
+}
+
export default function ProviderKeysPage() {
const { brandId } = useParams<{ brandId: string }>();
const router = useRouter();
@@ -121,6 +136,7 @@ export default function ProviderKeysPage() {
const keyInputRef = useRef(null);
const retryIdRef = useRef(null);
const validatingKeyIdsRef = useRef(new Set());
+ const activatingKeyIdsRef = useRef(new Set());
const [brand, setBrand] = useState(null);
const [keys, setKeys] = useState([]);
const [provider, setProvider] = useState("openai");
@@ -134,9 +150,15 @@ export default function ProviderKeysPage() {
const [validatingKeyIds, setValidatingKeyIds] = useState>(
() => new Set()
);
+ const [activatingKeyIds, setActivatingKeyIds] = useState>(
+ () => new Set()
+ );
const [validationFeedbackByKey, setValidationFeedbackByKey] = useState<
Record
>({});
+ const [activationFeedbackByKey, setActivationFeedbackByKey] = useState<
+ Record
+ >({});
useEffect(() => {
let active = true;
@@ -271,6 +293,11 @@ export default function ProviderKeysPage() {
validatingKeyIdsRef.current.add(keyId);
setValidatingKeyIds((current) => new Set(current).add(keyId));
+ setActivationFeedbackByKey((current) => {
+ const next = { ...current };
+ delete next[keyId];
+ return next;
+ });
setValidationFeedbackByKey((current) => {
const next = { ...current };
delete next[keyId];
@@ -335,6 +362,86 @@ export default function ProviderKeysPage() {
}
}
+ async function activateKey(keyId: string) {
+ if (activatingKeyIdsRef.current.has(keyId)) return;
+
+ activatingKeyIdsRef.current.add(keyId);
+ setActivatingKeyIds((current) => new Set(current).add(keyId));
+ setValidationFeedbackByKey((current) => {
+ const next = { ...current };
+ delete next[keyId];
+ return next;
+ });
+ setActivationFeedbackByKey((current) => {
+ const next = { ...current };
+ delete next[keyId];
+ return next;
+ });
+
+ try {
+ const { data } = await supabase.auth.getSession();
+ const session = data.session;
+ if (!session) {
+ router.push("/login");
+ return;
+ }
+
+ const response = await fetch(
+ `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/keys/${encodeURIComponent(keyId)}/activate`,
+ {
+ method: "PATCH",
+ headers: { Authorization: `Bearer ${session.access_token}` },
+ }
+ );
+ const body = (await response.json().catch(() => null)) as
+ | ProviderKey
+ | ErrorResponse
+ | null;
+ if (!response.ok) {
+ setActivationFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: {
+ message: activationErrorMessage(
+ (body as ErrorResponse | null)?.error?.code
+ ),
+ tone: "error",
+ },
+ }));
+ return;
+ }
+
+ const activatedKey = body as ProviderKey;
+ setKeys((current) =>
+ current.map((key) =>
+ key.id === activatedKey.id
+ ? activatedKey
+ : key.provider === activatedKey.provider
+ ? { ...key, is_active: false }
+ : key
+ )
+ );
+ setActivationFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: { message: "Key activated.", tone: "success" },
+ }));
+ } catch {
+ setActivationFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: {
+ message: "Unable to activate this key. Refresh its status before trying again.",
+ tone: "error",
+ },
+ }));
+ } finally {
+ activatingKeyIdsRef.current.delete(keyId);
+ setActivatingKeyIds((current) => {
+ const next = new Set(current);
+ next.delete(keyId);
+ return next;
+ });
+ }
+ }
+
if (isLoading) {
return Loading provider keys...
;
}
@@ -416,11 +523,18 @@ export default function ProviderKeysPage() {
) : (
providerKeys.map((key) => {
const isValidating = validatingKeyIds.has(key.id);
- const feedback = validationFeedbackByKey[key.id];
+ const isActivating = activatingKeyIds.has(key.id);
+ const feedback =
+ activationFeedbackByKey[key.id] ?? validationFeedbackByKey[key.id];
const validationDisabled =
isValidating ||
cleanupRequired ||
key.cleanup_state === "cleanup_required";
+ const activationDisabled =
+ isActivating ||
+ cleanupRequired ||
+ key.cleanup_state === "cleanup_required" ||
+ key.is_valid === false;
return (
@@ -452,14 +566,26 @@ export default function ProviderKeysPage() {
: "Not yet validated"}
- void validateKey(key.id)}
- >
- {isValidating ? "Validating..." : "Validate key"}
-
+
+ {!key.is_active ? (
+ void activateKey(key.id)}
+ >
+ {isActivating ? "Activating..." : "Activate key"}
+
+ ) : null}
+ void validateKey(key.id)}
+ >
+ {isValidating ? "Validating..." : "Validate key"}
+
+
{feedback ? (
let requestWasOpaque = true;
let responseWasOpaque = true;
let validationRequests = 0;
+ let activationRequests = 0;
let brandCleanupState: "normal" | "cleanup_required" = "normal";
let validationMode:
| "valid"
@@ -116,6 +117,37 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
return;
}
+ const activationMatch = url.pathname.match(
+ new RegExp(`^/api/v1/brands/${brandId}/keys/([^/]+)/activate$`)
+ );
+ if (activationMatch && request.method() === "PATCH") {
+ activationRequests += 1;
+ const key = savedKeys.find((item) => item.id === activationMatch[1]);
+ if (!key) {
+ await route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "PROVIDER_KEY_NOT_FOUND" } }),
+ });
+ return;
+ }
+ if (key.is_valid === false) {
+ await route.fulfill({
+ status: 409,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "KEY_INVALID" } }),
+ });
+ return;
+ }
+ savedKeys.forEach((item) => {
+ if (item.provider === key.provider) item.is_active = item.id === key.id;
+ });
+ const body = JSON.stringify(key);
+ responseWasOpaque &&= !Object.values(rawKeys).some((rawKey) => body.includes(rawKey));
+ await route.fulfill({ status: 200, contentType: "application/json", body });
+ return;
+ }
+
const validationMatch = url.pathname.match(
new RegExp(`^/api/v1/brands/${brandId}/keys/([^/]+)/validate$`)
);
@@ -214,6 +246,9 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
await expect(openAIKey.getByText("Valid", { exact: true })).toBeVisible();
await expect(openAIKey.getByText(/^Last validated /)).toBeVisible();
await expect(openAIKey.getByRole("status")).toHaveText("The provider accepted this key.");
+ await openAIKey.getByRole("button", { name: "Activate key" }).click();
+ await expect(openAIKey.getByText("Active", { exact: true })).toBeVisible();
+ await expect(openAIKey.getByRole("status")).toHaveText("Key activated.");
const validatedText = await openAIKey.getByText(/^Last validated /).textContent();
validationMode = "unavailable";
@@ -257,10 +292,12 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
await expect(geminiKey.getByText("Invalid", { exact: true })).toBeVisible();
await expect(geminiKey.getByText("Inactive", { exact: true })).toBeVisible();
await expect(geminiKey.getByRole("status")).toHaveText("The provider rejected this key.");
+ await expect(geminiKey.getByRole("button", { name: "Activate key" })).toBeDisabled();
await page.reload();
await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible();
await expect(page.getByText("***A1B2")).toBeVisible();
+ await expect(page.getByText("Active", { exact: true })).toBeVisible();
await page.getByRole("tab", { name: "Gemini" }).click();
await expect(page.getByText("***G3D4")).toBeVisible();
const visibleText = (await page.locator("body").textContent()) ?? "";
@@ -275,6 +312,12 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
storedOpenAIKey.last_validation_error = null;
await page.reload();
await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible();
+ await expect(
+ page
+ .getByRole("article")
+ .filter({ hasText: "Inactive OpenAI" })
+ .getByRole("button", { name: "Activate key" })
+ ).toBeDisabled();
await expect(
page
.getByRole("article")
@@ -286,6 +329,12 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
brandCleanupState = "cleanup_required";
await page.reload();
await expect(page.getByRole("heading", { name: "Provider keys" })).toBeVisible();
+ await expect(
+ page
+ .getByRole("article")
+ .filter({ hasText: "Inactive OpenAI" })
+ .getByRole("button", { name: "Activate key" })
+ ).toBeDisabled();
await expect(
page
.getByRole("article")
@@ -296,6 +345,7 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
expect(submittedKeyBodies === 2).toBeTruthy();
expect(validationRequests).toBe(6);
+ expect(activationRequests).toBe(1);
expect(requestWasOpaque).toBeTruthy();
expect(responseWasOpaque).toBeTruthy();
expect(rawKeyIsVisible).toBeFalsy();
diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md
index a7a59ac..ef7447d 100644
--- a/specs/004-provider-keys/tasks.md
+++ b/specs/004-provider-keys/tasks.md
@@ -121,7 +121,7 @@ for both providers and verify only accepted/explicit-invalid outcomes persist ch
- [X] T031 [US2] Extend `backend/tests/integration/test_provider_key_rls.py` with owner/non-owner/nonexistent validate parity and assertions that direct clients cannot read or mutate validation tokens, lease expiries, Vault IDs, decrypted values, validity fields, or active state
- [X] T032 [US2] Add deadline and secrecy regression cases to `backend/tests/contract/test_provider_keys.py` that consume budget during authentication, pool checkout, lock/Vault access, provider I/O, and completion; assert every leased request settles within 15 seconds and captured logs/errors omit keys, binds, labels, hints, Vault IDs, provider content, headers, exception text, tokens, and PII
- [X] T033 [US2] Extend `frontend/tests/e2e/provider-keys.spec.ts` with mocked valid, explicit-invalid, temporary, timeout, and already-in-progress UI outcomes, including invalid active-key deactivation and preservation of prior status on temporary outcomes
-- [ ] T034 [US2] Execute Quickstart Scenario 2's deterministic steps from `specs/004-provider-keys/quickstart.md` and run `backend/tests/unit/test_provider_validation.py`, the validation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, and `frontend/tests/e2e/provider-keys.spec.ts`
+- [X] T034 [US2] Execute Quickstart Scenario 2's deterministic steps from `specs/004-provider-keys/quickstart.md` and run `backend/tests/unit/test_provider_validation.py`, the validation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_keys.py`, and `frontend/tests/e2e/provider-keys.spec.ts`
**Checkpoint**: Both providers validate safely without generation, false invalidation, or
late-result corruption.
@@ -139,15 +139,15 @@ terminal state must satisfy the database active-key and invalid-inactive constra
### Tests for User Story 3
-- [ ] T035 [P] [US3] Extend `backend/tests/contract/test_provider_keys.py` with failing tests for `PATCH /api/v1/brands/{brand_id}/keys/{key_id}/activate`: successful safe shape, unvalidated eligibility, atomic prior deactivation, other-provider preservation, known-invalid block, key/brand cleanup conflicts, opaque path ownership, and no provider call
-- [ ] T036 [P] [US3] Extend `backend/tests/integration/test_provider_key_cleanup.py` with failing real-Supabase concurrency tests for simultaneous activation of two same-provider keys, provider-independent activation, activation versus invalid validation in both lock orderings, database unique/check invariants, and safe conflict responses without leaked SQL details
+- [X] T035 [P] [US3] Extend `backend/tests/contract/test_provider_keys.py` with failing tests for `PATCH /api/v1/brands/{brand_id}/keys/{key_id}/activate`: successful safe shape, unvalidated eligibility, atomic prior deactivation, other-provider preservation, known-invalid block, key/brand cleanup conflicts, opaque path ownership, and no provider call
+- [X] T036 [P] [US3] Extend `backend/tests/integration/test_provider_key_cleanup.py` with failing real-Supabase concurrency tests for simultaneous activation of two same-provider keys, provider-independent activation, activation versus invalid validation in both lock orderings, database unique/check invariants, and safe conflict responses without leaked SQL details
### Implementation for User Story 3
-- [ ] T037 [US3] Extend `backend/app/services/provider_key_store.py` with atomic activation that locks brand then target key, rejects fenced/known-invalid targets, deactivates only the current active row for the same provider, activates the target in one transaction, and converts uniqueness races to a fixed safe retry outcome while ensuring no completed ordering leaves an invalid key active
-- [ ] T038 [US3] Add the activate handler and fixed `KEY_INVALID`, `KEY_CLEANUP_REQUIRED`, `BRAND_CLEANUP_REQUIRED`, and opaque-not-found mappings to `backend/app/routes/provider_keys.py`
-- [ ] T039 [US3] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with activate controls for eligible inactive keys, disabled controls for invalid/cleanup states, independent provider refresh, fixed conflict feedback, and defensive loading-state cleanup
-- [ ] T040 [US3] Execute Quickstart Scenario 4 steps 1-2 from `specs/004-provider-keys/quickstart.md` and run the activation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_key_cleanup.py`, and `frontend/tests/e2e/provider-keys.spec.ts`
+- [X] T037 [US3] Extend `backend/app/services/provider_key_store.py` with atomic activation that locks brand then target key, rejects fenced/known-invalid targets, deactivates only the current active row for the same provider, activates the target in one transaction, and converts uniqueness races to a fixed safe retry outcome while ensuring no completed ordering leaves an invalid key active
+- [X] T038 [US3] Add the activate handler and fixed `KEY_INVALID`, `KEY_CLEANUP_REQUIRED`, `BRAND_CLEANUP_REQUIRED`, and opaque-not-found mappings to `backend/app/routes/provider_keys.py`
+- [X] T039 [US3] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with activate controls for eligible inactive keys, disabled controls for invalid/cleanup states, independent provider refresh, fixed conflict feedback, and defensive loading-state cleanup
+- [X] T040 [US3] Execute Quickstart Scenario 4 steps 1-2 from `specs/004-provider-keys/quickstart.md` and run the activation cases in `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_provider_key_cleanup.py`, and `frontend/tests/e2e/provider-keys.spec.ts`
**Checkpoint**: Rotation is atomic, provider-independent, and safe under concurrency.
From 6f5561ddc26bbe32f8e78b0cbb6991aaa490ff7f Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 20:52:51 +0300
Subject: [PATCH 12/14] Add repository contributor guide
---
AGENTS.md | 42 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 42 insertions(+)
create mode 100644 AGENTS.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..508742a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,42 @@
+# Repository Guidelines
+
+## Project Structure
+
+- `frontend/` contains the Next.js 15 App Router application and Playwright tests.
+- `backend/` contains the FastAPI service, SQLAlchemy stores/models, and pytest suites.
+- `supabase/migrations/` contains ordered PostgreSQL schema, RLS, Vault, and Storage migrations.
+- `specs/` contains feature specifications, plans, contracts, quickstarts, and task checklists.
+- `docs/` and `scripts/` contain deployment/runbook documentation and container helpers.
+
+## Build, Test, and Development Commands
+
+Run `make help` for the complete command list. Common workflows are:
+
+```bash
+make install # Install frontend and backend dependencies
+make supabase-start # Start the local Supabase/Docker stack
+make dev # Run backend and frontend development servers
+make test-backend # Run backend pytest suites from the repository root
+cd frontend && npm run lint # Run ESLint
+cd frontend && npx tsc --noEmit # Type-check TypeScript
+cd frontend && npx playwright test # Run browser tests
+make build # Build the production container image
+```
+
+Real-Supabase integration tests require exported values from `backend/.env`, including `DATABASE_URL`, and a running local stack.
+
+## Coding Style and Naming
+
+Use four-space indentation for Python and two spaces for TypeScript/TSX. Follow existing FastAPI dependency, Pydantic model, and service-store patterns. Use `snake_case` for Python names and API fields, `PascalCase` for React components/types, and descriptive kebab-case feature directories such as `specs/004-provider-keys/`. Keep secrets, raw provider keys, Vault IDs, and SQL details out of responses and logs. Run ESLint and TypeScript checks before submitting frontend changes.
+
+## Testing Guidelines
+
+Name Python tests `test_*.py` and test functions `test_`. Keep contract tests in `backend/tests/contract/`, unit tests in `backend/tests/unit/`, and real Supabase coverage in `backend/tests/integration/`. Add focused Playwright coverage under `frontend/tests/e2e/`. Run the narrowest relevant tests first, then the full backend suite and frontend checks.
+
+## Commits and Pull Requests
+
+Use concise imperative commit subjects, typically describing the completed feature or fix (for example, `Complete provider key activation`). Pull requests should explain the behavior changed, list verification commands and results, identify migrations or security implications, and include screenshots for meaningful UI changes. Keep spec/task status synchronized with verified work.
+
+## Security and Configuration
+
+Never commit `.env` files, service-role keys, provider credentials, or decrypted secrets. Apply Supabase migrations locally before integration testing, and preserve the repository’s RLS, Vault isolation, ownership, hard-delete, and opaque-error boundaries.
From dad9afb3736eea1352457cb758894808daed73cd Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Tue, 28 Jul 2026 21:33:10 +0300
Subject: [PATCH 13/14] Set explicit Next.js tracing root
---
frontend/next.config.js | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/frontend/next.config.js b/frontend/next.config.js
index 5a85d65..11402c0 100644
--- a/frontend/next.config.js
+++ b/frontend/next.config.js
@@ -1,6 +1,11 @@
/** @type {import('next').NextConfig} */
+// Next config is loaded as CommonJS by the project setup.
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const path = require("path");
+
const nextConfig = {
reactStrictMode: true,
+ outputFileTracingRoot: path.join(__dirname, ".."),
async rewrites() {
const backendUrl = process.env.NEXT_SERVER_API_URL ?? "http://127.0.0.1:8000";
From cbe80d60c4fcb2b63782302609bce544e535193f Mon Sep 17 00:00:00 2001
From: Mohammed Zayan
Date: Wed, 29 Jul 2026 00:09:42 +0300
Subject: [PATCH 14/14] Complete provider key hardening and verification
---
backend/.env.example | 7 +-
backend/app/auth.py | 5 +-
backend/app/config.py | 5 +-
backend/app/models/provider_key.py | 10 +-
backend/app/routes/auth.py | 6 +-
backend/app/routes/brands.py | 232 ++++++----
backend/app/routes/provider_keys.py | 56 +++
backend/app/services/brand_deletion.py | 118 +++++
backend/app/services/brand_storage.py | 139 +++++-
backend/app/services/brand_store.py | 313 ++++++++++---
backend/app/services/provider_key_store.py | 95 ++++
backend/app/services/provider_validation.py | 8 +-
backend/tests/contract/test_brands.py | 258 +++++++++--
backend/tests/contract/test_provider_keys.py | 362 ++++++++++++++-
backend/tests/integration/test_brand_crud.py | 35 +-
backend/tests/integration/test_brand_rls.py | 7 +-
.../integration/test_provider_key_cleanup.py | 418 +++++++++++++++++-
.../integration/test_provider_key_rls.py | 12 +-
.../tests/integration/test_provider_keys.py | 11 +-
backend/tests/unit/test_brand_storage.py | 113 +++++
.../tests/unit/test_provider_validation.py | 37 +-
docs/docker.md | 60 ++-
.../brands/[brandId]/keys/page.tsx | 146 +++++-
.../app/(dashboard)/brands/[brandId]/page.tsx | 52 ++-
frontend/app/(dashboard)/brands/page.tsx | 14 +-
frontend/tests/e2e/provider-keys.spec.ts | 213 +++++++++
specs/004-provider-keys/tasks.md | 44 +-
specs/004-provider-keys/verification.md | 41 ++
28 files changed, 2509 insertions(+), 308 deletions(-)
create mode 100644 backend/app/services/brand_deletion.py
create mode 100644 backend/tests/unit/test_brand_storage.py
create mode 100644 specs/004-provider-keys/verification.md
diff --git a/backend/.env.example b/backend/.env.example
index 9af3bc6..77f1984 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -1,4 +1,9 @@
SUPABASE_URL=http://localhost:54321
SUPABASE_SECRET_KEY=replace-me
SUPABASE_JWT_SECRET=replace-me
-DATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres
+# Private backend-only PostgreSQL login; never expose it to browser/client roles.
+# It must bypass forced RLS (BYPASSRLS, or the local postgres superuser) and have
+# SELECT/INSERT/UPDATE/DELETE on brands, provider_keys, provider_key_idempotency,
+# and brand_asset_operations. Vault access is limited to schema USAGE,
+# create_secret EXECUTE, decrypted id/value SELECT, and secret id SELECT/DELETE.
+DATABASE_URL=
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 1c825ab..0c5129a 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -11,12 +11,15 @@
from .config import load_settings
-@dataclass(frozen=True, slots=True)
+@dataclass(frozen=True, slots=True, repr=False)
class CurrentUser:
user_id: str
email: str | None = None
access_token: str | None = None
+ def __repr__(self) -> str:
+ return ""
+
def _unauthorized(message: str = "Sign in required.") -> HTTPException:
return HTTPException(
diff --git a/backend/app/config.py b/backend/app/config.py
index 51b1742..7301570 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -12,7 +12,7 @@
_DATABASE_TIMEOUT_MILLISECONDS = _DATABASE_TIMEOUT_SECONDS * 1000
-@dataclass(frozen=True, slots=True)
+@dataclass(frozen=True, slots=True, repr=False)
class Settings:
supabase_url: str
supabase_secret_key: str
@@ -20,6 +20,9 @@ class Settings:
database_url: str | None = None
allowed_origins: tuple[str, ...] = ()
+ def __repr__(self) -> str:
+ return ""
+
def load_settings() -> Settings:
origins = os.getenv("ALLOWED_ORIGINS", _DEFAULT_ALLOWED_ORIGINS)
diff --git a/backend/app/models/provider_key.py b/backend/app/models/provider_key.py
index f2e8758..64d120f 100644
--- a/backend/app/models/provider_key.py
+++ b/backend/app/models/provider_key.py
@@ -6,7 +6,7 @@
from typing import Literal
from uuid import UUID
-from pydantic import BaseModel, field_validator, model_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
_KEY_SUFFIX = re.compile(r"[A-Za-z0-9_-]{4}$")
@@ -42,8 +42,8 @@ class ProviderValidationCode(str, Enum):
class ProviderKeyAdd(BaseModel):
provider: Provider
- key: str
- label: str | None = None
+ key: str = Field(repr=False)
+ label: str | None = Field(default=None, repr=False)
make_active: bool = True
@field_validator("key")
@@ -73,8 +73,8 @@ def reject_key_in_label(self) -> ProviderKeyAdd:
class ProviderKey(BaseModel):
id: UUID
provider: Provider
- label: str | None
- key_hint: str
+ label: str | None = Field(repr=False)
+ key_hint: str = Field(repr=False)
is_active: bool
is_valid: bool | None
last_validated_at: datetime | None
diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py
index a32fcfd..452a054 100644
--- a/backend/app/routes/auth.py
+++ b/backend/app/routes/auth.py
@@ -5,7 +5,7 @@
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from ..config import load_settings
from ..services.login_guard import LoginGuard, get_login_guard
@@ -16,8 +16,8 @@
class LoginRequest(BaseModel):
- email: str
- password: str
+ email: str = Field(repr=False)
+ password: str = Field(repr=False)
class InvalidCredentialsError(Exception):
diff --git a/backend/app/routes/brands.py b/backend/app/routes/brands.py
index 8d30e45..d8c20a3 100644
--- a/backend/app/routes/brands.py
+++ b/backend/app/routes/brands.py
@@ -8,12 +8,20 @@
from ..auth import CurrentUserDep
from ..models.brand import Brand, BrandCreate, BrandDelete, BrandListResponse
+from ..services.brand_deletion import (
+ BrandConfirmationMismatchError,
+ BrandDeletion,
+ BrandDeletionCleanupError,
+ get_brand_deletion,
+)
from ..services.brand_storage import (
BrandStorage,
BrandStorageError,
+ BrandStorageUnknownError,
get_brand_storage,
)
from ..services.brand_store import (
+ BrandAssetOperationStaleError,
BrandCleanupRequiredError,
BrandMutationInProgressError,
BrandNameTakenError,
@@ -27,6 +35,7 @@
BrandStoreDep = Annotated[BrandStore, Depends(get_brand_store)]
BrandStorageDep = Annotated[BrandStorage, Depends(get_brand_storage)]
+BrandDeletionDep = Annotated[BrandDeletion, Depends(get_brand_deletion)]
MAX_LOGO_BYTES = 5 * 1024 * 1024
LOGO_TYPES = {
@@ -96,7 +105,7 @@ def _mutation_in_progress() -> HTTPException:
def _mutation_error(exc: Exception) -> HTTPException:
if isinstance(exc, BrandCleanupRequiredError):
return _cleanup_required()
- if isinstance(exc, BrandMutationInProgressError):
+ if isinstance(exc, (BrandMutationInProgressError, BrandAssetOperationStaleError)):
return _mutation_in_progress()
return _not_found()
@@ -141,6 +150,31 @@ def _has_valid_signature(content_type: str, data: bytes) -> bool:
return False
+async def _reconcile_asset_operation(
+ user_id: str,
+ brand_id: UUID,
+ brand_store: BrandStore,
+ brand_storage: BrandStorage,
+) -> None:
+ operation = brand_store.get_asset_operation(user_id, brand_id)
+ if operation is None:
+ return
+ if operation.remote_status == "failed":
+ brand_store.abandon_definitive_asset_failure(
+ user_id, brand_id, operation.id
+ )
+ return
+ if operation.remote_status != "succeeded":
+ raise BrandMutationInProgressError
+ if operation.operation == "upload" and operation.previous_path:
+ try:
+ await brand_storage.delete_logo(operation.previous_path)
+ except BrandStorageError as exc:
+ brand_store.mark_asset_cleanup_required(user_id, brand_id, operation.id)
+ raise _storage_unavailable() from exc
+ brand_store.complete_asset_operation(user_id, brand_id, operation.id)
+
+
@router.get("", response_model=BrandListResponse)
def list_brands(
request: Request,
@@ -207,48 +241,30 @@ async def delete_brand(
request: Request,
brand_id: UUID,
current_user: CurrentUserDep,
- brand_store: BrandStoreDep,
- brand_storage: BrandStorageDep,
+ brand_deletion: BrandDeletionDep,
payload: BrandDelete | None = None,
) -> Response:
+ if payload is None:
+ raise _confirmation_mismatch()
try:
- brand = brand_store.get_brand(current_user.user_id, brand_id)
+ await brand_deletion.delete(
+ current_user.user_id, brand_id, payload.confirm_name
+ )
except LookupError as exc:
raise _not_found() from exc
-
- if payload is None or payload.confirm_name != brand.name:
- raise _confirmation_mismatch()
-
- try:
- logo_path = brand_store.begin_brand_cleanup(current_user.user_id, brand_id)
- except (
- LookupError,
- BrandCleanupRequiredError,
- BrandMutationInProgressError,
- ) as exc:
- raise _mutation_error(exc) from exc
-
- if logo_path:
- try:
- await brand_storage.delete_logo(logo_path)
- except BrandStorageError as exc:
- logger.warning(
- "brands.delete_logo_cleanup_failed",
- extra={
- "event": "brands.delete_logo_cleanup_failed",
- "request_id": getattr(request.state, "request_id", "unknown"),
- },
- )
- raise _cleanup_failed() from exc
-
- try:
- brand_store.delete_brand_after_cleanup(current_user.user_id, brand_id)
- except (
- LookupError,
- BrandCleanupRequiredError,
- BrandMutationInProgressError,
- ) as exc:
- raise _mutation_error(exc) from exc
+ except BrandConfirmationMismatchError as exc:
+ raise _confirmation_mismatch() from exc
+ except BrandMutationInProgressError as exc:
+ raise _mutation_in_progress() from exc
+ except BrandDeletionCleanupError as exc:
+ logger.warning(
+ "brands.delete_cleanup_failed",
+ extra={
+ "event": "brands.delete_cleanup_failed",
+ "request_id": getattr(request.state, "request_id", "unknown"),
+ },
+ )
+ raise _cleanup_failed() from exc
logger.info(
"brands.delete_success",
@@ -270,7 +286,7 @@ async def upload_brand_logo(
file: Annotated[UploadFile, File()],
) -> Brand:
try:
- old_path = brand_store.get_logo_path(current_user.user_id, brand_id)
+ brand_store.get_logo_path(current_user.user_id, brand_id)
except (
LookupError,
BrandCleanupRequiredError,
@@ -278,6 +294,13 @@ async def upload_brand_logo(
) as exc:
raise _mutation_error(exc) from exc
+ try:
+ await _reconcile_asset_operation(
+ current_user.user_id, brand_id, brand_store, brand_storage
+ )
+ except (BrandMutationInProgressError, BrandAssetOperationStaleError) as exc:
+ raise _mutation_error(exc) from exc
+
content_type = file.content_type or ""
extension = LOGO_TYPES.get(content_type)
if extension is None:
@@ -294,48 +317,67 @@ async def upload_brand_logo(
if not _has_valid_signature(content_type, data):
raise _unsupported_media_type()
- new_path = f"brands/{brand_id}/logo.{extension}"
try:
- await brand_storage.upload_logo(new_path, data, content_type)
+ operation = brand_store.begin_logo_upload(
+ current_user.user_id, brand_id, extension
+ )
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ BrandMutationInProgressError,
+ ) as exc:
+ raise _mutation_error(exc) from exc
+
+ assert operation.object_path is not None
+ try:
+ await brand_storage.upload_logo(operation.object_path, data, content_type)
+ except BrandStorageUnknownError as exc:
+ brand_store.record_asset_remote_status(
+ current_user.user_id, brand_id, operation.id, "unknown"
+ )
+ raise _storage_unavailable() from exc
except BrandStorageError as exc:
+ brand_store.record_asset_remote_status(
+ current_user.user_id, brand_id, operation.id, "failed"
+ )
+ brand_store.abandon_definitive_asset_failure(
+ current_user.user_id, brand_id, operation.id
+ )
raise _storage_unavailable() from exc
try:
- brand = brand_store.update_logo_path(
- current_user.user_id,
- brand_id,
- new_path,
+ brand_store.record_asset_remote_status(
+ current_user.user_id, brand_id, operation.id, "succeeded"
+ )
+ brand = brand_store.publish_uploaded_logo(
+ current_user.user_id, brand_id, operation.id
)
- except Exception as exc:
- if new_path != old_path:
- try:
- await brand_storage.delete_logo(new_path)
- except BrandStorageError:
- logger.warning(
- "brands.logo_rollback_failed",
- extra={
- "event": "brands.logo_rollback_failed",
- "request_id": getattr(request.state, "request_id", "unknown"),
- },
- )
- if isinstance(
- exc,
- (LookupError, BrandCleanupRequiredError, BrandMutationInProgressError),
- ):
- raise _mutation_error(exc) from exc
- raise
-
- if old_path and old_path != new_path:
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ BrandAssetOperationStaleError,
+ ) as exc:
+ raise _mutation_error(exc) from exc
+
+ if operation.previous_path:
try:
- await brand_storage.delete_logo(old_path)
- except BrandStorageError:
- logger.warning(
- "brands.logo_cleanup_failed",
- extra={
- "event": "brands.logo_cleanup_failed",
- "request_id": getattr(request.state, "request_id", "unknown"),
- },
+ await brand_storage.delete_logo(operation.previous_path)
+ except BrandStorageError as exc:
+ brand_store.mark_asset_cleanup_required(
+ current_user.user_id, brand_id, operation.id
)
+ raise _storage_unavailable() from exc
+
+ try:
+ brand_store.complete_asset_operation(
+ current_user.user_id, brand_id, operation.id
+ )
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ BrandAssetOperationStaleError,
+ ) as exc:
+ raise _mutation_error(exc) from exc
logger.info(
"brands.logo_upload_success",
@@ -356,7 +398,11 @@ async def delete_brand_logo(
brand_storage: BrandStorageDep,
) -> Response:
try:
- old_path = brand_store.get_logo_path(current_user.user_id, brand_id)
+ brand_store.get_logo_path(current_user.user_id, brand_id)
+ await _reconcile_asset_operation(
+ current_user.user_id, brand_id, brand_store, brand_storage
+ )
+ operation = brand_store.begin_logo_remove(current_user.user_id, brand_id)
except (
LookupError,
BrandCleanupRequiredError,
@@ -364,27 +410,39 @@ async def delete_brand_logo(
) as exc:
raise _mutation_error(exc) from exc
- if old_path is None:
+ if operation is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
+ assert operation.object_path is not None
+ try:
+ await brand_storage.delete_logo(operation.object_path)
+ except BrandStorageUnknownError as exc:
+ brand_store.record_asset_remote_status(
+ current_user.user_id, brand_id, operation.id, "unknown"
+ )
+ raise _storage_unavailable() from exc
+ except BrandStorageError as exc:
+ brand_store.record_asset_remote_status(
+ current_user.user_id, brand_id, operation.id, "failed"
+ )
+ brand_store.abandon_definitive_asset_failure(
+ current_user.user_id, brand_id, operation.id
+ )
+ raise _storage_unavailable() from exc
+
+ brand_store.record_asset_remote_status(
+ current_user.user_id, brand_id, operation.id, "succeeded"
+ )
try:
- brand_store.update_logo_path(current_user.user_id, brand_id, None)
+ brand_store.complete_asset_operation(
+ current_user.user_id, brand_id, operation.id
+ )
except (
LookupError,
BrandCleanupRequiredError,
- BrandMutationInProgressError,
+ BrandAssetOperationStaleError,
) as exc:
raise _mutation_error(exc) from exc
- try:
- await brand_storage.delete_logo(old_path)
- except BrandStorageError:
- logger.warning(
- "brands.logo_cleanup_failed",
- extra={
- "event": "brands.logo_cleanup_failed",
- "request_id": getattr(request.state, "request_id", "unknown"),
- },
- )
logger.info(
"brands.logo_delete_success",
diff --git a/backend/app/routes/provider_keys.py b/backend/app/routes/provider_keys.py
index 7633048..7f501fa 100644
--- a/backend/app/routes/provider_keys.py
+++ b/backend/app/routes/provider_keys.py
@@ -112,6 +112,26 @@ def _activation_error(exc: Exception) -> HTTPException:
)
+def _deletion_error(exc: Exception) -> HTTPException:
+ if isinstance(exc, LookupError):
+ return _error(
+ status.HTTP_404_NOT_FOUND,
+ "PROVIDER_KEY_NOT_FOUND",
+ "Provider key not found.",
+ )
+ if isinstance(exc, BrandCleanupRequiredError):
+ return _error(
+ status.HTTP_409_CONFLICT,
+ "BRAND_CLEANUP_REQUIRED",
+ "Brand cleanup is required. Retry deletion.",
+ )
+ return _error(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "KEY_CLEANUP_REQUIRED",
+ "Key cleanup did not complete. Retry deletion.",
+ )
+
+
async def _within_deadline(awaitable: Awaitable[_T], deadline: float) -> _T:
remaining = max(0.0, deadline - time.monotonic())
return await asyncio.wait_for(awaitable, timeout=remaining)
@@ -269,6 +289,42 @@ def activate_provider_key(
return key
+@router.delete(
+ "/{brand_id}/keys/{key_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+def delete_provider_key(
+ request: Request,
+ brand_id: UUID,
+ key_id: UUID,
+ current_user: CurrentUserDep,
+ provider_key_store: ProviderKeyStoreDep,
+) -> None:
+ try:
+ provider_key_store.delete_key(current_user.user_id, brand_id, key_id)
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ KeyCleanupRequiredError,
+ ) as exc:
+ logger.warning(
+ "provider_keys.delete_retry",
+ extra={
+ "event": "provider_keys.delete_retry",
+ "request_id": getattr(request.state, "request_id", "unknown"),
+ },
+ )
+ raise _deletion_error(exc) from exc
+
+ logger.info(
+ "provider_keys.delete_success",
+ extra={
+ "event": "provider_keys.delete_success",
+ "request_id": getattr(request.state, "request_id", "unknown"),
+ },
+ )
+
+
@router.post(
"/{brand_id}/keys/{key_id}/validate",
response_model=ProviderKeyValidationResponse,
diff --git a/backend/app/services/brand_deletion.py b/backend/app/services/brand_deletion.py
new file mode 100644
index 0000000..83dccd9
--- /dev/null
+++ b/backend/app/services/brand_deletion.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import lru_cache
+from uuid import UUID
+
+from sqlalchemy import text
+from sqlalchemy.engine import Engine
+from sqlalchemy.exc import SQLAlchemyError
+
+from ..config import get_engine
+from .brand_storage import BrandStorage, BrandStorageError, get_brand_storage
+from .brand_store import BrandStore, get_brand_store
+
+
+class BrandConfirmationMismatchError(Exception):
+ pass
+
+
+class BrandDeletionCleanupError(Exception):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class BrandDeletion:
+ engine: Engine
+ brand_store: BrandStore
+ storage: BrandStorage
+
+ async def delete(self, user_id: str, brand_id: UUID, confirm_name: str) -> None:
+ try:
+ self.brand_store.fence_brand_for_deletion(
+ user_id, brand_id, confirm_name
+ )
+ except ValueError as exc:
+ raise BrandConfirmationMismatchError from exc
+
+ try:
+ await self.storage.delete_brand_prefix(brand_id)
+ if not await self.storage.brand_prefix_is_empty(brand_id):
+ raise BrandStorageError
+ except BrandStorageError as exc:
+ raise BrandDeletionCleanupError from exc
+
+ try:
+ self._delete_database_dependencies(user_id, brand_id)
+ except (LookupError, BrandDeletionCleanupError):
+ raise
+ except SQLAlchemyError as exc:
+ raise BrandDeletionCleanupError from exc
+
+ def _delete_database_dependencies(self, user_id: str, brand_id: UUID) -> None:
+ with self.engine.begin() as connection:
+ brand = BrandStore.lock_owned_brand(connection, user_id, brand_id)
+ BrandStore.require_no_asset_operation(connection, brand_id)
+ if brand["deletion_state"] != "cleanup_required":
+ raise BrandDeletionCleanupError
+ secret_ids = connection.execute(
+ text(
+ "SELECT vault_secret_id FROM provider_keys "
+ "WHERE brand_id = :brand_id FOR UPDATE"
+ ),
+ {"brand_id": brand_id},
+ ).scalars().all()
+ if secret_ids:
+ connection.execute(
+ text("DELETE FROM vault.secrets WHERE id = ANY(:secret_ids)"),
+ {"secret_ids": list(secret_ids)},
+ )
+ remaining_secrets = connection.execute(
+ text(
+ "SELECT count(*) FROM vault.secrets "
+ "WHERE id = ANY(:secret_ids)"
+ ),
+ {"secret_ids": list(secret_ids)},
+ ).scalar_one()
+ if remaining_secrets:
+ raise BrandDeletionCleanupError
+ connection.execute(
+ text("DELETE FROM provider_key_idempotency WHERE brand_id = :brand_id"),
+ {"brand_id": brand_id},
+ )
+ connection.execute(
+ text("DELETE FROM provider_keys WHERE brand_id = :brand_id"),
+ {"brand_id": brand_id},
+ )
+ remaining = connection.execute(
+ text(
+ """
+ SELECT
+ (SELECT count(*) FROM provider_keys WHERE brand_id = :brand_id) +
+ (SELECT count(*) FROM provider_key_idempotency WHERE brand_id = :brand_id) +
+ (SELECT count(*) FROM brand_asset_operations WHERE brand_id = :brand_id)
+ """
+ ),
+ {"brand_id": brand_id},
+ ).scalar_one()
+ if remaining:
+ raise BrandDeletionCleanupError
+ deleted = connection.execute(
+ text(
+ "DELETE FROM brands WHERE id = :brand_id "
+ "AND owner_user_id = :user_id RETURNING id"
+ ),
+ {"brand_id": brand_id, "user_id": user_id},
+ ).scalar_one_or_none()
+ if deleted is None:
+ raise LookupError("Brand not found.")
+ if connection.execute(
+ text("SELECT EXISTS (SELECT 1 FROM brands WHERE id = :brand_id)"),
+ {"brand_id": brand_id},
+ ).scalar_one():
+ raise BrandDeletionCleanupError
+
+
+@lru_cache(maxsize=1)
+def get_brand_deletion() -> BrandDeletion:
+ return BrandDeletion(get_engine(), get_brand_store(), get_brand_storage())
diff --git a/backend/app/services/brand_storage.py b/backend/app/services/brand_storage.py
index 5764f7e..3d76637 100644
--- a/backend/app/services/brand_storage.py
+++ b/backend/app/services/brand_storage.py
@@ -3,6 +3,7 @@
from dataclasses import dataclass
from functools import lru_cache
from urllib.parse import quote
+from uuid import UUID
import httpx
@@ -10,13 +11,25 @@
class BrandStorageError(Exception):
- pass
+ """A Storage operation did not complete safely."""
-@dataclass(frozen=True, slots=True)
+class BrandStorageUnknownError(BrandStorageError):
+ """The remote outcome may have been committed and must remain fenced."""
+
+
+@dataclass(frozen=True, slots=True, repr=False)
class BrandStorage:
supabase_url: str
secret_key: str
+ transport: httpx.AsyncBaseTransport | None = None
+
+ PAGE_SIZE = 100
+ DELETE_BATCH_SIZE = 100
+ MAX_CLEANUP_BATCHES = 1_000
+
+ def __repr__(self) -> str:
+ return ""
@property
def _headers(self) -> dict[str, str]:
@@ -25,6 +38,13 @@ def _headers(self) -> dict[str, str]:
"Authorization": f"Bearer {self.secret_key}",
}
+ @staticmethod
+ def logo_path(brand_id: UUID, operation_id: UUID, extension: str) -> str:
+ return f"brands/{brand_id}/logos/{operation_id}.{extension}"
+
+ def _client(self) -> httpx.AsyncClient:
+ return httpx.AsyncClient(timeout=30.0, transport=self.transport)
+
async def upload_logo(self, path: str, data: bytes, content_type: str) -> None:
encoded_path = quote(path, safe="/")
url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/brand-assets/{encoded_path}"
@@ -32,36 +52,129 @@ async def upload_logo(self, path: str, data: bytes, content_type: str) -> None:
**self._headers,
"Content-Type": content_type,
"Cache-Control": "no-cache",
- "x-upsert": "true",
+ "x-upsert": "false",
}
-
try:
- async with httpx.AsyncClient(timeout=30.0) as client:
+ async with self._client() as client:
response = await client.post(url, headers=headers, content=data)
except (httpx.TimeoutException, httpx.RequestError) as exc:
- raise BrandStorageError from exc
+ raise BrandStorageUnknownError from exc
+ if not response.is_success:
+ if response.status_code >= 500:
+ raise BrandStorageUnknownError
+ raise BrandStorageError
+ async def object_exists(self, path: str) -> bool:
+ encoded_path = quote(path, safe="/")
+ url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/brand-assets/{encoded_path}"
+ try:
+ async with self._client() as client:
+ response = await client.head(url, headers=self._headers)
+ except (httpx.TimeoutException, httpx.RequestError) as exc:
+ raise BrandStorageUnknownError from exc
+ if response.status_code in {400, 404}:
+ return False
if not response.is_success:
+ if response.status_code >= 500:
+ raise BrandStorageUnknownError
raise BrandStorageError
+ return True
async def delete_logo(self, path: str) -> None:
- url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/brand-assets"
- headers = {**self._headers, "Content-Type": "application/json"}
+ await self._delete_paths([path])
+ if await self.object_exists(path):
+ raise BrandStorageError
+ async def _list_directory(
+ self, prefix: str, offset: int = 0
+ ) -> list[dict[str, object]]:
+ url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/list/brand-assets"
+ payload = {
+ "prefix": prefix.rstrip("/"),
+ "limit": self.PAGE_SIZE,
+ "offset": offset,
+ "sortBy": {"column": "name", "order": "asc"},
+ }
try:
- async with httpx.AsyncClient(timeout=30.0) as client:
+ async with self._client() as client:
+ response = await client.post(
+ url,
+ headers={**self._headers, "Content-Type": "application/json"},
+ json=payload,
+ )
+ except (httpx.TimeoutException, httpx.RequestError) as exc:
+ raise BrandStorageUnknownError from exc
+ if not response.is_success:
+ if response.status_code >= 500:
+ raise BrandStorageUnknownError
+ raise BrandStorageError
+ value = response.json()
+ if not isinstance(value, list):
+ raise BrandStorageError
+ return value
+
+ async def _list_prefix_page(self, root: str) -> list[str]:
+ objects: list[str] = []
+ directories = [(root.rstrip("/"), 0)]
+ visited: set[tuple[str, int]] = set()
+ while directories and len(objects) < self.PAGE_SIZE:
+ directory, offset = directories.pop()
+ page_key = (directory, offset)
+ if page_key in visited:
+ continue
+ visited.add(page_key)
+ items = await self._list_directory(directory, offset)
+ if len(items) == self.PAGE_SIZE:
+ directories.append((directory, offset + self.PAGE_SIZE))
+ for item in items:
+ name = item.get("name")
+ if not isinstance(name, str) or not name or "/" in name:
+ raise BrandStorageError
+ path = f"{directory}/{name}"
+ if item.get("metadata") is None:
+ directories.append((path, 0))
+ else:
+ objects.append(path)
+ if len(objects) == self.PAGE_SIZE:
+ break
+ return objects
+
+ async def _delete_paths(self, paths: list[str]) -> None:
+ if not paths:
+ return
+ url = f"{self.supabase_url.rstrip('/')}/storage/v1/object/brand-assets"
+ try:
+ async with self._client() as client:
response = await client.request(
"DELETE",
url,
- headers=headers,
- json={"prefixes": [path]},
+ headers={**self._headers, "Content-Type": "application/json"},
+ json={"prefixes": paths},
)
except (httpx.TimeoutException, httpx.RequestError) as exc:
- raise BrandStorageError from exc
-
+ raise BrandStorageUnknownError from exc
if not response.is_success:
+ if response.status_code >= 500:
+ raise BrandStorageUnknownError
raise BrandStorageError
+ async def delete_brand_prefix(self, brand_id: UUID) -> None:
+ prefix = f"brands/{brand_id}"
+ for _ in range(self.MAX_CLEANUP_BATCHES):
+ paths = await self._list_prefix_page(prefix)
+ if not paths:
+ # A separate final read prevents a transient empty page from authorizing
+ # physical deletion.
+ if await self._list_prefix_page(prefix):
+ continue
+ return
+ for start in range(0, len(paths), self.DELETE_BATCH_SIZE):
+ await self._delete_paths(paths[start : start + self.DELETE_BATCH_SIZE])
+ raise BrandStorageError
+
+ async def brand_prefix_is_empty(self, brand_id: UUID) -> bool:
+ return not await self._list_prefix_page(f"brands/{brand_id}")
+
@lru_cache(maxsize=1)
def get_brand_storage() -> BrandStorage:
diff --git a/backend/app/services/brand_store.py b/backend/app/services/brand_store.py
index c9419dd..3e8b76d 100644
--- a/backend/app/services/brand_store.py
+++ b/backend/app/services/brand_store.py
@@ -4,7 +4,7 @@
from dataclasses import dataclass
from functools import lru_cache
from typing import Any
-from uuid import UUID
+from uuid import UUID, uuid4
from sqlalchemy import text
from sqlalchemy.engine import Connection, Engine
@@ -26,6 +26,21 @@ class BrandMutationInProgressError(Exception):
pass
+class BrandAssetOperationStaleError(Exception):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class BrandAssetOperation:
+ id: UUID
+ brand_id: UUID
+ operation: str
+ object_path: str | None
+ previous_path: str | None
+ state: str
+ remote_status: str
+
+
@dataclass(frozen=True, slots=True)
class BrandStore:
engine: Engine
@@ -171,113 +186,271 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand:
def get_logo_path(self, user_id: str, brand_id: UUID) -> str | None:
with self.engine.begin() as connection:
- row = self.lock_owned_brand_without_asset_operation(
- connection, user_id, brand_id
- )
+ row = self.lock_owned_brand_for_mutation(connection, user_id, brand_id)
return row["logo_path"]
- def begin_brand_cleanup(self, user_id: str, brand_id: UUID) -> str | None:
+ def get_asset_operation(
+ self, user_id: str, brand_id: UUID
+ ) -> BrandAssetOperation | None:
with self.engine.begin() as connection:
- row = self.lock_owned_brand(connection, user_id, brand_id)
- self.require_no_asset_operation(connection, brand_id)
- if row["deletion_state"] == "active":
- row = connection.execute(
- text(
- """
- UPDATE brands
- SET deletion_state = 'cleanup_required'
- WHERE id = :brand_id AND owner_user_id = :owner_user_id
- RETURNING id, name, logo_path, deletion_state, created_at
- """
- ),
- {"brand_id": brand_id, "owner_user_id": user_id},
- ).mappings().one_or_none()
+ self.lock_owned_brand(connection, user_id, brand_id)
+ connection.execute(
+ text(
+ """
+ UPDATE brand_asset_operations
+ SET state = 'cleanup_required', remote_status = 'unknown'
+ WHERE brand_id = :brand_id AND remote_status = 'pending'
+ AND started_at < clock_timestamp() - interval '5 minutes'
+ """
+ ),
+ {"brand_id": brand_id},
+ )
+ row = connection.execute(
+ text(
+ """
+ SELECT id, brand_id, operation, object_path, previous_path,
+ state, remote_status
+ FROM brand_asset_operations
+ WHERE brand_id = :brand_id
+ FOR UPDATE
+ """
+ ),
+ {"brand_id": brand_id},
+ ).mappings().one_or_none()
+ return BrandAssetOperation(**row) if row is not None else None
- if row is None:
- raise LookupError("Brand not found.")
- return row["logo_path"]
+ def begin_logo_upload(
+ self, user_id: str, brand_id: UUID, extension: str
+ ) -> BrandAssetOperation:
+ operation_id = uuid4()
+ object_path = f"brands/{brand_id}/logos/{operation_id}.{extension}"
+ with self.engine.begin() as connection:
+ brand = self.lock_owned_brand_without_asset_operation(
+ connection, user_id, brand_id
+ )
+ row = connection.execute(
+ text(
+ """
+ INSERT INTO brand_asset_operations (
+ id, brand_id, operation, object_path, previous_path,
+ state, remote_status
+ ) VALUES (
+ :id, :brand_id, 'upload', :object_path, :previous_path,
+ 'in_progress', 'pending'
+ )
+ RETURNING id, brand_id, operation, object_path, previous_path,
+ state, remote_status
+ """
+ ),
+ {
+ "id": operation_id,
+ "brand_id": brand_id,
+ "object_path": object_path,
+ "previous_path": brand["logo_path"],
+ },
+ ).mappings().one()
+ return BrandAssetOperation(**row)
- def update_logo_path(
- self,
- user_id: str,
- brand_id: UUID,
- logo_path: str | None,
- ) -> Brand:
+ def begin_logo_remove(
+ self, user_id: str, brand_id: UUID
+ ) -> BrandAssetOperation | None:
+ operation_id = uuid4()
with self.engine.begin() as connection:
- self.lock_owned_brand_without_asset_operation(
+ brand = self.lock_owned_brand_without_asset_operation(
connection, user_id, brand_id
)
+ if brand["logo_path"] is None:
+ return None
row = connection.execute(
text(
"""
- UPDATE brands
- SET logo_path = :logo_path
- WHERE id = :brand_id AND owner_user_id = :owner_user_id
- RETURNING id, name, logo_path, deletion_state, created_at
+ INSERT INTO brand_asset_operations (
+ id, brand_id, operation, object_path, previous_path,
+ state, remote_status
+ ) VALUES (
+ :id, :brand_id, 'remove', :object_path, NULL,
+ 'in_progress', 'pending'
+ )
+ RETURNING id, brand_id, operation, object_path, previous_path,
+ state, remote_status
"""
),
{
+ "id": operation_id,
"brand_id": brand_id,
- "owner_user_id": user_id,
- "logo_path": logo_path,
+ "object_path": brand["logo_path"],
},
- ).mappings().one_or_none()
+ ).mappings().one()
+ return BrandAssetOperation(**row)
- if row is None:
- raise LookupError("Brand not found.")
- return self._to_brand(row)
+ @staticmethod
+ def _lock_operation(
+ connection: Connection, brand_id: UUID, operation_id: UUID
+ ) -> Mapping[str, Any]:
+ operation = connection.execute(
+ text(
+ """
+ SELECT id, brand_id, operation, object_path, previous_path,
+ state, remote_status
+ FROM brand_asset_operations
+ WHERE brand_id = :brand_id AND id = :operation_id
+ FOR UPDATE
+ """
+ ),
+ {"brand_id": brand_id, "operation_id": operation_id},
+ ).mappings().one_or_none()
+ if operation is None:
+ raise BrandAssetOperationStaleError
+ return operation
- def mark_cleanup_required(self, user_id: str, brand_id: UUID) -> Brand:
+ def record_asset_remote_status(
+ self,
+ user_id: str,
+ brand_id: UUID,
+ operation_id: UUID,
+ remote_status: str,
+ ) -> None:
+ if remote_status not in {"succeeded", "failed", "unknown"}:
+ raise ValueError("Invalid remote status.")
with self.engine.begin() as connection:
- current = self.lock_owned_brand(connection, user_id, brand_id)
- self.require_no_asset_operation(connection, brand_id)
- if current["deletion_state"] == "cleanup_required":
- return self._to_brand(current)
+ self.lock_owned_brand(connection, user_id, brand_id)
+ self._lock_operation(connection, brand_id, operation_id)
+ connection.execute(
+ text(
+ """
+ UPDATE brand_asset_operations
+ SET remote_status = :remote_status,
+ state = CASE WHEN :remote_status = 'succeeded'
+ THEN state ELSE 'cleanup_required' END
+ WHERE id = :operation_id
+ """
+ ),
+ {"operation_id": operation_id, "remote_status": remote_status},
+ )
+
+ def abandon_definitive_asset_failure(
+ self, user_id: str, brand_id: UUID, operation_id: UUID
+ ) -> None:
+ with self.engine.begin() as connection:
+ self.lock_owned_brand(connection, user_id, brand_id)
+ operation = self._lock_operation(connection, brand_id, operation_id)
+ if operation["remote_status"] != "failed":
+ raise BrandAssetOperationStaleError
+ connection.execute(
+ text("DELETE FROM brand_asset_operations WHERE id = :operation_id"),
+ {"operation_id": operation_id},
+ )
+
+ def mark_asset_cleanup_required(
+ self, user_id: str, brand_id: UUID, operation_id: UUID
+ ) -> None:
+ with self.engine.begin() as connection:
+ self.lock_owned_brand(connection, user_id, brand_id)
+ operation = self._lock_operation(connection, brand_id, operation_id)
+ if operation["remote_status"] != "succeeded":
+ raise BrandAssetOperationStaleError
+ connection.execute(
+ text(
+ "UPDATE brand_asset_operations SET state = 'cleanup_required' "
+ "WHERE id = :operation_id"
+ ),
+ {"operation_id": operation_id},
+ )
+
+ def publish_uploaded_logo(
+ self, user_id: str, brand_id: UUID, operation_id: UUID
+ ) -> Brand:
+ with self.engine.begin() as connection:
+ brand = self.lock_owned_brand_for_mutation(connection, user_id, brand_id)
+ operation = self._lock_operation(connection, brand_id, operation_id)
+ if (
+ operation["operation"] != "upload"
+ or operation["remote_status"] != "succeeded"
+ ):
+ raise BrandAssetOperationStaleError
row = connection.execute(
text(
"""
- UPDATE brands
- SET deletion_state = 'cleanup_required'
- WHERE id = :brand_id AND owner_user_id = :owner_user_id
+ UPDATE brands SET logo_path = :logo_path
+ WHERE id = :brand_id AND deletion_state = 'active'
RETURNING id, name, logo_path, deletion_state, created_at
"""
),
- {"brand_id": brand_id, "owner_user_id": user_id},
+ {"brand_id": brand_id, "logo_path": operation["object_path"]},
).mappings().one_or_none()
-
- if row is None:
- raise LookupError("Brand not found.")
+ if row is None:
+ raise BrandAssetOperationStaleError
return self._to_brand(row)
- def delete_brand_after_cleanup(self, user_id: str, brand_id: UUID) -> None:
+ def complete_asset_operation(
+ self, user_id: str, brand_id: UUID, operation_id: UUID
+ ) -> None:
+ with self.engine.begin() as connection:
+ brand = self.lock_owned_brand_for_mutation(connection, user_id, brand_id)
+ operation = self._lock_operation(connection, brand_id, operation_id)
+ if operation["remote_status"] != "succeeded":
+ raise BrandAssetOperationStaleError
+ if operation["operation"] == "upload":
+ if brand["logo_path"] != operation["object_path"]:
+ raise BrandAssetOperationStaleError
+ elif brand["logo_path"] != operation["object_path"]:
+ raise BrandAssetOperationStaleError
+ else:
+ connection.execute(
+ text("UPDATE brands SET logo_path = NULL WHERE id = :brand_id"),
+ {"brand_id": brand_id},
+ )
+ connection.execute(
+ text("DELETE FROM brand_asset_operations WHERE id = :operation_id"),
+ {"operation_id": operation_id},
+ )
+
+ def mark_abandoned_asset_operations_unknown(self, brand_id: UUID) -> int:
+ with self.engine.begin() as connection:
+ result = connection.execute(
+ text(
+ """
+ UPDATE brand_asset_operations
+ SET state = 'cleanup_required', remote_status = 'unknown'
+ WHERE brand_id = :brand_id AND remote_status = 'pending'
+ AND started_at < clock_timestamp() - interval '5 minutes'
+ """
+ ),
+ {"brand_id": brand_id},
+ )
+ return result.rowcount
+
+ def fence_brand_for_deletion(
+ self, user_id: str, brand_id: UUID, confirm_name: str
+ ) -> None:
with self.engine.begin() as connection:
- current = self.lock_owned_brand(connection, user_id, brand_id)
+ brand = self.lock_owned_brand(connection, user_id, brand_id)
+ if brand["name"] != confirm_name:
+ raise ValueError("Confirmation mismatch.")
self.require_no_asset_operation(connection, brand_id)
- if current["deletion_state"] != "cleanup_required":
- raise BrandCleanupRequiredError
- has_provider_keys = connection.execute(
+ connection.execute(
text(
- "SELECT EXISTS ("
- "SELECT 1 FROM provider_keys WHERE brand_id = :brand_id"
- ")"
+ """
+ UPDATE brands SET deletion_state = 'cleanup_required'
+ WHERE id = :brand_id AND deletion_state = 'active'
+ """
),
{"brand_id": brand_id},
- ).scalar_one()
- if has_provider_keys:
- raise BrandCleanupRequiredError
- deleted_id = connection.execute(
+ )
+ connection.execute(
text(
"""
- DELETE FROM brands
- WHERE id = :brand_id AND owner_user_id = :owner_user_id
- RETURNING id
+ UPDATE provider_keys
+ SET lifecycle = 'cleanup_required', is_active = false,
+ is_valid = NULL, last_validated_at = NULL,
+ last_validation_error = NULL, validation_token = NULL,
+ validation_lease_expires_at = NULL
+ WHERE brand_id = :brand_id
"""
),
- {"brand_id": brand_id, "owner_user_id": user_id},
- ).scalar_one_or_none()
+ {"brand_id": brand_id},
+ )
- if deleted_id is None:
- raise LookupError("Brand not found.")
@lru_cache(maxsize=1)
def get_brand_store() -> BrandStore:
diff --git a/backend/app/services/provider_key_store.py b/backend/app/services/provider_key_store.py
index c875934..01414f7 100644
--- a/backend/app/services/provider_key_store.py
+++ b/backend/app/services/provider_key_store.py
@@ -276,6 +276,101 @@ def activate_key(
raise KeyActivationConflictError from exc
raise
+ def delete_key(self, user_id: str, brand_id: UUID, key_id: UUID) -> None:
+ try:
+ with self.engine.begin() as connection:
+ brand = BrandStore.lock_owned_brand(connection, user_id, brand_id)
+ row = connection.execute(
+ text(
+ """
+ SELECT vault_secret_id, lifecycle
+ FROM provider_keys
+ WHERE brand_id = :brand_id AND id = :key_id
+ FOR UPDATE
+ """
+ ),
+ {"brand_id": brand_id, "key_id": key_id},
+ ).mappings().one_or_none()
+ if row is None:
+ raise LookupError("Provider key not found.")
+
+ # Resolve path membership before exposing whole-brand cleanup state.
+ if brand["deletion_state"] != "active":
+ raise BrandCleanupRequiredError
+ if row["lifecycle"] == "normal":
+ connection.execute(
+ text(
+ """
+ UPDATE provider_keys
+ SET lifecycle = 'cleanup_required', is_active = false,
+ is_valid = NULL, last_validated_at = NULL,
+ last_validation_error = NULL,
+ validation_token = NULL,
+ validation_lease_expires_at = NULL
+ WHERE id = :key_id
+ """
+ ),
+ {"key_id": key_id},
+ )
+ except (LookupError, BrandCleanupRequiredError):
+ raise
+ except SQLAlchemyError as exc:
+ raise KeyCleanupRequiredError from exc
+
+ try:
+ with self.engine.begin() as connection:
+ brand = BrandStore.lock_owned_brand(connection, user_id, brand_id)
+ row = connection.execute(
+ text(
+ """
+ SELECT vault_secret_id, lifecycle
+ FROM provider_keys
+ WHERE brand_id = :brand_id AND id = :key_id
+ FOR UPDATE
+ """
+ ),
+ {"brand_id": brand_id, "key_id": key_id},
+ ).mappings().one_or_none()
+ if row is None:
+ raise LookupError("Provider key not found.")
+ if brand["deletion_state"] != "active":
+ raise BrandCleanupRequiredError
+ if row["lifecycle"] != "cleanup_required":
+ raise KeyCleanupRequiredError
+
+ connection.execute(
+ text(
+ """
+ UPDATE provider_key_idempotency
+ SET state = 'deleted', provider_key_id = NULL
+ WHERE provider_key_id = :key_id
+ """
+ ),
+ {"key_id": key_id},
+ )
+ connection.execute(
+ text("DELETE FROM vault.secrets WHERE id = :vault_secret_id"),
+ {"vault_secret_id": row["vault_secret_id"]},
+ )
+ deleted = connection.execute(
+ text(
+ "DELETE FROM provider_keys "
+ "WHERE id = :key_id AND lifecycle = 'cleanup_required'"
+ ),
+ {"key_id": key_id},
+ )
+ if deleted.rowcount != 1:
+ raise KeyCleanupRequiredError
+ except (
+ LookupError,
+ BrandCleanupRequiredError,
+ KeyCleanupRequiredError,
+ ):
+ raise
+ except SQLAlchemyError as exc:
+ # The first transaction remains committed as the durable retry anchor.
+ raise KeyCleanupRequiredError from exc
+
def claim_validation(
self,
user_id: str,
diff --git a/backend/app/services/provider_validation.py b/backend/app/services/provider_validation.py
index fc99737..7c941c2 100644
--- a/backend/app/services/provider_validation.py
+++ b/backend/app/services/provider_validation.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-import re
+import hashlib
import time
from dataclasses import dataclass
from functools import lru_cache
@@ -27,7 +27,6 @@
"INVALID_CREDENTIAL",
}
)
-_SAFE_REQUEST_ID = re.compile(r"[A-Za-z0-9._:/-]{1,200}\Z")
@dataclass(frozen=True, slots=True, repr=False)
@@ -69,9 +68,10 @@ def _result(
def _request_id(response: httpx.Response, raw_key: str) -> str | None:
value = response.headers.get("x-request-id")
- if value is None or raw_key in value or _SAFE_REQUEST_ID.fullmatch(value) is None:
+ if value is None or raw_key in value or len(value) > 512:
return None
- return value
+ # Provider headers are untrusted. Preserve correlation without logging raw content.
+ return f"sha256:{hashlib.sha256(value.encode()).hexdigest()[:16]}"
def _is_openai_invalid(body: object) -> bool:
diff --git a/backend/tests/contract/test_brands.py b/backend/tests/contract/test_brands.py
index b1b20c6..cd6e665 100644
--- a/backend/tests/contract/test_brands.py
+++ b/backend/tests/contract/test_brands.py
@@ -1,8 +1,8 @@
from __future__ import annotations
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
-from uuid import UUID
+from uuid import UUID, uuid4
import pytest
from fastapi.testclient import TestClient
@@ -10,8 +10,17 @@
from backend.app.auth import CurrentUser, get_current_user
from backend.app.main import app
from backend.app.models.brand import Brand, BrandCreate
-from backend.app.routes.brands import get_brand_storage, get_brand_store
+from backend.app.routes.brands import (
+ get_brand_deletion,
+ get_brand_storage,
+ get_brand_store,
+)
+from backend.app.services.brand_deletion import (
+ BrandConfirmationMismatchError,
+ BrandDeletionCleanupError,
+)
from backend.app.services.brand_store import (
+ BrandAssetOperation,
BrandCleanupRequiredError,
BrandMutationInProgressError,
BrandNameTakenError,
@@ -26,6 +35,7 @@ class FakeBrandStore:
owners: dict[UUID, str] = field(default_factory=dict)
cleanup_required: set[UUID] = field(default_factory=set)
asset_operations: set[UUID] = field(default_factory=set)
+ operations: dict[UUID, BrandAssetOperation] = field(default_factory=dict)
def create_brand(self, user_id: str, payload: BrandCreate) -> Brand:
normalized_name = payload.name.casefold()
@@ -54,7 +64,8 @@ def get_brand(self, user_id: str, brand_id: UUID) -> Brand:
def get_logo_path(self, user_id: str, brand_id: UUID) -> str | None:
self.get_brand(user_id, brand_id)
- self._check_mutation(brand_id)
+ if brand_id in self.cleanup_required:
+ raise BrandCleanupRequiredError
return self.logo_paths.get(brand_id)
def begin_brand_cleanup(self, user_id: str, brand_id: UUID) -> str | None:
@@ -91,6 +102,112 @@ def update_logo_path(
self.brands = [updated_brand if item.id == brand_id else item for item in self.brands]
return updated_brand
+ def begin_logo_upload(
+ self, user_id: str, brand_id: UUID, extension: str
+ ) -> BrandAssetOperation:
+ brand = self.get_brand(user_id, brand_id)
+ self._check_mutation(brand_id)
+ operation_id = uuid4()
+ operation = BrandAssetOperation(
+ id=operation_id,
+ brand_id=brand_id,
+ operation="upload",
+ object_path=f"brands/{brand_id}/logos/{operation_id}.{extension}",
+ previous_path=self.logo_paths.get(brand_id),
+ state="in_progress",
+ remote_status="pending",
+ )
+ self.asset_operations.add(brand_id)
+ self.operations[operation_id] = operation
+ return operation
+
+ def get_asset_operation(
+ self, user_id: str, brand_id: UUID
+ ) -> BrandAssetOperation | None:
+ self.get_brand(user_id, brand_id)
+ return next(
+ (
+ operation
+ for operation in self.operations.values()
+ if operation.brand_id == brand_id
+ ),
+ None,
+ )
+
+ def begin_logo_remove(
+ self, user_id: str, brand_id: UUID
+ ) -> BrandAssetOperation | None:
+ self.get_brand(user_id, brand_id)
+ self._check_mutation(brand_id)
+ path = self.logo_paths.get(brand_id)
+ if path is None:
+ return None
+ operation_id = uuid4()
+ operation = BrandAssetOperation(
+ id=operation_id,
+ brand_id=brand_id,
+ operation="remove",
+ object_path=path,
+ previous_path=None,
+ state="in_progress",
+ remote_status="pending",
+ )
+ self.asset_operations.add(brand_id)
+ self.operations[operation_id] = operation
+ return operation
+
+ def record_asset_remote_status(
+ self, user_id: str, brand_id: UUID, operation_id: UUID, remote_status: str
+ ) -> None:
+ self.get_brand(user_id, brand_id)
+ operation = self.operations[operation_id]
+ self.operations[operation_id] = replace(
+ operation,
+ remote_status=remote_status,
+ state=(
+ operation.state
+ if remote_status == "succeeded"
+ else "cleanup_required"
+ ),
+ )
+
+ def abandon_definitive_asset_failure(
+ self, user_id: str, brand_id: UUID, operation_id: UUID
+ ) -> None:
+ self.operations.pop(operation_id)
+ self.asset_operations.discard(brand_id)
+
+ def mark_asset_cleanup_required(
+ self, user_id: str, brand_id: UUID, operation_id: UUID
+ ) -> None:
+ operation = self.operations[operation_id]
+ self.operations[operation_id] = replace(operation, state="cleanup_required")
+
+ def publish_uploaded_logo(
+ self, user_id: str, brand_id: UUID, operation_id: UUID
+ ) -> Brand:
+ operation = self.operations[operation_id]
+ self.logo_paths[brand_id] = operation.object_path
+ brand = self.get_brand(user_id, brand_id)
+ updated = brand.model_copy(
+ update={
+ "logo_url": (
+ "https://example.supabase.co/storage/v1/object/public/"
+ f"brand-assets/{operation.object_path}"
+ )
+ }
+ )
+ self.brands = [updated if item.id == brand_id else item for item in self.brands]
+ return updated
+
+ def complete_asset_operation(
+ self, user_id: str, brand_id: UUID, operation_id: UUID
+ ) -> None:
+ operation = self.operations.pop(operation_id)
+ if operation.operation == "remove":
+ self.logo_paths[brand_id] = None
+ self.asset_operations.discard(brand_id)
+
def delete_brand(self, user_id: str, brand_id: UUID) -> None:
self.get_brand(user_id, brand_id)
self._check_mutation(brand_id)
@@ -132,6 +249,47 @@ async def delete_logo(self, path: str) -> None:
raise BrandStorageError
+ async def delete_brand_prefix(self, brand_id: UUID) -> None:
+ await self.delete_logo(f"brands/{brand_id}/")
+
+ async def brand_prefix_is_empty(self, brand_id: UUID) -> bool:
+ return not self.fail_delete
+
+
+@dataclass
+class FakeBrandDeletion:
+ store: FakeBrandStore
+ storage: FakeBrandStorage
+
+ async def delete(self, user_id: str, brand_id: UUID, confirm_name: str) -> None:
+ brand = self.store.get_brand(user_id, brand_id)
+ if brand.name != confirm_name:
+ raise BrandConfirmationMismatchError
+ if brand_id in self.store.asset_operations:
+ raise BrandMutationInProgressError
+ self.store.cleanup_required.add(brand_id)
+ updated = brand.model_copy(update={"cleanup_state": "cleanup_required"})
+ self.store.brands = [
+ updated if item.id == brand_id else item for item in self.store.brands
+ ]
+ try:
+ await self.storage.delete_brand_prefix(brand_id)
+ if not await self.storage.brand_prefix_is_empty(brand_id):
+ raise BrandDeletionCleanupError
+ except Exception as exc:
+ if isinstance(exc, BrandDeletionCleanupError):
+ raise
+ raise BrandDeletionCleanupError from exc
+ self.store.delete_brand_after_cleanup(user_id, brand_id)
+
+
+def _override_brand_dependencies(
+ store: FakeBrandStore, storage: FakeBrandStorage
+) -> None:
+ app.dependency_overrides[get_brand_store] = lambda: store
+ app.dependency_overrides[get_brand_storage] = lambda: storage
+ app.dependency_overrides[get_brand_deletion] = lambda: FakeBrandDeletion(store, storage)
+
@pytest.mark.parametrize("name", ["", " ", "A", "A" * 121])
def test_create_brand_rejects_invalid_names(name: str):
@@ -355,8 +513,7 @@ def test_logo_and_brand_mutations_respect_brand_fences(
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
with TestClient(app) as client:
@@ -380,7 +537,9 @@ def test_logo_and_brand_mutations_respect_brand_fences(
assert delete_response.status_code == 409
assert delete_response.json()["error"]["code"] == expected_code
assert storage.uploads == []
- assert storage.deletes == []
+ assert storage.deletes == (
+ [f"brands/{brand.id}/"] if fence_field == "cleanup_required" else []
+ )
assert store.brands == ([] if fence_field == "cleanup_required" else [brand])
finally:
app.dependency_overrides.clear()
@@ -410,8 +569,7 @@ def test_upload_logo_rejects_unsupported_or_spoofed_content(
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
with TestClient(app) as client:
@@ -442,8 +600,7 @@ def test_upload_logo_rejects_files_over_five_megabytes():
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
oversized_png = b"\x89PNG\r\n\x1a\n" + b"x" * (5 * 1024 * 1024)
@@ -475,8 +632,7 @@ def test_upload_logo_returns_updated_brand():
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
png = b"\x89PNG\r\n\x1a\nvalid"
@@ -487,11 +643,12 @@ def test_upload_logo_returns_updated_brand():
)
assert response.status_code == 200
- assert response.json()["logo_url"].endswith(f"brands/{brand.id}/logo.png")
- assert storage.uploads == [
- (f"brands/{brand.id}/logo.png", png, "image/png")
- ]
- assert store.logo_paths[brand.id] == f"brands/{brand.id}/logo.png"
+ logo_path = store.logo_paths[brand.id]
+ assert logo_path is not None
+ assert logo_path.startswith(f"brands/{brand.id}/logos/")
+ assert logo_path.endswith(".png")
+ assert response.json()["logo_url"].endswith(logo_path)
+ assert storage.uploads == [(logo_path, png, "image/png")]
finally:
app.dependency_overrides.clear()
@@ -510,8 +667,7 @@ def test_delete_logo_without_existing_logo_is_idempotent():
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
with TestClient(app) as client:
@@ -525,6 +681,50 @@ def test_delete_logo_without_existing_logo_is_idempotent():
app.dependency_overrides.clear()
+def test_replacement_cleanup_failure_is_durable_and_retry_completes_it():
+ brand = Brand(
+ id=UUID("22222222-2222-2222-2222-222222222222"),
+ name="Acme Coffee",
+ logo_url="https://example.supabase.co/old.png",
+ created_at=datetime(2026, 7, 25, tzinfo=UTC),
+ )
+ old_path = f"brands/{brand.id}/logo.png"
+ store = FakeBrandStore(brands=[brand], logo_paths={brand.id: old_path})
+ storage = FakeBrandStorage(fail_delete=True)
+ app.dependency_overrides[get_current_user] = lambda: CurrentUser(
+ user_id="11111111-1111-1111-1111-111111111111",
+ email="owner@example.com",
+ access_token="eyJ...",
+ )
+ _override_brand_dependencies(store, storage)
+
+ try:
+ png = b"\x89PNG\r\n\x1a\nvalid"
+ with TestClient(app) as client:
+ failed = client.post(
+ f"/api/v1/brands/{brand.id}/logo",
+ files={"file": ("logo.png", png, "image/png")},
+ )
+ assert failed.status_code == 502
+ operation = next(iter(store.operations.values()))
+ assert operation.remote_status == "succeeded"
+ assert operation.state == "cleanup_required"
+ assert store.logo_paths[brand.id] == operation.object_path
+
+ storage.fail_delete = False
+ retried = client.post(
+ f"/api/v1/brands/{brand.id}/logo",
+ files={"file": ("logo.png", png, "image/png")},
+ )
+
+ assert retried.status_code == 200
+ assert store.operations == {}
+ assert old_path in storage.deletes
+ assert operation.object_path in storage.deletes
+ finally:
+ app.dependency_overrides.clear()
+
+
def test_delete_brand_with_exact_confirmation_removes_brand_and_logo():
brand = Brand(
id=UUID("22222222-2222-2222-2222-222222222222"),
@@ -540,8 +740,7 @@ def test_delete_brand_with_exact_confirmation_removes_brand_and_logo():
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
with TestClient(app) as client:
@@ -554,7 +753,7 @@ def test_delete_brand_with_exact_confirmation_removes_brand_and_logo():
assert response.status_code == 204
assert response.content == b""
assert store.brands == []
- assert storage.deletes == [logo_path]
+ assert storage.deletes == [f"brands/{brand.id}/"]
finally:
app.dependency_overrides.clear()
@@ -574,8 +773,7 @@ def test_delete_brand_storage_failure_retains_cleanup_required_brand():
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
with TestClient(app) as client:
@@ -594,7 +792,7 @@ def test_delete_brand_storage_failure_retains_cleanup_required_brand():
assert len(store.brands) == 1
assert store.brands[0].cleanup_state == "cleanup_required"
assert store.logo_paths[brand.id] == logo_path
- assert storage.deletes == [logo_path]
+ assert storage.deletes == [f"brands/{brand.id}/"]
storage.fail_delete = False
with TestClient(app) as client:
@@ -607,7 +805,7 @@ def test_delete_brand_storage_failure_retains_cleanup_required_brand():
assert retry.status_code == 204
assert retry.content == b""
assert store.brands == []
- assert storage.deletes == [logo_path, logo_path]
+ assert storage.deletes == [f"brands/{brand.id}/", f"brands/{brand.id}/"]
finally:
app.dependency_overrides.clear()
@@ -629,8 +827,7 @@ def test_delete_brand_rejects_missing_or_wrong_confirmation_without_mutation(
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
with TestClient(app) as client:
@@ -668,8 +865,7 @@ def test_delete_brand_returns_opaque_not_found_for_non_owner_and_nonexistent_bra
email="owner@example.com",
access_token="eyJ...",
)
- app.dependency_overrides[get_brand_store] = lambda: store
- app.dependency_overrides[get_brand_storage] = lambda: storage
+ _override_brand_dependencies(store, storage)
try:
with TestClient(app) as client:
diff --git a/backend/tests/contract/test_provider_keys.py b/backend/tests/contract/test_provider_keys.py
index efad9c3..e13495f 100644
--- a/backend/tests/contract/test_provider_keys.py
+++ b/backend/tests/contract/test_provider_keys.py
@@ -4,7 +4,9 @@
import io
import json
import logging
+import secrets
import time
+from collections.abc import Container
from dataclasses import dataclass, field
from datetime import UTC, datetime
from types import SimpleNamespace
@@ -22,12 +24,21 @@
from backend.app.models.provider_key import ProviderKey, ProviderKeyAdd
from backend.app.routes import provider_keys as provider_key_routes
from backend.app.routes.provider_keys import get_provider_key_store
+from backend.app.services import provider_key_store as provider_key_store_module
from backend.app.services.brand_store import BrandCleanupRequiredError
from backend.app.services.provider_key_store import (
IdempotencyKeyRetiredError,
+ KeyCleanupRequiredError,
+ ProviderKeyStore,
VaultUnavailableError,
)
-from backend.app.services import provider_key_store as provider_key_store_module
+
+
+def _assert_not_exposed(
+ observable: Container[str], *prohibited_values: str
+) -> None:
+ if any(value in observable for value in prohibited_values):
+ raise AssertionError("sensitive value was exposed")
def _request(path: str = "/api/v1/brands/brand-id/keys/key-id/validate") -> Request:
@@ -131,7 +142,8 @@ def test_json_log_formatter_allows_only_audited_safe_fields():
"duration_ms": 42,
"provider_request_id": "provider-request-id",
}
- for secret in (
+ _assert_not_exposed(
+ rendered,
raw_key,
"Production Key",
"***A1B2",
@@ -139,8 +151,7 @@ def test_json_log_formatter_allows_only_audited_safe_fields():
"Bearer",
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"owner@example.com",
- ):
- assert secret not in rendered
+ )
def test_engine_hides_parameters_and_bounds_database_waits(monkeypatch: pytest.MonkeyPatch):
@@ -439,7 +450,7 @@ def _safe_key(**updates) -> ProviderKey:
return ProviderKey.model_validate(values)
-@dataclass(frozen=True)
+@dataclass(frozen=True, repr=False)
class FakeValidationClaim:
attempted_at: datetime
key: ProviderKey
@@ -473,7 +484,7 @@ def advance(self, seconds: float) -> None:
self.current += seconds
-@dataclass
+@dataclass(repr=False)
class FakeValidationScenario:
before: ProviderKey = field(default_factory=_safe_key)
outcome: str = "valid"
@@ -524,7 +535,7 @@ def completed_key(self, result: FakeProviderResult) -> ProviderKey:
return self.before
-@dataclass
+@dataclass(repr=False)
class FakeProviderValidator:
scenario: FakeValidationScenario
calls: list[tuple[str, str, float]] = field(default_factory=list)
@@ -541,12 +552,13 @@ async def validate(
)
-@dataclass
+@dataclass(repr=False)
class FakeProviderKeyStore:
keys: list[ProviderKey] = field(default_factory=list)
error: Exception | None = None
add_calls: list[tuple[str, UUID, ProviderKeyAdd, UUID]] = field(default_factory=list)
activate_calls: list[tuple[str, UUID, UUID]] = field(default_factory=list)
+ delete_calls: list[tuple[str, UUID, UUID]] = field(default_factory=list)
validation: FakeValidationScenario | None = None
claim_calls: list[tuple[str, UUID, UUID, float]] = field(default_factory=list)
complete_calls: list[tuple[str, UUID, UUID, UUID, Any, float]] = field(
@@ -593,6 +605,12 @@ def activate_key(
]
return activated
+ def delete_key(self, user_id: str, brand_id: UUID, key_id: UUID) -> None:
+ self.delete_calls.append((user_id, brand_id, key_id))
+ if self.error:
+ raise self.error
+ self.keys = [key for key in self.keys if key.id != key_id]
+
def claim_validation(
self,
user_id: str,
@@ -684,7 +702,7 @@ def test_list_keys_returns_exact_empty_and_populated_safe_shapes(provider_key_cl
]
}
forbidden = {"brand_id", "vault_secret_id", "lifecycle", "validation_token", "updated_at"}
- assert forbidden.isdisjoint(populated.json()["keys"][0])
+ _assert_not_exposed(populated.json()["keys"][0], *forbidden)
@pytest.mark.parametrize("make_active", [None, True, False])
@@ -704,7 +722,7 @@ def test_add_key_defaults_active_and_returns_only_safe_shape(provider_key_client
assert response.status_code == 201
assert response.json()["is_active"] is expected_active
assert response.json()["key_hint"] == "***A1B2"
- assert RAW_KEY not in response.text
+ _assert_not_exposed(response.text, RAW_KEY)
assert store.add_calls[0][2].key == RAW_KEY
assert store.add_calls[0][2].make_active is expected_active
assert store.add_calls[0][3] == REQUEST_ID
@@ -748,7 +766,7 @@ def test_add_key_rejects_invalid_raw_request_before_store(provider_key_client, b
assert response.status_code == 400
assert response.json()["error"]["code"] == "VALIDATION_ERROR"
- assert RAW_KEY not in response.text
+ _assert_not_exposed(response.text, RAW_KEY)
assert store.add_calls == []
@@ -791,7 +809,7 @@ def test_provider_key_errors_use_exact_safe_envelopes(
assert response.json()["error"]["code"] == code
assert response.json()["error"]["message"] == message
assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
- assert RAW_KEY not in response.text
+ _assert_not_exposed(response.text, RAW_KEY)
def test_opaque_brand_error_is_identical_for_list_and_add(provider_key_client):
@@ -824,8 +842,7 @@ def test_response_model_filters_internal_store_fields(provider_key_client):
response = client.get(f"/api/v1/brands/{BRAND_ID}/keys")
assert response.status_code == 200
- assert RAW_KEY not in response.text
- assert "vault_secret_id" not in response.text
+ _assert_not_exposed(response.text, RAW_KEY, "vault_secret_id")
def test_add_uses_no_provider_client_dependency(provider_key_client):
@@ -854,13 +871,14 @@ def test_activate_returns_exact_safe_shape_and_accepts_unvalidated_key(
("11111111-1111-1111-1111-111111111111", BRAND_ID, KEY_ID)
]
assert store.validator.calls == []
- assert {
+ _assert_not_exposed(
+ response.json(),
"brand_id",
"vault_secret_id",
"lifecycle",
"validation_token",
"updated_at",
- }.isdisjoint(response.json())
+ )
def test_activate_deactivates_only_prior_key_for_same_provider(provider_key_client):
@@ -965,6 +983,310 @@ def test_activate_rejects_malformed_path_before_store(provider_key_client):
assert store.activate_calls == []
+@pytest.mark.parametrize("is_active", [True, False])
+def test_delete_key_returns_empty_204_without_activating_replacement(
+ provider_key_client,
+ is_active: bool,
+):
+ client, store = provider_key_client
+ replacement_id = UUID("77777777-7777-7777-7777-777777777777")
+ store.keys = [
+ _safe_key(is_active=is_active),
+ _safe_key(id=replacement_id, is_active=False),
+ ]
+
+ response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}")
+
+ assert response.status_code == 204
+ assert response.content == b""
+ assert store.delete_calls == [
+ ("11111111-1111-1111-1111-111111111111", BRAND_ID, KEY_ID)
+ ]
+ assert store.activate_calls == []
+ assert [key.id for key in store.keys] == [replacement_id]
+ assert store.keys[0].is_active is False
+
+
+def test_delete_cleanup_required_key_retries_same_operation(provider_key_client):
+ client, store = provider_key_client
+ store.keys = [
+ _safe_key(
+ is_active=False,
+ is_valid=None,
+ cleanup_state="cleanup_required",
+ )
+ ]
+
+ response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}")
+
+ assert response.status_code == 204
+ assert store.keys == []
+
+
+@pytest.mark.parametrize(
+ ("error", "status_code", "code", "message"),
+ [
+ (
+ LookupError("secret path detail"),
+ 404,
+ "PROVIDER_KEY_NOT_FOUND",
+ "Provider key not found.",
+ ),
+ (
+ BrandCleanupRequiredError("secret brand detail"),
+ 409,
+ "BRAND_CLEANUP_REQUIRED",
+ "Brand cleanup is required. Retry deletion.",
+ ),
+ (
+ KeyCleanupRequiredError("secret SQL bind"),
+ 503,
+ "KEY_CLEANUP_REQUIRED",
+ "Key cleanup did not complete. Retry deletion.",
+ ),
+ ],
+)
+def test_delete_key_errors_use_exact_safe_envelopes(
+ provider_key_client,
+ error: Exception,
+ status_code: int,
+ code: str,
+ message: str,
+):
+ client, store = provider_key_client
+ store.error = error
+
+ response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}")
+
+ assert response.status_code == status_code
+ assert response.json()["error"]["code"] == code
+ assert response.json()["error"]["message"] == message
+ assert response.headers["X-Request-Id"] == response.json()["error"]["request_id"]
+ _assert_not_exposed(response.text, "secret")
+
+
+def test_delete_path_membership_is_resolved_before_brand_cleanup(provider_key_client):
+ client, store = provider_key_client
+ store.error = LookupError("not owned while brand cleanup is pending")
+
+ response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}")
+
+ assert response.status_code == 404
+ assert response.json()["error"]["code"] == "PROVIDER_KEY_NOT_FOUND"
+
+
+def test_delete_brand_cleanup_takes_precedence_for_owned_key(provider_key_client):
+ client, store = provider_key_client
+ store.error = BrandCleanupRequiredError()
+
+ response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}")
+
+ assert response.status_code == 409
+ assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+
+
+def test_delete_rejects_malformed_path_before_store(provider_key_client):
+ client, store = provider_key_client
+
+ response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/not-a-uuid")
+
+ assert response.status_code == 400
+ assert response.json()["error"]["code"] == "VALIDATION_ERROR"
+ assert store.delete_calls == []
+
+
+def test_delete_logs_exclude_key_and_exception_details(
+ provider_key_client,
+ caplog: pytest.LogCaptureFixture,
+):
+ client, store = provider_key_client
+ store.error = KeyCleanupRequiredError(
+ f"{RAW_KEY}; Production Key; ***A1B2; secret SQL bind"
+ )
+ caplog.set_level(logging.INFO)
+
+ response = client.delete(f"/api/v1/brands/{BRAND_ID}/keys/{KEY_ID}")
+
+ rendered_logs = "\n".join(
+ main._JsonLogFormatter().format(record) for record in caplog.records
+ )
+ observable = response.text + rendered_logs + caplog.text
+ assert response.status_code == 503
+ _assert_not_exposed(
+ observable, RAW_KEY, "Production Key", "***A1B2", "secret SQL bind"
+ )
+
+
+class _StoreResult:
+ def __init__(self, *, row=None, rowcount: int = 0):
+ self._row = row
+ self.rowcount = rowcount
+
+ def mappings(self):
+ return self
+
+ def one_or_none(self):
+ return self._row
+
+
+class _StoreConnection:
+ def __init__(self, results: list[_StoreResult | Exception]):
+ self.results = results
+ self.statements: list[str] = []
+
+ def execute(self, statement, parameters=None):
+ self.statements.append(str(statement))
+ result = self.results.pop(0)
+ if isinstance(result, Exception):
+ raise result
+ return result
+
+
+class _StoreTransaction:
+ def __init__(self, connection: _StoreConnection, events: list[str]):
+ self.connection = connection
+ self.events = events
+
+ def __enter__(self):
+ self.events.append("begin")
+ return self.connection
+
+ def __exit__(self, exception_type, exception, traceback):
+ self.events.append("rollback" if exception_type else "commit")
+ return False
+
+
+class _StoreEngine:
+ def __init__(self, connections: list[_StoreConnection]):
+ self.connections = connections
+ self.events: list[str] = []
+
+ def begin(self):
+ return _StoreTransaction(self.connections.pop(0), self.events)
+
+
+def _deletion_row(lifecycle: str = "normal") -> dict[str, Any]:
+ return {
+ "vault_secret_id": UUID("55555555-5555-5555-5555-555555555555"),
+ "lifecycle": lifecycle,
+ }
+
+
+def test_store_delete_commits_fence_before_atomic_receipt_vault_and_key_cleanup(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ fence = _StoreConnection([_StoreResult(row=_deletion_row()), _StoreResult()])
+ cleanup = _StoreConnection(
+ [
+ _StoreResult(row=_deletion_row("cleanup_required")),
+ _StoreResult(),
+ _StoreResult(rowcount=1),
+ _StoreResult(rowcount=1),
+ ]
+ )
+ engine = _StoreEngine([fence, cleanup])
+ brands = iter([{"deletion_state": "active"}, {"deletion_state": "active"}])
+ monkeypatch.setattr(
+ provider_key_store_module.BrandStore,
+ "lock_owned_brand",
+ lambda connection, user_id, brand_id: next(brands),
+ )
+
+ ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID)
+
+ assert engine.events == ["begin", "commit", "begin", "commit"]
+ assert "lifecycle = 'cleanup_required'" in fence.statements[1]
+ assert "is_active = false" in fence.statements[1]
+ assert "validation_token = NULL" in fence.statements[1]
+ cleanup_sql = "\n".join(cleanup.statements)
+ assert cleanup_sql.index("provider_key_idempotency") < cleanup_sql.index(
+ "vault.secrets"
+ )
+ assert cleanup_sql.index("vault.secrets") < cleanup_sql.rindex("provider_keys")
+
+
+def test_store_delete_treats_absent_vault_secret_as_success(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ fence = _StoreConnection([_StoreResult(row=_deletion_row()), _StoreResult()])
+ cleanup = _StoreConnection(
+ [
+ _StoreResult(row=_deletion_row("cleanup_required")),
+ _StoreResult(),
+ _StoreResult(rowcount=0),
+ _StoreResult(rowcount=1),
+ ]
+ )
+ engine = _StoreEngine([fence, cleanup])
+ brands = iter([{"deletion_state": "active"}, {"deletion_state": "active"}])
+ monkeypatch.setattr(
+ provider_key_store_module.BrandStore,
+ "lock_owned_brand",
+ lambda connection, user_id, brand_id: next(brands),
+ )
+
+ ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID)
+
+ assert engine.events == ["begin", "commit", "begin", "commit"]
+
+
+def test_store_delete_cleanup_sql_failure_rolls_back_after_committed_fence(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ from sqlalchemy.exc import OperationalError
+
+ fence = _StoreConnection([_StoreResult(row=_deletion_row()), _StoreResult()])
+ cleanup = _StoreConnection(
+ [
+ _StoreResult(row=_deletion_row("cleanup_required")),
+ _StoreResult(),
+ OperationalError("DELETE", {}, Exception("ambiguous commit")),
+ ]
+ )
+ engine = _StoreEngine([fence, cleanup])
+ brands = iter([{"deletion_state": "active"}, {"deletion_state": "active"}])
+ monkeypatch.setattr(
+ provider_key_store_module.BrandStore,
+ "lock_owned_brand",
+ lambda connection, user_id, brand_id: next(brands),
+ )
+
+ with pytest.raises(KeyCleanupRequiredError):
+ ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID)
+
+ assert engine.events == ["begin", "commit", "begin", "rollback"]
+
+
+def test_store_delete_checks_key_membership_before_brand_cleanup(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ missing = _StoreConnection([_StoreResult(row=None)])
+ engine = _StoreEngine([missing])
+ monkeypatch.setattr(
+ provider_key_store_module.BrandStore,
+ "lock_owned_brand",
+ lambda connection, user_id, brand_id: {"deletion_state": "cleanup_required"},
+ )
+
+ with pytest.raises(LookupError):
+ ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID)
+
+
+def test_store_delete_owned_key_defers_to_whole_brand_cleanup(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ owned = _StoreConnection([_StoreResult(row=_deletion_row())])
+ engine = _StoreEngine([owned])
+ monkeypatch.setattr(
+ provider_key_store_module.BrandStore,
+ "lock_owned_brand",
+ lambda connection, user_id, brand_id: {"deletion_state": "cleanup_required"},
+ )
+
+ with pytest.raises(BrandCleanupRequiredError):
+ ProviderKeyStore(engine).delete_key("user-id", BRAND_ID, KEY_ID)
+
+
def _safe_key_json(key: ProviderKey) -> dict[str, Any]:
return {
"id": str(key.id),
@@ -1046,9 +1368,10 @@ def test_validate_returns_exact_outcome_matrix_and_complete_snapshot(
store.claim_calls[0][3],
)
]
- assert store.validator.calls[0][:2] == (provider, RAW_KEY)
+ assert store.validator.calls[0][0] == provider
+ assert secrets.compare_digest(store.validator.calls[0][1], RAW_KEY)
assert len(store.complete_calls) == 1
- assert RAW_KEY not in response.text
+ _assert_not_exposed(response.text, RAW_KEY)
def test_invalid_validation_deactivates_without_replacement(provider_key_client):
@@ -1305,8 +1628,7 @@ def test_validation_response_and_logs_exclude_every_sensitive_stage_value(
main._JsonLogFormatter().format(record) for record in caplog.records
)
observable = response.text + rendered_logs + caplog.text
- for value in sensitive:
- assert value not in observable
+ _assert_not_exposed(observable, *sensitive)
def test_safe_validation_log_contains_only_fixed_allowlisted_metadata():
diff --git a/backend/tests/integration/test_brand_crud.py b/backend/tests/integration/test_brand_crud.py
index 83d99e5..f8b7502 100644
--- a/backend/tests/integration/test_brand_crud.py
+++ b/backend/tests/integration/test_brand_crud.py
@@ -55,6 +55,13 @@ def _hard_delete_owned_brands(user_id: str) -> None:
from backend.app.config import get_engine
with get_engine().begin() as connection:
+ connection.execute(
+ text(
+ "DELETE FROM brand_asset_operations WHERE brand_id IN ("
+ "SELECT id FROM brands WHERE owner_user_id = :owner_user_id)"
+ ),
+ {"owner_user_id": user_id},
+ )
connection.execute(
text("DELETE FROM brands WHERE owner_user_id = :owner_user_id"),
{"owner_user_id": user_id},
@@ -152,13 +159,19 @@ def test_create_brand_against_real_supabase():
files={"file": ("logo.png", PNG_B, "image/png")},
)
assert same_format_response.status_code == 200
- assert same_format_response.json()["logo_url"] == png_url
+ replacement_png_url = same_format_response.json()["logo_url"]
+ assert replacement_png_url != png_url
+ assert f"brands/{brand_id}/logos/" in replacement_png_url
replaced_png = supabase_client.get(
- png_url,
+ replacement_png_url,
params={"v": uuid4().hex},
)
assert replaced_png.status_code == 200
assert replaced_png.content == PNG_B
+ assert supabase_client.get(
+ png_url,
+ params={"v": uuid4().hex},
+ ).status_code in {400, 404}
different_format_response = api_client.post(
f"/api/v1/brands/{brand_id}/logo",
@@ -167,7 +180,7 @@ def test_create_brand_against_real_supabase():
)
assert different_format_response.status_code == 200
jpeg_url = different_format_response.json()["logo_url"]
- assert jpeg_url and jpeg_url != png_url
+ assert jpeg_url and jpeg_url != replacement_png_url
stored_jpeg = supabase_client.get(
jpeg_url,
params={"v": uuid4().hex},
@@ -175,7 +188,7 @@ def test_create_brand_against_real_supabase():
assert stored_jpeg.status_code == 200
assert stored_jpeg.content == JPEG
assert supabase_client.get(
- png_url,
+ replacement_png_url,
params={"v": uuid4().hex},
).status_code in {400, 404}
@@ -211,7 +224,7 @@ def test_create_brand_against_real_supabase():
assert unchanged_jpeg.status_code == 200
assert unchanged_jpeg.content == JPEG
assert supabase_client.get(
- png_url,
+ replacement_png_url,
params={"v": uuid4().hex},
).status_code in {400, 404}
@@ -645,16 +658,21 @@ def test_brand_storage_failure_persists_cleanup_required_retry_anchor():
from backend.app.config import get_engine
from backend.app.main import app
- from backend.app.routes.brands import get_brand_storage
+ from backend.app.routes.brands import get_brand_deletion, get_brand_storage
+ from backend.app.services.brand_deletion import BrandDeletion
from backend.app.services.brand_storage import BrandStorageError
+ from backend.app.services.brand_store import get_brand_store
class ControllableStorage:
fail_delete = True
- async def delete_logo(self, path: str) -> None:
+ async def delete_brand_prefix(self, brand_id) -> None:
if self.fail_delete:
raise BrandStorageError
+ async def brand_prefix_is_empty(self, brand_id) -> bool:
+ return not self.fail_delete
+
user_id: str | None = None
brand_id = str(uuid4())
logo_path = f"brands/{brand_id}/logo.png"
@@ -682,6 +700,9 @@ async def delete_logo(self, path: str) -> None:
storage = ControllableStorage()
app.dependency_overrides[get_brand_storage] = lambda: storage
+ app.dependency_overrides[get_brand_deletion] = lambda: BrandDeletion(
+ get_engine(), get_brand_store(), storage
+ )
with TestClient(app) as api_client:
response = api_client.request(
"DELETE",
diff --git a/backend/tests/integration/test_brand_rls.py b/backend/tests/integration/test_brand_rls.py
index f1afcb2..e9459e8 100644
--- a/backend/tests/integration/test_brand_rls.py
+++ b/backend/tests/integration/test_brand_rls.py
@@ -146,6 +146,7 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers():
)
assert owner_upload_response.status_code == 200
logo_url = owner_upload_response.json()["logo_url"]
+ assert f"brands/{brand_id}/logos/" in logo_url
owner_response = api_client.get(
f"/api/v1/brands/{brand_id}",
@@ -215,12 +216,6 @@ def test_brand_operations_are_owner_scoped_at_api_and_database_layers():
)
assert stored_logo.status_code == 200
assert stored_logo.content == PNG
- alternate_logo_url = logo_url.removesuffix("logo.png") + "logo.jpg"
- assert supabase_client.get(
- alternate_logo_url,
- params={"v": uuid4().hex},
- ).status_code in {400, 404}
-
engine = get_engine()
assert _visible_brand_ids(engine, token_a, brand_id) == [brand_id]
assert _visible_brand_ids(engine, token_b, brand_id) == []
diff --git a/backend/tests/integration/test_provider_key_cleanup.py b/backend/tests/integration/test_provider_key_cleanup.py
index 645ea87..9c7aa02 100644
--- a/backend/tests/integration/test_provider_key_cleanup.py
+++ b/backend/tests/integration/test_provider_key_cleanup.py
@@ -1,12 +1,14 @@
from __future__ import annotations
+import asyncio
from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager
from uuid import UUID, uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
-from sqlalchemy.exc import DBAPIError
+from sqlalchemy.exc import DBAPIError, SQLAlchemyError
from backend.tests.integration.test_provider_keys import (
MockProviderValidator,
@@ -214,3 +216,417 @@ def test_database_rejects_multiple_active_invalid_and_cleanup_active_rows(
text(f"UPDATE provider_keys SET {assignment} WHERE id = :key_id"),
{"key_id": second_id},
)
+
+
+def test_individual_delete_removes_active_secret_retires_receipt_and_has_no_replacement(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ with TestClient(app) as client:
+ active_id = _add_real_key(
+ client,
+ fixture,
+ SecretFixture(f"delete-active-{uuid4().hex}-A1B2"),
+ make_active=True,
+ )
+ inactive_id = _add_real_key(
+ client,
+ fixture,
+ SecretFixture(f"delete-inactive-{uuid4().hex}-C3D4"),
+ make_active=False,
+ )
+ deleted = client.delete(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{active_id}",
+ headers=fixture["headers"],
+ )
+
+ assert deleted.status_code == 204
+ with fixture["engine"].connect() as connection:
+ assert connection.execute(
+ text("SELECT count(*) FROM provider_keys WHERE id = :id"),
+ {"id": active_id},
+ ).scalar_one() == 0
+ assert connection.execute(
+ text("SELECT is_active FROM provider_keys WHERE id = :id"),
+ {"id": inactive_id},
+ ).scalar_one() is False
+ receipt = connection.execute(
+ text(
+ "SELECT state, provider_key_id FROM provider_key_idempotency "
+ "WHERE brand_id = :brand_id AND state = 'deleted'"
+ ),
+ {"brand_id": fixture["brand_id"]},
+ ).one()
+ assert receipt == ("deleted", None)
+
+
+def test_individual_delete_accepts_missing_secret_and_reconciles_cleanup_row(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ with TestClient(app) as client:
+ key_id = _add_real_key(
+ client,
+ fixture,
+ SecretFixture(f"delete-missing-{uuid4().hex}-E5F6"),
+ make_active=True,
+ )
+ with fixture["engine"].begin() as connection:
+ vault_id = connection.execute(
+ text("SELECT vault_secret_id FROM provider_keys WHERE id = :id"),
+ {"id": key_id},
+ ).scalar_one()
+ connection.execute(
+ text("DELETE FROM vault.secrets WHERE id = :id"), {"id": vault_id}
+ )
+ connection.execute(
+ text(
+ """
+ UPDATE provider_keys
+ SET lifecycle = 'cleanup_required', is_active = false,
+ is_valid = NULL, last_validated_at = NULL,
+ last_validation_error = NULL, validation_token = NULL,
+ validation_lease_expires_at = NULL
+ WHERE id = :id
+ """
+ ),
+ {"id": key_id},
+ )
+ deleted = client.delete(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}",
+ headers=fixture["headers"],
+ )
+ repeated = client.delete(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}",
+ headers=fixture["headers"],
+ )
+
+ assert deleted.status_code == 204
+ assert repeated.status_code == 404
+
+
+def test_individual_delete_gives_brand_cleanup_precedence_and_retains_key(
+ provider_fixture,
+):
+ from backend.app.main import app
+
+ fixture = provider_fixture
+ with TestClient(app) as client:
+ key_id = _add_real_key(
+ client,
+ fixture,
+ SecretFixture(f"delete-brand-fence-{uuid4().hex}-G7H8"),
+ make_active=True,
+ )
+ with fixture["engine"].begin() as connection:
+ connection.execute(
+ text(
+ "UPDATE brands SET deletion_state = 'cleanup_required' "
+ "WHERE id = :brand_id"
+ ),
+ {"brand_id": fixture["brand_id"]},
+ )
+ response = client.delete(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}",
+ headers=fixture["headers"],
+ )
+
+ assert response.status_code == 409
+ assert response.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+ with fixture["engine"].connect() as connection:
+ assert connection.execute(
+ text("SELECT count(*) FROM provider_keys WHERE id = :id"),
+ {"id": key_id},
+ ).scalar_one() == 1
+
+
+def test_individual_vault_failure_keeps_fence_and_retry_finishes_cleanup(
+ provider_fixture,
+):
+ from backend.app.main import app
+ from backend.app.routes.provider_keys import get_provider_key_store
+ from backend.app.services.provider_key_store import ProviderKeyStore
+
+ class FailingConnection:
+ def __init__(self, connection):
+ self.connection = connection
+
+ def execute(self, statement, parameters=None):
+ if "DELETE FROM vault.secrets" in str(statement):
+ raise SQLAlchemyError("injected vault failure")
+ return self.connection.execute(statement, parameters)
+
+ class FailingCleanupEngine:
+ def __init__(self, engine):
+ self.engine = engine
+ self.transactions = 0
+
+ @contextmanager
+ def begin(self):
+ self.transactions += 1
+ with self.engine.begin() as connection:
+ if self.transactions == 2:
+ yield FailingConnection(connection)
+ else:
+ yield connection
+
+ fixture = provider_fixture
+ with TestClient(app) as client:
+ key_id = _add_real_key(
+ client,
+ fixture,
+ SecretFixture(f"delete-vault-retry-{uuid4().hex}-N3P4"),
+ make_active=True,
+ )
+ failing_store = ProviderKeyStore(FailingCleanupEngine(fixture["engine"]))
+ app.dependency_overrides[get_provider_key_store] = lambda: failing_store
+ try:
+ failed = client.delete(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}",
+ headers=fixture["headers"],
+ )
+ finally:
+ app.dependency_overrides.pop(get_provider_key_store, None)
+
+ assert failed.status_code == 503
+ assert failed.json()["error"]["code"] == "KEY_CLEANUP_REQUIRED"
+ with fixture["engine"].connect() as connection:
+ retained = connection.execute(
+ text(
+ """
+ SELECT lifecycle, is_active, validation_token,
+ validation_lease_expires_at,
+ EXISTS (
+ SELECT 1 FROM vault.secrets
+ WHERE id = provider_keys.vault_secret_id
+ ) AS secret_exists
+ FROM provider_keys WHERE id = :id
+ """
+ ),
+ {"id": key_id},
+ ).one()
+ assert retained == ("cleanup_required", False, None, None, True)
+
+ activation = client.patch(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/activate",
+ headers=fixture["headers"],
+ )
+ validation = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}/validate",
+ headers=fixture["headers"],
+ )
+ retried = client.delete(
+ f"/api/v1/brands/{fixture['brand_id']}/keys/{key_id}",
+ headers=fixture["headers"],
+ )
+
+ assert activation.status_code == validation.status_code == 409
+ assert activation.json()["error"]["code"] == "KEY_CLEANUP_REQUIRED"
+ assert validation.json()["error"]["code"] == "KEY_CLEANUP_REQUIRED"
+ assert retried.status_code == 204
+
+
+def test_asset_operation_is_unique_unknown_non_expiring_and_stale_completion_is_blocked(
+ provider_fixture,
+):
+ from backend.app.services.brand_store import (
+ BrandAssetOperationStaleError,
+ BrandMutationInProgressError,
+ BrandStore,
+ )
+
+ fixture = provider_fixture
+ store = BrandStore(fixture["engine"])
+ with fixture["engine"].connect() as connection:
+ user_id = connection.execute(
+ text("SELECT owner_user_id FROM brands WHERE id = :id"),
+ {"id": fixture["brand_id"]},
+ ).scalar_one()
+ operation = store.begin_logo_upload(user_id, fixture["brand_id"], "png")
+ try:
+ with pytest.raises(BrandMutationInProgressError):
+ store.begin_logo_remove(user_id, fixture["brand_id"])
+ with fixture["engine"].begin() as connection:
+ connection.execute(
+ text(
+ "UPDATE brand_asset_operations "
+ "SET started_at = clock_timestamp() - interval '1 hour' "
+ "WHERE id = :id"
+ ),
+ {"id": operation.id},
+ )
+ assert store.mark_abandoned_asset_operations_unknown(fixture["brand_id"]) == 1
+ assert store.mark_abandoned_asset_operations_unknown(fixture["brand_id"]) == 0
+ with fixture["engine"].connect() as connection:
+ state = connection.execute(
+ text(
+ "SELECT state, remote_status FROM brand_asset_operations "
+ "WHERE id = :id"
+ ),
+ {"id": operation.id},
+ ).one()
+ assert state == ("cleanup_required", "unknown")
+ with pytest.raises(BrandAssetOperationStaleError):
+ store.complete_asset_operation(user_id, fixture["brand_id"], uuid4())
+ finally:
+ with fixture["engine"].begin() as connection:
+ connection.execute(
+ text("DELETE FROM brand_asset_operations WHERE id = :id"),
+ {"id": operation.id},
+ )
+
+
+def test_brand_hard_delete_removes_legacy_tokenized_storage_vault_and_all_rows(
+ provider_fixture,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ from backend.app.main import app
+ from backend.app.services.brand_storage import BrandStorage, get_brand_storage
+
+ fixture = provider_fixture
+ storage = get_brand_storage()
+ monkeypatch.setattr(BrandStorage, "PAGE_SIZE", 1)
+ monkeypatch.setattr(BrandStorage, "DELETE_BATCH_SIZE", 1)
+ seeded_paths = (
+ f"brands/{fixture['brand_id']}/logo.png",
+ f"brands/{fixture['brand_id']}/archive/legacy.webp",
+ f"brands/{fixture['brand_id']}/logos/orphan.jpg",
+ )
+ for path in seeded_paths:
+ asyncio.run(storage.upload_logo(path, b"legacy", "image/png"))
+ with TestClient(app) as client:
+ _add_real_key(
+ client,
+ fixture,
+ SecretFixture(f"brand-delete-a-{uuid4().hex}-J9K0"),
+ make_active=True,
+ )
+ _add_real_key(
+ client,
+ fixture,
+ SecretFixture(f"brand-delete-b-{uuid4().hex}-L1M2"),
+ make_active=False,
+ )
+ logo = client.post(
+ f"/api/v1/brands/{fixture['brand_id']}/logo",
+ headers=fixture["headers"],
+ files={"file": ("logo.png", b"\x89PNG\r\n\x1a\nlogo", "image/png")},
+ )
+ assert logo.status_code == 200
+ with fixture["engine"].connect() as connection:
+ secret_ids = connection.execute(
+ text(
+ "SELECT vault_secret_id FROM provider_keys "
+ "WHERE brand_id = :brand_id"
+ ),
+ {"brand_id": fixture["brand_id"]},
+ ).scalars().all()
+ deleted = client.request(
+ "DELETE",
+ f"/api/v1/brands/{fixture['brand_id']}",
+ headers=fixture["headers"],
+ json={"confirm_name": "Provider Keys Integration"},
+ )
+
+ assert deleted.status_code == 204
+ with fixture["engine"].connect() as connection:
+ assert connection.execute(
+ text(
+ """
+ SELECT
+ (SELECT count(*) FROM brands WHERE id = :brand_id) +
+ (SELECT count(*) FROM provider_keys WHERE brand_id = :brand_id) +
+ (SELECT count(*) FROM provider_key_idempotency WHERE brand_id = :brand_id) +
+ (SELECT count(*) FROM brand_asset_operations WHERE brand_id = :brand_id)
+ """
+ ),
+ {"brand_id": fixture["brand_id"]},
+ ).scalar_one() == 0
+ assert connection.execute(
+ text("SELECT count(*) FROM vault.secrets WHERE id = ANY(:ids)"),
+ {"ids": secret_ids},
+ ).scalar_one() == 0
+ assert asyncio.run(storage.brand_prefix_is_empty(fixture["brand_id"]))
+
+
+def test_brand_vault_failure_rolls_back_and_retry_completes(provider_fixture):
+ from backend.app.main import app
+ from backend.app.routes.brands import get_brand_deletion
+ from backend.app.services.brand_deletion import BrandDeletion
+ from backend.app.services.brand_storage import get_brand_storage
+ from backend.app.services.brand_store import BrandStore
+
+ class FailingConnection:
+ def __init__(self, connection):
+ self.connection = connection
+
+ def execute(self, statement, parameters=None):
+ if "DELETE FROM vault.secrets" in str(statement):
+ raise SQLAlchemyError("injected brand vault failure")
+ return self.connection.execute(statement, parameters)
+
+ class FailingEngine:
+ def __init__(self, engine):
+ self.engine = engine
+
+ @contextmanager
+ def begin(self):
+ with self.engine.begin() as connection:
+ yield FailingConnection(connection)
+
+ fixture = provider_fixture
+ with TestClient(app) as client:
+ key_id = _add_real_key(
+ client,
+ fixture,
+ SecretFixture(f"brand-vault-retry-{uuid4().hex}-R5S6"),
+ make_active=True,
+ )
+ failing_deletion = BrandDeletion(
+ FailingEngine(fixture["engine"]),
+ BrandStore(fixture["engine"]),
+ get_brand_storage(),
+ )
+ app.dependency_overrides[get_brand_deletion] = lambda: failing_deletion
+ try:
+ failed = client.request(
+ "DELETE",
+ f"/api/v1/brands/{fixture['brand_id']}",
+ headers=fixture["headers"],
+ json={"confirm_name": "Provider Keys Integration"},
+ )
+ finally:
+ app.dependency_overrides.pop(get_brand_deletion, None)
+
+ assert failed.status_code == 503
+ assert failed.json()["error"]["code"] == "BRAND_CLEANUP_REQUIRED"
+ with fixture["engine"].connect() as connection:
+ retained = connection.execute(
+ text(
+ "SELECT b.deletion_state, pk.lifecycle, pk.is_active, "
+ "EXISTS (SELECT 1 FROM vault.secrets "
+ "WHERE id = pk.vault_secret_id) AS secret_exists "
+ "FROM brands b JOIN provider_keys pk ON pk.brand_id = b.id "
+ "WHERE b.id = :brand_id AND pk.id = :key_id"
+ ),
+ {"brand_id": fixture["brand_id"], "key_id": key_id},
+ ).one()
+ assert retained == ("cleanup_required", "cleanup_required", False, True)
+
+ retried = client.request(
+ "DELETE",
+ f"/api/v1/brands/{fixture['brand_id']}",
+ headers=fixture["headers"],
+ json={"confirm_name": "Provider Keys Integration"},
+ )
+
+ assert retried.status_code == 204
+ with fixture["engine"].connect() as connection:
+ assert connection.execute(
+ text("SELECT count(*) FROM brands WHERE id = :brand_id"),
+ {"brand_id": fixture["brand_id"]},
+ ).scalar_one() == 0
diff --git a/backend/tests/integration/test_provider_key_rls.py b/backend/tests/integration/test_provider_key_rls.py
index 0b6820b..7adb534 100644
--- a/backend/tests/integration/test_provider_key_rls.py
+++ b/backend/tests/integration/test_provider_key_rls.py
@@ -2,6 +2,7 @@
import json
import os
+from collections.abc import Container
from types import SimpleNamespace
from uuid import uuid4
@@ -20,6 +21,13 @@
)
+def _assert_not_exposed(
+ observable: Container[str], *prohibited_values: str
+) -> None:
+ if any(value in observable for value in prohibited_values):
+ raise AssertionError("sensitive value was exposed")
+
+
class SecurityFixture(dict):
def __repr__(self) -> str:
return ""
@@ -797,12 +805,12 @@ def test_provider_key_api_owner_and_hidden_brand_parity(security_fixture):
}
assert owner_add.status_code == 201
assert owner_add.json()["key_hint"] == "***Q7_W"
- assert raw_key not in owner_add.text
+ _assert_not_exposed(owner_add.text, raw_key)
for hidden, missing in ((hidden_list, missing_list), (hidden_add, missing_add)):
assert hidden.status_code == missing.status_code == 404
assert hidden.json()["error"]["code"] == missing.json()["error"]["code"] == "BRAND_NOT_FOUND"
assert hidden.json()["error"]["message"] == missing.json()["error"]["message"] == "Brand not found."
- assert raw_key not in hidden_add.text + missing_add.text
+ _assert_not_exposed(hidden_add.text + missing_add.text, raw_key)
def test_provider_key_data_api_exposes_only_owner_safe_columns(security_fixture):
diff --git a/backend/tests/integration/test_provider_keys.py b/backend/tests/integration/test_provider_keys.py
index aa45a0c..91d37d6 100644
--- a/backend/tests/integration/test_provider_keys.py
+++ b/backend/tests/integration/test_provider_keys.py
@@ -4,6 +4,7 @@
import os
import threading
import time
+from collections.abc import Container
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from uuid import UUID, uuid4
@@ -19,6 +20,13 @@
)
+def _assert_not_exposed(
+ observable: Container[str], *prohibited_values: str
+) -> None:
+ if any(value in observable for value in prohibited_values):
+ raise AssertionError("sensitive value was exposed")
+
+
class SecretFixture(str):
def __repr__(self) -> str:
return ""
@@ -279,8 +287,7 @@ def test_real_vault_add_list_activation_idempotency_and_retired_receipt(
("gemini", gemini.json()["id"]),
]
serialized = "".join(response.text for response in (first, gemini, replacement, retry, listed))
- for raw_key in (openai_key, gemini_key, replacement_key):
- assert raw_key not in serialized
+ _assert_not_exposed(serialized, openai_key, gemini_key, replacement_key)
engine = fixture["engine"]
with engine.connect() as connection:
diff --git a/backend/tests/unit/test_brand_storage.py b/backend/tests/unit/test_brand_storage.py
new file mode 100644
index 0000000..d216bc1
--- /dev/null
+++ b/backend/tests/unit/test_brand_storage.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from uuid import UUID
+
+import httpx
+import pytest
+
+from backend.app.services.brand_storage import (
+ BrandStorage,
+ BrandStorageUnknownError,
+)
+
+
+def test_upload_uses_token_owned_path_without_upsert():
+ observed: dict[str, str] = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ observed["path"] = request.url.path
+ observed["upsert"] = request.headers["x-upsert"]
+ return httpx.Response(200)
+
+ storage = BrandStorage(
+ "https://example.supabase.co", "secret", httpx.MockTransport(handler)
+ )
+ brand_id = UUID("11111111-1111-1111-1111-111111111111")
+ operation_id = UUID("22222222-2222-2222-2222-222222222222")
+ path = storage.logo_path(brand_id, operation_id, "png")
+
+ asyncio.run(storage.upload_logo(path, b"png", "image/png"))
+
+ assert path == f"brands/{brand_id}/logos/{operation_id}.png"
+ assert observed == {
+ "path": f"/storage/v1/object/brand-assets/{path}",
+ "upsert": "false",
+ }
+
+
+def test_upload_transport_failure_is_an_unknown_remote_outcome():
+ def handler(request: httpx.Request) -> httpx.Response:
+ raise httpx.ReadTimeout("ambiguous", request=request)
+
+ storage = BrandStorage(
+ "https://example.supabase.co", "secret", httpx.MockTransport(handler)
+ )
+
+ with pytest.raises(BrandStorageUnknownError):
+ asyncio.run(
+ storage.upload_logo(
+ "brands/brand/logos/token.png", b"png", "image/png"
+ )
+ )
+
+
+def test_prefix_cleanup_restarts_at_zero_and_removes_nested_and_legacy_objects():
+ brand_id = UUID("11111111-1111-1111-1111-111111111111")
+ prefix = f"brands/{brand_id}"
+ objects = {
+ f"{prefix}/logo.png",
+ f"{prefix}/logos/token.png",
+ f"{prefix}/nested/archive/legacy.webp",
+ }
+ list_offsets: list[tuple[str, int]] = []
+ deleted_batches: list[list[str]] = []
+
+ def children(directory: str) -> list[dict[str, object]]:
+ direct_objects: set[str] = set()
+ direct_folders: set[str] = set()
+ marker = f"{directory}/"
+ for path in objects:
+ if not path.startswith(marker):
+ continue
+ remainder = path[len(marker) :]
+ first, separator, _ = remainder.partition("/")
+ if separator:
+ direct_folders.add(first)
+ else:
+ direct_objects.add(first)
+ return [
+ {"name": name, "metadata": None} for name in sorted(direct_folders)
+ ] + [
+ {"name": name, "metadata": {"size": 1}}
+ for name in sorted(direct_objects)
+ ]
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith("/object/list/brand-assets"):
+ payload = json.loads(request.content)
+ directory = payload["prefix"]
+ offset = payload["offset"]
+ list_offsets.append((directory, offset))
+ page = children(directory)[offset : offset + payload["limit"]]
+ return httpx.Response(200, json=page)
+ if request.method == "DELETE":
+ paths = json.loads(request.content)["prefixes"]
+ deleted_batches.append(paths)
+ objects.difference_update(paths)
+ return httpx.Response(200, json=[])
+ raise AssertionError(f"Unexpected request: {request.method} {request.url}")
+
+ storage = BrandStorage(
+ "https://example.supabase.co", "secret", httpx.MockTransport(handler)
+ )
+ asyncio.run(storage.delete_brand_prefix(brand_id))
+
+ assert objects == set()
+ assert {path for batch in deleted_batches for path in batch} == {
+ f"{prefix}/logo.png",
+ f"{prefix}/logos/token.png",
+ f"{prefix}/nested/archive/legacy.webp",
+ }
+ assert list_offsets.count((prefix, 0)) >= 3
diff --git a/backend/tests/unit/test_provider_validation.py b/backend/tests/unit/test_provider_validation.py
index edc6bb3..02ca18b 100644
--- a/backend/tests/unit/test_provider_validation.py
+++ b/backend/tests/unit/test_provider_validation.py
@@ -1,8 +1,10 @@
from __future__ import annotations
import asyncio
+import hashlib
+import hmac
import logging
-from collections.abc import Callable
+from collections.abc import Callable, Container
import httpx
import pytest
@@ -17,6 +19,13 @@
RAW_KEY = "unit-test-provider-secret-A1B2"
+def _assert_not_exposed(
+ observable: Container[str], *prohibited_values: str
+) -> None:
+ if any(value in observable for value in prohibited_values):
+ raise AssertionError("sensitive value was exposed")
+
+
def _validate(
provider: str,
handler: Callable[[httpx.Request], httpx.Response],
@@ -92,12 +101,13 @@ def handler(request: httpx.Request) -> httpx.Response:
request = requests[0]
assert request.method == "GET"
assert str(request.url) == expected_url
- assert request.headers[auth_header[0]] == auth_header[1]
+ assert hmac.compare_digest(request.headers[auth_header[0]], auth_header[1])
assert ("x-goog-api-key" in request.headers) is (provider == "gemini")
assert ("authorization" in request.headers) is (provider == "openai")
- assert "openai-organization" not in request.headers
- assert "openai-project" not in request.headers
- assert RAW_KEY not in str(request.url)
+ _assert_not_exposed(
+ request.headers, "openai-organization", "openai-project"
+ )
+ _assert_not_exposed(str(request.url), RAW_KEY)
_assert_result(
result,
"valid",
@@ -452,7 +462,7 @@ def test_extracts_provider_request_id_only_from_response_header(provider: str):
"valid",
"VALID",
f"{'OpenAI' if provider == 'openai' else 'Gemini'} accepted this API key.",
- "provider-request-123",
+ f"sha256:{hashlib.sha256(b'provider-request-123').hexdigest()[:16]}",
)
@@ -467,7 +477,7 @@ def test_provider_request_id_cannot_echo_the_raw_key():
)
assert result.provider_request_id is None
- assert RAW_KEY not in repr(result)
+ _assert_not_exposed(repr(result), RAW_KEY)
@pytest.mark.parametrize("provider", ["openai", "gemini"])
@@ -498,7 +508,12 @@ def handler(request: httpx.Request) -> httpx.Response:
rendered_logs = "\n".join(caplog.messages)
rendered_result = repr(result)
- for secret in (RAW_KEY, authorization_value, provider_body_secret):
- assert secret not in rendered_logs
- assert secret not in rendered_result
- assert result.provider_request_id == "safe-request-id"
+ _assert_not_exposed(
+ rendered_logs, RAW_KEY, authorization_value, provider_body_secret
+ )
+ _assert_not_exposed(
+ rendered_result, RAW_KEY, authorization_value, provider_body_secret
+ )
+ assert result.provider_request_id == (
+ f"sha256:{hashlib.sha256(b'safe-request-id').hexdigest()[:16]}"
+ )
diff --git a/docs/docker.md b/docs/docker.md
index 955c9d8..dd009b2 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -7,7 +7,7 @@ container. Run every local command from the repository root.
- Docker with the BuildKit builder running.
- A reachable Supabase project and its project URL, publishable key, secret key, JWT
- secret, and PostgreSQL connection string.
+ secret, and a private backend PostgreSQL connection as described below.
- For Bunny deployment, an image repository in Docker Hub or GitHub Container Registry
and access to the [bunny.net dashboard](https://dash.bunny.net/).
@@ -43,7 +43,7 @@ Git and Docker; do not commit it.
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=replace-with-secret-key
SUPABASE_JWT_SECRET=replace-with-jwt-secret
-DATABASE_URL=postgresql://user:password@host:5432/database
+DATABASE_URL=replace-with-private-backend-connection-string
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=replace-with-publishable-key
NEXT_PUBLIC_API_URL=/api
@@ -97,7 +97,7 @@ credentials remain server-side.
| `SUPABASE_URL` | Backend | Supabase API URL reachable from inside the container |
| `SUPABASE_SECRET_KEY` | Backend | Supabase secret/service-role credential; keep secret |
| `SUPABASE_JWT_SECRET` | Backend | JWT verification secret; keep secret |
-| `DATABASE_URL` | Backend | PostgreSQL connection string reachable from inside the container; keep credentials secret |
+| `DATABASE_URL` | Backend | Private, non-client PostgreSQL login reachable from the container; keep the entire value secret |
| `NEXT_PUBLIC_SUPABASE_URL` | Browser | Supabase API URL reachable from each user's browser |
| `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | Browser | Supabase publishable key; public by design |
| `NEXT_PUBLIC_API_URL` | Browser | `/api` for same-origin backend access through Next.js |
@@ -109,6 +109,50 @@ such as `http://host.docker.internal:54321`, while the browser needs
`http://localhost:54321`. On Linux, add
`--add-host=host.docker.internal:host-gateway` to `docker run` when using that hostname.
+### Hosted database role
+
+`DATABASE_URL` is used directly by SQLAlchemy; `SUPABASE_SECRET_KEY` does not grant its
+database privileges. Before deployment, have a database administrator create a private
+login that is not `anon`, `authenticated`, or `service_role`. The hosted login must be a
+non-superuser with `BYPASSRLS`, because table ownership does not bypass forced RLS. The
+local `postgres` superuser is accepted only for a loopback local-development database.
+
+The login requires `SELECT`, `INSERT`, `UPDATE`, and `DELETE` on `brands`,
+`provider_keys`, `provider_key_idempotency`, and `brand_asset_operations`. Its Vault
+capabilities must be exactly those used by the backend:
+
+- `USAGE` on schema `vault`.
+- `EXECUTE` on `vault.create_secret(text,text,text,uuid)`.
+- Column `SELECT` on `id` and `decrypted_secret` in `vault.decrypted_secrets`.
+- Column `SELECT` on `id`, plus `DELETE`, on `vault.secrets`.
+- No Vault schema `CREATE`, `vault.update_secret(...)` execution, or table-wide Vault
+ `SELECT`.
+
+Apply the provider-key migration before starting the container. Every backend startup
+checks the connected role's identity, forced-RLS bypass, application DML, and Vault
+least-privilege capabilities; a mismatch stops the backend before it serves requests.
+Do not place `DATABASE_URL` in a `NEXT_PUBLIC_*` variable, browser configuration, image,
+log, ticket, or command output.
+
+### Provider validation egress
+
+Allow direct outbound HTTPS (TCP 443, including DNS and TLS) only as needed for these
+official read-only model-list requests:
+
+- OpenAI: `GET https://api.openai.com/v1/models`.
+- Gemini: `GET https://generativelanguage.googleapis.com/v1beta/models?pageSize=1`.
+
+Validation does not follow redirects, use provider SDKs, or generate content. If an
+egress policy filters by hostname, allow `api.openai.com` and
+`generativelanguage.googleapis.com`; do not add alternate or redirect hosts.
+
+For a real validation check, create disposable keys in dedicated provider projects with
+only the access needed to list models. Enter them through the PostForge UI, never through
+source files, environment files, shell arguments/history, screenshots, fixtures, logs,
+or tickets. Revoke them at the provider immediately after the check and verify they do
+not appear in browser responses or application logs. A provider outage or temporary
+validation result blocks the check; it is not a passing result.
+
Rotate a leaked credential at its source, update `.env.docker` or the hosting platform's
runtime variables, and replace the running container. Rebuilding the image is neither
required nor an acceptable way to distribute secrets.
@@ -202,6 +246,16 @@ then replace the container. Do not print secret values into shared logs or ticke
both local probes pass but sign-in fails, check Supabase and database connectivity
separately; external services are deliberately outside the container healthcheck.
+If logs report `database role lacks required backend privileges`, do not bypass the
+startup check or substitute a client/service role. Confirm `DATABASE_URL` reaches the
+intended database as the intended private login, migration `00016` is applied, and the
+role has every forced-RLS, application, and Vault capability in **Hosted database role**
+without broader Vault access. Have a database administrator correct the role, rotate the
+connection credential if it was exposed while diagnosing, and replace the container.
+Provider validation failures with an otherwise healthy app should be checked against the
+two required outbound hosts; never log request headers, keys, or provider response bodies
+while testing egress.
+
### Port 3000 is already allocated
Stop the conflicting container shown by `docker ps`, or use a different host port while
diff --git a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
index 942831a..b04f0bd 100644
--- a/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
+++ b/frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx
@@ -137,6 +137,7 @@ export default function ProviderKeysPage() {
const retryIdRef = useRef(null);
const validatingKeyIdsRef = useRef(new Set());
const activatingKeyIdsRef = useRef(new Set());
+ const deletingKeyIdsRef = useRef(new Set());
const [brand, setBrand] = useState(null);
const [keys, setKeys] = useState([]);
const [provider, setProvider] = useState("openai");
@@ -153,6 +154,13 @@ export default function ProviderKeysPage() {
const [activatingKeyIds, setActivatingKeyIds] = useState>(
() => new Set()
);
+ const [deletingKeyIds, setDeletingKeyIds] = useState>(
+ () => new Set()
+ );
+ const [deleteFeedbackByKey, setDeleteFeedbackByKey] = useState<
+ Record
+ >({});
+ const [deleteNotice, setDeleteNotice] = useState(null);
const [validationFeedbackByKey, setValidationFeedbackByKey] = useState<
Record
>({});
@@ -442,6 +450,105 @@ export default function ProviderKeysPage() {
}
}
+ async function deleteKey(keyId: string) {
+ if (deletingKeyIdsRef.current.has(keyId)) return;
+
+ deletingKeyIdsRef.current.add(keyId);
+ setDeletingKeyIds((current) => new Set(current).add(keyId));
+ setDeleteNotice(null);
+ setDeleteFeedbackByKey((current) => {
+ const next = { ...current };
+ delete next[keyId];
+ return next;
+ });
+
+ try {
+ const { data } = await supabase.auth.getSession();
+ const session = data.session;
+ if (!session) {
+ router.push("/login");
+ return;
+ }
+
+ const keysPath = `${apiBase}/v1/brands/${encodeURIComponent(brandId)}/keys`;
+ let response: Response | null = null;
+ try {
+ response = await fetch(`${keysPath}/${encodeURIComponent(keyId)}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${session.access_token}` },
+ });
+ } catch {
+ // A lost response can hide a committed deletion, so prove absence from a fresh list.
+ }
+
+ if (response?.status === 204) {
+ setKeys((current) => current.filter((key) => key.id !== keyId));
+ setDeleteNotice("Provider key deleted. No replacement key was activated.");
+ return;
+ }
+
+ if (response === null || response.status === 404 || response.status >= 500) {
+ try {
+ const listResponse = await fetch(keysPath, {
+ headers: { Authorization: `Bearer ${session.access_token}` },
+ });
+ if (!listResponse.ok) throw new Error("Unable to reconcile provider keys.");
+
+ const body = (await listResponse.json()) as { keys: ProviderKey[] };
+ setKeys(body.keys);
+ if (!body.keys.some((key) => key.id === keyId)) {
+ setDeleteNotice("Provider key deletion was confirmed from the refreshed list. No replacement key was activated.");
+ return;
+ }
+
+ setDeleteFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: {
+ message: "Key cleanup did not complete. Retry deletion.",
+ tone: "error",
+ },
+ }));
+ } catch {
+ setDeleteFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: {
+ message: "The deletion outcome is unknown. Refresh or retry deletion.",
+ tone: "error",
+ },
+ }));
+ }
+ return;
+ }
+
+ const body = (await response.json().catch(() => null)) as ErrorResponse | null;
+ setDeleteFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: {
+ message:
+ body?.error?.code === "BRAND_CLEANUP_REQUIRED"
+ ? "Brand cleanup owns this deletion. Retry deletion from the brand page."
+ : "Unable to delete this key.",
+ tone: "error",
+ },
+ }));
+ } catch {
+ setDeleteFeedbackByKey((current) => ({
+ ...current,
+ [keyId]: {
+ message: "The deletion outcome is unknown. Refresh or retry deletion.",
+ tone: "error",
+ },
+ }));
+ } finally {
+ deletingKeyIdsRef.current.delete(keyId);
+ setDeletingKeyIds((current) => {
+ const next = new Set(current);
+ next.delete(keyId);
+ return next;
+ });
+ }
+ }
+
if (isLoading) {
return Loading provider keys...
;
}
@@ -467,6 +574,10 @@ export default function ProviderKeysPage() {
const providerName = PROVIDERS.find((item) => item.id === provider)?.name ?? provider;
const providerKeys = keys.filter((key) => key.provider === provider);
const cleanupRequired = brand.cleanup_state === "cleanup_required";
+ const addBlockedByCleanup =
+ cleanupRequired ||
+ deletingKeyIds.size > 0 ||
+ keys.some((key) => key.cleanup_state === "cleanup_required");
return (
@@ -512,6 +623,9 @@ export default function ProviderKeysPage() {
+ {deleteNotice ? (
+
{deleteNotice}
+ ) : null}
{providerName} keys
{providerKeys.length} saved
@@ -524,14 +638,19 @@ export default function ProviderKeysPage() {
providerKeys.map((key) => {
const isValidating = validatingKeyIds.has(key.id);
const isActivating = activatingKeyIds.has(key.id);
+ const isDeleting = deletingKeyIds.has(key.id);
const feedback =
- activationFeedbackByKey[key.id] ?? validationFeedbackByKey[key.id];
+ deleteFeedbackByKey[key.id] ??
+ activationFeedbackByKey[key.id] ??
+ validationFeedbackByKey[key.id];
const validationDisabled =
isValidating ||
+ isDeleting ||
cleanupRequired ||
key.cleanup_state === "cleanup_required";
const activationDisabled =
isActivating ||
+ isDeleting ||
cleanupRequired ||
key.cleanup_state === "cleanup_required" ||
key.is_valid === false;
@@ -585,6 +704,23 @@ export default function ProviderKeysPage() {
>
{isValidating ? "Validating..." : "Validate key"}
+ void deleteKey(key.id)}
+ >
+ {isDeleting
+ ? "Deleting..."
+ : key.cleanup_state === "cleanup_required"
+ ? "Retry deletion"
+ : "Delete key"}
+
{feedback ? (
@@ -619,7 +755,7 @@ export default function ProviderKeysPage() {
type="text"
maxLength={100}
value={label}
- disabled={isAdding || cleanupRequired}
+ disabled={isAdding || addBlockedByCleanup}
onChange={(event) => {
setLabel(event.target.value);
beginNewAttempt();
@@ -633,7 +769,7 @@ export default function ProviderKeysPage() {
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:border-black"
type="password"
autoComplete="new-password"
- disabled={isAdding || cleanupRequired}
+ disabled={isAdding || addBlockedByCleanup}
onChange={beginNewAttempt}
/>
@@ -642,7 +778,7 @@ export default function ProviderKeysPage() {
className="mt-1"
type="checkbox"
checked={makeActive}
- disabled={isAdding || cleanupRequired}
+ disabled={isAdding || addBlockedByCleanup}
onChange={(event) => {
setMakeActive(event.target.checked);
beginNewAttempt();
@@ -654,7 +790,7 @@ export default function ProviderKeysPage() {
{isAdding ? "Storing securely..." : `Add ${providerName} key`}
diff --git a/frontend/app/(dashboard)/brands/[brandId]/page.tsx b/frontend/app/(dashboard)/brands/[brandId]/page.tsx
index f70572e..3f11ead 100644
--- a/frontend/app/(dashboard)/brands/[brandId]/page.tsx
+++ b/frontend/app/(dashboard)/brands/[brandId]/page.tsx
@@ -223,18 +223,46 @@ export default function BrandDetailPage() {
return;
}
- const response = await fetch(
- `${apiBase}/v1/brands/${encodeURIComponent(brandId)}`,
- {
+ const brandPath = `${apiBase}/v1/brands/${encodeURIComponent(brandId)}`;
+ let response: Response | null = null;
+ try {
+ response = await fetch(brandPath, {
method: "DELETE",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ confirm_name: confirmationName }),
+ });
+ } catch {
+ // Reconcile below before deciding whether an ambiguous deletion completed.
+ }
+
+ if (response?.status !== 204) {
+ if (response === null || response.status === 404 || response.status >= 500) {
+ try {
+ const listResponse = await fetch(`${apiBase}/v1/brands`, {
+ headers: { Authorization: `Bearer ${session.access_token}` },
+ });
+ if (!listResponse.ok) throw new Error("Unable to reconcile brands.");
+
+ const body = (await listResponse.json()) as { brands: Brand[] };
+ const retainedBrand = body.brands.find((item) => item.id === brandId);
+ if (!retainedBrand) {
+ window.dispatchEvent(new Event("postforge:brands-changed"));
+ router.push("/brands");
+ router.refresh();
+ return;
+ }
+
+ setBrand(retainedBrand);
+ setDeleteError("Brand cleanup did not complete. Retry deletion.");
+ } catch {
+ setDeleteError("The deletion outcome is unknown. Refresh or retry deletion.");
+ }
+ return;
}
- );
- if (!response.ok) {
+
const body = await response.json().catch(() => null);
setDeleteError(body?.error?.message ?? "Unable to delete the brand.");
return;
@@ -317,9 +345,9 @@ export default function BrandDetailPage() {
Configure brand-scoped OpenAI and Gemini credentials without exposing them after submission.
- {cleanupRequired ? (
+ {cleanupRequired || isDeleting ? (
- Provider setup is unavailable until brand cleanup completes.
+ Provider setup is unavailable while brand deletion is in progress.
) : (
Delete brand
- This permanently deletes the brand and its logo. This action cannot be
- undone.
+ This permanently deletes the brand, provider keys, and all stored assets.
+ This action cannot be undone.
diff --git a/frontend/app/(dashboard)/brands/page.tsx b/frontend/app/(dashboard)/brands/page.tsx
index 9808beb..6168285 100644
--- a/frontend/app/(dashboard)/brands/page.tsx
+++ b/frontend/app/(dashboard)/brands/page.tsx
@@ -11,6 +11,7 @@ type Brand = {
id: string;
name: string;
logo_url: string | null;
+ cleanup_state: "normal" | "cleanup_required";
created_at: string;
};
@@ -121,9 +122,16 @@ export default function BrandsPage() {
{brand.name.slice(0, 2).toUpperCase()}
-
- {brand.name}
-
+
+
+ {brand.name}
+
+ {brand.cleanup_state === "cleanup_required" ? (
+
+ Cleanup required
+
+ ) : null}
+
Created {new Date(brand.created_at).toLocaleDateString()}
diff --git a/frontend/tests/e2e/provider-keys.spec.ts b/frontend/tests/e2e/provider-keys.spec.ts
index e5dac2d..593736e 100644
--- a/frontend/tests/e2e/provider-keys.spec.ts
+++ b/frontend/tests/e2e/provider-keys.spec.ts
@@ -350,3 +350,216 @@ test("adds, groups, and reloads safe provider-key metadata", async ({ page }) =>
expect(responseWasOpaque).toBeTruthy();
expect(rawKeyIsVisible).toBeFalsy();
});
+
+test("reconciles key and brand deletion outcomes without activating replacements", async ({
+ page,
+}) => {
+ const brandId = crypto.randomUUID();
+ const activeKeyId = crypto.randomUUID();
+ const inactiveKeyId = crypto.randomUUID();
+ const retryKeyId = crypto.randomUUID();
+ let brandExists = true;
+ let brandCleanupState: "normal" | "cleanup_required" = "normal";
+ let retryKeyAttempts = 0;
+ let brandDeleteAttempts = 0;
+ const savedKeys: SafeKey[] = [
+ {
+ id: activeKeyId,
+ provider: "openai",
+ label: "Active key",
+ key_hint: "***1111",
+ is_active: true,
+ is_valid: true,
+ last_validated_at: "2026-07-28T10:00:00Z",
+ last_validation_error: null,
+ cleanup_state: "normal",
+ created_at: "2026-07-28T09:00:00Z",
+ },
+ {
+ id: inactiveKeyId,
+ provider: "openai",
+ label: "Inactive key",
+ key_hint: "***2222",
+ is_active: false,
+ is_valid: null,
+ last_validated_at: null,
+ last_validation_error: null,
+ cleanup_state: "normal",
+ created_at: "2026-07-28T08:00:00Z",
+ },
+ {
+ id: retryKeyId,
+ provider: "openai",
+ label: "Cleanup key",
+ key_hint: "***3333",
+ is_active: false,
+ is_valid: null,
+ last_validated_at: null,
+ last_validation_error: null,
+ cleanup_state: "normal",
+ created_at: "2026-07-28T07:00:00Z",
+ },
+ ];
+
+ const brandBody = () => ({
+ id: brandId,
+ name: "Deletion Test Brand",
+ logo_url: null,
+ cleanup_state: brandCleanupState,
+ created_at: "2026-07-28T06:00:00Z",
+ });
+
+ await page.route("**/api/v1/**", async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+
+ if (url.pathname === "/api/v1/brands" && request.method() === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ brands: brandExists ? [brandBody()] : [] }),
+ });
+ return;
+ }
+
+ if (url.pathname === `/api/v1/brands/${brandId}` && request.method() === "GET") {
+ await route.fulfill({
+ status: brandExists ? 200 : 404,
+ contentType: "application/json",
+ body: JSON.stringify(
+ brandExists
+ ? brandBody()
+ : { error: { code: "BRAND_NOT_FOUND", message: "Brand not found." } }
+ ),
+ });
+ return;
+ }
+
+ if (url.pathname === `/api/v1/brands/${brandId}/keys` && request.method() === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ keys: savedKeys }),
+ });
+ return;
+ }
+
+ const keyDeleteMatch = url.pathname.match(
+ new RegExp(`^/api/v1/brands/${brandId}/keys/([^/]+)$`)
+ );
+ if (keyDeleteMatch && request.method() === "DELETE") {
+ const keyId = keyDeleteMatch[1];
+ const keyIndex = savedKeys.findIndex((key) => key.id === keyId);
+ if (keyId === activeKeyId) {
+ savedKeys.splice(keyIndex, 1);
+ await route.fulfill({ status: 204 });
+ return;
+ }
+ if (keyId === inactiveKeyId) {
+ savedKeys.splice(keyIndex, 1);
+ await route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "PROVIDER_KEY_NOT_FOUND" } }),
+ });
+ return;
+ }
+ if (keyId === retryKeyId && retryKeyAttempts++ === 0) {
+ savedKeys[keyIndex] = {
+ ...savedKeys[keyIndex],
+ is_active: false,
+ is_valid: null,
+ last_validated_at: null,
+ cleanup_state: "cleanup_required",
+ };
+ await route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "KEY_CLEANUP_REQUIRED" } }),
+ });
+ return;
+ }
+ savedKeys.splice(keyIndex, 1);
+ await route.fulfill({ status: 204 });
+ return;
+ }
+
+ if (url.pathname === `/api/v1/brands/${brandId}` && request.method() === "DELETE") {
+ const payload = request.postDataJSON() as { confirm_name: string };
+ expect(payload.confirm_name).toBe("Deletion Test Brand");
+ brandDeleteAttempts += 1;
+ if (brandDeleteAttempts === 1) {
+ brandCleanupState = "cleanup_required";
+ await route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "BRAND_CLEANUP_REQUIRED" } }),
+ });
+ return;
+ }
+
+ brandExists = false;
+ await route.abort("connectionreset");
+ return;
+ }
+
+ await route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { code: "NOT_FOUND" } }),
+ });
+ });
+
+ await page.goto("/signup");
+ await page.getByLabel("Email").fill(`delete-flows-${Date.now()}@example.com`);
+ await page.getByLabel("Password").fill("password123");
+ await page.getByRole("button", { name: "Sign up" }).click();
+ await expect(page.getByText(/Account created/i)).toBeVisible();
+
+ await page.goto(`/brands/${brandId}/keys`);
+ const activeKey = page.getByRole("article").filter({
+ has: page.getByRole("heading", { name: "Active key", exact: true }),
+ });
+ const inactiveKey = page.getByRole("article").filter({
+ has: page.getByRole("heading", { name: "Inactive key", exact: true }),
+ });
+ const cleanupKey = page.getByRole("article").filter({
+ has: page.getByRole("heading", { name: "Cleanup key", exact: true }),
+ });
+
+ await activeKey.getByRole("button", { name: "Delete key" }).click();
+ await expect(activeKey).toHaveCount(0);
+ await expect(inactiveKey.getByText("Inactive", { exact: true })).toBeVisible();
+ await expect(inactiveKey.getByRole("button", { name: "Activate key" })).toBeVisible();
+
+ await inactiveKey.getByRole("button", { name: "Delete key" }).click();
+ await expect(inactiveKey).toHaveCount(0);
+
+ await cleanupKey.getByRole("button", { name: "Delete key" }).click();
+ await expect(cleanupKey.getByText("Cleanup required", { exact: true })).toBeVisible();
+ await expect(cleanupKey.getByRole("button", { name: "Activate key" })).toBeDisabled();
+ await expect(cleanupKey.getByRole("button", { name: "Validate key" })).toBeDisabled();
+ await expect(page.getByLabel("API key")).toBeDisabled();
+ await cleanupKey.getByRole("button", { name: "Retry deletion" }).click();
+ await expect(cleanupKey).toHaveCount(0);
+
+ await page.goto(`/brands/${brandId}`);
+ const confirmation = page.getByLabel(/Type Deletion Test Brand to confirm/);
+ await confirmation.fill("Deletion Test Brand");
+ await page.getByRole("button", { name: "Delete brand permanently" }).click();
+ await expect(page.getByText("Cleanup required", { exact: true })).toBeVisible();
+ await expect(confirmation).toHaveValue("Deletion Test Brand");
+ await expect(page.getByRole("button", { name: "Retry brand deletion" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Manage provider keys" })).toHaveCount(0);
+ await expect(page.locator('input[type="file"]')).toBeDisabled();
+
+ await page.goto("/brands");
+ const cleanupBrand = page.getByRole("link", { name: /Deletion Test Brand/ });
+ await expect(cleanupBrand.getByText("Cleanup required", { exact: true })).toBeVisible();
+ await cleanupBrand.click();
+ await page.getByLabel(/Type Deletion Test Brand to confirm/).fill("Deletion Test Brand");
+ await page.getByRole("button", { name: "Retry brand deletion" }).click();
+ await expect(page).toHaveURL(/\/brands$/);
+ await expect(page.getByText("Deletion Test Brand")).toHaveCount(0);
+ expect(brandDeleteAttempts).toBe(2);
+});
diff --git a/specs/004-provider-keys/tasks.md b/specs/004-provider-keys/tasks.md
index ef7447d..e5ae667 100644
--- a/specs/004-provider-keys/tasks.md
+++ b/specs/004-provider-keys/tasks.md
@@ -165,25 +165,25 @@ success must leave no row, secret, receipt, operation, or object under the brand
### Tests for User Story 4
-- [ ] T041 [P] [US4] Extend `backend/tests/contract/test_provider_keys.py` with failing individual-delete tests for active/inactive/cleanup-required keys, 204 only after secret and row absence, retired idempotency receipt, absent-secret idempotency, opaque ownership, brand-cleanup precedence, and fixed 503 `KEY_CLEANUP_REQUIRED` retention
-- [ ] T042 [P] [US4] Extend `backend/tests/contract/test_brands.py` with failing cleanup-aware logo and brand-delete tests for durable mutation conflicts, exact confirmation, `BRAND_CLEANUP_REQUIRED`, retry behavior, visible cleanup state, and prohibition of 204 while Storage/Vault/asset-operation cleanup is unresolved
-- [ ] T043 [P] [US4] Extend `backend/tests/integration/test_provider_key_cleanup.py` with failing individual cleanup tests for durable inactive fencing before Vault deletion, activation/validation rejection while fenced, Vault rollback, known and ambiguous commit failures, already-absent secret success, receipt retirement, repeated DELETE reconciliation, and no automatic replacement for a deleted active key
-- [ ] T044 [P] [US4] Add failing brand hard-delete and asset-operation integration cases to `backend/tests/integration/test_provider_key_cleanup.py` for upload/delete overlap, unique operation ownership, abandoned pending-to-unknown reconciliation, non-expiring unknown outcomes, stale completion, replacement old-object failure, legacy plus tokenized objects, multi-page prefix cleanup, Storage/Vault failure retry, mutation rejection after brand fencing, empty-brand deletion, and final physical absence of all assets/secrets/rows
+- [X] T041 [P] [US4] Extend `backend/tests/contract/test_provider_keys.py` with failing individual-delete tests for active/inactive/cleanup-required keys, 204 only after secret and row absence, retired idempotency receipt, absent-secret idempotency, opaque ownership, brand-cleanup precedence, and fixed 503 `KEY_CLEANUP_REQUIRED` retention
+- [X] T042 [P] [US4] Extend `backend/tests/contract/test_brands.py` with failing cleanup-aware logo and brand-delete tests for durable mutation conflicts, exact confirmation, `BRAND_CLEANUP_REQUIRED`, retry behavior, visible cleanup state, and prohibition of 204 while Storage/Vault/asset-operation cleanup is unresolved
+- [X] T043 [P] [US4] Extend `backend/tests/integration/test_provider_key_cleanup.py` with failing individual cleanup tests for durable inactive fencing before Vault deletion, activation/validation rejection while fenced, Vault rollback, known and ambiguous commit failures, already-absent secret success, receipt retirement, repeated DELETE reconciliation, and no automatic replacement for a deleted active key
+- [X] T044 [P] [US4] Add failing brand hard-delete and asset-operation integration cases to `backend/tests/integration/test_provider_key_cleanup.py` for upload/delete overlap, unique operation ownership, abandoned pending-to-unknown reconciliation, non-expiring unknown outcomes, stale completion, replacement old-object failure, legacy plus tokenized objects, multi-page prefix cleanup, Storage/Vault failure retry, mutation rejection after brand fencing, empty-brand deletion, and final physical absence of all assets/secrets/rows
### Implementation for User Story 4
-- [ ] T045 [US4] Extend `backend/app/services/provider_key_store.py` with individual deletion that first commits irreversible cleanup-required/inactive state and clears validation fields/lease, then atomically treats an absent Vault row as clean, deletes the secret and key row, retires idempotency receipts, and retains a safe retry anchor on known or ambiguous cleanup failure
-- [ ] T046 [US4] Add the individual DELETE handler to `backend/app/routes/provider_keys.py` with whole-brand-cleanup precedence, fixed 204/404/409/503 behavior, safe retry logs, and no replacement activation
-- [ ] T047 [US4] Refactor `backend/app/services/brand_storage.py` to create token-owned `brands/{brand_id}/logos/{operation_id}.{ext}` objects, paginate/list and batch-delete complete prefixes from offset zero, verify final absence, distinguish definitive from unknown remote outcomes, and never clear unknown state from elapsed time or temporary absence
-- [ ] T048 [US4] Extend `backend/app/services/brand_store.py` with durable `brand_asset_operations` create/complete/fail/reconcile primitives, one-operation-per-brand enforcement, recorded current/previous paths, stale-token-safe logo updates/removals, abandoned pending-to-unknown fencing, brand deletion fencing, and irreversible transition of all provider keys to cleanup-required/inactive
-- [ ] T049 [US4] Create `backend/app/services/brand_deletion.py` to coordinate exact-confirmation ownership lookup, zero-asset-operation precondition, committed brand/key fence, external exhaustive Storage-prefix cleanup without a DB connection, one-transaction Vault/key/receipt cleanup with absent-secret success, final empty-prefix and row verification, and physical brand deletion only after every dependency is absent
-- [ ] T050 [US4] Refactor logo upload/removal and confirmed brand deletion in `backend/app/routes/brands.py` to use durable asset operations and `BrandDeletion`, preserve ownership-before-lifecycle ordering, stop treating Storage failure as best effort, and return fixed retryable cleanup/mutation errors without reporting false success
-- [ ] T051 [US4] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with explicit individual deletion, cleanup-required rendering, normal-action disabling, retry DELETE, ambiguous-response relisting, and removal from UI only after confirmed absence
-- [ ] T052 [US4] Extend `frontend/app/(dashboard)/brands/[brandId]/page.tsx` with visible brand cleanup state, disabled normal/logo/key navigation mutations, retained exact-name retry deletion, and ambiguous-delete reconciliation before redirect
-- [ ] T053 [P] [US4] Extend `frontend/app/(dashboard)/brands/page.tsx` to parse and display `cleanup_state`, keep cleanup-required brands navigable as retry anchors, and distinguish them from normal brands without treating them as deleted
-- [ ] T054 [US4] Update `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` for tokenized logo paths, no direct authenticated brand writes, hard-delete-only user cleanup, exact owner/non-owner mutation parity, and regression coverage that successful deletion removes the complete Storage prefix
-- [ ] T055 [US4] Extend `frontend/tests/e2e/provider-keys.spec.ts` with active/inactive delete, injected cleanup-required retry, disabled normal actions, brand cleanup-state retry, and no automatic replacement assertions
-- [ ] T056 [US4] Execute Quickstart Scenarios 4-5 from `specs/004-provider-keys/quickstart.md` and run `backend/tests/contract/test_brands.py`, `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_brand_crud.py`, `backend/tests/integration/test_brand_rls.py`, `backend/tests/integration/test_provider_key_cleanup.py`, and `frontend/tests/e2e/provider-keys.spec.ts`
+- [X] T045 [US4] Extend `backend/app/services/provider_key_store.py` with individual deletion that first commits irreversible cleanup-required/inactive state and clears validation fields/lease, then atomically treats an absent Vault row as clean, deletes the secret and key row, retires idempotency receipts, and retains a safe retry anchor on known or ambiguous cleanup failure
+- [X] T046 [US4] Add the individual DELETE handler to `backend/app/routes/provider_keys.py` with whole-brand-cleanup precedence, fixed 204/404/409/503 behavior, safe retry logs, and no replacement activation
+- [X] T047 [US4] Refactor `backend/app/services/brand_storage.py` to create token-owned `brands/{brand_id}/logos/{operation_id}.{ext}` objects, paginate/list and batch-delete complete prefixes from offset zero, verify final absence, distinguish definitive from unknown remote outcomes, and never clear unknown state from elapsed time or temporary absence
+- [X] T048 [US4] Extend `backend/app/services/brand_store.py` with durable `brand_asset_operations` create/complete/fail/reconcile primitives, one-operation-per-brand enforcement, recorded current/previous paths, stale-token-safe logo updates/removals, abandoned pending-to-unknown fencing, brand deletion fencing, and irreversible transition of all provider keys to cleanup-required/inactive
+- [X] T049 [US4] Create `backend/app/services/brand_deletion.py` to coordinate exact-confirmation ownership lookup, zero-asset-operation precondition, committed brand/key fence, external exhaustive Storage-prefix cleanup without a DB connection, one-transaction Vault/key/receipt cleanup with absent-secret success, final empty-prefix and row verification, and physical brand deletion only after every dependency is absent
+- [X] T050 [US4] Refactor logo upload/removal and confirmed brand deletion in `backend/app/routes/brands.py` to use durable asset operations and `BrandDeletion`, preserve ownership-before-lifecycle ordering, stop treating Storage failure as best effort, and return fixed retryable cleanup/mutation errors without reporting false success
+- [X] T051 [US4] Extend `frontend/app/(dashboard)/brands/[brandId]/keys/page.tsx` with explicit individual deletion, cleanup-required rendering, normal-action disabling, retry DELETE, ambiguous-response relisting, and removal from UI only after confirmed absence
+- [X] T052 [US4] Extend `frontend/app/(dashboard)/brands/[brandId]/page.tsx` with visible brand cleanup state, disabled normal/logo/key navigation mutations, retained exact-name retry deletion, and ambiguous-delete reconciliation before redirect
+- [X] T053 [P] [US4] Extend `frontend/app/(dashboard)/brands/page.tsx` to parse and display `cleanup_state`, keep cleanup-required brands navigable as retry anchors, and distinguish them from normal brands without treating them as deleted
+- [X] T054 [US4] Update `backend/tests/integration/test_brand_crud.py` and `backend/tests/integration/test_brand_rls.py` for tokenized logo paths, no direct authenticated brand writes, hard-delete-only user cleanup, exact owner/non-owner mutation parity, and regression coverage that successful deletion removes the complete Storage prefix
+- [X] T055 [US4] Extend `frontend/tests/e2e/provider-keys.spec.ts` with active/inactive delete, injected cleanup-required retry, disabled normal actions, brand cleanup-state retry, and no automatic replacement assertions
+- [X] T056 [US4] Execute Quickstart Scenarios 4-5 from `specs/004-provider-keys/quickstart.md` and run `backend/tests/contract/test_brands.py`, `backend/tests/contract/test_provider_keys.py`, `backend/tests/integration/test_brand_crud.py`, `backend/tests/integration/test_brand_rls.py`, `backend/tests/integration/test_provider_key_cleanup.py`, and `frontend/tests/e2e/provider-keys.spec.ts`
**Checkpoint**: All key and brand deletion outcomes are either physically complete or
durably retryable; no successful response can leave an untracked secret or asset.
@@ -195,13 +195,13 @@ durably retryable; no successful response can leave an untracked secret or asset
**Purpose**: Complete deployment documentation, real-provider checks, regression,
secrecy review, and the phase-aware constitutional Definition of Done.
-- [ ] T057 [P] Document in `backend/.env.example` that `DATABASE_URL` must use a private non-client role with forced-RLS bypass and only the required application/Vault privileges, without placing credentials or full example secrets in the file
-- [ ] T058 [P] Update `docs/docker.md` with hosted backend-role creation/verification expectations, startup failure troubleshooting, official provider outbound-host requirements, and safe handling of disposable validation keys
-- [ ] T059 Run the complete backend suite with `backend/.venv/bin/python -m pytest backend/tests -q` and use `specs/004-provider-keys/quickstart.md` to confirm all provider-key real-Supabase integration files execute rather than skip
-- [ ] T060 [P] Run `npm run lint`, `npx tsc --noEmit`, `npm run build`, and `npx playwright test tests/e2e/provider-keys.spec.ts` from `frontend/`; resolve feature-related failures in the changed frontend files
-- [ ] T061 [P] Run `supabase db lint --level warning` against `supabase/migrations/00016_create_provider_keys.sql` and re-run the catalog, SQLSTATE, RLS, backend-only table, Data API, and Vault privilege assertions in `backend/tests/integration/test_provider_key_rls.py`
+- [X] T057 [P] Document in `backend/.env.example` that `DATABASE_URL` must use a private non-client role with forced-RLS bypass and only the required application/Vault privileges, without placing credentials or full example secrets in the file
+- [X] T058 [P] Update `docs/docker.md` with hosted backend-role creation/verification expectations, startup failure troubleshooting, official provider outbound-host requirements, and safe handling of disposable validation keys
+- [X] T059 Run the complete backend suite with `backend/.venv/bin/python -m pytest backend/tests -q` and use `specs/004-provider-keys/quickstart.md` to confirm all provider-key real-Supabase integration files execute rather than skip
+- [X] T060 [P] Run `npm run lint`, `npx tsc --noEmit`, `npm run build`, and `npx playwright test tests/e2e/provider-keys.spec.ts` from `frontend/`; resolve feature-related failures in the changed frontend files
+- [X] T061 [P] Run `supabase db lint --level warning` against `supabase/migrations/00016_create_provider_keys.sql` and re-run the catalog, SQLSTATE, RLS, backend-only table, Data API, and Vault privilege assertions in `backend/tests/integration/test_provider_key_rls.py`
- [ ] T062 Perform the real disposable OpenAI and Gemini checks in Quickstart Scenario 2 steps 8-10 from `specs/004-provider-keys/quickstart.md`; require `valid` from each official model-list endpoint, confirm no generation occurs, and treat provider outage/temporary classification as blocked rather than passed
-- [ ] T063 Audit `backend/app`, `backend/tests`, and captured logs using Quickstart Scenario 6 in `specs/004-provider-keys/quickstart.md`; confirm no raw key, label, hint, Vault UUID, decrypted value, authorization header, provider body, SQL bind, token, PII, or secret-bearing exception can cross response/log/test-output boundaries
+- [X] T063 Audit `backend/app`, `backend/tests`, and captured logs using Quickstart Scenario 6 in `specs/004-provider-keys/quickstart.md`; confirm no raw key, label, hint, Vault UUID, decrypted value, authorization header, provider body, SQL bind, token, PII, or secret-bearing exception can cross response/log/test-output boundaries
- [ ] T064 Execute all six end-to-end scenarios in `specs/004-provider-keys/quickstart.md`, including more-than-one-batch Storage cleanup, concurrent activation/validation/deletion, ambiguous commits, missing secrets, and auth-user deletion restrictions, and record any environment-only blocker before claiming completion
- [ ] T065 Verify Constitution v2.0.0 Definition of Done against `specs/004-provider-keys/spec.md` and `specs/004-provider-keys/quickstart.md`: all acceptance layers, direct RLS/privilege tests, server ownership, secrecy/logging, physical DB/Vault/Storage deletion, and real OpenAI/Gemini behavior pass; document Brand Kit and generation/preset/PNG checks as N/A because their prerequisite capabilities do not exist and this feature does not consume them
diff --git a/specs/004-provider-keys/verification.md b/specs/004-provider-keys/verification.md
new file mode 100644
index 0000000..d39e574
--- /dev/null
+++ b/specs/004-provider-keys/verification.md
@@ -0,0 +1,41 @@
+# Provider Keys Verification
+
+**Date**: 2026-07-28
+
+## Automated Results
+
+- Backend suite: 226 passed with no skipped tests.
+- Provider-key RLS, catalog, Data API, backend-table, and Vault checks: 9 passed.
+- Supabase schema lint: no errors at warning level.
+- Frontend ESLint, TypeScript, and production build: passed.
+- Provider-key Playwright flow: 2 passed.
+- Production `linux/amd64` container image build: passed.
+- Focused secrecy suites after diagnostic-boundary hardening: 172 passed.
+
+The automated suites cover safe add/list behavior, deterministic OpenAI and Gemini
+classification, ownership and RLS, Vault isolation, activation and validation races,
+idempotent and ambiguous database outcomes, individual cleanup retry, token-owned
+Storage operations, multi-page cleanup, brand hard deletion, and auth-user deletion
+restrictions.
+
+## Environment Blocker
+
+Quickstart Scenario 2 steps 8-10 require user-supplied disposable OpenAI and Gemini
+credentials entered through the UI. No disposable provider credentials were available
+in this verification environment. Those checks were not simulated or treated as
+passing: real official model-list acceptance and the associated live-log review remain
+blocked.
+
+Because real-provider verification is applicable to this feature, Quickstart Scenario 2,
+the complete six-scenario run, and the final constitutional Definition of Done remain
+open until both providers return `valid` through the UI and the live logs pass review.
+
+## Applicability
+
+- Brand Kit zero-answer and completed-kit checks are not applicable because Brand Kit
+ does not exist and provider-key behavior does not read or depend on it.
+- Generation lifecycle, platform preset, and PNG-output checks are not applicable
+ because image generation does not exist and provider-key operations perform no
+ generation.
+- OpenAI and Gemini behavior is applicable because this feature introduces both
+ integrations; deterministic checks pass, but the real-provider gate above remains.