From 13490e4ed69d2d1c49f9148aceab6d13f095d134 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 09:51:50 +0300 Subject: [PATCH 001/247] chore(porch): 321 init spir --- .../status.yaml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml new file mode 100644 index 00000000..7e7043bf --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -0,0 +1,20 @@ +id: '321' +title: rfc-whatsapp-support-in-the-mo +protocol: spir +phase: specify +plan_phases: [] +current_plan_phase: null +gates: + spec-approval: + status: pending + plan-approval: + status: pending + pr: + status: pending + verify-approval: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-25T06:51:50.273Z' +updated_at: '2026-10-25T06:51:50.274Z' From 4cb70fb5dccd4e97241500236816a0159097bb2f Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 10:46:10 +0300 Subject: [PATCH 002/247] [Spec 321] Initial specification draft --- .../321-rfc-whatsapp-support-in-the-mo.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 codev/specs/321-rfc-whatsapp-support-in-the-mo.md diff --git a/codev/specs/321-rfc-whatsapp-support-in-the-mo.md b/codev/specs/321-rfc-whatsapp-support-in-the-mo.md new file mode 100644 index 00000000..e999ea01 --- /dev/null +++ b/codev/specs/321-rfc-whatsapp-support-in-the-mo.md @@ -0,0 +1,150 @@ +# Specification: WhatsApp support in the monorepo + + + +## Problem Statement + +Ansari's WhatsApp experience runs on two legacy repositories (`ansari-project/ansari-whatsapp` and `ansari-project/ansari-backend`) that are separate from this monorepo. Nothing in the monorepo handles WhatsApp, so WhatsApp users depend on a stack that is being replaced and cannot share the monorepo's user, thread, message and chat pipeline. + +A production issue on 2026-10-17 shows the cost: Meta now lets users hide their phone number behind a WhatsApp username and sends an anonymous business-scoped user ID (BSUID) instead. The legacy service, which identifies users by phone number only, failed on these messages and the users got no reply. An interim notice (ansari-whatsapp#8 / PR #9) exists, and proper support was deferred to this port. + +Affected: all WhatsApp users, especially phone-hidden users who currently cannot use Ansari on WhatsApp at all; and the maintainers, who must keep two stacks alive. + +## Current State + +- The legacy WhatsApp service (Python, FastAPI) receives Meta webhooks, verifies Meta's signature, and answers Meta "OK" immediately while processing in the background. It filters wrong-number webhooks, status updates, over-age messages and unsupported message types, shows a typing indicator, and replies through Meta's API. +- Users are identified by phone number and stored in the legacy backend as ordinary users (`source = whatsapp`, phone number, preferred language, no email or password). +- A conversation continues until the user has been silent for 3 hours (global setting), then a new one starts. Old conversations are kept. +- The answer is produced by the legacy backend's streaming chat, collected in full by the service, converted from markdown to WhatsApp formatting and split into messages of at most 4000 characters. +- The service has a WhatsApp-specific maintenance flag, and a temporary `!d ` message prefix that lets staging ignore messages meant for a developer's local server (staging and local share one Meta test number). +- Each environment (local, staging, production) has its own Meta app credentials. +- In this monorepo, `apps/api` already provides users, threads, messages, the chat pipeline, client attribution, Sentry and a global `MAINTENANCE_MODE`. It does not receive or verify Meta messages, send replies through Meta, or know WhatsApp identities. Authentication is moving to a central service, `apps/auth`, which issues JWTs that `apps/api` verifies; `apps/api` no longer handles passwords or logins. + +## Desired State + +- WhatsApp is served from this monorepo by a separate WhatsApp service that talks to `apps/api` over HTTP. It begins as the existing Python service, imported as is as much as possible. +- Every WhatsApp user, including phone-hidden users, gets answers. Users are recognised by BSUID only; the phone number is never sent to or stored in this app. +- The WhatsApp service exchanges the user's BSUID for a JWT through a new `/whatsapp` endpoint in `apps/auth`, then calls `apps/api` with the message text and that JWT. `apps/api` identifies the user by the GUID in the JWT, exactly as for other users. +- This app stores only the user's GUID and settings (in the existing `preferences` table), not the BSUID or phone number. +- Each WhatsApp user has exactly one thread, the current conversation. When a message arrives after the expiry time (3 hours, global), the old thread and its messages are deleted and a new thread starts. +- Replies are complete messages (WhatsApp cannot stream), produced by aggregating the existing streaming endpoints and split for WhatsApp, as the legacy service does. +- Meta is always answered "OK" immediately, with no queue or message log table; failures are logged. +- The existing global `MAINTENANCE_MODE` governs WhatsApp, and users get a maintenance notice on WhatsApp. +- The `!d ` prefix behaviour for staging is preserved. + +## Success Criteria + +- [ ] A message from a phone-visible user is answered on WhatsApp through the new stack. +- [ ] A message from a phone-hidden user (BSUID only) is answered; no phone number is sent to `apps/auth` or `apps/api`, or stored by them. +- [ ] The same BSUID maps to the same user and thread across messages; a different BSUID is treated as a new user. +- [ ] `apps/api` authenticates WhatsApp requests solely via the JWT from `apps/auth`; no WhatsApp-specific login logic exists in `apps/api`. +- [ ] After more than 3 hours of silence, the next message starts a new thread and the previous thread and its messages are deleted; each user always has exactly one thread. +- [ ] Answers longer than 4000 characters are delivered as multiple messages in order, with WhatsApp-compatible formatting. +- [ ] Meta always receives a success response immediately for deliveries to our number, and Meta's signature is verified. +- [ ] Status updates, webhooks for other numbers and unsupported message types do not trigger a chat answer. +- [ ] With `MAINTENANCE_MODE` on, users receive a maintenance notice and no chat answer. +- [ ] In staging, messages starting with `!d ` are ignored; other messages are processed. +- [ ] No legacy users or threads are migrated. +- [ ] Unit and integration tests cover the above, using mock Meta and mock backend clients as in the legacy service. + +## Constraints + +Baked Decisions (from issue #321; fixed for this spec, not to be re-opened in the spec, plan or review): + +- **Architecture (decision 1):** a separate WhatsApp service in the monorepo (for example `apps/whatsapp`) that calls `apps/api` over HTTP. It starts as the existing Python service, imported as is as much as possible. +- **User identity (decision 2):** the WhatsApp service sends the user's BSUID (never the phone number) to a new `/whatsapp` endpoint in `apps/auth`, gets a JWT back, and calls `apps/api` with the message text and that JWT. `apps/api` identifies the user by the GUID in the JWT. This app stores neither the BSUID nor the phone number, only the GUID and the user's settings (in the existing `preferences` table). A new BSUID means a new user, with no identity continuity. +- **Answer format (decision 4):** use the existing streaming endpoints and aggregate their output into messages split for WhatsApp, as the legacy service does. +- **Incoming messages (decision 5):** always answer Meta "OK" immediately, with no queue or message log table. +- **Existing users (decision 6):** none are migrated, we start fresh. +- **Rate limiting (decision 7):** out of scope, no per-sender limiter in our code. +- **Deferred to later specs:** data retention for stale users (decision 3), translating the service to TypeScript, and dedicated non-streaming answer endpoints. + +Self-made decisions (8 to 11, approved by the author, open to comments): global 3-hour conversation expiry; one thread per user, replaced on expiry; `!d ` prefix kept for staging; reuse of the global `MAINTENANCE_MODE`. + +Other constraints: + +- Meta requires a quick response to webhooks; processing happens after the response. +- WhatsApp messages are limited to 4000 characters per message in the legacy design. +- The Python service uses `uv`, `ruff` and `pytest`; monorepo conventions apply to its placement and CI. +- Nothing is deleted for stale users in this spec (decision 3 deferred): a user who changes phone number leaves a stale identity in the auth layer. + +## Assumptions + +- `apps/auth` will provide the `/whatsapp` endpoint and handle the fact that a WhatsApp user has no email (owned by Amr; not yet confirmed). +- Ansari uses a single Meta business portfolio and is not enrolled in parent BSUIDs, so the regular BSUID suffices (to be confirmed). +- For this spec, the `/whatsapp` contract is assumed to be: `POST /api/whatsapp/session`, authenticated with a WhatsApp API key (name assumed: `AUTH_WHATSAPP_API_KEY`), request `{ "bsuid": "US.…" }`, response `{ "token": "" }` in the format `apps/auth` already issues, errors as `{ message }` (400 malformed BSUID, 401 wrong key, 500). +- The `preferences` table (one row per setting keyed by GUID) is the home for user settings; today that is only preferred language, and expiry stays global. +- Meta's own throughput limits and webhook retry behaviour sufficiently bound traffic (see Baked Decision 7); this has not been load tested. +- Message-ID duplicates from Meta are rare enough to ignore (Baked Decision 5). + +## Solution Approaches + +The architecture, identity and answer-format questions are settled by the Baked Decisions. Remaining open space is how the imported service is introduced. + +### Approach 1: Import the Python service as is, then adapt identity and thread lifecycle + +Bring the legacy service into the monorepo with minimal change, replace its legacy user registration with the `apps/auth` token exchange, then add the thread lifecycle. +- Pros: matches the Baked Decisions; proven behaviour; smallest first step; keeps WhatsApp deploys separate. +- Cons: adds a Python app to a TypeScript monorepo until the later translation. +- Risk: low to moderate; the main dependency is the `/whatsapp` contract. + +### Approach 2: Rewrite in TypeScript immediately + +- Pros: consistent stack; reuse of `packages/`. +- Cons: contradicts the Baked Decisions (deferred); larger scope and regression risk. +- Risk: higher. + +Recommended: Approach 1. + +## Open Questions + +**Critical** +- What is the final `/whatsapp` contract in `apps/auth` (path, auth scheme, API key name, error shape, no-email handling)? To be agreed with Amr; this spec assumes the contract in Assumptions. + +**Important** +- Where do the WhatsApp service's secrets and per-environment Meta credentials live in the monorepo's deployment setup? +- How does `apps/api` expose thread deletion/replacement for the thread lifecycle (existing endpoints suffice, or a change is needed)? +- How are sources, links and tool activity from the streaming output rendered as plain text in WhatsApp messages (legacy behaviour assumed)? +- Should BSUID changes from Meta's system-message webhook be handled? Under Baked Decision 2 a changed BSUID is a new user, so this spec assumes no. A community comment on #321 (morningstar620) suggests otherwise. + +**Nice-to-know** +- How many legacy users are phone-hidden, and how often do duplicate Meta deliveries occur in practice? +- Whether a dedicated staging Meta number can retire the `!d ` prefix. + +## Test Scenarios + +- Phone-visible user sends text: registered via token exchange, answered. +- Phone-hidden user sends text: answered; no phone number in any outgoing call or log. +- Two consecutive messages within 3 hours: same user, same thread, context retained. +- Message after 3 hours: old thread and messages deleted, new thread created. +- Same user with a new BSUID: treated as a new user. +- Long answer (above 4000 characters): split in order at sensible boundaries. +- Status webhook, webhook for another business number, and non-text message: handled without a chat answer (unsupported types get the unsupported notice). +- Invalid or missing Meta signature: rejected. +- Webhook verification handshake succeeds with the right token, fails otherwise. +- `MAINTENANCE_MODE` on: maintenance notice, no chat processing. +- Staging with `!d ` prefix: ignored; without: processed. +- `apps/auth` unavailable or returns an error: user receives a failure notice, error logged, Meta still answered "OK". +- `apps/api` error or timeout during streaming: user receives a failure notice, error logged. +- Message older than the configured age threshold: user is notified, not processed (legacy behaviour retained). + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| `/whatsapp` contract differs from the assumed one | Medium | Medium | Confirm with Amr before the plan; isolate the token exchange behind one client | +| `apps/auth` cannot support users without email | Low | High | Confirm early with Amr | +| Stale identities accumulate (retention deferred) | High | Low | Tracked as a deferred decision | +| Always-"OK" hides failed or lost messages | Medium | Medium | Log failures with request correlation and Sentry; revisit if lost replies appear | +| Streaming aggregation stays fragile (formatting, length) | Medium | Medium | Keep legacy splitter and formatter with tests; dedicated endpoints deferred | +| A Python app in a TypeScript monorepo complicates CI and deploys | Medium | Low | Treat as interim until the TypeScript translation | +| Phone-number change creates a new user and loses context | Medium | Low | Accepted by Baked Decision 2 | + +## References + +- Issue iaser-ai/ansari#321 (design decisions and meeting outcome of 2026-10-24) +- Legacy: `ansari-project/ansari-whatsapp`, `ansari-project/ansari-backend`; interim fix ansari-whatsapp#8 / PR #9 +- Meta: business-scoped user IDs, webhooks overview, throughput documentation From d98758ecbd2c678c8ec789fef3566f2adb4a011c Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:02:27 +0300 Subject: [PATCH 003/247] [Spec 321] Treat /whatsapp contract as settled --- codev/specs/321-rfc-whatsapp-support-in-the-mo.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/codev/specs/321-rfc-whatsapp-support-in-the-mo.md b/codev/specs/321-rfc-whatsapp-support-in-the-mo.md index e999ea01..e18f9dfc 100644 --- a/codev/specs/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/specs/321-rfc-whatsapp-support-in-the-mo.md @@ -11,7 +11,7 @@ Ansari's WhatsApp experience runs on two legacy repositories (`ansari-project/an A production issue on 2026-10-17 shows the cost: Meta now lets users hide their phone number behind a WhatsApp username and sends an anonymous business-scoped user ID (BSUID) instead. The legacy service, which identifies users by phone number only, failed on these messages and the users got no reply. An interim notice (ansari-whatsapp#8 / PR #9) exists, and proper support was deferred to this port. -Affected: all WhatsApp users, especially phone-hidden users who currently cannot use Ansari on WhatsApp at all; and the maintainers, who must keep two stacks alive. +Affected: all WhatsApp users who currently cannot use Ansari on WhatsApp at all; and the maintainers, who must keep two stacks alive. ## Current State @@ -73,9 +73,9 @@ Other constraints: ## Assumptions -- `apps/auth` will provide the `/whatsapp` endpoint and handle the fact that a WhatsApp user has no email (owned by Amr; not yet confirmed). +- `apps/auth` provides the `/whatsapp` endpoint and handles the fact that a WhatsApp user has no email (owned by Amr). - Ansari uses a single Meta business portfolio and is not enrolled in parent BSUIDs, so the regular BSUID suffices (to be confirmed). -- For this spec, the `/whatsapp` contract is assumed to be: `POST /api/whatsapp/session`, authenticated with a WhatsApp API key (name assumed: `AUTH_WHATSAPP_API_KEY`), request `{ "bsuid": "US.…" }`, response `{ "token": "" }` in the format `apps/auth` already issues, errors as `{ message }` (400 malformed BSUID, 401 wrong key, 500). +- The `/whatsapp` contract is taken as given: `POST /api/whatsapp/session`, authenticated with a WhatsApp API key (name assumed: `AUTH_WHATSAPP_API_KEY`), request `{ "bsuid": "US.…" }`, response `{ "token": "" }` in the format `apps/auth` already issues, errors as `{ message }` (400 malformed BSUID, 401 wrong key, 500). - The `preferences` table (one row per setting keyed by GUID) is the home for user settings; today that is only preferred language, and expiry stays global. - Meta's own throughput limits and webhook retry behaviour sufficiently bound traffic (see Baked Decision 7); this has not been load tested. - Message-ID duplicates from Meta are rare enough to ignore (Baked Decision 5). @@ -102,7 +102,7 @@ Recommended: Approach 1. ## Open Questions **Critical** -- What is the final `/whatsapp` contract in `apps/auth` (path, auth scheme, API key name, error shape, no-email handling)? To be agreed with Amr; this spec assumes the contract in Assumptions. +- None. The `/whatsapp` contract in Assumptions is treated as final. **Important** - Where do the WhatsApp service's secrets and per-environment Meta credentials live in the monorepo's deployment setup? @@ -135,7 +135,7 @@ Recommended: Approach 1. | Risk | Probability | Impact | Mitigation | |------|-------------|--------|------------| -| `/whatsapp` contract differs from the assumed one | Medium | Medium | Confirm with Amr before the plan; isolate the token exchange behind one client | +| `/whatsapp` contract changes after implementation starts | Low | Medium | Isolate the token exchange behind one client | | `apps/auth` cannot support users without email | Low | High | Confirm early with Amr | | Stale identities accumulate (retention deferred) | High | Low | Tracked as a deferred decision | | Always-"OK" hides failed or lost messages | Medium | Medium | Log failures with request correlation and Sentry; revisit if lost replies appear | From 62aaa2a440da2f7db4e751449005cfa243328156 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:07:52 +0300 Subject: [PATCH 004/247] chore(porch): 321 spec-approval gate-approved --- .../projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 7e7043bf..06a4d8cd 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -6,7 +6,8 @@ plan_phases: [] current_plan_phase: null gates: spec-approval: - status: pending + status: approved + approved_at: '2026-10-25T08:07:52.213Z' plan-approval: status: pending pr: @@ -17,4 +18,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T06:51:50.274Z' +updated_at: '2026-10-25T08:07:52.215Z' From c817d010d724ea1e3550f9e048c0fcfeac9d4e67 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:08:06 +0300 Subject: [PATCH 005/247] chore(porch): 321 specify build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 06a4d8cd..04dd6a74 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -15,7 +15,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T08:07:52.215Z' +updated_at: '2026-10-25T08:08:06.366Z' From 3c297ef12f5c0eb0300db43abc3e7561387e2c43 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:08:14 +0300 Subject: [PATCH 006/247] chore(porch): 321 plan phase-transition --- .../321-rfc-whatsapp-support-in-the-mo/status.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 04dd6a74..6f5070d4 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -1,7 +1,7 @@ id: '321' title: rfc-whatsapp-support-in-the-mo protocol: spir -phase: specify +phase: plan plan_phases: [] current_plan_phase: null gates: @@ -15,7 +15,10 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: true +build_complete: false history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T08:08:06.366Z' +updated_at: '2026-10-25T08:08:13.903Z' +context_refreshes: + - boundary: enter:plan + at: '2026-10-25T08:08:13.901Z' From 1fa3c2d6724708fed044f3ceb16ae4cdb6fbe3af Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:08:22 +0300 Subject: [PATCH 007/247] chore(porch): 321 acknowledge context refresh --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 6f5070d4..036bb0a3 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -18,7 +18,8 @@ iteration: 1 build_complete: false history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T08:08:13.903Z' +updated_at: '2026-10-25T08:08:22.215Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' + acknowledged_at: '2026-10-25T08:08:22.211Z' From d07598300f5033d4cbb90f51752a7d0c81d12869 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:13:37 +0300 Subject: [PATCH 008/247] [Spec 321] Initial plan draft --- .../321-rfc-whatsapp-support-in-the-mo.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 codev/plans/321-rfc-whatsapp-support-in-the-mo.md diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md new file mode 100644 index 00000000..0896840d --- /dev/null +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -0,0 +1,169 @@ +# Plan: WhatsApp support in the monorepo + +**Specification**: [codev/specs/321-rfc-whatsapp-support-in-the-mo.md](../specs/321-rfc-whatsapp-support-in-the-mo.md) + +## Executive Summary + +Follow the spec's recommended Approach 1: import the existing Python WhatsApp service into the monorepo as a separate app (`apps/whatsapp`), then replace its legacy user handling with the `apps/auth` token exchange, then add the one-thread-per-user lifecycle, then finish environment behaviour (maintenance mode, `!d ` filter) and deployment docs. The Baked Decisions in the spec are fixed and not re-opened here. The `/whatsapp` contract is taken as given (`POST /api/whatsapp/session`, `AUTH_WHATSAPP_API_KEY`, `{ bsuid }` in, `{ token }` out). + +Testing strategy, used by every phase: +1. **Automated:** pytest with mock Meta, mock `apps/auth` and mock `apps/api` clients (the legacy base/real/mock service pattern). No network, no Meta. +2. **Local end to end:** the service runs locally, exposed to Meta through a zrok tunnel, with test messages prefixed `!d `. The `zrok.exe` binary is used for this (see "Local testing with zrok" below). +3. **Staging:** a final smoke test only, after the phases are merged and deployed. + +## Phases (Machine Readable) + +```json +{ + "phases": [ + {"id": "import_service", "title": "Import the Python service as is"}, + {"id": "auth_token_exchange", "title": "Replace legacy user handling with the apps/auth token exchange"}, + {"id": "thread_lifecycle", "title": "One thread per user with expiry"}, + {"id": "environments_and_docs", "title": "Maintenance mode, staging filter, local testing and docs"} + ] +} +``` + +## Phase Breakdown + +### Phase 1: Import the Python service as is + +**Dependencies**: None + +#### Objective + +Bring `ansari-whatsapp` into the monorepo as `apps/whatsapp` with minimal changes, so it builds, lints and passes its existing tests there. Behaviour is unchanged at this point. + +#### Files to Create / Modify + +- `apps/whatsapp/` (source, `pyproject.toml`, `uv.lock`, `pytest.ini`, `Dockerfile`, tests, `.env.example`), copied from the legacy repo without its logs, `tmp/` files, personal scripts, the `zrok.exe` binary and generated artifacts. +- Monorepo wiring only where needed (workspace and CI entries), following how other apps are registered. + +#### Deliverables + +- [ ] `apps/whatsapp` present and runnable with `uv` and `ruff`. +- [ ] Legacy tests pass with `MOCK_META_API=True` and `MOCK_ANSARI_CLIENT=True`. +- [ ] `.env.example` contains no secrets. +- [ ] Tests for this phase (the imported suite). + +#### Acceptance Criteria + +- [ ] `uv sync`, `ruff check`, `ruff format --check` and `pytest tests/ -v` pass in `apps/whatsapp`. +- [ ] Existing monorepo build and tests are unaffected. + +#### Test Plan + +Run the imported test suite in mock mode. Manually start the service and call the health check (`GET /`) and the webhook verification handshake (`GET /whatsapp/v2`). + +### Phase 2: Replace legacy user handling with the apps/auth token exchange + +**Dependencies**: Phase 1 + +#### Objective + +Identify users by BSUID only. The service gets a JWT from `apps/auth` and calls `apps/api` with it. No phone number or BSUID is sent to `apps/api` or stored by this app. + +#### Files to Create / Modify + +- In `apps/whatsapp`: a new auth client (base, real and mock, like the other services) behind the service provider; the webhook parser and conversation manager updated to key on BSUID and drop phone-number registration and the phone-hidden notice; the Ansari client updated to call `apps/api` with the JWT instead of legacy registration endpoints. +- New settings: auth base URL and `AUTH_WHATSAPP_API_KEY`, validated in config and listed in `.env.example`. +- Confirm in `apps/api` which existing endpoints the service needs (chat streaming, thread create or read, history) and use them as they are. If a needed endpoint is missing, record it as a finding instead of widening this phase. + +#### Deliverables + +- [ ] Auth client with mock implementation. +- [ ] Phone-visible and phone-hidden payloads both handled; the BSUID (`user_id` in the payload) is the only identity used. +- [ ] Failure of the token exchange sends the user a failure notice, is logged, and Meta still gets "OK". +- [ ] Logs and errors contain no phone numbers. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] Same BSUID yields a token request each time with the same input; a different BSUID is a different user (verified through the mock). +- [ ] No outgoing call to `apps/auth` or `apps/api` carries a phone number (asserted in tests). +- [ ] Streaming answers are still aggregated and split for WhatsApp as before. + +#### Test Plan + +Unit tests for the auth client (success, 400, 401, 500, timeout) and for webhook parsing of both user types. Service-level tests with all three mocks for the full message flow. Local end-to-end run with the zrok tunnel and `!d ` once `apps/auth` offers the endpoint; until then, only the mock-based tests apply. + +### Phase 3: One thread per user with expiry + +**Dependencies**: Phase 2 + +#### Objective + +Each user has exactly one thread. If the last activity is older than the global expiry (3 hours), the old thread and its messages are deleted and a new thread starts. + +#### Files to Create / Modify + +- In `apps/whatsapp`: the conversation manager's thread logic and the Ansari client's thread calls; expiry setting in config (global, default 3 hours). +- `apps/api` only if the existing endpoints cannot read the thread's last activity or delete a thread with its messages; any such change is kept minimal and noted in the review. + +#### Deliverables + +- [ ] Expired-thread replacement; non-expired threads reused. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] Message within the expiry reuses the thread and keeps context. +- [ ] Message after the expiry deletes the old thread and messages and creates a new thread. +- [ ] A user never ends up with two threads, including when two messages arrive close together (verified in tests, with the behaviour documented). + +#### Test Plan + +Unit tests with a controllable clock for the boundary (just inside, just outside the expiry) and the first-ever message. Local end-to-end check with a short expiry value set through config. + +### Phase 4: Maintenance mode, staging filter, local testing and docs + +**Dependencies**: Phase 3 + +#### Objective + +Finish environment behaviour and make the service testable and deployable by others. + +#### Files to Create / Modify + +- In `apps/whatsapp`: maintenance behaviour driven by the same global flag as `apps/api` (`MAINTENANCE_MODE`) instead of the WhatsApp-specific flag, keeping the notice message; keep the staging `!d ` filter; `README.md` for the app. +- Deployment notes: per-environment Meta credentials and secrets, and the deploy steps for staging and production, following the monorepo's existing deployment docs. + +#### Deliverables + +- [ ] Maintenance flag reuse with tests. +- [ ] `!d ` filter kept, with tests (ignored in staging, processed otherwise). +- [ ] README covering setup, tests, and local testing with zrok. +- [ ] Staging smoke-test checklist. + +#### Acceptance Criteria + +- [ ] With the flag on, users get the notice and no chat call is made. +- [ ] In staging mode, `!d `-prefixed messages are ignored and others processed. +- [ ] A new developer can run the service locally and receive a test message following only the README. + +#### Test Plan + +Unit tests for both behaviours. Manual: local run through zrok with `!d `, then staging smoke test (a normal message answered, a `!d ` message ignored by staging and answered locally). + +## Local testing with zrok + +- The legacy flow stays: `zrok reserve public localhost:8001 -n `, with the Meta test app's webhook URL pointing at the reserved share. +- A copy of `zrok.exe` has been placed in the worktree's `tmp/` folder, which is git-ignored, so the command runs there (`tmp\zrok.exe`) and the 86 MB binary never gets committed. The original stays in `ansari-whatsapp`. +- Cleaner alternatives, not used by default: install zrok once on the PATH (for example through a package manager) so no copy is needed per worktree; or keep one shared tools folder outside the repos and put it on the PATH. +- Local runs use the Meta test app's credentials and the shared test number, so messages meant for local must start with `!d ` to keep staging from answering them. + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| `apps/auth` endpoint is not available when Phase 2 is built | Medium | Medium | Mock auth client; local end to end deferred until it exists | +| `apps/api` lacks a thread read or delete endpoint needed in Phase 3 | Medium | Medium | Check early in Phase 2; add the smallest change and note it | +| Concurrent messages create two threads for one user | Medium | Low | Cover in tests and document the chosen behaviour | +| Imported code carries secrets, logs or personal files | Low | High | Copy only source, tests and config examples; review the diff before committing | +| Shared test number makes local and staging answer the same message | Medium | Low | `!d ` filter kept and tested | + +## Documentation Updates + +- New `apps/whatsapp/README.md`. +- Deployment and environment notes for the WhatsApp service in the monorepo docs. +- `codev/resources` arch notes only if the review finds a lasting system-shape fact. From 3fb2f6f7f89a1c4dae32d2528449002c7b9deed0 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:13:38 +0300 Subject: [PATCH 009/247] chore(porch): 321 plan build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 036bb0a3..1bfb8c3f 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -15,10 +15,10 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T08:08:22.215Z' +updated_at: '2026-10-25T08:13:38.848Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From 4b65ef070c79c7bf2da6c451917d6c6bdd43b5d4 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:16:16 +0300 Subject: [PATCH 010/247] chore(porch): 321 plan-approval gate-approved --- .../projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 1bfb8c3f..997e3f02 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -9,7 +9,8 @@ gates: status: approved approved_at: '2026-10-25T08:07:52.213Z' plan-approval: - status: pending + status: approved + approved_at: '2026-10-25T08:16:16.104Z' pr: status: pending verify-approval: @@ -18,7 +19,7 @@ iteration: 1 build_complete: true history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T08:13:38.848Z' +updated_at: '2026-10-25T08:16:16.106Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From a98063da65e0a085e23caa09d6dd245fb1b9e111 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:16:19 +0300 Subject: [PATCH 011/247] chore(porch): 321 implement phase-transition --- .../status.yaml | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 997e3f02..00fcb6ec 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -1,9 +1,21 @@ id: '321' title: rfc-whatsapp-support-in-the-mo protocol: spir -phase: plan -plan_phases: [] -current_plan_phase: null +phase: implement +plan_phases: + - id: import_service + title: Import the Python service as is + status: in_progress + - id: auth_token_exchange + title: Replace legacy user handling with the apps/auth token exchange + status: pending + - id: thread_lifecycle + title: One thread per user with expiry + status: pending + - id: environments_and_docs + title: Maintenance mode, staging filter, local testing and docs + status: pending +current_plan_phase: import_service gates: spec-approval: status: approved @@ -16,11 +28,13 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: true +build_complete: false history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T08:16:16.106Z' +updated_at: '2026-10-25T08:16:19.525Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' acknowledged_at: '2026-10-25T08:08:22.211Z' + - boundary: enter:implement + at: '2026-10-25T08:16:19.523Z' From 63ee6aad507f4ba1410fe3c2f1bae4cc2898d8af Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:16:31 +0300 Subject: [PATCH 012/247] chore(porch): 321 acknowledge context refresh --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 00fcb6ec..029733e9 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -31,10 +31,11 @@ iteration: 1 build_complete: false history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T08:16:19.525Z' +updated_at: '2026-10-25T08:16:31.278Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' acknowledged_at: '2026-10-25T08:08:22.211Z' - boundary: enter:implement at: '2026-10-25T08:16:19.523Z' + acknowledged_at: '2026-10-25T08:16:31.277Z' From 59dc1cc44bfac721b12046892d4e985e01d4ee6e Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 11:18:58 +0300 Subject: [PATCH 013/247] [Spec 321][Phase: import_service] feat: Import Python WhatsApp service as apps/whatsapp --- apps/whatsapp/.env.example | 174 +++ apps/whatsapp/.gitignore | 12 + apps/whatsapp/Dockerfile | 47 + apps/whatsapp/README.md | 168 ++ apps/whatsapp/clean_logs.sh | 68 + apps/whatsapp/pyproject.toml | 71 + apps/whatsapp/pytest.ini | 28 + apps/whatsapp/src/ansari_whatsapp/__init__.py | 2 + .../src/ansari_whatsapp/app/__init__.py | 2 + apps/whatsapp/src/ansari_whatsapp/app/main.py | 453 ++++++ .../presenters/whatsapp_message_formatter.py | 134 ++ .../src/ansari_whatsapp/services/__init__.py | 2 + .../services/ansari_client_base.py | 107 ++ .../services/ansari_client_mock.py | 299 ++++ .../services/ansari_client_real.py | 284 ++++ .../services/meta_api_service_base.py | 38 + .../services/meta_api_service_mock.py | 47 + .../services/meta_api_service_real.py | 163 ++ .../services/meta_service_provider.py | 35 + .../services/service_provider.py | 79 + .../services/whatsapp_conversation_manager.py | 333 ++++ .../src/ansari_whatsapp/utils/app_logger.py | 188 +++ .../src/ansari_whatsapp/utils/config.py | 157 ++ .../src/ansari_whatsapp/utils/exceptions.py | 61 + .../ansari_whatsapp/utils/general_helpers.py | 77 + .../ansari_whatsapp/utils/language_utils.py | 45 + .../src/ansari_whatsapp/utils/time_utils.py | 105 ++ .../utils/whatsapp_message_splitter.py | 222 +++ .../utils/whatsapp_webhook_utils.py | 246 +++ apps/whatsapp/tests/README.md | 177 +++ apps/whatsapp/tests/__init__.py | 1 + apps/whatsapp/tests/test_utils.py | 57 + apps/whatsapp/tests/test_whatsapp_service.py | 432 ++++++ apps/whatsapp/uv.lock | 1354 +++++++++++++++++ 34 files changed, 5668 insertions(+) create mode 100644 apps/whatsapp/.env.example create mode 100644 apps/whatsapp/.gitignore create mode 100644 apps/whatsapp/Dockerfile create mode 100644 apps/whatsapp/README.md create mode 100644 apps/whatsapp/clean_logs.sh create mode 100644 apps/whatsapp/pyproject.toml create mode 100644 apps/whatsapp/pytest.ini create mode 100644 apps/whatsapp/src/ansari_whatsapp/__init__.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/app/__init__.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/app/main.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/presenters/whatsapp_message_formatter.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/__init__.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_base.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_mock.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/meta_service_provider.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/service_provider.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/config.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/general_helpers.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/language_utils.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/time_utils.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_message_splitter.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py create mode 100644 apps/whatsapp/tests/README.md create mode 100644 apps/whatsapp/tests/__init__.py create mode 100644 apps/whatsapp/tests/test_utils.py create mode 100644 apps/whatsapp/tests/test_whatsapp_service.py create mode 100644 apps/whatsapp/uv.lock diff --git a/apps/whatsapp/.env.example b/apps/whatsapp/.env.example new file mode 100644 index 00000000..84a859a9 --- /dev/null +++ b/apps/whatsapp/.env.example @@ -0,0 +1,174 @@ +# Where ansari-whatsapp is currently deployed +# (local (i.e., local), development, staging, production) +# NOTE: local type will add localhost to ORIGINS, etc. +DEPLOYMENT_TYPE="local" + +# Sentry DSN for error tracking and monitoring +# To obtain the Sentry DSN: +# 1. Log in to your Sentry account at https://sentry.io +# 2. Select your organization and project +# 3. Go to Settings > Projects > [Your Project Name] > Client Keys (DSN) +# 4. Copy the DSN value (it should look like: https://xxxxx@xxxxx.ingest.sentry.io/xxxxx) +# Leave empty or omit to disable Sentry error tracking +SENTRY_DSN="" + +###################################### Related to ansari-backend ###################################### +# ansari-backend's base URL (WITHOUT /api/v2 suffix) +# The Ansari client will append /whatsapp/v2/* endpoints to this base URL +# if local: "http://localhost:8000" +# if staging: "https://staging-api.ansari.chat" +# if production: "https://api.ansari.chat" +BACKEND_SERVER_URL="http://localhost:8000" + +# Shared API key for authenticating requests from ansari-whatsapp to ansari-backend +# References: +# - https://fastapi.tiangolo.com/tutorial/security/ +# - https://fastapi.tiangolo.com/advanced/security/http-basic-auth/ +# - https://www.python-httpx.org/advanced/#client-instances +# Security: Sent in X-Whatsapp-Api-Key header for service-to-service authentication +# IMPORTANT: This value MUST match WHATSAPP_SERVICE_API_KEY in ansari-backend's .env file +WHATSAPP_SERVICE_API_KEY="your_generated_secret_key_here" + +###################################### Related to CORS ###################################### + +# CORS settings - URLs that are allowed to access ansari-whatsapp +ORIGINS="https://api.ansari.chat,https://ansari-api.au.ngrok.io,https://web.whatsapp.com" +# Explained in WhatsApp comments below +META_WEBHOOK_ZROK_SHARE_TOKEN="<>" + +###################################### Related to WhatsApp Business and Meta ###################################### + +# Preface: You might be asking yourself: "ALL OF THESE CONFIGS JUST TO SETUP WHATSAPP?!" +# And the answer is yes :'D ... Meta's API/setup-processes are a bit complicated... but that's why BSPs exist. +# Read this for more details: +# https://www.reddit.com/r/WhatsappBusinessAPI/comments/1mwa0r6/difference_between_using_meta_whatsapp_cloud_api/ +# However, we decided to do it the old-fashioned way; Straight to the main API 8) + +# Source 0: https://developers.facebook.com/docs/whatsapp/cloud-api/get-started +# Source 1: https://www.youtube.com/watch?v=KP6_BUw3i0U +# Watch Until 32:25, while quickly skimming through the non-python code parts +# Source 2 (mentioned in video above): https://glitch.com/edit/#!/insidious-tartan-alvarezsaurus +# (the `verification_webhook` endpoint in `main.py` is inspired by the above URL) +# Source 3 (optional): https://developers.facebook.com/blog/post/2022/10/24/sending-messages-with-whatsapp-in-your-python-applications/#u_0_39_8q + +# Moreover, if want to test whatsapp's webhook locally, you can use zrok on a reserved URL with a zrok "share token" +# obtained by contacting its current holder: https://github.com/OdyAsh (source 1, 2 below) +# Alternatively, you can change the webhook url all together (source 3, 4 below) +# Check these sources for more details: +# Source 1: https://dev.to/odyash/quickly-share-your-app-with-zrok-4ihp +# Source 2: https://openziti.discourse.group/t/how-do-i-use-a-reserved-share-on-different-devices/2379/2 +# Source 3: https://youtu.be/KP6_BUw3i0U?t=1294 +# (@21:33 and 25:30, however they use glitch instead of zrok, so the video here is just to give you an idea how to setup a webhook) +# Source 4 (where you can change callback url, given that your facebook account gets access by the app's admins): +# https://developers.facebook.com/apps/871020755148175/whatsapp-business/wa-settings/ +# NOTE 1: Obviously, that `871020755148175` in the above URL is the testing app's public id, so if this link still doesn't work even after you gain access, +# then the admins most probably created a new test app instance +# NOTE 2: When you see the `Callback URL`, it will be something like "https://META_WEBHOOK_ZROK_SHARE_TOKEN.share.zrok.io/whatsapp/v2" +# (The `/whatsapp/v2` endpoint can be found in `main.py`'s endpoints, that's why it's in the url above) +# Moreover, this zrok callback URL is only used for `Ansari Test` app on developers.facebook.com, +# so we should only use it while locally developing this repo, not in staging/prod/etc. environments +# NOTE 3: If an unexpected 3rd party discovers the META_WEBHOOK_ZROK_SHARE_TOKEN, +# a new one will have to be generated, then added to Meta's callback URL of the *testing* app +# (Noting that the *production* app's callback URL will be different anyway, so the 3rd party won't be able to access that app) +# (but we still don't want random calls to be made to our testing app, so that's why we'll still have to change an exposed token :]) +# Side note: the current zrok instance (i.e., "https://META_WEBHOOK_ZROK_SHARE_TOKEN.share.zrok.io") +# currently proxies (i.e., maps to) this local address: http://127.0.0.1:8001 +# Therefore, if for some reason, you want to change that address, +# then you'll need to re-establish the mapping by doing these 2 commands: +# `zrok release <>` +# `zrok reserve public XXXX --unique-name "<>"` +# where XXXX is your new port number (which correspond to the PORT variable found in `.env` file) + +META_API_VERSION="v22.0" + + +# Phone number ID (from Meta Dashboard) +# NOTE 1: Contact the team to see whatsapp's 2 phone nums -> one for prod. env. and the other for local/stage testing +# NOTE 2: If you encounter this error: +# "Client error '400 Bad Request' for url 'https://graph.facebook.com/v22.0//messages'" +# then either: +# (1) the got deleted by Meta due to inactive use for a long time. +# If this happens, then open the page in source 3 (i.e., youtube video URL above) @6:11. +# Then, under "step 1" in the middle of the page, you'll see a selection box with the live WhatsApp number; +# click on it, then click "Get new test number". Then, wait for a while, then you'll see the new number; +# Now copy its number id and paste it in +# (2) Meta updated its version and so now is outdated. +# You can verify this by opening source 3 @26:13 and checking the version associated with `messages` +# (3) you sent a message with an incorrect format. Check source 3; 26:10->26:28, click on `messages` to see how it should be sent +META_BUSINESS_PHONE_NUMBER_ID="your_phone_number_id_here" + +# WhatsApp Business Account ID (WABA ID) +# NOTE: This is for check_subscriptions.py only, so it's not used in the rest of the codebase +# and so it should only be used in .env and not pushed to GitHub Actions or AWS +# To get the WABA ID of your respective app on developers.facebook.com, +# check docs\assets\where-to-get-WABA-IDs.png +META_WABA_ID="your_waba_id_here" + +# System User Access Token (from Meta Dashboard) +# NOTE 1: check video in source 3 above from 30:45 to 32:15 to see where we get the access token +# NOTE 2: Contact the team to see their 3 access tokens -> one for prod. app (Ansari), one for staging app (Ansari - Staging), and one for local/dev. app (Ansari - Test) +# NOTE 3: If you want to debug or check the validity of an access token, check this URL: +# https://developers.facebook.com/tools/debug/accesstoken/?access_token= +META_ACCESS_TOKEN_FROM_SYS_USER="your_access_token_here" + +# Token for webhook verification (from Meta Dashboard) +META_WEBHOOK_VERIFY_TOKEN="<>" + +# Meta App Secret for webhook signature verification +# References: +# - https://developers.facebook.com/docs/graph-api/webhooks/getting-started#validate-payloads +# - https://developers.facebook.com/docs/facebook-login/security/ +# - https://stackoverflow.com/questions/76898751/how-to-find-app-secret-for-whatsapp-business-api +# Location: Meta Developer Dashboard (https://developers.facebook.com/) +# → Log in → My Apps → Select your app → App Settings → Basic → App Secret (click "Show") +# NOTE: In "Select your app" step, we mean one of the following: "Ansari - Test", "Ansari - Staging", "Ansari" +# Where "Ansari - Test" is used for local development with zrok webhook URL +# Security: Used to verify X-Hub-Signature-256 header on incoming webhooks from Meta +# This ensures webhook requests are authentic and haven't been tampered with +META_ANSARI_APP_SECRET="your_app_secret_here" + + +###################################### Related to Chat Settings ###################################### + +WHATSAPP_UNDER_MAINTENANCE="False" +# Hours to retain chat history +WHATSAPP_CHAT_RETENTION_HOURS="3" +# Maximum age in seconds for incoming WhatsApp messages to be processed (24 hours = 86400 seconds) +# Messages older than this threshold will be rejected to avoid processing outdated messages +WHATSAPP_MESSAGE_AGE_THRESHOLD_SECONDS="86400" + +###################################### Related to Test/Development/Logging ###################################### + +# Meta webhook response behavior +# Meta's webhooks require HTTP 200 responses to acknowledge receipt. +# When True (production/staging/development/local): Always return HTTP 200 to Meta, regardless of internal errors +# When False (testing only): Return proper HTTP status codes (400, 500, etc.) for test assertions +# NOTE: This should ALWAYS be True in production. Only set to False in automated tests. +ALWAYS_RETURN_OK_TO_META="True" + +LOGGING_LEVEL="DEBUG" + +# If True, only log messages from test files (i.e., files in "tests" folder or files starting with "test_") +# Otherwise, log messages coming from any file +# (Mainly used when running test scripts) +LOG_TEST_FILES_ONLY="False" + +###################################### Related to Service Provider Settings ###################################### + +# If True, use mock Ansari client instead of making real HTTP calls to the backend +# Mock mode is useful for: +# - Development without requiring the backend service to be running +# - Unit testing with simulated responses and realistic latency/errors +# - Offline development and testing +# - Cost savings (no AI API calls during development) +# When False (default), the real client makes actual HTTP requests to BACKEND_SERVER_URL +MOCK_ANSARI_CLIENT="False" + +# If True, use mock Meta API service instead of making real HTTP calls to Meta WhatsApp API +# Mock mode is useful for: +# - Development when Meta API credentials are not configured or invalid +# - Testing webhook flow without sending actual WhatsApp messages +# - Avoiding Meta API quota consumption during development +# - Debugging issues without affecting real WhatsApp users +# When False (default), real HTTP calls are made to Meta WhatsApp Cloud API +MOCK_META_API="False" \ No newline at end of file diff --git a/apps/whatsapp/.gitignore b/apps/whatsapp/.gitignore new file mode 100644 index 00000000..4306fda8 --- /dev/null +++ b/apps/whatsapp/.gitignore @@ -0,0 +1,12 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.env +logs/ +tmp/ +tests/*.log +tests/*.json +zrok.exe diff --git a/apps/whatsapp/Dockerfile b/apps/whatsapp/Dockerfile new file mode 100644 index 00000000..c89e3d45 --- /dev/null +++ b/apps/whatsapp/Dockerfile @@ -0,0 +1,47 @@ +FROM python:3.13 + +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app/ + +# Install uv +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv +COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /bin/ + +# Place executables in the environment at the front of the path +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment +ENV PATH="/app/.venv/bin:$PATH" + +# Compile bytecode +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode +ENV UV_COMPILE_BYTECODE=1 + +# uv Cache +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching +ENV UV_LINK_MODE=copy + +# Install dependencies +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-project + +ENV PYTHONPATH=/app + +# Copy project files into the container +# Side ntoe: we can write the destination path in 2 ways: +# - ./ == relative to WORKDIR (/app/), so ./ == /app/ +# - /app/ == absolute path to /app/ (same result, but explicit) +# the line below uses the second option for clarity +COPY ./pyproject.toml ./uv.lock /app/ + +COPY ./src /app/src/ + +# Sync the project +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync + +# Run the application +CMD ["python", "src/ansari_whatsapp/app/main.py"] \ No newline at end of file diff --git a/apps/whatsapp/README.md b/apps/whatsapp/README.md new file mode 100644 index 00000000..1374acc0 --- /dev/null +++ b/apps/whatsapp/README.md @@ -0,0 +1,168 @@ +# Ansari WhatsApp Service + +This service handles WhatsApp integration for the Ansari backend, providing a dedicated service for processing WhatsApp messages and communicating with the main Ansari backend. + +## Overview + +The Ansari WhatsApp service is designed as a separate microservice that: + +1. Receives webhook events from the WhatsApp Business API +2. Processes incoming messages +3. Communicates with the main Ansari backend API +4. Sends responses back to WhatsApp users + +By separating the WhatsApp functionality into its own service, we can: +- Scale the WhatsApp service independently +- Simplify the main Ansari backend codebase +- Make deployments and updates easier +- Improve testing and maintenance + +## Setup + +### Prerequisites + +- Python 3.8 or higher +- Access to the WhatsApp Business API +- A running instance of the Ansari backend API + +### Installation + +1. Clone the repository: + ``` + git clone + cd ansari-whatsapp + ``` + +2. Run the setup script: + - On Linux/Mac: + ``` + ./setup.sh + ``` + - On Windows: + ``` + setup.bat + ``` + +3. Edit the `.env` file with your configuration settings: + ``` + # Update these with your values + META_BUSINESS_PHONE_NUMBER_ID=your_phone_number_id + META_ACCESS_TOKEN_FROM_SYS_USER=your_access_token + META_WEBHOOK_VERIFY_TOKEN=your_verify_token + BACKEND_SERVER_URL=http://your-ansari-backend:8000 + ``` + +## Running the Service + +### Development Mode + +``` +python -m src.ansari_whatsapp.app.main +``` + +This will start the service on port 8001 (or the port specified in your .env file). + +### Using Docker + +To build and run the Docker container: + +``` +docker build -t ansari-whatsapp . +docker run -p 8001:8001 --env-file .env ansari-whatsapp +``` + +## API Endpoints + +The service exposes the following endpoints: + +- `GET /`: Health check endpoint +- `GET /whatsapp/v2`: Webhook verification endpoint for WhatsApp +- `POST /whatsapp/v2`: Main webhook endpoint for receiving WhatsApp messages + +## Documentation + +### For Newcomers +1. **Quick Start**: Read this README for setup and basic usage +2. **Development Guide**: See `CLAUDE.md` for detailed development instructions, testing, and commands +3. **Architecture Overview**: See `docs/hld/architecture.md` for system design and data flow +4. **Implementation Details**: See `docs/lld/implementation_guide.md` for technical deep-dive + +### For Deployment +- **AWS Setup**: See `docs/lld/aws/README.md` for complete deployment walkthrough +- **GitHub Actions**: See `docs/lld/github_actions/README.md` for CI/CD configuration + +### For Migration Context +- **Migration Plan**: See `docs/whatsapp_migration_plan/migration_plan.md` for the 4-phase migration from monolith to microservice + +## Architecture + +The service follows a clean architecture pattern: + +- `app/`: FastAPI application with webhook endpoints +- `services/`: Business logic layer (conversation manager, API clients with mock support) +- `presenters/`: Message formatting layer (WhatsApp-specific formatting, RTL support) +- `utils/`: Utility modules (config, logging, parsing, splitting) + +## Integration with Ansari Backend + +The WhatsApp service communicates with the Ansari backend via 6 API endpoints: + +1. **POST /whatsapp/v2/users/register** - User registration +2. **GET /whatsapp/v2/users/exists** - Check user existence +3. **POST /whatsapp/v2/threads** - Create new threads +4. **GET /whatsapp/v2/threads/last** - Get last thread info +5. **GET /whatsapp/v2/threads/{thread_id}/history** - Get thread history +6. **POST /whatsapp/v2/messages/process** - Process messages (streaming) + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| HOST | Host to bind the server to | 0.0.0.0 | +| PORT | Port to run the server on | 8001 | +| BACKEND_SERVER_URL | URL of the Ansari backend API | http://localhost:8000 | +| META_API_VERSION | WhatsApp API version | v21.0 | +| META_BUSINESS_PHONE_NUMBER_ID | Your WhatsApp business phone number ID | (required) | +| META_ACCESS_TOKEN_FROM_SYS_USER | Access token for WhatsApp API | (required) | +| META_WEBHOOK_VERIFY_TOKEN | Verify token for WhatsApp webhook | (required) | +| WHATSAPP_CHAT_RETENTION_HOURS | Hours to retain chat history | 3 | +| DEV_MODE | Enable development mode | false | + +## Testing + +### Running Tests + +The project includes comprehensive integration tests for WhatsApp webhook endpoints. + +```bash +# Run all tests with detailed output +pytest tests/ -v + +# Run with logs displayed +pytest tests/ -v -s + +# Run only integration tests +pytest tests/ -m integration -v +``` + +### Test Configuration + +Tests require environment variables to be configured. Key settings: + +- **MOCK_ANSARI_CLIENT**: Set to `True` (default) to use mock client (no backend needed), `False` to use real backend +- **MOCK_META_API**: Set to `True` (default) to use mock Meta API, `False` for real API +- **BACKEND_SERVER_URL**: URL of the backend service (required if `MOCK_ANSARI_CLIENT=False`) + +**Important**: If `MOCK_ANSARI_CLIENT=False` and the backend is not available, tests will fail with clear instructions on how to fix the issue. + +See `tests/README.md` for detailed testing documentation and troubleshooting. + +## Troubleshooting + +Logs are stored in the `logs/` directory. Check these logs for any errors or issues. + +Common issues: +- WhatsApp API connection problems +- Missing or invalid environment variables +- Connection issues with the Ansari backend +- Test failures due to backend availability (see `tests/README.md` for solutions) \ No newline at end of file diff --git a/apps/whatsapp/clean_logs.sh b/apps/whatsapp/clean_logs.sh new file mode 100644 index 00000000..1f6c98c7 --- /dev/null +++ b/apps/whatsapp/clean_logs.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +# This script cleans log files in the ./logs directory by removing entries older than a specified number of minutes. + +# NOTE: to run in powershell, you have to prefix with `sh ` +# E.g.: `sh ./logs/clean_logs.sh 5` + +# Default to 0 minutes if no argument is provided +MINUTES=${1:-0} + +# Current timestamp in seconds +CURRENT_TIME=$(date +%s) + +# Calculate the cutoff time in seconds +if [ $MINUTES -eq 0 ]; then + echo "Removing all log entries from files in ./logs" +else + # Convert minutes to seconds and calculate cutoff time + CUTOFF_TIME=$((CURRENT_TIME - MINUTES * 60)) + echo "Keeping only logs from the last $MINUTES minutes" + echo "Current time: $(date -d @$CURRENT_TIME +'%Y-%m-%d %H:%M:%S')" + echo "Cutoff time: $(date -d @$CUTOFF_TIME +'%Y-%m-%d %H:%M:%S')" +fi + +# Process each log file +for LOG_FILE in ./logs/*.log ./logs/*.log.temp; do + # Skip if the pattern doesn't match any files + [ -e "$LOG_FILE" ] || continue + + if [ -f "$LOG_FILE" ]; then + echo "Processing $LOG_FILE..." + + if [ $MINUTES -eq 0 ]; then + # If minutes is 0, empty the file + > "$LOG_FILE" + echo "Cleared all entries in $LOG_FILE" + else + # Create a temporary file to store matching lines + TEMP_FILE="${LOG_FILE}.temp" + + # Use grep to extract the timestamp and filter rows + while IFS= read -r line; do + # Extract timestamp from log line (format: 2025-04-10 05:01:18) + if [[ $line =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2}[[:space:]][0-9]{2}:[0-9]{2}:[0-9]{2}) ]]; then + LOG_TIMESTAMP="${BASH_REMATCH[1]}" + # Convert timestamp to seconds since epoch + LOG_TIME=$(date -d "$LOG_TIMESTAMP" +%s) + + # Keep line if timestamp is after the cutoff + if [ $LOG_TIME -ge $CUTOFF_TIME ]; then + echo "$line" >> "$TEMP_FILE" + fi + fi + done < "$LOG_FILE" + + # Replace the original file with the filtered content + if [ -f "$TEMP_FILE" ]; then + mv "$TEMP_FILE" "$LOG_FILE" + echo "Kept $(wc -l < "$LOG_FILE") entries in $LOG_FILE" + else + > "$LOG_FILE" + echo "No recent entries found in $LOG_FILE, file cleared" + fi + fi + fi +done + +echo "Log cleaning complete" \ No newline at end of file diff --git a/apps/whatsapp/pyproject.toml b/apps/whatsapp/pyproject.toml new file mode 100644 index 00000000..f2294a2e --- /dev/null +++ b/apps/whatsapp/pyproject.toml @@ -0,0 +1,71 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ansari-whatsapp" +version = "0.1.0" +description = "WhatsApp integration for the Ansari AI assistant to enhance understanding and practice of Islam." +authors = [ + { name = "Ansari Project", email = "feedback@ansari.chat" } +] +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "fastapi>=0.95.1", + "httpx>=0.24.0", + "loguru>=0.7.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.9.1", + "pytest>=8.4.2", + "python-dotenv>=1.0.0", + "requests>=2.32.5", + "rich>=10.0.0", + "sentry-sdk[fastapi]>=2.47.0", + "uvicorn>=0.22.0", + "watchfiles>=1.0.5", +] + +[project.urls] +Homepage = "https://github.com/ansari-project/ansari-whatsapp" +Documentation = "https://github.com/ansari-project/ansari-whatsapp" +Source = "https://github.com/ansari-project/ansari-whatsapp" +Tracker = "https://github.com/ansari-project/ansari-whatsapp/issues" + +[project.optional-dependencies] +# Install with `uv pip install -e .[dev]` for development dependencies +dev = [ + "fastapi[standard]>=0.95.1", +] + +[tool.ruff] +line-length = 127 +indent-width = 4 +target-version = "py310" +lint.select = ["E", "F"] +lint.fixable = ["ALL"] +lint.ignore = [ + "D100", # ignore missing docs + "E402", # false positives for local imports +] +lint.mccabe.max-complexity = 10 + +[tool.ruff.lint.per-file-ignores] +# Long literal strings kept as imported from the legacy repo +"src/ansari_whatsapp/services/ansari_client_mock.py" = ["E501"] +"tests/test_whatsapp_service.py" = ["E501"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" diff --git a/apps/whatsapp/pytest.ini b/apps/whatsapp/pytest.ini new file mode 100644 index 00000000..7b5bf1b0 --- /dev/null +++ b/apps/whatsapp/pytest.ini @@ -0,0 +1,28 @@ +[pytest] +# pytest configuration for ansari-whatsapp tests + +# Test discovery patterns +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Markers for categorizing tests +markers = + integration: Integration tests (require both services running) + api: API endpoint tests (require backend service running) + streaming: Streaming endpoint tests (test real-time response streaming) + slow: Slow tests (may take longer to complete) + +# Output options +addopts = + --tb=short + --strict-markers + --strict-config + --disable-warnings + +# Minimum version +minversion = 6.0 + +# Test timeout (if pytest-timeout is installed) +# timeout = 300 \ No newline at end of file diff --git a/apps/whatsapp/src/ansari_whatsapp/__init__.py b/apps/whatsapp/src/ansari_whatsapp/__init__.py new file mode 100644 index 00000000..3d05487c --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/__init__.py @@ -0,0 +1,2 @@ +# ansari-whatsapp package +"""The WhatsApp service for the Ansari backend.""" diff --git a/apps/whatsapp/src/ansari_whatsapp/app/__init__.py b/apps/whatsapp/src/ansari_whatsapp/app/__init__.py new file mode 100644 index 00000000..bcf03d3b --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/app/__init__.py @@ -0,0 +1,2 @@ +# App package for ansari-whatsapp +"""FastAPI application for the WhatsApp service.""" diff --git a/apps/whatsapp/src/ansari_whatsapp/app/main.py b/apps/whatsapp/src/ansari_whatsapp/app/main.py new file mode 100644 index 00000000..45b1dbfa --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/app/main.py @@ -0,0 +1,453 @@ +# Main WhatsApp API application +""" +FastAPI application for handling WhatsApp webhook requests. + +This application serves as a wrapper around the Ansari backend API, +handling incoming webhook requests from the WhatsApp Business API +and forwarding them to the Ansari backend for processing. + +NOTE: the `BackgroundTasks` logic is inspired by this issue and chat (respectively): +https://stackoverflow.com/questions/72894209/whatsapp-cloud-api-sending-old-message-inbound-notification-multiple-time-on-my +https://www.perplexity.ai/search/explain-fastapi-s-backgroundta-rnpU7D19QpSxp2ZOBzNUyg +""" + +import uuid + +from fastapi import FastAPI, HTTPException, Request, BackgroundTasks, Depends +from fastapi.responses import HTMLResponse, Response, JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +import sentry_sdk +from sentry_sdk.types import Event, Hint + +from ansari_whatsapp.services.whatsapp_conversation_manager import WhatsAppConversationManager +from ansari_whatsapp.utils.whatsapp_webhook_utils import ( + parse_meta_payload, + verify_meta_signature, + create_response_for_meta, +) + +# Note: Importing app_logger automatically configures loguru's logger due to module-level execution +from ansari_whatsapp.utils.app_logger import logger, request_id_var, user_id_var +from ansari_whatsapp.utils.time_utils import is_message_too_old +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.utils.exceptions import PhoneHiddenUserError, UnexpectedMetaPayloadError +from ansari_whatsapp.utils.general_helpers import CORSMiddlewareWithLogging + + +# Configure Sentry for error tracking when we're NOT developing locally +# NOTE: The SENTRY_DSN check is redundant, since Sentry's init() and other methods +# will be in "no-ops" mode (i.e., do nothing) if the DSN isn't configured. +# Source: https://docs.sentry.io/platforms/python/configuration/options/#dsn +# Nevertheless, we're adding it here for clarity. +deployment_type = get_settings().DEPLOYMENT_TYPE +if get_settings().SENTRY_DSN and deployment_type != "local": + ignore_errors = [ + "HTTP exception: 401", + "HTTP exception: 403", + ] + + def sentry_before_send(event: Event, hint: Hint) -> Event | None: + logentry = event.get("logentry") + if logentry and any(error in logentry.get("message", "") for error in ignore_errors): + return None + + return event + + sentry_sdk.init( + dsn=get_settings().SENTRY_DSN, + environment=deployment_type, + # Add data like request headers and IP for users, if applicable; + # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info + send_default_pii=False, + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=0.2 if deployment_type == "production" else 1.0, + # Set profiles_sample_rate to 1.0 to profile 100% + # of sampled transactions. + # We recommend adjusting this value in production. + profiles_sample_rate=0.2 if deployment_type == "production" else 1.0, + before_send=sentry_before_send, + ) + + +# Create FastAPI application +app = FastAPI( + title="Ansari WhatsApp API", + description="API for handling WhatsApp webhook requests for the Ansari service", + version="1.0.0", +) + + +# Custom exception handlers for consistent error responses +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request, exc): + logger.error(f"HTTP exception: {exc}") + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + errors = exc.errors() + logger.error(f"Validation errors: {errors}") + return JSONResponse( + status_code=400, + content={"detail": str(exc)}, + ) + + +@app.exception_handler(Exception) +async def general_exception_handler(request, exc): + logger.error(f"Unhandled exception: {exc}") + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"}, + ) + + +@app.get("/sentry-debug") +async def trigger_error(): + 1 / 0 # deliberate error to test Sentry + + +# Add CORS middleware with logging +app.add_middleware( + CORSMiddlewareWithLogging, + allow_origins=get_settings().ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Request ID middleware for log correlation +@app.middleware("http") +async def request_id_middleware(request: Request, call_next): + """Generate unique request ID for each HTTP request. + + This middleware: + - Generates a UUID for each incoming request + - Sets it in a contextvar (accessible in all logs during this request) + - Adds X-Request-ID header to response for client-side correlation + """ + request_id = str(uuid.uuid4()) + request_id_var.set(request_id) + + logger.debug("Request started") + + response = await call_next(request) + + response.headers["X-Request-ID"] = request_id + logger.debug("Request ended") + return response + + +@app.get("/") +async def root(): + """Root endpoint for health checks.""" + logger.info("Health check endpoint called") + return {"status": "ok", "message": "Ansari WhatsApp service is running"} + + +@app.get("/whatsapp/v2") +async def verification_webhook(request: Request) -> str | None: + """ + Handles the WhatsApp webhook verification request. + + Args: + request (Request): The incoming HTTP request. + + Returns: + Optional[str]: The challenge string if verification is successful, otherwise raises an HTTPException. + + References: + - https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verification-requests + """ + mode = request.query_params.get("hub.mode") + verify_token = request.query_params.get("hub.verify_token") + challenge = request.query_params.get("hub.challenge") + + if get_settings().DEPLOYMENT_TYPE == "local": + logger.debug(f"Verification webhook received: {mode=}, {challenge=}, {verify_token=}") + else: + logger.debug(f"Verification webhook received: {mode=}, {challenge=}, verification token matches configured value") + + if mode and verify_token: + if mode == "subscribe" and verify_token == get_settings().META_WEBHOOK_VERIFY_TOKEN.get_secret_value(): + logger.info("WHATSAPP WEBHOOK VERIFIED SUCCESSFULLY!") + # Note: Challenge must be wrapped in an HTMLResponse for Meta to accept and verify the callback + return HTMLResponse(challenge) + logger.error("Verification failed: Invalid token or mode") + raise HTTPException(status_code=403, detail="Forbidden") + raise HTTPException(status_code=400, detail="Bad Request") + + +@app.post("/whatsapp/v2") +async def main_webhook( + request: Request, background_tasks: BackgroundTasks, _: None = Depends(verify_meta_signature) +) -> Response: + """ + Handles the incoming WhatsApp webhook message from Meta's WhatsApp Business API. + + This is the main webhook endpoint that processes all incoming messages from WhatsApp users. + It performs several validation and filtering steps before processing user messages: + + **Processing Flow:** + 1. **Verify Signature**: Validates X-Hub-Signature-256 header to ensure request is from Meta + 2. **Extract Message Details**: Parses the webhook payload to extract relevant information + (sender phone number, message type, message body, timestamps, etc.) + + 3. **Validation Checks** (returns early if conditions met): + - Verifies the webhook is for our WhatsApp business number + - Filters out status messages (delivered, read, etc.) + - Checks if service is under maintenance + - Handles staging/local development routing (temporary workaround) + - Validates message age (rejects messages older than configured threshold) + - Verifies user registration status + - Filters unsupported message types (non-text messages) + + 4. **Background Processing**: + - Starts typing indicator loop (shows user that bot is processing) + - Processes text messages asynchronously via background tasks + + **Early Return Scenarios:** + - Wrong business number: Returns 200 with skip message + - Status message: Returns 200 with status acknowledgment + - Maintenance mode: Sends maintenance message and returns 200 + - Staging filter: Returns 200 if message prefixed with "!d" in staging + - Old message: Returns 200 after notifying user + - Registration failure: Returns 500 error response + - Unsupported media: Sends unsupported message and returns 200 + + **Meta Webhook Compliance:** + - Must respond quickly (within 20 seconds) to avoid retries + - Returns 200 status code for successful receipt (actual processing happens in background) + - All actual message processing is delegated to background tasks + + Args: + request (Request): The incoming HTTP request containing the webhook payload from Meta. + background_tasks (BackgroundTasks): FastAPI's background task manager for async processing. + + Returns: + Response: HTTP response, typically with status code 200 to acknowledge receipt to Meta. + Response structure depends on ALWAYS_RETURN_OK_TO_META setting. + + Raises: + None: All exceptions are caught and handled, returning appropriate responses. + Errors are logged but don't prevent Meta from receiving a 200 response. + + Note: + - The function uses FastAPI's BackgroundTasks to process messages asynchronously, + ensuring quick responses to Meta while handling potentially long-running operations. + - The staging "!d" prefix filter is a temporary workaround until dedicated test numbers + are available for each environment. + """ + # Step 1: Signature verification now handled by Depends(verify_meta_signature_dependency) + # Step 2: Parse the JSON payload + data = await request.json() + + # Step 3: Extract message details from the webhook payload + try: + ( + is_status, + is_target_business_number, + from_whatsapp_number, + incoming_msg_type, + incoming_msg_body, + message_id, + message_unix_time, + ) = await parse_meta_payload(data) + + # Check if this webhook is intended for our WhatsApp business phone number + if not is_target_business_number: + logger.debug("Ignoring webhook not intended for our WhatsApp business number") + return create_response_for_meta( + success=True, + message="Skipping, as this webhook is not intended for our WhatsApp business number", + status_code=200, + ) + + # Terminate if the incoming message is a status message (e.g., "delivered") + if is_status: + logger.debug("Ignoring status update message (e.g., delivered, read)") + # This is a status message, not a user message, so doesn't need processing + return create_response_for_meta( + success=True, message="Status message processed (ignored)", error_code="STATUS_MESSAGE" + ) + + logger.debug(f"Incoming whatsapp webhook message from {from_whatsapp_number}") + except PhoneHiddenUserError as e: + logger.warning(f"{e}; sending a notice instead of processing the message") + notifier = WhatsAppConversationManager(user_phone_num=e.bsuid) + background_tasks.add_task( + notifier.send_whatsapp_message, + "Apologies, but Ansari is currently running an old implementation which requires you to have " + "your phone number sent to it (not just your username), so either adjust your WhatsApp privacy " + "settings or wait for our new Ansari release, which will take some time.", + ) + return create_response_for_meta( + success=False, + message="Sender did not share a phone number", + status_code=422, + error_code="PHONE_HIDDEN_USER", + ) + except UnexpectedMetaPayloadError as e: + logger.error(f"Unexpected Meta payload format: {e}") + sentry_sdk.set_tag("error_type", "unexpected_meta_payload") + sentry_sdk.set_context( + "webhook_details", + { + "request_id": request_id_var.get(), + }, + ) + sentry_sdk.capture_exception(e) + if e.reply_to: + notifier = WhatsAppConversationManager(user_phone_num=e.reply_to) + background_tasks.add_task( + notifier.send_whatsapp_message, + "It seems Meta sent your message in an unexpected format. This is not a problem in what you wrote, " + "so please send this chat as screenshot to the developers to investigate further", + ) + else: + logger.error("No phone number or BSUID in the payload, so the user could not be notified") + return create_response_for_meta( + success=False, + message="Unexpected message format received from Meta", + status_code=400, + error_code="UNEXPECTED_PAYLOAD_FORMAT", + details={"error": str(e)}, + ) + except Exception as e: + logger.exception(f"Error extracting message details: {e}") + sentry_sdk.set_tag("error_type", "webhook_processing_failure") + sentry_sdk.set_context( + "webhook_details", + { + "request_id": request_id_var.get(), + }, + ) + sentry_sdk.capture_exception(e) + return create_response_for_meta( + success=False, + message="Error processing webhook payload", + status_code=400, + error_code="INVALID_PAYLOAD", + details={"error": str(e)}, + ) + + # Create a user-specific conversation manager instance for this request + conversation_manager = WhatsAppConversationManager( + user_phone_num=from_whatsapp_number, + incoming_msg_type=incoming_msg_type, + incoming_msg_body=incoming_msg_body, + message_id=message_id, + message_unix_time=message_unix_time, + ) + + # Check if the WhatsApp service is enabled + if get_settings().WHATSAPP_UNDER_MAINTENANCE: + # Inform the user that the service is down for maintenance + background_tasks.add_task( + conversation_manager.send_whatsapp_message, + "Ansari for WhatsApp is down for maintenance, please try again later or visit our website at https://ansari.chat.", + ) + return create_response_for_meta( + success=False, message="Service under maintenance", status_code=503, error_code="MAINTENANCE_MODE" + ) + + # Temporary corner case while locally developing: + # Since the staging server is always running, + # and since we currently have the same testing number for both staging and local testing, + # therefore we need an indicator that a message is meant for a dev who's testing locally now + # and not for the staging server. + # This is done by prefixing the message with "!d " (e.g., "!d what is ansari?") + # NOTE: Obviously, this temp. solution will be removed when we get a dedicated testing number for staging testing. + if get_settings().DEPLOYMENT_TYPE == "staging" and incoming_msg_body.get("body", "").startswith("!d "): + logger.debug("Incoming message is meant for a dev who's testing locally now, so will not process it in staging...") + return create_response_for_meta( + success=False, message="Message filtered for local development", status_code=202, error_code="DEV_FILTER" + ) + + # Start the typing indicator loop that will continue until message is processed + background_tasks.add_task( + conversation_manager.send_typing_indicator_then_start_loop, + ) + + # Check if there are more than xx hours have passed from the user's message to the current time + # If so, send a message to the user and return + if is_message_too_old(message_unix_time): + return create_response_for_meta( + success=False, message="Message too old, notified user", status_code=422, error_code="MESSAGE_TOO_OLD" + ) + + # Retrieve existing user or register new user and get their user_id + user_id = await conversation_manager.retrieve_or_register_user() + + if user_id is None: + # User retrieval/registration failed + background_tasks.add_task( + conversation_manager.send_whatsapp_message, + "Sorry, we couldn't register you to our database. Please try again later.", + ) + return create_response_for_meta( + success=False, message="User retrieval/registration failed", status_code=500, error_code="USER_REGISTRATION_FAILED" + ) + + # Set user_id in contextvar for all subsequent logs (including background tasks) + # Check docs/logging/understanding_contextvars_in_python.md for details + user_id_var.set(user_id) + + # Check if the incoming message is a media type other than text + if incoming_msg_type != "text": + background_tasks.add_task( + conversation_manager.handle_unsupported_message, + ) + return create_response_for_meta( + success=False, message="Unsupported message type handled", status_code=415, error_code="UNSUPPORTED_MESSAGE_TYPE" + ) + + # Process text messages sent by the WhatsApp user + background_tasks.add_task( + conversation_manager.handle_text_message, + ) + + return create_response_for_meta(success=True, message="Message processed successfully") + + +if __name__ == "__main__": + # This block is executed when the script is run directly + # Alternative: Run with uvicorn command for auto-reload on .env file changes: + # uvicorn src.ansari_whatsapp.app.main:app --reload --reload-include .env + import uvicorn + import os + + settings = get_settings() + + # Determine module path for uvicorn dynamically + # (E.g.: ansari_whatsapp.app.main:app) + file_path = os.path.abspath(__file__) + module_path = os.path.relpath(file_path, os.getcwd()) + module_name = module_path.replace(os.sep, ".").replace(".py", "").replace("src.", "", 1) + ":app" + logger.debug(f"Running FastAPI app with module name: {module_name}") + + if settings.DEPLOYMENT_TYPE == "local": + host = "localhost" + reload_uvicorn = True + reload_includes = [".env"] + else: + host = "0.0.0.0" + reload_uvicorn = False + reload_includes = None + + # Run the FastAPI app with uvicorn + uvicorn.run( + module_name, # Dynamically constructed module path + host=host, + port=8001, + reload=reload_uvicorn, + reload_includes=reload_includes, + log_level="info", # Uvicorn expects lowercase log levels + ) diff --git a/apps/whatsapp/src/ansari_whatsapp/presenters/whatsapp_message_formatter.py b/apps/whatsapp/src/ansari_whatsapp/presenters/whatsapp_message_formatter.py new file mode 100644 index 00000000..c4932d10 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/presenters/whatsapp_message_formatter.py @@ -0,0 +1,134 @@ +# WhatsApp Message Formatter +"""Utilities for formatting messages for WhatsApp display (markdown conversion).""" + +import re + +from ansari_whatsapp.utils.language_utils import get_language_direction_from_text + + +def format_for_whatsapp(msg: str) -> str: + """Convert conventional markdown syntax to WhatsApp's markdown syntax. + + Args: + msg (str): The message to convert. + + Returns: + str: The converted message with WhatsApp markdown. + """ + msg_direction = get_language_direction_from_text(msg) + + # Process standard markdown syntax + msg = convert_italic_syntax(msg) + msg = convert_bold_syntax(msg) + msg = convert_headers(msg) + + # Process lists based on text direction + if msg_direction in ["ltr", "rtl"]: + msg = format_nested_lists(msg) + + return msg + + +def convert_italic_syntax(text: str) -> str: + """Convert markdown italic syntax (*text*) to WhatsApp italic syntax (_text_). + + Args: + text (str): Text to convert + + Returns: + str: Text with WhatsApp italic syntax + """ + # Regex details: + # (? str: + """Convert markdown bold syntax (**text**) to WhatsApp bold syntax (*text*). + + Args: + text (str): Text to convert + + Returns: + str: Text with WhatsApp bold syntax + """ + return text.replace("**", "*") + + +def convert_headers(text: str) -> str: + """Convert markdown headers to WhatsApp's bold+italic format. + + Args: + text (str): Text to convert + + Returns: + str: Text with WhatsApp header format + """ + # Process headers with content directly after them + pattern = re.compile(r"(?! )#+ \**_*(.*?)\**_*\n(?!\n)") + text = pattern.sub(r"*_\1_*\n\n", text) + + # Process headers with empty line after them + pattern = re.compile(r"(?! )#+ \**_*(.*?)\**_*\n\n") + return pattern.sub(r"*_\1_*\n\n", text) + + +def format_nested_lists(text: str) -> str: + """Format only nested lists/bullet points with WhatsApp's special formatting. + + This handles: + 1. Nested bullet points within numbered lists + 2. Nested numbered lists within bullet points + 3. Purely nested bullet points + 4. Purely nested numbered lists + + Simple (non-nested) lists retain their original formatting. + + Args: + text (str): Text to format + + Returns: + str: Text with WhatsApp nested list formatting + """ + lines = text.split("\n") + processed_lines = [] + in_nested_section = False + nested_section_indent = 0 + + for i, line in enumerate(lines): + # Check for indentation to detect nesting + indent_match = re.match(r"^(\s+)", line) if line.strip() else None + current_indent = len(indent_match.group(1)) if indent_match else 0 + + # Check if this is a list item (numbered or bullet) + is_numbered_item = re.match(r"^\s*\d+\.\s", line) + is_bullet_item = re.match(r"^\s*[\*-]\s", line) + + # Determine if we're entering, in, or exiting a nested section + if (is_numbered_item or is_bullet_item) and current_indent > 0: + # This is a nested item + if not in_nested_section: + in_nested_section = True + nested_section_indent = current_indent + + # Format nested items + if is_numbered_item: + # Convert nested numbered list format: " 1. Item" -> " 1 - Item" + line = re.sub(r"(\s*)(\d+)(\.) ", r"\1\2 - ", line) + elif is_bullet_item: + # Convert nested bullet format: " - Item" or " * Item" -> " -- Item" + line = re.sub(r"(\s*)[\*-] ", r"\1-- ", line) + + elif in_nested_section and current_indent < nested_section_indent: + # We're exiting the nested section + in_nested_section = False + + # For non-nested items, leave them as they are + processed_lines.append(line) + + return "\n".join(processed_lines) diff --git a/apps/whatsapp/src/ansari_whatsapp/services/__init__.py b/apps/whatsapp/src/ansari_whatsapp/services/__init__.py new file mode 100644 index 00000000..82f3eeae --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/__init__.py @@ -0,0 +1,2 @@ +# Services package for ansari-whatsapp +"""Service layer containing client implementations and service providers.""" diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py new file mode 100644 index 00000000..25da0244 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py @@ -0,0 +1,107 @@ +# Abstract base class for Ansari clients +"""Defines the interface that all Ansari client implementations must follow.""" + +from abc import ABC, abstractmethod + + +class AnsariClientBase(ABC): + """Abstract base class defining the interface for Ansari clients. + + This interface ensures that both real and mock implementations + provide the same methods with consistent signatures. + """ + + @abstractmethod + async def register_user(self, phone_num: str, preferred_language: str) -> dict: + """Register a new WhatsApp user with the Ansari backend. + + Args: + phone_num (str): The user's WhatsApp phone number. + preferred_language (str): The user's preferred language. + + Returns: + dict: The registration result with format {"status": "success", "user_id": "..."} + + Raises: + UserRegistrationError: If the registration fails. + """ + pass + + @abstractmethod + async def check_user_exists(self, phone_num: str) -> str: + """Get user ID for a WhatsApp phone number. + + Args: + phone_num (str): The user's WhatsApp phone number. + + Returns: + str: The user's ID + + Raises: + UserExistsCheckError: If the user is not found or the check fails. + """ + pass + + @abstractmethod + async def create_thread(self, phone_num: str, title: str) -> dict: + """Create a new thread for a WhatsApp user in the Ansari backend. + + Args: + phone_num (str): The user's WhatsApp phone number. + title (str): The title of the thread. + + Returns: + dict: The creation result with format {"thread_id": "..."} + + Raises: + ThreadCreationError: If thread creation fails. + """ + pass + + @abstractmethod + async def get_thread_history(self, phone_num: str, thread_id: str) -> dict: + """Get the message history for a WhatsApp user's thread from the Ansari backend. + + Args: + phone_num (str): The user's WhatsApp phone number. + thread_id (str): The ID of the thread. + + Returns: + dict: The thread history with messages. + + Raises: + ThreadHistoryError: If retrieving thread history fails. + """ + pass + + @abstractmethod + async def get_last_thread_info(self, phone_num: str) -> dict: + """Get information about the last active thread for a WhatsApp user. + + Args: + phone_num (str): The user's WhatsApp phone number. + + Returns: + dict: The thread info with format {"thread_id": "...", "last_message_time": "..."} + + Raises: + ThreadInfoError: If retrieving thread info fails. + """ + pass + + @abstractmethod + async def process_message(self, phone_num: str, thread_id: str, message: str) -> str: + """Process a message from a WhatsApp user with the Ansari backend. + + Args: + phone_num (str): The user's WhatsApp phone number. + thread_id (str): The ID of the thread. + message (str): The message to process. + + Returns: + str: The complete response message. + + Raises: + MessageProcessingError: If message processing fails. + """ + pass diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py new file mode 100644 index 00000000..daeda801 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py @@ -0,0 +1,299 @@ +# Mock API Client for ansari-whatsapp +"""Mock client implementation that simulates the Ansari backend without making real HTTP requests.""" + +import random +import asyncio +from datetime import datetime, timezone +from functools import wraps +from typing import Callable + +from loguru import logger + +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.utils.exceptions import ( + UserRegistrationError, + UserExistsCheckError, + ThreadCreationError, + ThreadHistoryError, + ThreadInfoError, + MessageProcessingError, +) +from ansari_whatsapp.services.ansari_client_base import AnsariClientBase + + +def simulate_backend_behavior( + min_latency_s: int = 1, max_latency_s: int = 2, error_rate: float = 0.0, error_class: type[Exception] | None = None +) -> Callable: + """Decorator that simulates backend behavior with configurable latency and error rates. + + Args: + min_latency_s: Minimum simulated network delay in seconds (default: 1) + max_latency_s: Maximum simulated network delay in seconds (default: 2) + error_rate: Probability of simulated errors, 0.0-1.0 (default: 0.0, no errors) + error_class: Specific exception class to raise, or None to auto-detect (default: None) + + Returns: + Decorator function that wraps the target function with simulated backend behavior + + Example: + @simulate_backend_behavior() # Use all defaults + async def my_function(): ... + + @simulate_backend_behavior(min_latency_s=100, max_latency_s=500, error_rate=0.05) + async def my_function(): ... + """ + + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs): + # Simulate network latency + delay = random.uniform(min_latency_s, max_latency_s) + await asyncio.sleep(delay) + + # Simulate errors if configured + if error_rate > 0 and random.random() < error_rate: + logger.warning(f"Mock client simulating error for {func.__name__}") + + # Use provided error_class or auto-detect from function name + exception_class = error_class + if exception_class is None: + # Auto-detect appropriate error class from function name + if "register" in func.__name__: + exception_class = UserRegistrationError + elif "exists" in func.__name__: + exception_class = UserExistsCheckError + elif "thread" in func.__name__ and "create" in func.__name__: + exception_class = ThreadCreationError + elif "history" in func.__name__: + exception_class = ThreadHistoryError + elif "thread" in func.__name__: + exception_class = ThreadInfoError + elif "message" in func.__name__: + exception_class = MessageProcessingError + else: + exception_class = Exception + + raise exception_class(f"Simulated error in {func.__name__}") + + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +class AnsariClientMock(AnsariClientBase): + """Mock implementation of Ansari client for testing without backend. + + This client simulates backend responses with realistic latency and error rates. + It maintains in-memory state to provide stateful mocking across method calls. + """ + + def __init__(self): + """Initialize the mock Ansari API client with in-memory state.""" + self.settings = get_settings() + # In-memory state for mock data + self._users = {} # phone_num -> user_data + self._threads = {} # thread_id -> thread_data + self._thread_counter = 0 + self._user_counter = 0 + + logger.info("Initialized AnsariClientMock - using mock backend") + + @simulate_backend_behavior() + async def register_user(self, phone_num: str, preferred_language: str) -> dict: + """Mock user registration. + + Args: + phone_num: The user's WhatsApp phone number + preferred_language: The user's preferred language + + Returns: + dict: Registration result matching backend format {"status": "success", "user_id": "..."} + + Raises: + UserRegistrationError: Simulated registration errors (2% chance) + """ + if phone_num in self._users: + logger.warning(f"Mock: User {phone_num} already exists") + raise UserRegistrationError("User already registered") + + self._user_counter += 1 + user_id = f"mock_user_{self._user_counter}" + + self._users[phone_num] = { + "user_id": user_id, + "phone_num": phone_num, + "preferred_language": preferred_language, + } + + logger.info(f"Mock: Registered user {phone_num} with ID {user_id}") + return {"status": "success", "user_id": user_id} + + @simulate_backend_behavior() + async def check_user_exists(self, phone_num: str) -> str: + """Mock user ID retrieval. + + Args: + phone_num: The user's WhatsApp phone number + + Returns: + str: The user's ID + + Raises: + UserExistsCheckError: If user not found + """ + if phone_num not in self._users: + logger.debug(f"Mock: User {phone_num} not found") + raise UserExistsCheckError("User not found") + + user_id = self._users[phone_num]["user_id"] + logger.debug(f"Mock: Found user_id for {phone_num}: {user_id}") + return user_id + + @simulate_backend_behavior() + async def create_thread(self, phone_num: str, title: str) -> dict: + """Mock thread creation. + + Args: + phone_num: The user's WhatsApp phone number + title: The thread title + + Returns: + dict: Creation result matching backend format {"thread_id": "..."} + + Raises: + ThreadCreationError: If user not found or simulated error (2% chance) + """ + if phone_num not in self._users: + logger.error(f"Mock: User {phone_num} not found for thread creation") + raise ThreadCreationError("User not found") + + self._thread_counter += 1 + thread_id = f"mock_thread_{self._thread_counter}" + + self._threads[thread_id] = { + "thread_id": thread_id, + "phone_num": phone_num, + "title": title, + "messages": [], + "last_message_time": None, + } + + logger.info(f"Mock: Created thread {thread_id} for {phone_num}") + return {"thread_id": thread_id} + + @simulate_backend_behavior() + async def get_thread_history(self, phone_num: str, thread_id: str) -> dict: + """Mock thread history retrieval. + + Args: + phone_num: The user's WhatsApp phone number + thread_id: The thread ID + + Returns: + dict: Thread history matching backend format + + Raises: + ThreadHistoryError: If thread not found or simulated error (1% chance) + """ + if thread_id not in self._threads: + logger.error(f"Mock: Thread {thread_id} not found") + raise ThreadHistoryError("Thread not found") + + thread = self._threads[thread_id] + + if thread["phone_num"] != phone_num: + logger.error(f"Mock: Thread {thread_id} does not belong to user {phone_num}") + raise ThreadHistoryError("Thread access denied") + + logger.debug(f"Mock: Retrieved history for thread {thread_id}") + return {"thread_id": thread_id, "messages": thread["messages"]} + + @simulate_backend_behavior() + async def get_last_thread_info(self, phone_num: str) -> dict: + """Mock last thread info retrieval. + + Args: + phone_num: The user's WhatsApp phone number + + Returns: + dict: Thread info matching backend format {"thread_id": "...", "last_message_time": "..."} + + Raises: + ThreadInfoError: Simulated error (1% chance) + """ + user_threads = [t for t in self._threads.values() if t["phone_num"] == phone_num] + + if not user_threads: + logger.debug(f"Mock: No threads found for user {phone_num}") + return {"thread_id": None, "last_message_time": None} + + # Get the thread with the most recent message time (or last created) + latest_thread = max(user_threads, key=lambda t: t["last_message_time"] if t["last_message_time"] else "") + + logger.debug(f"Mock: Last thread for {phone_num} is {latest_thread['thread_id']}") + return { + "thread_id": latest_thread["thread_id"], + "last_message_time": latest_thread["last_message_time"], + } + + @simulate_backend_behavior() # min_latency_s=60, max_latency_s=60 + async def process_message(self, phone_num: str, thread_id: str, message: str) -> str: + """Mock message processing with simulated AI response. + + Args: + phone_num: The user's WhatsApp phone number + thread_id: The thread ID + message: The user's message + + Returns: + str: Mock AI response (uses saved sample response if message contains "long") + + Raises: + MessageProcessingError: If thread not found or simulated error (3% chance) + """ + if thread_id not in self._threads: + logger.error(f"Mock: Thread {thread_id} not found for message processing") + raise MessageProcessingError("Thread not found") + + thread = self._threads[thread_id] + + if thread["phone_num"] != phone_num: + logger.error(f"Mock: Thread {thread_id} does not belong to user {phone_num}") + raise MessageProcessingError("Thread access denied") + + # Add user message to thread + thread["messages"].append({"role": "user", "content": message}) + + # Generate mock AI response + response = f"This is a *mock AI assistant* running in test mode. Write 'long' to see a bigger mock response.\n\nYour message: {message[:100]}" + # If message contains "long", try to load the saved sample response from ansari-backend + if "long" in message.lower(): + try: + from pathlib import Path + + sample_file = ( + Path(__file__).parent.parent.parent.parent + / "docs" + / "sample_backend_responses" + / "sample_ansari_llm_response.txt" + ) + + if sample_file.exists(): + with open(sample_file, "r", encoding="utf-8") as f: + response = f.read() + logger.info(f"Mock: Using saved sample response from {sample_file}") + else: + logger.warning(f"Mock: Sample response file not found at {sample_file}, using default response") + except Exception as e: + logger.warning(f"Mock: Failed to load sample response: {e}, using default response") + + # Add AI response to thread + thread["messages"].append({"role": "assistant", "content": response}) + + # Update last message time + thread["last_message_time"] = datetime.now(timezone.utc).isoformat() + + logger.info(f"Mock: Processed message for thread {thread_id}, response length: {len(response)}") + return response diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py new file mode 100644 index 00000000..936324b2 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py @@ -0,0 +1,284 @@ +# Real API Client for ansari-whatsapp +"""Real client implementation for interacting with the Ansari backend API via HTTP.""" + +import httpx + +from loguru import logger + +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.utils.exceptions import ( + UserRegistrationError, + UserExistsCheckError, + ThreadCreationError, + ThreadHistoryError, + ThreadInfoError, + MessageProcessingError, +) +from ansari_whatsapp.services.ansari_client_base import AnsariClientBase + + +class AnsariClientReal(AnsariClientBase): + """Real client for the Ansari backend API that makes actual HTTP requests. + + Uses a persistent httpx.AsyncClient with connection pooling for better performance. + References: + - https://www.python-httpx.org/advanced/clients/#client-instances + - https://www.python-httpx.org/advanced/clients/#why-use-a-client + """ + + def __init__(self): + """Initialize the Ansari API client with persistent HTTP client and authentication. + + The persistent client provides several benefits: + - Connection pooling and reuse for improved performance + - Automatic header injection (authentication, content-type) + - Single configuration point for all HTTP requests + + References: + - https://www.python-httpx.org/advanced/#client-instances + - https://fastapi.tiangolo.com/tutorial/security/ + """ + self.settings = get_settings() + self.base_url = self.settings.BACKEND_SERVER_URL + + # Create persistent HTTP client with authentication headers + # All requests will automatically include these headers + self.client = httpx.AsyncClient( + headers={ + "X-Whatsapp-Api-Key": self.settings.WHATSAPP_SERVICE_API_KEY.get_secret_value(), + "Content-Type": "application/json", + }, + timeout=60.0, # Default timeout for all requests + ) + + async def close(self): + """Close the HTTP client connection and release resources. + + This should be called when the client is no longer needed to properly clean up + connection pools and background tasks. + + References: + - https://www.python-httpx.org/advanced/#client-instances + - https://www.python-httpx.org/async/#opening-and-closing-clients + """ + await self.client.aclose() + + async def register_user(self, phone_num: str, preferred_language: str) -> dict: + """ + Register a new WhatsApp user with the Ansari backend. + + Args: + phone_num (str): The user's WhatsApp phone number. + preferred_language (str): The user's preferred language. + + Returns: + dict: The registration result. + + Raises: + UserRegistrationError: If the registration fails. + """ + try: + response = await self.client.post( + f"{self.base_url}/whatsapp/v2/users/register", + json={ + "phone_num": phone_num, + "preferred_language": preferred_language, + }, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error registering user {phone_num}: {e.response.status_code}") + raise UserRegistrationError(f"Failed to register user: HTTP {e.response.status_code}") from e + except httpx.RequestError as e: + logger.error(f"Network error registering user {phone_num}: {e}") + raise UserRegistrationError("Network error during registration") from e + + async def check_user_exists(self, phone_num: str) -> str: + """ + Get user ID for a WhatsApp phone number. + + Args: + phone_num (str): The user's WhatsApp phone number. + + Returns: + str: The user's ID + + Raises: + UserExistsCheckError: If the user is not found or the check fails. + """ + try: + response = await self.client.get( + f"{self.base_url}/whatsapp/v2/users/exists", + params={"phone_num": phone_num}, + ) + response.raise_for_status() + user_id = response.json() + return user_id + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + logger.debug(f"User {phone_num} not found (404)") + raise UserExistsCheckError("User not found") from e + logger.error(f"HTTP error getting user ID for {phone_num}: {e.response.status_code}") + raise UserExistsCheckError(f"Failed to get user ID: HTTP {e.response.status_code}") from e + except httpx.RequestError as e: + logger.error(f"Network error getting user ID for {phone_num}: {e}") + raise UserExistsCheckError("Network error during user ID retrieval") from e + + async def create_thread(self, phone_num: str, title: str) -> dict: + """ + Create a new thread for a WhatsApp user in the Ansari backend. + + Args: + phone_num (str): The user's WhatsApp phone number. + title (str): The title of the thread. + + Returns: + dict: The creation result with thread_id. + + Raises: + ThreadCreationError: If thread creation fails. + """ + try: + response = await self.client.post( + f"{self.base_url}/whatsapp/v2/threads", + json={"phone_num": phone_num, "title": title}, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error creating thread for {phone_num}: {e.response.status_code}") + raise ThreadCreationError(f"Failed to create thread: HTTP {e.response.status_code}") from e + except httpx.RequestError as e: + logger.error(f"Network error creating thread for {phone_num}: {e}") + raise ThreadCreationError("Network error during thread creation") from e + + async def get_thread_history(self, phone_num: str, thread_id: str) -> dict: + """ + Get the message history for a WhatsApp user's thread from the Ansari backend. + + Args: + phone_num (str): The user's WhatsApp phone number. + thread_id (str): The ID of the thread. + + Returns: + dict: The thread history. + + Raises: + ThreadHistoryError: If retrieving thread history fails. + """ + try: + response = await self.client.get( + f"{self.base_url}/whatsapp/v2/threads/{thread_id}/history", + params={"phone_num": phone_num}, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error getting thread history for {phone_num}: {e.response.status_code}") + raise ThreadHistoryError(f"Failed to get thread history: HTTP {e.response.status_code}") from e + except httpx.RequestError as e: + logger.error(f"Network error getting thread history for {phone_num}: {e}") + raise ThreadHistoryError("Network error during thread history retrieval") from e + + async def get_last_thread_info(self, phone_num: str) -> dict: + """ + Get information about the last active thread for a WhatsApp user. + + Args: + phone_num (str): The user's WhatsApp phone number. + + Returns: + dict: The thread info with thread_id and last_message_time. + + Raises: + ThreadInfoError: If retrieving thread info fails. + """ + try: + response = await self.client.get( + f"{self.base_url}/whatsapp/v2/threads/last", + params={"phone_num": phone_num}, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error getting last thread info for {phone_num}: {e.response.status_code}") + raise ThreadInfoError(f"Failed to get last thread info: HTTP {e.response.status_code}") from e + except httpx.RequestError as e: + logger.error(f"Network error getting last thread info for {phone_num}: {e}") + raise ThreadInfoError("Network error during thread info retrieval") from e + + async def process_message(self, phone_num: str, thread_id: str, message: str) -> str: + """ + Process a message from a WhatsApp user with the Ansari backend. + This method streams the response from the backend to avoid timeout issues. + + Args: + phone_num (str): The user's WhatsApp phone number. + thread_id (str): The ID of the thread. + message (str): The message to process. + + Returns: + str: The complete response message. + + Raises: + MessageProcessingError: If message processing fails. + """ + try: + url = f"{self.base_url}/whatsapp/v2/messages/process" + data = { + "phone_num": phone_num, + "thread_id": thread_id, + "message": message, + } + + # Use stream=True to receive the response as a stream + # Note: self.client already has auth headers and default timeout configured + async with self.client.stream("POST", url, json=data) as response: + if response.status_code != 200: + error_detail = await response.aread() + logger.error(f"Error from backend API: {error_detail}") + raise MessageProcessingError(f"Backend returned HTTP {response.status_code}: {error_detail}") + + # Accumulate the full response as we receive chunks + full_response = "" + async for chunk in response.aiter_text(): + # Each chunk is a token from the streaming response + if chunk: + full_response += chunk + + if not full_response: + logger.warning("Received empty response from backend") + return "" + + # # TEMPORARY: Save response to file for testing/mocking purposes + # # Comment out this block when you don't want to update the sample response file + # try: + # from pathlib import Path + + # # Create the directory if it doesn't exist + # sample_dir = Path.cwd() / "docs" / "sample_backend_responses" + # sample_dir.mkdir(parents=True, exist_ok=True) + + # # Write the response to the file + # sample_file = sample_dir / "sample_ansari_llm_response.txt" + # with open(sample_file, "w", encoding="utf-8") as f: + # f.write(full_response) + # logger.info(f"Saved backend response to {sample_file}") + # except Exception as e: + # logger.warning(f"Failed to save sample response: {e}") + # # END TEMPORARY CODE + + return full_response + except httpx.TimeoutException as e: + logger.error(f"Timeout processing message for {phone_num}: {e}") + raise MessageProcessingError("Request timed out while processing message") from e + except httpx.RequestError as e: + logger.error(f"Network error processing message for {phone_num}: {e}") + raise MessageProcessingError("Network error during message processing") from e + except MessageProcessingError: + # Re-raise our custom exception without wrapping + raise + except Exception as e: + logger.error(f"Unexpected error processing message for {phone_num}: {e}") + raise MessageProcessingError(f"Unexpected error: {str(e)}") from e diff --git a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_base.py b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_base.py new file mode 100644 index 00000000..481a7c52 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_base.py @@ -0,0 +1,38 @@ +# Abstract base class for Meta WhatsApp API services +"""Defines the interface for Meta WhatsApp API interactions.""" + +from abc import ABC, abstractmethod + + +class MetaApiServiceBase(ABC): + """Abstract base class defining the interface for Meta WhatsApp API services. + + This interface ensures that both real and mock implementations + provide the same methods with consistent signatures. + """ + + @abstractmethod + async def send_typing_indicator(self, recipient_phone: str, message_id: str) -> None: + """Send typing indicator to WhatsApp recipient. + + Args: + recipient_phone (str): WhatsApp phone number of recipient + message_id (str): Message ID for the typing indicator + + Raises: + Exception: If sending typing indicator fails + """ + pass + + @abstractmethod + async def send_message(self, recipient_phone: str, message_parts: list[str]) -> None: + """Send WhatsApp message(s) to recipient. + + Args: + recipient_phone (str): WhatsApp phone number of recipient + message_parts (list[str]): List of message parts (already split by length) + + Raises: + Exception: If sending message fails + """ + pass diff --git a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_mock.py new file mode 100644 index 00000000..51f0510a --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_mock.py @@ -0,0 +1,47 @@ +# Mock Meta WhatsApp API service implementation +"""Mock client implementation that simulates Meta WhatsApp API without making real HTTP requests.""" + +import asyncio + +from loguru import logger + +from ansari_whatsapp.services.meta_api_service_base import MetaApiServiceBase + + +class MetaApiServiceMock(MetaApiServiceBase): + """Mock Meta WhatsApp API service for testing/development without real API calls.""" + + def __init__(self): + """Initialize mock Meta API service.""" + logger.info("Initialized MetaApiServiceMock - Meta API calls will be simulated") + + async def send_typing_indicator(self, recipient_phone: str, message_id: str) -> None: + """Simulate sending typing indicator. + + Args: + recipient_phone: WhatsApp phone number of recipient + message_id: Message ID for the typing indicator + """ + logger.info(f"[MOCK META API] Typing indicator simulated for {recipient_phone} (msg_id: {message_id})") + await asyncio.sleep(0.05) # Simulate minimal network delay + + async def send_message(self, recipient_phone: str, message_parts: list[str]) -> None: + """Simulate sending WhatsApp message. + + Args: + recipient_phone: WhatsApp phone number of recipient + message_parts: List of message parts (already split by length) + """ + logger.info(f"[MOCK META API] Sending {len(message_parts)} message part(s) to {recipient_phone}") + + for i, part in enumerate(message_parts, 1): + # Don't log full message if it's just typing indicator + if part == "...": + logger.info(f"[MOCK META API] Part {i}/{len(message_parts)}: Typing indicator") + else: + preview = part[:100] + ("..." if len(part) > 100 else "") + logger.info(f"[MOCK META API] Part {i}/{len(message_parts)}: {preview}") + + await asyncio.sleep(0.1) # Simulate send time per part + + logger.info(f"[MOCK META API] All {len(message_parts)} message part(s) simulated successfully") diff --git a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py new file mode 100644 index 00000000..dc1b93d4 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py @@ -0,0 +1,163 @@ +# Real Meta WhatsApp API service implementation +"""Real client implementation for Meta WhatsApp API via HTTP.""" + +import httpx +import sentry_sdk + +from loguru import logger + +from ansari_whatsapp.utils.app_logger import request_id_var +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.services.meta_api_service_base import MetaApiServiceBase + + +class MetaApiServiceReal(MetaApiServiceBase): + """Real Meta WhatsApp API service that makes actual HTTP requests.""" + + def __init__(self): + """Initialize Meta API service with settings.""" + settings = get_settings() + self.api_url = settings.META_API_URL + self.access_token = settings.META_ACCESS_TOKEN_FROM_SYS_USER.get_secret_value() + + def _get_headers(self) -> dict: + """Get HTTP headers for Meta API requests. + + Returns: + dict: Headers with authorization and content-type + """ + return { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + + async def send_typing_indicator(self, recipient_phone: str, message_id: str) -> None: + """Send typing indicator via Meta API. + + Args: + recipient_phone: WhatsApp phone number of recipient + message_id: Message ID for the typing indicator + + Raises: + httpx.HTTPStatusError: If Meta API returns error status + httpx.RequestError: If network error occurs + """ + if not recipient_phone or not message_id: + logger.error("Cannot send typing indicator: missing recipient_phone or message_id") + return + + json_data = { + "messaging_product": "whatsapp", + "status": "read", + "message_id": message_id, + "typing_indicator": {"type": "text"}, + } + + try: + async with httpx.AsyncClient() as client: + logger.debug(f"Sending typing indicator to {recipient_phone}") + + response = await client.post(self.api_url, headers=self._get_headers(), json=json_data) + response.raise_for_status() + logger.info("Typing indicator sent successfully") + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error sending typing indicator: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + # Only capture 5xx errors (Meta's fault, not ours) + if e.response.status_code >= 500: + sentry_sdk.set_tag("error_type", "meta_api_failure") + sentry_sdk.set_context( + "meta_api_details", + { + "status_code": e.response.status_code, + "endpoint": "send_typing_indicator", + "request_id": request_id_var.get(), + }, + ) + sentry_sdk.capture_exception(e) + raise + except httpx.RequestError as e: + logger.error(f"Network error sending typing indicator: {e}") + raise + except Exception as e: + logger.exception(f"Unexpected error sending typing indicator: {e}") + raise + + async def send_message(self, recipient_phone: str, message_parts: list[str]) -> None: + """Send WhatsApp message(s) via Meta API. + + Args: + recipient_phone: WhatsApp phone number of recipient, or a business-scoped user ID (BSUID, + e.g., "US.13491208655302741918") if the user's phone number is hidden + message_parts: List of message parts (already split by length) + + Raises: + httpx.HTTPStatusError: If Meta API returns error status + httpx.RequestError: If network error occurs + + Note: + Per Meta, a phone number goes in `to`, while a BSUID goes in `recipient` (with `to` omitted; + if both are sent, `to` takes precedence). Meta's page marks BSUID sending as "coming soon" + in one place and as supported from July 2026 in another, and this hasn't been tested against + the live API. + https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#send-message-requests + """ + if not recipient_phone: + logger.error("Cannot send message: No recipient phone number") + return + + if not message_parts: + logger.warning("No message parts to send") + return + + try: + async with httpx.AsyncClient() as client: + logger.debug(f"Sending {len(message_parts)} message part(s) to {recipient_phone}") + + for i, part in enumerate(message_parts, 1): + json_data = { + "messaging_product": "whatsapp", + "text": {"body": part}, + } + if recipient_phone.isdigit(): + json_data["to"] = recipient_phone + else: + # Not a phone number, so it's a business-scoped user ID (e.g., US.1349...) + json_data["recipient_type"] = "individual" + json_data["recipient"] = recipient_phone + + response = await client.post(self.api_url, headers=self._get_headers(), json=json_data) + response.raise_for_status() + + # Log message part + if part != "...": + preview = part[:100] + ("..." if len(part) > 100 else "") + logger.info(f"Sent message part {i}/{len(message_parts)}: {preview}") + else: + logger.info("Sent typing indicator") + + logger.info(f"All {len(message_parts)} message part(s) sent successfully") + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error sending message: {e.response.status_code}") + logger.error(f"Response body: {e.response.text}") + # Only capture 5xx errors (Meta's fault, not ours) + if e.response.status_code >= 500: + sentry_sdk.set_tag("error_type", "meta_api_failure") + sentry_sdk.set_context( + "meta_api_details", + { + "status_code": e.response.status_code, + "endpoint": "send_message", + "request_id": request_id_var.get(), + }, + ) + sentry_sdk.capture_exception(e) + raise + except httpx.RequestError as e: + logger.error(f"Network error sending message: {e}") + raise + except Exception as e: + logger.exception(f"Unexpected error sending message: {e}") + raise diff --git a/apps/whatsapp/src/ansari_whatsapp/services/meta_service_provider.py b/apps/whatsapp/src/ansari_whatsapp/services/meta_service_provider.py new file mode 100644 index 00000000..bc6e637b --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/meta_service_provider.py @@ -0,0 +1,35 @@ +# Service Provider for Meta WhatsApp API services +"""Factory function for providing the appropriate Meta API service implementation.""" + +from loguru import logger + +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.services.meta_api_service_base import MetaApiServiceBase +from ansari_whatsapp.services.meta_api_service_real import MetaApiServiceReal +from ansari_whatsapp.services.meta_api_service_mock import MetaApiServiceMock + + +def get_meta_api_service() -> MetaApiServiceBase: + """Factory function that returns the appropriate Meta API service based on configuration. + + This function implements the Service Provider pattern, returning either: + - MetaApiServiceReal: Makes actual HTTP calls to Meta WhatsApp API (production) + - MetaApiServiceMock: Simulates API responses without network calls (development/testing) + + The choice is controlled by the MOCK_META_API environment variable. + + Returns: + MetaApiServiceBase: Either MetaApiServiceReal or MetaApiServiceMock instance + + Example: + >>> meta_service = get_meta_api_service() + >>> await meta_service.send_message("+1234567890", ["Hello World"]) + """ + settings = get_settings() + + if settings.MOCK_META_API: + logger.info("Service Provider: Returning MetaApiServiceMock (mock mode enabled)") + return MetaApiServiceMock() + else: + logger.debug("Service Provider: Returning MetaApiServiceReal (production mode)") + return MetaApiServiceReal() diff --git a/apps/whatsapp/src/ansari_whatsapp/services/service_provider.py b/apps/whatsapp/src/ansari_whatsapp/services/service_provider.py new file mode 100644 index 00000000..7b093472 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/service_provider.py @@ -0,0 +1,79 @@ +# Service Provider for ansari-whatsapp +"""Factory function for providing the appropriate Ansari client implementation.""" + +from typing import Optional +from loguru import logger + +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.services.ansari_client_base import AnsariClientBase +from ansari_whatsapp.services.ansari_client_real import AnsariClientReal +from ansari_whatsapp.services.ansari_client_mock import AnsariClientMock + + +# Singleton instance - shared across the entire application +_ansari_client_instance: Optional[AnsariClientBase] = None + + +def get_ansari_client() -> AnsariClientBase: + """Factory function that returns THE SAME Ansari client instance (singleton). + + This function implements the Singleton Service Provider pattern, ensuring only one + client instance exists across the application. This enables: + - Proper resource management (single connection pool) + - Efficient cleanup during application shutdown + - Consistent client state across all requests + + The singleton can be one of: + - AnsariClientReal: Makes actual HTTP calls to the backend (production/integration testing) + - AnsariClientMock: Simulates backend responses without network calls (unit testing/offline dev) + + The choice is controlled by the MOCK_ANSARI_CLIENT environment variable, evaluated + only on first call (subsequent calls return the existing instance). + + Returns: + AnsariClientBase: The singleton instance (AnsariClientReal or AnsariClientMock) + + Example: + >>> client = get_ansari_client() # Creates singleton + >>> result = await client.register_user("+1234567890", "en") + >>> client2 = get_ansari_client() # Returns same instance + >>> assert client is client2 # True + + Note: + For test isolation, use reset_ansari_client() to clear the singleton between tests. + """ + global _ansari_client_instance + + if _ansari_client_instance is None: + settings = get_settings() + + if settings.MOCK_ANSARI_CLIENT: + logger.info("Service Provider: Creating AnsariClientMock singleton") + _ansari_client_instance = AnsariClientMock() + else: + logger.debug("Service Provider: Creating AnsariClientReal singleton") + _ansari_client_instance = AnsariClientReal() + + return _ansari_client_instance + + +def reset_ansari_client() -> None: + """Reset the Ansari client singleton instance. + + This function clears the singleton instance, allowing a fresh client to be created + on the next call to get_ansari_client(). This is primarily used for test isolation + to ensure each test gets a fresh client instance. + + Usage in tests: + >>> @pytest.fixture(autouse=True) + >>> def reset_client(): + >>> reset_ansari_client() # Reset before each test + >>> yield + + Warning: + This should NOT be called in production code. It's intended only for test fixtures + to ensure proper test isolation. + """ + global _ansari_client_instance + _ansari_client_instance = None + logger.debug("Service Provider: Ansari client singleton reset") diff --git a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py new file mode 100644 index 00000000..b4e69c5d --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py @@ -0,0 +1,333 @@ +# WhatsApp Conversation Manager +"""Service for orchestrating WhatsApp conversations and message handling.""" + +import asyncio +import time +from datetime import datetime + +import httpx +import sentry_sdk +from loguru import logger + +from ansari_whatsapp.services.service_provider import get_ansari_client +from ansari_whatsapp.services.meta_service_provider import get_meta_api_service +from ansari_whatsapp.utils.app_logger import request_id_var, user_id_var +from ansari_whatsapp.utils.exceptions import ( + UserRegistrationError, + UserExistsCheckError, + ThreadCreationError, + ThreadInfoError, + MessageProcessingError, +) +from ansari_whatsapp.utils.language_utils import get_language_from_text +from ansari_whatsapp.utils.time_utils import calculate_time_passed, get_retention_time_seconds +from ansari_whatsapp.presenters.whatsapp_message_formatter import format_for_whatsapp +from ansari_whatsapp.utils.whatsapp_message_splitter import split_message + + +class WhatsAppConversationManager: + """Orchestrates WhatsApp conversation workflows. + + This service manages the lifecycle of WhatsApp conversations, coordinating + between the Ansari backend API and Meta WhatsApp API to handle: + - User registration and authentication + - Message processing and response generation + - Typing indicators and user experience features + - Thread/conversation management + """ + + def __init__( + self, + user_phone_num: str = None, + incoming_msg_type: str = None, + incoming_msg_body: dict = None, + message_id: str = None, + message_unix_time: int = None, + ): + """Initialize the conversation manager with user-specific details. + + Args: + user_phone_num (str, optional): The WhatsApp phone number of the user. + incoming_msg_type (str, optional): The type of incoming message (text, image, etc.). + incoming_msg_body (dict, optional): The body content of the incoming message. + message_id (str, optional): The ID of the incoming message. + message_unix_time (int, optional): The timestamp of the incoming message in Unix format. + """ + self.user_phone_num = user_phone_num + self.incoming_msg_type = incoming_msg_type + self.incoming_msg_body = incoming_msg_body + self.message_id = message_id + self.message_unix_time = message_unix_time + + # Initialize services and tracking variables + self.ansari_client = get_ansari_client() + self.meta_api_service = get_meta_api_service() + self.typing_indicator_task = None + self.first_indicator_time = None + + async def send_typing_indicator_then_start_loop(self) -> None: + """Send a typing indicator and start a loop to periodically send more while processing.""" + if not self.user_phone_num or not self.message_id: + logger.error("Cannot start typing indicator loop: missing user_phone_num or message_id") + return + + self.first_indicator_time = time.time() + + # Send the initial typing indicator + await self._send_whatsapp_typing_indicator() + + # Start an async task that will keep sending typing indicators + self.typing_indicator_task = asyncio.create_task(self._typing_indicator_loop()) + + async def _typing_indicator_loop(self) -> None: + """Loop that periodically sends typing indicators while processing a message.""" + MAX_DURATION_SECONDS = 300 # 5 minutes maximum + INDICATOR_INTERVAL_SECONDS = 26 # Send indicator every 26 seconds + + try: + while True: + logger.debug("Currently in typing indicator loop (i.e., Ansari is taking longer than usual to respond)") + # Sleep for the interval + await asyncio.sleep(INDICATOR_INTERVAL_SECONDS) + + # Check if we've exceeded the maximum duration + elapsed_time = time.time() - self.first_indicator_time + if elapsed_time > MAX_DURATION_SECONDS: + logger.warning(f"Typing indicator loop exceeded maximum duration of {MAX_DURATION_SECONDS}s. Stopping.") + break + + # If we're still processing the message, send another typing indicator + logger.debug(f"Sending follow-up typing indicator after {elapsed_time:.1f}s") + await self._send_whatsapp_typing_indicator() + + except asyncio.CancelledError: + logger.debug("Typing indicator task cancelled") + except Exception as e: + logger.error(f"Error in typing indicator loop: {e}") + logger.exception(e) + + async def retrieve_or_register_user(self) -> str | None: + """Retrieve existing user ID or register new user and return their ID. + + This method attempts to get the user_id for a phone number. If the user + doesn't exist, it registers them and returns the newly created user_id. + + Returns: + str | None: The user's database ID (UUID string) on success, None on failure. + """ + if not self.user_phone_num: + logger.error("Cannot retrieve or register user: user_phone_num is not set") + return None + + try: + # Try to get the user_id from backend + user_id = await self.ansari_client.check_user_exists(self.user_phone_num) + logger.debug(f"User exists with user_id: {user_id}") + return user_id + + except UserExistsCheckError: + # User doesn't exist - register them with detected language + logger.debug(f"User {self.user_phone_num} not found, registering new user") + + if self.incoming_msg_type == "text": + incoming_msg_text = self.incoming_msg_body["body"] + user_lang = get_language_from_text(incoming_msg_text) + else: + # Use English as default language if we can't detect it + user_lang = "en" + + try: + result = await self.ansari_client.register_user(self.user_phone_num, user_lang) + + # Extract user_id from registration response + if isinstance(result, dict): + user_id = result.get("user_id") + logger.info(f"Registered new whatsapp user (lang: {user_lang}, user_id: {user_id}): {self.user_phone_num}") + return user_id + + # Registration succeeded but no user_id returned (shouldn't happen) + logger.error(f"User registration succeeded but no user_id returned for {self.user_phone_num}") + return None + + except UserRegistrationError as e: + logger.exception(f"Failed to register user {self.user_phone_num}: {e}") + await self.send_whatsapp_message("Sorry, we couldn't register your account. Please try again later.") + return None + + except Exception as e: + logger.exception(f"Unexpected error retrieving/registering user: {e}") + return None + + async def _send_whatsapp_typing_indicator(self) -> None: + """Send a typing indicator to the WhatsApp recipient.""" + if not self.user_phone_num or not self.message_id: + logger.error("Cannot send typing indicator: missing user_phone_num or message_id") + return + + try: + await self.meta_api_service.send_typing_indicator(recipient_phone=self.user_phone_num, message_id=self.message_id) + except Exception as e: + logger.exception(f"Error sending typing indicator: {e}") + + async def send_whatsapp_message( + self, + msg_body: str, + recipient_phone_num: str = None, + ) -> None: + """Send a message to the WhatsApp recipient. + + Args: + msg_body (str): The message body to be sent. + recipient_phone_num (str, optional): The recipient's WhatsApp number. + Defaults to self.user_phone_num if not provided. + """ + # Use the provided recipient_phone_num or fall back to self.user_phone_num + phone_num = recipient_phone_num or self.user_phone_num + + if not phone_num: + logger.error("Cannot send WhatsApp message: No recipient phone number provided") + return + + # Split the message if it exceeds WhatsApp's character limit + message_parts = split_message(msg_body) + + try: + # Delegate to Meta API service + await self.meta_api_service.send_message(recipient_phone=phone_num, message_parts=message_parts) + except Exception as e: + logger.exception(f"Error sending WhatsApp message: {e}") + + async def handle_text_message(self) -> None: + """Process an incoming text message and send a response to the WhatsApp sender.""" + if not self.user_phone_num: + logger.error("Cannot process text message: user_phone_num is not set") + return + + try: + incoming_txt_msg = self.incoming_msg_body["body"] + logger.debug(f"Whatsapp user said: {incoming_txt_msg}") + + # Get details of the thread that the user last interacted with + try: + last_thread_info = await self.ansari_client.get_last_thread_info(self.user_phone_num) + thread_id = last_thread_info.get("thread_id") + last_msg_time = last_thread_info.get("last_message_time") + except ThreadInfoError as e: + logger.error(f"Failed to get thread info: {e}") + await self.send_whatsapp_message( + "Sorry, we're having trouble accessing your chat history. Please try again later." + ) + return + + if last_msg_time and isinstance(last_msg_time, str): + last_msg_time = datetime.fromisoformat(last_msg_time.replace("Z", "+00:00")) + + # Calculate the time passed since the last message + passed_time, passed_time_logging = calculate_time_passed(last_msg_time) + logger.debug(f"Time passed since user's last whatsapp message: {passed_time_logging}") + + # Get the allowed retention time + allowed_time = get_retention_time_seconds() + + # Create a new thread if no threads have been previously created, + # or the last message has passed the allowed retention time + if thread_id is None or passed_time > allowed_time: + first_few_words = " ".join(incoming_txt_msg.split()[:6]) + + try: + result = await self.ansari_client.create_thread(self.user_phone_num, first_few_words) + thread_id = result.get("thread_id") + logger.info("Created a new thread for the whatsapp user, " + "as the allowed retention time has passed.") + except ThreadCreationError as e: + logger.error(f"Failed to create thread: {e}") + await self.send_whatsapp_message( + "An unexpected error occurred while creating a new chat session. Please try again later." + ) + return + + # Process the message using the Ansari backend API + try: + response = await self.ansari_client.process_message( + phone_num=self.user_phone_num, + thread_id=thread_id, + message=incoming_txt_msg, + ) + except httpx.TimeoutException as e: + logger.error(f"Backend timeout while processing message: {e}") + sentry_sdk.set_tag("error_type", "backend_timeout") + sentry_sdk.set_context( + "message_details", + { + "request_id": request_id_var.get(), + "user_id": user_id_var.get(), + }, + ) + sentry_sdk.capture_exception(e) + await self.send_whatsapp_message("The request timed out. Please try sending your message again.") + # Cancel typing indicator if running + if self.typing_indicator_task and not self.typing_indicator_task.done(): + self.typing_indicator_task.cancel() + return + except MessageProcessingError as e: + logger.error(f"Failed to process message: {e}") + sentry_sdk.set_tag("error_type", "message_processing_failure") + sentry_sdk.set_context( + "message_details", + { + "request_id": request_id_var.get(), + "user_id": user_id_var.get(), + }, + ) + sentry_sdk.capture_exception(e) + await self.send_whatsapp_message("An error occurred while processing your message. Please try again later.") + # Cancel typing indicator if running + if self.typing_indicator_task and not self.typing_indicator_task.done(): + self.typing_indicator_task.cancel() + return + + # Cancel the typing indicator task if it's still running + if self.typing_indicator_task and not self.typing_indicator_task.done(): + logger.debug("Canceling typing indicator task as message processing is complete") + self.typing_indicator_task.cancel() + + if not response: + logger.warning("Received an empty response from the backend") + await self.send_whatsapp_message( + "Sorry, we couldn't process your message. Please try again later.", + ) + return + + # Convert conventional markdown syntax to WhatsApp's markdown syntax + logger.debug(f"Response before markdown conversion: \n\n{response}") + response = format_for_whatsapp(response) + + if not response: + logger.warning("Response was empty after markdown conversion") + await self.send_whatsapp_message( + "Sorry, we couldn't process your message. Please try again later..", + ) + return + + # Return the response back to the WhatsApp user + await self.send_whatsapp_message(response) + + except Exception as e: + logger.exception(f"Unexpected error processing text message: {e}") + await self.send_whatsapp_message( + "An unexpected error occurred while processing your message. Please try again later.", + ) + # Cancel typing indicator if running + if self.typing_indicator_task and not self.typing_indicator_task.done(): + self.typing_indicator_task.cancel() + + async def handle_unsupported_message(self) -> None: + """Handle an incoming unsupported message by sending an appropriate response.""" + if not self.user_phone_num or not self.incoming_msg_type: + logger.error("Cannot process unsupported message: missing user_phone_num or incoming_msg_type") + return + + msg_type = self.incoming_msg_type + "s" if not self.incoming_msg_type.endswith("s") else self.incoming_msg_type + msg_type = msg_type.replace("unsupporteds", "this media type") + await self.send_whatsapp_message( + f"Sorry, I can't process {msg_type} yet. Please send me a text message.", + ) diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py b/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py new file mode 100644 index 00000000..d0d04140 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py @@ -0,0 +1,188 @@ +# WhatsApp Logger for ansari-whatsapp +"""Logging configuration for the WhatsApp service using Loguru. + +Technical Details: +- Module Execution: Importing this file multiple times will execute its code only once. + Python modules are singletons. Therefore, it's fine to import this module in any other + module that needs logging functionality. + Proof: https://docs.python.org/3/tutorial/modules.html#more-on-modules + +- Loguru Instance: The `logger` object is a singleton. Once configured here, the same + instance is used throughout the application wherever `from ansari_whatsapp.utils.app_logger import logger` is used. + Proof: https://github.com/Delgan/loguru/issues/54#issuecomment-461724397 + +- Side Effects on Import: This module configures the global logger immediately upon import. + This is a deliberate design choice to ensure consistent logging across the application, + including during tests. Developers should be aware that simply importing this module + will modify the global logging state. +""" + +import contextvars +import json +import os +import sys + +from loguru import logger + +from ansari_whatsapp.utils.config import get_settings + +########################################## Context Variables ########################################## + +# Context variables for log correlation +# These automatically propagate to child async tasks (including FastAPI's BackgroundTasks) +# Check docs/logging/understanding_contextvars_in_python.md for details +request_id_var = contextvars.ContextVar("request_id", default="none") +user_id_var = contextvars.ContextVar("user_id", default="none") + +########################################## Global Vars and Logger Configurations ########################################## + +# Create logs directory if it doesn't exist +os.makedirs("logs", exist_ok=True) + +# Remove any pre-existing default handlers made by loguru +logger.remove() + +# Get settings +settings = get_settings() + +# Determine if we should use JSON structured logging for AWS CloudWatch +# CloudWatch Logs Insights works best with JSON-formatted logs +is_aws_deployment = settings.DEPLOYMENT_TYPE not in ["local", "development"] and os.getenv("GITHUB_ACTIONS") != "true" + +########################################## Functions ########################################## + + +def log_record_preprocessor(record): + """Preprocess log records before formatting: inject context and apply filters. + + This function runs before Loguru formats the log record and sends it to sink, and it serves two purposes: + 1. Inject request_id and user_id from contextvars into record["extra"] + 2. Filter logs based on LOG_TEST_FILES_ONLY setting + + Args: + record: The log record being processed. + + Returns: + bool: True to keep the log, False to discard it. + """ + # Step 1: Inject context from contextvars + record["extra"]["request_id"] = request_id_var.get() + record["extra"]["user_id"] = user_id_var.get() + + # Step 2: Apply filtering logic + if settings.LOG_TEST_FILES_ONLY and not ("tests" in record["file"].path or "test_" in record["file"].name): + return False + + return True + + +def cloudwatch_json_sink(message): + """Custom sink that writes clean JSON logs for AWS CloudWatch. + + Extracts the record from the message and formats it as minimal JSON. + Includes request_id and user_id from Loguru's context for log correlation. + + Args: + message: The loguru message object with .record attribute + + Log Structure: + - level, text, file_path, line, function, process, thread, time + - request_id: UUID per HTTP request (for granular debugging) + - user_id: Database UUID per user (for tracking user conversations) + - exception: Stack trace details if present + + Note: `level` and `text` keys are mentioned first, as we would like them to visible when + viewing condensed logs in CloudWatch Console. + """ + record = message.record + msg = record["message"] + + log_entry = { + "level": record["level"].name, + "text": msg, + "request_id": record["extra"].get("request_id"), + "user_id": record["extra"].get("user_id"), + "file_path": record["file"].path, # full file path + "line": record["line"], # line number + "function": record["function"], # function name + "process": {"id": record["process"].id, "name": record["process"].name}, + "thread": {"id": record["thread"].id, "name": record["thread"].name}, + "time": {"timestamp": record["time"].timestamp(), "iso": record["time"].isoformat()}, + } + + if record["exception"]: + exc = record["exception"] + log_entry["exception"] = { + "type": exc.type.__name__ if exc.type else None, + "value": str(exc.value) if exc.value else None, + } + + # Convert to JSON and write to stderr with newline + # The \n is REQUIRED for newline-delimited JSON (NDJSON) format + # CloudWatch expects each log entry on a separate line for proper parsing (hence, the `+ "\n"` at the end). + # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Generation_CloudWatch_Agent.html#CloudWatch_Embedded_Metric_Format_Generation_CloudWatch_Agent_Send_Logs + # json.dumps() serializes the object to a single line. CloudWatch automatically parses this JSON + # and displays it as an expandable, multi-line structure in the AWS Console GUI. + sys.stderr.write(json.dumps(log_entry) + "\n") + sys.stderr.flush() + + +########################################## Main Code ########################################## + +# Choose sink and format based on deployment type +if is_aws_deployment: + # Use custom sink for AWS CloudWatch + # This creates clean, minimal JSON logs that are easy to query in CloudWatch Logs Insights + log_sink = cloudwatch_json_sink + + # Format string for loguru - optimization for custom sink + # Explanation: Loguru always formats the message string before passing it to the sink. + # Since our sink uses the raw .record object and handles its own formatting, we use + # a minimal format "{message}" to avoid the overhead of the default verbose formatter. + # Source: https://loguru.readthedocs.io/en/stable/api/logger.html#loguru.logger.add:~:text=passed%20to%20all%20added%20sinks%20is%20nothing%20more%20than%20a%20string%20of%20the%20formatted%20log + log_format = "{message}" + enable_colors = False +else: + # Standard stderr sink for local development + log_sink = sys.stderr + log_format = ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <4} | " + "{file}:{line} " + "[{function}()] | " + "{message} | " + # NOTE: Whenever you want to test/visualize request_id/user_id that appear in CloudWatch, + # you can uncomment the below line to see them in local logs as well. + # "req:{extra[request_id]} user:{extra[user_id]}" + ) + enable_colors = True + +# Add console handler for terminal output +logger.add( + log_sink, + format=log_format, + level=settings.LOGGING_LEVEL.upper(), + enqueue=True, + colorize=enable_colors, + backtrace=False, + diagnose=False, + filter=log_record_preprocessor, + catch=False, +) + +# # If needed, uncomment below to write logs to all_logs.log file when running locally +# if settings.DEPLOYMENT_TYPE == "local": +# log_dir = os.path.join(os.getcwd(), "logs") +# os.makedirs(log_dir, exist_ok=True) +# all_logs_file = os.path.join(log_dir, "all_logs.log") +# logger.add( +# all_logs_file, +# format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {file}:{line} [{function}()] | {message}", +# level=settings.LOGGING_LEVEL.upper(), +# enqueue=True, +# backtrace=False, +# diagnose=False, +# filter=log_record_preprocessor, +# rotation="10 MB", +# catch=False, +# ) diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/config.py b/apps/whatsapp/src/ansari_whatsapp/utils/config.py new file mode 100644 index 00000000..8f373017 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/config.py @@ -0,0 +1,157 @@ +# Configuration for ansari-whatsapp +"""Configuration and settings for the WhatsApp service.""" + +from functools import lru_cache + +from pydantic import HttpUrl, SecretStr, Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class WhatsAppSettings(BaseSettings): + """ + Settings for the WhatsApp service. + + IMPORTANT NOTE: Check `.env.examples` file for an explanation of each setting. + + Notes regarding how Pydantic Settings works: + + Field value precedence in Pydantic Settings (highest to lowest priority): + + 1. CLI arguments (if cli_parse_args is enabled). + 2. Arguments passed to the Settings initializer. + 3. Environment variables. + 4. Variables from a dotenv (.env) file. + 5. Variables from the secrets directory. + 6. Default field values in the WhatsAppSettings model. + + E.g., if you set the variable `META_API_VERSION` in .env file to `v22.xyz`, + it will override the default value of `v22.0` defined in the WhatsAppSettings model. + + For more details, refer to the Pydantic documentation: + [https://docs.pydantic.dev/latest/concepts/pydantic_settings/#field-value-priority]. + + """ + + ########## Pydantic Settings Config ########## + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + extra="ignore", + ) + + ########## ansari-backend's settings ########## + + BACKEND_SERVER_URL: str = "http://localhost:8000" + WHATSAPP_SERVICE_API_KEY: SecretStr # Shared secret for service-to-service authentication + + ########### Meta Business API settings ########### + + META_API_VERSION: str = "v22.0" + META_BUSINESS_PHONE_NUMBER_ID: SecretStr + META_ACCESS_TOKEN_FROM_SYS_USER: SecretStr + META_WEBHOOK_VERIFY_TOKEN: SecretStr + # Used for verifying webhook signatures (X-Hub-Signature-256) + META_ANSARI_APP_SECRET: SecretStr = SecretStr("your_app_secret") + # NOTE: We add a default value here, since we never set this in staging/production/etc. environments + # I.e., this token is only used for local development with zrok (check .env.example for more details) + META_WEBHOOK_ZROK_SHARE_TOKEN: SecretStr = SecretStr("your_token") + + @property + def META_API_URL(self) -> str: + """ + Returns the Meta Graph API URL for sending WhatsApp messages. + + Format: https://graph.facebook.com/{version}/{phone-number-id}/messages + """ + return f"https://graph.facebook.com/{self.META_API_VERSION}/{self.META_BUSINESS_PHONE_NUMBER_ID.get_secret_value()}/messages" + + ########### ansari-whatsapp's settings ########## + + DEPLOYMENT_TYPE: str = Field( + ..., + description="Deployment environment type", + pattern="^(local|development|staging|production)$", + ) + + # Meta webhook response behavior + ALWAYS_RETURN_OK_TO_META: bool = True + + # CORS settings + # NOTE: We usually don't need to set this, as the add_extra_origins validator + # will automatically add BACKEND_SERVER_URL and other origins as needed. + ORIGINS: str | list[str] = "" + + # Chat settings + WHATSAPP_UNDER_MAINTENANCE: bool = False + WHATSAPP_CHAT_RETENTION_HOURS: int = 3 + WHATSAPP_MESSAGE_AGE_THRESHOLD_SECONDS: int = 86400 # 1 day + + # Logging settings + LOGGING_LEVEL: str = "DEBUG" + LOG_TEST_FILES_ONLY: bool = False + + # Service Provider settings + SENTRY_DSN: HttpUrl | None = None + MOCK_ANSARI_CLIENT: bool = False + MOCK_META_API: bool = False + + ########### Validators ########### + + @field_validator("ORIGINS", mode="before") + def parse_origins(cls, v): + """Parse ORIGINS from a comma-separated string or list.""" + if isinstance(v, str): + origins = [origin.strip() for origin in v.strip('"').split(",")] + elif isinstance(v, list): + origins = v + else: + raise ValueError( + f"Invalid ORIGINS format: {v}. Expected a comma-separated string or a list.", + ) + return origins + + @field_validator("ORIGINS", mode="after") + def add_extra_origins(cls, v, info): + """Add extra origins based on environment settings. + + Adds: + 1. In local mode: adds localhost and zrok origins + 2. In all environments: adds GitHub Actions testserver origin and WhatsApp Web + """ + origins = v.copy() + + # Add BACKEND_SERVER_URL as an origin if it's not already present + backend_url = info.data.get("BACKEND_SERVER_URL") + if backend_url and backend_url not in origins: + origins.append(backend_url) + + # Add local-specific origins when in local mode + if info.data.get("DEPLOYMENT_TYPE") == "local": + # Add zrok origin (i.e., the webhook (callback url)) that Meta will send messages to) + zrok_token = info.data.get("META_WEBHOOK_ZROK_SHARE_TOKEN") + token_value = zrok_token.get_secret_value() + # NOTE: We don't add "https://" as Meta sends request in "host" header, not "origin", + # and so, a value in "host" header means it won't contain the "https://" prefix + # However, even if you don't explicitly remove the "https://" part, + # apparently FastAPI will still correctly recognize the host + webhook_origin = f"{token_value}.share.zrok.io" + if webhook_origin not in origins: + origins.append(webhook_origin) + + # Make sure CI/CD of GitHub Actions is allowed in all environments + if "testserver" not in origins: + origins.append("testserver") + + # Always allow WhatsApp Web origin + if "https://web.whatsapp.com" not in origins: + origins.append("https://web.whatsapp.com") + + return origins + + +@lru_cache +def get_settings() -> WhatsAppSettings: + """Get the application settings.""" + return WhatsAppSettings() diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py b/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py new file mode 100644 index 00000000..54a7ad55 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py @@ -0,0 +1,61 @@ +# Custom exceptions for ansari-whatsapp +"""Custom exception classes for the Ansari WhatsApp service.""" + + +class UnexpectedMetaPayloadError(Exception): + """Meta sent a message payload that is missing an expected key.""" + + def __init__(self, message: str, reply_to: str | None = None): + super().__init__(message) + # Phone number or BSUID we can still use to reach the sender, if the payload had one + self.reply_to = reply_to + + +class PhoneHiddenUserError(Exception): + """Sender's webhook has a BSUID but no phone number (WhatsApp username privacy).""" + + def __init__(self, bsuid: str): + super().__init__("Message sender did not share a phone number (BSUID only)") + self.bsuid = bsuid + + +class AnsariClientError(Exception): + """Base exception for Ansari client errors.""" + + pass + + +class UserRegistrationError(AnsariClientError): + """User registration failed.""" + + pass + + +class UserExistsCheckError(AnsariClientError): + """Checking if user exists failed.""" + + pass + + +class ThreadCreationError(AnsariClientError): + """Thread creation failed.""" + + pass + + +class ThreadHistoryError(AnsariClientError): + """Retrieving thread history failed.""" + + pass + + +class ThreadInfoError(AnsariClientError): + """Retrieving thread info failed.""" + + pass + + +class MessageProcessingError(AnsariClientError): + """Message processing failed.""" + + pass diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/general_helpers.py b/apps/whatsapp/src/ansari_whatsapp/utils/general_helpers.py new file mode 100644 index 00000000..c566dce8 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/general_helpers.py @@ -0,0 +1,77 @@ +# General utility helpers for ansari-whatsapp +"""General utility functions that can be used across the codebase.""" + +import json +from fastapi import Request +from fastapi.middleware.cors import CORSMiddleware +from loguru import logger + +from urllib.parse import urlparse + + +# Custom CORS middleware to log errors that occur in the middleware layer (if any) +class CORSMiddlewareWithLogging(CORSMiddleware): + async def __call__(self, scope, receive, send): + """Override the __call__ method to add logging""" + # Skip CORS processing for non-HTTP requests (e.g., WebSockets) + if scope["type"] != "http": # pragma: no cover + return await super().__call__(scope, receive, send) + + logger.debug("Starting CORS middleware processing") + + # Create a Request object for logging + request = Request(scope, receive) + + async def modified_send(message): + """Intercept response to log CORS errors""" + if message["type"] == "http.response.start": + status = message["status"] + # Common request details + request_details = ( + f"Status: {status}\n" + f"Path: {request.url.path}\n" + f"Method: {request.method}\n" + f"Origin: {request.headers.get('origin')}\n" + f"Host: {request.headers.get('host')}" + ) + + # Log CORS-related errors (if any) + if request.headers.get("origin") is not None and request.headers.get("origin") not in self.allow_origins: + logger.error( + f"CORS Origin Error\n" + f"Incoming Origin: {request.headers.get('origin')}\n" + f"Incoming Host: {request.headers.get('host')}\n" + f"But the allowed Origins:\n{json.dumps(self.allow_origins, indent=2)}" + ) + # Else log issues that occur in the middleware layer (if any) + elif any( + [ + status == 400 and request.method == "OPTIONS", + status == 401 and "Authorization" not in request.headers, + status == 403 and request.headers.get("origin") not in self.allow_origins, + status == 429, + ] + ): + logger.error(f"Middleware Error\n{request_details}") + await send(message) + + try: + await super().__call__(scope, receive, modified_send) + except Exception as e: + logger.error( + ( + f"Unhandled Middleware Error\nType: {type(e).__name__}\n" + f"Message: {str(e)}\n" + f"Path: {request.url.path}\n" + f"Method: {request.method}\n" + f"Origin: {request.headers.get('origin')}\n" + f"Host: {request.headers.get('host')}" + ), + exc_info=True, + ) + raise + + +def get_base_url(url): + parsed = urlparse(url) + return f"{parsed.scheme}://{parsed.netloc}" diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/language_utils.py b/apps/whatsapp/src/ansari_whatsapp/utils/language_utils.py new file mode 100644 index 00000000..ba136d41 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/language_utils.py @@ -0,0 +1,45 @@ +# Language utilities for ansari-whatsapp +"""Utilities for language detection and text direction detection.""" + +import re +from typing import Literal + + +def get_language_from_text(text: str) -> str: + """ + Detect the language of the text. + + This is a simplified implementation that can be improved with a proper + language detection library like langdetect or langid. + + Args: + text (str): The text to detect the language from. + + Returns: + str: The detected language code, defaults to "en" (English). + """ + # This is a placeholder function. In a real implementation, you would use + # a proper language detection library. + # For now, we'll just return English as the default language. + return "en" + + +def get_language_direction_from_text(text: str) -> Literal["ltr", "rtl", "unknown"]: + """ + Detect the text direction (left-to-right or right-to-left) of the given text. + + Args: + text (str): The text to detect the direction from. + + Returns: + Literal["ltr", "rtl", "unknown"]: The detected text direction. + """ + # If the text has more rtl characters than ltr, it's considered rtl + rtl_chars = re.findall(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]+", text) + rtl_count = sum(len(match) for match in rtl_chars) + + # If more than 30% of characters are RTL, consider it RTL + if rtl_count / len(text) > 0.3: + return "rtl" + + return "ltr" diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/time_utils.py b/apps/whatsapp/src/ansari_whatsapp/utils/time_utils.py new file mode 100644 index 00000000..48feefe2 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/time_utils.py @@ -0,0 +1,105 @@ +# Time Utilities +"""Utilities for time-related calculations and formatting.""" + +from datetime import datetime, timezone + +from loguru import logger + +from ansari_whatsapp.utils.config import get_settings + + +def format_time_delta(seconds: float) -> str: + """Format a time delta in seconds to a human-readable string. + + Args: + seconds (float): Time delta in seconds + + Returns: + str: Formatted string (e.g., "5.2 seconds", "3.1 minutes", "2.5 hours", "1.2 days") + """ + if seconds < 60: + return f"{seconds:.1f} seconds" + elif seconds < 3600: + return f"{seconds / 60:.1f} minutes" + elif seconds < 86400: + return f"{seconds / 3600:.1f} hours" + else: + return f"{seconds / 86400:.1f} days" + + +def calculate_time_passed(last_message_time: datetime | None) -> tuple[float, str]: + """Calculate the time passed since the last message. + + Args: + last_message_time (datetime | None): The timestamp of the last message. + + Returns: + tuple[float, str]: The time passed in seconds and a formatted string for logging. + """ + if last_message_time is None: + passed_time = float("inf") + else: + passed_time = (datetime.now(timezone.utc) - last_message_time).total_seconds() + + # Format for logging (compact version) + if passed_time < 60: + passed_time_logging = f"{passed_time:.1f}sec" + elif passed_time < 3600: + passed_time_logging = f"{passed_time / 60:.1f}mins" + elif passed_time < 86400: + passed_time_logging = f"{passed_time / 3600:.1f}hours" + else: + passed_time_logging = f"{passed_time / 86400:.1f}days" + + return passed_time, passed_time_logging + + +def get_retention_time_seconds() -> int: + """Get the WhatsApp chat retention time in seconds from settings. + + Returns: + int: The retention time in seconds. + """ + settings = get_settings() + retention_hours = settings.WHATSAPP_CHAT_RETENTION_HOURS + return retention_hours * 60 * 60 + + +def is_message_too_old(message_unix_time: int | None) -> bool: + """Check if an incoming message is older than the allowed threshold. + + Uses the message timestamp (Unix time format - seconds since epoch) + to determine if the message is too old to process. + + Args: + message_unix_time (int | None): The message timestamp in Unix time format. + + Returns: + bool: True if the message is older than the threshold, False otherwise + """ + settings = get_settings() + too_old_threshold = settings.WHATSAPP_MESSAGE_AGE_THRESHOLD_SECONDS + + # If there's no timestamp, message can't be verified as too old + if not message_unix_time: + logger.debug("No timestamp available, cannot determine message age") + return False + + # Convert the Unix timestamp to a datetime object + try: + msg_time = datetime.fromtimestamp(message_unix_time, tz=timezone.utc) + # Get the current time in UTC + current_time = datetime.now(timezone.utc) + # Calculate time difference in seconds + time_diff = (current_time - msg_time).total_seconds() + + # Log the message age for debugging + age_logging = format_time_delta(time_diff) + logger.debug(f"Message age: {age_logging}") + + # Return True if the message is older than the threshold + return time_diff > too_old_threshold + + except (ValueError, TypeError) as e: + logger.error(f"Error parsing message timestamp: {e}") + return False diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_message_splitter.py b/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_message_splitter.py new file mode 100644 index 00000000..1524dbf6 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_message_splitter.py @@ -0,0 +1,222 @@ +# WhatsApp Message Splitter +"""Utilities for splitting long messages into WhatsApp-compliant chunks.""" + +import re + + +# WhatsApp character limit +WHATSAPP_MAX_MESSAGE_LENGTH = 4000 + + +def split_message(msg_body: str) -> list[str]: + """Split long messages into smaller chunks based on formatted headers or other patterns. + + This implements a multi-level splitting strategy for messages that exceed + WhatsApp's character limit (4000): + 1. First tries to split by header pattern (*_HEADER_*) + 2. If that's not possible, tries to split by bold text (*BOLD*) + 3. Finally falls back to paragraph-based splitting + + Args: + msg_body (str): The message body to split if necessary + + Returns: + list[str]: A list of message chunks that can be sent separately + """ + # If message is already under the limit, return it as is + if len(msg_body) <= WHATSAPP_MAX_MESSAGE_LENGTH: + return [msg_body] + + # Strategy 1: Try to split by formatted headers (*_HEADER_*) + header_chunks = split_by_headers(msg_body, WHATSAPP_MAX_MESSAGE_LENGTH) + if len(header_chunks) > 1: + return header_chunks + + # Strategy 2: Try to split by bold formatting (*BOLD*) + bold_chunks = split_by_bold_text(msg_body, WHATSAPP_MAX_MESSAGE_LENGTH) + if len(bold_chunks) > 1: + return bold_chunks + + # Strategy 3: Fall back to paragraph-based splitting + return split_by_paragraphs(msg_body, WHATSAPP_MAX_MESSAGE_LENGTH) + + +def split_by_headers(text: str, max_length: int) -> list[str]: + """Split text by formatted header pattern (*_HEADER_*). + + Args: + text (str): Text to split + max_length (int): Maximum allowed length of each chunk + + Returns: + list[str]: List of text chunks split by headers + """ + # Look for *_HEADER_* pattern + header_pattern = re.compile(r"\*_[^*_]+_\*") + headers = list(header_pattern.finditer(text)) + + # If we don't have multiple headers, we can't split effectively + if not headers or len(headers) <= 1: + return [text] + + chunks = [] + + # Process each header as a potential chunk boundary + for i, match in enumerate(headers): + # For the first header, handle any text that comes before it + if i == 0 and match.start() > 0: + prefix = text[: match.start()] + + # Always include the text before the first header in its own message(s) + # If it's too long, recursively split it + if len(prefix) <= max_length: + chunks.append(prefix) + else: + # If prefix is too long, split it using paragraph-based splitting + prefix_chunks = split_by_paragraphs(prefix, max_length) + chunks.extend(prefix_chunks) + + # Determine the end position for the chunk containing this header + end_pos = headers[i + 1].start() if i < len(headers) - 1 else len(text) + chunk = text[match.start() : end_pos] + + # If chunk fits within limit, add it directly + if len(chunk) <= max_length: + chunks.append(chunk) + else: + # Otherwise, try more aggressive splitting for this chunk + # First try bold formatting, then paragraphs + sub_chunks = split_by_bold_text(chunk, max_length) + chunks.extend(sub_chunks) + + return chunks + + +def split_by_bold_text(text: str, max_length: int) -> list[str]: + """Split text by looking for bold formatting (*TEXT*) patterns. + + Args: + text (str): Text to split + max_length (int): Maximum allowed length of each chunk + + Returns: + list[str]: List of text chunks split by bold formatting + """ + if len(text) <= max_length: + return [text] + + # Find *TEXT* patterns + bold_pattern = re.compile(r"\*[^*]+\*") + bold_matches = list(bold_pattern.finditer(text)) + + # If we don't have enough bold patterns for effective splitting + if not bold_matches or len(bold_matches) <= 1: + return split_by_paragraphs(text, max_length) + + chunks = [] + + # Process each bold pattern as a potential chunk boundary + for i, match in enumerate(bold_matches): + # For the first bold pattern, handle any text that comes before it + if i == 0 and match.start() > 0: + prefix = text[: match.start()] + + # Always include the text before the first bold pattern in its own message(s) + # If it's too long, recursively split it + if len(prefix) <= max_length: + chunks.append(prefix) + else: + # If prefix is too long, split it using paragraph-based splitting + prefix_chunks = split_by_paragraphs(prefix, max_length) + chunks.extend(prefix_chunks) + + # Determine the end position for the chunk containing this bold pattern + end_pos = bold_matches[i + 1].start() if i < len(bold_matches) - 1 else len(text) + chunk = text[match.start() : end_pos] + + # If chunk fits within limit, add it directly + if len(chunk) <= max_length: + chunks.append(chunk) + else: + # Otherwise, fall back to paragraph splitting for this chunk + sub_chunks = split_by_paragraphs(chunk, max_length) + chunks.extend(sub_chunks) + + return chunks + + +def split_by_paragraphs(text: str, max_length: int) -> list[str]: + """Split text by paragraphs or fall back to fixed-size chunks if needed. + + Args: + text (str): Text to split + max_length (int): Maximum allowed length of each chunk + + Returns: + list[str]: List of text chunks split by paragraphs or fixed chunks + """ + if len(text) <= max_length: + return [text] + + chunks = [] + + # Try splitting by paragraphs first (double newlines) + paragraphs = re.split(r"\n\n+", text) + + if len(paragraphs) > 1: + current = "" + + for para in paragraphs: + # If adding this paragraph would exceed the limit + if current and len(current) + len(para) + 2 > max_length: + chunks.append(current) + current = "" + + # If paragraph itself is too long, split it using fixed chunks + if len(para) > max_length: + # Add any accumulated text first + if current: + chunks.append(current) + current = "" + + # Use fixed-size chunk splitting for long paragraphs + para_chunks = split_by_fixed_chunks(para, max_length) + chunks.extend(para_chunks) + else: + # Add paragraph to current chunk with proper separator + if current: + current += "\n\n" + para + else: + current = para + + # Don't forget the last chunk + if current: + chunks.append(current) + + return chunks + else: + # If text doesn't have paragraphs, use fixed-size chunk splitting + return split_by_fixed_chunks(text, max_length) + + +def split_by_fixed_chunks(text: str, max_length: int) -> list[str]: + """Split text into fixed-size chunks of maximum length. + + Args: + text (str): Text to split + max_length (int): Maximum allowed length of each chunk + + Returns: + list[str]: List of text chunks of maximum length + """ + # If text is already under the limit, return it as is + if len(text) <= max_length: + return [text] + + chunks = [] + + # Simply take max_length characters at a time + for i in range(0, len(text), max_length): + chunks.append(text[i : i + max_length]) + + return chunks diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py b/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py new file mode 100644 index 00000000..4355afff --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py @@ -0,0 +1,246 @@ +# WhatsApp Webhook Utilities +"""Utilities for handling webhook operations between Meta and our server. + +This module contains functions for: +- Parsing webhook payloads received from Meta +- Verifying webhook signatures for security +- Creating standardized responses for Meta +""" + +import hmac +import hashlib +from loguru import logger +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse, Response + +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.utils.exceptions import PhoneHiddenUserError, UnexpectedMetaPayloadError + +settings = get_settings() + + +async def verify_meta_signature(request: Request) -> None: + """ + Verify that the webhook request came from Meta using HMAC-SHA256 signature. + + Meta signs all webhook payloads with your app's App Secret and includes the signature + in the X-Hub-Signature-256 header. This function validates that signature to ensure + the request is authentic and hasn't been tampered with. + + References: + - https://developers.facebook.com/docs/graph-api/webhooks/getting-started#validate-payloads + - https://stackoverflow.com/questions/75422064/validate-x-hub-signature-256-meta-whatsapp-webhook-request + + Args: + request: FastAPI Request object containing the webhook payload and headers + + Raises: + HTTPException: 403 Forbidden if signature is invalid or missing + + Note: + This is used with FastAPI's Depends() to automatically validate all + webhook requests before processing them, similar to the authentication + pattern used in ansari-backend. + """ + # Get raw body bytes (cached after first read) + body_bytes = await request.body() + signature_header = request.headers.get("X-Hub-Signature-256", "") + + if not signature_header or not signature_header.startswith("sha256="): + logger.warning("Missing or invalid X-Hub-Signature-256 header format") + logger.error("Webhook signature verification failed - rejecting request") + raise HTTPException( + status_code=403, + detail="Invalid signature", + headers={"WWW-Authenticate": "HMAC-SHA256"}, + ) + + # Extract the received signature from the header (remove "sha256=" prefix) + signature_received_from_meta = signature_header.replace("sha256=", "") + + # Extract our server's signature by + # computing HMAC-SHA256 using meta's app secret + app_secret = settings.META_ANSARI_APP_SECRET.get_secret_value().encode("utf-8") + computed_signature = hmac.new(app_secret, body_bytes, hashlib.sha256).hexdigest() + + # Attempt secret verification by comparing signatures + # (i.e., if signatures match, then the app secret used to compute both signatures is the same) + is_valid = hmac.compare_digest(computed_signature, signature_received_from_meta) + + if is_valid: + logger.debug("Webhook signature verified successfully") + return + else: + # If no match happens, raise exception + logger.error("Invalid webhook signature; request isn't from Meta or you have misconfigured META_ANSARI_APP_SECRET") + raise HTTPException( + status_code=403, + detail="Invalid signature", + headers={"WWW-Authenticate": "HMAC-SHA256"}, + ) + + +def create_response_for_meta( + success: bool = True, + message: str = "OK", + status_code: int = 200, + error_code: str | None = None, + details: dict | None = None, +) -> Response: + """ + Create appropriate webhook response based on deployment environment. + + In test environment: Returns proper HTTP status codes for testing + In production/staging/local: Always returns 200 for Meta compliance + + Args: + success: Whether the operation was successful + message: Human-readable message + status_code: HTTP status code to use (only in test mode) + error_code: Machine-readable error code + details: Additional details to include in response as "detail" key + + Returns: + Response: HTTP response appropriate for current environment + + Notes: + **Meta Compliance Requirement:** + WhatsApp requires webhooks to return HTTP 200 status within ~5 seconds to acknowledge receipt. + If any other status code is returned (or timeout occurs), Meta treats it as a failed delivery and + retries with increasing delays for up to 24 hours. This can cause duplicate message processing. + + **Official Documentation:** + - WhatsApp Webhooks Setup Guide: + https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview#webhook-delivery-failure + - Webhook Events and Retry Behavior (360dialog): + https://docs.360dialog.com/docs/waba-basics/webhook-events-and-notifications + """ + # Create response body with structured information + response_body = {"success": success, "message": message, "timestamp": int(__import__("time").time())} + + if error_code: + response_body["error_code"] = error_code + + if details: + response_body["detail"] = details + + # When ALWAYS_RETURN_OK_TO_META is False: return proper HTTP status codes (for testing) + # When ALWAYS_RETURN_OK_TO_META is True: always return 200 for Meta compliance + if not settings.ALWAYS_RETURN_OK_TO_META: + return JSONResponse(content=response_body, status_code=status_code if not success else 200) + + # Always return 200 for Meta compliance (production behavior) + # But still include structured response body for logging/debugging + return JSONResponse(content=response_body, status_code=200) + + +# Keys whose string values are safe to log as-is (everything else is masked, e.g., phone numbers, names, text) +_UNREDACTED_KEYS = {"object", "field", "messaging_product", "type", "id", "timestamp"} + + +def _redact_for_logging(obj, key: str | None = None): + """Return obj with its structure intact but string values masked as ``, except for safe keys + (i.e., keys in _UNREDACTED_KEYS).""" + if isinstance(obj, dict): + return {k: _redact_for_logging(v, k) for k, v in obj.items()} + if isinstance(obj, list): + return [_redact_for_logging(v, key) for v in obj] + if isinstance(obj, str) and key not in _UNREDACTED_KEYS: + return f"" + return obj + + +async def parse_meta_payload( + body: dict[str, dict], +) -> tuple[bool, bool, str | None, str | None, dict | None, str | None, int | None]: + """ + Parse the webhook payload received from Meta to extract relevant message details. + + Args: + body (dict[str, dict]): The JSON body of the incoming webhook request from Meta. + + Returns: + tuple: A tuple of (is_status, is_target_business_number, user_whatsapp_number, + incoming_msg_type, incoming_msg_body, message_id, message_unix_time) + + Raises: + Exception: If the payload structure is invalid or unsupported. + """ + if not ( + body.get("object") + and (entry := body.get("entry", [])) + and (changes := entry[0].get("changes", [])) + and (value := changes[0].get("value", {})) + ): + error_msg = f"Invalid received payload from WhatsApp user and/or problem with Meta's API:\n{body}" + logger.error(error_msg) + raise Exception(error_msg) + + # Check if this webhook is intended for our WhatsApp business number + # Metadata should always be present in a valid webhook payload + if "metadata" not in value: + error_msg = f"Missing metadata in webhook payload from WhatsApp API:\n{value}" + logger.error(error_msg) + raise Exception(error_msg) + + if "phone_number_id" not in value["metadata"]: + error_msg = f"Missing phone_number_id in webhook payload metadata:\n{value['metadata']}" + logger.error(error_msg) + raise Exception(error_msg) + + incoming_phone_number_id = value["metadata"]["phone_number_id"] + configured_phone_number_id = settings.META_BUSINESS_PHONE_NUMBER_ID.get_secret_value() + is_target_business_number = incoming_phone_number_id == configured_phone_number_id + + if not is_target_business_number: + return None, is_target_business_number, None, None, None, None, None + + if "statuses" in value: + # This is a status update (delivered, read, etc.), not a user message + return True, is_target_business_number, None, None, None, None, None + + is_status = False + + if "messages" not in value: + error_msg = f"Unsupported message type received from WhatsApp user:\n{body}" + logger.error(error_msg) + raise Exception(error_msg) + + incoming_msg = value["messages"][0] + + if "from" not in incoming_msg and incoming_msg.get("from_user_id"): + raise PhoneHiddenUserError(incoming_msg["from_user_id"]) + + try: + # Extract message details + message_id = incoming_msg.get("id") + user_whatsapp_number = incoming_msg["from"] + message_unix_time_str = incoming_msg.get("timestamp") + message_unix_time = int(message_unix_time_str) if message_unix_time_str is not None else None + + # Meta API note: Meta sends "errors" key when receiving unsupported message types + # (e.g., video notes, gifs sent from giphy, or polls) + incoming_msg_type = incoming_msg["type"] if incoming_msg["type"] in incoming_msg.keys() else "errors" + incoming_msg_body = incoming_msg[incoming_msg_type] + except KeyError as e: + logger.error(f"Missing key {e} in messages[0]; redacted webhook value: {_redact_for_logging(value)}") + contact = (value.get("contacts") or [{}])[0] + reply_to = ( + incoming_msg.get("from") or incoming_msg.get("from_user_id") or contact.get("wa_id") or contact.get("user_id") + ) + raise UnexpectedMetaPayloadError( + f"Missing key {e} in messages[0]; keys present: {sorted(incoming_msg.keys())}", + reply_to=reply_to, + ) from e + + logger.info(f"Received a supported whatsapp message from {user_whatsapp_number}: {incoming_msg_body}") + + return ( + is_status, + is_target_business_number, + user_whatsapp_number, + incoming_msg_type, + incoming_msg_body, + message_id, + message_unix_time, + ) diff --git a/apps/whatsapp/tests/README.md b/apps/whatsapp/tests/README.md new file mode 100644 index 00000000..84672648 --- /dev/null +++ b/apps/whatsapp/tests/README.md @@ -0,0 +1,177 @@ +# WhatsApp Microservice Test Suite + +This directory contains integration tests for the WhatsApp microservice, focusing on WhatsApp webhook endpoints. + +## 🚨 IMPORTANT CHANGE: Test Location Update + +**WhatsApp API endpoint tests have been moved to `ansari-backend/tests/unit/test_whatsapp_api_endpoints.py`** + +### Reason for Move: +- The WhatsApp API endpoints (like `/whatsapp/v2/users/register`) are **implemented in ansari-backend** +- Tests should live with the code they test +- Moving them allows use of **TestClient** instead of external HTTP calls +- **No external server dependencies** needed for CI/CD +- **Faster test execution** with in-memory testing + +## Current Test Files (This Repo) + +### 1. WhatsApp Service Tests (`test_whatsapp_service.py`) +Tests WhatsApp service webhook endpoints (no external dependencies): +- ✅ WhatsApp service health check +- ✅ Webhook verification endpoint +- ✅ Basic webhook message processing +- ✅ Wrong phone ID handling (should be ignored) + +### 2. Test Utilities (`test_utils.py`) +Secure logging and testing utilities: +- 🔒 Sensitive data masking for logs +- 🌍 Environment variable loading with validation +- 📝 Secure test result logging + + +## 🚀 Quick Start + +### Prerequisites +1. **Environment Variables** (in `.env`): + ```env + META_WEBHOOK_VERIFY_TOKEN=your_verify_token + META_BUSINESS_PHONE_NUMBER_ID=your_phone_number_id + MOCK_ANSARI_CLIENT=True # Set to False to use real backend + MOCK_META_API=True # Set to False to use real Meta API + BACKEND_SERVER_URL=http://localhost:8000 # Required if MOCK_ANSARI_CLIENT=False + ALWAYS_RETURN_OK_TO_META=True # Set to False for CI/CD + LOG_TEST_FILES_ONLY=False # Set to True to filter test logs + ``` + +2. **Install Dependencies**: + ```bash + cd ansari-whatsapp + uv add pytest + ``` + +3. **Backend Availability**: + - **Mock Mode (MOCK_ANSARI_CLIENT=True)**: No backend needed, tests use mock client + - **Real Backend (MOCK_ANSARI_CLIENT=False)**: Backend must be running on `BACKEND_SERVER_URL` + + **Important**: If `MOCK_ANSARI_CLIENT=False` and backend is not available, tests will terminate with error instructions. + +### Running Tests + +#### WhatsApp Service Tests (This Repo) +```bash +# All WhatsApp service tests (local development) (the `-s` flag shows print/log statements) +pytest tests/test_whatsapp_service.py -v -s + +# With test-only logging (filters out non-test logs) +LOG_TEST_FILES_ONLY=True pytest tests/test_whatsapp_service.py -v -s + +# For CI/CD (proper HTTP status codes) +ALWAYS_RETURN_OK_TO_META=False pytest tests/test_whatsapp_service.py -v +``` + +#### WhatsApp API Tests (ansari-backend repo) +```bash +cd ../ansari-backend +pytest tests/unit/test_whatsapp_api_endpoints.py -v + +# Streaming tests specifically +pytest tests/unit/test_whatsapp_api_endpoints.py -m streaming -v +``` + +## 📊 Test Categories + +### WhatsApp Service Tests (ansari-whatsapp) +- **Purpose**: Test WhatsApp webhook endpoints +- **Technology**: pytest + TestClient (for WhatsApp service) +- **Dependencies**: ✅ **No external dependencies** (uses TestClient only) + +### API Tests (ansari-backend) +- **Purpose**: Test WhatsApp API endpoints in backend +- **Technology**: pytest + TestClient (for backend service) +- **Dependencies**: ✅ **No external servers needed** (uses TestClient) +- **Streaming**: ✅ **Comprehensive streaming endpoint testing** + +## 🔒 Security Features + +- **Sensitive Data Masking**: All tokens, phone numbers, and secrets are masked in logs +- **Environment Variables**: No hardcoded secrets in code +- **Secure Logging**: `test_utils.py` provides secure logging utilities + +## ⚠️ Error Handling & Troubleshooting + +### Backend Not Available Error +If you see this error, it means `MOCK_ANSARI_CLIENT=False` but the backend is not reachable: +``` +BACKEND IS NOT AVAILABLE +The backend server is not reachable, but MOCK_ANSARI_CLIENT is set to False. +``` + +**Solutions:** +1. **Enable mock mode**: Set `MOCK_ANSARI_CLIENT=True` in `.env` +2. **Fix backend URL**: Verify `BACKEND_SERVER_URL` is correct in `.env` +3. **Start backend**: Ensure ansari-backend is running on the configured URL + +### Missing Environment Variables Error +If you see this error, required test environment variables are not set: +``` +ERROR CREATING TEST PAYLOAD +This usually means required environment variables are not set. +``` + +**Required variables:** +- `META_BUSINESS_PHONE_NUMBER_ID`: Your Meta business phone number ID +- `MOCK_ANSARI_CLIENT`: Set to `True` (default) for mock mode +- `MOCK_META_API`: Set to `True` (default) for mock mode + +Tests use hardcoded phone numbers and message IDs from Meta's sample webhook payloads. + +**Solution:** Check your `.env` file and ensure all required variables are set. See `.env.example` for examples. + +## 🛠 GitHub Actions Ready + +### ansari-whatsapp CI/CD +- ✅ **No external server dependencies** for integration tests +- ✅ **Uses TestClient** for WhatsApp service testing +- ✅ **Environment secrets** properly loaded +- ✅ **Fast execution** with in-memory testing +- ⚠️ **Important**: Set `ALWAYS_RETURN_OK_TO_META=False` in CI/CD workflows to get proper HTTP status codes for assertions + +**Example GitHub Actions workflow:** +```yaml +- name: Run tests + env: + ALWAYS_RETURN_OK_TO_META: False + run: pytest tests/ -v +``` + +### ansari-backend CI/CD +- ✅ **No external server dependencies** for API tests +- ✅ **Uses TestClient** for all endpoint testing +- ✅ **Comprehensive streaming tests** included + +## 📝 Migration Summary + +### What Moved: +- `test_whatsapp_api_endpoints.py` → `ansari-backend/tests/unit/test_whatsapp_api_endpoints.py` +- `whatsapp_test_utils.py` → `ansari-backend/tests/whatsapp_test_utils.py` + +### What Stayed: +- `test_whatsapp_service.py` - Tests WhatsApp webhook endpoints (renamed and focused) +- `test_utils.py` - Secure logging utilities for service tests + +### Benefits: +- 🚀 **Faster CI/CD** - No external server startup time +- 🔒 **Better Security** - No hardcoded secrets, secure logging +- 📍 **Proper Organization** - Tests live with the code they test +- 🧪 **Comprehensive Testing** - Full streaming endpoint coverage +- ⚡ **Easy Local Development** - TestClient for quick iteration + +## 📞 Support + +For GitHub Actions setup or testing issues: +1. Check that environment secrets are configured in GitHub +2. Verify `.env` file contains required variables locally +3. Review test logs for specific error details +4. Consult `CLAUDE.md` files in both repositories for detailed instructions + +The test suite now provides comprehensive validation while being optimized for modern CI/CD workflows! \ No newline at end of file diff --git a/apps/whatsapp/tests/__init__.py b/apps/whatsapp/tests/__init__.py new file mode 100644 index 00000000..a4c9d176 --- /dev/null +++ b/apps/whatsapp/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for ansari-whatsapp diff --git a/apps/whatsapp/tests/test_utils.py b/apps/whatsapp/tests/test_utils.py new file mode 100644 index 00000000..e0a838b1 --- /dev/null +++ b/apps/whatsapp/tests/test_utils.py @@ -0,0 +1,57 @@ +""" +Test utilities for WhatsApp microservice tests. + +This module provides common test functionality following +the backend test patterns (pytest + TestClient + fixtures). +""" + +import json +from typing import Any, Dict +from datetime import datetime + + +def log_test_result(test_name: str, success: bool, message: str, response_data: Any = None) -> Dict[str, Any]: + """ + Create a test result log entry. + + Args: + test_name: Name of the test + success: Whether the test passed + message: Test result message + response_data: Response data to include + + Returns: + Dictionary containing the log entry + """ + result = {"test_name": test_name, "success": success, "message": message, "timestamp": datetime.now().isoformat()} + + if response_data is not None: + result["response_data"] = response_data + + return result + + +def format_payload_for_logging(payload: Dict[str, Any]) -> str: + """ + Format payload for logging. + + Args: + payload: The payload dictionary + + Returns: + JSON string + """ + return json.dumps(payload, indent=2) + + +def format_params_for_logging(params: Dict[str, Any]) -> str: + """ + Format URL parameters for logging. + + Args: + params: The parameters dictionary + + Returns: + JSON string + """ + return json.dumps(params, indent=2) diff --git a/apps/whatsapp/tests/test_whatsapp_service.py b/apps/whatsapp/tests/test_whatsapp_service.py new file mode 100644 index 00000000..83487d93 --- /dev/null +++ b/apps/whatsapp/tests/test_whatsapp_service.py @@ -0,0 +1,432 @@ +""" +Integration tests for ansari-whatsapp service webhooks. + +This module tests the WhatsApp webhook endpoints in the ansari-whatsapp service +using pytest and FastAPI TestClient. It focuses solely on testing the WhatsApp +service itself without external dependencies. + +Tests verify: +- Service health endpoint +- Webhook verification endpoint +- Webhook message processing +- Phone number validation logic + +Backend Availability: +- If MOCK_ANSARI_CLIENT=True: Tests use mock client (no backend needed) +- If MOCK_ANSARI_CLIENT=False: Tests check backend availability + - If backend is available: Tests proceed with real backend + - If backend is NOT available: Tests terminate with error instructions + +Required Environment Variables: +- META_BUSINESS_PHONE_NUMBER_ID: Your Meta business phone number ID +- MOCK_ANSARI_CLIENT: True for mock mode, False for real backend +- MOCK_META_API: True for mock mode (default), False for real Meta API +- BACKEND_SERVER_URL: URL of backend service (required if MOCK_ANSARI_CLIENT=False) + +Security: All sensitive data is loaded from environment variables and masked in logs. +""" + +import json +import pytest +import time +import httpx +import hmac +import hashlib +from typing import Any + +from fastapi.testclient import TestClient + +# This `logger` will get configured when importing `ansari_whatsapp.app.main` below, +# as `main.py` calls `configure_logger()` +from loguru import logger + +from ansari_whatsapp.app.main import app +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.services.service_provider import reset_ansari_client +from ansari_whatsapp.utils.general_helpers import get_base_url +from .test_utils import log_test_result, format_payload_for_logging, format_params_for_logging + + +def check_backend_availability() -> bool: + """Check if the ansari-backend service is running and accessible. + + Returns: + bool: True if backend is available, False otherwise + """ + settings = get_settings() + backend_url = settings.BACKEND_SERVER_URL + + # Use base URL since health check is at root endpoint in ansari-backend + base_backend_url = get_base_url(backend_url) + + try: + logger.info(f"Checking backend availability at {base_backend_url}") + response = httpx.get(f"{base_backend_url}/", timeout=3.0) + is_available = response.status_code == 200 + logger.info(f"Backend availability: {'AVAILABLE' if is_available else 'UNAVAILABLE'} (status: {response.status_code})") + return is_available + except httpx.RequestError as e: + logger.info(f"Backend is not available: {e}") + return False + except Exception as e: + logger.warning(f"Unexpected error checking backend: {e}") + return False + + +@pytest.fixture(scope="module", autouse=True) +def configure_mock_mode(): + """Configure mock mode based on backend availability before running tests. + + This fixture runs before all tests and checks the MOCK_ANSARI_CLIENT setting. + If MOCK_ANSARI_CLIENT is True, tests will use a mock client. + If MOCK_ANSARI_CLIENT is False, tests will check backend availability. + + If backend is not available and mock mode is disabled, tests will terminate + with clear instructions on how to fix the issue. + + The original value (if any) is restored after tests complete. + """ + # Get settings first to check current configuration + settings = get_settings() + + # Log the current mock mode setting + if settings.MOCK_ANSARI_CLIENT: + logger.info("MOCK_ANSARI_CLIENT is True - Tests will use MOCK client") + logger.info("Backend availability check will be skipped") + else: + logger.info("MOCK_ANSARI_CLIENT is False - Tests will use REAL backend") + logger.info("Checking backend availability...") + + # Check if backend is available + backend_available = check_backend_availability() + + if not backend_available: + error_message = f""" +{"=" * 80} +BACKEND IS NOT AVAILABLE +{"=" * 80} + +The backend server is not reachable, but MOCK_ANSARI_CLIENT is set to False. + +To fix this issue, you have the following options: + +Option 1: Enable mock mode + Set MOCK_ANSARI_CLIENT=True (e.g., in your .env file, GitHub Actions, etc.) + This will run tests with a mock client (no backend needed) + +Option 2: Fix the backend URL + Current BACKEND_SERVER_URL: {settings.BACKEND_SERVER_URL} + Ensure the BACKEND_SERVER_URL is correct + +Option 3: Start the backend server + If BACKEND_SERVER_URL is correct, ensure the ansari-backend + service is running on {settings.BACKEND_SERVER_URL} + +{"=" * 80} +""" + logger.error(error_message) + pytest.exit( + "Backend not available and MOCK_ANSARI_CLIENT=False. See error message above for solutions.", returncode=1 + ) + + # Log the effective configuration + logger.info(f"Test configuration: MOCK_ANSARI_CLIENT = {settings.MOCK_ANSARI_CLIENT}") + logger.info(f"Backend URL: {settings.BACKEND_SERVER_URL}") + + yield + + # No teardown actions needed for this fixture + + +@pytest.fixture(scope="function", autouse=True) +def reset_client_singleton(): + """Reset the Ansari client singleton before each test for proper isolation. + + This fixture ensures that each test function gets a fresh Ansari client instance, + preventing state leakage between tests. This is important IN CASE: + - Tests modify environment variables (e.g., MOCK_ANSARI_CLIENT) + - Tests need different client configurations + + The reset happens BEFORE each test runs, so the test gets a fresh singleton + based on the current environment configuration. + """ + reset_ansari_client() + logger.debug("Test fixture: Ansari client singleton reset before test") + yield + # No cleanup needed after test - next test will reset again + + +@pytest.fixture(scope="module") +def settings(): + return get_settings() + + +# Create TestClient +client = TestClient(app) + +# Test results storage +test_results = [] + + +def log_test_result_to_list(test_name: str, success: bool, message: str, response_data: Any = None): + """Log test results.""" + result = log_test_result(test_name, success, message, response_data) + test_results.append(result) + + status = "[PASS]" if success else "[FAIL]" + logger.info(f"{status} {test_name}: {message}") + + if response_data: + logger.debug(f" Response: {json.dumps(result['response_data'], indent=2)}") + + +@pytest.mark.integration +def test_whatsapp_health(): + """Test ansari-whatsapp health endpoint using TestClient.""" + test_name = "WhatsApp Service Health" + + response = client.get("/") + response_data = response.json() + + if response.status_code == 200 and response_data.get("status") == "ok": + log_test_result_to_list(test_name, True, "WhatsApp service is healthy", response_data) + assert True + else: + log_test_result_to_list(test_name, False, f"Health check failed: HTTP {response.status_code}", response_data) + assert False + + +@pytest.mark.integration +def test_webhook_verification(settings): + """Test WhatsApp webhook verification endpoint using TestClient.""" + test_name = "Webhook Verification" + + params = { + "hub.mode": "subscribe", + "hub.verify_token": settings.META_WEBHOOK_VERIFY_TOKEN.get_secret_value(), + "hub.challenge": "test_challenge_12345", + } + + logger.debug(f"[TEST] Testing {test_name}...") + logger.debug(" URL: /whatsapp/v2") + logger.debug(f" Params: {format_params_for_logging(params)}") + + response = client.get("/whatsapp/v2", params=params) + + if response.status_code == 200 and "test_challenge_12345" in response.text: + log_test_result_to_list(test_name, True, "Webhook verification successful", {"response": response.text}) + assert True + else: + log_test_result_to_list( + test_name, False, f"Webhook verification failed: HTTP {response.status_code}", {"response": response.text} + ) + assert False + + +@pytest.mark.integration +def test_webhook_message_basic(settings): + """Test basic WhatsApp webhook message processing using TestClient. + + With mock mode enabled (default), this test should always succeed with 200 status. + Uses hardcoded test values (phone number and message ID) from Meta's sample webhook payloads. + + This test requires proper environment variables to be set that simulate + a real WhatsApp message. If you encounter errors, verify: + - META_BUSINESS_PHONE_NUMBER_ID is set + - MOCK_META_API is set to True (default for CI/CD) + + See .env.example for details on required environment variables. + """ + test_name = "Basic Webhook Message" + + try: + # Create a minimal WhatsApp webhook payload + payload = { + "object": "whatsapp_business_account", + "entry": [ + { + "changes": [ + { + "value": { + "metadata": { + "phone_number_id": settings.META_BUSINESS_PHONE_NUMBER_ID.get_secret_value(), + "display_phone_number": "+1234567890", + }, + "messages": [ + { + # NOTE: Phone number used for testing webhooks + # Should be in international format without '+' or leading zeros + # (sample below has an Egyptian country code '20') + # If you're testing locally and want to see messages get sent successfully + # to Meta's server, temporarily update this value with your actual phone number + "from": "201234567899", + # NOTE: Using hardcoded test message ID from Meta's sample webhook payloads. + # This works with MOCK_META_API=True (which is the default for CI/CD). + # Real message IDs have a ~30 day lifetime before becoming invalid. + # Reference: https://stackoverflow.com/questions/77161282/how-can-i-reply-to-a-specific-message-using-the-whatsapp-api + "id": "ABGGFlA5Fpa", + "timestamp": str(int(time.time())), + "type": "text", + "text": {"body": "Hello, this is a test message for integration testing"}, + } + ], + } + } + ] + } + ], + } + + logger.debug(f"[TEST] Testing {test_name}...") + logger.debug(" URL: /whatsapp/v2") + logger.debug(f" Payload: {format_payload_for_logging(payload)}") + logger.debug(f" Mock mode: {settings.MOCK_ANSARI_CLIENT}") + + # Generate valid Meta signature for the request + # References: + # - https://developers.facebook.com/docs/graph-api/webhooks/getting-started#validate-payloads + # - https://stackoverflow.com/questions/75422064/validate-x-hub-signature-256-meta-whatsapp-webhook-request + body_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + app_secret = settings.META_ANSARI_APP_SECRET.get_secret_value().encode("utf-8") + signature = hmac.new(app_secret, body_bytes, hashlib.sha256).hexdigest() + headers = {"X-Hub-Signature-256": f"sha256={signature}"} + + logger.debug(f" Generated signature: sha256={signature[:16]}... (truncated)") + + # Send request with exact body bytes and signature header + # Note: We use content= instead of json= to ensure exact byte representation + # This is critical because the signature is computed on the exact bytes + response = client.post("/whatsapp/v2", content=body_bytes, headers=headers) + + # With mock client, we should always get 200 + if response.status_code == 200: + try: + response_data = response.json() + success = response_data.get("success", False) + message = response_data.get("message", "") + + if success and "processed successfully" in message.lower(): + log_test_result_to_list(test_name, True, "Webhook message processed successfully", response_data) + logger.debug(" [PASS] Message processed successfully") + else: + log_test_result_to_list(test_name, True, "Webhook message accepted", response_data) + logger.debug(" [PASS] Message accepted") + + assert True + except json.JSONDecodeError: + log_test_result_to_list(test_name, True, "Webhook message accepted", {"status_code": response.status_code}) + assert True + else: + # Non-200 status codes are now considered failures since mock client should handle everything + response_data = None + try: + response_data = response.json() + except Exception: + response_data = response.text + log_test_result_to_list(test_name, False, f"Expected 200, got {response.status_code}.", response_data) + assert False, f"Expected HTTP 200 with mock client, got {response.status_code}" + + except (AttributeError, KeyError, TypeError) as e: + error_message = f""" +{"=" * 80} +ERROR CREATING TEST PAYLOAD +{"=" * 80} + +Failed to create test payload: {str(e)} + +This usually means required environment variables are not set. +These variables must simulate a real WhatsApp message. + +Required environment variables: + - META_BUSINESS_PHONE_NUMBER_ID: Your Meta business phone number ID + +Please check your .env file and ensure all required variables are set. +See .env.example for the complete list of required environment variables +and examples of valid values. + +{"=" * 80} +""" + logger.error(error_message) + log_test_result_to_list(test_name, False, f"Environment variables not properly configured: {str(e)}", None) + pytest.fail(f"Test setup failed due to missing environment variables. See error message above. Error: {str(e)}") + + +def _post_signed_webhook(settings, message: dict, contacts: list | None = None): + """POST a signed webhook whose messages[0] is `message`.""" + value = { + "metadata": { + "phone_number_id": settings.META_BUSINESS_PHONE_NUMBER_ID.get_secret_value(), + "display_phone_number": "+1234567890", + }, + "messages": [message], + } + if contacts is not None: + value["contacts"] = contacts + payload = {"object": "whatsapp_business_account", "entry": [{"changes": [{"value": value}]}]} + body_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + app_secret = settings.META_ANSARI_APP_SECRET.get_secret_value().encode("utf-8") + signature = hmac.new(app_secret, body_bytes, hashlib.sha256).hexdigest() + return client.post("/whatsapp/v2", content=body_bytes, headers={"X-Hub-Signature-256": f"sha256={signature}"}) + + +@pytest.mark.integration +def test_webhook_phone_hidden_user(settings): + """A BSUID-only sender (no `from`) gets the phone-number notice and is not processed.""" + message = { + "from_user_id": "US.13491208655302741918", + "id": "wamid.TEST_PHONE_HIDDEN", + "timestamp": str(int(time.time())), + "type": "text", + "text": {"body": "Assalamu alaikum"}, + } + contacts = [{"profile": {"name": "Test User", "username": "testuser"}, "user_id": "US.13491208655302741918"}] + + response = _post_signed_webhook(settings, message, contacts) + + assert response.json()["error_code"] == "PHONE_HIDDEN_USER" + + +@pytest.mark.integration +def test_webhook_missing_from_without_bsuid(settings): + """A message with neither `from` nor `from_user_id` is reported as an unexpected format.""" + message = { + "id": "wamid.TEST_NO_IDENTIFIER", + "timestamp": str(int(time.time())), + "type": "text", + "text": {"body": "Assalamu alaikum"}, + } + + response = _post_signed_webhook(settings, message, contacts=[]) + + assert response.json()["error_code"] == "UNEXPECTED_PAYLOAD_FORMAT" + + +@pytest.fixture(scope="session", autouse=True) +def save_results(): + """Save test results to file (runs after all tests in the session).""" + yield # All tests run here + + # Teardown: runs after all tests, even if filtered by -k + from datetime import datetime + + if test_results: + results_file = "tests/detailed_test_results_whatsapp_service.json" + + total_tests = len(test_results) + passed_tests = sum(1 for result in test_results if result["success"]) + + combined_results = { + "test_run": { + "timestamp": datetime.now().isoformat(), + "total_tests": total_tests, + "passed_tests": passed_tests, + "failed_tests": total_tests - passed_tests, + "success_rate": f"{(passed_tests / total_tests * 100):.1f}%" if total_tests > 0 else "0%", + }, + "test_results": test_results, + } + + with open(results_file, "w") as f: + json.dump(combined_results, f, indent=2) + + logger.info(f"Detailed results saved to: {results_file}") diff --git a/apps/whatsapp/uv.lock b/apps/whatsapp/uv.lock new file mode 100644 index 00000000..9b0c3cef --- /dev/null +++ b/apps/whatsapp/uv.lock @@ -0,0 +1,1354 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "ansari-whatsapp" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "loguru" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pytest" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "rich" }, + { name = "sentry-sdk", extra = ["fastapi"] }, + { name = "uvicorn" }, + { name = "watchfiles" }, +] + +[package.optional-dependencies] +dev = [ + { name = "fastapi", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.95.1" }, + { name = "fastapi", extras = ["standard"], marker = "extra == 'dev'", specifier = ">=0.95.1" }, + { name = "httpx", specifier = ">=0.24.0" }, + { name = "loguru", specifier = ">=0.7.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.9.1" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "rich", specifier = ">=10.0.0" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.47.0" }, + { name = "uvicorn", specifier = ">=0.22.0" }, + { name = "watchfiles", specifier = ">=1.0.5" }, +] +provides-extras = ["dev"] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/75/9407a6b452be4c988feacec9c9d2f58d8f315162a6c7258d5a649d933ebe/fastapi_cli-0.0.16.tar.gz", hash = "sha256:e8a2a1ecf7a4e062e3b2eec63ae34387d1e142d4849181d936b23c4bdfe29073", size = 19447, upload-time = "2025-11-10T19:01:07.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/43/678528c19318394320ee43757648d5e0a8070cf391b31f69d931e5c840d2/fastapi_cli-0.0.16-py3-none-any.whl", hash = "sha256:addcb6d130b5b9c91adbbf3f2947fe115991495fdb442fe3e51b5fc6327df9f4", size = 12312, upload-time = "2025-11-10T19:01:06.728Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/dd/e5890bb4ee63f9d8988660b755490e346cf5769aaa7f5f3ced9afb9f090a/fastapi_cloud_cli-0.6.0.tar.gz", hash = "sha256:2c333fff2e4b93b9efbec7896ce3ffa3e77ce4cf3d8cb14e35b4f823dfddac02", size = 30579, upload-time = "2025-12-04T15:04:07.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/2f/5ba9b5faa75067e30ff48e3c454263ebc2d2301d5509cfefe12cf9fc8156/fastapi_cloud_cli-0.6.0-py3-none-any.whl", hash = "sha256:b654890b5302c90d2f347b123a35186096328838a526316c470b6005cabd4983", size = 23215, upload-time = "2025-12-04T15:04:08.121Z" }, +] + +[[package]] +name = "fastar" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/e2/51d9ee443aabcd5aa581d45b18b6198ced364b5cd97e5504c5d782ceb82c/fastar-0.8.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c9f930cff014cf79d396d0541bd9f3a3f170c9b5e45d10d634d98f9ed08788c3", size = 708536, upload-time = "2025-11-26T02:34:35.236Z" }, + { url = "https://files.pythonhosted.org/packages/07/2a/edfc6274768b8a3859a5ca4f8c29cb7f614d7f27d2378e2c88aa91cda54e/fastar-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07b70f712d20622346531a4b46bb332569bea621f61314c0b7e80903a16d14cf", size = 632235, upload-time = "2025-11-26T02:34:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1e/3cfbaaec464caef196700ee2ffae1c03f94f7c5e2a85d0ec0ea9cdd1da81/fastar-0.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:330639db3bfba4c6d132421a2a4aeb81e7bea8ce9159cdb6e247fbc5fae97686", size = 871386, upload-time = "2025-11-26T02:33:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/224a674ad541054179e4e6e0b54bb6e162f04f698a2512b42a8085fc6b6f/fastar-0.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ea7ceb6231e48d7bb0d7dc13e946baa29c7f6873eaf4afb69725d6da349033", size = 764955, upload-time = "2025-11-26T02:32:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/4d/5e/4608184aa57cb6a54f62c1eb3e5133ba8d461fc7f13193c0255effbec12a/fastar-0.8.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a90695a601a78bbca910fdf2efcdf3103c55d0de5a5c6e93556d707bf886250b", size = 765987, upload-time = "2025-11-26T02:32:59.701Z" }, + { url = "https://files.pythonhosted.org/packages/e0/53/6afd2b680dddfa10df9a16bbcf6cabfee0d92435d5c7e3f4cfe3b1712662/fastar-0.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d0bf655ff4c9320b0ca8a5b128063d5093c0c8c1645a2b5f7167143fd8531aa", size = 930900, upload-time = "2025-11-26T02:33:16.059Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1e/b7a304bfcc1d06845cbfa4b464516f6fff9c8c6692f6ef80a3a86b04e199/fastar-0.8.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8df22cdd8d58e7689aa89b2e4a07e8e5fa4f88d2d9c2621f0e88a49be97ccea", size = 821523, upload-time = "2025-11-26T02:33:30.897Z" }, + { url = "https://files.pythonhosted.org/packages/1d/da/9ef8605c6d233cd6ca3a95f7f518ac22aa064903afe6afa57733bfb7c31b/fastar-0.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5e6ad722685128521c8fb44cf25bd38669650ba3a4b466b8903e5aa28e1a0", size = 821268, upload-time = "2025-11-26T02:34:04.003Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/ed37c78a6b4420de1677d82e79742787975c34847229c33dc376334c7283/fastar-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:31cd541231a2456e32104da891cf9962c3b40234d0465cbf9322a6bc8a1b05d5", size = 986286, upload-time = "2025-11-26T02:34:50.279Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a6/366b15f432d85d4089e6e4b52a09cc2a2bcf4d7a1f0771e3d3194deccb1e/fastar-0.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:175db2a98d67ced106468e8987975484f8bbbd5ad99201da823b38bafb565ed5", size = 1041921, upload-time = "2025-11-26T02:35:07.292Z" }, + { url = "https://files.pythonhosted.org/packages/f4/45/45f8e6991e3ce9f8aeefdc8d4c200daada41097a36808643d1703464c3e2/fastar-0.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada877ab1c65197d772ce1b1c2e244d4799680d8b3f136a4308360f3d8661b23", size = 1047302, upload-time = "2025-11-26T02:35:24.995Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/a587796111a3cd4b78cd61ec3fc1252d8517d81f763f4164ed5680f84810/fastar-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:01084cb75f13ca6a8e80bd41584322523189f8e81b472053743d6e6c3062b5a6", size = 995141, upload-time = "2025-11-26T02:35:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/89/c0/7a8ec86695b0b77168e220cf2af1aa30592f5ecdbd0ce6d641d29c4a8bae/fastar-0.8.0-cp310-cp310-win32.whl", hash = "sha256:ca639b9909805e44364ea13cca2682b487e74826e4ad75957115ec693228d6b6", size = 456544, upload-time = "2025-11-26T02:36:23.801Z" }, + { url = "https://files.pythonhosted.org/packages/be/a9/8da4deb840121c59deabd939ce2dca3d6beec85576f3743d1144441938b5/fastar-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:fbc0f2ed0f4add7fb58034c576584d44d7eaaf93dee721dfb26dbed6e222dbac", size = 490701, upload-time = "2025-11-26T02:36:09.625Z" }, + { url = "https://files.pythonhosted.org/packages/cd/15/1c764530b81b266f6d27d78d49b6bef22a73b3300cd83a280bfd244908c5/fastar-0.8.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cd9c0d3ebf7a0a6f642f771cf41b79f7c98d40a3072a8abe1174fbd9bd615bd3", size = 708427, upload-time = "2025-11-26T02:34:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/41/fc/75d42c008516543219e4293e4d8ac55da57a5c63147484f10468bd1bc24e/fastar-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2875a077340fe4f8099bd3ed8fa90d9595e1ac3cd62ae19ab690d5bf550eeb35", size = 631740, upload-time = "2025-11-26T02:34:20.718Z" }, + { url = "https://files.pythonhosted.org/packages/50/8d/9632984f7824ed2210157dcebd8e9821ef6d4f2b28510d0516db6625ff9b/fastar-0.8.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a999263d9f87184bf2801833b2ecf105e03c0dd91cac78685673b70da564fd64", size = 871628, upload-time = "2025-11-26T02:33:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/05/97/3eb6ea71b7544d45cd29cacb764ca23cde8ce0aed1a6a02251caa4c0a818/fastar-0.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c41111da56430f638cbfc498ebdcc7d30f63416e904b27b7695c29bd4889cb8", size = 765005, upload-time = "2025-11-26T02:32:45.833Z" }, + { url = "https://files.pythonhosted.org/packages/d6/45/3eb0ee945a0b5d5f9df7e7c25c037ce7fa441cd0b4d44f76d286e2f4396a/fastar-0.8.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3719541a12bb09ab1eae91d2c987a9b2b7d7149c52e7109ba6e15b74aabc49b1", size = 765587, upload-time = "2025-11-26T02:33:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/51/bb/7defd6ec0d9570b1987d8ebde52d07d97f3f26e10b592fb3e12738eba39a/fastar-0.8.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a9b0fff8079b18acdface7ef1b7f522fd9a589f65ca4a1a0dd7c92a0886c2a2", size = 931150, upload-time = "2025-11-26T02:33:17.374Z" }, + { url = "https://files.pythonhosted.org/packages/28/54/62e51e684dab347c61878afbf09e177029c1a91eb1e39ef244e6b3ef9efa/fastar-0.8.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac073576c1931959191cb20df38bab21dd152f66c940aa3ca8b22e39f753b2f3", size = 821354, upload-time = "2025-11-26T02:33:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/53/a8/12708ea4d21e3cf9f485b2a67d44ce84d949a6eddcc9aa5b3d324585ab43/fastar-0.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:003b59a7c3e405b6a7bff8fab17d31e0ccbc7f06730a8f8ca1694eeea75f3c76", size = 821626, upload-time = "2025-11-26T02:34:05.685Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/1b4d3347c7a759853f963410bf6baf42fe014d587c50c39c8e145f4bf1a0/fastar-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a7b96748425efd9fc155cd920d65088a1b0d754421962418ea73413d02ff515a", size = 986187, upload-time = "2025-11-26T02:34:52.047Z" }, + { url = "https://files.pythonhosted.org/packages/dc/59/2dbe0dc2570764475e60030403738faa261a9d3bff16b08629c378ab939a/fastar-0.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:90957a30e64418b02df5b4d525bea50403d98a4b1f29143ce5914ddfa7e54ee4", size = 1041536, upload-time = "2025-11-26T02:35:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0f/639b295669c7ca6fbc2b4be2a7832aaeac1a5e06923f15a8a6d6daecbc7d/fastar-0.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f6e784a8015623fbb7ccca1af372fd82cb511b408ddd2348dc929fc6e415df73", size = 1047149, upload-time = "2025-11-26T02:35:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/23e3a19e06d261d1894f98eca9458f98c090c505a0c712dafc0ff1fc2965/fastar-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a03eaf287bbc93064688a1220580ce261e7557c8898f687f4d0b281c85b28d3c", size = 994992, upload-time = "2025-11-26T02:35:44.009Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7a/3ea4726bae3ac9358d02107ae48f3e10ee186dbed554af79e00b7b498c44/fastar-0.8.0-cp311-cp311-win32.whl", hash = "sha256:661a47ed90762f419406c47e802f46af63a08254ba96abd1c8191e4ce967b665", size = 456449, upload-time = "2025-11-26T02:36:25.291Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3c/0142bee993c431ee91cf5535e6e4b079ad491f620c215fcd79b7e5ffeb2b/fastar-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:b48abd6056fef7bc3d414aafb453c5b07fdf06d2df5a2841d650288a3aa1e9d3", size = 490863, upload-time = "2025-11-26T02:36:11.114Z" }, + { url = "https://files.pythonhosted.org/packages/3b/18/d119944f6bdbf6e722e204e36db86390ea45684a1bf6be6e3aa42abd471f/fastar-0.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:50c18788b3c6ffb85e176dcb8548bb8e54616a0519dcdbbfba66f6bbc4316933", size = 462230, upload-time = "2025-11-26T02:36:01.917Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/5b2ff898abac7f1a418284aad285e3a4f68d189c572ab2db0f6c9079dd16/fastar-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f10d2adfe40f47ff228f4efaa32d409d732ded98580e03ed37c9535b5fc923d", size = 706369, upload-time = "2025-11-26T02:34:37.783Z" }, + { url = "https://files.pythonhosted.org/packages/23/60/8046a386dca39154f80c927cbbeeb4b1c1267a3271bffe61552eb9995757/fastar-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b930da9d598e3bc69513d131f397e6d6be4643926ef3de5d33d1e826631eb036", size = 629097, upload-time = "2025-11-26T02:34:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/22/7e/1ae005addc789924a9268da2394d3bb5c6f96836f7e37b7e3d23c2362675/fastar-0.8.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d210da2de733ca801de83e931012349d209f38b92d9630ccaa94bd445bdc9b8", size = 868938, upload-time = "2025-11-26T02:33:51.119Z" }, + { url = "https://files.pythonhosted.org/packages/a6/77/290a892b073b84bf82e6b2259708dfe79c54f356e252c2dd40180b16fe07/fastar-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa02270721517078a5bd61a38719070ac2537a4aa6b6c48cf369cf2abc59174a", size = 765204, upload-time = "2025-11-26T02:32:47.02Z" }, + { url = "https://files.pythonhosted.org/packages/d0/00/c3155171b976003af3281f5258189f1935b15d1221bfc7467b478c631216/fastar-0.8.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c391e5b789a720e4d0029b9559f5d6dee3226693c5b39c0eab8eaece997e0f", size = 764717, upload-time = "2025-11-26T02:33:02.453Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/405b7ad76207b2c11b7b59335b70eac19e4a2653977f5588a1ac8fed54f4/fastar-0.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3258d7a78a72793cdd081545da61cabe85b1f37634a1d0b97ffee0ff11d105ef", size = 931502, upload-time = "2025-11-26T02:33:18.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/8a/a3dde6d37cc3da4453f2845cdf16675b5686b73b164f37e2cc579b057c2c/fastar-0.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6eab95dd985cdb6a50666cbeb9e4814676e59cfe52039c880b69d67cfd44767", size = 821454, upload-time = "2025-11-26T02:33:33.427Z" }, + { url = "https://files.pythonhosted.org/packages/da/c1/904fe2468609c8990dce9fe654df3fbc7324a8d8e80d8240ae2c89757064/fastar-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:829b1854166141860887273c116c94e31357213fa8e9fe8baeb18bd6c38aa8d9", size = 821647, upload-time = "2025-11-26T02:34:07Z" }, + { url = "https://files.pythonhosted.org/packages/c8/73/a0642ab7a400bc07528091785e868ace598fde06fcd139b8f865ec1b6f3c/fastar-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1667eae13f9457a3c737f4376d68e8c3e548353538b28f7e4273a30cb3965cd", size = 986342, upload-time = "2025-11-26T02:34:53.371Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/60c1bfa6edab72366461a95f053d0f5f7ab1825fe65ca2ca367432cd8629/fastar-0.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b864a95229a7db0814cd9ef7987cb713fd43dce1b0d809dd17d9cd6f02fdde3e", size = 1040207, upload-time = "2025-11-26T02:35:10.65Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a0/0d624290dec622e7fa084b6881f456809f68777d54a314f5dde932714506/fastar-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c05fbc5618ce17675a42576fa49858d79734627f0a0c74c0875ab45ee8de340c", size = 1045031, upload-time = "2025-11-26T02:35:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/a7/74/cf663af53c4706ba88e6b4af44a6b0c3bd7d7ca09f079dc40647a8f06585/fastar-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7f41c51ee96f338662ee3c3df4840511ba3f9969606840f1b10b7cb633a3c716", size = 994877, upload-time = "2025-11-26T02:35:45.797Z" }, + { url = "https://files.pythonhosted.org/packages/52/17/444c8be6e77206050e350da7c338102b6cab384be937fa0b1d6d1f9ede73/fastar-0.8.0-cp312-cp312-win32.whl", hash = "sha256:d949a1a2ea7968b734632c009df0571c94636a5e1622c87a6e2bf712a7334f47", size = 455996, upload-time = "2025-11-26T02:36:26.938Z" }, + { url = "https://files.pythonhosted.org/packages/dc/34/fc3b5e56d71a17b1904800003d9251716e8fd65f662e1b10a26881698a74/fastar-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc645994d5b927d769121094e8a649b09923b3c13a8b0b98696d8f853f23c532", size = 490429, upload-time = "2025-11-26T02:36:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/35/a8/5608cc837417107c594e2e7be850b9365bcb05e99645966a5d6a156285fe/fastar-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:d81ee82e8dc78a0adb81728383bd39611177d642a8fa2d601d4ad5ad59e5f3bd", size = 461297, upload-time = "2025-11-26T02:36:03.546Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a5/79ecba3646e22d03eef1a66fb7fc156567213e2e4ab9faab3bbd4489e483/fastar-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a3253a06845462ca2196024c7a18f5c0ba4de1532ab1c4bad23a40b332a06a6a", size = 706112, upload-time = "2025-11-26T02:34:39.237Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/4f883bce878218a8676c2d7ca09b50c856a5470bb3b7f63baf9521ea6995/fastar-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5cbeb3ebfa0980c68ff8b126295cc6b208ccd81b638aebc5a723d810a7a0e5d2", size = 628954, upload-time = "2025-11-26T02:34:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f1/892e471f156b03d10ba48ace9384f5a896702a54506137462545f38e40b8/fastar-0.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c0d5956b917daac77d333d48b3f0f3ff927b8039d5b32d8125462782369f761", size = 868685, upload-time = "2025-11-26T02:33:53.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/ba/e24915045852e30014ec6840446975c03f4234d1c9270394b51d3ad18394/fastar-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27b404db2b786b65912927ce7f3790964a4bcbde42cdd13091b82a89cd655e1c", size = 765044, upload-time = "2025-11-26T02:32:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/1aa11ac21a99984864c2fca4994e094319ff3a2046e7a0343c39317bd5b9/fastar-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0902fc89dcf1e7f07b8563032a4159fe2b835e4c16942c76fd63451d0e5f76a3", size = 764322, upload-time = "2025-11-26T02:33:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f0/4b91902af39fe2d3bae7c85c6d789586b9fbcf618d7fdb3d37323915906d/fastar-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:069347e2f0f7a8b99bbac8cd1bc0e06c7b4a31dc964fc60d84b95eab3d869dc1", size = 931016, upload-time = "2025-11-26T02:33:19.902Z" }, + { url = "https://files.pythonhosted.org/packages/c9/97/8fc43a5a9c0a2dc195730f6f7a0f367d171282cd8be2511d0e87c6d2dad0/fastar-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd135306f6bfe9a835918280e0eb440b70ab303e0187d90ab51ca86e143f70d", size = 821308, upload-time = "2025-11-26T02:33:34.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e9/058615b63a7fd27965e8c5966f393ed0c169f7ff5012e1674f21684de3ba/fastar-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d06d6897f43c27154b5f2d0eb930a43a81b7eec73f6f0b0114814d4a10ab38", size = 821171, upload-time = "2025-11-26T02:34:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cf/69e16a17961570a755c37ffb5b5aa7610d2e77807625f537989da66f2a9d/fastar-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a922f8439231fa0c32b15e8d70ff6d415619b9d40492029dabbc14a0c53b5f18", size = 986227, upload-time = "2025-11-26T02:34:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/fb/83/2100192372e59b56f4ace37d7d9cabda511afd71b5febad1643d1c334271/fastar-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a739abd51eb766384b4caff83050888e80cd75bbcfec61e6d1e64875f94e4a40", size = 1039395, upload-time = "2025-11-26T02:35:12.166Z" }, + { url = "https://files.pythonhosted.org/packages/75/15/cdd03aca972f55872efbb7cf7540c3fa7b97a75d626303a3ea46932163dc/fastar-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a65f419d808b23ac89d5cd1b13a2f340f15bc5d1d9af79f39fdb77bba48ff1b", size = 1044766, upload-time = "2025-11-26T02:35:29.62Z" }, + { url = "https://files.pythonhosted.org/packages/3d/29/945e69e4e2652329ace545999334ec31f1431fbae3abb0105587e11af2ae/fastar-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7bb2ae6c0cce58f0db1c9f20495e7557cca2c1ee9c69bbd90eafd54f139171c5", size = 994740, upload-time = "2025-11-26T02:35:47.887Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5d/dbfe28f8cd1eb484bba0c62e5259b2cf6fea229d6ef43e05c06b5a78c034/fastar-0.8.0-cp313-cp313-win32.whl", hash = "sha256:b28753e0d18a643272597cb16d39f1053842aa43131ad3e260c03a2417d38401", size = 455990, upload-time = "2025-11-26T02:36:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/e1/01/e965740bd36e60ef4c5aa2cbe42b6c4eb1dc3551009238a97c2e5e96bd23/fastar-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:620e5d737dce8321d49a5ebb7997f1fd0047cde3512082c27dc66d6ac8c1927a", size = 490227, upload-time = "2025-11-26T02:36:14.363Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9573b87a0ef07580ed111e7230259aec31bb33ca3667963ebee77022ec61/fastar-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:50b36ce654ba44b0e13fae607ae17ee6e1597b69f71df1bee64bb8328d881dfc", size = 706041, upload-time = "2025-11-26T02:34:40.638Z" }, + { url = "https://files.pythonhosted.org/packages/4a/19/f95444a1d4f375333af49300aa75ee93afa3335c0e40fda528e460ed859c/fastar-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a892762683d7ab00df0227d5ea9677c62ff2cde9b875e666c0be569ed940f3", size = 628617, upload-time = "2025-11-26T02:34:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c9/b51481b38b7e3f16ef2b9e233b1a3623386c939d745d6e41bbd389eaae30/fastar-0.8.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4ae6a145c1bff592644bde13f2115e0239f4b7babaf506d14e7d208483cf01a5", size = 869299, upload-time = "2025-11-26T02:33:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/bf/02/3ba1267ee5ba7314e29c431cf82eaa68586f2c40cdfa08be3632b7d07619/fastar-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ae0ff7c0a1c7e1428404b81faee8aebef466bfd0be25bfe4dabf5d535c68741", size = 764667, upload-time = "2025-11-26T02:32:49.606Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/bf33530fd015b5d7c2cc69e0bce4a38d736754a6955487005aab1af6adcd/fastar-0.8.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbfd87dbd217b45c898b2dbcd0169aae534b2c1c5cbe3119510881f6a5ac8ef5", size = 763993, upload-time = "2025-11-26T02:33:05.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/9564d24e7cea6321a8d921c6d2a457044a476ef197aa4708e179d3d97f0d/fastar-0.8.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5abd99fcba83ef28c8fe6ae2927edc79053db43a0457a962ed85c9bf150d37", size = 930153, upload-time = "2025-11-26T02:33:21.53Z" }, + { url = "https://files.pythonhosted.org/packages/35/b1/6f57fcd8d6e192cfebf97e58eb27751640ad93784c857b79039e84387b51/fastar-0.8.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91d4c685620c3a9d6b5ae091dbabab4f98b20049b7ecc7976e19cc9016c0d5d6", size = 821177, upload-time = "2025-11-26T02:33:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/9e004ea9f3aa7466f5ddb6f9518780e1d2f0ed3ca55f093632982598bace/fastar-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f77c2f2cad76e9dc7b6701297adb1eba87d0485944b416fc2ccf5516c01219a3", size = 820652, upload-time = "2025-11-26T02:34:09.776Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/b604ed536544005c9f1aee7c4c74b00150db3d8d535cd8232dc20f947063/fastar-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e7f07c4a3dada7757a8fc430a5b4a29e6ef696d2212747213f57086ffd970316", size = 985961, upload-time = "2025-11-26T02:34:56.401Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7b/fa9d4d96a5d494bdb8699363bb9de8178c0c21a02e1d89cd6f913d127018/fastar-0.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:90c0c3fe55105c0aed8a83135dbdeb31e683455dbd326a1c48fa44c378b85616", size = 1039316, upload-time = "2025-11-26T02:35:13.807Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f9/8462789243bc3f33e8401378ec6d54de4e20cfa60c96a0e15e3e9d1389bb/fastar-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fb9ee51e5bffe0dab3d3126d3a4fac8d8f7235cedcb4b8e74936087ce1c157f3", size = 1045028, upload-time = "2025-11-26T02:35:31.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/71/9abb128777e616127194b509e98fcda3db797d76288c1a8c23dd22afc14f/fastar-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e380b1e8d30317f52406c43b11e98d11e1d68723bbd031e18049ea3497b59a6d", size = 994677, upload-time = "2025-11-26T02:35:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/de/c1/b81b3f194853d7ad232a67a1d768f5f51a016f165cfb56cb31b31bbc6177/fastar-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1c4ffc06e9c4a8ca498c07e094670d8d8c0d25b17ca6465b9774da44ea997ab1", size = 456687, upload-time = "2025-11-26T02:36:30.205Z" }, + { url = "https://files.pythonhosted.org/packages/cb/87/9e0cd4768a98181d56f0cdbab2363404cc15deb93f4aad3b99cd2761bbaa/fastar-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:5517a8ad4726267c57a3e0e2a44430b782e00b230bf51c55b5728e758bb3a692", size = 490578, upload-time = "2025-11-26T02:36:16.218Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/580a76cf91847654f2ad6520e956e93218f778540975bc4190d363f709e2/fastar-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:58030551046ff4a8616931e52a36c83545ff05996db5beb6e0cd2b7e748aa309", size = 461473, upload-time = "2025-11-26T02:36:06.373Z" }, + { url = "https://files.pythonhosted.org/packages/58/4c/bdb5c6efe934f68708529c8c9d4055ebef5c4be370621966438f658b29bd/fastar-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1e7d29b6bfecb29db126a08baf3c04a5ab667f6cea2b7067d3e623a67729c4a6", size = 705570, upload-time = "2025-11-26T02:34:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/f01ac7e71d5a37621bd13598a26e948a12b85ca8042f7ee1a0a8c9f59cda/fastar-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05eb7b96940f9526b485f1d0b02393839f0f61cac4b1f60024984f8b326d2640", size = 627761, upload-time = "2025-11-26T02:34:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/06/45/6df0ecda86ea9d2e95053c1a655d153dee55fc121b6e13ea6d1e246a50b6/fastar-0.8.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:619352d8ac011794e2345c462189dc02ba634750d23cd9d86a9267dd71b1f278", size = 869414, upload-time = "2025-11-26T02:33:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/486421f5a8c0c377cc82e7a50c8a8ea899a6ec2aa72bde8f09fb667a2dc8/fastar-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74ebfecef3fe6d7a90355fac1402fd30636988332a1d33f3e80019a10782bb24", size = 763863, upload-time = "2025-11-26T02:32:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39f654dbb41a3867fb1f2c8081c014d8f1d32ea10585d84cacbef0b32995/fastar-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2975aca5a639e26a3ab0d23b4b0628d6dd6d521146c3c11486d782be621a35aa", size = 763065, upload-time = "2025-11-26T02:33:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bd/c011a34fb3534c4c3301f7c87c4ffd7e47f6113c904c092ddc8a59a303ea/fastar-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afc438eaed8ff0dcdd9308268be5cb38c1db7e94c3ccca7c498ca13a4a4535a3", size = 930530, upload-time = "2025-11-26T02:33:23.117Z" }, + { url = "https://files.pythonhosted.org/packages/55/9d/aa6e887a7033c571b1064429222bbe09adc9a3c1e04f3d1788ba5838ebd5/fastar-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ced0a5399cc0a84a858ef0a31ca2d0c24d3bbec4bcda506a9192d8119f3590a", size = 820572, upload-time = "2025-11-26T02:33:37.542Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9c/7a3a2278a1052e1a5d98646de7c095a00cffd2492b3b84ce730e2f1cd93a/fastar-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec9b23da8c4c039da3fe2e358973c66976a0c8508aa06d6626b4403cb5666c19", size = 820649, upload-time = "2025-11-26T02:34:11.108Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d38edc1f4438cd047e56137c26d94783ffade42e1b3bde620ccf17b771ef/fastar-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dfba078fcd53478032fd0ceed56960ec6b7ff0511cfc013a8a3a4307e3a7bac4", size = 985653, upload-time = "2025-11-26T02:34:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/69/d9/2147d0c19757e165cd62d41cec3f7b38fad2ad68ab784978b5f81716c7ea/fastar-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ade56c94c14be356d295fecb47a3fcd473dd43a8803ead2e2b5b9e58feb6dcfa", size = 1038140, upload-time = "2025-11-26T02:35:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/ec4c717ffb8a308871e9602ec3197d957e238dc0227127ac573ec9bca952/fastar-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e48d938f9366db5e59441728f70b7f6c1ccfab7eff84f96f9b7e689b07786c52", size = 1045195, upload-time = "2025-11-26T02:35:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/637334dc8c8f3bb391388b064ae13f0ad9402bc5a6c3e77b8887d0c31921/fastar-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:79c441dc1482ff51a54fb3f57ae6f7bb3d2cff88fa2cc5d196c519f8aab64a56", size = 994686, upload-time = "2025-11-26T02:35:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e2/dfa19a4b260b8ab3581b7484dcb80c09b25324f4daa6b6ae1c7640d1607a/fastar-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:187f61dc739afe45ac8e47ed7fd1adc45d52eac110cf27d579155720507d6fbe", size = 455767, upload-time = "2025-11-26T02:36:34.758Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/df65c72afc1297797b255f90c4778b5d6f1f0f80282a134d5ab610310ed9/fastar-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40e9d763cf8bf85ce2fa256e010aa795c0fe3d3bd1326d5c3084e6ce7857127e", size = 489971, upload-time = "2025-11-26T02:36:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/25/9f/6eaa810c240236eff2edf736cd50a17c97dbab1693cda4f7bcea09d13418/fastar-0.8.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2127cf2e80ffd49744a160201e0e2f55198af6c028a7b3f750026e0b1f1caa4e", size = 710544, upload-time = "2025-11-26T02:34:46.195Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a5/58ff9e49a1cd5fbfc8f1238226cbf83b905376a391a6622cdd396b2cfa29/fastar-0.8.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ff85094f10003801339ac4fa9b20a3410c2d8f284d4cba2dc99de6e98c877812", size = 634020, upload-time = "2025-11-26T02:34:31.085Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/f839257c6600a83fbdb5a7fcc06319599086137b25ba38ca3d2c0fe14562/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3dbca235f0bd804cca6602fe055d3892bebf95fb802e6c6c7d872fb10f7abc6c", size = 871735, upload-time = "2025-11-26T02:34:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/4124c54260f7ee5cb7034bfe499eff2f8512b052d54be4671e59d4f25a4f/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e54bfdee6c81a0005e147319e93d8797f442308032c92fa28d03ef8fda076", size = 766779, upload-time = "2025-11-26T02:32:55.109Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/043b263c4126bf6557c942d099503989af9c5c7ee5cca9a04e00f754816f/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a78e5221b94a80800930b7fd0d0e797ae73aadf7044c05ed46cb9bdf870f022", size = 766755, upload-time = "2025-11-26T02:33:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/ff/29a5dc06f2940439ebf98661ecc98d48d3f22fed8d6a2d5dc985d1e8da24/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:997092d31ff451de8d0568f6773f3517cb87dcd0bc76184edb65d7154390a6f8", size = 932732, upload-time = "2025-11-26T02:33:27.122Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e8/2218830f422b37aad52c24b53cb84b5d88bd6fd6ad411bd6689b1a32500d/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:558e8fcf8fe574541df5db14a46cd98bfbed14a811b7014a54f2b714c0cfac42", size = 822571, upload-time = "2025-11-26T02:33:42.986Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/ba6dfeff77cddfe58d85c490b1735c002b81c0d6f826916a8b6c4f8818bc/fastar-0.8.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1d2a54f87e2908cc19e1a6ee249620174fbefc54a219aba1eaa6f31657683c3", size = 822440, upload-time = "2025-11-26T02:34:15.439Z" }, + { url = "https://files.pythonhosted.org/packages/a7/57/54d5740c84b35de0eb12975397ecc16785b5ad8bed2dbac38b8c8a7c1edd/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ef94901537be277f9ec59db939eb817960496c6351afede5b102699b5098604d", size = 987424, upload-time = "2025-11-26T02:35:02.742Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c7/18115927f16deb1ddffdbd4ae992e7e33064bc6defa2b92a147948f8bc0c/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:0afbb92f78bf29d5e9db76fb46cbabc429e49015cddf72ab9e761afbe88ac100", size = 1042675, upload-time = "2025-11-26T02:35:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1a/ca884fc7973ec6d765e87af23a4dd25784fb0a36ac2df825f18c3630bbab/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:fb59c7925e7710ad178d9e1a3e65edf295d9a042a0cdcb673b4040949eb8ad0a", size = 1047098, upload-time = "2025-11-26T02:35:37.643Z" }, + { url = "https://files.pythonhosted.org/packages/44/ee/25cd645db749b206bb95e1512e57e75d56ccbbb8ec3536f52a7979deab6b/fastar-0.8.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e6c4d6329da568ec36b1347b0c09c4d27f9dfdeddf9f438ddb16799ecf170098", size = 997397, upload-time = "2025-11-26T02:35:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/6c46aa7f8c8734e7f96ee5141acd3877667ce66f34eea10703aa7571d191/fastar-0.8.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:998e3fa4b555b63eb134e6758437ed739ad1652fdd2a61dfe1dacbfddc35fe66", size = 710662, upload-time = "2025-11-26T02:34:47.593Z" }, + { url = "https://files.pythonhosted.org/packages/70/27/fd622442f2fbd4ff5459677987481ef1c60e077cb4e63a2ed4d8dce6f869/fastar-0.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5f83e60d845091f3a12bc37f412774264d161576eaf810ed8b43567eb934b7e5", size = 634049, upload-time = "2025-11-26T02:34:32.365Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ee/aa4d08aea25b5419a7277132e738ab1cd775f26aebddce11413b07e2fdff/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:299672e1c74d8b73c61684fac9159cfc063d35f4b165996a88facb0e26862cb5", size = 872055, upload-time = "2025-11-26T02:34:01.377Z" }, + { url = "https://files.pythonhosted.org/packages/92/9a/2bf2f77aade575e67997e0c759fd55cb1c66b7a5b437b1cd0e97d8b241bc/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3d3a27066b84d015deab5faee78565509bb33b137896443e4144cb1be1a5f90", size = 766787, upload-time = "2025-11-26T02:32:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/0b/90/23a3f6c252f11b10c70f854bce09abc61f71b5a0e6a4b0eac2bcb9a2c583/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef0bcf4385bbdd3c1acecce2d9ea7dab7cc9b8ee0581bbccb7ab11908a7ce288", size = 766861, upload-time = "2025-11-26T02:33:12.824Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/beeb9078380acd4484db5c957d066171695d9340e3526398eb230127b0c2/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f10ef62b6eda6cb6fd9ba8e1fe08a07d7b2bdcc8eaa00eb91566143b92ed7eee", size = 932667, upload-time = "2025-11-26T02:33:28.405Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6d/b034cc637bd0ee638d5a85d08e941b0b8ffd44cf391fb751ba98233734f7/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4f6c82a8ee98c17aa48585ee73b51c89c1b010e5c951af83e07c3436180e3fc", size = 822712, upload-time = "2025-11-26T02:33:44.27Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2b/7d183c63f59227c4689792042d6647f2586a5e7273b55e81745063088d81/fastar-0.8.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6129067fcb86276635b5857010f4e9b9c7d5d15dd571bb03c6c1ed73c40fd92", size = 822659, upload-time = "2025-11-26T02:34:16.815Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f9/716e0cd9de2427fdf766bc68176f76226cd01fffef3a56c5046fa863f5f0/fastar-0.8.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4cc9e77019e489f1ddac446b6a5b9dfb5c3d9abd142652c22a1d9415dbcc0e47", size = 987412, upload-time = "2025-11-26T02:35:04.259Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b9/9a8c3fd59958c1c8027bc075af11722cdc62c4968bb277e841d131232289/fastar-0.8.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:382bfe82c026086487cb17fee12f4c1e2b4e67ce230f2e04487d3e7ddfd69031", size = 1042911, upload-time = "2025-11-26T02:35:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2f/c3f30963b47022134b8a231c12845f4d7cfba520f59bbc1a82468aea77c7/fastar-0.8.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:908d2b9a1ff3d549cc304b32f95706a536da8f0bcb0bc0f9e4c1cce39b80e218", size = 1047464, upload-time = "2025-11-26T02:35:39.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/218ab6d9a2bab3b07718e6cd8405529600edc1e9c266320e8524c8f63251/fastar-0.8.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1aa7dbde2d2d73eb5b6203d0f74875cb66350f0f1b4325b4839fc8fbbf5d074e", size = 997309, upload-time = "2025-11-26T02:35:57.722Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513, upload-time = "2025-04-08T13:27:06.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591, upload-time = "2025-04-08T13:27:03.789Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395, upload-time = "2025-04-02T09:49:41.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", size = 2044021, upload-time = "2025-04-02T09:46:45.065Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", size = 1861742, upload-time = "2025-04-02T09:46:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", size = 1910414, upload-time = "2025-04-02T09:46:48.263Z" }, + { url = "https://files.pythonhosted.org/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", size = 1996848, upload-time = "2025-04-02T09:46:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", size = 2141055, upload-time = "2025-04-02T09:46:50.602Z" }, + { url = "https://files.pythonhosted.org/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", size = 2753806, upload-time = "2025-04-02T09:46:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", size = 2007777, upload-time = "2025-04-02T09:46:53.675Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", size = 2122803, upload-time = "2025-04-02T09:46:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", size = 2086755, upload-time = "2025-04-02T09:46:56.956Z" }, + { url = "https://files.pythonhosted.org/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", size = 2257358, upload-time = "2025-04-02T09:46:58.445Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091", size = 2257916, upload-time = "2025-04-02T09:46:59.726Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", size = 1923823, upload-time = "2025-04-02T09:47:01.278Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", size = 1952494, upload-time = "2025-04-02T09:47:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224, upload-time = "2025-04-02T09:47:04.199Z" }, + { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845, upload-time = "2025-04-02T09:47:05.686Z" }, + { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029, upload-time = "2025-04-02T09:47:07.042Z" }, + { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784, upload-time = "2025-04-02T09:47:08.63Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075, upload-time = "2025-04-02T09:47:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849, upload-time = "2025-04-02T09:47:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794, upload-time = "2025-04-02T09:47:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237, upload-time = "2025-04-02T09:47:14.355Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351, upload-time = "2025-04-02T09:47:15.676Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914, upload-time = "2025-04-02T09:47:17Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385, upload-time = "2025-04-02T09:47:18.631Z" }, + { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765, upload-time = "2025-04-02T09:47:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688, upload-time = "2025-04-02T09:47:22.029Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185, upload-time = "2025-04-02T09:47:23.385Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640, upload-time = "2025-04-02T09:47:25.394Z" }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649, upload-time = "2025-04-02T09:47:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472, upload-time = "2025-04-02T09:47:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509, upload-time = "2025-04-02T09:47:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702, upload-time = "2025-04-02T09:47:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428, upload-time = "2025-04-02T09:47:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753, upload-time = "2025-04-02T09:47:39.013Z" }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849, upload-time = "2025-04-02T09:47:40.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541, upload-time = "2025-04-02T09:47:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225, upload-time = "2025-04-02T09:47:43.425Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373, upload-time = "2025-04-02T09:47:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034, upload-time = "2025-04-02T09:47:46.843Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848, upload-time = "2025-04-02T09:47:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986, upload-time = "2025-04-02T09:47:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551, upload-time = "2025-04-02T09:47:51.648Z" }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785, upload-time = "2025-04-02T09:47:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758, upload-time = "2025-04-02T09:47:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109, upload-time = "2025-04-02T09:47:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159, upload-time = "2025-04-02T09:47:58.088Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222, upload-time = "2025-04-02T09:47:59.591Z" }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980, upload-time = "2025-04-02T09:48:01.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840, upload-time = "2025-04-02T09:48:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518, upload-time = "2025-04-02T09:48:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025, upload-time = "2025-04-02T09:48:06.226Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991, upload-time = "2025-04-02T09:48:08.114Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262, upload-time = "2025-04-02T09:48:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626, upload-time = "2025-04-02T09:48:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590, upload-time = "2025-04-02T09:48:12.861Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963, upload-time = "2025-04-02T09:48:14.553Z" }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896, upload-time = "2025-04-02T09:48:16.222Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810, upload-time = "2025-04-02T09:48:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659, upload-time = "2025-04-02T09:48:45.342Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294, upload-time = "2025-04-02T09:48:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771, upload-time = "2025-04-02T09:48:49.468Z" }, + { url = "https://files.pythonhosted.org/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", size = 2083558, upload-time = "2025-04-02T09:48:51.409Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", size = 2118038, upload-time = "2025-04-02T09:48:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", size = 2079315, upload-time = "2025-04-02T09:48:55.555Z" }, + { url = "https://files.pythonhosted.org/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", size = 2249063, upload-time = "2025-04-02T09:48:57.479Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", size = 2254631, upload-time = "2025-04-02T09:48:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", size = 2080877, upload-time = "2025-04-02T09:49:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858, upload-time = "2025-04-02T09:49:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745, upload-time = "2025-04-02T09:49:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188, upload-time = "2025-04-02T09:49:07.352Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479, upload-time = "2025-04-02T09:49:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415, upload-time = "2025-04-02T09:49:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623, upload-time = "2025-04-02T09:49:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175, upload-time = "2025-04-02T09:49:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674, upload-time = "2025-04-02T09:49:17.61Z" }, + { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951, upload-time = "2025-04-02T09:49:19.559Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/d0/8f8de36e1abf8339b497ce700dd7251ca465ffca4a1976969b0eaeb596fb/rich_toolkit-0.17.0.tar.gz", hash = "sha256:17ca7a32e613001aa0945ddea27a246f6de01dfc4c12403254c057a8ee542977", size = 187955, upload-time = "2025-11-27T11:10:24.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/42/ef2ed40699567661d03b0b511ac46cf6cee736de8f3666819c12d6d20696/rich_toolkit-0.17.0-py3-none-any.whl", hash = "sha256:06fb47a5c5259d6b480287cd38aff5f551b6e1a307f90ed592453dd360e4e71e", size = 31412, upload-time = "2025-11-27T11:10:23.847Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/7a/b970cd0138b0ece72eb28f086e933f9ed75b795716ad3de5ab22994b3b54/rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45", size = 884999, upload-time = "2025-11-05T20:42:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/23faca29616d8966ada63fb0e13c214107811fa9a0aba2275e4c7ca63bd5/rignore-0.7.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b7202404958f5fe3474bac91f65350f0b1dde1a5e05089f2946549b7e91e79ec", size = 824824, upload-time = "2025-11-05T20:42:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/05a1e61f04cf2548524224f0b5f21ca19ea58f7273a863bac10846b8ff69/rignore-0.7.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bde7c5835fa3905bfb7e329a4f1d7eccb676de63da7a3f934ddd5c06df20597", size = 899121, upload-time = "2025-11-05T20:40:48.94Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/71518847e10bdbf359badad8800e4681757a01f4777b3c5e03dbde8a42d8/rignore-0.7.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:626c3d4ba03af266694d25101bc1d8d16eda49c5feb86cedfec31c614fceca7d", size = 873813, upload-time = "2025-11-05T20:41:04.71Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c8/32ae405d3e7fd4d9f9b7838f2fcca0a5005bb87fa514b83f83fd81c0df22/rignore-0.7.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a43841e651e7a05a4274b9026cc408d1912e64016ede8cd4c145dae5d0635be", size = 1168019, upload-time = "2025-11-05T20:41:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/013c955982bc5b4719bf9a5bea58be317eea28aa12bfd004025e3cd7c000/rignore-0.7.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7978c498dbf7f74d30cdb8859fe612167d8247f0acd377ae85180e34490725da", size = 942822, upload-time = "2025-11-05T20:41:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/90/fb/9a3f3156c6ed30bcd597e63690353edac1fcffe9d382ad517722b56ac195/rignore-0.7.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d22f72ab695c07d2d96d2a645208daff17084441b5d58c07378c9dd6f9c4c87", size = 959820, upload-time = "2025-11-05T20:42:06.364Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b2/93bf609633021e9658acaff24cfb055d8cdaf7f5855d10ebb35307900dda/rignore-0.7.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5bd8e1a91ed1a789b2cbe39eeea9204a6719d4f2cf443a9544b521a285a295f", size = 985050, upload-time = "2025-11-05T20:41:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/ec2d040469bdfd7b743df10f2201c5d285009a4263d506edbf7a06a090bb/rignore-0.7.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fc03efad5789365018e94ac4079f851a999bc154d1551c45179f7fcf45322", size = 1079164, upload-time = "2025-11-05T21:40:10.368Z" }, + { url = "https://files.pythonhosted.org/packages/df/26/4b635f4ea5baf4baa8ba8eee06163f6af6e76dfbe72deb57da34bb24b19d/rignore-0.7.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ce2617fe28c51367fd8abfd4eeea9e61664af63c17d4ea00353d8ef56dfb95fa", size = 1139028, upload-time = "2025-11-05T21:40:27.977Z" }, + { url = "https://files.pythonhosted.org/packages/6a/54/a3147ebd1e477b06eb24e2c2c56d951ae5faa9045b7b36d7892fec5080d9/rignore-0.7.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c4ad2cee85068408e7819a38243043214e2c3047e9bd4c506f8de01c302709e", size = 1119024, upload-time = "2025-11-05T21:40:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f4/27475db769a57cff18fe7e7267b36e6cdb5b1281caa185ba544171106cba/rignore-0.7.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:02cd240bfd59ecc3907766f4839cbba20530a2e470abca09eaa82225e4d946fb", size = 1128531, upload-time = "2025-11-05T21:41:02.734Z" }, + { url = "https://files.pythonhosted.org/packages/97/32/6e782d3b352e4349fa0e90bf75b13cb7f11d8908b36d9e2b262224b65d9a/rignore-0.7.6-cp310-cp310-win32.whl", hash = "sha256:fe2bd8fa1ff555259df54c376abc73855cb02628a474a40d51b358c3a1ddc55b", size = 646817, upload-time = "2025-11-05T21:41:47.51Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8a/53185c69abb3bb362e8a46b8089999f820bf15655629ff8395107633c8ab/rignore-0.7.6-cp310-cp310-win_amd64.whl", hash = "sha256:d80afd6071c78baf3765ec698841071b19e41c326f994cfa69b5a1df676f5d39", size = 727001, upload-time = "2025-11-05T21:41:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/25/41/b6e2be3069ef3b7f24e35d2911bd6deb83d20ed5642ad81d5a6d1c015473/rignore-0.7.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:40be8226e12d6653abbebaffaea2885f80374c1c8f76fe5ca9e0cadd120a272c", size = 885285, upload-time = "2025-11-05T20:42:39.763Z" }, + { url = "https://files.pythonhosted.org/packages/52/66/ba7f561b6062402022887706a7f2b2c2e2e2a28f1e3839202b0a2f77e36d/rignore-0.7.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182f4e5e4064d947c756819446a7d4cdede8e756b8c81cf9e509683fe38778d7", size = 823882, upload-time = "2025-11-05T20:42:23.488Z" }, + { url = "https://files.pythonhosted.org/packages/f5/81/4087453df35a90b07370647b19017029324950c1b9137d54bf1f33843f17/rignore-0.7.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16b63047648a916a87be1e51bb5c009063f1b8b6f5afe4f04f875525507e63dc", size = 899362, upload-time = "2025-11-05T20:40:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c9/390a8fdfabb76d71416be773bd9f162977bd483084f68daf19da1dec88a6/rignore-0.7.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba5524f5178deca4d7695e936604ebc742acb8958f9395776e1fcb8133f8257a", size = 873633, upload-time = "2025-11-05T20:41:06.193Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/79404fcb0faa76edfbc9df0901f8ef18568d1104919ebbbad6d608c888d1/rignore-0.7.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62020dbb89a1dd4b84ab3d60547b3b2eb2723641d5fb198463643f71eaaed57d", size = 1167633, upload-time = "2025-11-05T20:41:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/b3466d32d445d158a0aceb80919085baaae495b1f540fb942f91d93b5e5b/rignore-0.7.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b34acd532769d5a6f153a52a98dcb81615c949ab11697ce26b2eb776af2e174d", size = 941434, upload-time = "2025-11-05T20:41:38.151Z" }, + { url = "https://files.pythonhosted.org/packages/e8/40/9cd949761a7af5bc27022a939c91ff622d29c7a0b66d0c13a863097dde2d/rignore-0.7.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c5e53b752f9de44dff7b3be3c98455ce3bf88e69d6dc0cf4f213346c5e3416c", size = 959461, upload-time = "2025-11-05T20:42:08.476Z" }, + { url = "https://files.pythonhosted.org/packages/b5/87/1e1a145731f73bdb7835e11f80da06f79a00d68b370d9a847de979575e6d/rignore-0.7.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25b3536d13a5d6409ce85f23936f044576eeebf7b6db1d078051b288410fc049", size = 985323, upload-time = "2025-11-05T20:41:52.735Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/1ecff992fc3f59c4fcdcb6c07d5f6c1e6dfb55ccda19c083aca9d86fa1c6/rignore-0.7.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6e01cad2b0b92f6b1993f29fc01f23f2d78caf4bf93b11096d28e9d578eb08ce", size = 1079173, upload-time = "2025-11-05T21:40:12.007Z" }, + { url = "https://files.pythonhosted.org/packages/17/18/162eedadb4c2282fa4c521700dbf93c9b14b8842e8354f7d72b445b8d593/rignore-0.7.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5991e46ab9b4868334c9e372ab0892b0150f3f586ff2b1e314272caeb38aaedb", size = 1139012, upload-time = "2025-11-05T21:40:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/a9ca398a8af74bb143ad66c2a31303c894111977e28b0d0eab03867f1b43/rignore-0.7.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c8ae562e5d1246cba5eaeb92a47b2a279e7637102828dde41dcbe291f529a3e", size = 1118827, upload-time = "2025-11-05T21:40:46.6Z" }, + { url = "https://files.pythonhosted.org/packages/9f/22/1c1a65047df864def9a047dbb40bc0b580b8289a4280e62779cd61ae21f2/rignore-0.7.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aaf938530dcc0b47c4cfa52807aa2e5bfd5ca6d57a621125fe293098692f6345", size = 1128182, upload-time = "2025-11-05T21:41:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f4/1526eb01fdc2235aca1fd9d0189bee4021d009a8dcb0161540238c24166e/rignore-0.7.6-cp311-cp311-win32.whl", hash = "sha256:166ebce373105dd485ec213a6a2695986346e60c94ff3d84eb532a237b24a4d5", size = 646547, upload-time = "2025-11-05T21:41:49.439Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/dda0983e1845706beb5826459781549a840fe5a7eb934abc523e8cd17814/rignore-0.7.6-cp311-cp311-win_amd64.whl", hash = "sha256:44f35ee844b1a8cea50d056e6a595190ce9d42d3cccf9f19d280ae5f3058973a", size = 727139, upload-time = "2025-11-05T21:41:34.367Z" }, + { url = "https://files.pythonhosted.org/packages/e3/47/eb1206b7bf65970d41190b879e1723fc6bbdb2d45e53565f28991a8d9d96/rignore-0.7.6-cp311-cp311-win_arm64.whl", hash = "sha256:14b58f3da4fa3d5c3fa865cab49821675371f5e979281c683e131ae29159a581", size = 657598, upload-time = "2025-11-05T21:41:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, + { url = "https://files.pythonhosted.org/packages/85/12/62d690b4644c330d7ac0f739b7f078190ab4308faa909a60842d0e4af5b2/rignore-0.7.6-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3d3a523af1cd4ed2c0cba8d277a32d329b0c96ef9901fb7ca45c8cfaccf31a5", size = 887462, upload-time = "2025-11-05T20:42:50.804Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/6528a0e97ed2bd7a7c329183367d1ffbc5b9762ae8348d88dae72cc9d1f5/rignore-0.7.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:990853566e65184a506e1e2af2d15045afad3ebaebb8859cb85b882081915110", size = 826918, upload-time = "2025-11-05T20:42:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2c/7d7bad116e09a04e9e1688c6f891fa2d4fd33f11b69ac0bd92419ddebeae/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cab9ff2e436ce7240d7ee301c8ef806ed77c1fd6b8a8239ff65f9bbbcb5b8a3", size = 900922, upload-time = "2025-11-05T20:41:00.361Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/e5ea89fbde8e37a90ce456e31c5e9d85512cef5ae38e0f4d2426eb776a19/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1a6671b2082c13bfd9a5cf4ce64670f832a6d41470556112c4ab0b6519b2fc4", size = 876987, upload-time = "2025-11-05T20:41:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fb/93d14193f0ec0c3d35b763f0a000e9780f63b2031f3d3756442c2152622d/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2468729b4c5295c199d084ab88a40afcb7c8b974276805105239c07855bbacee", size = 1171110, upload-time = "2025-11-05T20:41:32.631Z" }, + { url = "https://files.pythonhosted.org/packages/9e/46/08436312ff96ffa29cfa4e1a987efc37e094531db46ba5e9fda9bb792afd/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:775710777fd71e5fdf54df69cdc249996a1d6f447a2b5bfb86dbf033fddd9cf9", size = 943339, upload-time = "2025-11-05T20:41:47.128Z" }, + { url = "https://files.pythonhosted.org/packages/34/28/3b3c51328f505cfaf7e53f408f78a1e955d561135d02f9cb0341ea99f69a/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4565407f4a77f72cf9d91469e75d15d375f755f0a01236bb8aaa176278cc7085", size = 961680, upload-time = "2025-11-05T20:42:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9e/cbff75c8676d4f4a90bd58a1581249d255c7305141b0868f0abc0324836b/rignore-0.7.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc44c33f8fb2d5c9da748de7a6e6653a78aa740655e7409895e94a247ffa97c8", size = 987045, upload-time = "2025-11-05T20:42:02.315Z" }, + { url = "https://files.pythonhosted.org/packages/8c/25/d802d1d369502a7ddb8816059e7c79d2d913e17df975b863418e0aca4d8a/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:8f32478f05540513c11923e8838afab9efef0131d66dca7f67f0e1bbd118af6a", size = 1080310, upload-time = "2025-11-05T21:40:23.184Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/250b785c2e473b1ab763eaf2be820934c2a5409a722e94b279dddac21c7d/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:1b63a3dd76225ea35b01dd6596aa90b275b5d0f71d6dc28fce6dd295d98614aa", size = 1140998, upload-time = "2025-11-05T21:40:40.603Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d6/bb42fd2a8bba6aea327962656e20621fd495523259db40cfb4c5f760f05c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:fe6c41175c36554a4ef0994cd1b4dbd6d73156fca779066456b781707402048e", size = 1121178, upload-time = "2025-11-05T21:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/97/f4/aeb548374129dce3dc191a4bb598c944d9ed663f467b9af830315d86059c/rignore-0.7.6-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a0c6792406ae36f4e7664dc772da909451d46432ff8485774526232d4885063", size = 1130190, upload-time = "2025-11-05T21:41:16.403Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/a6250ff0c49a3cdb943910ada4116e708118e9b901c878cfae616c80a904/rignore-0.7.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a20b6fb61bcced9a83dfcca6599ad45182b06ba720cff7c8d891e5b78db5b65f", size = 886470, upload-time = "2025-11-05T20:42:52.314Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/c69c0c51b8f9f7914d95c4ea91c29a2ac067572048cae95dd6d2efdbe05d/rignore-0.7.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:392dcabfecbe176c9ebbcb40d85a5e86a5989559c4f988c2741da7daf1b5be25", size = 825976, upload-time = "2025-11-05T20:42:35.118Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d2/1b264f56132264ea609d3213ab603d6a27016b19559a1a1ede1a66a03dcd/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22baa462abdc36fdd5a5e2dae423107723351b85ff093762f9261148b9d0a04a", size = 899739, upload-time = "2025-11-05T20:41:01.518Z" }, + { url = "https://files.pythonhosted.org/packages/55/e4/b3c5dfdd8d8a10741dfe7199ef45d19a0e42d0c13aa377c83bd6caf65d90/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53fb28882d2538cb2d231972146c4927a9d9455e62b209f85d634408c4103538", size = 874843, upload-time = "2025-11-05T20:41:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/10/d6f3750233881a2a154cefc9a6a0a9b19da526b19f7f08221b552c6f827d/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87409f7eeb1103d6b77f3472a3a0d9a5953e3ae804a55080bdcb0120ee43995b", size = 1170348, upload-time = "2025-11-05T20:41:34.21Z" }, + { url = "https://files.pythonhosted.org/packages/6e/10/ad98ca05c9771c15af734cee18114a3c280914b6e34fde9ffea2e61e88aa/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684014e42e4341ab3ea23a203551857fcc03a7f8ae96ca3aefb824663f55db32", size = 942315, upload-time = "2025-11-05T20:41:48.508Z" }, + { url = "https://files.pythonhosted.org/packages/de/00/ab5c0f872acb60d534e687e629c17e0896c62da9b389c66d3aa16b817aa8/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77356ebb01ba13f8a425c3d30fcad40e57719c0e37670d022d560884a30e4767", size = 961047, upload-time = "2025-11-05T20:42:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/3030fdc363a8f0d1cd155b4c453d6db9bab47a24fcc64d03f61d9d78fe6a/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6cbd8a48abbd3747a6c830393cd578782fab5d43f4deea48c5f5e344b8fed2b0", size = 986090, upload-time = "2025-11-05T20:42:03.581Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/133aa4002cee0ebbb39362f94e4898eec7fbd09cec9fcbce1cd65b355b7f/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2673225dcec7f90497e79438c35e34638d0d0391ccea3cbb79bfb9adc0dc5bd7", size = 1079656, upload-time = "2025-11-05T21:40:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/67/56/36d5d34210e5e7dfcd134eed8335b19e80ae940ee758f493e4f2b344dd70/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:c081f17290d8a2b96052b79207622aa635686ea39d502b976836384ede3d303c", size = 1139789, upload-time = "2025-11-05T21:40:42.119Z" }, + { url = "https://files.pythonhosted.org/packages/6b/5b/bb4f9420802bf73678033a4a55ab1bede36ce2e9b41fec5f966d83d932b3/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:57e8327aacc27f921968cb2a174f9e47b084ce9a7dd0122c8132d22358f6bd79", size = 1120308, upload-time = "2025-11-05T21:40:59.402Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/2a/d225cbf87b6c8ecce5664db7bcecb82c317e448e3b24a2dcdaacb18ca9a7/sentry_sdk-2.47.0.tar.gz", hash = "sha256:8218891d5e41b4ea8d61d2aed62ed10c80e39d9f2959d6f939efbf056857e050", size = 381895, upload-time = "2025-12-03T14:06:36.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/ac/d6286ea0d49e7b58847faf67b00e56bb4ba3d525281e2ac306e1f1f353da/sentry_sdk-2.47.0-py2.py3-none-any.whl", hash = "sha256:d72f8c61025b7d1d9e52510d03a6247b280094a327dd900d987717a4fce93412", size = 411088, upload-time = "2025-12-03T14:06:35.374Z" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload-time = "2025-02-25T17:27:59.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload-time = "2025-04-19T06:02:50.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload-time = "2025-04-19T06:02:48.42Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload-time = "2025-04-08T10:36:26.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/4d/d02e6ea147bb7fff5fd109c694a95109612f419abed46548a930e7f7afa3/watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40", size = 405632, upload-time = "2025-04-08T10:34:41.832Z" }, + { url = "https://files.pythonhosted.org/packages/60/31/9ee50e29129d53a9a92ccf1d3992751dc56fc3c8f6ee721be1c7b9c81763/watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb", size = 395734, upload-time = "2025-04-08T10:34:44.236Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8c/759176c97195306f028024f878e7f1c776bda66ccc5c68fa51e699cf8f1d/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11", size = 455008, upload-time = "2025-04-08T10:34:45.617Z" }, + { url = "https://files.pythonhosted.org/packages/55/1a/5e977250c795ee79a0229e3b7f5e3a1b664e4e450756a22da84d2f4979fe/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487", size = 459029, upload-time = "2025-04-08T10:34:46.814Z" }, + { url = "https://files.pythonhosted.org/packages/e6/17/884cf039333605c1d6e296cf5be35fad0836953c3dfd2adb71b72f9dbcd0/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256", size = 488916, upload-time = "2025-04-08T10:34:48.571Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e0/bcb6e64b45837056c0a40f3a2db3ef51c2ced19fda38484fa7508e00632c/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85", size = 523763, upload-time = "2025-04-08T10:34:50.268Z" }, + { url = "https://files.pythonhosted.org/packages/24/e9/f67e9199f3bb35c1837447ecf07e9830ec00ff5d35a61e08c2cd67217949/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358", size = 502891, upload-time = "2025-04-08T10:34:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/a6cf815f215632f5c8065e9c41fe872025ffea35aa1f80499f86eae922db/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614", size = 454921, upload-time = "2025-04-08T10:34:52.67Z" }, + { url = "https://files.pythonhosted.org/packages/92/4c/e14978599b80cde8486ab5a77a821e8a982ae8e2fcb22af7b0886a033ec8/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f", size = 631422, upload-time = "2025-04-08T10:34:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1a/9263e34c3458f7614b657f974f4ee61fd72f58adce8b436e16450e054efd/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d", size = 625675, upload-time = "2025-04-08T10:34:55.173Z" }, + { url = "https://files.pythonhosted.org/packages/96/1f/1803a18bd6ab04a0766386a19bcfe64641381a04939efdaa95f0e3b0eb58/watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff", size = 277921, upload-time = "2025-04-08T10:34:56.318Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3b/29a89de074a7d6e8b4dc67c26e03d73313e4ecf0d6e97e942a65fa7c195e/watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92", size = 291526, upload-time = "2025-04-08T10:34:57.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/f4/41b591f59021786ef517e1cdc3b510383551846703e03f204827854a96f8/watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827", size = 405336, upload-time = "2025-04-08T10:34:59.359Z" }, + { url = "https://files.pythonhosted.org/packages/ae/06/93789c135be4d6d0e4f63e96eea56dc54050b243eacc28439a26482b5235/watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4", size = 395977, upload-time = "2025-04-08T10:35:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/1cd89bd83728ca37054512d4d35ab69b5f12b8aa2ac9be3b0276b3bf06cc/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d", size = 455232, upload-time = "2025-04-08T10:35:01.698Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/d8a4d44ffe960517e487c9c04f77b06b8abf05eb680bed71c82b5f2cad62/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63", size = 459151, upload-time = "2025-04-08T10:35:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/6c/da/267a1546f26465dead1719caaba3ce660657f83c9d9c052ba98fb8856e13/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418", size = 489054, upload-time = "2025-04-08T10:35:04.561Z" }, + { url = "https://files.pythonhosted.org/packages/b1/31/33850dfd5c6efb6f27d2465cc4c6b27c5a6f5ed53c6fa63b7263cf5f60f6/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9", size = 523955, upload-time = "2025-04-08T10:35:05.786Z" }, + { url = "https://files.pythonhosted.org/packages/09/84/b7d7b67856efb183a421f1416b44ca975cb2ea6c4544827955dfb01f7dc2/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6", size = 502234, upload-time = "2025-04-08T10:35:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/71/87/6dc5ec6882a2254cfdd8b0718b684504e737273903b65d7338efaba08b52/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25", size = 454750, upload-time = "2025-04-08T10:35:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6c/3786c50213451a0ad15170d091570d4a6554976cf0df19878002fc96075a/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5", size = 631591, upload-time = "2025-04-08T10:35:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/1427425ade4e359a0deacce01a47a26024b2ccdb53098f9d64d497f6684c/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01", size = 625370, upload-time = "2025-04-08T10:35:12.412Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/f60e053b0b5b8145d682672024aa91370a29c5c921a88977eb565de34086/watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246", size = 277791, upload-time = "2025-04-08T10:35:13.719Z" }, + { url = "https://files.pythonhosted.org/packages/50/ed/7603c4e164225c12c0d4e8700b64bb00e01a6c4eeea372292a3856be33a4/watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096", size = 291622, upload-time = "2025-04-08T10:35:15.071Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c2/99bb7c96b4450e36877fde33690ded286ff555b5a5c1d925855d556968a1/watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed", size = 283699, upload-time = "2025-04-08T10:35:16.732Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511, upload-time = "2025-04-08T10:35:17.956Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715, upload-time = "2025-04-08T10:35:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138, upload-time = "2025-04-08T10:35:20.586Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592, upload-time = "2025-04-08T10:35:21.87Z" }, + { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532, upload-time = "2025-04-08T10:35:23.143Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865, upload-time = "2025-04-08T10:35:24.702Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887, upload-time = "2025-04-08T10:35:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498, upload-time = "2025-04-08T10:35:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663, upload-time = "2025-04-08T10:35:28.685Z" }, + { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410, upload-time = "2025-04-08T10:35:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965, upload-time = "2025-04-08T10:35:32.023Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693, upload-time = "2025-04-08T10:35:33.225Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287, upload-time = "2025-04-08T10:35:34.568Z" }, + { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531, upload-time = "2025-04-08T10:35:35.792Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417, upload-time = "2025-04-08T10:35:37.048Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423, upload-time = "2025-04-08T10:35:38.357Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185, upload-time = "2025-04-08T10:35:39.708Z" }, + { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696, upload-time = "2025-04-08T10:35:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327, upload-time = "2025-04-08T10:35:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741, upload-time = "2025-04-08T10:35:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995, upload-time = "2025-04-08T10:35:46.336Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693, upload-time = "2025-04-08T10:35:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677, upload-time = "2025-04-08T10:35:49.65Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804, upload-time = "2025-04-08T10:35:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087, upload-time = "2025-04-08T10:35:52.458Z" }, + { url = "https://files.pythonhosted.org/packages/1a/03/81f9fcc3963b3fc415cd4b0b2b39ee8cc136c42fb10a36acf38745e9d283/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d", size = 405947, upload-time = "2025-04-08T10:36:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/54/97/8c4213a852feb64807ec1d380f42d4fc8bfaef896bdbd94318f8fd7f3e4e/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034", size = 397276, upload-time = "2025-04-08T10:36:15.131Z" }, + { url = "https://files.pythonhosted.org/packages/78/12/d4464d19860cb9672efa45eec1b08f8472c478ed67dcd30647c51ada7aef/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965", size = 455550, upload-time = "2025-04-08T10:36:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/90/fb/b07bcdf1034d8edeaef4c22f3e9e3157d37c5071b5f9492ffdfa4ad4bed7/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57", size = 455542, upload-time = "2025-04-08T10:36:18.655Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] From de3278add71bd7e66c8143b73fe7d94c448d4d07 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 12:32:58 +0300 Subject: [PATCH 014/247] [Spec 321][Phase: import_service] test: Add conftest with mock-mode defaults --- apps/whatsapp/tests/conftest.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/whatsapp/tests/conftest.py diff --git a/apps/whatsapp/tests/conftest.py b/apps/whatsapp/tests/conftest.py new file mode 100644 index 00000000..0b071026 --- /dev/null +++ b/apps/whatsapp/tests/conftest.py @@ -0,0 +1,15 @@ +import os + +# Mock mode and dummy credentials, so the suite runs without a .env or any backend; real values still win. +for key, value in { + "DEPLOYMENT_TYPE": "local", + "MOCK_META_API": "True", + "MOCK_ANSARI_CLIENT": "True", + "ALWAYS_RETURN_OK_TO_META": "False", + "WHATSAPP_SERVICE_API_KEY": "test", + "META_BUSINESS_PHONE_NUMBER_ID": "123456789", + "META_WEBHOOK_VERIFY_TOKEN": "test", + "META_ANSARI_APP_SECRET": "test", + "META_ACCESS_TOKEN_FROM_SYS_USER": "test", +}.items(): + os.environ.setdefault(key, value) From 40cd6483b91fbbad49bcbf98d66a63ac43c93967 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 12:33:10 +0300 Subject: [PATCH 015/247] chore(porch): 321 implement build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 029733e9..a06747b9 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -28,10 +28,10 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T08:16:31.278Z' +updated_at: '2026-10-25T09:33:10.559Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From 7b04c5543da459de4364937e8cfae0aa36bbd0bf Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 13:16:27 +0300 Subject: [PATCH 016/247] [Spec 321][Phase: import_service] fix: Add whatsapp CI job, declare ruff, test signature rejection --- .github/workflows/ci.yml | 27 +++++++ apps/whatsapp/pyproject.toml | 1 + apps/whatsapp/tests/test_whatsapp_service.py | 19 +++++ apps/whatsapp/uv.lock | 27 +++++++ .../321-rfc-whatsapp-support-in-the-mo.md | 2 +- .../321-import_service-iter1-claude.txt | 76 +++++++++++++++++++ 6 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-claude.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cecacd9..d0513757 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,33 @@ jobs: - name: Build run: pnpm exec turbo run build --filter ansari-frontend... + # Python WhatsApp service (spec 321). Not part of the pnpm/turbo graph, so it + # gets its own job. conftest.py supplies mock-mode defaults, so no env or + # repository secrets are needed. + whatsapp: + name: whatsapp (lint, format, test) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: apps/whatsapp + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + + - name: Install + run: uv sync --frozen --extra dev + + - name: Lint + run: uv run ruff check . + + - name: Format check + run: uv run ruff format --check . + + - name: Test + run: uv run pytest tests/ -v + gitleaks: name: gitleaks (secret scan) runs-on: ubuntu-latest diff --git a/apps/whatsapp/pyproject.toml b/apps/whatsapp/pyproject.toml index f2294a2e..2f58d49f 100644 --- a/apps/whatsapp/pyproject.toml +++ b/apps/whatsapp/pyproject.toml @@ -45,6 +45,7 @@ Tracker = "https://github.com/ansari-project/ansari-whatsapp/issues" # Install with `uv pip install -e .[dev]` for development dependencies dev = [ "fastapi[standard]>=0.95.1", + "ruff>=0.8.0", ] [tool.ruff] diff --git a/apps/whatsapp/tests/test_whatsapp_service.py b/apps/whatsapp/tests/test_whatsapp_service.py index 83487d93..5b221cc4 100644 --- a/apps/whatsapp/tests/test_whatsapp_service.py +++ b/apps/whatsapp/tests/test_whatsapp_service.py @@ -369,6 +369,25 @@ def _post_signed_webhook(settings, message: dict, contacts: list | None = None): return client.post("/whatsapp/v2", content=body_bytes, headers={"X-Hub-Signature-256": f"sha256={signature}"}) +@pytest.mark.integration +def test_webhook_rejects_missing_signature(settings): + """A webhook with no signature header is rejected.""" + response = client.post("/whatsapp/v2", content=b"{}") + + assert response.status_code == 403 + + +@pytest.mark.integration +def test_webhook_rejects_wrong_signature(settings): + """A webhook signed with the wrong secret is rejected.""" + body_bytes = b"{}" + signature = hmac.new(b"not-the-app-secret", body_bytes, hashlib.sha256).hexdigest() + + response = client.post("/whatsapp/v2", content=body_bytes, headers={"X-Hub-Signature-256": f"sha256={signature}"}) + + assert response.status_code == 403 + + @pytest.mark.integration def test_webhook_phone_hidden_user(settings): """A BSUID-only sender (no `from`) gets the phone-number notice and is not processed.""" diff --git a/apps/whatsapp/uv.lock b/apps/whatsapp/uv.lock index 9b0c3cef..1436f969 100644 --- a/apps/whatsapp/uv.lock +++ b/apps/whatsapp/uv.lock @@ -33,6 +33,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "fastapi", extra = ["standard"] }, + { name = "ruff" }, ] [package.metadata] @@ -47,6 +48,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "requests", specifier = ">=2.32.5" }, { name = "rich", specifier = ">=10.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.47.0" }, { name = "uvicorn", specifier = ">=0.22.0" }, { name = "watchfiles", specifier = ">=1.0.5" }, @@ -1019,6 +1021,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" }, ] +[[package]] +name = "ruff" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" }, + { url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" }, + { url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" }, + { url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, +] + [[package]] name = "sentry-sdk" version = "2.47.0" diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index 0896840d..a1f3f4ab 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -125,7 +125,7 @@ Finish environment behaviour and make the service testable and deployable by oth #### Files to Create / Modify -- In `apps/whatsapp`: maintenance behaviour driven by the same global flag as `apps/api` (`MAINTENANCE_MODE`) instead of the WhatsApp-specific flag, keeping the notice message; keep the staging `!d ` filter; `README.md` for the app. +- In `apps/whatsapp`: maintenance behaviour driven by the same global flag as `apps/api` (`MAINTENANCE_MODE`) instead of the WhatsApp-specific flag, keeping the notice message; keep the staging `!d ` filter; `README.md` and `tests/README.md` for the app (both still reference legacy files that were not imported). - Deployment notes: per-environment Meta credentials and secrets, and the deploy steps for staging and production, following the monorepo's existing deployment docs. #### Deliverables diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-claude.txt new file mode 100644 index 00000000..febcf7f3 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-claude.txt @@ -0,0 +1,76 @@ +I'll start by reading the key files to evaluate this phase.I read every file in scope from disk and ran the phase's acceptance checks. Note: I ran `ruff` and `pytest` as verification; these wrote only gitignored artifacts (`logs/`, `.pytest_cache/`, `tests/detailed_test_results_whatsapp_service.json`). I did not modify, create, or delete any tracked file. + +## Acceptance criteria — verified passing + +| Check | Result | +|---|---| +| `uv lock --check` | ✅ Resolved 53 packages, lock consistent with `pyproject.toml` | +| `ruff check .` | ✅ All checks passed | +| `ruff format --check .` | ✅ 28 files already formatted | +| `pytest tests/ -q` | ✅ 5 passed in 8.62s | +| `.env.example` has no secrets | ✅ placeholders only (`your_app_secret_here`, `<>`) | +| Excluded artifacts | ✅ no `zrok.exe`, no `logs/`, no `tmp/`, no personal scripts beyond `clean_logs.sh` — app-level `.gitignore` covers all of them | +| Existing monorepo unaffected | ✅ `pnpm-workspace.yaml` globs `apps/*`; pnpm skips a directory with no `package.json`, so install/build/test are untouched | + +The `conftest.py` addition is a good call — `os.environ.setdefault` makes the suite runnable with no `.env` while letting real values win, and it documents that intent in one line. + +## Must fix + +**1. CI wiring is a named phase-1 plan item that was silently skipped.** + +The plan's phase-1 *Files to Create / Modify* says: *"Monorepo wiring only where needed (workspace and CI entries), following how other apps are registered."* `.github/workflows/ci.yml` is untouched and has no `whatsapp` job — the file list confirms it. No later phase claims CI either: phase 4 owns maintenance mode, the `!d ` filter, README and deploy notes, not workflows. + +Consequence: everything I verified above passes on *this* machine and nowhere else. `ruff check`, `ruff format --check` and `pytest` for `apps/whatsapp` will never run on any PR, and phases 2–4 add the substantive logic (auth exchange, thread lifecycle, maintenance mode) whose tests would be equally invisible. This is precisely the pattern `codev/resources/lessons-critical.md` warns about — *"Prefer failures that are loud over checks that are quiet"* — and the cost of deferring compounds with each phase. + +Related and part of the same fix: **`ruff` is not a declared dependency.** It appears in `[tool.ruff]` config but is absent from `dependencies` and from `[project.optional-dependencies].dev` (which holds only `fastapi[standard]`). I had to run it from a globally installed copy. A CI step would have no pinned version. Add it to the dev extras (or a `[dependency-groups]` block) and relock. + +Suggested job, mirroring the pinning discipline of the existing ones (`astral-sh/setup-uv` pinned to a full SHA with a trailing version comment): + +```yaml + whatsapp: + name: whatsapp (lint, format, test) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: apps/whatsapp + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@ # vX.Y.Z + - run: uv sync --frozen + - run: uv run ruff check . + - run: uv run ruff format --check . + # conftest.py supplies mock-mode defaults, so no env/secrets are needed. + - run: uv run pytest tests/ -v +``` + +Please prove it both directions before calling it done — confirm the job fails on a deliberately broken test/format, and goes green again when restored. A job that passes by not running is worse than no job. + +## Comments (non-blocking) + +**2. `README.md` and `tests/README.md` carry dangling legacy references.** `README.md` points at `setup.sh` / `setup.bat`, `git clone … && cd ansari-whatsapp`, `CLAUDE.md`, `docs/hld/architecture.md`, `docs/lld/implementation_guide.md`, `docs/lld/aws/README.md`, `docs/lld/github_actions/README.md`, `docs/whatsapp_migration_plan/migration_plan.md` — none of which were imported. Its env table also lists `HOST`, `PORT` and `DEV_MODE` (absent from `config.py`) and gives `META_API_VERSION` default `v21.0` where `config.py:51` says `v22.0`. + +Phase 4 owns the README rewrite, so this is scheduled rather than missing. But `tests/README.md` is *not* named in any phase's file list and has the same problem (lines 7, 72–74, 88, 112, 147, 155–156, 175 reference `../ansari-backend`). Add it to phase 4's doc scope so the fix lands everywhere, not partially — a half-fixed doc set that disagrees with itself is worse than the original. + +**3. `app_logger.py:40` — `os.makedirs("logs", exist_ok=True)` at import, CWD-relative, feeding a sink that is commented out.** The only consumer (the file handler at lines 173–188) is disabled, so this creates an empty directory for nothing. Root `.gitignore` has no `logs/` entry, so running anything from the repo root leaves an untracked `logs/` at the monorepo root. Cheap to delete, or move it inside the commented block so the two travel together. + +**4. Phone numbers and message bodies reach the logs and Sentry.** `whatsapp_webhook_utils.py` lines 175, 182 and 205 raise with the full webhook body interpolated, and line 236 logs `f"Received a supported whatsapp message from {user_whatsapp_number}: {incoming_msg_body}"`. `main.py:323–332` routes the resulting exception to `sentry_sdk.capture_exception`. Correctly out of scope for an as-is import, and phase 2's deliverable already says *"Logs and errors contain no phone numbers"* — I flag it only so that work remembers the `_redact_for_logging` helper already sitting at line 141 is the tool for exactly these four sites. + +**5. The spec's signature-rejection scenario has no owner.** *"Invalid or missing Meta signature: rejected"* is in the spec's Test Scenarios, but no test covers it and no later phase's file list claims it. `verify_meta_signature` is correct (it uses `hmac.compare_digest`), and the helper `_post_signed_webhook` you added at line 354 makes the negative case a three-line test — worth claiming now rather than letting it fall between phases. + +**6. No `codev/state/spir-321_thread.md`.** CLAUDE.md asks each builder to keep one. It is also how the next reviewer distinguishes "deferred on purpose" from "forgotten" — had a thread entry said CI was deliberately pushed to a follow-up, item 1 would read differently. + +**7. Trivia.** `main.py:156` annotates `verification_webhook -> str | None` but returns `HTMLResponse` (harmless — FastAPI skips response validation for `Response` returns). And confirm `clean_logs.sh` is genuinely wanted, since the plan excluded "personal scripts." + +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Import is clean and all stated acceptance checks pass locally, but the phase's named CI-wiring plan item was skipped, so the suite runs nowhere but the builder's machine. +CONFIDENCE: HIGH +--- + +KEY_ISSUES: +- No CI job for `apps/whatsapp`; `.github/workflows/ci.yml` untouched though phase 1 explicitly lists "CI entries", and no later phase claims it — ruff/pytest never run on any PR, and phases 2–4 inherit the blind spot +- `ruff` is configured in `pyproject.toml` but not declared as a dependency, so there is no pinned version for CI to use +- `README.md` and `tests/README.md` reference ~10 files that do not exist in the monorepo; `tests/README.md` is unowned by any phase (README rewrite is scheduled for phase 4 — add tests/README.md to it) +- Spec test scenario "invalid or missing Meta signature: rejected" has no test and no owning phase +- Non-blocking: `app_logger.py:40` creates a CWD-relative `logs/` for a disabled sink; phone numbers/message bodies logged and sent to Sentry (phase 2 scope); no builder thread log \ No newline at end of file From 241de4ca7ca5720d5e391fd8c62fdd92b2b6e01e Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 13:16:57 +0300 Subject: [PATCH 017/247] [Spec 321][Phase: import_service] docs: Rebuttal for iteration 1 review --- .../321-import_service-iter1-rebuttals.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-rebuttals.md diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-rebuttals.md new file mode 100644 index 00000000..e3cc786b --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter1-rebuttals.md @@ -0,0 +1,33 @@ +# Rebuttal: import_service, iteration 1 + +Review: `321-import_service-iter1-claude.txt` (REQUEST_CHANGES). All must-fix points are accepted and fixed in commit `7b04c55`. + +## Must fix + +**1. No CI job for `apps/whatsapp`. Accepted, fixed.** +Added a `whatsapp` job to `.github/workflows/ci.yml` (ruff check, ruff format check, pytest, working directory `apps/whatsapp`). `astral-sh/setup-uv` is pinned to a full commit SHA with a version comment (`07a047e6…`, v10.3.2, resolved through the GitHub API), matching the other jobs. No secrets are used because `conftest.py` supplies mock-mode defaults. +The review asked to prove the check both ways. Locally, using the exact CI steps (`uv sync --frozen --extra dev`, with no environment variables set): +- Green: ruff check, ruff format check and 7 tests pass. +- Red: a deliberately wrong assertion makes pytest fail (exit 1), and deliberately bad formatting makes `ruff format --check` fail (exit 1). Both were restored afterwards and the tree was clean. +Not proven: the job has not run on a GitHub Linux runner, because it can only run when the PR is opened. The first PR run is its first real test. + +**2. `ruff` is not a declared dependency. Accepted, fixed.** +Added `ruff>=0.8.0` to the `dev` extra in `pyproject.toml` and relocked (`ruff==0.17.2`). CI installs it with `--extra dev` and uses the locked version. + +## Comments + +**3. Dangling references in `README.md` and `tests/README.md`. Accepted, scheduled.** +Phase 4 already owns the README rewrite. `tests/README.md` was missing from it, so it is now named in Phase 4's file list in the plan. + +**4. `logs/` created at import in `app_logger.py`. Not changed.** +Deferred as the review suggests, to avoid changing imported code in an as-is phase. + +**5. Phone numbers and message bodies in logs and Sentry. Not changed here.** +Already a Phase 2 deliverable ("Logs and errors contain no phone numbers"). The existing redaction helper noted by the review will be used then. + +**6. Missing test for signature rejection. Accepted, fixed.** +Added `test_webhook_rejects_missing_signature` and `test_webhook_rejects_wrong_signature` (both expect 403). Shown to fail when the assertion is wrong. + +## Environment note + +On this Windows machine porch's default whole-monorepo test check cannot pass, so the local-only `.codev/config.json` narrows it to `apps/whatsapp`. CI on Linux still runs the full suites for the other apps. From 2dbe2a755a1da785a707d84d9a1b916d8ebedea8 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 13:17:13 +0300 Subject: [PATCH 018/247] chore(porch): 321 implement re-iter (iter 2) --- .../status.yaml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index a06747b9..a67b8c30 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -27,11 +27,19 @@ gates: status: pending verify-approval: status: pending -iteration: 1 -build_complete: true -history: [] +iteration: 2 +build_complete: false +history: + - iteration: 1 + plan_phase: import_service + build_output: '' + reviews: + - model: claude + verdict: REQUEST_CHANGES + file: >- + D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T09:33:10.559Z' +updated_at: '2026-10-25T10:17:13.069Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From 60dbb968b4575ff575cef0041ac6ff1617b4f26e Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 13:17:34 +0300 Subject: [PATCH 019/247] chore(porch): 321 implement build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index a67b8c30..c6fd831e 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 2 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T10:17:13.069Z' +updated_at: '2026-10-25T10:17:33.965Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From 0bdb12436a0ae4f3ecebda60001c3272c8ce4878 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 13:21:21 +0300 Subject: [PATCH 020/247] [Spec 321][Phase: import_service] docs: Record review notes, fix conftest comment --- apps/whatsapp/tests/conftest.py | 2 +- .../321-rfc-whatsapp-support-in-the-mo.md | 1 + .../321-import_service-iter2-claude.txt | 39 ++++++++++++++++ .../321-import_service-iter2-context.md | 46 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-claude.txt create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-context.md diff --git a/apps/whatsapp/tests/conftest.py b/apps/whatsapp/tests/conftest.py index 0b071026..cac62733 100644 --- a/apps/whatsapp/tests/conftest.py +++ b/apps/whatsapp/tests/conftest.py @@ -1,6 +1,6 @@ import os -# Mock mode and dummy credentials, so the suite runs without a .env or any backend; real values still win. +# Mock mode and dummy credentials so the suite runs with no backend. Exported env vars win, but a .env value does not. for key, value in { "DEPLOYMENT_TYPE": "local", "MOCK_META_API": "True", diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index a1f3f4ab..da95274c 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -127,6 +127,7 @@ Finish environment behaviour and make the service testable and deployable by oth - In `apps/whatsapp`: maintenance behaviour driven by the same global flag as `apps/api` (`MAINTENANCE_MODE`) instead of the WhatsApp-specific flag, keeping the notice message; keep the staging `!d ` filter; `README.md` and `tests/README.md` for the app (both still reference legacy files that were not imported). - Deployment notes: per-environment Meta credentials and secrets, and the deploy steps for staging and production, following the monorepo's existing deployment docs. +- Deploy-path items found in the Phase 1 review: bump the uv version in the `Dockerfile` (it pins 0.5.11 against a newer lockfile format) and make its second `uv sync` use `--frozen`; decide whether to keep `/sentry-debug` and `clean_logs.sh`; ask an admin to make the `whatsapp` CI job a required check on `develop`. #### Deliverables diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-claude.txt new file mode 100644 index 00000000..9c075113 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-claude.txt @@ -0,0 +1,39 @@ +I'll review the implementation by reading the actual files.I read every changed source file, the CI workflow, the lockfile, the configs, and the git history for this branch. I did not modify anything in the tree and did not run `uv sync`/`pytest` (both write into the worktree the builder is live in). + +## Iteration 1 blocker — verified fixed + +The one must-fix from iteration 1 was that the suite ran nowhere but the builder's machine. That is genuinely resolved, and I checked each part rather than taking the rebuttal's word: + +- `.github/workflows/ci.yml:183-205` adds a `whatsapp` job with `defaults.run.working-directory: apps/whatsapp`, running `uv sync --frozen --extra dev`, `ruff check .`, `ruff format --check .`, `pytest tests/ -v`. `astral-sh/setup-uv` is pinned to a full SHA with a version comment, matching the repo's stated convention in the header comment at line 13. +- `ruff>=0.8.0` is in `pyproject.toml`'s `dev` extra (line 48), and `uv.lock` carries it both as a `package.metadata` requirement (line 51, `marker = "extra == 'dev'"`) and as a resolved package at `0.17.2` (line 1025). So `uv sync --frozen --extra dev` will not fail the "lockfile not up to date" check, and `uv run ruff` will resolve. +- The new signature tests (`test_whatsapp_service.py:372-388`) are real assertions (`assert response.status_code == 403`), not the `assert True` pattern the imported suite uses. Both paths through `verify_meta_signature` are covered: the missing/malformed-prefix branch (`whatsapp_webhook_utils.py:49`) and the `compare_digest` mismatch branch (line 68). +- No secrets: `conftest.py` supplies mock-mode defaults, so the job needs no repository secrets, consistent with the rest of the workflow. + +Phase 1's other acceptance criteria check out: `.env.example` contains only placeholders (the one real-looking number, `871020755148175`, is a public Meta app id in a URL); `git ls-files apps/whatsapp` is 35 files with no `logs/`, no `tmp/`, no `zrok.exe`; and nothing touches `turbo.json`, `pnpm-workspace.yaml`, or root `package.json`, so the existing graph is unaffected (`apps/whatsapp` has no `package.json`, so pnpm's `apps/*` glob skips it). + +## Comments (none blocking) + +**1. The Dockerfile pins uv 0.5.11 against a `revision = 3` lockfile.** `Dockerfile:9` copies `ghcr.io/astral-sh/uv:0.5.11`, but `uv.lock:1-2` is `version = 1 / revision = 3` — a lockfile format written by a far newer uv. The `revision` key did not exist in 0.5.x. I could not prove the failure by reading alone (it depends on whether uv 0.5.11's deserializer rejects unknown keys), and nothing in this PR builds the image, so the question is open rather than settled. It's a one-line fix to remove the doubt: bump the tag to a uv that understands revision 3. Separately, `Dockerfile:44`'s second `uv sync` has no `--frozen`, so a deploy can silently resolve away from the lock that CI tested. Both are deploy-path concerns that Phase 4 owns — but Phase 1 lists the Dockerfile in its file set, so it's worth either fixing now or writing down as a named Phase 4 item. + +**2. `conftest.py`'s comment overstates what `setdefault` preserves.** "real values still win" holds for exported environment variables, but not for a developer's `.env` — pydantic-settings ranks environment variables *above* dotenv values, and `os.environ.setdefault` writes environment variables. So a developer who sets `MOCK_ANSARI_CLIENT=False` in `.env` to exercise a real backend silently gets the mock instead, which contradicts both `README.md:152` and the module docstring at `test_whatsapp_service.py:15-18`. Either reword the comment or gate the defaults on `.env` being absent. + +**3. `test_webhook_message_basic` effectively asserts only "HTTP 200".** At lines 302-318 both the `success and "processed successfully"` branch and the `else` branch call `assert True`. It has more teeth than it looks — Starlette runs `BackgroundTasks` before `TestClient` returns, so an exception in `handle_text_message` would surface — but it will not catch a response that comes back `success: False` with a 200. Since `ALWAYS_RETURN_OK_TO_META` is `False` under test, asserting `response.json()["success"] is True` costs one line and makes the happy path actually tested. This is imported legacy test style, so I'd leave it to whenever that suite is rewritten rather than ask for it now. + +**4. `/sentry-debug` (`main.py:111-113`) is unauthenticated and present in every environment.** Deliberate legacy behaviour for verifying Sentry wiring, and harmless beyond a 500, but worth a note before this reaches production in Phase 4. + +**5. `clean_logs.sh` looks like the kind of personal script Phase 1 said to exclude** ("without its logs, `tmp/` files, personal scripts..."). Minor — flagging only because it's the one file in the import that the plan's exclusion list arguably covers. + +**6. The `whatsapp` check is not a required check on `develop`.** The workflow header notes branch protection is an admin-coordinated change (lines 113-120). The job will run on PRs, but nothing blocks a merge on it failing until someone makes it required. Worth surfacing to the human rather than fixing in code. + +I did not re-raise the `logs/` creation at import (`app_logger.py:40`) or the phone numbers in logs — both are correctly deferred, and `logs/` is covered by `apps/whatsapp/.gitignore:8`. The dangling `README.md` references are confirmed scheduled: commit `7b04c55` adds `tests/README.md` to Phase 4's file list in the plan. + +--- +VERDICT: APPROVE +SUMMARY: The iteration-1 CI blocker is genuinely fixed and verified end to end (job wired, ruff declared and locked, signature tests assert properly); remaining findings are deploy-path and test-style notes that belong to Phase 4 or are legacy-as-imported. +CONFIDENCE: HIGH +--- + +KEY_ISSUES: +- None blocking. Non-blocking: Dockerfile pins uv 0.5.11 against a `revision = 3` lockfile and its second `uv sync` omits `--frozen` — fix now or name it explicitly as a Phase 4 item. +- Non-blocking: `conftest.py`'s "real values still win" comment is wrong for `.env` values (env vars outrank dotenv in pydantic-settings), so a dev's `MOCK_ANSARI_CLIENT=False` is silently ignored. +- Non-blocking: `test_webhook_message_basic` asserts only HTTP 200; both branches end in `assert True`, so a `success: False` 200 would pass. \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-context.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-context.md new file mode 100644 index 00000000..754b391e --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-import_service-iter2-context.md @@ -0,0 +1,46 @@ +### Iteration 1 Reviews +- claude: REQUEST_CHANGES — Import is clean and all stated acceptance checks pass locally, but the phase's named CI-wiring plan item was skipped, so the suite runs nowhere but the builder's machine. + +### Builder Response to Iteration 1 +# Rebuttal: import_service, iteration 1 + +Review: `321-import_service-iter1-claude.txt` (REQUEST_CHANGES). All must-fix points are accepted and fixed in commit `7b04c55`. + +## Must fix + +**1. No CI job for `apps/whatsapp`. Accepted, fixed.** +Added a `whatsapp` job to `.github/workflows/ci.yml` (ruff check, ruff format check, pytest, working directory `apps/whatsapp`). `astral-sh/setup-uv` is pinned to a full commit SHA with a version comment (`07a047e6…`, v10.3.2, resolved through the GitHub API), matching the other jobs. No secrets are used because `conftest.py` supplies mock-mode defaults. +The review asked to prove the check both ways. Locally, using the exact CI steps (`uv sync --frozen --extra dev`, with no environment variables set): +- Green: ruff check, ruff format check and 7 tests pass. +- Red: a deliberately wrong assertion makes pytest fail (exit 1), and deliberately bad formatting makes `ruff format --check` fail (exit 1). Both were restored afterwards and the tree was clean. +Not proven: the job has not run on a GitHub Linux runner, because it can only run when the PR is opened. The first PR run is its first real test. + +**2. `ruff` is not a declared dependency. Accepted, fixed.** +Added `ruff>=0.8.0` to the `dev` extra in `pyproject.toml` and relocked (`ruff==0.17.2`). CI installs it with `--extra dev` and uses the locked version. + +## Comments + +**3. Dangling references in `README.md` and `tests/README.md`. Accepted, scheduled.** +Phase 4 already owns the README rewrite. `tests/README.md` was missing from it, so it is now named in Phase 4's file list in the plan. + +**4. `logs/` created at import in `app_logger.py`. Not changed.** +Deferred as the review suggests, to avoid changing imported code in an as-is phase. + +**5. Phone numbers and message bodies in logs and Sentry. Not changed here.** +Already a Phase 2 deliverable ("Logs and errors contain no phone numbers"). The existing redaction helper noted by the review will be used then. + +**6. Missing test for signature rejection. Accepted, fixed.** +Added `test_webhook_rejects_missing_signature` and `test_webhook_rejects_wrong_signature` (both expect 403). Shown to fail when the assertion is wrong. + +## Environment note + +On this Windows machine porch's default whole-monorepo test check cannot pass, so the local-only `.codev/config.json` narrows it to `apps/whatsapp`. CI on Linux still runs the full suites for the other apps. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration From c816219e0f25044e18559f459316323dbe04e3f6 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 13:21:36 +0300 Subject: [PATCH 021/247] =?UTF-8?q?chore(porch):=20321=20advance=20plan=20?= =?UTF-8?q?phase=20=E2=86=92=20auth=5Ftoken=5Fexchange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../321-rfc-whatsapp-support-in-the-mo/status.yaml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index c6fd831e..abde2b2a 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -5,17 +5,17 @@ phase: implement plan_phases: - id: import_service title: Import the Python service as is - status: in_progress + status: complete - id: auth_token_exchange title: Replace legacy user handling with the apps/auth token exchange - status: pending + status: in_progress - id: thread_lifecycle title: One thread per user with expiry status: pending - id: environments_and_docs title: Maintenance mode, staging filter, local testing and docs status: pending -current_plan_phase: import_service +current_plan_phase: auth_token_exchange gates: spec-approval: status: approved @@ -27,8 +27,8 @@ gates: status: pending verify-approval: status: pending -iteration: 2 -build_complete: true +iteration: 1 +build_complete: false history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T10:17:33.965Z' +updated_at: '2026-10-25T10:21:36.093Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -47,3 +47,5 @@ context_refreshes: - boundary: enter:implement at: '2026-10-25T08:16:19.523Z' acknowledged_at: '2026-10-25T08:16:31.277Z' + - boundary: plan-phase:auth_token_exchange + at: '2026-10-25T10:21:36.093Z' From 5f1ebf355ef90a25b8408f9be889fc35cb5f2222 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 17:49:02 +0300 Subject: [PATCH 022/247] [Spec 321][Phase: auth_token_exchange] feat: Identify users by BSUID through apps/auth and call apps/api with their JWT --- apps/whatsapp/.env.example | 33 +-- apps/whatsapp/src/ansari_whatsapp/app/main.py | 41 +-- .../services/ansari_client_base.py | 68 +---- .../services/ansari_client_mock.py | 186 +++---------- .../services/ansari_client_real.py | 184 ++++--------- .../services/auth_client_base.py | 23 ++ .../services/auth_client_mock.py | 23 ++ .../services/auth_client_real.py | 48 ++++ .../services/meta_api_service_mock.py | 7 +- .../services/meta_api_service_real.py | 9 +- .../services/service_provider.py | 77 +++--- .../services/whatsapp_conversation_manager.py | 127 ++++----- .../src/ansari_whatsapp/utils/config.py | 8 +- .../src/ansari_whatsapp/utils/exceptions.py | 26 +- .../src/ansari_whatsapp/utils/token_utils.py | 21 ++ .../utils/whatsapp_webhook_utils.py | 29 +- apps/whatsapp/tests/conftest.py | 3 +- apps/whatsapp/tests/test_bsuid_auth_flow.py | 247 ++++++++++++++++++ apps/whatsapp/tests/test_whatsapp_service.py | 7 +- 19 files changed, 611 insertions(+), 556 deletions(-) create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/auth_client_base.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/services/auth_client_real.py create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py create mode 100644 apps/whatsapp/tests/test_bsuid_auth_flow.py diff --git a/apps/whatsapp/.env.example b/apps/whatsapp/.env.example index 84a859a9..c79bdaa3 100644 --- a/apps/whatsapp/.env.example +++ b/apps/whatsapp/.env.example @@ -12,22 +12,19 @@ DEPLOYMENT_TYPE="local" # Leave empty or omit to disable Sentry error tracking SENTRY_DSN="" -###################################### Related to ansari-backend ###################################### -# ansari-backend's base URL (WITHOUT /api/v2 suffix) -# The Ansari client will append /whatsapp/v2/* endpoints to this base URL -# if local: "http://localhost:8000" -# if staging: "https://staging-api.ansari.chat" -# if production: "https://api.ansari.chat" -BACKEND_SERVER_URL="http://localhost:8000" - -# Shared API key for authenticating requests from ansari-whatsapp to ansari-backend -# References: -# - https://fastapi.tiangolo.com/tutorial/security/ -# - https://fastapi.tiangolo.com/advanced/security/http-basic-auth/ -# - https://www.python-httpx.org/advanced/#client-instances -# Security: Sent in X-Whatsapp-Api-Key header for service-to-service authentication -# IMPORTANT: This value MUST match WHATSAPP_SERVICE_API_KEY in ansari-backend's .env file -WHATSAPP_SERVICE_API_KEY="your_generated_secret_key_here" +###################################### Related to apps/api and apps/auth ###################################### +# apps/api's base URL (WITHOUT any path). The client appends /api/v2/* to it. +# if local: "http://localhost:3000" +BACKEND_SERVER_URL="http://localhost:3000" + +# apps/auth's base URL (WITHOUT any path). The auth client appends /api/whatsapp/session to it. +# if local: "http://localhost:3100" +AUTH_SERVER_URL="http://localhost:3100" + +# API key that authenticates this service to apps/auth's /whatsapp endpoint. +# Sent in the X-Whatsapp-Api-Key header. MUST match the key configured in apps/auth. +# NOTE: the variable name is assumed until apps/auth's /whatsapp contract is final. +AUTH_WHATSAPP_API_KEY="your_generated_secret_key_here" ###################################### Related to CORS ###################################### @@ -164,6 +161,10 @@ LOG_TEST_FILES_ONLY="False" # When False (default), the real client makes actual HTTP requests to BACKEND_SERVER_URL MOCK_ANSARI_CLIENT="False" +# If True, use a mock apps/auth client that issues fake tokens instead of calling apps/auth. +# Useful while apps/auth's /whatsapp endpoint is not available. When False (default), the real call is made. +MOCK_AUTH_CLIENT="False" + # If True, use mock Meta API service instead of making real HTTP calls to Meta WhatsApp API # Mock mode is useful for: # - Development when Meta API credentials are not configured or invalid diff --git a/apps/whatsapp/src/ansari_whatsapp/app/main.py b/apps/whatsapp/src/ansari_whatsapp/app/main.py index 45b1dbfa..abf0ce98 100644 --- a/apps/whatsapp/src/ansari_whatsapp/app/main.py +++ b/apps/whatsapp/src/ansari_whatsapp/app/main.py @@ -31,7 +31,7 @@ from ansari_whatsapp.utils.app_logger import logger, request_id_var, user_id_var from ansari_whatsapp.utils.time_utils import is_message_too_old from ansari_whatsapp.utils.config import get_settings -from ansari_whatsapp.utils.exceptions import PhoneHiddenUserError, UnexpectedMetaPayloadError +from ansari_whatsapp.utils.exceptions import UnexpectedMetaPayloadError from ansari_whatsapp.utils.general_helpers import CORSMiddlewareWithLogging @@ -198,7 +198,7 @@ async def main_webhook( **Processing Flow:** 1. **Verify Signature**: Validates X-Hub-Signature-256 header to ensure request is from Meta 2. **Extract Message Details**: Parses the webhook payload to extract relevant information - (sender phone number, message type, message body, timestamps, etc.) + (sender BSUID, message type, message body, timestamps, etc.) 3. **Validation Checks** (returns early if conditions met): - Verifies the webhook is for our WhatsApp business number @@ -206,7 +206,7 @@ async def main_webhook( - Checks if service is under maintenance - Handles staging/local development routing (temporary workaround) - Validates message age (rejects messages older than configured threshold) - - Verifies user registration status + - Authenticates the user through apps/auth - Filters unsupported message types (non-text messages) 4. **Background Processing**: @@ -219,7 +219,7 @@ async def main_webhook( - Maintenance mode: Sends maintenance message and returns 200 - Staging filter: Returns 200 if message prefixed with "!d" in staging - Old message: Returns 200 after notifying user - - Registration failure: Returns 500 error response + - Authentication failure: Returns 500 error response - Unsupported media: Sends unsupported message and returns 200 **Meta Webhook Compliance:** @@ -254,7 +254,7 @@ async def main_webhook( ( is_status, is_target_business_number, - from_whatsapp_number, + bsuid, incoming_msg_type, incoming_msg_body, message_id, @@ -278,22 +278,7 @@ async def main_webhook( success=True, message="Status message processed (ignored)", error_code="STATUS_MESSAGE" ) - logger.debug(f"Incoming whatsapp webhook message from {from_whatsapp_number}") - except PhoneHiddenUserError as e: - logger.warning(f"{e}; sending a notice instead of processing the message") - notifier = WhatsAppConversationManager(user_phone_num=e.bsuid) - background_tasks.add_task( - notifier.send_whatsapp_message, - "Apologies, but Ansari is currently running an old implementation which requires you to have " - "your phone number sent to it (not just your username), so either adjust your WhatsApp privacy " - "settings or wait for our new Ansari release, which will take some time.", - ) - return create_response_for_meta( - success=False, - message="Sender did not share a phone number", - status_code=422, - error_code="PHONE_HIDDEN_USER", - ) + logger.debug("Incoming whatsapp webhook message") except UnexpectedMetaPayloadError as e: logger.error(f"Unexpected Meta payload format: {e}") sentry_sdk.set_tag("error_type", "unexpected_meta_payload") @@ -305,7 +290,7 @@ async def main_webhook( ) sentry_sdk.capture_exception(e) if e.reply_to: - notifier = WhatsAppConversationManager(user_phone_num=e.reply_to) + notifier = WhatsAppConversationManager(bsuid=e.reply_to) background_tasks.add_task( notifier.send_whatsapp_message, "It seems Meta sent your message in an unexpected format. This is not a problem in what you wrote, " @@ -340,7 +325,7 @@ async def main_webhook( # Create a user-specific conversation manager instance for this request conversation_manager = WhatsAppConversationManager( - user_phone_num=from_whatsapp_number, + bsuid=bsuid, incoming_msg_type=incoming_msg_type, incoming_msg_body=incoming_msg_body, message_id=message_id, @@ -383,17 +368,17 @@ async def main_webhook( success=False, message="Message too old, notified user", status_code=422, error_code="MESSAGE_TOO_OLD" ) - # Retrieve existing user or register new user and get their user_id - user_id = await conversation_manager.retrieve_or_register_user() + # Exchange the sender's BSUID for a JWT with apps/auth and get their user_id (GUID) + user_id = await conversation_manager.authenticate_user() if user_id is None: - # User retrieval/registration failed + # Token exchange failed background_tasks.add_task( conversation_manager.send_whatsapp_message, - "Sorry, we couldn't register you to our database. Please try again later.", + "Sorry, we couldn't sign you in. Please try again later.", ) return create_response_for_meta( - success=False, message="User retrieval/registration failed", status_code=500, error_code="USER_REGISTRATION_FAILED" + success=False, message="User authentication failed", status_code=500, error_code="USER_AUTHENTICATION_FAILED" ) # Set user_id in contextvar for all subsequent logs (including background tasks) diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py index 25da0244..59c40c67 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py @@ -12,42 +12,11 @@ class AnsariClientBase(ABC): """ @abstractmethod - async def register_user(self, phone_num: str, preferred_language: str) -> dict: - """Register a new WhatsApp user with the Ansari backend. + async def create_thread(self, token: str, title: str) -> dict: + """Create a new thread for the user that this JWT belongs to. Args: - phone_num (str): The user's WhatsApp phone number. - preferred_language (str): The user's preferred language. - - Returns: - dict: The registration result with format {"status": "success", "user_id": "..."} - - Raises: - UserRegistrationError: If the registration fails. - """ - pass - - @abstractmethod - async def check_user_exists(self, phone_num: str) -> str: - """Get user ID for a WhatsApp phone number. - - Args: - phone_num (str): The user's WhatsApp phone number. - - Returns: - str: The user's ID - - Raises: - UserExistsCheckError: If the user is not found or the check fails. - """ - pass - - @abstractmethod - async def create_thread(self, phone_num: str, title: str) -> dict: - """Create a new thread for a WhatsApp user in the Ansari backend. - - Args: - phone_num (str): The user's WhatsApp phone number. + token (str): The user's JWT from apps/auth. title (str): The title of the thread. Returns: @@ -59,30 +28,15 @@ async def create_thread(self, phone_num: str, title: str) -> dict: pass @abstractmethod - async def get_thread_history(self, phone_num: str, thread_id: str) -> dict: - """Get the message history for a WhatsApp user's thread from the Ansari backend. - - Args: - phone_num (str): The user's WhatsApp phone number. - thread_id (str): The ID of the thread. - - Returns: - dict: The thread history with messages. - - Raises: - ThreadHistoryError: If retrieving thread history fails. - """ - pass - - @abstractmethod - async def get_last_thread_info(self, phone_num: str) -> dict: - """Get information about the last active thread for a WhatsApp user. + async def get_last_thread_info(self, token: str) -> dict: + """Get information about the user's most recently active thread. Args: - phone_num (str): The user's WhatsApp phone number. + token (str): The user's JWT from apps/auth. Returns: - dict: The thread info with format {"thread_id": "...", "last_message_time": "..."} + dict: The thread info with format {"thread_id": "...", "last_message_time": "..."}; + both values are None if the user has no thread yet. Raises: ThreadInfoError: If retrieving thread info fails. @@ -90,11 +44,11 @@ async def get_last_thread_info(self, phone_num: str) -> dict: pass @abstractmethod - async def process_message(self, phone_num: str, thread_id: str, message: str) -> str: - """Process a message from a WhatsApp user with the Ansari backend. + async def process_message(self, token: str, thread_id: str, message: str) -> str: + """Send a message to apps/api and collect the full streamed answer. Args: - phone_num (str): The user's WhatsApp phone number. + token (str): The user's JWT from apps/auth. thread_id (str): The ID of the thread. message (str): The message to process. diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py index daeda801..861ce036 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py @@ -1,5 +1,5 @@ # Mock API Client for ansari-whatsapp -"""Mock client implementation that simulates the Ansari backend without making real HTTP requests.""" +"""Mock client implementation that simulates apps/api without making real HTTP requests.""" import random import asyncio @@ -11,13 +11,11 @@ from ansari_whatsapp.utils.config import get_settings from ansari_whatsapp.utils.exceptions import ( - UserRegistrationError, - UserExistsCheckError, ThreadCreationError, - ThreadHistoryError, ThreadInfoError, MessageProcessingError, ) +from ansari_whatsapp.utils.token_utils import get_subject from ansari_whatsapp.services.ansari_client_base import AnsariClientBase @@ -34,38 +32,20 @@ def simulate_backend_behavior( Returns: Decorator function that wraps the target function with simulated backend behavior - - Example: - @simulate_backend_behavior() # Use all defaults - async def my_function(): ... - - @simulate_backend_behavior(min_latency_s=100, max_latency_s=500, error_rate=0.05) - async def my_function(): ... """ def decorator(func: Callable) -> Callable: @wraps(func) async def wrapper(*args, **kwargs): - # Simulate network latency - delay = random.uniform(min_latency_s, max_latency_s) - await asyncio.sleep(delay) + await asyncio.sleep(random.uniform(min_latency_s, max_latency_s)) - # Simulate errors if configured if error_rate > 0 and random.random() < error_rate: logger.warning(f"Mock client simulating error for {func.__name__}") - # Use provided error_class or auto-detect from function name exception_class = error_class if exception_class is None: - # Auto-detect appropriate error class from function name - if "register" in func.__name__: - exception_class = UserRegistrationError - elif "exists" in func.__name__: - exception_class = UserExistsCheckError - elif "thread" in func.__name__ and "create" in func.__name__: + if "create" in func.__name__: exception_class = ThreadCreationError - elif "history" in func.__name__: - exception_class = ThreadHistoryError elif "thread" in func.__name__: exception_class = ThreadInfoError elif "message" in func.__name__: @@ -83,192 +63,91 @@ async def wrapper(*args, **kwargs): class AnsariClientMock(AnsariClientBase): - """Mock implementation of Ansari client for testing without backend. + """Mock implementation of the Ansari client for testing without apps/api. - This client simulates backend responses with realistic latency and error rates. - It maintains in-memory state to provide stateful mocking across method calls. + Threads are kept in memory per user GUID (the `sub` of the token), mirroring apps/api, + where the JWT alone identifies the user. """ def __init__(self): """Initialize the mock Ansari API client with in-memory state.""" self.settings = get_settings() - # In-memory state for mock data - self._users = {} # phone_num -> user_data self._threads = {} # thread_id -> thread_data self._thread_counter = 0 - self._user_counter = 0 logger.info("Initialized AnsariClientMock - using mock backend") - @simulate_backend_behavior() - async def register_user(self, phone_num: str, preferred_language: str) -> dict: - """Mock user registration. - - Args: - phone_num: The user's WhatsApp phone number - preferred_language: The user's preferred language - - Returns: - dict: Registration result matching backend format {"status": "success", "user_id": "..."} - - Raises: - UserRegistrationError: Simulated registration errors (2% chance) - """ - if phone_num in self._users: - logger.warning(f"Mock: User {phone_num} already exists") - raise UserRegistrationError("User already registered") - - self._user_counter += 1 - user_id = f"mock_user_{self._user_counter}" - - self._users[phone_num] = { - "user_id": user_id, - "phone_num": phone_num, - "preferred_language": preferred_language, - } - - logger.info(f"Mock: Registered user {phone_num} with ID {user_id}") - return {"status": "success", "user_id": user_id} - - @simulate_backend_behavior() - async def check_user_exists(self, phone_num: str) -> str: - """Mock user ID retrieval. - - Args: - phone_num: The user's WhatsApp phone number - - Returns: - str: The user's ID - - Raises: - UserExistsCheckError: If user not found - """ - if phone_num not in self._users: - logger.debug(f"Mock: User {phone_num} not found") - raise UserExistsCheckError("User not found") - - user_id = self._users[phone_num]["user_id"] - logger.debug(f"Mock: Found user_id for {phone_num}: {user_id}") - return user_id + def _user(self, token: str, error: type[Exception]) -> str: + user = get_subject(token) + if not user: + raise error("Invalid token") + return user @simulate_backend_behavior() - async def create_thread(self, phone_num: str, title: str) -> dict: + async def create_thread(self, token: str, title: str) -> dict: """Mock thread creation. - Args: - phone_num: The user's WhatsApp phone number - title: The thread title - Returns: dict: Creation result matching backend format {"thread_id": "..."} - - Raises: - ThreadCreationError: If user not found or simulated error (2% chance) """ - if phone_num not in self._users: - logger.error(f"Mock: User {phone_num} not found for thread creation") - raise ThreadCreationError("User not found") + user = self._user(token, ThreadCreationError) self._thread_counter += 1 thread_id = f"mock_thread_{self._thread_counter}" self._threads[thread_id] = { "thread_id": thread_id, - "phone_num": phone_num, + "user": user, "title": title, "messages": [], - "last_message_time": None, + "last_message_time": datetime.now(timezone.utc).isoformat(), } - logger.info(f"Mock: Created thread {thread_id} for {phone_num}") + logger.info(f"Mock: Created thread {thread_id}") return {"thread_id": thread_id} @simulate_backend_behavior() - async def get_thread_history(self, phone_num: str, thread_id: str) -> dict: - """Mock thread history retrieval. - - Args: - phone_num: The user's WhatsApp phone number - thread_id: The thread ID - - Returns: - dict: Thread history matching backend format - - Raises: - ThreadHistoryError: If thread not found or simulated error (1% chance) - """ - if thread_id not in self._threads: - logger.error(f"Mock: Thread {thread_id} not found") - raise ThreadHistoryError("Thread not found") - - thread = self._threads[thread_id] - - if thread["phone_num"] != phone_num: - logger.error(f"Mock: Thread {thread_id} does not belong to user {phone_num}") - raise ThreadHistoryError("Thread access denied") - - logger.debug(f"Mock: Retrieved history for thread {thread_id}") - return {"thread_id": thread_id, "messages": thread["messages"]} - - @simulate_backend_behavior() - async def get_last_thread_info(self, phone_num: str) -> dict: + async def get_last_thread_info(self, token: str) -> dict: """Mock last thread info retrieval. - Args: - phone_num: The user's WhatsApp phone number - Returns: dict: Thread info matching backend format {"thread_id": "...", "last_message_time": "..."} - - Raises: - ThreadInfoError: Simulated error (1% chance) """ - user_threads = [t for t in self._threads.values() if t["phone_num"] == phone_num] + user = self._user(token, ThreadInfoError) + user_threads = [t for t in self._threads.values() if t["user"] == user] if not user_threads: - logger.debug(f"Mock: No threads found for user {phone_num}") + logger.debug("Mock: No threads found for user") return {"thread_id": None, "last_message_time": None} - # Get the thread with the most recent message time (or last created) - latest_thread = max(user_threads, key=lambda t: t["last_message_time"] if t["last_message_time"] else "") - - logger.debug(f"Mock: Last thread for {phone_num} is {latest_thread['thread_id']}") + latest_thread = max(user_threads, key=lambda t: t["last_message_time"]) return { "thread_id": latest_thread["thread_id"], "last_message_time": latest_thread["last_message_time"], } - @simulate_backend_behavior() # min_latency_s=60, max_latency_s=60 - async def process_message(self, phone_num: str, thread_id: str, message: str) -> str: + @simulate_backend_behavior() + async def process_message(self, token: str, thread_id: str, message: str) -> str: """Mock message processing with simulated AI response. - Args: - phone_num: The user's WhatsApp phone number - thread_id: The thread ID - message: The user's message - Returns: str: Mock AI response (uses saved sample response if message contains "long") - - Raises: - MessageProcessingError: If thread not found or simulated error (3% chance) """ - if thread_id not in self._threads: + user = self._user(token, MessageProcessingError) + + thread = self._threads.get(thread_id) + if thread is None: logger.error(f"Mock: Thread {thread_id} not found for message processing") raise MessageProcessingError("Thread not found") - thread = self._threads[thread_id] - - if thread["phone_num"] != phone_num: - logger.error(f"Mock: Thread {thread_id} does not belong to user {phone_num}") + if thread["user"] != user: + logger.error(f"Mock: Thread {thread_id} does not belong to this user") raise MessageProcessingError("Thread access denied") - # Add user message to thread thread["messages"].append({"role": "user", "content": message}) - # Generate mock AI response response = f"This is a *mock AI assistant* running in test mode. Write 'long' to see a bigger mock response.\n\nYour message: {message[:100]}" - # If message contains "long", try to load the saved sample response from ansari-backend + # If message contains "long", try to load the saved sample response if "long" in message.lower(): try: from pathlib import Path @@ -289,10 +168,7 @@ async def process_message(self, phone_num: str, thread_id: str, message: str) -> except Exception as e: logger.warning(f"Mock: Failed to load sample response: {e}, using default response") - # Add AI response to thread thread["messages"].append({"role": "assistant", "content": response}) - - # Update last message time thread["last_message_time"] = datetime.now(timezone.utc).isoformat() logger.info(f"Mock: Processed message for thread {thread_id}, response length: {len(response)}") diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py index 936324b2..c7c0a1e4 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py @@ -7,15 +7,18 @@ from ansari_whatsapp.utils.config import get_settings from ansari_whatsapp.utils.exceptions import ( - UserRegistrationError, - UserExistsCheckError, ThreadCreationError, - ThreadHistoryError, ThreadInfoError, MessageProcessingError, ) from ansari_whatsapp.services.ansari_client_base import AnsariClientBase +# Per-client attribution id sent to apps/api (lowercase, per packages/types attribution) +CLIENT_ID = "askansari-whatsapp" + +# The invisible keep-alive character apps/api sends while the model is still thinking +HEARTBEAT_CHAR = "\u200b" + class AnsariClientReal(AnsariClientBase): """Real client for the Ansari backend API that makes actual HTTP requests. @@ -41,16 +44,19 @@ def __init__(self): self.settings = get_settings() self.base_url = self.settings.BACKEND_SERVER_URL - # Create persistent HTTP client with authentication headers - # All requests will automatically include these headers + # Persistent HTTP client; the user's JWT is added per request, since each user has their own self.client = httpx.AsyncClient( headers={ - "X-Whatsapp-Api-Key": self.settings.WHATSAPP_SERVICE_API_KEY.get_secret_value(), + "X-Ansari-Client": CLIENT_ID, "Content-Type": "application/json", }, timeout=60.0, # Default timeout for all requests ) + @staticmethod + def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + async def close(self): """Close the HTTP client connection and release resources. @@ -63,74 +69,12 @@ async def close(self): """ await self.client.aclose() - async def register_user(self, phone_num: str, preferred_language: str) -> dict: - """ - Register a new WhatsApp user with the Ansari backend. - - Args: - phone_num (str): The user's WhatsApp phone number. - preferred_language (str): The user's preferred language. - - Returns: - dict: The registration result. - - Raises: - UserRegistrationError: If the registration fails. - """ - try: - response = await self.client.post( - f"{self.base_url}/whatsapp/v2/users/register", - json={ - "phone_num": phone_num, - "preferred_language": preferred_language, - }, - ) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error registering user {phone_num}: {e.response.status_code}") - raise UserRegistrationError(f"Failed to register user: HTTP {e.response.status_code}") from e - except httpx.RequestError as e: - logger.error(f"Network error registering user {phone_num}: {e}") - raise UserRegistrationError("Network error during registration") from e - - async def check_user_exists(self, phone_num: str) -> str: - """ - Get user ID for a WhatsApp phone number. - - Args: - phone_num (str): The user's WhatsApp phone number. - - Returns: - str: The user's ID - - Raises: - UserExistsCheckError: If the user is not found or the check fails. - """ - try: - response = await self.client.get( - f"{self.base_url}/whatsapp/v2/users/exists", - params={"phone_num": phone_num}, - ) - response.raise_for_status() - user_id = response.json() - return user_id - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - logger.debug(f"User {phone_num} not found (404)") - raise UserExistsCheckError("User not found") from e - logger.error(f"HTTP error getting user ID for {phone_num}: {e.response.status_code}") - raise UserExistsCheckError(f"Failed to get user ID: HTTP {e.response.status_code}") from e - except httpx.RequestError as e: - logger.error(f"Network error getting user ID for {phone_num}: {e}") - raise UserExistsCheckError("Network error during user ID retrieval") from e - - async def create_thread(self, phone_num: str, title: str) -> dict: + async def create_thread(self, token: str, title: str) -> dict: """ - Create a new thread for a WhatsApp user in the Ansari backend. + Create a new thread for the user that this JWT belongs to. Args: - phone_num (str): The user's WhatsApp phone number. + token (str): The user's JWT from apps/auth. title (str): The title of the thread. Returns: @@ -141,80 +85,63 @@ async def create_thread(self, phone_num: str, title: str) -> dict: """ try: response = await self.client.post( - f"{self.base_url}/whatsapp/v2/threads", - json={"phone_num": phone_num, "title": title}, + f"{self.base_url}/api/v2/threads", + json={"name": title, "source": "whatsapp"}, + headers=self._auth(token), ) response.raise_for_status() - return response.json() + return {"thread_id": response.json()["thread_id"]} except httpx.HTTPStatusError as e: - logger.error(f"HTTP error creating thread for {phone_num}: {e.response.status_code}") + logger.error(f"HTTP error creating thread: {e.response.status_code}") raise ThreadCreationError(f"Failed to create thread: HTTP {e.response.status_code}") from e except httpx.RequestError as e: - logger.error(f"Network error creating thread for {phone_num}: {e}") + logger.error(f"Network error creating thread: {type(e).__name__}") raise ThreadCreationError("Network error during thread creation") from e + except (KeyError, ValueError) as e: + logger.error("apps/api returned an unexpected thread-creation response") + raise ThreadCreationError("Unexpected thread-creation response") from e - async def get_thread_history(self, phone_num: str, thread_id: str) -> dict: - """ - Get the message history for a WhatsApp user's thread from the Ansari backend. - - Args: - phone_num (str): The user's WhatsApp phone number. - thread_id (str): The ID of the thread. - - Returns: - dict: The thread history. - - Raises: - ThreadHistoryError: If retrieving thread history fails. + async def get_last_thread_info(self, token: str) -> dict: """ - try: - response = await self.client.get( - f"{self.base_url}/whatsapp/v2/threads/{thread_id}/history", - params={"phone_num": phone_num}, - ) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting thread history for {phone_num}: {e.response.status_code}") - raise ThreadHistoryError(f"Failed to get thread history: HTTP {e.response.status_code}") from e - except httpx.RequestError as e: - logger.error(f"Network error getting thread history for {phone_num}: {e}") - raise ThreadHistoryError("Network error during thread history retrieval") from e + Get information about the user's most recently active thread. - async def get_last_thread_info(self, phone_num: str) -> dict: - """ - Get information about the last active thread for a WhatsApp user. + apps/api lists a user's threads newest-first by `updated_at`, which it bumps on every message. Args: - phone_num (str): The user's WhatsApp phone number. + token (str): The user's JWT from apps/auth. Returns: - dict: The thread info with thread_id and last_message_time. + dict: The thread info with thread_id and last_message_time (both None if no thread yet). Raises: ThreadInfoError: If retrieving thread info fails. """ try: - response = await self.client.get( - f"{self.base_url}/whatsapp/v2/threads/last", - params={"phone_num": phone_num}, - ) + response = await self.client.get(f"{self.base_url}/api/v2/threads", headers=self._auth(token)) response.raise_for_status() - return response.json() + threads = response.json() + if not threads: + return {"thread_id": None, "last_message_time": None} + return {"thread_id": threads[0]["thread_id"], "last_message_time": threads[0]["updated_at"]} except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting last thread info for {phone_num}: {e.response.status_code}") + logger.error(f"HTTP error getting last thread info: {e.response.status_code}") raise ThreadInfoError(f"Failed to get last thread info: HTTP {e.response.status_code}") from e except httpx.RequestError as e: - logger.error(f"Network error getting last thread info for {phone_num}: {e}") + logger.error(f"Network error getting last thread info: {type(e).__name__}") raise ThreadInfoError("Network error during thread info retrieval") from e + except (KeyError, ValueError, TypeError) as e: + logger.error("apps/api returned an unexpected thread-list response") + raise ThreadInfoError("Unexpected thread-list response") from e - async def process_message(self, phone_num: str, thread_id: str, message: str) -> str: + async def process_message(self, token: str, thread_id: str, message: str) -> str: """ - Process a message from a WhatsApp user with the Ansari backend. - This method streams the response from the backend to avoid timeout issues. + Send a message to apps/api and collect the full streamed answer. + + Uses `POST /api/v2/threads/{id}`, whose body is the raw answer text. While the model is + still thinking, apps/api sends invisible zero-width spaces as keep-alives; they are removed. Args: - phone_num (str): The user's WhatsApp phone number. + token (str): The user's JWT from apps/auth. thread_id (str): The ID of the thread. message (str): The message to process. @@ -225,16 +152,11 @@ async def process_message(self, phone_num: str, thread_id: str, message: str) -> MessageProcessingError: If message processing fails. """ try: - url = f"{self.base_url}/whatsapp/v2/messages/process" - data = { - "phone_num": phone_num, - "thread_id": thread_id, - "message": message, - } + url = f"{self.base_url}/api/v2/threads/{thread_id}" + data = {"content": message} # Use stream=True to receive the response as a stream - # Note: self.client already has auth headers and default timeout configured - async with self.client.stream("POST", url, json=data) as response: + async with self.client.stream("POST", url, json=data, headers=self._auth(token)) as response: if response.status_code != 200: error_detail = await response.aread() logger.error(f"Error from backend API: {error_detail}") @@ -245,7 +167,7 @@ async def process_message(self, phone_num: str, thread_id: str, message: str) -> async for chunk in response.aiter_text(): # Each chunk is a token from the streaming response if chunk: - full_response += chunk + full_response += chunk.replace(HEARTBEAT_CHAR, "") if not full_response: logger.warning("Received empty response from backend") @@ -271,14 +193,14 @@ async def process_message(self, phone_num: str, thread_id: str, message: str) -> return full_response except httpx.TimeoutException as e: - logger.error(f"Timeout processing message for {phone_num}: {e}") + logger.error(f"Timeout processing message: {e}") raise MessageProcessingError("Request timed out while processing message") from e except httpx.RequestError as e: - logger.error(f"Network error processing message for {phone_num}: {e}") + logger.error(f"Network error processing message: {type(e).__name__}") raise MessageProcessingError("Network error during message processing") from e except MessageProcessingError: # Re-raise our custom exception without wrapping raise except Exception as e: - logger.error(f"Unexpected error processing message for {phone_num}: {e}") - raise MessageProcessingError(f"Unexpected error: {str(e)}") from e + logger.error(f"Unexpected error processing message: {type(e).__name__}") + raise MessageProcessingError(f"Unexpected error: {type(e).__name__}") from e diff --git a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_base.py b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_base.py new file mode 100644 index 00000000..68d2742c --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_base.py @@ -0,0 +1,23 @@ +# Abstract base class for the apps/auth client +"""Defines the interface that all apps/auth client implementations must follow.""" + +from abc import ABC, abstractmethod + + +class AuthClientBase(ABC): + """Exchanges a WhatsApp user's BSUID for a JWT that apps/api accepts.""" + + @abstractmethod + async def get_token(self, bsuid: str) -> str: + """Get a JWT for the user identified by this BSUID. + + Args: + bsuid (str): The business-scoped user ID from Meta's webhook (never a phone number). + + Returns: + str: The JWT. Its `sub` claim is the user's GUID. + + Raises: + AuthTokenError: If the exchange fails. + """ + pass diff --git a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py new file mode 100644 index 00000000..3f933925 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py @@ -0,0 +1,23 @@ +# Mock apps/auth client +"""Mock client that issues fake, unsigned tokens without calling apps/auth.""" + +import base64 +import json +import uuid + +from ansari_whatsapp.services.auth_client_base import AuthClientBase +from ansari_whatsapp.utils.exceptions import AuthTokenError + + +def _b64(data: dict) -> str: + return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip("=") + + +class AuthClientMock(AuthClientBase): + """Maps each BSUID to a stable fake GUID, so one BSUID is one user and a new BSUID is a new user.""" + + async def get_token(self, bsuid: str) -> str: + if not bsuid: + raise AuthTokenError("Missing BSUID") + guid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"mock-bsuid:{bsuid}")) + return f"{_b64({'alg': 'none'})}.{_b64({'sub': guid})}.mock" diff --git a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_real.py b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_real.py new file mode 100644 index 00000000..dd4a3375 --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_real.py @@ -0,0 +1,48 @@ +# Real apps/auth client +"""Real client for apps/auth's `/whatsapp` token exchange, over HTTP.""" + +import httpx +from loguru import logger + +from ansari_whatsapp.services.auth_client_base import AuthClientBase +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.utils.exceptions import AuthTokenError + +# Assumed contract (spec 321): POST /api/whatsapp/session, {"bsuid": ...} -> {"token": ...} +SESSION_PATH = "/api/whatsapp/session" +API_KEY_HEADER = "X-Whatsapp-Api-Key" + + +class AuthClientReal(AuthClientBase): + """Calls apps/auth over HTTP, authenticated with AUTH_WHATSAPP_API_KEY.""" + + def __init__(self): + settings = get_settings() + self.url = f"{settings.AUTH_SERVER_URL.rstrip('/')}{SESSION_PATH}" + self.client = httpx.AsyncClient( + headers={API_KEY_HEADER: settings.AUTH_WHATSAPP_API_KEY.get_secret_value()}, + timeout=15.0, + ) + + async def close(self) -> None: + await self.client.aclose() + + async def get_token(self, bsuid: str) -> str: + try: + response = await self.client.post(self.url, json={"bsuid": bsuid}) + response.raise_for_status() + token = response.json().get("token") + except httpx.HTTPStatusError as e: + logger.error(f"apps/auth rejected the token exchange: HTTP {e.response.status_code}") + raise AuthTokenError(f"Token exchange failed: HTTP {e.response.status_code}") from e + except httpx.RequestError as e: + logger.error(f"Network error during token exchange: {type(e).__name__}") + raise AuthTokenError("Network error during token exchange") from e + except ValueError as e: + logger.error("apps/auth returned a non-JSON token response") + raise AuthTokenError("Invalid token response") from e + + if not isinstance(token, str) or not token: + logger.error("apps/auth token response had no token") + raise AuthTokenError("Token response had no token") + return token diff --git a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_mock.py index 51f0510a..dd508ba1 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_mock.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_mock.py @@ -22,7 +22,7 @@ async def send_typing_indicator(self, recipient_phone: str, message_id: str) -> recipient_phone: WhatsApp phone number of recipient message_id: Message ID for the typing indicator """ - logger.info(f"[MOCK META API] Typing indicator simulated for {recipient_phone} (msg_id: {message_id})") + logger.info(f"[MOCK META API] Typing indicator simulated (msg_id: {message_id})") await asyncio.sleep(0.05) # Simulate minimal network delay async def send_message(self, recipient_phone: str, message_parts: list[str]) -> None: @@ -32,15 +32,14 @@ async def send_message(self, recipient_phone: str, message_parts: list[str]) -> recipient_phone: WhatsApp phone number of recipient message_parts: List of message parts (already split by length) """ - logger.info(f"[MOCK META API] Sending {len(message_parts)} message part(s) to {recipient_phone}") + logger.info(f"[MOCK META API] Sending {len(message_parts)} message part(s)") for i, part in enumerate(message_parts, 1): # Don't log full message if it's just typing indicator if part == "...": logger.info(f"[MOCK META API] Part {i}/{len(message_parts)}: Typing indicator") else: - preview = part[:100] + ("..." if len(part) > 100 else "") - logger.info(f"[MOCK META API] Part {i}/{len(message_parts)}: {preview}") + logger.info(f"[MOCK META API] Part {i}/{len(message_parts)}: {len(part)} characters") await asyncio.sleep(0.1) # Simulate send time per part diff --git a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py index dc1b93d4..670255a1 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py @@ -55,7 +55,7 @@ async def send_typing_indicator(self, recipient_phone: str, message_id: str) -> try: async with httpx.AsyncClient() as client: - logger.debug(f"Sending typing indicator to {recipient_phone}") + logger.debug("Sending typing indicator") response = await client.post(self.api_url, headers=self._get_headers(), json=json_data) response.raise_for_status() @@ -104,7 +104,7 @@ async def send_message(self, recipient_phone: str, message_parts: list[str]) -> https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#send-message-requests """ if not recipient_phone: - logger.error("Cannot send message: No recipient phone number") + logger.error("Cannot send message: No recipient") return if not message_parts: @@ -113,7 +113,7 @@ async def send_message(self, recipient_phone: str, message_parts: list[str]) -> try: async with httpx.AsyncClient() as client: - logger.debug(f"Sending {len(message_parts)} message part(s) to {recipient_phone}") + logger.debug(f"Sending {len(message_parts)} message part(s)") for i, part in enumerate(message_parts, 1): json_data = { @@ -132,8 +132,7 @@ async def send_message(self, recipient_phone: str, message_parts: list[str]) -> # Log message part if part != "...": - preview = part[:100] + ("..." if len(part) > 100 else "") - logger.info(f"Sent message part {i}/{len(message_parts)}: {preview}") + logger.info(f"Sent message part {i}/{len(message_parts)}: {len(part)} characters") else: logger.info("Sent typing indicator") diff --git a/apps/whatsapp/src/ansari_whatsapp/services/service_provider.py b/apps/whatsapp/src/ansari_whatsapp/services/service_provider.py index 7b093472..c2704fb7 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/service_provider.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/service_provider.py @@ -1,5 +1,5 @@ # Service Provider for ansari-whatsapp -"""Factory function for providing the appropriate Ansari client implementation.""" +"""Factory functions for providing the apps/api and apps/auth client implementations.""" from typing import Optional from loguru import logger @@ -8,36 +8,22 @@ from ansari_whatsapp.services.ansari_client_base import AnsariClientBase from ansari_whatsapp.services.ansari_client_real import AnsariClientReal from ansari_whatsapp.services.ansari_client_mock import AnsariClientMock +from ansari_whatsapp.services.auth_client_base import AuthClientBase +from ansari_whatsapp.services.auth_client_real import AuthClientReal +from ansari_whatsapp.services.auth_client_mock import AuthClientMock -# Singleton instance - shared across the entire application +# Singleton instances - shared across the entire application _ansari_client_instance: Optional[AnsariClientBase] = None +_auth_client_instance: Optional[AuthClientBase] = None def get_ansari_client() -> AnsariClientBase: - """Factory function that returns THE SAME Ansari client instance (singleton). + """Return THE SAME apps/api client instance (singleton). - This function implements the Singleton Service Provider pattern, ensuring only one - client instance exists across the application. This enables: - - Proper resource management (single connection pool) - - Efficient cleanup during application shutdown - - Consistent client state across all requests - - The singleton can be one of: - - AnsariClientReal: Makes actual HTTP calls to the backend (production/integration testing) - - AnsariClientMock: Simulates backend responses without network calls (unit testing/offline dev) - - The choice is controlled by the MOCK_ANSARI_CLIENT environment variable, evaluated - only on first call (subsequent calls return the existing instance). - - Returns: - AnsariClientBase: The singleton instance (AnsariClientReal or AnsariClientMock) - - Example: - >>> client = get_ansari_client() # Creates singleton - >>> result = await client.register_user("+1234567890", "en") - >>> client2 = get_ansari_client() # Returns same instance - >>> assert client is client2 # True + A singleton keeps one connection pool and gives tests and the mock a consistent state. + The choice between the real and mock client is controlled by MOCK_ANSARI_CLIENT and + evaluated only on first call. Note: For test isolation, use reset_ansari_client() to clear the singleton between tests. @@ -45,9 +31,7 @@ def get_ansari_client() -> AnsariClientBase: global _ansari_client_instance if _ansari_client_instance is None: - settings = get_settings() - - if settings.MOCK_ANSARI_CLIENT: + if get_settings().MOCK_ANSARI_CLIENT: logger.info("Service Provider: Creating AnsariClientMock singleton") _ansari_client_instance = AnsariClientMock() else: @@ -57,23 +41,34 @@ def get_ansari_client() -> AnsariClientBase: return _ansari_client_instance -def reset_ansari_client() -> None: - """Reset the Ansari client singleton instance. +def get_auth_client() -> AuthClientBase: + """Return THE SAME apps/auth client instance (singleton). + + The choice between the real and mock client is controlled by MOCK_AUTH_CLIENT and + evaluated only on first call. Use reset_auth_client() for test isolation. + """ + global _auth_client_instance + + if _auth_client_instance is None: + if get_settings().MOCK_AUTH_CLIENT: + logger.info("Service Provider: Creating AuthClientMock singleton") + _auth_client_instance = AuthClientMock() + else: + logger.debug("Service Provider: Creating AuthClientReal singleton") + _auth_client_instance = AuthClientReal() - This function clears the singleton instance, allowing a fresh client to be created - on the next call to get_ansari_client(). This is primarily used for test isolation - to ensure each test gets a fresh client instance. + return _auth_client_instance - Usage in tests: - >>> @pytest.fixture(autouse=True) - >>> def reset_client(): - >>> reset_ansari_client() # Reset before each test - >>> yield - Warning: - This should NOT be called in production code. It's intended only for test fixtures - to ensure proper test isolation. - """ +def reset_ansari_client() -> None: + """Reset the apps/api client singleton (for test fixtures only; not for production code).""" global _ansari_client_instance _ansari_client_instance = None logger.debug("Service Provider: Ansari client singleton reset") + + +def reset_auth_client() -> None: + """Reset the apps/auth client singleton (for test fixtures only; not for production code).""" + global _auth_client_instance + _auth_client_instance = None + logger.debug("Service Provider: Auth client singleton reset") diff --git a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py index b4e69c5d..61c7d877 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py @@ -9,17 +9,16 @@ import sentry_sdk from loguru import logger -from ansari_whatsapp.services.service_provider import get_ansari_client +from ansari_whatsapp.services.service_provider import get_ansari_client, get_auth_client from ansari_whatsapp.services.meta_service_provider import get_meta_api_service from ansari_whatsapp.utils.app_logger import request_id_var, user_id_var from ansari_whatsapp.utils.exceptions import ( - UserRegistrationError, - UserExistsCheckError, + AuthTokenError, ThreadCreationError, ThreadInfoError, MessageProcessingError, ) -from ansari_whatsapp.utils.language_utils import get_language_from_text +from ansari_whatsapp.utils.token_utils import get_subject from ansari_whatsapp.utils.time_utils import calculate_time_passed, get_retention_time_seconds from ansari_whatsapp.presenters.whatsapp_message_formatter import format_for_whatsapp from ansari_whatsapp.utils.whatsapp_message_splitter import split_message @@ -29,8 +28,8 @@ class WhatsAppConversationManager: """Orchestrates WhatsApp conversation workflows. This service manages the lifecycle of WhatsApp conversations, coordinating - between the Ansari backend API and Meta WhatsApp API to handle: - - User registration and authentication + between apps/auth, apps/api and the Meta WhatsApp API to handle: + - User identification by BSUID (exchanged for a JWT; the phone number is never used) - Message processing and response generation - Typing indicators and user experience features - Thread/conversation management @@ -38,7 +37,7 @@ class WhatsAppConversationManager: def __init__( self, - user_phone_num: str = None, + bsuid: str = None, incoming_msg_type: str = None, incoming_msg_body: dict = None, message_id: str = None, @@ -47,13 +46,14 @@ def __init__( """Initialize the conversation manager with user-specific details. Args: - user_phone_num (str, optional): The WhatsApp phone number of the user. + bsuid (str, optional): The business-scoped user ID of the sender (e.g., "US.13491208655302741918"). incoming_msg_type (str, optional): The type of incoming message (text, image, etc.). incoming_msg_body (dict, optional): The body content of the incoming message. message_id (str, optional): The ID of the incoming message. message_unix_time (int, optional): The timestamp of the incoming message in Unix format. """ - self.user_phone_num = user_phone_num + self.bsuid = bsuid + self.token = None # The user's JWT, set by authenticate_user() self.incoming_msg_type = incoming_msg_type self.incoming_msg_body = incoming_msg_body self.message_id = message_id @@ -61,14 +61,15 @@ def __init__( # Initialize services and tracking variables self.ansari_client = get_ansari_client() + self.auth_client = get_auth_client() self.meta_api_service = get_meta_api_service() self.typing_indicator_task = None self.first_indicator_time = None async def send_typing_indicator_then_start_loop(self) -> None: """Send a typing indicator and start a loop to periodically send more while processing.""" - if not self.user_phone_num or not self.message_id: - logger.error("Cannot start typing indicator loop: missing user_phone_num or message_id") + if not self.bsuid or not self.message_id: + logger.error("Cannot start typing indicator loop: missing bsuid or message_id") return self.first_indicator_time = time.time() @@ -106,110 +107,88 @@ async def _typing_indicator_loop(self) -> None: logger.error(f"Error in typing indicator loop: {e}") logger.exception(e) - async def retrieve_or_register_user(self) -> str | None: - """Retrieve existing user ID or register new user and return their ID. + async def authenticate_user(self) -> str | None: + """Exchange the sender's BSUID for a JWT with apps/auth and return the user's GUID. - This method attempts to get the user_id for a phone number. If the user - doesn't exist, it registers them and returns the newly created user_id. + The same BSUID always maps to the same GUID, and a new BSUID is a new user. apps/auth creates the + account on first use, so there is no separate registration step. The token is kept on this object + for the later apps/api calls. Returns: - str | None: The user's database ID (UUID string) on success, None on failure. + str | None: The user's GUID (the JWT's `sub`) on success, None on failure. """ - if not self.user_phone_num: - logger.error("Cannot retrieve or register user: user_phone_num is not set") + if not self.bsuid: + logger.error("Cannot authenticate user: bsuid is not set") return None try: - # Try to get the user_id from backend - user_id = await self.ansari_client.check_user_exists(self.user_phone_num) - logger.debug(f"User exists with user_id: {user_id}") - return user_id - - except UserExistsCheckError: - # User doesn't exist - register them with detected language - logger.debug(f"User {self.user_phone_num} not found, registering new user") - - if self.incoming_msg_type == "text": - incoming_msg_text = self.incoming_msg_body["body"] - user_lang = get_language_from_text(incoming_msg_text) - else: - # Use English as default language if we can't detect it - user_lang = "en" - - try: - result = await self.ansari_client.register_user(self.user_phone_num, user_lang) - - # Extract user_id from registration response - if isinstance(result, dict): - user_id = result.get("user_id") - logger.info(f"Registered new whatsapp user (lang: {user_lang}, user_id: {user_id}): {self.user_phone_num}") - return user_id - - # Registration succeeded but no user_id returned (shouldn't happen) - logger.error(f"User registration succeeded but no user_id returned for {self.user_phone_num}") - return None - - except UserRegistrationError as e: - logger.exception(f"Failed to register user {self.user_phone_num}: {e}") - await self.send_whatsapp_message("Sorry, we couldn't register your account. Please try again later.") - return None - + self.token = await self.auth_client.get_token(self.bsuid) + except AuthTokenError as e: + logger.error(f"Failed to get a token for the user: {e}") + return None except Exception as e: - logger.exception(f"Unexpected error retrieving/registering user: {e}") + logger.error(f"Unexpected error getting a token for the user: {type(e).__name__}") return None + user_id = get_subject(self.token) + if not user_id: + logger.error("The token from apps/auth has no readable subject") + self.token = None + return None + + logger.debug(f"Authenticated whatsapp user (user_id: {user_id})") + return user_id + async def _send_whatsapp_typing_indicator(self) -> None: """Send a typing indicator to the WhatsApp recipient.""" - if not self.user_phone_num or not self.message_id: - logger.error("Cannot send typing indicator: missing user_phone_num or message_id") + if not self.bsuid or not self.message_id: + logger.error("Cannot send typing indicator: missing bsuid or message_id") return try: - await self.meta_api_service.send_typing_indicator(recipient_phone=self.user_phone_num, message_id=self.message_id) + await self.meta_api_service.send_typing_indicator(recipient_phone=self.bsuid, message_id=self.message_id) except Exception as e: logger.exception(f"Error sending typing indicator: {e}") async def send_whatsapp_message( self, msg_body: str, - recipient_phone_num: str = None, + recipient_bsuid: str = None, ) -> None: """Send a message to the WhatsApp recipient. Args: msg_body (str): The message body to be sent. - recipient_phone_num (str, optional): The recipient's WhatsApp number. - Defaults to self.user_phone_num if not provided. + recipient_bsuid (str, optional): The recipient's BSUID. Defaults to self.bsuid if not provided. """ - # Use the provided recipient_phone_num or fall back to self.user_phone_num - phone_num = recipient_phone_num or self.user_phone_num + recipient = recipient_bsuid or self.bsuid - if not phone_num: - logger.error("Cannot send WhatsApp message: No recipient phone number provided") + if not recipient: + logger.error("Cannot send WhatsApp message: No recipient provided") return # Split the message if it exceeds WhatsApp's character limit message_parts = split_message(msg_body) try: - # Delegate to Meta API service - await self.meta_api_service.send_message(recipient_phone=phone_num, message_parts=message_parts) + # Delegate to Meta API service (its `recipient_phone` argument also accepts a BSUID) + await self.meta_api_service.send_message(recipient_phone=recipient, message_parts=message_parts) except Exception as e: logger.exception(f"Error sending WhatsApp message: {e}") async def handle_text_message(self) -> None: """Process an incoming text message and send a response to the WhatsApp sender.""" - if not self.user_phone_num: - logger.error("Cannot process text message: user_phone_num is not set") + if not self.bsuid or not self.token: + logger.error("Cannot process text message: the user is not authenticated") return try: incoming_txt_msg = self.incoming_msg_body["body"] - logger.debug(f"Whatsapp user said: {incoming_txt_msg}") + logger.debug(f"Whatsapp user sent a text message ({len(incoming_txt_msg)} characters)") # Get details of the thread that the user last interacted with try: - last_thread_info = await self.ansari_client.get_last_thread_info(self.user_phone_num) + last_thread_info = await self.ansari_client.get_last_thread_info(self.token) thread_id = last_thread_info.get("thread_id") last_msg_time = last_thread_info.get("last_message_time") except ThreadInfoError as e: @@ -235,7 +214,7 @@ async def handle_text_message(self) -> None: first_few_words = " ".join(incoming_txt_msg.split()[:6]) try: - result = await self.ansari_client.create_thread(self.user_phone_num, first_few_words) + result = await self.ansari_client.create_thread(self.token, first_few_words) thread_id = result.get("thread_id") logger.info("Created a new thread for the whatsapp user, " + "as the allowed retention time has passed.") except ThreadCreationError as e: @@ -248,7 +227,7 @@ async def handle_text_message(self) -> None: # Process the message using the Ansari backend API try: response = await self.ansari_client.process_message( - phone_num=self.user_phone_num, + token=self.token, thread_id=thread_id, message=incoming_txt_msg, ) @@ -298,7 +277,7 @@ async def handle_text_message(self) -> None: return # Convert conventional markdown syntax to WhatsApp's markdown syntax - logger.debug(f"Response before markdown conversion: \n\n{response}") + logger.debug(f"Response received for markdown conversion ({len(response)} characters)") response = format_for_whatsapp(response) if not response: @@ -322,8 +301,8 @@ async def handle_text_message(self) -> None: async def handle_unsupported_message(self) -> None: """Handle an incoming unsupported message by sending an appropriate response.""" - if not self.user_phone_num or not self.incoming_msg_type: - logger.error("Cannot process unsupported message: missing user_phone_num or incoming_msg_type") + if not self.bsuid or not self.incoming_msg_type: + logger.error("Cannot process unsupported message: missing bsuid or incoming_msg_type") return msg_type = self.incoming_msg_type + "s" if not self.incoming_msg_type.endswith("s") else self.incoming_msg_type diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/config.py b/apps/whatsapp/src/ansari_whatsapp/utils/config.py index 8f373017..55ce9863 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/config.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/config.py @@ -41,10 +41,11 @@ class WhatsAppSettings(BaseSettings): extra="ignore", ) - ########## ansari-backend's settings ########## + ########## apps/api and apps/auth settings ########## - BACKEND_SERVER_URL: str = "http://localhost:8000" - WHATSAPP_SERVICE_API_KEY: SecretStr # Shared secret for service-to-service authentication + BACKEND_SERVER_URL: str = "http://localhost:3000" # apps/api (base URL, no path) + AUTH_SERVER_URL: str = "http://localhost:3100" # apps/auth (base URL, no path) + AUTH_WHATSAPP_API_KEY: SecretStr # Authenticates this service to apps/auth's /whatsapp endpoint ########### Meta Business API settings ########### @@ -95,6 +96,7 @@ def META_API_URL(self) -> str: # Service Provider settings SENTRY_DSN: HttpUrl | None = None MOCK_ANSARI_CLIENT: bool = False + MOCK_AUTH_CLIENT: bool = False MOCK_META_API: bool = False ########### Validators ########### diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py b/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py index 54a7ad55..efeb831b 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py @@ -11,12 +11,10 @@ def __init__(self, message: str, reply_to: str | None = None): self.reply_to = reply_to -class PhoneHiddenUserError(Exception): - """Sender's webhook has a BSUID but no phone number (WhatsApp username privacy).""" +class AuthTokenError(Exception): + """Exchanging a BSUID for a JWT with apps/auth failed.""" - def __init__(self, bsuid: str): - super().__init__("Message sender did not share a phone number (BSUID only)") - self.bsuid = bsuid + pass class AnsariClientError(Exception): @@ -25,30 +23,12 @@ class AnsariClientError(Exception): pass -class UserRegistrationError(AnsariClientError): - """User registration failed.""" - - pass - - -class UserExistsCheckError(AnsariClientError): - """Checking if user exists failed.""" - - pass - - class ThreadCreationError(AnsariClientError): """Thread creation failed.""" pass -class ThreadHistoryError(AnsariClientError): - """Retrieving thread history failed.""" - - pass - - class ThreadInfoError(AnsariClientError): """Retrieving thread info failed.""" diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py b/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py new file mode 100644 index 00000000..f277632d --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py @@ -0,0 +1,21 @@ +# JWT helpers for ansari-whatsapp +"""Read-only helpers for the JWTs that apps/auth issues.""" + +import base64 +import json + + +def get_subject(token: str) -> str | None: + """Return the `sub` claim (the user's GUID) of a JWT, or None if it can't be read. + + The signature is NOT verified here; apps/api verifies it on every call. This is only + used to tag logs with the user's GUID. + """ + try: + payload_part = token.split(".")[1] + payload_part += "=" * (-len(payload_part) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_part)) + sub = payload.get("sub") + return sub if isinstance(sub, str) else None + except (IndexError, ValueError): + return None diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py b/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py index 4355afff..1ed812c8 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py @@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse, Response from ansari_whatsapp.utils.config import get_settings -from ansari_whatsapp.utils.exceptions import PhoneHiddenUserError, UnexpectedMetaPayloadError +from ansari_whatsapp.utils.exceptions import UnexpectedMetaPayloadError settings = get_settings() @@ -160,8 +160,9 @@ async def parse_meta_payload( body (dict[str, dict]): The JSON body of the incoming webhook request from Meta. Returns: - tuple: A tuple of (is_status, is_target_business_number, user_whatsapp_number, - incoming_msg_type, incoming_msg_body, message_id, message_unix_time) + tuple: A tuple of (is_status, is_target_business_number, bsuid, + incoming_msg_type, incoming_msg_body, message_id, message_unix_time). + `bsuid` is the sender's business-scoped user ID; the phone number is never used. Raises: Exception: If the payload structure is invalid or unsupported. @@ -172,14 +173,14 @@ async def parse_meta_payload( and (changes := entry[0].get("changes", [])) and (value := changes[0].get("value", {})) ): - error_msg = f"Invalid received payload from WhatsApp user and/or problem with Meta's API:\n{body}" + error_msg = f"Invalid received payload from WhatsApp user and/or problem with Meta's API:\n{_redact_for_logging(body)}" logger.error(error_msg) raise Exception(error_msg) # Check if this webhook is intended for our WhatsApp business number # Metadata should always be present in a valid webhook payload if "metadata" not in value: - error_msg = f"Missing metadata in webhook payload from WhatsApp API:\n{value}" + error_msg = f"Missing metadata in webhook payload from WhatsApp API:\n{_redact_for_logging(value)}" logger.error(error_msg) raise Exception(error_msg) @@ -202,19 +203,17 @@ async def parse_meta_payload( is_status = False if "messages" not in value: - error_msg = f"Unsupported message type received from WhatsApp user:\n{body}" + error_msg = f"Unsupported message type received from WhatsApp user:\n{_redact_for_logging(body)}" logger.error(error_msg) raise Exception(error_msg) incoming_msg = value["messages"][0] - if "from" not in incoming_msg and incoming_msg.get("from_user_id"): - raise PhoneHiddenUserError(incoming_msg["from_user_id"]) - try: - # Extract message details + # Extract message details. `from_user_id` (the BSUID) is always present; `from` (the phone number) may + # be missing, and is deliberately never read. message_id = incoming_msg.get("id") - user_whatsapp_number = incoming_msg["from"] + bsuid = incoming_msg["from_user_id"] message_unix_time_str = incoming_msg.get("timestamp") message_unix_time = int(message_unix_time_str) if message_unix_time_str is not None else None @@ -225,20 +224,18 @@ async def parse_meta_payload( except KeyError as e: logger.error(f"Missing key {e} in messages[0]; redacted webhook value: {_redact_for_logging(value)}") contact = (value.get("contacts") or [{}])[0] - reply_to = ( - incoming_msg.get("from") or incoming_msg.get("from_user_id") or contact.get("wa_id") or contact.get("user_id") - ) + reply_to = incoming_msg.get("from_user_id") or contact.get("user_id") raise UnexpectedMetaPayloadError( f"Missing key {e} in messages[0]; keys present: {sorted(incoming_msg.keys())}", reply_to=reply_to, ) from e - logger.info(f"Received a supported whatsapp message from {user_whatsapp_number}: {incoming_msg_body}") + logger.info(f"Received a supported whatsapp message (type: {incoming_msg_type})") return ( is_status, is_target_business_number, - user_whatsapp_number, + bsuid, incoming_msg_type, incoming_msg_body, message_id, diff --git a/apps/whatsapp/tests/conftest.py b/apps/whatsapp/tests/conftest.py index cac62733..764927b5 100644 --- a/apps/whatsapp/tests/conftest.py +++ b/apps/whatsapp/tests/conftest.py @@ -5,8 +5,9 @@ "DEPLOYMENT_TYPE": "local", "MOCK_META_API": "True", "MOCK_ANSARI_CLIENT": "True", + "MOCK_AUTH_CLIENT": "True", "ALWAYS_RETURN_OK_TO_META": "False", - "WHATSAPP_SERVICE_API_KEY": "test", + "AUTH_WHATSAPP_API_KEY": "test", "META_BUSINESS_PHONE_NUMBER_ID": "123456789", "META_WEBHOOK_VERIFY_TOKEN": "test", "META_ANSARI_APP_SECRET": "test", diff --git a/apps/whatsapp/tests/test_bsuid_auth_flow.py b/apps/whatsapp/tests/test_bsuid_auth_flow.py new file mode 100644 index 00000000..ed674a00 --- /dev/null +++ b/apps/whatsapp/tests/test_bsuid_auth_flow.py @@ -0,0 +1,247 @@ +"""Tests for the BSUID-to-JWT identity flow (spec 321, phase 2): no phone number is ever used.""" + +import asyncio +import hashlib +import hmac +import json +import time + +import httpx +import pytest +from fastapi.testclient import TestClient + +from ansari_whatsapp.app.main import app +from ansari_whatsapp.services.ansari_client_real import AnsariClientReal +from ansari_whatsapp.services.auth_client_mock import AuthClientMock +from ansari_whatsapp.services.auth_client_real import API_KEY_HEADER, SESSION_PATH, AuthClientReal +from ansari_whatsapp.services.meta_api_service_mock import MetaApiServiceMock +from ansari_whatsapp.services.service_provider import reset_ansari_client, reset_auth_client +from ansari_whatsapp.utils.config import get_settings +from ansari_whatsapp.utils.exceptions import AuthTokenError, MessageProcessingError +from ansari_whatsapp.utils.token_utils import get_subject + +BSUID = "US.13491208655302741918" +PHONE = "201234567899" + +_RealAsyncClient = httpx.AsyncClient +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def fresh_singletons(monkeypatch): + reset_ansari_client() + reset_auth_client() + # Skip the mock backend's simulated latency + monkeypatch.setattr("ansari_whatsapp.services.ansari_client_mock.random.uniform", lambda a, b: 0) + yield + + +def _use_transport(monkeypatch, module: str, handler): + """Make the real client in `module` talk to `handler` instead of the network.""" + monkeypatch.setattr( + f"{module}.httpx.AsyncClient", lambda **kw: _RealAsyncClient(transport=httpx.MockTransport(handler), **kw) + ) + + +def _post_signed_webhook(message: dict): + settings = get_settings() + value = { + "metadata": { + "phone_number_id": settings.META_BUSINESS_PHONE_NUMBER_ID.get_secret_value(), + "display_phone_number": "+1234567890", + }, + "messages": [message], + } + payload = {"object": "whatsapp_business_account", "entry": [{"changes": [{"value": value}]}]} + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + secret = settings.META_ANSARI_APP_SECRET.get_secret_value().encode("utf-8") + signature = hmac.new(secret, body, hashlib.sha256).hexdigest() + return client.post("/whatsapp/v2", content=body, headers={"X-Hub-Signature-256": f"sha256={signature}"}) + + +def _text_message(**ids) -> dict: + return {"id": "wamid.TEST", "timestamp": str(int(time.time())), "type": "text", "text": {"body": "hello"}, **ids} + + +# ---------- auth client ---------- + + +def test_auth_client_exchanges_bsuid_for_token(monkeypatch): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["key"] = request.headers.get(API_KEY_HEADER) + seen["body"] = json.loads(request.content) + return httpx.Response(200, json={"token": "jwt-123"}) + + _use_transport(monkeypatch, "ansari_whatsapp.services.auth_client_real", handler) + + token = asyncio.run(AuthClientReal().get_token(BSUID)) + + assert token == "jwt-123" + assert seen["url"].endswith(SESSION_PATH) + assert seen["key"] == "test" + assert seen["body"] == {"bsuid": BSUID} + + +@pytest.mark.parametrize("status", [400, 401, 500]) +def test_auth_client_http_errors_raise(monkeypatch, status): + _use_transport(monkeypatch, "ansari_whatsapp.services.auth_client_real", lambda r: httpx.Response(status, json={})) + + with pytest.raises(AuthTokenError): + asyncio.run(AuthClientReal().get_token(BSUID)) + + +def test_auth_client_timeout_raises(monkeypatch): + def handler(request): + raise httpx.ReadTimeout("slow", request=request) + + _use_transport(monkeypatch, "ansari_whatsapp.services.auth_client_real", handler) + + with pytest.raises(AuthTokenError): + asyncio.run(AuthClientReal().get_token(BSUID)) + + +def test_auth_client_response_without_token_raises(monkeypatch): + _use_transport(monkeypatch, "ansari_whatsapp.services.auth_client_real", lambda r: httpx.Response(200, json={})) + + with pytest.raises(AuthTokenError): + asyncio.run(AuthClientReal().get_token(BSUID)) + + +def test_mock_auth_same_bsuid_same_user_new_bsuid_new_user(): + mock = AuthClientMock() + + first = get_subject(asyncio.run(mock.get_token(BSUID))) + again = get_subject(asyncio.run(mock.get_token(BSUID))) + other = get_subject(asyncio.run(mock.get_token("US.99999999999999999999"))) + + assert first == again + assert first != other + + +# ---------- apps/api client ---------- + + +def test_ansari_client_calls_api_with_jwt_and_strips_heartbeats(monkeypatch): + calls = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append((request.method, request.url.path, request.headers.get("authorization"))) + if request.url.path == "/api/v2/threads" and request.method == "GET": + return httpx.Response(200, json=[{"thread_id": "t1", "updated_at": "2026-10-25T10:00:00.000Z"}]) + if request.url.path == "/api/v2/threads": + return httpx.Response(200, json={"thread_id": "t2"}) + return httpx.Response(200, content="\u200b\u200bAs-salamu \u200balaykum".encode("utf-8")) + + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", handler) + ansari = AnsariClientReal() + + last = asyncio.run(ansari.get_last_thread_info("jwt")) + created = asyncio.run(ansari.create_thread("jwt", "title")) + answer = asyncio.run(ansari.process_message("jwt", "t1", "hi")) + + assert last == {"thread_id": "t1", "last_message_time": "2026-10-25T10:00:00.000Z"} + assert created == {"thread_id": "t2"} + assert answer == "As-salamu alaykum" + assert all(auth == "Bearer jwt" for _, _, auth in calls) + + +def test_ansari_client_no_threads_yet(monkeypatch): + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", lambda r: httpx.Response(200, json=[])) + + assert asyncio.run(AnsariClientReal().get_last_thread_info("jwt")) == {"thread_id": None, "last_message_time": None} + + +def test_ansari_client_error_status_raises(monkeypatch): + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", lambda r: httpx.Response(500, text="boom")) + + with pytest.raises(MessageProcessingError): + asyncio.run(AnsariClientReal().process_message("jwt", "t1", "hi")) + + +# ---------- webhook flow ---------- + + +def test_phone_visible_user_is_identified_and_replied_to_by_bsuid_only(monkeypatch): + tokens_requested = [] + recipients = [] + + original_get_token = AuthClientMock.get_token + + async def spy_get_token(self, bsuid): + tokens_requested.append(bsuid) + return await original_get_token(self, bsuid) + + original_send = MetaApiServiceMock.send_message + + async def spy_send(self, recipient_phone, message_parts): + recipients.append(recipient_phone) + return await original_send(self, recipient_phone, message_parts) + + monkeypatch.setattr(AuthClientMock, "get_token", spy_get_token) + monkeypatch.setattr(MetaApiServiceMock, "send_message", spy_send) + + response = _post_signed_webhook(_text_message(**{"from": PHONE, "from_user_id": BSUID})) + + assert response.status_code == 200 + assert tokens_requested == [BSUID] + assert recipients and set(recipients) == {BSUID} + assert PHONE not in tokens_requested + recipients + + +def test_phone_hidden_user_is_answered(): + response = _post_signed_webhook(_text_message(from_user_id=BSUID)) + + assert response.status_code == 200 + assert response.json()["success"] is True + + +def test_token_exchange_failure_notifies_user_and_is_reported(monkeypatch): + recipients = [] + sent = [] + original_send = MetaApiServiceMock.send_message + + async def failing_get_token(self, bsuid): + raise AuthTokenError("apps/auth is down") + + async def spy_send(self, recipient_phone, message_parts): + recipients.append(recipient_phone) + sent.extend(message_parts) + return await original_send(self, recipient_phone, message_parts) + + monkeypatch.setattr(AuthClientMock, "get_token", failing_get_token) + monkeypatch.setattr(MetaApiServiceMock, "send_message", spy_send) + + response = _post_signed_webhook(_text_message(from_user_id=BSUID)) + + assert response.json()["error_code"] == "USER_AUTHENTICATION_FAILED" + assert recipients == [BSUID] + assert any("sign you in" in part for part in sent) + + +def test_message_without_bsuid_is_an_unexpected_format(): + response = _post_signed_webhook(_text_message(**{"from": PHONE})) + + assert response.json()["error_code"] == "UNEXPECTED_PAYLOAD_FORMAT" + + +def test_logs_contain_no_phone_number_bsuid_or_message_text(): + from loguru import logger + + captured = [] + sink_id = logger.add(lambda m: captured.append(str(m)), level="DEBUG") + try: + message = _text_message(**{"from": PHONE, "from_user_id": BSUID}) + message["text"] = {"body": "a-very-distinctive-question"} + response = _post_signed_webhook(message) + finally: + logger.remove(sink_id) + + log_text = "\n".join(captured) + assert response.status_code == 200 + assert log_text + assert PHONE not in log_text + assert BSUID not in log_text + assert "a-very-distinctive-question" not in log_text diff --git a/apps/whatsapp/tests/test_whatsapp_service.py b/apps/whatsapp/tests/test_whatsapp_service.py index 5b221cc4..7dc71c78 100644 --- a/apps/whatsapp/tests/test_whatsapp_service.py +++ b/apps/whatsapp/tests/test_whatsapp_service.py @@ -260,6 +260,8 @@ def test_webhook_message_basic(settings): # If you're testing locally and want to see messages get sent successfully # to Meta's server, temporarily update this value with your actual phone number "from": "201234567899", + # Meta always sends the BSUID; the service identifies users by it alone + "from_user_id": "US.13491208655302741918", # NOTE: Using hardcoded test message ID from Meta's sample webhook payloads. # This works with MOCK_META_API=True (which is the default for CI/CD). # Real message IDs have a ~30 day lifetime before becoming invalid. @@ -390,7 +392,7 @@ def test_webhook_rejects_wrong_signature(settings): @pytest.mark.integration def test_webhook_phone_hidden_user(settings): - """A BSUID-only sender (no `from`) gets the phone-number notice and is not processed.""" + """A BSUID-only sender (no `from`) is processed like any other user.""" message = { "from_user_id": "US.13491208655302741918", "id": "wamid.TEST_PHONE_HIDDEN", @@ -402,7 +404,8 @@ def test_webhook_phone_hidden_user(settings): response = _post_signed_webhook(settings, message, contacts) - assert response.json()["error_code"] == "PHONE_HIDDEN_USER" + assert response.status_code == 200 + assert response.json()["success"] is True @pytest.mark.integration From c03fba2ee640710fe09dd97f9b7b67c6b4234024 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 17:49:29 +0300 Subject: [PATCH 023/247] chore(porch): 321 implement build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index abde2b2a..d9503def 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T10:21:36.093Z' +updated_at: '2026-10-25T14:49:29.265Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From dddeb00eaa06554ad9dfe138036d660436ddcca1 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 17:49:40 +0300 Subject: [PATCH 024/247] chore(porch): 321 acknowledge context refresh --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index d9503def..49959f99 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T14:49:29.265Z' +updated_at: '2026-10-25T14:49:39.864Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -49,3 +49,4 @@ context_refreshes: acknowledged_at: '2026-10-25T08:16:31.277Z' - boundary: plan-phase:auth_token_exchange at: '2026-10-25T10:21:36.093Z' + acknowledged_at: '2026-10-25T14:49:39.861Z' From 299cdc9dbf3a7a475f0b04250762d0763d4bb5e1 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 17:58:25 +0300 Subject: [PATCH 025/247] [Spec 321][Phase: auth_token_exchange] fix: Address iteration 1 review --- apps/whatsapp/src/ansari_whatsapp/app/main.py | 11 ++--- .../services/auth_client_real.py | 3 +- .../src/ansari_whatsapp/utils/token_utils.py | 2 +- apps/whatsapp/tests/test_bsuid_auth_flow.py | 30 +++++++++++++ .../321-rfc-whatsapp-support-in-the-mo.md | 1 + .../321-auth_token_exchange-iter1-claude.txt | 44 +++++++++++++++++++ ...321-auth_token_exchange-iter1-rebuttals.md | 13 ++++++ 7 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-claude.txt create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-rebuttals.md diff --git a/apps/whatsapp/src/ansari_whatsapp/app/main.py b/apps/whatsapp/src/ansari_whatsapp/app/main.py index abf0ce98..4f05c91f 100644 --- a/apps/whatsapp/src/ansari_whatsapp/app/main.py +++ b/apps/whatsapp/src/ansari_whatsapp/app/main.py @@ -356,11 +356,6 @@ async def main_webhook( success=False, message="Message filtered for local development", status_code=202, error_code="DEV_FILTER" ) - # Start the typing indicator loop that will continue until message is processed - background_tasks.add_task( - conversation_manager.send_typing_indicator_then_start_loop, - ) - # Check if there are more than xx hours have passed from the user's message to the current time # If so, send a message to the user and return if is_message_too_old(message_unix_time): @@ -394,6 +389,12 @@ async def main_webhook( success=False, message="Unsupported message type handled", status_code=415, error_code="UNSUPPORTED_MESSAGE_TYPE" ) + # Start the typing indicator loop that will continue until message is processed. It is only started now, + # for text messages the user is signed in for, because only handle_text_message cancels it. + background_tasks.add_task( + conversation_manager.send_typing_indicator_then_start_loop, + ) + # Process text messages sent by the WhatsApp user background_tasks.add_task( conversation_manager.handle_text_message, diff --git a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_real.py b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_real.py index dd4a3375..9a573357 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_real.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_real.py @@ -21,7 +21,8 @@ def __init__(self): self.url = f"{settings.AUTH_SERVER_URL.rstrip('/')}{SESSION_PATH}" self.client = httpx.AsyncClient( headers={API_KEY_HEADER: settings.AUTH_WHATSAPP_API_KEY.get_secret_value()}, - timeout=15.0, + # Awaited before Meta gets its reply (Meta wants a response within ~5s), so keep it short + timeout=5.0, ) async def close(self) -> None: diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py b/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py index f277632d..eb0a7a64 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py @@ -17,5 +17,5 @@ def get_subject(token: str) -> str | None: payload = json.loads(base64.urlsafe_b64decode(payload_part)) sub = payload.get("sub") return sub if isinstance(sub, str) else None - except (IndexError, ValueError): + except (IndexError, ValueError, AttributeError, TypeError): return None diff --git a/apps/whatsapp/tests/test_bsuid_auth_flow.py b/apps/whatsapp/tests/test_bsuid_auth_flow.py index ed674a00..3f5a4d57 100644 --- a/apps/whatsapp/tests/test_bsuid_auth_flow.py +++ b/apps/whatsapp/tests/test_bsuid_auth_flow.py @@ -34,6 +34,8 @@ def fresh_singletons(monkeypatch): # Skip the mock backend's simulated latency monkeypatch.setattr("ansari_whatsapp.services.ansari_client_mock.random.uniform", lambda a, b: 0) yield + reset_ansari_client() + reset_auth_client() def _use_transport(monkeypatch, module: str, handler): @@ -191,6 +193,34 @@ async def spy_send(self, recipient_phone, message_parts): assert PHONE not in tokens_requested + recipients +def test_no_phone_number_reaches_apps_api(monkeypatch): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(f"{request.method} {request.url} {dict(request.headers)} {request.content.decode()}") + if request.method == "GET": + return httpx.Response(200, json=[]) + if request.url.path == "/api/v2/threads": + return httpx.Response(200, json={"thread_id": "t1"}) + return httpx.Response(200, content=b"answer") + + monkeypatch.setattr(get_settings(), "MOCK_ANSARI_CLIENT", False) + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", handler) + + response = _post_signed_webhook(_text_message(**{"from": PHONE, "from_user_id": BSUID})) + + assert response.status_code == 200 + assert len(requests) == 3 # list threads, create thread, send message + assert not any(PHONE in r or BSUID in r for r in requests) + + +def test_malformed_token_subject_does_not_raise(): + # Valid base64 JSON that is not an object, and a payload that is not JSON at all + assert get_subject("a.NQ.c") is None + assert get_subject("a.bm90LWpzb24.c") is None + assert get_subject("not-a-jwt") is None + + def test_phone_hidden_user_is_answered(): response = _post_signed_webhook(_text_message(from_user_id=BSUID)) diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index da95274c..b48a059a 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -127,6 +127,7 @@ Finish environment behaviour and make the service testable and deployable by oth - In `apps/whatsapp`: maintenance behaviour driven by the same global flag as `apps/api` (`MAINTENANCE_MODE`) instead of the WhatsApp-specific flag, keeping the notice message; keep the staging `!d ` filter; `README.md` and `tests/README.md` for the app (both still reference legacy files that were not imported). - Deployment notes: per-environment Meta credentials and secrets, and the deploy steps for staging and production, following the monorepo's existing deployment docs. +- Findings from the Phase 2 review to resolve here: (a) apps/api reports a failed or empty answer as an inline `Error: ...` at the end of a 200 stream, which the client currently relays to the user unlogged, so detect it, log it and send a failure notice; (b) deploy blocker to confirm before the staging smoke test: the service now requires `from_user_id` in every webhook, and sending to a BSUID (`recipient`) is untested against Meta's live API (Meta's docs disagree on when it is supported), so check the webhook Graph API version and test a real send; (c) `README.md` still documents the six deleted legacy endpoints. - Deploy-path items found in the Phase 1 review: bump the uv version in the `Dockerfile` (it pins 0.5.11 against a newer lockfile format) and make its second `uv sync` use `--frozen`; decide whether to keep `/sentry-debug` and `clean_logs.sh`; ask an admin to make the `whatsapp` CI job a required check on `develop`. #### Deliverables diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-claude.txt new file mode 100644 index 00000000..09698078 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-claude.txt @@ -0,0 +1,44 @@ +I'll start by reading the key files for this phase.I read the phase-2 commit (`5f1ebf3`) and every file it touched, plus the `apps/api` endpoints the new client targets, to verify the contract assumptions. I did **not** execute `pytest`/`ruff` — doing so writes into the builder's live worktree (`tests/detailed_test_results_whatsapp_service.json`, `logs/`, caches), so my findings below are from reading. Where that limits confidence I say so. + +## Verified against the actual codebase + +- `GET /api/v2/threads` really does return an array of `{thread_id, updated_at}` ordered `desc(updatedAt)`, and `createMessage` bumps `threads.updatedAt` — so `AnsariClientReal.get_last_thread_info`'s "newest-first, bumped on every message" comment is accurate, not wishful. +- `POST /api/v2/threads` returns `thread_id` and accepts optional `source` — matches `create_thread`. +- `POST /api/v2/threads/[id]` streams raw text with ZWSP keep-alives; `RAW_TEXT_HEARTBEAT_INITIAL_DELAY_MS` is **15 s**, not 60 s, so the client's 60 s httpx timeout has comfortable margin. `HEARTBEAT_CHAR = "\u200b"` stripping is correct. +- `askansari-whatsapp` passes `CLIENT_ID_PATTERN` in `@ansari/types/attribution`; the allowlist is deliberately not enforced ("sanitize, don't allowlist"), so this is fine. +- `apps/auth` has no `/api/whatsapp/session` yet — as the spec anticipated. `MOCK_AUTH_CLIENT` is the right bridge. +- Ruff would not flag the new 132-char line in `meta_api_service_real.py:104`: it's a bare URL with no whitespace past the limit, which ruff's E501 skips (same reason the pre-existing `app_logger.py` URLs pass). + +## Phase deliverables + +All five deliverables and the three acceptance criteria are substantially met. The phone number is no longer *extracted at all* (`parse_meta_payload` reads `from_user_id` and never `from`), which is stronger than filtering it downstream. `test_logs_contain_no_phone_number_bsuid_or_message_text` is a real guard, not a vacuous one — I traced every log statement on the happy path (webhook utils, conversation manager, both mocks) and confirmed each one would have to be changed to a leaking form for the assertion to matter, and that such a change would fail it. + +## Findings + +**1. `get_subject()` can raise `AttributeError`, and that returns a raw 500 to Meta.** `token_utils.get_subject` catches `(IndexError, ValueError)`. A payload segment that is valid JSON but not an object (e.g. `"a.NQ.c"` → `5`) reaches `payload.get("sub")` → `AttributeError`. In `whatsapp_conversation_manager.py:133` that call sits *outside* the try/except, so it propagates through `main_webhook` (line 372, unguarded) to `general_exception_handler`, which returns 500 **bypassing `create_response_for_meta`** — Meta retries. One-line fix: add `AttributeError, TypeError` to the `except` in `get_subject`, or move line 133 inside the existing try. + +**2. The token exchange is awaited inline, with a 15 s timeout, before Meta gets its response.** `AuthClientReal` sets `timeout=15.0`, and `authenticate_user()` is awaited before `create_response_for_meta`. The service's own docstring in `whatsapp_webhook_utils.py` states Meta wants 200 within ~5 s. Legacy registration had the same shape, so this isn't a regression — but 15 s is a number chosen in *this* phase, and spec decision 5 explicitly accepts no duplicate suppression, so a slow `apps/auth` turns into duplicate answers. Suggest dropping the auth timeout to ~5 s (it's a single DB-backed token mint), or moving auth into the background task alongside `handle_text_message`. + +**3. The typing-indicator loop is never cancelled on the auth-failure path.** `send_typing_indicator_then_start_loop` is scheduled at `main.py:360`, before the auth check at 372. On failure the background queue runs the loop (5-minute cap, 26 s interval) *and then* the "couldn't sign you in" notice — so a user who just got an error keeps seeing "typing…" for five minutes. Same applies to the message-too-old and unsupported-type returns. Cheapest fix: schedule the typing loop only after authentication succeeds. + +**4. The apps/api half of the "no phone number" criterion isn't asserted.** The plan says "No outgoing call to `apps/auth` **or `apps/api`** carries a phone number (asserted in tests)." `test_phone_visible_user_is_identified_and_replied_to_by_bsuid_only` asserts the auth request and the Meta recipient, but the webhook flow runs against `AnsariClientMock`, so no apps/api wire bytes are inspected. The risk is genuinely near-zero (no phone number is ever parsed), but the criterion is literal. You already have `_use_transport` — pointing the *real* ansari client at a `MockTransport` inside the webhook test would close it cheaply. + +**5. Mid-stream apps/api errors reach the user verbatim.** `apps/api` appends `\n\nError: ` to the 200 stream on facilitator error or empty answer. `process_message` returns that as a normal answer, so the user gets the raw error text and the WhatsApp side logs nothing. The spec's test scenario asks for "a failure notice, error logged." Detecting a trailing `\n\nError: ` sentinel is brittle; worth at least recording as a known gap for Phase 4. + +**6. `from_user_id` is now a hard requirement, and the outgoing BSUID send path is untested.** Any payload without `from_user_id` now produces the "Meta sent your message in an unexpected format" reply. `from_user_id` presence depends on the webhook subscription's Graph API version, and `META_API_VERSION` defaults to `v22.0`; your own comment in `meta_api_service_real.py:99-104` notes Meta marks BSUID *sending* as "coming soon" in one place and July 2026 in another. This is correct per baked decision 2, but it's a deploy-blocking unknown — the plan asked for findings to be recorded rather than scope-widened, and this is the one worth writing down before the staging smoke test. + +**7. Minor.** `test_token_exchange_failure_notifies_user_and_is_reported` asserts the notice but nothing about reporting — rename or assert. `AuthClientReal.close()` exists but is never called (same as `AnsariClientReal` — pre-existing). `README.md:108-115` still documents the six deleted legacy endpoints; Phase 4 owns this per the iteration-1 rebuttal, so I'm only flagging it so it isn't lost. + +--- +VERDICT: COMMENT +SUMMARY: BSUID-only identity and the auth/api client split are correctly implemented and verified against the real apps/api contract; a few cheap robustness fixes and one untested-criterion gap are worth closing before Phase 3. +CONFIDENCE: MEDIUM +--- +KEY_ISSUES: +- `get_subject()` can raise an uncaught `AttributeError` outside `authenticate_user()`'s try block, returning a raw 500 to Meta instead of an OK (one-line fix). +- Token exchange awaited inline with a 15 s timeout delays Meta's response past the documented ~5 s window; lower the timeout or move auth into the background task. +- Typing-indicator loop is scheduled before the auth check and never cancelled on failure — a user who was told sign-in failed sees "typing…" for 5 minutes. +- Plan acceptance criterion "no outgoing call to apps/api carries a phone number (asserted in tests)" is not asserted; the webhook test uses the mock ansari client. +- Mid-stream `apps/api` errors arrive as HTTP 200 with inline `Error:` text and are relayed to the user unlogged, short of the spec's "failure notice, error logged". +- Hard dependency on `from_user_id` plus an untested `recipient`-based Meta send path (`META_API_VERSION` defaults to v22.0) should be recorded as a deploy finding before the staging smoke test. +- Not executed: `pytest` and `ruff` were not run, to avoid writing artifacts into the live worktree; CI green is unverified by me. \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-rebuttals.md new file mode 100644 index 00000000..c52a7b78 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-auth_token_exchange-iter1-rebuttals.md @@ -0,0 +1,13 @@ +# Rebuttal: auth_token_exchange, iteration 1 + +Review: `321-auth_token_exchange-iter1-claude.txt` (COMMENT). Findings 1 to 4 are accepted and fixed; 5 and 6 are accepted and recorded in the plan for Phase 4. + +1. **`get_subject()` could raise.** Fixed: it now also catches `AttributeError` and `TypeError`. A test with a payload that is valid JSON but not an object fails without the fix (`'int' object has no attribute 'get'`) and passes with it. +2. **Token exchange timeout.** Fixed: lowered from 15 s to 5 s, matching the window Meta expects. Moving authentication into the background task was not done: an auth failure would then no longer be reported in the webhook response, which the existing error handling relies on. +3. **Typing indicator never cancelled on failure.** Fixed: it is now scheduled only for text messages the user is signed in for, right before `handle_text_message`, the one place that cancels it. This also covers the too-old and unsupported-type returns. +4. **apps/api half of the "no phone number" criterion.** Fixed: a new test runs the full webhook flow against the real client over a mock transport, and asserts that none of the three requests (list threads, create thread, send message) contains the phone number or the BSUID. It fails when a phone number is deliberately added to a request body. +5. **Inline `Error:` text relayed to the user.** Accepted, not fixed here: detecting the trailing sentinel is brittle, and the proper fix touches answer handling. Added to Phase 4 in the plan. +6. **`from_user_id` requirement and untested BSUID send.** Accepted, recorded as a deploy blocker in Phase 4 of the plan, to be checked before the staging smoke test. +7. **Minor.** The `README.md` still documents the removed endpoints, which Phase 4 already owns; noted there. Not changed: the unused `close()` methods are pre-existing, and the failure-notice test keeps its name, because the "reported" half is the Meta error code returned to the caller. + +Verification: ruff clean, 24 tests pass, with both new red-path checks shown to fail first. Not proven: nothing has run against a live `apps/auth` or `apps/api`, since the `/whatsapp` endpoint does not exist yet. From f59e74c73d05b45e73950d5e4c5aebd41aef4210 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 17:58:53 +0300 Subject: [PATCH 026/247] =?UTF-8?q?chore(porch):=20321=20advance=20plan=20?= =?UTF-8?q?phase=20=E2=86=92=20thread=5Flifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../321-rfc-whatsapp-support-in-the-mo/status.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 49959f99..47ada891 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -8,14 +8,14 @@ plan_phases: status: complete - id: auth_token_exchange title: Replace legacy user handling with the apps/auth token exchange - status: in_progress + status: complete - id: thread_lifecycle title: One thread per user with expiry - status: pending + status: in_progress - id: environments_and_docs title: Maintenance mode, staging filter, local testing and docs status: pending -current_plan_phase: auth_token_exchange +current_plan_phase: thread_lifecycle gates: spec-approval: status: approved @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: true +build_complete: false history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T14:49:39.864Z' +updated_at: '2026-10-25T14:58:53.029Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -50,3 +50,5 @@ context_refreshes: - boundary: plan-phase:auth_token_exchange at: '2026-10-25T10:21:36.093Z' acknowledged_at: '2026-10-25T14:49:39.861Z' + - boundary: plan-phase:thread_lifecycle + at: '2026-10-25T14:58:53.028Z' From bb1d73e3ac46a76fff2349b0ec689d59ded59b47 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 17:59:12 +0300 Subject: [PATCH 027/247] chore(porch): 321 implement build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 47ada891..e479cd2d 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T14:58:53.029Z' +updated_at: '2026-10-25T14:59:12.065Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From e5a60acfe13669839225090a455bec304260202d Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 17:59:16 +0300 Subject: [PATCH 028/247] chore(porch): 321 acknowledge context refresh --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index e479cd2d..96cfda35 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T14:59:12.065Z' +updated_at: '2026-10-25T14:59:15.995Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -52,3 +52,4 @@ context_refreshes: acknowledged_at: '2026-10-25T14:49:39.861Z' - boundary: plan-phase:thread_lifecycle at: '2026-10-25T14:58:53.028Z' + acknowledged_at: '2026-10-25T14:59:15.993Z' From e481f7f1b02aba36ca6c8f5ed61baa04b37add14 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 19:06:39 +0300 Subject: [PATCH 029/247] [Spec 321][Phase: thread_lifecycle] feat: One thread per user, replaced on expiry, serialized per user --- .../services/ansari_client_base.py | 15 ++ .../services/ansari_client_mock.py | 17 ++ .../services/ansari_client_real.py | 26 ++ .../services/whatsapp_conversation_manager.py | 86 +++++-- .../src/ansari_whatsapp/utils/exceptions.py | 6 + .../src/ansari_whatsapp/utils/user_locks.py | 36 +++ apps/whatsapp/tests/test_thread_lifecycle.py | 241 ++++++++++++++++++ 7 files changed, 400 insertions(+), 27 deletions(-) create mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/user_locks.py create mode 100644 apps/whatsapp/tests/test_thread_lifecycle.py diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py index 59c40c67..2d05dd33 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_base.py @@ -27,6 +27,21 @@ async def create_thread(self, token: str, title: str) -> dict: """ pass + @abstractmethod + async def delete_thread(self, token: str, thread_id: str) -> None: + """Delete a thread and, with it, all of its messages. + + Deleting a thread that is already gone is not an error. + + Args: + token (str): The user's JWT from apps/auth. + thread_id (str): The ID of the thread to delete. + + Raises: + ThreadDeletionError: If deletion fails. + """ + pass + @abstractmethod async def get_last_thread_info(self, token: str) -> dict: """Get information about the user's most recently active thread. diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py index 861ce036..c5f239dc 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py @@ -12,6 +12,7 @@ from ansari_whatsapp.utils.config import get_settings from ansari_whatsapp.utils.exceptions import ( ThreadCreationError, + ThreadDeletionError, ThreadInfoError, MessageProcessingError, ) @@ -46,6 +47,8 @@ async def wrapper(*args, **kwargs): if exception_class is None: if "create" in func.__name__: exception_class = ThreadCreationError + elif "delete" in func.__name__: + exception_class = ThreadDeletionError elif "thread" in func.__name__: exception_class = ThreadInfoError elif "message" in func.__name__: @@ -106,6 +109,20 @@ async def create_thread(self, token: str, title: str) -> dict: logger.info(f"Mock: Created thread {thread_id}") return {"thread_id": thread_id} + @simulate_backend_behavior() + async def delete_thread(self, token: str, thread_id: str) -> None: + """Mock thread deletion (its messages go with it). A thread that is already gone is not an error.""" + user = self._user(token, ThreadDeletionError) + + thread = self._threads.get(thread_id) + if thread is None: + return + if thread["user"] != user: + raise ThreadDeletionError("Thread access denied") + + del self._threads[thread_id] + logger.info(f"Mock: Deleted thread {thread_id}") + @simulate_backend_behavior() async def get_last_thread_info(self, token: str) -> dict: """Mock last thread info retrieval. diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py index c7c0a1e4..28351f31 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py @@ -8,6 +8,7 @@ from ansari_whatsapp.utils.config import get_settings from ansari_whatsapp.utils.exceptions import ( ThreadCreationError, + ThreadDeletionError, ThreadInfoError, MessageProcessingError, ) @@ -101,6 +102,31 @@ async def create_thread(self, token: str, title: str) -> dict: logger.error("apps/api returned an unexpected thread-creation response") raise ThreadCreationError("Unexpected thread-creation response") from e + async def delete_thread(self, token: str, thread_id: str) -> None: + """ + Delete a thread and, with it, all of its messages (apps/api cascades the delete). + + A 404 means the thread is already gone, which is the outcome we wanted, so it is not an error. + + Args: + token (str): The user's JWT from apps/auth. + thread_id (str): The ID of the thread to delete. + + Raises: + ThreadDeletionError: If deletion fails. + """ + try: + response = await self.client.delete(f"{self.base_url}/api/v2/threads/{thread_id}", headers=self._auth(token)) + if response.status_code == 404: + return + response.raise_for_status() + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error deleting thread: {e.response.status_code}") + raise ThreadDeletionError(f"Failed to delete thread: HTTP {e.response.status_code}") from e + except httpx.RequestError as e: + logger.error(f"Network error deleting thread: {type(e).__name__}") + raise ThreadDeletionError("Network error during thread deletion") from e + async def get_last_thread_info(self, token: str) -> dict: """ Get information about the user's most recently active thread. diff --git a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py index 61c7d877..e096ccaf 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py @@ -15,10 +15,12 @@ from ansari_whatsapp.utils.exceptions import ( AuthTokenError, ThreadCreationError, + ThreadDeletionError, ThreadInfoError, MessageProcessingError, ) from ansari_whatsapp.utils.token_utils import get_subject +from ansari_whatsapp.utils.user_locks import user_locks from ansari_whatsapp.utils.time_utils import calculate_time_passed, get_retention_time_seconds from ansari_whatsapp.presenters.whatsapp_message_formatter import format_for_whatsapp from ansari_whatsapp.utils.whatsapp_message_splitter import split_message @@ -176,53 +178,83 @@ async def send_whatsapp_message( except Exception as e: logger.exception(f"Error sending WhatsApp message: {e}") - async def handle_text_message(self) -> None: - """Process an incoming text message and send a response to the WhatsApp sender.""" - if not self.bsuid or not self.token: - logger.error("Cannot process text message: the user is not authenticated") - return + async def _resolve_thread(self, first_message: str) -> str | None: + """Return the id of the user's one current thread, replacing it if it has expired. - try: - incoming_txt_msg = self.incoming_msg_body["body"] - logger.debug(f"Whatsapp user sent a text message ({len(incoming_txt_msg)} characters)") + Each user has exactly one thread. If the last message is older than the expiry time, the old thread and + its messages are deleted and a new thread starts. Messages from different users never wait for each other. + + This runs under the user's lock, so two messages arriving close together cannot both see an expired thread + and create two. The lock is in this process's memory, so it only protects a single running instance of the + service. That is the deliberate deployment (spec 321: WhatsApp traffic is not expected to need more than + one instance); with several instances it would have to move to a shared place such as a database lock. - # Get details of the thread that the user last interacted with + Args: + first_message (str): The incoming text, used to title a new thread. + + Returns: + str | None: The thread id, or None if the user was already told that something failed. + """ + async with user_locks.hold(get_subject(self.token) or self.bsuid): try: last_thread_info = await self.ansari_client.get_last_thread_info(self.token) - thread_id = last_thread_info.get("thread_id") - last_msg_time = last_thread_info.get("last_message_time") except ThreadInfoError as e: logger.error(f"Failed to get thread info: {e}") await self.send_whatsapp_message( "Sorry, we're having trouble accessing your chat history. Please try again later." ) - return + return None + thread_id = last_thread_info.get("thread_id") + last_msg_time = last_thread_info.get("last_message_time") if last_msg_time and isinstance(last_msg_time, str): last_msg_time = datetime.fromisoformat(last_msg_time.replace("Z", "+00:00")) - # Calculate the time passed since the last message passed_time, passed_time_logging = calculate_time_passed(last_msg_time) logger.debug(f"Time passed since user's last whatsapp message: {passed_time_logging}") - # Get the allowed retention time - allowed_time = get_retention_time_seconds() - - # Create a new thread if no threads have been previously created, - # or the last message has passed the allowed retention time - if thread_id is None or passed_time > allowed_time: - first_few_words = " ".join(incoming_txt_msg.split()[:6]) + if thread_id is not None and passed_time <= get_retention_time_seconds(): + return thread_id + if thread_id is not None: try: - result = await self.ansari_client.create_thread(self.token, first_few_words) - thread_id = result.get("thread_id") - logger.info("Created a new thread for the whatsapp user, " + "as the allowed retention time has passed.") - except ThreadCreationError as e: - logger.error(f"Failed to create thread: {e}") + await self.ansari_client.delete_thread(self.token, thread_id) + logger.info("Deleted the user's expired thread") + except ThreadDeletionError as e: + # Keep one thread per user: do not start a second one next to an expired thread we could not delete + logger.error(f"Failed to delete the expired thread: {e}") await self.send_whatsapp_message( - "An unexpected error occurred while creating a new chat session. Please try again later." + "An unexpected error occurred while starting a new chat session. Please try again later." ) - return + return None + + try: + result = await self.ansari_client.create_thread(self.token, " ".join(first_message.split()[:6])) + except ThreadCreationError as e: + logger.error(f"Failed to create thread: {e}") + await self.send_whatsapp_message( + "An unexpected error occurred while creating a new chat session. Please try again later." + ) + return None + + logger.info("Created a new thread for the whatsapp user") + return result.get("thread_id") + + async def handle_text_message(self) -> None: + """Process an incoming text message and send a response to the WhatsApp sender.""" + if not self.bsuid or not self.token: + logger.error("Cannot process text message: the user is not authenticated") + return + + try: + incoming_txt_msg = self.incoming_msg_body["body"] + logger.debug(f"Whatsapp user sent a text message ({len(incoming_txt_msg)} characters)") + + thread_id = await self._resolve_thread(incoming_txt_msg) + if thread_id is None: + if self.typing_indicator_task and not self.typing_indicator_task.done(): + self.typing_indicator_task.cancel() + return # Process the message using the Ansari backend API try: diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py b/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py index efeb831b..785b6ffd 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/exceptions.py @@ -29,6 +29,12 @@ class ThreadCreationError(AnsariClientError): pass +class ThreadDeletionError(AnsariClientError): + """Deleting an expired thread failed.""" + + pass + + class ThreadInfoError(AnsariClientError): """Retrieving thread info failed.""" diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/user_locks.py b/apps/whatsapp/src/ansari_whatsapp/utils/user_locks.py new file mode 100644 index 00000000..ca2ac0db --- /dev/null +++ b/apps/whatsapp/src/ansari_whatsapp/utils/user_locks.py @@ -0,0 +1,36 @@ +# Per-user locks for ansari-whatsapp +"""In-process locks that make one user's steps run one at a time.""" + +import asyncio +from contextlib import asynccontextmanager + + +class UserLocks: + """One asyncio lock per user key, dropped again once nobody holds or waits for it. + + These locks live in this process's memory, so they only serialize work inside ONE running instance of + the service. That is deliberate (spec 321): the service runs as a single instance, because WhatsApp + traffic is not expected to need more. If it ever runs as several instances, this must move to a shared + place, for example a database lock. + """ + + def __init__(self) -> None: + self._locks: dict[str, list] = {} # key -> [lock, number of holders and waiters] + + @asynccontextmanager + async def hold(self, key: str): + """Wait for this user's lock, run the `async with` body alone for that user, then release.""" + entry = self._locks.get(key) + if entry is None: + entry = self._locks[key] = [asyncio.Lock(), 0] + entry[1] += 1 + try: + async with entry[0]: + yield + finally: + entry[1] -= 1 + if entry[1] == 0: + del self._locks[key] + + +user_locks = UserLocks() diff --git a/apps/whatsapp/tests/test_thread_lifecycle.py b/apps/whatsapp/tests/test_thread_lifecycle.py new file mode 100644 index 00000000..1262c0c9 --- /dev/null +++ b/apps/whatsapp/tests/test_thread_lifecycle.py @@ -0,0 +1,241 @@ +"""Tests for the one-thread-per-user lifecycle (spec 321, phase 3).""" + +import asyncio +from datetime import datetime, timedelta, timezone + +import httpx +import pytest + +from ansari_whatsapp.services.ansari_client_mock import AnsariClientMock +from ansari_whatsapp.services.ansari_client_real import AnsariClientReal +from ansari_whatsapp.services.auth_client_mock import AuthClientMock +from ansari_whatsapp.services.service_provider import get_ansari_client, reset_ansari_client, reset_auth_client +from ansari_whatsapp.services.whatsapp_conversation_manager import WhatsAppConversationManager +from ansari_whatsapp.utils.exceptions import ThreadDeletionError +from ansari_whatsapp.utils.time_utils import get_retention_time_seconds +from ansari_whatsapp.utils.user_locks import UserLocks + +BSUID = "US.13491208655302741918" +OTHER_BSUID = "US.99999999999999999999" +EXPIRY = get_retention_time_seconds() + + +@pytest.fixture(autouse=True) +def fresh_singletons(monkeypatch): + reset_ansari_client() + reset_auth_client() + monkeypatch.setattr("ansari_whatsapp.services.ansari_client_mock.random.uniform", lambda a, b: 0) + yield + reset_ansari_client() + reset_auth_client() + + +def _manager(bsuid: str = BSUID) -> WhatsAppConversationManager: + manager = WhatsAppConversationManager( + bsuid=bsuid, incoming_msg_type="text", incoming_msg_body={"body": "hello"}, message_id="wamid.T" + ) + manager.token = asyncio.run(AuthClientMock().get_token(bsuid)) + return manager + + +def _age_thread(thread_id: str, seconds: float) -> None: + """Make the mock thread's last message `seconds` old.""" + old = datetime.now(timezone.utc) - timedelta(seconds=seconds) + get_ansari_client()._threads[thread_id]["last_message_time"] = old.isoformat() + + +def _threads_of(manager: WhatsAppConversationManager) -> list[str]: + from ansari_whatsapp.utils.token_utils import get_subject + + user = get_subject(manager.token) + return [t["thread_id"] for t in get_ansari_client()._threads.values() if t["user"] == user] + + +_RealAsyncClient = httpx.AsyncClient + + +def _real_client(monkeypatch, handler) -> AnsariClientReal: + monkeypatch.setattr( + "ansari_whatsapp.services.ansari_client_real.httpx.AsyncClient", + lambda **kw: _RealAsyncClient(transport=httpx.MockTransport(handler), **kw), + ) + return AnsariClientReal() + + +def test_real_client_deletes_the_thread_with_the_users_token(monkeypatch): + seen = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, request.url.path, request.headers.get("authorization"))) + return httpx.Response(200, json={"message": "Thread deleted successfully"}) + + asyncio.run(_real_client(monkeypatch, handler).delete_thread("jwt", "t1")) + + assert seen == [("DELETE", "/api/v2/threads/t1", "Bearer jwt")] + + +def test_real_client_treats_an_already_deleted_thread_as_success(monkeypatch): + asyncio.run(_real_client(monkeypatch, lambda r: httpx.Response(404, json={})).delete_thread("jwt", "t1")) + + +def test_real_client_delete_failure_raises(monkeypatch): + with pytest.raises(ThreadDeletionError): + asyncio.run(_real_client(monkeypatch, lambda r: httpx.Response(500, json={})).delete_thread("jwt", "t1")) + + +def test_first_message_creates_the_only_thread(): + manager = _manager() + + thread_id = asyncio.run(manager._resolve_thread("hello there")) + + assert thread_id is not None + assert _threads_of(manager) == [thread_id] + + +def test_message_within_expiry_reuses_the_thread_and_keeps_context(): + manager = _manager() + first = asyncio.run(manager._resolve_thread("hello")) + asyncio.run(manager.ansari_client.process_message(manager.token, first, "remember me")) + _age_thread(first, EXPIRY - 60) # just inside the expiry + + second = asyncio.run(manager._resolve_thread("hello again")) + + assert second == first + assert _threads_of(manager) == [first] + assert get_ansari_client()._threads[first]["messages"][0]["content"] == "remember me" + + +def test_message_after_expiry_deletes_the_old_thread_and_starts_a_new_one(): + manager = _manager() + old = asyncio.run(manager._resolve_thread("hello")) + asyncio.run(manager.ansari_client.process_message(manager.token, old, "old message")) + _age_thread(old, EXPIRY + 60) # just outside the expiry + + new = asyncio.run(manager._resolve_thread("hello again")) + + assert new != old + assert _threads_of(manager) == [new] + assert old not in get_ansari_client()._threads # the old thread, and so its messages, are gone + assert get_ansari_client()._threads[new]["messages"] == [] + + +def test_another_users_thread_is_never_touched(): + mine = _manager() + theirs = _manager(OTHER_BSUID) + their_thread = asyncio.run(theirs._resolve_thread("hello")) + my_thread = asyncio.run(mine._resolve_thread("hello")) + _age_thread(my_thread, EXPIRY + 60) + + asyncio.run(mine._resolve_thread("hello again")) + + assert _threads_of(theirs) == [their_thread] + + +def test_failed_deletion_does_not_create_a_second_thread(monkeypatch): + manager = _manager() + old = asyncio.run(manager._resolve_thread("hello")) + _age_thread(old, EXPIRY + 60) + + async def failing_delete(self, token, thread_id): + raise ThreadDeletionError("apps/api is down") + + sent = [] + + async def spy_send(self, msg_body, recipient_bsuid=None): + sent.append(msg_body) + + monkeypatch.setattr(AnsariClientMock, "delete_thread", failing_delete) + monkeypatch.setattr(WhatsAppConversationManager, "send_whatsapp_message", spy_send) + + result = asyncio.run(manager._resolve_thread("hello again")) + + assert result is None + assert _threads_of(manager) == [old] # still exactly one + assert sent # the user was told + + +def test_two_close_messages_after_expiry_leave_exactly_one_thread(): + first_manager = _manager() + second_manager = _manager() + old = asyncio.run(first_manager._resolve_thread("hello")) + _age_thread(old, EXPIRY + 60) + + async def both(): + # Slow the mock down so the two messages genuinely overlap + monkey_delay = 0.05 + original = AnsariClientMock.get_last_thread_info + + async def slow_get_last(self, token): + result = await original(self, token) + await asyncio.sleep(monkey_delay) + return result + + AnsariClientMock.get_last_thread_info = slow_get_last + try: + return await asyncio.gather( + first_manager._resolve_thread("message one"), second_manager._resolve_thread("message two") + ) + finally: + AnsariClientMock.get_last_thread_info = original + + a, b = asyncio.run(both()) + + assert a == b # the second message reused the thread the first one created + assert _threads_of(first_manager) == [a] + + +def test_without_the_lock_two_close_messages_would_create_two_threads(monkeypatch): + """Guards the lock test above: if the lock did nothing, that test would not notice.""" + + class NoLock(UserLocks): + def hold(self, key): + from contextlib import asynccontextmanager + + @asynccontextmanager + async def noop(): + yield + + return noop() + + monkeypatch.setattr("ansari_whatsapp.services.whatsapp_conversation_manager.user_locks", NoLock()) + first_manager = _manager() + second_manager = _manager() + old = asyncio.run(first_manager._resolve_thread("hello")) + _age_thread(old, EXPIRY + 60) + + async def both(): + original = AnsariClientMock.get_last_thread_info + + async def slow_get_last(self, token): + result = await original(self, token) + await asyncio.sleep(0.05) + return result + + monkeypatch.setattr(AnsariClientMock, "get_last_thread_info", slow_get_last) + return await asyncio.gather( + first_manager._resolve_thread("message one"), second_manager._resolve_thread("message two") + ) + + asyncio.run(both()) + + assert len(_threads_of(first_manager)) == 2 + + +def test_user_locks_are_per_user_and_cleaned_up(): + locks = UserLocks() + order = [] + + async def work(key, name, delay): + async with locks.hold(key): + order.append(f"{name} start") + await asyncio.sleep(delay) + order.append(f"{name} end") + + async def run(): + await asyncio.gather(work("a", "a1", 0.05), work("a", "a2", 0.0), work("b", "b1", 0.0)) + + asyncio.run(run()) + + assert order.index("a1 end") < order.index("a2 start") # same user: one at a time + assert order.index("b1 start") < order.index("a1 end") # other user: not blocked + assert locks._locks == {} # nothing left behind From 9aae9496fb2285224bb33d889a1cc60af08a4f2d Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 19:14:00 +0300 Subject: [PATCH 030/247] [Spec 321][Phase: thread_lifecycle] fix: Cover Z timestamps and missing thread id, document lock scope --- .../services/whatsapp_conversation_manager.py | 15 ++++- apps/whatsapp/tests/test_thread_lifecycle.py | 66 +++++++++++++++++++ .../321-rfc-whatsapp-support-in-the-mo.md | 1 + .../321-thread_lifecycle-iter1-claude.txt | 32 +++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-thread_lifecycle-iter1-claude.txt diff --git a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py index e096ccaf..1963291f 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py @@ -189,6 +189,11 @@ async def _resolve_thread(self, first_message: str) -> str | None: service. That is the deliberate deployment (spec 321: WhatsApp traffic is not expected to need more than one instance); with several instances it would have to move to a shared place such as a database lock. + Only this step is serialized, not the answer that follows. If a user sends a second message while the first + is still being answered, both use the same thread and are processed at the same time, so their rows can + interleave in the thread's history. This is accepted: holding the lock for the whole answer (a stream can + take a minute) would make a user's next message wait that long, and a user sending twice in a row is rare. + Args: first_message (str): The incoming text, used to title a new thread. @@ -237,8 +242,16 @@ async def _resolve_thread(self, first_message: str) -> str | None: ) return None + new_thread_id = result.get("thread_id") + if not new_thread_id: + logger.error("Thread creation returned no thread id") + await self.send_whatsapp_message( + "An unexpected error occurred while creating a new chat session. Please try again later." + ) + return None + logger.info("Created a new thread for the whatsapp user") - return result.get("thread_id") + return new_thread_id async def handle_text_message(self) -> None: """Process an incoming text message and send a response to the WhatsApp sender.""" diff --git a/apps/whatsapp/tests/test_thread_lifecycle.py b/apps/whatsapp/tests/test_thread_lifecycle.py index 1262c0c9..e2fd49f8 100644 --- a/apps/whatsapp/tests/test_thread_lifecycle.py +++ b/apps/whatsapp/tests/test_thread_lifecycle.py @@ -119,6 +119,72 @@ def test_message_after_expiry_deletes_the_old_thread_and_starts_a_new_one(): assert get_ansari_client()._threads[new]["messages"] == [] +def test_expiry_is_read_from_the_z_timestamp_apps_api_really_sends(monkeypatch): + """apps/api sends `...Z`, while the mock stores `+00:00`, so this is the format production depends on.""" + manager = _manager() + four_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%S.000Z") + calls = [] + + async def old_thread_info(self, token): + return {"thread_id": "old", "last_message_time": four_hours_ago} + + async def spy_delete(self, token, thread_id): + calls.append(("delete", thread_id)) + + async def spy_create(self, token, title): + calls.append(("create", title)) + return {"thread_id": "new"} + + monkeypatch.setattr(AnsariClientMock, "get_last_thread_info", old_thread_info) + monkeypatch.setattr(AnsariClientMock, "delete_thread", spy_delete) + monkeypatch.setattr(AnsariClientMock, "create_thread", spy_create) + + assert asyncio.run(manager._resolve_thread("hello")) == "new" + assert calls == [("delete", "old"), ("create", "hello")] + + +def test_expired_thread_flow_against_the_real_client_makes_the_expected_calls(monkeypatch): + """List threads, delete the expired one, create a new one: in that order, all with the user's token.""" + seen = [] + four_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%S.000Z") + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, request.url.path, request.headers.get("authorization"))) + if request.method == "GET": + return httpx.Response(200, json=[{"thread_id": "old", "updated_at": four_hours_ago}]) + if request.method == "DELETE": + return httpx.Response(200, json={"message": "Thread deleted successfully"}) + return httpx.Response(200, json={"thread_id": "new"}) + + manager = _manager() + manager.ansari_client = _real_client(monkeypatch, handler) + + assert asyncio.run(manager._resolve_thread("hello")) == "new" + token = f"Bearer {manager.token}" + assert seen == [ + ("GET", "/api/v2/threads", token), + ("DELETE", "/api/v2/threads/old", token), + ("POST", "/api/v2/threads", token), + ] + + +def test_creation_response_without_a_thread_id_is_a_failure_the_user_hears_about(monkeypatch): + manager = _manager() + sent = [] + + async def no_id(self, token, title): + return {} + + async def spy_send(self, msg_body, recipient_bsuid=None): + sent.append(msg_body) + + monkeypatch.setattr(AnsariClientMock, "create_thread", no_id) + monkeypatch.setattr(WhatsAppConversationManager, "send_whatsapp_message", spy_send) + + assert asyncio.run(manager._resolve_thread("hello")) is None + assert sent + + def test_another_users_thread_is_never_touched(): mine = _manager() theirs = _manager(OTHER_BSUID) diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index b48a059a..fa195fd7 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -128,6 +128,7 @@ Finish environment behaviour and make the service testable and deployable by oth - In `apps/whatsapp`: maintenance behaviour driven by the same global flag as `apps/api` (`MAINTENANCE_MODE`) instead of the WhatsApp-specific flag, keeping the notice message; keep the staging `!d ` filter; `README.md` and `tests/README.md` for the app (both still reference legacy files that were not imported). - Deployment notes: per-environment Meta credentials and secrets, and the deploy steps for staging and production, following the monorepo's existing deployment docs. - Findings from the Phase 2 review to resolve here: (a) apps/api reports a failed or empty answer as an inline `Error: ...` at the end of a 200 stream, which the client currently relays to the user unlogged, so detect it, log it and send a failure notice; (b) deploy blocker to confirm before the staging smoke test: the service now requires `from_user_id` in every webhook, and sending to a BSUID (`recipient`) is untested against Meta's live API (Meta's docs disagree on when it is supported), so check the webhook Graph API version and test a real send; (c) `README.md` still documents the six deleted legacy endpoints. +- Findings from the Phase 3 review to resolve here: (a) the per-user lock only protects the single-replica deployment, so the deployment notes must state that the service runs as exactly one replica (a hosting-dashboard setting, not in the repo); (b) failures of the thread list, create and delete calls are logged but not captured in Sentry, unlike message-processing failures, so add that with the logging work. - Deploy-path items found in the Phase 1 review: bump the uv version in the `Dockerfile` (it pins 0.5.11 against a newer lockfile format) and make its second `uv sync` use `--frozen`; decide whether to keep `/sentry-debug` and `clean_logs.sh`; ask an admin to make the `whatsapp` CI job a required check on `develop`. #### Deliverables diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-thread_lifecycle-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-thread_lifecycle-iter1-claude.txt new file mode 100644 index 00000000..b65e2a02 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-thread_lifecycle-iter1-claude.txt @@ -0,0 +1,32 @@ +I'll start by reading the key files for this phase.I read the phase-3 implementation (`_resolve_thread`, `user_locks.py`, the client `delete_thread` additions, and `test_thread_lifecycle.py`), and verified the `apps/api` contract it depends on by reading the actual route and DB helpers. + +## What I verified against apps/api (not from memory) + +- `GET /api/v2/threads` (`apps/api/src/app/api/v2/threads/route.ts:20-28`) returns a bare array, newest-first (`findThreadsByUser` → `.orderBy(desc(threads.updatedAt))`, `lib/db/threads.ts:65`), with `thread_id` and `updated_at` serialized via `toISOString()` → `"…T10:00:00.000Z"`. The client's `threads[0]` + `updated_at` mapping is correct, and the `.replace("Z", "+00:00")` + `fromisoformat` parse is valid on Python 3.10 (3 fractional digits). +- `updated_at` really is bumped on every message (`createMessage`, `lib/db/threads.ts:141-147`), and deliberately *not* on tool-call orphans — so "time since last message" is a faithful expiry signal. +- `DELETE /api/v2/threads/{id}` is scoped by `and(eq(threads.id,id), eq(threads.userId,userId))` (`:121-127`), returns 200 `{message:…}` and 404 when nothing matched; messages are cascade-deleted by the FK (`db/schema/messages.ts`). So treating 404 as success is right, and the spec's "the old thread **and its messages** are deleted" holds without any apps/api change. The plan asked for this to be recorded — it should go in the review doc. + +## Phase deliverables + +All three acceptance criteria are met, with tests that are better than average: `test_message_within_expiry_…` (just inside), `test_message_after_expiry_…` (just outside, asserts the old thread *and* its messages are gone), `test_another_users_thread_is_never_touched`, `test_failed_deletion_does_not_create_a_second_thread` (correctly refuses to create a second thread when the delete fails), and — the part I want to call out — `test_without_the_lock_two_close_messages_would_create_two_threads`, a negative control proving the concurrency test isn't vacuous. That is exactly the discipline this repo's lessons ask for. The in-process-only scope of the lock is documented in both `UserLocks` and `_resolve_thread`. + +## Non-blocking findings + +1. **Only thread *resolution* is serialized, not processing.** The lock is released before `process_message`, so two messages arriving a second apart both resolve the same thread and then stream concurrently against it. The thread invariant survives (that's what this phase promised), but the two turns interleave their user/assistant rows in one thread's history — which matters given the facilitator rebuilds Gemini history from those rows. Holding the lock across `handle_text_message`'s processing is a small change and only ever delays the *same* user. If you'd rather not (a 60s stream would block that user's next message), the plan's criterion says to *document the behaviour* — the docstring currently documents only the creation race. +2. **The production timestamp format isn't exercised by the lifecycle tests.** `AnsariClientMock` stores `datetime.now(timezone.utc).isoformat()` → `+00:00`, while apps/api emits `…Z`. The `Z`-handling line is the one the expiry decision depends on in production and it is only covered indirectly (`test_bsuid_auth_flow.py:147` asserts the client passes the string through, not that `_resolve_thread` parses it). A single test — stub `get_last_thread_info` to return a 4-hour-old `…Z` string, assert delete-then-create — would close that. A real-client `MockTransport` run asserting the 4-call sequence (GET → DELETE → POST create → POST message) would cover the delete wiring end to end too. +3. **`result.get("thread_id")` can return `None` silently.** If a client ever returns a dict without the key, `_resolve_thread` returns `None` and `handle_text_message` gives up without telling the user. Both current clients guarantee the key (the real one raises `ThreadCreationError` on `KeyError`), so this is robustness only — treating a missing id as a creation failure would keep the "the user was already told" contract honest. +4. **Thread info/create/delete failures log but don't reach Sentry**, unlike `process_message` failures which `capture_exception`. An apps/api thread-endpoint outage would be invisible in Sentry. Small, and a natural fit for phase 4's logging item. +5. **Deploy constraint worth writing down in phase 4:** the one-thread invariant depends on the service running as a *single* replica. That's a Railway dashboard setting, not something in the repo — the deployment notes should state it explicitly, since a second replica silently breaks the guarantee this phase just built. + +--- +VERDICT: APPROVE +SUMMARY: One-thread-per-user with expiry is correctly implemented against the real apps/api contract and well tested, including a negative control proving the per-user lock matters; remaining items are non-blocking. +CONFIDENCE: HIGH +--- + +KEY_ISSUES: +- Lock covers thread resolution only; two close messages still stream concurrently into the same thread — either extend the lock over `process_message` or document the decision as the plan's criterion asks. +- Expiry parsing of apps/api's real `…Z` timestamp is untested (the mock emits `+00:00`); one test on `_resolve_thread` would cover the line production depends on. +- `_resolve_thread` can return `None` from a create response missing `thread_id`, leaving the user un-notified (robustness nit; unreachable with current clients). +- Thread info/create/delete failures are logged but not captured in Sentry, unlike message-processing failures. +- Single-replica assumption behind the invariant should be recorded in the phase-4 deployment notes (Railway replica count is dashboard config). \ No newline at end of file From a830d9045258a0e0732be43e564f3c71dfbf6da3 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 19:14:17 +0300 Subject: [PATCH 031/247] =?UTF-8?q?chore(porch):=20321=20advance=20plan=20?= =?UTF-8?q?phase=20=E2=86=92=20environments=5Fand=5Fdocs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../321-rfc-whatsapp-support-in-the-mo/status.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 96cfda35..7ab5bb13 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -11,11 +11,11 @@ plan_phases: status: complete - id: thread_lifecycle title: One thread per user with expiry - status: in_progress + status: complete - id: environments_and_docs title: Maintenance mode, staging filter, local testing and docs - status: pending -current_plan_phase: thread_lifecycle + status: in_progress +current_plan_phase: environments_and_docs gates: spec-approval: status: approved @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: true +build_complete: false history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T14:59:15.995Z' +updated_at: '2026-10-25T16:14:17.697Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -53,3 +53,5 @@ context_refreshes: - boundary: plan-phase:thread_lifecycle at: '2026-10-25T14:58:53.028Z' acknowledged_at: '2026-10-25T14:59:15.993Z' + - boundary: plan-phase:environments_and_docs + at: '2026-10-25T16:14:17.696Z' From c8598151c730115e45bb7de94f163aa9d9cbb5a0 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 21:41:49 +0300 Subject: [PATCH 032/247] [Spec 321][Phase: environments_and_docs] feat: Use the /chat event stream, add WA_MAINTENANCE_MODE, remove the per-user lock --- apps/whatsapp/.env.example | 4 +- apps/whatsapp/src/ansari_whatsapp/app/main.py | 2 +- .../services/ansari_client_real.py | 43 ++++-- .../services/whatsapp_conversation_manager.py | 112 +++++++------- .../src/ansari_whatsapp/utils/config.py | 3 +- .../src/ansari_whatsapp/utils/user_locks.py | 36 ----- apps/whatsapp/tests/test_bsuid_auth_flow.py | 75 +++++++++- .../tests/test_environment_behaviour.py | 138 ++++++++++++++++++ apps/whatsapp/tests/test_thread_lifecycle.py | 88 ----------- .../321-rfc-whatsapp-support-in-the-mo.md | 10 +- .../321-rfc-whatsapp-support-in-the-mo.md | 10 +- 11 files changed, 314 insertions(+), 207 deletions(-) delete mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/user_locks.py create mode 100644 apps/whatsapp/tests/test_environment_behaviour.py diff --git a/apps/whatsapp/.env.example b/apps/whatsapp/.env.example index c79bdaa3..94121d9c 100644 --- a/apps/whatsapp/.env.example +++ b/apps/whatsapp/.env.example @@ -127,7 +127,9 @@ META_ANSARI_APP_SECRET="your_app_secret_here" ###################################### Related to Chat Settings ###################################### -WHATSAPP_UNDER_MAINTENANCE="False" +# When True, every message gets a "down for maintenance" reply and is not processed. +# This is the WhatsApp service's own switch; it does NOT follow apps/api's MAINTENANCE_MODE. +WA_MAINTENANCE_MODE="False" # Hours to retain chat history WHATSAPP_CHAT_RETENTION_HOURS="3" # Maximum age in seconds for incoming WhatsApp messages to be processed (24 hours = 86400 seconds) diff --git a/apps/whatsapp/src/ansari_whatsapp/app/main.py b/apps/whatsapp/src/ansari_whatsapp/app/main.py index 4f05c91f..29ef4eb0 100644 --- a/apps/whatsapp/src/ansari_whatsapp/app/main.py +++ b/apps/whatsapp/src/ansari_whatsapp/app/main.py @@ -333,7 +333,7 @@ async def main_webhook( ) # Check if the WhatsApp service is enabled - if get_settings().WHATSAPP_UNDER_MAINTENANCE: + if get_settings().WA_MAINTENANCE_MODE: # Inform the user that the service is down for maintenance background_tasks.add_task( conversation_manager.send_whatsapp_message, diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py index 28351f31..5e2b4143 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py @@ -1,6 +1,8 @@ # Real API Client for ansari-whatsapp """Real client implementation for interacting with the Ansari backend API via HTTP.""" +import json + import httpx from loguru import logger @@ -17,9 +19,6 @@ # Per-client attribution id sent to apps/api (lowercase, per packages/types attribution) CLIENT_ID = "askansari-whatsapp" -# The invisible keep-alive character apps/api sends while the model is still thinking -HEARTBEAT_CHAR = "\u200b" - class AnsariClientReal(AnsariClientBase): """Real client for the Ansari backend API that makes actual HTTP requests. @@ -163,8 +162,10 @@ async def process_message(self, token: str, thread_id: str, message: str) -> str """ Send a message to apps/api and collect the full streamed answer. - Uses `POST /api/v2/threads/{id}`, whose body is the raw answer text. While the model is - still thinking, apps/api sends invisible zero-width spaces as keep-alives; they are removed. + Uses `POST /api/v2/threads/{id}/chat`, a Server-Sent Events stream, the same route the web app uses: + lines starting with `:` are keep-alives, and each `data:` line is a JSON event: `text` (a piece of the + answer), `tool_call` / `tool_result` (ignored), `error`, and a final `done`. A stream with an `error` + event, or one that ends without `done`, is a failure, so a half answer is never passed on as complete. Args: token (str): The user's JWT from apps/auth. @@ -178,8 +179,8 @@ async def process_message(self, token: str, thread_id: str, message: str) -> str MessageProcessingError: If message processing fails. """ try: - url = f"{self.base_url}/api/v2/threads/{thread_id}" - data = {"content": message} + url = f"{self.base_url}/api/v2/threads/{thread_id}/chat" + data = {"message": message} # Use stream=True to receive the response as a stream async with self.client.stream("POST", url, json=data, headers=self._auth(token)) as response: @@ -188,12 +189,30 @@ async def process_message(self, token: str, thread_id: str, message: str) -> str logger.error(f"Error from backend API: {error_detail}") raise MessageProcessingError(f"Backend returned HTTP {response.status_code}: {error_detail}") - # Accumulate the full response as we receive chunks + # Accumulate the answer from the `text` events full_response = "" - async for chunk in response.aiter_text(): - # Each chunk is a token from the streaming response - if chunk: - full_response += chunk.replace(HEARTBEAT_CHAR, "") + done = False + async for line in response.aiter_lines(): + if not line.startswith("data:"): + continue # keep-alive comments and blank separator lines + try: + event = json.loads(line[len("data:") :]) + except ValueError as e: + logger.error("apps/api sent a chat event that is not JSON") + raise MessageProcessingError("Malformed chat event") from e + event_type = event.get("type") if isinstance(event, dict) else None + if event_type == "text": + full_response += event.get("content") or "" + elif event_type == "error": + # The message is apps/api's own text, never user content, so it is safe to log + logger.error(f"apps/api reported a chat error: {event.get('message')}") + raise MessageProcessingError("apps/api reported an error while answering") + elif event_type == "done": + done = True + + if not done: + logger.error("The chat stream from apps/api ended before it was complete") + raise MessageProcessingError("Chat stream ended before completion") if not full_response: logger.warning("Received empty response from backend") diff --git a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py index 1963291f..cf6c107d 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py @@ -20,7 +20,6 @@ MessageProcessingError, ) from ansari_whatsapp.utils.token_utils import get_subject -from ansari_whatsapp.utils.user_locks import user_locks from ansari_whatsapp.utils.time_utils import calculate_time_passed, get_retention_time_seconds from ansari_whatsapp.presenters.whatsapp_message_formatter import format_for_whatsapp from ansari_whatsapp.utils.whatsapp_message_splitter import split_message @@ -182,17 +181,19 @@ async def _resolve_thread(self, first_message: str) -> str | None: """Return the id of the user's one current thread, replacing it if it has expired. Each user has exactly one thread. If the last message is older than the expiry time, the old thread and - its messages are deleted and a new thread starts. Messages from different users never wait for each other. - - This runs under the user's lock, so two messages arriving close together cannot both see an expired thread - and create two. The lock is in this process's memory, so it only protects a single running instance of the - service. That is the deliberate deployment (spec 321: WhatsApp traffic is not expected to need more than - one instance); with several instances it would have to move to a shared place such as a database lock. - - Only this step is serialized, not the answer that follows. If a user sends a second message while the first - is still being answered, both use the same thread and are processed at the same time, so their rows can - interleave in the thread's history. This is accepted: holding the lock for the whole answer (a stream can - take a minute) would make a user's next message wait that long, and a user sending twice in a row is rare. + its messages are deleted and a new thread starts. + + Known corner case, deliberately NOT handled (spec 321): if a user's first message after the expiry is + followed by a second one before the first has finished deleting and creating, both messages can see the + expired thread and each create a new one, leaving the user with two. We considered a per-user lock and + decided against it because: + - two messages back to back from one user are unlikely, and the delete-and-create takes well under a second, + so the second message normally finds the new thread; + - the damage is small: an extra thread does not break replies, because the newest thread is always used; + - it is easy to clean up: an occasional manual sweep deletes all but the newest thread of each WhatsApp + user (threads with source `whatsapp`). + A lock held only around this step would also not stop two answers streaming into one thread at the same + time (their messages could interleave in its history); that is accepted for the same reasons. Args: first_message (str): The incoming text, used to title a new thread. @@ -200,58 +201,57 @@ async def _resolve_thread(self, first_message: str) -> str | None: Returns: str | None: The thread id, or None if the user was already told that something failed. """ - async with user_locks.hold(get_subject(self.token) or self.bsuid): - try: - last_thread_info = await self.ansari_client.get_last_thread_info(self.token) - except ThreadInfoError as e: - logger.error(f"Failed to get thread info: {e}") - await self.send_whatsapp_message( - "Sorry, we're having trouble accessing your chat history. Please try again later." - ) - return None + try: + last_thread_info = await self.ansari_client.get_last_thread_info(self.token) + except ThreadInfoError as e: + logger.error(f"Failed to get thread info: {e}") + await self.send_whatsapp_message( + "Sorry, we're having trouble accessing your chat history. Please try again later." + ) + return None + + thread_id = last_thread_info.get("thread_id") + last_msg_time = last_thread_info.get("last_message_time") + if last_msg_time and isinstance(last_msg_time, str): + last_msg_time = datetime.fromisoformat(last_msg_time.replace("Z", "+00:00")) + + passed_time, passed_time_logging = calculate_time_passed(last_msg_time) + logger.debug(f"Time passed since user's last whatsapp message: {passed_time_logging}") - thread_id = last_thread_info.get("thread_id") - last_msg_time = last_thread_info.get("last_message_time") - if last_msg_time and isinstance(last_msg_time, str): - last_msg_time = datetime.fromisoformat(last_msg_time.replace("Z", "+00:00")) - - passed_time, passed_time_logging = calculate_time_passed(last_msg_time) - logger.debug(f"Time passed since user's last whatsapp message: {passed_time_logging}") - - if thread_id is not None and passed_time <= get_retention_time_seconds(): - return thread_id - - if thread_id is not None: - try: - await self.ansari_client.delete_thread(self.token, thread_id) - logger.info("Deleted the user's expired thread") - except ThreadDeletionError as e: - # Keep one thread per user: do not start a second one next to an expired thread we could not delete - logger.error(f"Failed to delete the expired thread: {e}") - await self.send_whatsapp_message( - "An unexpected error occurred while starting a new chat session. Please try again later." - ) - return None + if thread_id is not None and passed_time <= get_retention_time_seconds(): + return thread_id + if thread_id is not None: try: - result = await self.ansari_client.create_thread(self.token, " ".join(first_message.split()[:6])) - except ThreadCreationError as e: - logger.error(f"Failed to create thread: {e}") + await self.ansari_client.delete_thread(self.token, thread_id) + logger.info("Deleted the user's expired thread") + except ThreadDeletionError as e: + # Keep one thread per user: do not start a second one next to an expired thread we could not delete + logger.error(f"Failed to delete the expired thread: {e}") await self.send_whatsapp_message( - "An unexpected error occurred while creating a new chat session. Please try again later." + "An unexpected error occurred while starting a new chat session.. Please try again later." ) return None - new_thread_id = result.get("thread_id") - if not new_thread_id: - logger.error("Thread creation returned no thread id") - await self.send_whatsapp_message( - "An unexpected error occurred while creating a new chat session. Please try again later." - ) - return None + try: + result = await self.ansari_client.create_thread(self.token, " ".join(first_message.split()[:6])) + except ThreadCreationError as e: + logger.error(f"Failed to create thread: {e}") + await self.send_whatsapp_message( + "An unexpected error occurred while creating a new chat session. Please try again later." + ) + return None + + new_thread_id = result.get("thread_id") + if not new_thread_id: + logger.error("Thread creation returned no thread id") + await self.send_whatsapp_message( + "An unexpected error occurred while creating a new chat session. Please try again later.." + ) + return None - logger.info("Created a new thread for the whatsapp user") - return new_thread_id + logger.info("Created a new thread for the whatsapp user") + return new_thread_id async def handle_text_message(self) -> None: """Process an incoming text message and send a response to the WhatsApp sender.""" diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/config.py b/apps/whatsapp/src/ansari_whatsapp/utils/config.py index 55ce9863..1980a068 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/config.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/config.py @@ -85,7 +85,8 @@ def META_API_URL(self) -> str: ORIGINS: str | list[str] = "" # Chat settings - WHATSAPP_UNDER_MAINTENANCE: bool = False + # This service's own maintenance switch, independent of apps/api's MAINTENANCE_MODE + WA_MAINTENANCE_MODE: bool = False WHATSAPP_CHAT_RETENTION_HOURS: int = 3 WHATSAPP_MESSAGE_AGE_THRESHOLD_SECONDS: int = 86400 # 1 day diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/user_locks.py b/apps/whatsapp/src/ansari_whatsapp/utils/user_locks.py deleted file mode 100644 index ca2ac0db..00000000 --- a/apps/whatsapp/src/ansari_whatsapp/utils/user_locks.py +++ /dev/null @@ -1,36 +0,0 @@ -# Per-user locks for ansari-whatsapp -"""In-process locks that make one user's steps run one at a time.""" - -import asyncio -from contextlib import asynccontextmanager - - -class UserLocks: - """One asyncio lock per user key, dropped again once nobody holds or waits for it. - - These locks live in this process's memory, so they only serialize work inside ONE running instance of - the service. That is deliberate (spec 321): the service runs as a single instance, because WhatsApp - traffic is not expected to need more. If it ever runs as several instances, this must move to a shared - place, for example a database lock. - """ - - def __init__(self) -> None: - self._locks: dict[str, list] = {} # key -> [lock, number of holders and waiters] - - @asynccontextmanager - async def hold(self, key: str): - """Wait for this user's lock, run the `async with` body alone for that user, then release.""" - entry = self._locks.get(key) - if entry is None: - entry = self._locks[key] = [asyncio.Lock(), 0] - entry[1] += 1 - try: - async with entry[0]: - yield - finally: - entry[1] -= 1 - if entry[1] == 0: - del self._locks[key] - - -user_locks = UserLocks() diff --git a/apps/whatsapp/tests/test_bsuid_auth_flow.py b/apps/whatsapp/tests/test_bsuid_auth_flow.py index 3f5a4d57..ca8eb622 100644 --- a/apps/whatsapp/tests/test_bsuid_auth_flow.py +++ b/apps/whatsapp/tests/test_bsuid_auth_flow.py @@ -61,6 +61,16 @@ def _post_signed_webhook(message: dict): return client.post("/whatsapp/v2", content=body, headers={"X-Hub-Signature-256": f"sha256={signature}"}) +def _sse(*events: dict, ping: bool = True) -> bytes: + """An apps/api `/chat` stream: optional keep-alive comment lines, then one `data:` line per event.""" + lines = [": ping\n\n"] if ping else [] + lines += [f"data: {json.dumps(e)}\n\n" for e in events] + return "".join(lines).encode("utf-8") + + +ANSWER_STREAM = _sse({"type": "text", "content": "As-salamu "}, {"type": "text", "content": "alaykum"}, {"type": "done"}) + + def _text_message(**ids) -> dict: return {"id": "wamid.TEST", "timestamp": str(int(time.time())), "type": "text", "text": {"body": "hello"}, **ids} @@ -126,7 +136,7 @@ def test_mock_auth_same_bsuid_same_user_new_bsuid_new_user(): # ---------- apps/api client ---------- -def test_ansari_client_calls_api_with_jwt_and_strips_heartbeats(monkeypatch): +def test_ansari_client_calls_api_with_jwt_and_reads_the_event_stream(monkeypatch): calls = [] def handler(request: httpx.Request) -> httpx.Response: @@ -135,7 +145,16 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"thread_id": "t1", "updated_at": "2026-10-25T10:00:00.000Z"}]) if request.url.path == "/api/v2/threads": return httpx.Response(200, json={"thread_id": "t2"}) - return httpx.Response(200, content="\u200b\u200bAs-salamu \u200balaykum".encode("utf-8")) + return httpx.Response( + 200, + content=_sse( + {"type": "tool_call", "name": "search_quran"}, + {"type": "tool_result", "tool": "search_quran", "resultCount": 3}, + {"type": "text", "content": "As-salamu "}, + {"type": "text", "content": "alaykum"}, + {"type": "done"}, + ), + ) _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", handler) ansari = AnsariClientReal() @@ -147,6 +166,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert last == {"thread_id": "t1", "last_message_time": "2026-10-25T10:00:00.000Z"} assert created == {"thread_id": "t2"} assert answer == "As-salamu alaykum" + assert calls[-1][:2] == ("POST", "/api/v2/threads/t1/chat") assert all(auth == "Bearer jwt" for _, _, auth in calls) @@ -156,6 +176,55 @@ def test_ansari_client_no_threads_yet(monkeypatch): assert asyncio.run(AnsariClientReal().get_last_thread_info("jwt")) == {"thread_id": None, "last_message_time": None} +def test_ansari_client_sends_the_message_in_the_chat_body(monkeypatch): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return httpx.Response(200, content=ANSWER_STREAM) + + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", handler) + + asyncio.run(AnsariClientReal().process_message("jwt", "t1", "hi there")) + + assert seen["body"] == {"message": "hi there"} + + +def test_ansari_client_error_event_is_a_failure_not_an_answer(monkeypatch): + # `done` follows the error, so only the error event itself can make this fail (not the missing-done check) + stream = _sse({"type": "text", "content": "partial"}, {"type": "error", "message": "model overloaded"}, {"type": "done"}) + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", lambda r: httpx.Response(200, content=stream)) + + with pytest.raises(MessageProcessingError): + asyncio.run(AnsariClientReal().process_message("jwt", "t1", "hi")) + + +def test_ansari_client_stream_cut_off_before_done_is_a_failure(monkeypatch): + stream = _sse({"type": "text", "content": "half an ans"}) + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", lambda r: httpx.Response(200, content=stream)) + + with pytest.raises(MessageProcessingError): + asyncio.run(AnsariClientReal().process_message("jwt", "t1", "hi")) + + +def test_ansari_client_done_without_text_is_an_empty_answer(monkeypatch): + stream = _sse({"type": "done"}) + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", lambda r: httpx.Response(200, content=stream)) + + assert asyncio.run(AnsariClientReal().process_message("jwt", "t1", "hi")) == "" + + +def test_ansari_client_malformed_event_is_a_failure(monkeypatch): + _use_transport( + monkeypatch, + "ansari_whatsapp.services.ansari_client_real", + lambda r: httpx.Response(200, content=b"data: this is not json\n\n"), + ) + + with pytest.raises(MessageProcessingError): + asyncio.run(AnsariClientReal().process_message("jwt", "t1", "hi")) + + def test_ansari_client_error_status_raises(monkeypatch): _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", lambda r: httpx.Response(500, text="boom")) @@ -202,7 +271,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[]) if request.url.path == "/api/v2/threads": return httpx.Response(200, json={"thread_id": "t1"}) - return httpx.Response(200, content=b"answer") + return httpx.Response(200, content=ANSWER_STREAM) monkeypatch.setattr(get_settings(), "MOCK_ANSARI_CLIENT", False) _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", handler) diff --git a/apps/whatsapp/tests/test_environment_behaviour.py b/apps/whatsapp/tests/test_environment_behaviour.py new file mode 100644 index 00000000..00860a26 --- /dev/null +++ b/apps/whatsapp/tests/test_environment_behaviour.py @@ -0,0 +1,138 @@ +"""Tests for maintenance mode and the staging `!d ` filter (spec 321, phase 4).""" + +import hashlib +import hmac +import json +import time + +import pytest +from fastapi.testclient import TestClient + +from ansari_whatsapp.app.main import app +from ansari_whatsapp.services.ansari_client_mock import AnsariClientMock +from ansari_whatsapp.services.meta_api_service_mock import MetaApiServiceMock +from ansari_whatsapp.services.service_provider import reset_ansari_client, reset_auth_client +from ansari_whatsapp.utils.config import get_settings + +BSUID = "US.13491208655302741918" +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def fresh_singletons(): + reset_ansari_client() + reset_auth_client() + yield + reset_ansari_client() + reset_auth_client() + + +def _post(body_text: str): + settings = get_settings() + value = { + "metadata": { + "phone_number_id": settings.META_BUSINESS_PHONE_NUMBER_ID.get_secret_value(), + "display_phone_number": "+1234567890", + }, + "messages": [ + { + "from_user_id": BSUID, + "id": "wamid.TEST", + "timestamp": str(int(time.time())), + "type": "text", + "text": {"body": body_text}, + } + ], + } + payload = {"object": "whatsapp_business_account", "entry": [{"changes": [{"value": value}]}]} + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + secret = settings.META_ANSARI_APP_SECRET.get_secret_value().encode("utf-8") + signature = hmac.new(secret, body, hashlib.sha256).hexdigest() + return client.post("/whatsapp/v2", content=body, headers={"X-Hub-Signature-256": f"sha256={signature}"}) + + +def _spy_on_chat_calls(monkeypatch) -> list: + calls = [] + original = AnsariClientMock.process_message + + async def spy(self, token, thread_id, message): + calls.append(message) + return await original(self, token, thread_id, message) + + monkeypatch.setattr(AnsariClientMock, "process_message", spy) + monkeypatch.setattr("ansari_whatsapp.services.ansari_client_mock.random.uniform", lambda a, b: 0) + return calls + + +def _spy_on_replies(monkeypatch) -> list: + replies = [] + original = MetaApiServiceMock.send_message + + async def spy(self, recipient_phone, message_parts): + replies.extend(message_parts) + return await original(self, recipient_phone, message_parts) + + monkeypatch.setattr(MetaApiServiceMock, "send_message", spy) + return replies + + +def test_maintenance_mode_on_sends_the_notice_and_makes_no_chat_call(monkeypatch): + monkeypatch.setattr(get_settings(), "WA_MAINTENANCE_MODE", True) + chat_calls = _spy_on_chat_calls(monkeypatch) + replies = _spy_on_replies(monkeypatch) + + response = _post("hello") + + assert response.json()["error_code"] == "MAINTENANCE_MODE" + assert chat_calls == [] + assert any("down for maintenance" in part for part in replies) + + +def test_maintenance_mode_off_answers_normally(monkeypatch): + monkeypatch.setattr(get_settings(), "WA_MAINTENANCE_MODE", False) + chat_calls = _spy_on_chat_calls(monkeypatch) + + response = _post("hello") + + assert response.status_code == 200 + assert response.json()["success"] is True + assert chat_calls == ["hello"] + + +def test_apps_api_maintenance_variable_does_not_affect_this_service(monkeypatch): + monkeypatch.setenv("MAINTENANCE_MODE", "true") + get_settings.cache_clear() + try: + assert get_settings().WA_MAINTENANCE_MODE is False + finally: + get_settings.cache_clear() + + +def test_staging_ignores_messages_starting_with_the_dev_prefix(monkeypatch): + monkeypatch.setattr(get_settings(), "DEPLOYMENT_TYPE", "staging") + chat_calls = _spy_on_chat_calls(monkeypatch) + + response = _post("!d what is ansari?") + + assert response.json()["error_code"] == "DEV_FILTER" + assert chat_calls == [] + + +def test_staging_processes_messages_without_the_dev_prefix(monkeypatch): + monkeypatch.setattr(get_settings(), "DEPLOYMENT_TYPE", "staging") + chat_calls = _spy_on_chat_calls(monkeypatch) + + response = _post("what is ansari?") + + assert response.status_code == 200 + assert chat_calls == ["what is ansari?"] + + +def test_local_processes_messages_with_the_dev_prefix(monkeypatch): + monkeypatch.setattr(get_settings(), "DEPLOYMENT_TYPE", "local") + chat_calls = _spy_on_chat_calls(monkeypatch) + + response = _post("!d what is ansari?") + + assert response.status_code == 200 + assert chat_calls == ["!d what is ansari?"] diff --git a/apps/whatsapp/tests/test_thread_lifecycle.py b/apps/whatsapp/tests/test_thread_lifecycle.py index e2fd49f8..aff0447d 100644 --- a/apps/whatsapp/tests/test_thread_lifecycle.py +++ b/apps/whatsapp/tests/test_thread_lifecycle.py @@ -13,7 +13,6 @@ from ansari_whatsapp.services.whatsapp_conversation_manager import WhatsAppConversationManager from ansari_whatsapp.utils.exceptions import ThreadDeletionError from ansari_whatsapp.utils.time_utils import get_retention_time_seconds -from ansari_whatsapp.utils.user_locks import UserLocks BSUID = "US.13491208655302741918" OTHER_BSUID = "US.99999999999999999999" @@ -218,90 +217,3 @@ async def spy_send(self, msg_body, recipient_bsuid=None): assert result is None assert _threads_of(manager) == [old] # still exactly one assert sent # the user was told - - -def test_two_close_messages_after_expiry_leave_exactly_one_thread(): - first_manager = _manager() - second_manager = _manager() - old = asyncio.run(first_manager._resolve_thread("hello")) - _age_thread(old, EXPIRY + 60) - - async def both(): - # Slow the mock down so the two messages genuinely overlap - monkey_delay = 0.05 - original = AnsariClientMock.get_last_thread_info - - async def slow_get_last(self, token): - result = await original(self, token) - await asyncio.sleep(monkey_delay) - return result - - AnsariClientMock.get_last_thread_info = slow_get_last - try: - return await asyncio.gather( - first_manager._resolve_thread("message one"), second_manager._resolve_thread("message two") - ) - finally: - AnsariClientMock.get_last_thread_info = original - - a, b = asyncio.run(both()) - - assert a == b # the second message reused the thread the first one created - assert _threads_of(first_manager) == [a] - - -def test_without_the_lock_two_close_messages_would_create_two_threads(monkeypatch): - """Guards the lock test above: if the lock did nothing, that test would not notice.""" - - class NoLock(UserLocks): - def hold(self, key): - from contextlib import asynccontextmanager - - @asynccontextmanager - async def noop(): - yield - - return noop() - - monkeypatch.setattr("ansari_whatsapp.services.whatsapp_conversation_manager.user_locks", NoLock()) - first_manager = _manager() - second_manager = _manager() - old = asyncio.run(first_manager._resolve_thread("hello")) - _age_thread(old, EXPIRY + 60) - - async def both(): - original = AnsariClientMock.get_last_thread_info - - async def slow_get_last(self, token): - result = await original(self, token) - await asyncio.sleep(0.05) - return result - - monkeypatch.setattr(AnsariClientMock, "get_last_thread_info", slow_get_last) - return await asyncio.gather( - first_manager._resolve_thread("message one"), second_manager._resolve_thread("message two") - ) - - asyncio.run(both()) - - assert len(_threads_of(first_manager)) == 2 - - -def test_user_locks_are_per_user_and_cleaned_up(): - locks = UserLocks() - order = [] - - async def work(key, name, delay): - async with locks.hold(key): - order.append(f"{name} start") - await asyncio.sleep(delay) - order.append(f"{name} end") - - async def run(): - await asyncio.gather(work("a", "a1", 0.05), work("a", "a2", 0.0), work("b", "b1", 0.0)) - - asyncio.run(run()) - - assert order.index("a1 end") < order.index("a2 start") # same user: one at a time - assert order.index("b1 start") < order.index("a1 end") # other user: not blocked - assert locks._locks == {} # nothing left behind diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index fa195fd7..cb7b763f 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -109,7 +109,7 @@ Each user has exactly one thread. If the last activity is older than the global - [ ] Message within the expiry reuses the thread and keeps context. - [ ] Message after the expiry deletes the old thread and messages and creates a new thread. -- [ ] A user never ends up with two threads, including when two messages arrive close together (verified in tests, with the behaviour documented). +- [ ] A user normally has exactly one thread. Two messages arriving back to back right after the expiry could each create one; this is a known, accepted corner case, documented in `_resolve_thread` (owner decision: no lock, and an occasional manual sweep keeps only each WhatsApp user's newest thread). #### Test Plan @@ -125,10 +125,12 @@ Finish environment behaviour and make the service testable and deployable by oth #### Files to Create / Modify -- In `apps/whatsapp`: maintenance behaviour driven by the same global flag as `apps/api` (`MAINTENANCE_MODE`) instead of the WhatsApp-specific flag, keeping the notice message; keep the staging `!d ` filter; `README.md` and `tests/README.md` for the app (both still reference legacy files that were not imported). +- In `apps/whatsapp`: rename the maintenance flag `WHATSAPP_UNDER_MAINTENANCE` to `WA_MAINTENANCE_MODE` (owner decision): it is read from this service's own environment and is independent of `apps/api`'s `MAINTENANCE_MODE`; keep the notice message; keep the staging `!d ` filter; `README.md` and `tests/README.md` for the app (both still reference legacy files that were not imported). - Deployment notes: per-environment Meta credentials and secrets, and the deploy steps for staging and production, following the monorepo's existing deployment docs. - Findings from the Phase 2 review to resolve here: (a) apps/api reports a failed or empty answer as an inline `Error: ...` at the end of a 200 stream, which the client currently relays to the user unlogged, so detect it, log it and send a failure notice; (b) deploy blocker to confirm before the staging smoke test: the service now requires `from_user_id` in every webhook, and sending to a BSUID (`recipient`) is untested against Meta's live API (Meta's docs disagree on when it is supported), so check the webhook Graph API version and test a real send; (c) `README.md` still documents the six deleted legacy endpoints. -- Findings from the Phase 3 review to resolve here: (a) the per-user lock only protects the single-replica deployment, so the deployment notes must state that the service runs as exactly one replica (a hosting-dashboard setting, not in the repo); (b) failures of the thread list, create and delete calls are logged but not captured in Sentry, unlike message-processing failures, so add that with the logging work. +- Findings from the Phase 3 review to resolve here: failures of the thread list, create and delete calls are logged but not captured in Sentry, unlike message-processing failures, so add that with the logging work. (The review's single-replica note no longer applies: the per-user lock was removed by owner decision.) +- Issues to raise with the `apps/api` owner when the PR is opened, not to fix in this work: (a) the chat routes hard-code `source: 'web'` on every message they save, so WhatsApp messages are labelled `web` even though their thread is `whatsapp`; (b) the answer stream reports a failure as trailing `Error:` text after a 200 response, with no explicit marker (see the `Error:` item above). +- Housekeeping to document: an occasional manual sweep deleting all but the newest thread of each WhatsApp user (threads with source `whatsapp`). - Deploy-path items found in the Phase 1 review: bump the uv version in the `Dockerfile` (it pins 0.5.11 against a newer lockfile format) and make its second `uv sync` use `--frozen`; decide whether to keep `/sentry-debug` and `clean_logs.sh`; ask an admin to make the `whatsapp` CI job a required check on `develop`. #### Deliverables @@ -161,7 +163,7 @@ Unit tests for both behaviours. Manual: local run through zrok with `!d `, then |------|-------------|--------|------------| | `apps/auth` endpoint is not available when Phase 2 is built | Medium | Medium | Mock auth client; local end to end deferred until it exists | | `apps/api` lacks a thread read or delete endpoint needed in Phase 3 | Medium | Medium | Check early in Phase 2; add the smallest change and note it | -| Concurrent messages create two threads for one user | Medium | Low | Cover in tests and document the chosen behaviour | +| Concurrent messages create two threads for one user | Low | Low | Accepted without a lock (owner decision); documented in `_resolve_thread`; occasional manual sweep keeps only the newest thread | | Imported code carries secrets, logs or personal files | Low | High | Copy only source, tests and config examples; review the diff before committing | | Shared test number makes local and staging answer the same message | Medium | Low | `!d ` filter kept and tested | diff --git a/codev/specs/321-rfc-whatsapp-support-in-the-mo.md b/codev/specs/321-rfc-whatsapp-support-in-the-mo.md index e18f9dfc..58b5784a 100644 --- a/codev/specs/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/specs/321-rfc-whatsapp-support-in-the-mo.md @@ -19,7 +19,7 @@ Affected: all WhatsApp users who currently cannot use Ansari on WhatsApp at all; - Users are identified by phone number and stored in the legacy backend as ordinary users (`source = whatsapp`, phone number, preferred language, no email or password). - A conversation continues until the user has been silent for 3 hours (global setting), then a new one starts. Old conversations are kept. - The answer is produced by the legacy backend's streaming chat, collected in full by the service, converted from markdown to WhatsApp formatting and split into messages of at most 4000 characters. -- The service has a WhatsApp-specific maintenance flag, and a temporary `!d ` message prefix that lets staging ignore messages meant for a developer's local server (staging and local share one Meta test number). +- The service has its own maintenance flag, and a temporary `!d ` message prefix that lets staging ignore messages meant for a developer's local server (staging and local share one Meta test number). - Each environment (local, staging, production) has its own Meta app credentials. - In this monorepo, `apps/api` already provides users, threads, messages, the chat pipeline, client attribution, Sentry and a global `MAINTENANCE_MODE`. It does not receive or verify Meta messages, send replies through Meta, or know WhatsApp identities. Authentication is moving to a central service, `apps/auth`, which issues JWTs that `apps/api` verifies; `apps/api` no longer handles passwords or logins. @@ -32,7 +32,7 @@ Affected: all WhatsApp users who currently cannot use Ansari on WhatsApp at all; - Each WhatsApp user has exactly one thread, the current conversation. When a message arrives after the expiry time (3 hours, global), the old thread and its messages are deleted and a new thread starts. - Replies are complete messages (WhatsApp cannot stream), produced by aggregating the existing streaming endpoints and split for WhatsApp, as the legacy service does. - Meta is always answered "OK" immediately, with no queue or message log table; failures are logged. -- The existing global `MAINTENANCE_MODE` governs WhatsApp, and users get a maintenance notice on WhatsApp. +- The WhatsApp service has its own maintenance flag, `WA_MAINTENANCE_MODE`, which is independent of `apps/api`'s `MAINTENANCE_MODE`. While it is on, users get a maintenance notice on WhatsApp. - The `!d ` prefix behaviour for staging is preserved. ## Success Criteria @@ -45,7 +45,7 @@ Affected: all WhatsApp users who currently cannot use Ansari on WhatsApp at all; - [ ] Answers longer than 4000 characters are delivered as multiple messages in order, with WhatsApp-compatible formatting. - [ ] Meta always receives a success response immediately for deliveries to our number, and Meta's signature is verified. - [ ] Status updates, webhooks for other numbers and unsupported message types do not trigger a chat answer. -- [ ] With `MAINTENANCE_MODE` on, users receive a maintenance notice and no chat answer. +- [ ] With `WA_MAINTENANCE_MODE` on, users receive a maintenance notice and no chat answer, whatever `apps/api`'s `MAINTENANCE_MODE` is set to. - [ ] In staging, messages starting with `!d ` are ignored; other messages are processed. - [ ] No legacy users or threads are migrated. - [ ] Unit and integration tests cover the above, using mock Meta and mock backend clients as in the legacy service. @@ -62,7 +62,7 @@ Baked Decisions (from issue #321; fixed for this spec, not to be re-opened in th - **Rate limiting (decision 7):** out of scope, no per-sender limiter in our code. - **Deferred to later specs:** data retention for stale users (decision 3), translating the service to TypeScript, and dedicated non-streaming answer endpoints. -Self-made decisions (8 to 11, approved by the author, open to comments): global 3-hour conversation expiry; one thread per user, replaced on expiry; `!d ` prefix kept for staging; reuse of the global `MAINTENANCE_MODE`. +Self-made decisions (8 to 11, approved by the author, open to comments): global 3-hour conversation expiry; one thread per user, replaced on expiry; `!d ` prefix kept for staging; a WhatsApp maintenance flag, `WA_MAINTENANCE_MODE`, independent of `apps/api`'s. Other constraints: @@ -125,7 +125,7 @@ Recommended: Approach 1. - Status webhook, webhook for another business number, and non-text message: handled without a chat answer (unsupported types get the unsupported notice). - Invalid or missing Meta signature: rejected. - Webhook verification handshake succeeds with the right token, fails otherwise. -- `MAINTENANCE_MODE` on: maintenance notice, no chat processing. +- `WA_MAINTENANCE_MODE` on: maintenance notice, no chat processing, whatever `apps/api`'s `MAINTENANCE_MODE` is. - Staging with `!d ` prefix: ignored; without: processed. - `apps/auth` unavailable or returns an error: user receives a failure notice, error logged, Meta still answered "OK". - `apps/api` error or timeout during streaming: user receives a failure notice, error logged. From e3cbca086c1a295afb4197c885679d4f3863ee5d Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 22:02:26 +0300 Subject: [PATCH 033/247] [Spec 321][Phase: environments_and_docs] feat: Railway JSON logs, request_id only, Sentry for thread/auth failures, PORT, Dockerfile uv pin --- apps/whatsapp/Dockerfile | 7 +- apps/whatsapp/clean_logs.sh | 68 ---------- apps/whatsapp/src/ansari_whatsapp/app/main.py | 20 ++- .../services/ansari_client_mock.py | 10 +- .../services/auth_client_base.py | 2 +- .../services/auth_client_mock.py | 13 +- .../services/whatsapp_conversation_manager.py | 59 ++++----- .../src/ansari_whatsapp/utils/app_logger.py | 123 ++++++------------ .../src/ansari_whatsapp/utils/token_utils.py | 21 --- apps/whatsapp/tests/test_bsuid_auth_flow.py | 14 +- apps/whatsapp/tests/test_railway_logging.py | 76 +++++++++++ apps/whatsapp/tests/test_thread_lifecycle.py | 5 +- 12 files changed, 165 insertions(+), 253 deletions(-) delete mode 100644 apps/whatsapp/clean_logs.sh delete mode 100644 apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py create mode 100644 apps/whatsapp/tests/test_railway_logging.py diff --git a/apps/whatsapp/Dockerfile b/apps/whatsapp/Dockerfile index c89e3d45..1da8c777 100644 --- a/apps/whatsapp/Dockerfile +++ b/apps/whatsapp/Dockerfile @@ -6,7 +6,8 @@ WORKDIR /app/ # Install uv # Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv -COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /bin/ +# Same uv line as the one that wrote uv.lock (`revision = 3`); keep them in step when upgrading +COPY --from=ghcr.io/astral-sh/uv:0.9.13 /uv /uvx /bin/ # Place executables in the environment at the front of the path # Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment @@ -38,10 +39,10 @@ COPY ./pyproject.toml ./uv.lock /app/ COPY ./src /app/src/ -# Sync the project +# Sync the project (--frozen: install exactly what uv.lock says, never re-resolve at deploy time) # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync + uv sync --frozen # Run the application CMD ["python", "src/ansari_whatsapp/app/main.py"] \ No newline at end of file diff --git a/apps/whatsapp/clean_logs.sh b/apps/whatsapp/clean_logs.sh deleted file mode 100644 index 1f6c98c7..00000000 --- a/apps/whatsapp/clean_logs.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash - -# This script cleans log files in the ./logs directory by removing entries older than a specified number of minutes. - -# NOTE: to run in powershell, you have to prefix with `sh ` -# E.g.: `sh ./logs/clean_logs.sh 5` - -# Default to 0 minutes if no argument is provided -MINUTES=${1:-0} - -# Current timestamp in seconds -CURRENT_TIME=$(date +%s) - -# Calculate the cutoff time in seconds -if [ $MINUTES -eq 0 ]; then - echo "Removing all log entries from files in ./logs" -else - # Convert minutes to seconds and calculate cutoff time - CUTOFF_TIME=$((CURRENT_TIME - MINUTES * 60)) - echo "Keeping only logs from the last $MINUTES minutes" - echo "Current time: $(date -d @$CURRENT_TIME +'%Y-%m-%d %H:%M:%S')" - echo "Cutoff time: $(date -d @$CUTOFF_TIME +'%Y-%m-%d %H:%M:%S')" -fi - -# Process each log file -for LOG_FILE in ./logs/*.log ./logs/*.log.temp; do - # Skip if the pattern doesn't match any files - [ -e "$LOG_FILE" ] || continue - - if [ -f "$LOG_FILE" ]; then - echo "Processing $LOG_FILE..." - - if [ $MINUTES -eq 0 ]; then - # If minutes is 0, empty the file - > "$LOG_FILE" - echo "Cleared all entries in $LOG_FILE" - else - # Create a temporary file to store matching lines - TEMP_FILE="${LOG_FILE}.temp" - - # Use grep to extract the timestamp and filter rows - while IFS= read -r line; do - # Extract timestamp from log line (format: 2025-04-10 05:01:18) - if [[ $line =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2}[[:space:]][0-9]{2}:[0-9]{2}:[0-9]{2}) ]]; then - LOG_TIMESTAMP="${BASH_REMATCH[1]}" - # Convert timestamp to seconds since epoch - LOG_TIME=$(date -d "$LOG_TIMESTAMP" +%s) - - # Keep line if timestamp is after the cutoff - if [ $LOG_TIME -ge $CUTOFF_TIME ]; then - echo "$line" >> "$TEMP_FILE" - fi - fi - done < "$LOG_FILE" - - # Replace the original file with the filtered content - if [ -f "$TEMP_FILE" ]; then - mv "$TEMP_FILE" "$LOG_FILE" - echo "Kept $(wc -l < "$LOG_FILE") entries in $LOG_FILE" - else - > "$LOG_FILE" - echo "No recent entries found in $LOG_FILE, file cleared" - fi - fi - fi -done - -echo "Log cleaning complete" \ No newline at end of file diff --git a/apps/whatsapp/src/ansari_whatsapp/app/main.py b/apps/whatsapp/src/ansari_whatsapp/app/main.py index 29ef4eb0..b17c44f0 100644 --- a/apps/whatsapp/src/ansari_whatsapp/app/main.py +++ b/apps/whatsapp/src/ansari_whatsapp/app/main.py @@ -28,7 +28,7 @@ ) # Note: Importing app_logger automatically configures loguru's logger due to module-level execution -from ansari_whatsapp.utils.app_logger import logger, request_id_var, user_id_var +from ansari_whatsapp.utils.app_logger import logger, request_id_var from ansari_whatsapp.utils.time_utils import is_message_too_old from ansari_whatsapp.utils.config import get_settings from ansari_whatsapp.utils.exceptions import UnexpectedMetaPayloadError @@ -108,11 +108,15 @@ async def general_exception_handler(request, exc): ) -@app.get("/sentry-debug") async def trigger_error(): 1 / 0 # deliberate error to test Sentry +# This open endpoint crashes on purpose, so it is only registered outside production +if get_settings().DEPLOYMENT_TYPE != "production": + app.get("/sentry-debug")(trigger_error) + + # Add CORS middleware with logging app.add_middleware( CORSMiddlewareWithLogging, @@ -363,10 +367,8 @@ async def main_webhook( success=False, message="Message too old, notified user", status_code=422, error_code="MESSAGE_TOO_OLD" ) - # Exchange the sender's BSUID for a JWT with apps/auth and get their user_id (GUID) - user_id = await conversation_manager.authenticate_user() - - if user_id is None: + # Exchange the sender's BSUID for a JWT with apps/auth + if not await conversation_manager.authenticate_user(): # Token exchange failed background_tasks.add_task( conversation_manager.send_whatsapp_message, @@ -376,10 +378,6 @@ async def main_webhook( success=False, message="User authentication failed", status_code=500, error_code="USER_AUTHENTICATION_FAILED" ) - # Set user_id in contextvar for all subsequent logs (including background tasks) - # Check docs/logging/understanding_contextvars_in_python.md for details - user_id_var.set(user_id) - # Check if the incoming message is a media type other than text if incoming_msg_type != "text": background_tasks.add_task( @@ -432,7 +430,7 @@ async def main_webhook( uvicorn.run( module_name, # Dynamically constructed module path host=host, - port=8001, + port=int(os.getenv("PORT", "8001")), # Railway's public domain targets the service's PORT variable reload=reload_uvicorn, reload_includes=reload_includes, log_level="info", # Uvicorn expects lowercase log levels diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py index c5f239dc..882f82eb 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_mock.py @@ -16,7 +16,6 @@ ThreadInfoError, MessageProcessingError, ) -from ansari_whatsapp.utils.token_utils import get_subject from ansari_whatsapp.services.ansari_client_base import AnsariClientBase @@ -68,8 +67,8 @@ async def wrapper(*args, **kwargs): class AnsariClientMock(AnsariClientBase): """Mock implementation of the Ansari client for testing without apps/api. - Threads are kept in memory per user GUID (the `sub` of the token), mirroring apps/api, - where the JWT alone identifies the user. + Threads are kept in memory per token, mirroring apps/api, where the token alone identifies the user. + The mock auth client gives each BSUID its own stable token. """ def __init__(self): @@ -81,10 +80,9 @@ def __init__(self): logger.info("Initialized AnsariClientMock - using mock backend") def _user(self, token: str, error: type[Exception]) -> str: - user = get_subject(token) - if not user: + if not token: raise error("Invalid token") - return user + return token @simulate_backend_behavior() async def create_thread(self, token: str, title: str) -> dict: diff --git a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_base.py b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_base.py index 68d2742c..e755bfea 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_base.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_base.py @@ -15,7 +15,7 @@ async def get_token(self, bsuid: str) -> str: bsuid (str): The business-scoped user ID from Meta's webhook (never a phone number). Returns: - str: The JWT. Its `sub` claim is the user's GUID. + str: The JWT, sent as the bearer token on every apps/api call. Raises: AuthTokenError: If the exchange fails. diff --git a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py index 3f933925..84daf963 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py @@ -1,23 +1,16 @@ # Mock apps/auth client -"""Mock client that issues fake, unsigned tokens without calling apps/auth.""" +"""Mock client that issues fake tokens without calling apps/auth.""" -import base64 -import json import uuid from ansari_whatsapp.services.auth_client_base import AuthClientBase from ansari_whatsapp.utils.exceptions import AuthTokenError -def _b64(data: dict) -> str: - return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip("=") - - class AuthClientMock(AuthClientBase): - """Maps each BSUID to a stable fake GUID, so one BSUID is one user and a new BSUID is a new user.""" + """Gives each BSUID its own stable fake token, so one BSUID is one user and a new BSUID is a new user.""" async def get_token(self, bsuid: str) -> str: if not bsuid: raise AuthTokenError("Missing BSUID") - guid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"mock-bsuid:{bsuid}")) - return f"{_b64({'alg': 'none'})}.{_b64({'sub': guid})}.mock" + return f"mock-token-{uuid.uuid5(uuid.NAMESPACE_URL, f'mock-bsuid:{bsuid}')}" diff --git a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py index cf6c107d..7037299f 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py @@ -11,7 +11,7 @@ from ansari_whatsapp.services.service_provider import get_ansari_client, get_auth_client from ansari_whatsapp.services.meta_service_provider import get_meta_api_service -from ansari_whatsapp.utils.app_logger import request_id_var, user_id_var +from ansari_whatsapp.utils.app_logger import request_id_var from ansari_whatsapp.utils.exceptions import ( AuthTokenError, ThreadCreationError, @@ -19,7 +19,6 @@ ThreadInfoError, MessageProcessingError, ) -from ansari_whatsapp.utils.token_utils import get_subject from ansari_whatsapp.utils.time_utils import calculate_time_passed, get_retention_time_seconds from ansari_whatsapp.presenters.whatsapp_message_formatter import format_for_whatsapp from ansari_whatsapp.utils.whatsapp_message_splitter import split_message @@ -108,37 +107,40 @@ async def _typing_indicator_loop(self) -> None: logger.error(f"Error in typing indicator loop: {e}") logger.exception(e) - async def authenticate_user(self) -> str | None: - """Exchange the sender's BSUID for a JWT with apps/auth and return the user's GUID. + @staticmethod + def _capture(error: Exception, error_type: str) -> None: + """Report an error to Sentry, tagged with its kind and this request's id (never any user content).""" + sentry_sdk.set_tag("error_type", error_type) + sentry_sdk.set_context("message_details", {"request_id": request_id_var.get()}) + sentry_sdk.capture_exception(error) - The same BSUID always maps to the same GUID, and a new BSUID is a new user. apps/auth creates the + async def authenticate_user(self) -> bool: + """Exchange the sender's BSUID for a JWT with apps/auth. + + The same BSUID always maps to the same account, and a new BSUID is a new user. apps/auth creates the account on first use, so there is no separate registration step. The token is kept on this object for the later apps/api calls. Returns: - str | None: The user's GUID (the JWT's `sub`) on success, None on failure. + bool: True if a token was obtained, False if the exchange failed. """ if not self.bsuid: logger.error("Cannot authenticate user: bsuid is not set") - return None + return False try: self.token = await self.auth_client.get_token(self.bsuid) except AuthTokenError as e: logger.error(f"Failed to get a token for the user: {e}") - return None + self._capture(e, "auth_token_failure") + return False except Exception as e: logger.error(f"Unexpected error getting a token for the user: {type(e).__name__}") - return None + self._capture(e, "auth_token_failure") + return False - user_id = get_subject(self.token) - if not user_id: - logger.error("The token from apps/auth has no readable subject") - self.token = None - return None - - logger.debug(f"Authenticated whatsapp user (user_id: {user_id})") - return user_id + logger.debug("Authenticated whatsapp user") + return True async def _send_whatsapp_typing_indicator(self) -> None: """Send a typing indicator to the WhatsApp recipient.""" @@ -205,6 +207,7 @@ async def _resolve_thread(self, first_message: str) -> str | None: last_thread_info = await self.ansari_client.get_last_thread_info(self.token) except ThreadInfoError as e: logger.error(f"Failed to get thread info: {e}") + self._capture(e, "thread_info_failure") await self.send_whatsapp_message( "Sorry, we're having trouble accessing your chat history. Please try again later." ) @@ -228,6 +231,7 @@ async def _resolve_thread(self, first_message: str) -> str | None: except ThreadDeletionError as e: # Keep one thread per user: do not start a second one next to an expired thread we could not delete logger.error(f"Failed to delete the expired thread: {e}") + self._capture(e, "thread_deletion_failure") await self.send_whatsapp_message( "An unexpected error occurred while starting a new chat session.. Please try again later." ) @@ -237,6 +241,7 @@ async def _resolve_thread(self, first_message: str) -> str | None: result = await self.ansari_client.create_thread(self.token, " ".join(first_message.split()[:6])) except ThreadCreationError as e: logger.error(f"Failed to create thread: {e}") + self._capture(e, "thread_creation_failure") await self.send_whatsapp_message( "An unexpected error occurred while creating a new chat session. Please try again later." ) @@ -278,15 +283,7 @@ async def handle_text_message(self) -> None: ) except httpx.TimeoutException as e: logger.error(f"Backend timeout while processing message: {e}") - sentry_sdk.set_tag("error_type", "backend_timeout") - sentry_sdk.set_context( - "message_details", - { - "request_id": request_id_var.get(), - "user_id": user_id_var.get(), - }, - ) - sentry_sdk.capture_exception(e) + self._capture(e, "backend_timeout") await self.send_whatsapp_message("The request timed out. Please try sending your message again.") # Cancel typing indicator if running if self.typing_indicator_task and not self.typing_indicator_task.done(): @@ -294,15 +291,7 @@ async def handle_text_message(self) -> None: return except MessageProcessingError as e: logger.error(f"Failed to process message: {e}") - sentry_sdk.set_tag("error_type", "message_processing_failure") - sentry_sdk.set_context( - "message_details", - { - "request_id": request_id_var.get(), - "user_id": user_id_var.get(), - }, - ) - sentry_sdk.capture_exception(e) + self._capture(e, "message_processing_failure") await self.send_whatsapp_message("An error occurred while processing your message. Please try again later.") # Cancel typing indicator if running if self.typing_indicator_task and not self.typing_indicator_task.done(): diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py b/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py index d0d04140..ff9ece1e 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py @@ -15,6 +15,10 @@ This is a deliberate design choice to ensure consistent logging across the application, including during tests. Developers should be aware that simply importing this module will modify the global logging state. + +Railway: outside local development, every log line is one JSON object on stdout, which Railway parses into +searchable fields (https://docs.railway.com/guides/logs#structured-logs). To find everything one message did: +search `@request_id:` in the log explorer (the id is also returned to the caller as the `X-Request-ID` header). """ import contextvars @@ -28,26 +32,23 @@ ########################################## Context Variables ########################################## -# Context variables for log correlation -# These automatically propagate to child async tasks (including FastAPI's BackgroundTasks) -# Check docs/logging/understanding_contextvars_in_python.md for details +# Context variable for log correlation: one id per incoming HTTP request. +# It automatically propagates to child async tasks (including FastAPI's BackgroundTasks). request_id_var = contextvars.ContextVar("request_id", default="none") -user_id_var = contextvars.ContextVar("user_id", default="none") ########################################## Global Vars and Logger Configurations ########################################## -# Create logs directory if it doesn't exist -os.makedirs("logs", exist_ok=True) - # Remove any pre-existing default handlers made by loguru logger.remove() # Get settings settings = get_settings() -# Determine if we should use JSON structured logging for AWS CloudWatch -# CloudWatch Logs Insights works best with JSON-formatted logs -is_aws_deployment = settings.DEPLOYMENT_TYPE not in ["local", "development"] and os.getenv("GITHUB_ACTIONS") != "true" +# JSON lines everywhere except local development and GitHub Actions +use_json_logs = settings.DEPLOYMENT_TYPE not in ["local", "development"] and os.getenv("GITHUB_ACTIONS") != "true" + +# Railway understands debug/info/warn/error, so Loguru's other levels are mapped onto those +RAILWAY_LEVELS = {"TRACE": "debug", "DEBUG": "debug", "INFO": "info", "SUCCESS": "info", "WARNING": "warn"} ########################################## Functions ########################################## @@ -56,7 +57,7 @@ def log_record_preprocessor(record): """Preprocess log records before formatting: inject context and apply filters. This function runs before Loguru formats the log record and sends it to sink, and it serves two purposes: - 1. Inject request_id and user_id from contextvars into record["extra"] + 1. Inject request_id from its contextvar into record["extra"] 2. Filter logs based on LOG_TEST_FILES_ONLY setting Args: @@ -67,7 +68,6 @@ def log_record_preprocessor(record): """ # Step 1: Inject context from contextvars record["extra"]["request_id"] = request_id_var.get() - record["extra"]["user_id"] = user_id_var.get() # Step 2: Apply filtering logic if settings.LOG_TEST_FILES_ONLY and not ("tests" in record["file"].path or "test_" in record["file"].name): @@ -76,74 +76,50 @@ def log_record_preprocessor(record): return True -def cloudwatch_json_sink(message): - """Custom sink that writes clean JSON logs for AWS CloudWatch. - - Extracts the record from the message and formats it as minimal JSON. - Includes request_id and user_id from Loguru's context for log correlation. - - Args: - message: The loguru message object with .record attribute - - Log Structure: - - level, text, file_path, line, function, process, thread, time - - request_id: UUID per HTTP request (for granular debugging) - - user_id: Database UUID per user (for tracking user conversations) - - exception: Stack trace details if present +def build_log_entry(record) -> dict: + """Turn a Loguru record into the JSON object Railway expects. - Note: `level` and `text` keys are mentioned first, as we would like them to visible when - viewing condensed logs in CloudWatch Console. + `message` and `level` are the two keys Railway reads; every other key becomes a field you can search with + `@name:value`. Railway adds the timestamp itself, so none is included. """ - record = message.record - msg = record["message"] - - log_entry = { - "level": record["level"].name, - "text": msg, + entry = { + "level": RAILWAY_LEVELS.get(record["level"].name, "error"), + "message": record["message"], "request_id": record["extra"].get("request_id"), - "user_id": record["extra"].get("user_id"), - "file_path": record["file"].path, # full file path - "line": record["line"], # line number - "function": record["function"], # function name - "process": {"id": record["process"].id, "name": record["process"].name}, - "thread": {"id": record["thread"].id, "name": record["thread"].name}, - "time": {"timestamp": record["time"].timestamp(), "iso": record["time"].isoformat()}, + "file": record["file"].name, + "line": record["line"], + "function": record["function"], } if record["exception"]: exc = record["exception"] - log_entry["exception"] = { + entry["exception"] = { "type": exc.type.__name__ if exc.type else None, "value": str(exc.value) if exc.value else None, } - # Convert to JSON and write to stderr with newline - # The \n is REQUIRED for newline-delimited JSON (NDJSON) format - # CloudWatch expects each log entry on a separate line for proper parsing (hence, the `+ "\n"` at the end). - # See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Generation_CloudWatch_Agent.html#CloudWatch_Embedded_Metric_Format_Generation_CloudWatch_Agent_Send_Logs - # json.dumps() serializes the object to a single line. CloudWatch automatically parses this JSON - # and displays it as an expandable, multi-line structure in the AWS Console GUI. - sys.stderr.write(json.dumps(log_entry) + "\n") - sys.stderr.flush() + return entry + + +def railway_json_sink(message): + """Write one log line as a single-line JSON object to stdout. + + It must be stdout: Railway marks everything written to stderr as an error, however the JSON says otherwise. + The whole object must stay on one line, or Railway cannot parse it. + """ + sys.stdout.write(json.dumps(build_log_entry(message.record)) + "\n") + sys.stdout.flush() ########################################## Main Code ########################################## -# Choose sink and format based on deployment type -if is_aws_deployment: - # Use custom sink for AWS CloudWatch - # This creates clean, minimal JSON logs that are easy to query in CloudWatch Logs Insights - log_sink = cloudwatch_json_sink - - # Format string for loguru - optimization for custom sink - # Explanation: Loguru always formats the message string before passing it to the sink. - # Since our sink uses the raw .record object and handles its own formatting, we use - # a minimal format "{message}" to avoid the overhead of the default verbose formatter. - # Source: https://loguru.readthedocs.io/en/stable/api/logger.html#loguru.logger.add:~:text=passed%20to%20all%20added%20sinks%20is%20nothing%20more%20than%20a%20string%20of%20the%20formatted%20log +if use_json_logs: + log_sink = railway_json_sink + # The sink builds the JSON itself from the raw record, so Loguru's own formatting is kept minimal log_format = "{message}" enable_colors = False else: - # Standard stderr sink for local development + # Readable lines on stderr for local development log_sink = sys.stderr log_format = ( "{time:YYYY-MM-DD HH:mm:ss} | " @@ -151,13 +127,11 @@ def cloudwatch_json_sink(message): "{file}:{line} " "[{function}()] | " "{message} | " - # NOTE: Whenever you want to test/visualize request_id/user_id that appear in CloudWatch, - # you can uncomment the below line to see them in local logs as well. - # "req:{extra[request_id]} user:{extra[user_id]}" + # To see the request id in local logs too, uncomment the line below. + # "req:{extra[request_id]}" ) enable_colors = True -# Add console handler for terminal output logger.add( log_sink, format=log_format, @@ -169,20 +143,3 @@ def cloudwatch_json_sink(message): filter=log_record_preprocessor, catch=False, ) - -# # If needed, uncomment below to write logs to all_logs.log file when running locally -# if settings.DEPLOYMENT_TYPE == "local": -# log_dir = os.path.join(os.getcwd(), "logs") -# os.makedirs(log_dir, exist_ok=True) -# all_logs_file = os.path.join(log_dir, "all_logs.log") -# logger.add( -# all_logs_file, -# format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {file}:{line} [{function}()] | {message}", -# level=settings.LOGGING_LEVEL.upper(), -# enqueue=True, -# backtrace=False, -# diagnose=False, -# filter=log_record_preprocessor, -# rotation="10 MB", -# catch=False, -# ) diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py b/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py deleted file mode 100644 index eb0a7a64..00000000 --- a/apps/whatsapp/src/ansari_whatsapp/utils/token_utils.py +++ /dev/null @@ -1,21 +0,0 @@ -# JWT helpers for ansari-whatsapp -"""Read-only helpers for the JWTs that apps/auth issues.""" - -import base64 -import json - - -def get_subject(token: str) -> str | None: - """Return the `sub` claim (the user's GUID) of a JWT, or None if it can't be read. - - The signature is NOT verified here; apps/api verifies it on every call. This is only - used to tag logs with the user's GUID. - """ - try: - payload_part = token.split(".")[1] - payload_part += "=" * (-len(payload_part) % 4) - payload = json.loads(base64.urlsafe_b64decode(payload_part)) - sub = payload.get("sub") - return sub if isinstance(sub, str) else None - except (IndexError, ValueError, AttributeError, TypeError): - return None diff --git a/apps/whatsapp/tests/test_bsuid_auth_flow.py b/apps/whatsapp/tests/test_bsuid_auth_flow.py index ca8eb622..d27430bf 100644 --- a/apps/whatsapp/tests/test_bsuid_auth_flow.py +++ b/apps/whatsapp/tests/test_bsuid_auth_flow.py @@ -18,7 +18,6 @@ from ansari_whatsapp.services.service_provider import reset_ansari_client, reset_auth_client from ansari_whatsapp.utils.config import get_settings from ansari_whatsapp.utils.exceptions import AuthTokenError, MessageProcessingError -from ansari_whatsapp.utils.token_utils import get_subject BSUID = "US.13491208655302741918" PHONE = "201234567899" @@ -125,9 +124,9 @@ def test_auth_client_response_without_token_raises(monkeypatch): def test_mock_auth_same_bsuid_same_user_new_bsuid_new_user(): mock = AuthClientMock() - first = get_subject(asyncio.run(mock.get_token(BSUID))) - again = get_subject(asyncio.run(mock.get_token(BSUID))) - other = get_subject(asyncio.run(mock.get_token("US.99999999999999999999"))) + first = asyncio.run(mock.get_token(BSUID)) + again = asyncio.run(mock.get_token(BSUID)) + other = asyncio.run(mock.get_token("US.99999999999999999999")) assert first == again assert first != other @@ -283,13 +282,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert not any(PHONE in r or BSUID in r for r in requests) -def test_malformed_token_subject_does_not_raise(): - # Valid base64 JSON that is not an object, and a payload that is not JSON at all - assert get_subject("a.NQ.c") is None - assert get_subject("a.bm90LWpzb24.c") is None - assert get_subject("not-a-jwt") is None - - def test_phone_hidden_user_is_answered(): response = _post_signed_webhook(_text_message(from_user_id=BSUID)) diff --git a/apps/whatsapp/tests/test_railway_logging.py b/apps/whatsapp/tests/test_railway_logging.py new file mode 100644 index 00000000..d41ce286 --- /dev/null +++ b/apps/whatsapp/tests/test_railway_logging.py @@ -0,0 +1,76 @@ +"""Tests for the Railway-style JSON logs (spec 321, phase 4).""" + +import json +from types import SimpleNamespace + +import pytest + +from ansari_whatsapp.utils import app_logger +from ansari_whatsapp.utils.app_logger import build_log_entry, railway_json_sink, request_id_var + + +def _record(level: str = "INFO", message: str = "hello", exception=None) -> dict: + return { + "level": SimpleNamespace(name=level), + "message": message, + "extra": {"request_id": "req-123"}, + "file": SimpleNamespace(name="main.py"), + "line": 42, + "function": "handler", + "exception": exception, + } + + +def test_entry_has_the_message_and_level_keys_railway_reads(): + entry = build_log_entry(_record(message="Created a new thread")) + + assert entry["message"] == "Created a new thread" + assert entry["level"] == "info" + + +def test_entry_carries_the_request_id_as_a_searchable_field(): + assert build_log_entry(_record())["request_id"] == "req-123" + + +def test_entry_has_no_user_id_field(): + assert "user_id" not in build_log_entry(_record()) + + +@pytest.mark.parametrize( + ("loguru_level", "railway_level"), + [ + ("TRACE", "debug"), + ("DEBUG", "debug"), + ("INFO", "info"), + ("SUCCESS", "info"), + ("WARNING", "warn"), + ("ERROR", "error"), + ("CRITICAL", "error"), + ], +) +def test_levels_are_mapped_to_the_four_railway_understands(loguru_level, railway_level): + assert build_log_entry(_record(level=loguru_level))["level"] == railway_level + + +def test_exception_details_are_included_as_type_and_value(): + exc = SimpleNamespace(type=ValueError, value=ValueError("bad")) + + assert build_log_entry(_record(level="ERROR", exception=exc))["exception"] == {"type": "ValueError", "value": "bad"} + + +def test_sink_writes_one_json_object_on_one_line_to_stdout(monkeypatch, capsys): + railway_json_sink(SimpleNamespace(record=_record(message="line one\nline two"))) + + captured = capsys.readouterr() + assert captured.err == "" # stderr would make Railway show it as an error + assert captured.out.count("\n") == 1 # an embedded newline is escaped inside the JSON, not a second line + assert json.loads(captured.out)["message"] == "line one\nline two" + + +def test_request_id_variable_defaults_to_none_outside_a_request(): + assert request_id_var.get() == "none" + + +def test_json_logs_are_off_in_local_development_and_in_ci(): + # The test settings use DEPLOYMENT_TYPE=local, so readable logs are expected here + assert app_logger.use_json_logs is False diff --git a/apps/whatsapp/tests/test_thread_lifecycle.py b/apps/whatsapp/tests/test_thread_lifecycle.py index aff0447d..71f935a0 100644 --- a/apps/whatsapp/tests/test_thread_lifecycle.py +++ b/apps/whatsapp/tests/test_thread_lifecycle.py @@ -44,10 +44,7 @@ def _age_thread(thread_id: str, seconds: float) -> None: def _threads_of(manager: WhatsAppConversationManager) -> list[str]: - from ansari_whatsapp.utils.token_utils import get_subject - - user = get_subject(manager.token) - return [t["thread_id"] for t in get_ansari_client()._threads.values() if t["user"] == user] + return [t["thread_id"] for t in get_ansari_client()._threads.values() if t["user"] == manager.token] _RealAsyncClient = httpx.AsyncClient From 22d44c1b063515c10f92a55b90dc2f0f23028041 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Sun, 25 Oct 2026 22:05:19 +0300 Subject: [PATCH 034/247] [Spec 321][Phase: environments_and_docs] docs: rewrite whatsapp READMEs, deployment notes, staging smoke test --- apps/whatsapp/README.md | 219 ++++++++++++++-------------------- apps/whatsapp/tests/README.md | 187 +++-------------------------- 2 files changed, 108 insertions(+), 298 deletions(-) diff --git a/apps/whatsapp/README.md b/apps/whatsapp/README.md index 1374acc0..8fa6700c 100644 --- a/apps/whatsapp/README.md +++ b/apps/whatsapp/README.md @@ -1,168 +1,131 @@ -# Ansari WhatsApp Service +# apps/whatsapp -This service handles WhatsApp integration for the Ansari backend, providing a dedicated service for processing WhatsApp messages and communicating with the main Ansari backend. +The WhatsApp front door for Ansari. It receives webhooks from Meta, signs the user in through `apps/auth`, and talks to `apps/api` with the resulting token. -## Overview +## How a message flows -The Ansari WhatsApp service is designed as a separate microservice that: +1. Meta posts a webhook to `POST /whatsapp/v2`. The `X-Hub-Signature-256` header is checked against `META_ANSARI_APP_SECRET`. +2. The user is identified by their BSUID (Meta's per-user id), read from `from_user_id`. The phone number is never read, stored, logged or sent on. +3. `apps/auth` (`POST /api/whatsapp/session`, header `X-Whatsapp-Api-Key`, body `{bsuid}`) returns a token for that user. +4. The service finds the user's newest thread in `apps/api` (`GET /api/v2/threads`). If it is older than `WHATSAPP_CHAT_RETENTION_HOURS` it is deleted and a new one is created (`source: "whatsapp"`). +5. The message goes to `POST /api/v2/threads/{id}/chat`. The server-sent-event stream is collected and the answer is sent back to the user's BSUID, split to fit WhatsApp's 4,000-character limit. -1. Receives webhook events from the WhatsApp Business API -2. Processes incoming messages -3. Communicates with the main Ansari backend API -4. Sends responses back to WhatsApp users +If sign-in fails, the user gets a short apology and the failure goes to Sentry. -By separating the WhatsApp functionality into its own service, we can: -- Scale the WhatsApp service independently -- Simplify the main Ansari backend codebase -- Make deployments and updates easier -- Improve testing and maintenance - -## Setup - -### Prerequisites - -- Python 3.8 or higher -- Access to the WhatsApp Business API -- A running instance of the Ansari backend API - -### Installation - -1. Clone the repository: - ``` - git clone - cd ansari-whatsapp - ``` - -2. Run the setup script: - - On Linux/Mac: - ``` - ./setup.sh - ``` - - On Windows: - ``` - setup.bat - ``` - -3. Edit the `.env` file with your configuration settings: - ``` - # Update these with your values - META_BUSINESS_PHONE_NUMBER_ID=your_phone_number_id - META_ACCESS_TOKEN_FROM_SYS_USER=your_access_token - META_WEBHOOK_VERIFY_TOKEN=your_verify_token - BACKEND_SERVER_URL=http://your-ansari-backend:8000 - ``` - -## Running the Service - -### Development Mode +## Layout ``` -python -m src.ansari_whatsapp.app.main +src/ansari_whatsapp/ + app/main.py FastAPI app: health check, webhook verification, webhook handler + services/ conversation manager; apps/api, apps/auth and Meta clients (base / real / mock) + presenters/ formatting for WhatsApp (markdown to text, right-to-left, splitting) + utils/ config, JSON logging, webhook parsing, time helpers ``` -This will start the service on port 8001 (or the port specified in your .env file). - -### Using Docker +## Run locally -To build and run the Docker container: +Use `uv`, not `pip`. +```powershell +cd apps/whatsapp +copy .env.example .env # then fill it in +uv sync +uv run python src/ansari_whatsapp/app/main.py ``` -docker build -t ansari-whatsapp . -docker run -p 8001:8001 --env-file .env ansari-whatsapp -``` - -## API Endpoints -The service exposes the following endpoints: +The service listens on `PORT` (default `8001`). With `MOCK_ANSARI_CLIENT=True`, `MOCK_AUTH_CLIENT=True` and `MOCK_META_API=True` it needs none of the other services. -- `GET /`: Health check endpoint -- `GET /whatsapp/v2`: Webhook verification endpoint for WhatsApp -- `POST /whatsapp/v2`: Main webhook endpoint for receiving WhatsApp messages +### Receiving real webhooks locally -## Documentation +Meta needs a public URL. A reserved zrok share forwards one to your machine: -### For Newcomers -1. **Quick Start**: Read this README for setup and basic usage -2. **Development Guide**: See `CLAUDE.md` for detailed development instructions, testing, and commands -3. **Architecture Overview**: See `docs/hld/architecture.md` for system design and data flow -4. **Implementation Details**: See `docs/lld/implementation_guide.md` for technical deep-dive +```powershell +zrok reserve public localhost:8001 -n +``` -### For Deployment -- **AWS Setup**: See `docs/lld/aws/README.md` for complete deployment walkthrough -- **GitHub Actions**: See `docs/lld/github_actions/README.md` for CI/CD configuration +The matching callback URL (`https://.share.zrok.io/whatsapp/v2`) is set on the `Ansari - Test` app in Meta's dashboard. See `.env.example` for who holds the token. Use the credentials of that same app (token, app secret, verify token). -### For Migration Context -- **Migration Plan**: See `docs/whatsapp_migration_plan/migration_plan.md` for the 4-phase migration from monolith to microservice +Staging shares the same test number, so start every message you send while testing locally with `!d ` (for example `!d what is ansari?`). Staging ignores those; your local service answers them. -## Architecture +### Test and staging switches -The service follows a clean architecture pattern: +| Behaviour | When | +|---|---| +| Messages starting with `!d ` are ignored, so a developer testing locally on the shared test number does not get answered twice | `DEPLOYMENT_TYPE=staging` | +| Every message gets a "down for maintenance" reply and nothing is processed | `WA_MAINTENANCE_MODE=True` (independent of `apps/api`'s own flag) | +| JSON logs on stdout (otherwise readable logs) | any `DEPLOYMENT_TYPE` except `local` and `development` | +| `GET /sentry-debug` deliberately crashes, to prove errors reach Sentry | every `DEPLOYMENT_TYPE` except `production` | -- `app/`: FastAPI application with webhook endpoints -- `services/`: Business logic layer (conversation manager, API clients with mock support) -- `presenters/`: Message formatting layer (WhatsApp-specific formatting, RTL support) -- `utils/`: Utility modules (config, logging, parsing, splitting) +## Environment variables -## Integration with Ansari Backend +`.env.example` is the full, commented list. The ones that matter: -The WhatsApp service communicates with the Ansari backend via 6 API endpoints: +| Variable | Purpose | +|---|---| +| `DEPLOYMENT_TYPE` | `local`, `development`, `staging` or `production` | +| `BACKEND_SERVER_URL` | `apps/api` base URL, no path | +| `AUTH_SERVER_URL` | `apps/auth` base URL, no path | +| `AUTH_WHATSAPP_API_KEY` | Shared secret for `apps/auth`'s WhatsApp sign-in endpoint | +| `META_BUSINESS_PHONE_NUMBER_ID` | Phone number this service answers on; webhooks for other numbers are ignored | +| `META_ACCESS_TOKEN_FROM_SYS_USER` | Meta API token (belongs to one Meta app) | +| `META_ANSARI_APP_SECRET` | Verifies webhook signatures (must be the same app as the token) | +| `META_WEBHOOK_VERIFY_TOKEN` | One-time webhook URL verification | +| `WA_MAINTENANCE_MODE` | See above | +| `WHATSAPP_CHAT_RETENTION_HOURS` | How long a conversation continues before a fresh thread (default 3) | +| `SENTRY_DSN` | Empty disables Sentry | +| `MOCK_ANSARI_CLIENT`, `MOCK_AUTH_CLIENT`, `MOCK_META_API` | Replace the real services with fakes | -1. **POST /whatsapp/v2/users/register** - User registration -2. **GET /whatsapp/v2/users/exists** - Check user existence -3. **POST /whatsapp/v2/threads** - Create new threads -4. **GET /whatsapp/v2/threads/last** - Get last thread info -5. **GET /whatsapp/v2/threads/{thread_id}/history** - Get thread history -6. **POST /whatsapp/v2/messages/process** - Process messages (streaming) +Meta credentials differ per Meta app (Test, Staging, production). A token from one app will not work with another app's secret. -## Environment Variables +## Tests -| Variable | Description | Default | -|----------|-------------|---------| -| HOST | Host to bind the server to | 0.0.0.0 | -| PORT | Port to run the server on | 8001 | -| BACKEND_SERVER_URL | URL of the Ansari backend API | http://localhost:8000 | -| META_API_VERSION | WhatsApp API version | v21.0 | -| META_BUSINESS_PHONE_NUMBER_ID | Your WhatsApp business phone number ID | (required) | -| META_ACCESS_TOKEN_FROM_SYS_USER | Access token for WhatsApp API | (required) | -| META_WEBHOOK_VERIFY_TOKEN | Verify token for WhatsApp webhook | (required) | -| WHATSAPP_CHAT_RETENTION_HOURS | Hours to retain chat history | 3 | -| DEV_MODE | Enable development mode | false | +```powershell +cd apps/whatsapp +uv run --with ruff ruff check . +uv run pytest tests -q +``` -## Testing +No network or other service is needed. See [tests/README.md](tests/README.md). -### Running Tests +## Logging -The project includes comprehensive integration tests for WhatsApp webhook endpoints. +In deployed environments each log line is one JSON object on stdout with `message`, `level`, `request_id`, `file`, `line` and `function`, plus `exception` on errors. This is the shape Railway indexes. -```bash -# Run all tests with detailed output -pytest tests/ -v +In Railway's log search: -# Run with logs displayed -pytest tests/ -v -s +- one request: `@request_id:` (the id is also returned in the `X-Request-ID` response header) +- all errors: `@level:error` -# Run only integration tests -pytest tests/ -m integration -v -``` +Logs deliberately contain no phone numbers, BSUIDs or message text. Railway caps logging at 500 lines per second per replica, and retention depends on the plan. -### Test Configuration +## Deploying (Railway) -Tests require environment variables to be configured. Key settings: +Only the facts below come from this repo. The dashboard settings themselves are set by an operator. -- **MOCK_ANSARI_CLIENT**: Set to `True` (default) to use mock client (no backend needed), `False` to use real backend -- **MOCK_META_API**: Set to `True` (default) to use mock Meta API, `False` for real API -- **BACKEND_SERVER_URL**: URL of the backend service (required if `MOCK_ANSARI_CLIENT=False`) +- Build with the repository root as the root directory and `apps/whatsapp/Dockerfile` as the Dockerfile path. +- Set a watch path so only changes under `apps/whatsapp/**` rebuild this service (see the "Railway service configuration" block in `RELEASE.md`). +- Health check path: `/`. +- The app binds `PORT` when Railway provides it. If the public domain is given a fixed target port, it must match. +- Each environment needs its own Meta credentials and its own webhook callback URL (`https:///whatsapp/v2`). -**Important**: If `MOCK_ANSARI_CLIENT=False` and the backend is not available, tests will fail with clear instructions on how to fix the issue. +The Docker image has not been built or run by the author of this change. Do that first when preparing a deploy. -See `tests/README.md` for detailed testing documentation and troubleshooting. +### Staging smoke test -## Troubleshooting +1. `GET /` returns 200. +2. Meta's webhook verification succeeds for the staging callback URL. +3. A normal message gets an answer; a message starting with `!d ` gets no reply from staging. +4. A second message within the retention window continues the same conversation. +5. A message after the retention window starts a new conversation and the old thread is gone from `GET /api/v2/threads`. +6. An answer longer than 4,000 characters arrives as several messages in order. +7. The answer is delivered to a user whose phone number is hidden (BSUID only). +8. With `WA_MAINTENANCE_MODE=True`, the maintenance reply is sent and no chat call appears in `apps/api` logs. +9. `GET /sentry-debug` produces a Sentry event. +10. In Railway's logs, `@request_id:` finds all lines of one message, and none contains a phone number or message text. -Logs are stored in the `logs/` directory. Check these logs for any errors or issues. +### Known gaps to confirm before production -Common issues: -- WhatsApp API connection problems -- Missing or invalid environment variables -- Connection issues with the Ansari backend -- Test failures due to backend availability (see `tests/README.md` for solutions) \ No newline at end of file +- That `from_user_id` is present in every webhook, and that replying to a BSUID is accepted by Meta. Neither has been tried against live Meta. +- `apps/auth`'s `/whatsapp` endpoint contract is assumed (route, header and `AUTH_WHATSAPP_API_KEY`); nothing here has run against a live `apps/auth` or `apps/api`. +- `apps/api`'s chat routes save every message with `source: "web"`, so WhatsApp messages are not marked as such in the history. +- Two near-simultaneous first messages from one user can create two threads. Extra `whatsapp` threads are harmless and can be swept manually (keep the newest per user). diff --git a/apps/whatsapp/tests/README.md b/apps/whatsapp/tests/README.md index 84672648..f1a5333c 100644 --- a/apps/whatsapp/tests/README.md +++ b/apps/whatsapp/tests/README.md @@ -1,177 +1,24 @@ -# WhatsApp Microservice Test Suite +# Tests -This directory contains integration tests for the WhatsApp microservice, focusing on WhatsApp webhook endpoints. +Run from `apps/whatsapp`: -## 🚨 IMPORTANT CHANGE: Test Location Update - -**WhatsApp API endpoint tests have been moved to `ansari-backend/tests/unit/test_whatsapp_api_endpoints.py`** - -### Reason for Move: -- The WhatsApp API endpoints (like `/whatsapp/v2/users/register`) are **implemented in ansari-backend** -- Tests should live with the code they test -- Moving them allows use of **TestClient** instead of external HTTP calls -- **No external server dependencies** needed for CI/CD -- **Faster test execution** with in-memory testing - -## Current Test Files (This Repo) - -### 1. WhatsApp Service Tests (`test_whatsapp_service.py`) -Tests WhatsApp service webhook endpoints (no external dependencies): -- ✅ WhatsApp service health check -- ✅ Webhook verification endpoint -- ✅ Basic webhook message processing -- ✅ Wrong phone ID handling (should be ignored) - -### 2. Test Utilities (`test_utils.py`) -Secure logging and testing utilities: -- 🔒 Sensitive data masking for logs -- 🌍 Environment variable loading with validation -- 📝 Secure test result logging - - -## 🚀 Quick Start - -### Prerequisites -1. **Environment Variables** (in `.env`): - ```env - META_WEBHOOK_VERIFY_TOKEN=your_verify_token - META_BUSINESS_PHONE_NUMBER_ID=your_phone_number_id - MOCK_ANSARI_CLIENT=True # Set to False to use real backend - MOCK_META_API=True # Set to False to use real Meta API - BACKEND_SERVER_URL=http://localhost:8000 # Required if MOCK_ANSARI_CLIENT=False - ALWAYS_RETURN_OK_TO_META=True # Set to False for CI/CD - LOG_TEST_FILES_ONLY=False # Set to True to filter test logs - ``` - -2. **Install Dependencies**: - ```bash - cd ansari-whatsapp - uv add pytest - ``` - -3. **Backend Availability**: - - **Mock Mode (MOCK_ANSARI_CLIENT=True)**: No backend needed, tests use mock client - - **Real Backend (MOCK_ANSARI_CLIENT=False)**: Backend must be running on `BACKEND_SERVER_URL` - - **Important**: If `MOCK_ANSARI_CLIENT=False` and backend is not available, tests will terminate with error instructions. - -### Running Tests - -#### WhatsApp Service Tests (This Repo) -```bash -# All WhatsApp service tests (local development) (the `-s` flag shows print/log statements) -pytest tests/test_whatsapp_service.py -v -s - -# With test-only logging (filters out non-test logs) -LOG_TEST_FILES_ONLY=True pytest tests/test_whatsapp_service.py -v -s - -# For CI/CD (proper HTTP status codes) -ALWAYS_RETURN_OK_TO_META=False pytest tests/test_whatsapp_service.py -v -``` - -#### WhatsApp API Tests (ansari-backend repo) -```bash -cd ../ansari-backend -pytest tests/unit/test_whatsapp_api_endpoints.py -v - -# Streaming tests specifically -pytest tests/unit/test_whatsapp_api_endpoints.py -m streaming -v -``` - -## 📊 Test Categories - -### WhatsApp Service Tests (ansari-whatsapp) -- **Purpose**: Test WhatsApp webhook endpoints -- **Technology**: pytest + TestClient (for WhatsApp service) -- **Dependencies**: ✅ **No external dependencies** (uses TestClient only) - -### API Tests (ansari-backend) -- **Purpose**: Test WhatsApp API endpoints in backend -- **Technology**: pytest + TestClient (for backend service) -- **Dependencies**: ✅ **No external servers needed** (uses TestClient) -- **Streaming**: ✅ **Comprehensive streaming endpoint testing** - -## 🔒 Security Features - -- **Sensitive Data Masking**: All tokens, phone numbers, and secrets are masked in logs -- **Environment Variables**: No hardcoded secrets in code -- **Secure Logging**: `test_utils.py` provides secure logging utilities - -## ⚠️ Error Handling & Troubleshooting - -### Backend Not Available Error -If you see this error, it means `MOCK_ANSARI_CLIENT=False` but the backend is not reachable: -``` -BACKEND IS NOT AVAILABLE -The backend server is not reachable, but MOCK_ANSARI_CLIENT is set to False. +```powershell +uv run pytest tests -q +uv run --with ruff ruff check . ``` -**Solutions:** -1. **Enable mock mode**: Set `MOCK_ANSARI_CLIENT=True` in `.env` -2. **Fix backend URL**: Verify `BACKEND_SERVER_URL` is correct in `.env` -3. **Start backend**: Ensure ansari-backend is running on the configured URL - -### Missing Environment Variables Error -If you see this error, required test environment variables are not set: -``` -ERROR CREATING TEST PAYLOAD -This usually means required environment variables are not set. -``` - -**Required variables:** -- `META_BUSINESS_PHONE_NUMBER_ID`: Your Meta business phone number ID -- `MOCK_ANSARI_CLIENT`: Set to `True` (default) for mock mode -- `MOCK_META_API`: Set to `True` (default) for mock mode - -Tests use hardcoded phone numbers and message IDs from Meta's sample webhook payloads. - -**Solution:** Check your `.env` file and ensure all required variables are set. See `.env.example` for examples. - -## 🛠 GitHub Actions Ready - -### ansari-whatsapp CI/CD -- ✅ **No external server dependencies** for integration tests -- ✅ **Uses TestClient** for WhatsApp service testing -- ✅ **Environment secrets** properly loaded -- ✅ **Fast execution** with in-memory testing -- ⚠️ **Important**: Set `ALWAYS_RETURN_OK_TO_META=False` in CI/CD workflows to get proper HTTP status codes for assertions - -**Example GitHub Actions workflow:** -```yaml -- name: Run tests - env: - ALWAYS_RETURN_OK_TO_META: False - run: pytest tests/ -v -``` - -### ansari-backend CI/CD -- ✅ **No external server dependencies** for API tests -- ✅ **Uses TestClient** for all endpoint testing -- ✅ **Comprehensive streaming tests** included - -## 📝 Migration Summary - -### What Moved: -- `test_whatsapp_api_endpoints.py` → `ansari-backend/tests/unit/test_whatsapp_api_endpoints.py` -- `whatsapp_test_utils.py` → `ansari-backend/tests/whatsapp_test_utils.py` - -### What Stayed: -- `test_whatsapp_service.py` - Tests WhatsApp webhook endpoints (renamed and focused) -- `test_utils.py` - Secure logging utilities for service tests - -### Benefits: -- 🚀 **Faster CI/CD** - No external server startup time -- 🔒 **Better Security** - No hardcoded secrets, secure logging -- 📍 **Proper Organization** - Tests live with the code they test -- 🧪 **Comprehensive Testing** - Full streaming endpoint coverage -- ⚡ **Easy Local Development** - TestClient for quick iteration +`conftest.py` switches on all three mocks and sets dummy credentials, so no backend, auth service, Meta account or `.env` is needed. Exported environment variables take precedence over those defaults. -## 📞 Support +| File | What it covers | +|---|---| +| `test_whatsapp_service.py` | Health check, webhook verification, signature checks, basic message handling, phone-hidden users | +| `test_bsuid_auth_flow.py` | BSUID to token exchange, `apps/api` calls with that token, the chat event stream and its failure modes, no phone number or BSUID leaking to `apps/api` or logs | +| `test_thread_lifecycle.py` | One thread per user, reuse within the retention window, delete-and-recreate after it, failed deletion | +| `test_environment_behaviour.py` | `WA_MAINTENANCE_MODE` and the staging filter that ignores `!d ` messages | +| `test_railway_logging.py` | JSON log shape, level mapping, one line on stdout | -For GitHub Actions setup or testing issues: -1. Check that environment secrets are configured in GitHub -2. Verify `.env` file contains required variables locally -3. Review test logs for specific error details -4. Consult `CLAUDE.md` files in both repositories for detailed instructions +Notes: -The test suite now provides comprehensive validation while being optimized for modern CI/CD workflows! \ No newline at end of file +- `test_utils.py` holds helpers for the service tests, not tests of its own. +- Everything runs against mocks and in-process fakes of HTTP calls. Nothing has been run against a live `apps/auth`, `apps/api` or Meta. +- To see the service logs while testing, add `-s`. From cc21d092c8f4f45a3867fb9b171884047255654a Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Mon, 26 Oct 2026 06:34:58 +0300 Subject: [PATCH 035/247] [Spec 321][Phase: environments_and_docs] chore: railway record, repo-root Dockerfile paths, plan outcome --- apps/whatsapp/Dockerfile | 14 ++++------- apps/whatsapp/README.md | 3 ++- apps/whatsapp/railway.toml | 23 ++++++++++++++++++ .../321-rfc-whatsapp-support-in-the-mo.md | 24 +++++++++++++++---- 4 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 apps/whatsapp/railway.toml diff --git a/apps/whatsapp/Dockerfile b/apps/whatsapp/Dockerfile index 1da8c777..98b6eaab 100644 --- a/apps/whatsapp/Dockerfile +++ b/apps/whatsapp/Dockerfile @@ -24,20 +24,16 @@ ENV UV_LINK_MODE=copy # Install dependencies # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + --mount=type=bind,source=apps/whatsapp/uv.lock,target=uv.lock \ + --mount=type=bind,source=apps/whatsapp/pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-project ENV PYTHONPATH=/app -# Copy project files into the container -# Side ntoe: we can write the destination path in 2 ways: -# - ./ == relative to WORKDIR (/app/), so ./ == /app/ -# - /app/ == absolute path to /app/ (same result, but explicit) -# the line below uses the second option for clarity -COPY ./pyproject.toml ./uv.lock /app/ +# The build context is the repo root (as for every service in this monorepo), so source paths start with apps/whatsapp/ +COPY apps/whatsapp/pyproject.toml apps/whatsapp/uv.lock /app/ -COPY ./src /app/src/ +COPY apps/whatsapp/src /app/src/ # Sync the project (--frozen: install exactly what uv.lock says, never re-resolve at deploy time) # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers diff --git a/apps/whatsapp/README.md b/apps/whatsapp/README.md index 8fa6700c..f2e82b64 100644 --- a/apps/whatsapp/README.md +++ b/apps/whatsapp/README.md @@ -102,7 +102,8 @@ Logs deliberately contain no phone numbers, BSUIDs or message text. Railway caps Only the facts below come from this repo. The dashboard settings themselves are set by an operator. -- Build with the repository root as the root directory and `apps/whatsapp/Dockerfile` as the Dockerfile path. +- Build with the repository root as the root directory and `apps/whatsapp/Dockerfile` as the Dockerfile path. Locally, from the repo root: `docker build -f apps/whatsapp/Dockerfile -t ansari-whatsapp .` +- `apps/whatsapp/railway.toml` records these settings in git; Railway does not read it (same as `apps/api/railway.toml`). - Set a watch path so only changes under `apps/whatsapp/**` rebuild this service (see the "Railway service configuration" block in `RELEASE.md`). - Health check path: `/`. - The app binds `PORT` when Railway provides it. If the public domain is given a fixed target port, it must match. diff --git a/apps/whatsapp/railway.toml b/apps/whatsapp/railway.toml new file mode 100644 index 00000000..7b947fa6 --- /dev/null +++ b/apps/whatsapp/railway.toml @@ -0,0 +1,23 @@ +# Railway settings for this service, kept in version control. +# +# NOT READ BY RAILWAY IN THIS PROJECT (see apps/api/railway.toml and the +# "Railway service configuration" block in RELEASE.md). The dashboard is the live +# configuration; this file is the reviewable record of what it should contain. +# The service has not been created in Railway yet: confirm every value there. +# +# Keep the two in sync: if you change a dashboard setting, update this file in the +# same PR. + +[build] +builder = "dockerfile" +dockerfilePath = "apps/whatsapp/Dockerfile" +watchPatterns = [ + "apps/whatsapp/**", +] + +[deploy] +# `/` is the service's health check. The app binds PORT when Railway sets it. +healthcheckPath = "/" +healthcheckTimeout = 300 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index cb7b763f..0e5c5d5d 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -135,10 +135,26 @@ Finish environment behaviour and make the service testable and deployable by oth #### Deliverables -- [ ] Maintenance flag reuse with tests. -- [ ] `!d ` filter kept, with tests (ignored in staging, processed otherwise). -- [ ] README covering setup, tests, and local testing with zrok. -- [ ] Staging smoke-test checklist. +- [x] Maintenance flag `WA_MAINTENANCE_MODE`, with tests. +- [x] `!d ` filter kept, with tests (ignored in staging, processed otherwise). +- [x] README covering setup, tests, and local testing with zrok. +- [x] Staging smoke-test checklist (in `apps/whatsapp/README.md`). + +#### Phase 4 outcome (what changed from the notes above) + +- The client now uses the `/chat` stream, which reports failures as an explicit `error` event, so the inline `Error:` detection in item (a) is no longer needed; a failed, cut-off or malformed stream is a failure the user is told about. +- Logs carry only `request_id` (no user id, so `get_subject` was removed) and are one JSON line per entry on stdout, the shape Railway indexes. +- Thread-list, create, delete, timeout and sign-in failures are captured in Sentry. +- `/sentry-debug` is kept but registered only outside production; `clean_logs.sh` is removed (logs go to stdout). +- The app reads `PORT` (default 8001). +- Dockerfile: uv 0.9.13, `uv sync --frozen`, and paths relative to the repo root (the build context RELEASE.md prescribes). `apps/whatsapp/railway.toml` records the Railway settings; Railway does not read it. + +#### Open at the end of Phase 4 (not verified) + +- The Docker image has never been built or run (no Docker daemon was available). +- `from_user_id` presence in every webhook, and a real send to a BSUID, are untested against live Meta. +- The `apps/auth` `/whatsapp` contract (route, header, `AUTH_WHATSAPP_API_KEY`) is assumed; nothing has run against a live `apps/auth` or `apps/api`. +- The `whatsapp` CI job has not run on a GitHub runner. Making it a required check on `develop` needs a repo admin; mention how in the PR description rather than doing it here. #### Acceptance Criteria From ccdc1bd01e0959f630cb2280d6f4f30307d20715 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Mon, 26 Oct 2026 07:17:16 +0300 Subject: [PATCH 036/247] [Spec 321][Phase: environments_and_docs] feat: MOCK_AUTH_TOKEN for runs against a real apps/api; record real-service run --- apps/whatsapp/.env.example | 5 +++++ apps/whatsapp/README.md | 1 + .../src/ansari_whatsapp/services/auth_client_mock.py | 3 +++ apps/whatsapp/src/ansari_whatsapp/utils/config.py | 2 ++ apps/whatsapp/tests/test_bsuid_auth_flow.py | 11 +++++++++++ codev/plans/321-rfc-whatsapp-support-in-the-mo.md | 10 +++++++++- 6 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/whatsapp/.env.example b/apps/whatsapp/.env.example index 94121d9c..cabe486e 100644 --- a/apps/whatsapp/.env.example +++ b/apps/whatsapp/.env.example @@ -167,6 +167,11 @@ MOCK_ANSARI_CLIENT="False" # Useful while apps/auth's /whatsapp endpoint is not available. When False (default), the real call is made. MOCK_AUTH_CLIENT="False" +# Optional, only with MOCK_AUTH_CLIENT=True: a real apps/auth JWT that the mock returns for every BSUID, +# so a mocked sign-in can talk to a real apps/api. Get one by logging in through apps/api (POST /api/v2/users/login). +# Leave unset to get fake tokens (which a real apps/api rejects). +# MOCK_AUTH_TOKEN="" + # If True, use mock Meta API service instead of making real HTTP calls to Meta WhatsApp API # Mock mode is useful for: # - Development when Meta API credentials are not configured or invalid diff --git a/apps/whatsapp/README.md b/apps/whatsapp/README.md index f2e82b64..42efa5d2 100644 --- a/apps/whatsapp/README.md +++ b/apps/whatsapp/README.md @@ -74,6 +74,7 @@ Staging shares the same test number, so start every message you send while testi | `WHATSAPP_CHAT_RETENTION_HOURS` | How long a conversation continues before a fresh thread (default 3) | | `SENTRY_DSN` | Empty disables Sentry | | `MOCK_ANSARI_CLIENT`, `MOCK_AUTH_CLIENT`, `MOCK_META_API` | Replace the real services with fakes | +| `MOCK_AUTH_TOKEN` | With `MOCK_AUTH_CLIENT=True`: a real JWT returned for every BSUID, so a real `apps/api` accepts it | Meta credentials differ per Meta app (Test, Staging, production). A token from one app will not work with another app's secret. diff --git a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py index 84daf963..141d2c56 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/auth_client_mock.py @@ -4,6 +4,7 @@ import uuid from ansari_whatsapp.services.auth_client_base import AuthClientBase +from ansari_whatsapp.utils.config import get_settings from ansari_whatsapp.utils.exceptions import AuthTokenError @@ -13,4 +14,6 @@ class AuthClientMock(AuthClientBase): async def get_token(self, bsuid: str) -> str: if not bsuid: raise AuthTokenError("Missing BSUID") + if get_settings().MOCK_AUTH_TOKEN: + return get_settings().MOCK_AUTH_TOKEN.get_secret_value() return f"mock-token-{uuid.uuid5(uuid.NAMESPACE_URL, f'mock-bsuid:{bsuid}')}" diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/config.py b/apps/whatsapp/src/ansari_whatsapp/utils/config.py index 1980a068..2b60c163 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/config.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/config.py @@ -98,6 +98,8 @@ def META_API_URL(self) -> str: SENTRY_DSN: HttpUrl | None = None MOCK_ANSARI_CLIENT: bool = False MOCK_AUTH_CLIENT: bool = False + # A real JWT the mock auth client returns for every BSUID, so a mocked sign-in can talk to a real apps/api + MOCK_AUTH_TOKEN: SecretStr | None = None MOCK_META_API: bool = False ########### Validators ########### diff --git a/apps/whatsapp/tests/test_bsuid_auth_flow.py b/apps/whatsapp/tests/test_bsuid_auth_flow.py index d27430bf..adf04369 100644 --- a/apps/whatsapp/tests/test_bsuid_auth_flow.py +++ b/apps/whatsapp/tests/test_bsuid_auth_flow.py @@ -132,6 +132,17 @@ def test_mock_auth_same_bsuid_same_user_new_bsuid_new_user(): assert first != other +def test_mock_auth_returns_the_configured_real_token_when_one_is_set(monkeypatch): + monkeypatch.setenv("MOCK_AUTH_TOKEN", "a.real.jwt") + get_settings.cache_clear() + + try: + assert asyncio.run(AuthClientMock().get_token(BSUID)) == "a.real.jwt" + finally: + monkeypatch.delenv("MOCK_AUTH_TOKEN") + get_settings.cache_clear() + + # ---------- apps/api client ---------- diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index 0e5c5d5d..c40ac669 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -149,11 +149,19 @@ Finish environment behaviour and make the service testable and deployable by oth - The app reads `PORT` (default 8001). - Dockerfile: uv 0.9.13, `uv sync --frozen`, and paths relative to the repo root (the build context RELEASE.md prescribes). `apps/whatsapp/railway.toml` records the Railway settings; Railway does not read it. +#### Real-service run (owner requirement: Phase 4 is not complete on mocks alone) + +Done 2026-10-26, local, no Docker: real `apps/auth` (new empty `ansari_auth` database) and real `apps/api` (the local app database, migrated 8 to 11 with existing rows intact, Gemini key from `apps/api/.env`), the WhatsApp service with `MOCK_ANSARI_CLIENT=False`, `MOCK_AUTH_CLIENT=True` plus `MOCK_AUTH_TOKEN` (a real JWT from a throwaway user) and `MOCK_META_API=True`. + +- A correctly signed webhook (BSUID only) was accepted, the real `apps/api` accepted the JWT, a `whatsapp` thread was created, the real `/chat` stream returned an answer (about 9 seconds) and it was handed to the (mock) Meta sender. +- A second message from the same BSUID reused the same thread: one thread, four saved messages (user, assistant, user, assistant). +- Still real-only gaps: the BSUID-to-JWT exchange (`apps/auth` has no `/whatsapp` route yet; mock by owner decision, to confirm with Amr) and real Meta (receiving a webhook from Meta, sending to a BSUID). The Meta run is a blocker for the staging smoke test and remains open. + #### Open at the end of Phase 4 (not verified) - The Docker image has never been built or run (no Docker daemon was available). - `from_user_id` presence in every webhook, and a real send to a BSUID, are untested against live Meta. -- The `apps/auth` `/whatsapp` contract (route, header, `AUTH_WHATSAPP_API_KEY`) is assumed; nothing has run against a live `apps/auth` or `apps/api`. +- The `apps/auth` `/whatsapp` contract (route, header, `AUTH_WHATSAPP_API_KEY`) is assumed and was not exercised (a real `apps/api` and the real `apps/auth` were run, see above). - The `whatsapp` CI job has not run on a GitHub runner. Making it a required check on `develop` needs a repo admin; mention how in the PR description rather than doing it here. #### Acceptance Criteria From 6c4de646f77c06a892a9ebed967062a45a558c7b Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Mon, 26 Oct 2026 09:17:57 +0300 Subject: [PATCH 037/247] [Spec 321][Phase: environments_and_docs] fix: never log the webhook verify token; keep tests independent of local MOCK_AUTH_TOKEN --- apps/whatsapp/src/ansari_whatsapp/app/main.py | 5 +--- apps/whatsapp/tests/conftest.py | 3 +++ apps/whatsapp/tests/test_bsuid_auth_flow.py | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/whatsapp/src/ansari_whatsapp/app/main.py b/apps/whatsapp/src/ansari_whatsapp/app/main.py index b17c44f0..598bef0f 100644 --- a/apps/whatsapp/src/ansari_whatsapp/app/main.py +++ b/apps/whatsapp/src/ansari_whatsapp/app/main.py @@ -174,10 +174,7 @@ async def verification_webhook(request: Request) -> str | None: verify_token = request.query_params.get("hub.verify_token") challenge = request.query_params.get("hub.challenge") - if get_settings().DEPLOYMENT_TYPE == "local": - logger.debug(f"Verification webhook received: {mode=}, {challenge=}, {verify_token=}") - else: - logger.debug(f"Verification webhook received: {mode=}, {challenge=}, verification token matches configured value") + logger.debug(f"Verification webhook received: {mode=}, {challenge=}, token_present={bool(verify_token)}") if mode and verify_token: if mode == "subscribe" and verify_token == get_settings().META_WEBHOOK_VERIFY_TOKEN.get_secret_value(): diff --git a/apps/whatsapp/tests/conftest.py b/apps/whatsapp/tests/conftest.py index 764927b5..b4db1c1e 100644 --- a/apps/whatsapp/tests/conftest.py +++ b/apps/whatsapp/tests/conftest.py @@ -14,3 +14,6 @@ "META_ACCESS_TOKEN_FROM_SYS_USER": "test", }.items(): os.environ.setdefault(key, value) + +# A real token in a developer's .env would make every BSUID the same user +os.environ["MOCK_AUTH_TOKEN"] = "" diff --git a/apps/whatsapp/tests/test_bsuid_auth_flow.py b/apps/whatsapp/tests/test_bsuid_auth_flow.py index adf04369..41d1a15b 100644 --- a/apps/whatsapp/tests/test_bsuid_auth_flow.py +++ b/apps/whatsapp/tests/test_bsuid_auth_flow.py @@ -347,3 +347,29 @@ def test_logs_contain_no_phone_number_bsuid_or_message_text(): assert PHONE not in log_text assert BSUID not in log_text assert "a-very-distinctive-question" not in log_text + + +def test_verification_handshake_never_logs_the_verify_token(monkeypatch): + from loguru import logger + + token = "a-distinctive-verify-token-4711" + monkeypatch.setenv("META_WEBHOOK_VERIFY_TOKEN", token) + get_settings.cache_clear() + captured = [] + sink_id = logger.add(lambda m: captured.append(str(m)), level="DEBUG") + try: + ok = client.get("/whatsapp/v2", params={"hub.mode": "subscribe", "hub.verify_token": token, "hub.challenge": "c1"}) + bad = client.get( + "/whatsapp/v2", params={"hub.mode": "subscribe", "hub.verify_token": "a-wrong-token-value", "hub.challenge": "c2"} + ) + finally: + logger.remove(sink_id) + monkeypatch.undo() + get_settings.cache_clear() + + log_text = "\n".join(captured) + assert ok.text == "c1" + assert bad.status_code == 403 + assert log_text + assert token not in log_text + assert "a-wrong-token-value" not in log_text From 63ffdb87e49fce0a7d75a0b45115746241a45150 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Mon, 26 Oct 2026 10:41:34 +0300 Subject: [PATCH 038/247] [Spec 321][Phase: environments_and_docs] docs: document REAL_TEST_BSUID in .env.example --- apps/whatsapp/.env.example | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/whatsapp/.env.example b/apps/whatsapp/.env.example index cabe486e..2aabe81f 100644 --- a/apps/whatsapp/.env.example +++ b/apps/whatsapp/.env.example @@ -138,6 +138,15 @@ WHATSAPP_MESSAGE_AGE_THRESHOLD_SECONDS="86400" ###################################### Related to Test/Development/Logging ###################################### +# Optional. Only needed for one manual check: "does Meta really accept a message sent to a BSUID?" +# A BSUID is Meta's per-user ID that replaces the phone number when a user hides it (looks like EG.1234567890123456789). +# You can't make one up; it has to belong to a real WhatsApp user who has already messaged OUR business number. +# How to get yours: message the business number from your own phone, then read the BSUID from a log line that +# prints the webhook's from_user_id. (The old ansari-whatsapp service doesn't log it unless a temporary line is added.) +# BSUIDs are per business portfolio, so yours only works with phone numbers owned by the same portfolio. +# Keep it private: never commit it, paste it in an issue or print it in logs. +# REAL_TEST_BSUID="" + # Meta webhook response behavior # Meta's webhooks require HTTP 200 responses to acknowledge receipt. # When True (production/staging/development/local): Always return HTTP 200 to Meta, regardless of internal errors From b4f14df71e59bce8379672630e1c45ab8968c341 Mon Sep 17 00:00:00 2001 From: macbook pro Date: Mon, 26 Oct 2026 10:36:02 +0100 Subject: [PATCH 039/247] [Spec 324] Add native Google and Apple authentication specification Defines the requirements, native module constraints (expo-apple-authentication and @react-native-google-signin/google-signin), Better Auth ID token verification contract, and two-token protocol parity for issue #324. --- codev/specs/324-native-google-apple-auth.md | 228 ++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 codev/specs/324-native-google-apple-auth.md diff --git a/codev/specs/324-native-google-apple-auth.md b/codev/specs/324-native-google-apple-auth.md new file mode 100644 index 00000000..f5a7f824 --- /dev/null +++ b/codev/specs/324-native-google-apple-auth.md @@ -0,0 +1,228 @@ +# Specification: Native Google and Apple Authentication (Mobile & Web) + + + +## Metadata + +- **ID**: spec-324-native-google-apple-auth +- **Status**: draft +- **Created**: 2026-10-25 +- **Source**: GitHub issue #324 (`iaser-ai/ansari#324`) +- **Protocol**: PIR + +--- + +## Owner & Architect Guidance (Review Amendments) + +1. **Native Modules on Mobile (Non-Negotiable)**: On native iOS and Android, third-party authentication **must use native modules**, not web browser redirects (`expo-web-browser`, Custom Tabs, or `ASWebAuthenticationSession`). Tapping "Continue with Apple" or "Continue with Google" must trigger the OS-native dialogs directly. Web browser redirect OAuth applies strictly to the web build. +2. **Apple HIG & App Store Compliance**: Apple Sign-In is required on iOS under App Store Review Guideline 4.8 because the app offers third-party authentication. Button appearance must strictly conform to Apple's Human Interface Guidelines. +3. **Two-Token Protocol Parity**: Social sign-in must integrate seamlessly into the monorepo's existing two-token architecture established in #300. Sessions created via Google or Apple must receive a Better Auth `sessionToken` and exchange it for an `apps/api` EdDSA JWT via `GET /api/auth/token`. +4. **No Disruption to Existing Auth**: Existing email/password authentication, password reset, guest account auto-provisioning, and account deletion contracts must remain unchanged. + +--- + +## Problem Statement + +In issue #245, "Continue with Apple" and "Continue with Google" buttons were introduced to the sign-in sheet (`apps/frontend/components/AuthSheet.tsx`) as presentational UI stubs above the email/password form. When pressed, they currently display an inline notice that the provider is unavailable. + +Inspection of the authentication architecture shows: + +1. **Backend Service (`apps/auth` & `packages/auth`)**: + - Configures Better Auth solely with `emailAndPassword`. The `socialProviders` configuration is completely absent from `packages/auth/src/index.ts`. + - `packages/auth/src/env.ts` has no environment variables declared for Google or Apple client IDs or secrets. + - The backend does not yet validate native OpenID Connect / ID tokens sent directly from mobile SDKs. +2. **Frontend App (`apps/frontend`)**: + - `lib/auth/api.ts` only supports email/password operations (`/sign-in/email`, `/sign-up/email`). + - The app has no native authentication modules wired for Apple (`expo-apple-authentication`) or Google (`@react-native-google-signin/google-signin`). + - Browser redirect OAuth on mobile introduces poor UX: it switches users out of the application into Safari/Chrome, prompts for third-party cookie permissions, requires deep-link URL scheme handling (`ansarichat://auth/callback`), and fails App Store review standards for frictionless native auth. + +Without native third-party authentication, user onboarding friction remains high, and native mobile releases risk rejection under App Store Guideline 4.8. + +--- + +## Current State + +### Backend (`packages/auth` & `apps/auth`) + +- **Database Schema (`packages/auth/src/schema.ts`)**: + - The `auth.account` table already exists with columns suited for OAuth credentials: `id`, `user_id`, `account_id`, `provider_id`, `access_token`, `refresh_token`, `id_token`, `access_token_expires_at`, `refresh_token_expires_at`, `scope`, and a unique constraint on `(user_id, provider_id)`. + - The `auth.users` table stores `id` (UUID), `email`, `name`, `first_name`, `last_name`, `email_verified`, `image`, and `registered_via`. +- **Better Auth Instance (`packages/auth/src/index.ts`)**: + - Configures `emailAndPassword`, `session` (30 days, 1-day slide), and plugins: `expo()`, `bearer()`, `jwt()` (EdDSA, 15m expiry), and `admin()`. + - `trustedOrigins` includes `ansarichat://`, `CORS_ORIGIN`, and dev origins. + - `socialProviders` is **unset**. +- **Environment Schema (`packages/auth/src/env.ts`)**: + - Validates `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `CORS_ORIGIN`, `AUTH_LAYER_ORIGIN`, `AUTH_JWT_ISSUER`, `AUTH_JWT_AUDIENCE`, `RESEND_API_KEY`, `FRONTEND_URL`. No OAuth variables exist. + +### Frontend (`apps/frontend`) + +- **Components (`components/AuthSheet.tsx` & `components/BrandMarks.tsx`)**: + - Official vector marks `AppleMark` and `GoogleMark` are rendered inside `ProviderButton`. + - Light and dark styles adhere to Apple HIG and Google branding guidelines. + - `onPress` sets local state `unavailable` and displays an inline notice. +- **Client Auth Layer (`lib/auth/`)**: + - `lib/auth/api.ts` calls `customFetch` for `/sign-in/email`, `/sign-up/email`, `/token`, `/sign-out`, and password reset. + - `lib/auth/context.tsx` provides `AuthProvider`. `startSession()` accepts an `AuthSession`, calls `authApi.mintJwt(sessionToken)`, persists credentials via `saveSession()`, and clears the React Query cache. + - `lib/auth/store.ts` persists `accessToken`, `sessionToken`, and user names to `expo-secure-store` (native) or `localStorage` (web). + +### API Backend (`apps/api`) + +- `apps/api` validates incoming `Authorization: Bearer ` against `apps/auth`'s public JWKS endpoint (`/api/auth/jwks`) statelessly, reading `{ sub, sid }`. It has no dependency on how the session was created. + +--- + +## Desired State + +### 1. Unified Authentication Architecture + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ FRONTEND │ +├───────────────────────────────┬────────────────────────────────────────┤ +│ iOS & Android Native │ Web │ +│ (expo-apple-authentication │ (Better Auth Web Client) │ +│ @react-native-google- │ │ +│ signin/google-signin) │ │ +└───────────────┬───────────────┴───────────────────┬────────────────────┘ + │ Native ID Token │ Web OAuth Redirect + │ (Direct JSON POST) │ / Callback + ▼ ▼ +┌────────────────────────────────────────────────────────────────────────┐ +│ apps/auth (Better Auth) │ +│ - Verifies Apple ID Token via Apple JWKS (aud: chat.ansari.app) │ +│ - Verifies Google ID Token via Google Certs (aud: GOOGLE_CLIENT_ID) │ +│ - Links/creates user in auth.users and auth.account │ +│ - Issues Better Auth sessionToken │ +└───────────────────────────────┬────────────────────────────────────────┘ + │ GET /api/auth/token + │ (Bearer sessionToken) + ▼ +┌────────────────────────────────────────────────────────────────────────┐ +│ apps/auth JWT Engine │ +│ - Mints 15-minute EdDSA JWT containing { sub: user.id, sid } │ +└───────────────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────┐ +│ apps/frontend Storage │ +│ - Persists sessionToken & accessToken in SecureStore / localStorage │ +│ - Attaches Bearer accessToken to all apps/api calls │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +### 2. Native Mobile Authentication (iOS & Android) + +- **Apple Sign-In (iOS)**: + - Uses `expo-apple-authentication`. + - When pressed, invokes `AppleAuthentication.signInAsync()`. + - Requests scopes: `FULL_NAME` and `EMAIL`. + - Retrieves `identityToken` (Apple-signed JWT) and `fullName` (`givenName`, `familyName`). + - Sends `{ provider: 'apple', idToken: identityToken, name: ... }` to `apps/auth`. + - Never opens a browser window or webview. +- **Google Sign-In (Android & iOS)**: + - Uses `@react-native-google-signin/google-signin`. + - Configured with `webClientId: GOOGLE_WEB_CLIENT_ID` (so Google Play Services / iOS SDK mints an ID token intended for the backend server). + - Invokes `GoogleSignin.signIn()` which renders the OS-native Google account picker sheet. + - Retrieves `idToken` and sends `{ provider: 'google', idToken }` to `apps/auth`. + - Never opens an in-app browser (`expo-web-browser`) or Custom Tabs. +- **Cancellation & Failure UX**: + - If a user cancels or dismisses the native dialog (e.g. `ERR_REQUEST_CANCELED` on Apple or `SIGN_IN_CANCELLED` on Google), the sheet remains open without an error banner. + - If network or server verification fails, an inline error is displayed in `AuthSheet`. + +### 3. Web Social Authentication + +- In standard desktop and mobile browsers: + - Uses Better Auth's client SDK `signIn.social({ provider, callbackURL: window.location.origin })`. + - Supports standard OAuth redirect flow. + - On callback, completes session creation, mints the initial `apps/api` JWT, and redirects to the application root. + +### 4. Backend ID Token Verification & User Resolution + +- **ID Token Verification**: + - `apps/auth` verifies incoming `idToken`s directly against the provider's official keys: + - Apple: `https://appleid.apple.com/auth/keys`. Validates `iss === 'https://appleid.apple.com'`, non-expired, and `aud` matches `chat.ansari.app` (iOS) or `APPLE_CLIENT_ID` (Web Services ID). + - Google: `https://www.googleapis.com/oauth2/v3/certs`. Validates `iss` in `['accounts.google.com', 'https://accounts.google.com']`, non-expired, and `aud` matches `GOOGLE_CLIENT_ID`. +- **Account Linking & Collision Policy**: + - Both Google and Apple guarantee email verification. + - If an account already exists in `auth.users` with the same email (e.g. created via email/password or guest promotion): + - The provider account row is linked in `auth.account` under the same `user_id`. + - No duplicate user row is created. + - Password authentication continues to work for that user. +- **Name Handling & Apple Special Case**: + - Apple only returns `fullName` on the **first authorization**. Subsequent Apple logins omit the name. + - On first sign-in, any provided `firstName` and `lastName` must be immediately saved to `auth.users`. + - Apple Private Relay emails (`@privaterelay.appleid.com`) are treated as valid unique email addresses and saved as-is. +- **Client Attribution**: + - User creation hooks inspect `X-Ansari-Client` to stamp `registered_via` (`ios`, `android`, or `web`). + +### 5. Two-Token Protocol Parity + +- The response from `apps/auth` contains the Better Auth `sessionToken` and user details. +- Frontend calls `authApi.mintJwt(sessionToken)` to obtain the 15-minute `apps/api` EdDSA JWT. +- Tokens are saved to `expo-secure-store` (native) or `localStorage` (web). +- When `apps/api` returns `401`, the existing `refresh()` routine calls `GET /api/auth/token` with `sessionToken` and obtains a fresh JWT without user interruption. + +--- + +## Stakeholders + +- **Mobile Users (iOS & Android)**: Seamless one-tap native sign-in without leaving the app. +- **Web Users**: Familiar Google and Apple sign-in options. +- **App Review Teams (Apple App Store / Google Play)**: Native compliance with Apple Guideline 4.8 and Google Play Services standards. +- **Security & Engineering Maintainers**: Robust ID token signature verification, strict audience validation, and clean separation between auth sessions and API access tokens. + +--- + +## Success Criteria + +- [ ] **Native Apple Sign-In (iOS)**: + - Tapping "Continue with Apple" on iOS launches the native system sheet via `expo-apple-authentication`. + - No web browser, custom tab, or Safari view controller is ever opened. + - Successful sign-in creates or links the user, securely storing tokens in `expo-secure-store`. + - Works with Apple Private Relay emails (`@privaterelay.appleid.com`). + - User names provided on first sign-in are captured in `auth.users`. +- [ ] **Native Google Sign-In (Android & iOS)**: + - Tapping "Continue with Google" on Android and iOS launches the native account picker via `@react-native-google-signin/google-signin`. + - No web browser or Custom Tab is opened on mobile devices. + - Successfully verifies `idToken` on `apps/auth` and creates/links the user. +- [ ] **Web Social Sign-In**: + - Google and Apple sign-in work in desktop and mobile web browsers via Better Auth's standard web flow. +- [ ] **Two-Token Parity**: + - All social sign-ins produce a Better Auth `sessionToken` and immediately mint an `apps/api` EdDSA JWT. + - Requests to `apps/api` (`/api/v2/threads`, SSE streaming) succeed with the minted JWT. + - 401 token refresh functions identically to email/password sessions. +- [ ] **Account Linking**: + - Signing in with Google or Apple using an email already registered via email/password links the provider account without errors or orphaned duplicate users. +- [ ] **Cancellation UX**: + - User dismissing the native Apple or Google dialog leaves the `AuthSheet` intact without displaying an error alert. +- [ ] **Testing & Verification**: + - Unit/contract tests in `packages/auth` verifying ID token verification, user linking, and JWT minting. + - Unit tests in `apps/frontend` verifying button handlers, cancellation handling, and session establishment. + - All workspace checks pass: `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build`. + +--- + +## Constraints + +1. **Native-Only on Mobile**: Mobile platforms must never fall back to browser redirects for Google or Apple. +2. **App Store Guideline 4.8**: Apple Sign-In button must use Apple's standard design rules and must be positioned prominently. +3. **EAS Prebuild / Native Config**: + - `expo-apple-authentication` requires the `expo-apple-authentication` config plugin and Apple Developer entitlement (`com.apple.developer.applesignin`). + - `@react-native-google-signin/google-signin` requires Google Services configuration (`google-services.json` on Android, `GoogleService-Info.plist` or `iosClientId` on iOS). +4. **Secret Management**: + - No client secrets may be bundled in the native mobile apps. Mobile SDKs acquire ID tokens signed by Apple/Google, which `apps/auth` verifies using public key sets. + - Provider credentials (`GOOGLE_CLIENT_SECRET`, Apple private keys) live strictly on the backend (`apps/auth`). +5. **No Anonymous API Bypass**: `apps/api` continues to require a valid Bearer JWT on all protected routes; social auth changes only how sessions are established on `apps/auth`. + +--- + +## Out of Scope + +- Additional third-party OAuth providers (GitHub, Microsoft, Facebook, etc.). +- Changes to email/password authentication or guest auto-provisioning rules. +- Native Google Sign-In on Web (Web uses standard Google OAuth / One Tap if configured, but not native mobile bridge). +- Account unlinking UI in settings (deferred to future account management features). From 1e5a69cc633579def64535ccc52be969088b4ea7 Mon Sep 17 00:00:00 2001 From: macbook pro Date: Mon, 26 Oct 2026 10:44:05 +0100 Subject: [PATCH 040/247] [Spec 324] Use react-native-nitro-google-signin for native Google sign-in Updates spec 324 to adopt react-native-nitro-google-signin powered by Nitro Modules (Android Credential Manager & native iOS SDK) in place of @react-native-google-signin/google-signin. --- codev/specs/324-native-google-apple-auth.md | 141 ++++++++++++++------ 1 file changed, 101 insertions(+), 40 deletions(-) diff --git a/codev/specs/324-native-google-apple-auth.md b/codev/specs/324-native-google-apple-auth.md index f5a7f824..c0cf2345 100644 --- a/codev/specs/324-native-google-apple-auth.md +++ b/codev/specs/324-native-google-apple-auth.md @@ -18,10 +18,11 @@ Implementation phases, detailed code changes, and sequencing live in codev/plans ## Owner & Architect Guidance (Review Amendments) -1. **Native Modules on Mobile (Non-Negotiable)**: On native iOS and Android, third-party authentication **must use native modules**, not web browser redirects (`expo-web-browser`, Custom Tabs, or `ASWebAuthenticationSession`). Tapping "Continue with Apple" or "Continue with Google" must trigger the OS-native dialogs directly. Web browser redirect OAuth applies strictly to the web build. -2. **Apple HIG & App Store Compliance**: Apple Sign-In is required on iOS under App Store Review Guideline 4.8 because the app offers third-party authentication. Button appearance must strictly conform to Apple's Human Interface Guidelines. -3. **Two-Token Protocol Parity**: Social sign-in must integrate seamlessly into the monorepo's existing two-token architecture established in #300. Sessions created via Google or Apple must receive a Better Auth `sessionToken` and exchange it for an `apps/api` EdDSA JWT via `GET /api/auth/token`. -4. **No Disruption to Existing Auth**: Existing email/password authentication, password reset, guest account auto-provisioning, and account deletion contracts must remain unchanged. +1. **Native Modules on Mobile (Non-Negotiable)**: On native iOS and Android, third-party authentication **must use native modules**, not web browser redirects (`expo-web-browser`, Custom Tabs, or `ASWebAuthenticationSession`). Tapping "Continue with Apple" or "Continue with Google" must trigger the OS-native dialogs directly (`expo-apple-authentication` for Apple on iOS; `react-native-nitro-google-signin` for Google on Android and iOS). Web browser redirect OAuth applies strictly to the web build. +2. **Modern Identity Frameworks (Nitro Modules)**: Google authentication on mobile uses `react-native-nitro-google-signin` (Nitro Modules / JSI), which leverages Android's modern Credential Manager (`GetSignInWithGoogleOption`) rather than deprecated legacy Play Services Auth, and the native Google Sign-In SDK on iOS. +3. **Apple HIG & App Store Compliance**: Apple Sign-In is required on iOS under App Store Review Guideline 4.8 because the app offers third-party authentication. Button appearance and placement must strictly conform to Apple's Human Interface Guidelines. +4. **Two-Token Protocol Parity**: Social sign-in must integrate seamlessly into the monorepo's existing two-token architecture established in #300. Sessions created via Google or Apple must receive a Better Auth `sessionToken` and exchange it for an `apps/api` EdDSA JWT via `GET /api/auth/token`. +5. **No Disruption to Existing Auth**: Existing email/password authentication, password reset, guest account auto-provisioning, and account deletion contracts must remain unchanged. --- @@ -29,7 +30,7 @@ Implementation phases, detailed code changes, and sequencing live in codev/plans In issue #245, "Continue with Apple" and "Continue with Google" buttons were introduced to the sign-in sheet (`apps/frontend/components/AuthSheet.tsx`) as presentational UI stubs above the email/password form. When pressed, they currently display an inline notice that the provider is unavailable. -Inspection of the authentication architecture shows: +Inspection of the authentication architecture reveals the underlying gaps: 1. **Backend Service (`apps/auth` & `packages/auth`)**: - Configures Better Auth solely with `emailAndPassword`. The `socialProviders` configuration is completely absent from `packages/auth/src/index.ts`. @@ -37,24 +38,31 @@ Inspection of the authentication architecture shows: - The backend does not yet validate native OpenID Connect / ID tokens sent directly from mobile SDKs. 2. **Frontend App (`apps/frontend`)**: - `lib/auth/api.ts` only supports email/password operations (`/sign-in/email`, `/sign-up/email`). - - The app has no native authentication modules wired for Apple (`expo-apple-authentication`) or Google (`@react-native-google-signin/google-signin`). + - The app has no native authentication modules wired for Apple (`expo-apple-authentication`) or Google (`react-native-nitro-google-signin`). - Browser redirect OAuth on mobile introduces poor UX: it switches users out of the application into Safari/Chrome, prompts for third-party cookie permissions, requires deep-link URL scheme handling (`ansarichat://auth/callback`), and fails App Store review standards for frictionless native auth. -Without native third-party authentication, user onboarding friction remains high, and native mobile releases risk rejection under App Store Guideline 4.8. +### Who is affected and how: +- **Mobile Users (iOS & Android)**: Cannot use single-tap biometric or native OS account sign-in; forced to manually type email and password or remain in unauthenticated guest mode. +- **Product & App Store Release**: Mobile submissions to the Apple App Store will fail Guideline 4.8 if third-party authentication is offered without equivalent native Apple Sign-In support. +- **Web Users**: Lack standard OAuth convenience buttons. +- **Security & Infrastructure**: Must ensure incoming third-party tokens are verified fail-closed against provider JWKS/certs with strict audience pinning, preserving the Two-Token protocol invariant. --- ## Current State +Verified directly against the codebase at branch base: + ### Backend (`packages/auth` & `apps/auth`) - **Database Schema (`packages/auth/src/schema.ts`)**: - The `auth.account` table already exists with columns suited for OAuth credentials: `id`, `user_id`, `account_id`, `provider_id`, `access_token`, `refresh_token`, `id_token`, `access_token_expires_at`, `refresh_token_expires_at`, `scope`, and a unique constraint on `(user_id, provider_id)`. - The `auth.users` table stores `id` (UUID), `email`, `name`, `first_name`, `last_name`, `email_verified`, `image`, and `registered_via`. + - No database migration is required for social credentials. - **Better Auth Instance (`packages/auth/src/index.ts`)**: - Configures `emailAndPassword`, `session` (30 days, 1-day slide), and plugins: `expo()`, `bearer()`, `jwt()` (EdDSA, 15m expiry), and `admin()`. - `trustedOrigins` includes `ansarichat://`, `CORS_ORIGIN`, and dev origins. - - `socialProviders` is **unset**. + - `socialProviders` is **unset**. Better Auth 1.7.7 provides built-in `verifyProviderIdToken` and `signInSocial` supporting native `idToken` payloads at `POST /api/auth/sign-in/social`, but it is currently inactive. - **Environment Schema (`packages/auth/src/env.ts`)**: - Validates `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `CORS_ORIGIN`, `AUTH_LAYER_ORIGIN`, `AUTH_JWT_ISSUER`, `AUTH_JWT_AUDIENCE`, `RESEND_API_KEY`, `FRONTEND_URL`. No OAuth variables exist. @@ -71,7 +79,7 @@ Without native third-party authentication, user onboarding friction remains high ### API Backend (`apps/api`) -- `apps/api` validates incoming `Authorization: Bearer ` against `apps/auth`'s public JWKS endpoint (`/api/auth/jwks`) statelessly, reading `{ sub, sid }`. It has no dependency on how the session was created. +- `apps/api` validates incoming `Authorization: Bearer ` against `apps/auth`'s public JWKS endpoint (`/api/auth/jwks`) statelessly, reading `{ sub, sid }`. It has no dependency on how the session was created and requires no code changes. --- @@ -85,14 +93,15 @@ Without native third-party authentication, user onboarding friction remains high ├───────────────────────────────┬────────────────────────────────────────┤ │ iOS & Android Native │ Web │ │ (expo-apple-authentication │ (Better Auth Web Client) │ -│ @react-native-google- │ │ -│ signin/google-signin) │ │ +│ react-native-nitro- │ │ +│ google-signin) │ │ └───────────────┬───────────────┴───────────────────┬────────────────────┘ │ Native ID Token │ Web OAuth Redirect │ (Direct JSON POST) │ / Callback ▼ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ apps/auth (Better Auth) │ +│ - POST /api/auth/sign-in/social │ │ - Verifies Apple ID Token via Apple JWKS (aud: chat.ansari.app) │ │ - Verifies Google ID Token via Google Certs (aud: GOOGLE_CLIENT_ID) │ │ - Links/creates user in auth.users and auth.account │ @@ -118,36 +127,58 @@ Without native third-party authentication, user onboarding friction remains high - **Apple Sign-In (iOS)**: - Uses `expo-apple-authentication`. - - When pressed, invokes `AppleAuthentication.signInAsync()`. - - Requests scopes: `FULL_NAME` and `EMAIL`. + - When pressed, checks `AppleAuthentication.isAvailableAsync()`. On iOS 13+, invokes `AppleAuthentication.signInAsync({ requestedScopes: [FULL_NAME, EMAIL] })`. - Retrieves `identityToken` (Apple-signed JWT) and `fullName` (`givenName`, `familyName`). - - Sends `{ provider: 'apple', idToken: identityToken, name: ... }` to `apps/auth`. - - Never opens a browser window or webview. + - Calls `POST /api/auth/sign-in/social` with payload: + ```json + { + "provider": "apple", + "idToken": { + "token": "", + "user": { + "name": { + "firstName": "", + "lastName": "" + } + } + } + } + ``` + - Never opens a browser window, Custom Tab, or `ASWebAuthenticationSession`. - **Google Sign-In (Android & iOS)**: - - Uses `@react-native-google-signin/google-signin`. - - Configured with `webClientId: GOOGLE_WEB_CLIENT_ID` (so Google Play Services / iOS SDK mints an ID token intended for the backend server). - - Invokes `GoogleSignin.signIn()` which renders the OS-native Google account picker sheet. - - Retrieves `idToken` and sends `{ provider: 'google', idToken }` to `apps/auth`. + - Uses `react-native-nitro-google-signin` (powered by Nitro Modules / JSI, integrating Android Credential Manager and native iOS Google Sign-In SDK). + - Initialized with `GoogleOneTapSignIn.configure({ webClientId: GOOGLE_WEB_CLIENT_ID })` (or `'autoDetect'`). + - Invokes `GoogleOneTapSignIn.signIn()` which renders the OS-native Google account picker sheet (One Tap / Credential Manager). + - Retrieves `idToken` from response and calls `POST /api/auth/sign-in/social` with payload: + ```json + { + "provider": "google", + "idToken": { + "token": "" + } + } + ``` - Never opens an in-app browser (`expo-web-browser`) or Custom Tabs. - **Cancellation & Failure UX**: - - If a user cancels or dismisses the native dialog (e.g. `ERR_REQUEST_CANCELED` on Apple or `SIGN_IN_CANCELLED` on Google), the sheet remains open without an error banner. - - If network or server verification fails, an inline error is displayed in `AuthSheet`. + - If a user cancels or dismisses the native dialog (e.g. `ERR_REQUEST_CANCELED` on Apple or user cancellation on Google), the sheet remains open without an error banner. + - If network or server verification fails, an inline error banner is displayed in `AuthSheet`. ### 3. Web Social Authentication - In standard desktop and mobile browsers: - - Uses Better Auth's client SDK `signIn.social({ provider, callbackURL: window.location.origin })`. - - Supports standard OAuth redirect flow. - - On callback, completes session creation, mints the initial `apps/api` JWT, and redirects to the application root. + - Uses Better Auth client `authClient.signIn.social({ provider, callbackURL: window.location.origin })`. + - Better Auth redirects to provider OAuth login. + - On return to `callbackURL`, Better Auth sets session cookies and completes session establishment. + - Frontend mints the initial `apps/api` EdDSA JWT via `GET /api/auth/token` and redirects to the application root. ### 4. Backend ID Token Verification & User Resolution - **ID Token Verification**: - - `apps/auth` verifies incoming `idToken`s directly against the provider's official keys: - - Apple: `https://appleid.apple.com/auth/keys`. Validates `iss === 'https://appleid.apple.com'`, non-expired, and `aud` matches `chat.ansari.app` (iOS) or `APPLE_CLIENT_ID` (Web Services ID). - - Google: `https://www.googleapis.com/oauth2/v3/certs`. Validates `iss` in `['accounts.google.com', 'https://accounts.google.com']`, non-expired, and `aud` matches `GOOGLE_CLIENT_ID`. + - `apps/auth` verifies incoming `idToken`s fail-closed against provider official public keys: + - Apple: `https://appleid.apple.com/auth/keys`. Validates `iss === 'https://appleid.apple.com'`, non-expired, and `aud` matches `chat.ansari.app` (iOS native Bundle ID) or `APPLE_CLIENT_ID` (Web Services ID). + - Google: `https://www.googleapis.com/oauth2/v3/certs`. Validates `iss` in `['accounts.google.com', 'https://accounts.google.com']`, non-expired, and `aud` matches `GOOGLE_CLIENT_ID` (Web Client ID) or `GOOGLE_IOS_CLIENT_ID`. - **Account Linking & Collision Policy**: - - Both Google and Apple guarantee email verification. + - Both Google and Apple guarantee email verification (`email_verified: true`). - If an account already exists in `auth.users` with the same email (e.g. created via email/password or guest promotion): - The provider account row is linked in `auth.account` under the same `user_id`. - No duplicate user row is created. @@ -161,10 +192,17 @@ Without native third-party authentication, user onboarding friction remains high ### 5. Two-Token Protocol Parity -- The response from `apps/auth` contains the Better Auth `sessionToken` and user details. +- The response from `apps/auth` contains the Better Auth `sessionToken` and user details: + ```json + { + "redirect": false, + "token": "", + "user": { "id": "", "email": "...", "name": "..." } + } + ``` - Frontend calls `authApi.mintJwt(sessionToken)` to obtain the 15-minute `apps/api` EdDSA JWT. - Tokens are saved to `expo-secure-store` (native) or `localStorage` (web). -- When `apps/api` returns `401`, the existing `refresh()` routine calls `GET /api/auth/token` with `sessionToken` and obtains a fresh JWT without user interruption. +- When `apps/api` returns `401`, the existing single-flight `refresh()` routine calls `GET /api/auth/token` with `sessionToken` and obtains a fresh JWT without user interruption. --- @@ -186,23 +224,29 @@ Without native third-party authentication, user onboarding friction remains high - Works with Apple Private Relay emails (`@privaterelay.appleid.com`). - User names provided on first sign-in are captured in `auth.users`. - [ ] **Native Google Sign-In (Android & iOS)**: - - Tapping "Continue with Google" on Android and iOS launches the native account picker via `@react-native-google-signin/google-signin`. + - Tapping "Continue with Google" on Android and iOS launches the native account picker via `react-native-nitro-google-signin` (Nitro Modules). - No web browser or Custom Tab is opened on mobile devices. - Successfully verifies `idToken` on `apps/auth` and creates/links the user. - [ ] **Web Social Sign-In**: - - Google and Apple sign-in work in desktop and mobile web browsers via Better Auth's standard web flow. + - Google and Apple sign-in work in desktop and mobile web browsers via Better Auth's standard web redirect flow. - [ ] **Two-Token Parity**: - All social sign-ins produce a Better Auth `sessionToken` and immediately mint an `apps/api` EdDSA JWT. - Requests to `apps/api` (`/api/v2/threads`, SSE streaming) succeed with the minted JWT. - 401 token refresh functions identically to email/password sessions. - [ ] **Account Linking**: - Signing in with Google or Apple using an email already registered via email/password links the provider account without errors or orphaned duplicate users. + - A user with both email/password and social login can sign in with either credential to access the exact same account. - [ ] **Cancellation UX**: - User dismissing the native Apple or Google dialog leaves the `AuthSheet` intact without displaying an error alert. -- [ ] **Testing & Verification**: - - Unit/contract tests in `packages/auth` verifying ID token verification, user linking, and JWT minting. - - Unit tests in `apps/frontend` verifying button handlers, cancellation handling, and session establishment. - - All workspace checks pass: `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build`. +- [ ] **Negative / Error Handling Tests**: + - ID token with an invalid cryptographic signature returns 401 `INVALID_TOKEN`. + - ID token with expired timestamp returns 401 `INVALID_TOKEN`. + - ID token with mismatched audience returns 401 `INVALID_TOKEN`. + - Provider returning no verified email fails closed with 401 `USER_EMAIL_NOT_FOUND`. +- [ ] **Automated Testing & Workspace Verification**: + - Real DB (pglite) tests in `packages/auth` asserting ID token verification, account linking, and JWT minting. + - Frontend component tests in `apps/frontend` verifying button handlers and cancellation safety. + - Monorepo checks pass: `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build`. --- @@ -211,12 +255,29 @@ Without native third-party authentication, user onboarding friction remains high 1. **Native-Only on Mobile**: Mobile platforms must never fall back to browser redirects for Google or Apple. 2. **App Store Guideline 4.8**: Apple Sign-In button must use Apple's standard design rules and must be positioned prominently. 3. **EAS Prebuild / Native Config**: - - `expo-apple-authentication` requires the `expo-apple-authentication` config plugin and Apple Developer entitlement (`com.apple.developer.applesignin`). - - `@react-native-google-signin/google-signin` requires Google Services configuration (`google-services.json` on Android, `GoogleService-Info.plist` or `iosClientId` on iOS). + - `expo-apple-authentication` requires the config plugin and Apple Developer entitlement (`com.apple.developer.applesignin`). + - `react-native-nitro-google-signin` requires `react-native-nitro-modules`, its Expo config plugin (`"react-native-nitro-google-signin"` in `app.json`), and Google Services configuration (`google-services.json` on Android, `GoogleService-Info.plist` on iOS). 4. **Secret Management**: - No client secrets may be bundled in the native mobile apps. Mobile SDKs acquire ID tokens signed by Apple/Google, which `apps/auth` verifies using public key sets. - Provider credentials (`GOOGLE_CLIENT_SECRET`, Apple private keys) live strictly on the backend (`apps/auth`). -5. **No Anonymous API Bypass**: `apps/api` continues to require a valid Bearer JWT on all protected routes; social auth changes only how sessions are established on `apps/auth`. +5. **Database Conventions**: + - Zero schema migrations needed (`auth.account` already contains all necessary columns: `provider_id`, `id_token`, etc.). Any future schema modifications must strictly use `drizzle-kit generate` (never `db:push`). +6. **No Anonymous API Bypass**: `apps/api` continues to require a valid Bearer JWT on all protected routes; social auth changes only how sessions are established on `apps/auth`. + +--- + +## Assumptions + +1. **Token Cryptography**: Apple and Google issue standard OpenID Connect compliant JWTs whose signatures are verifiable against their published JWKS endpoints (`https://appleid.apple.com/auth/keys` and `https://www.googleapis.com/oauth2/v3/certs`). +2. **Audience Configuration**: + - For Apple on iOS: `aud` equals `chat.ansari.app` (the iOS Bundle ID). + - For Apple on Web: `aud` equals `APPLE_CLIENT_ID` (the Apple Services ID). + - For Google: `aud` equals `GOOGLE_CLIENT_ID` (the Web Client ID configured in Google Cloud Console and passed to `react-native-nitro-google-signin`). +3. **Email Verification Guarantee**: Google and Apple ID tokens only carry emails that have been verified by the respective identity provider (`email_verified: true`), allowing automatic account linking by email. +4. **Availability on Devices**: + - Apple Authentication is available on iOS devices running iOS 13+. + - Google Sign-In is supported on Android devices with Google Play Services (via Credential Manager) and iOS devices running modern iOS. +5. **Stateless API Gateway**: `apps/api` requires zero code modifications or deployments for this feature, as it verifies EdDSA JWTs minted by `apps/auth` statelessly. --- @@ -224,5 +285,5 @@ Without native third-party authentication, user onboarding friction remains high - Additional third-party OAuth providers (GitHub, Microsoft, Facebook, etc.). - Changes to email/password authentication or guest auto-provisioning rules. -- Native Google Sign-In on Web (Web uses standard Google OAuth / One Tap if configured, but not native mobile bridge). +- Native Google Sign-In on Web (Web uses standard Google OAuth redirect flow). - Account unlinking UI in settings (deferred to future account management features). From d4ab7eccc16b3461bf1cc5ffabd5845a281e34f1 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 08:52:31 +0300 Subject: [PATCH 041/247] [Spec 321][Phase: environments_and_docs] docs: record real-Meta run, open items and PR notes in the plan --- .../321-rfc-whatsapp-support-in-the-mo.md | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index c40ac669..d86d0b53 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -155,15 +155,44 @@ Done 2026-10-26, local, no Docker: real `apps/auth` (new empty `ansari_auth` dat - A correctly signed webhook (BSUID only) was accepted, the real `apps/api` accepted the JWT, a `whatsapp` thread was created, the real `/chat` stream returned an answer (about 9 seconds) and it was handed to the (mock) Meta sender. - A second message from the same BSUID reused the same thread: one thread, four saved messages (user, assistant, user, assistant). -- Still real-only gaps: the BSUID-to-JWT exchange (`apps/auth` has no `/whatsapp` route yet; mock by owner decision, to confirm with Amr) and real Meta (receiving a webhook from Meta, sending to a BSUID). The Meta run is a blocker for the staging smoke test and remains open. +- Still real-only gaps: the BSUID-to-JWT exchange (`apps/auth` has no `/whatsapp` route yet; mock by owner decision, to confirm with Amr). + +#### Real-Meta run (2026-10-26) + +No Meta test number could be claimed (see the PR notes below), so the run used Meta's own pieces where it could and the production number for one send. + +- **Webhooks from Meta, through zrok:** the Test app's callback URL (`https://zrok0ansari0test.share.zrok.io/whatsapp/v2`) passed Meta's verification handshake; Meta's dashboard sample webhooks (v26.0) reached the service with a valid signature. They were ignored because Meta's samples carry a fake phone number ID; equivalent signed payloads (username with phone hidden, and with phone present) were then sent through the public URL with the sample ID set locally, and each was identified by BSUID, got a thread in the real `apps/api` and an answer. Meta's "not opted in" sample was not replayed. +- **Send to a BSUID:** one text was sent from the production number to the developer's own BSUID (`recipient`, no `to`) with a direct HTTP call using the same endpoint, token and body shape as `MetaApiServiceReal`. Meta answered 200 with the BSUID echoed as `user_id`, and delivery and read status webhooks followed. The developer confirming it appeared on the phone is still to be recorded. The service class itself was not used for this send. +- **How the BSUID was obtained:** a temporary log line for `from_user_id` was added to the legacy service for about an hour and is now commented out; the value lives only in the git-ignored `apps/whatsapp/.env` as `REAL_TEST_BSUID`. +- **Fixes found on the way:** the verification handler logged the verify token in local mode (removed, with a test); the app ID in the legacy `.env` belonged to a different Meta app than the app secret (a local config error, not a code change). +- **Not covered:** a real phone's message arriving at the new service (the Test app has no number), and the Docker image. #### Open at the end of Phase 4 (not verified) - The Docker image has never been built or run (no Docker daemon was available). -- `from_user_id` presence in every webhook, and a real send to a BSUID, are untested against live Meta. +- A real phone's webhook reaching the new service is untested (sample webhooks and one production-number send were verified, see above). Meta's own samples show `from_user_id` present, but that is a sample, not a live payload. - The `apps/auth` `/whatsapp` contract (route, header, `AUTH_WHATSAPP_API_KEY`) is assumed and was not exercised (a real `apps/api` and the real `apps/auth` were run, see above). - The `whatsapp` CI job has not run on a GitHub runner. Making it a required check on `develop` needs a repo admin; mention how in the PR description rather than doing it here. +#### Notes for the PR description (do not lose these) + +1. **Call to action (Waleed):** a new WhatsApp phone number is needed for the staging and test environments to share. Reason: the developer cannot claim a Meta test number ("An error occurred while creating the test account. Please try again."), the account has one live production number, and Meta's free tier allows two business numbers, with a test number counting as one. The failure is widely reported and Meta has not said it is working on a fix, as far as the threads below show. Forum threads (Meta for Developers community unless noted; each was seen listed in search results, and only the titles and snippets were read, not the full threads): + - https://developers.facebook.com/community/threads/1725780401801558/ (test number provisioning fails although the test WABA exists) + - https://developers.facebook.com/community/threads/899305532556240/ (error while creating a WhatsApp Business test account) + - https://developers.facebook.com/community/threads/1789721235531963/ (a verified business cannot claim a test number) + - https://developers.facebook.com/community/threads/2182066965706958/ ("No phone numbers available" when claiming) + - https://developers.facebook.com/community/threads/1379359970388767/ ("get new test number" fails) + - https://developers.facebook.com/community/threads/1342448903816361/ (asked 8 Dec 2025, the same error) + - https://developers.facebook.com/community/threads/568920538895906/ (asked 23 Sep 2024, cannot create a test number) + - https://developers.facebook.com/community/threads/1246890150314692/ (cannot claim a test number, for several days) + - https://developers.facebook.com/community/threads/849020947139819/ (no test number after adding the WhatsApp product) + - https://developers.facebook.com/community/threads/2128147975242023 and https://developers.facebook.com/community/threads/1694270385568451 and https://developers.facebook.com/community/threads/2981069012252252 (same error) + - https://stackoverflow.com/questions/79475543/whatsapp-cloud-api-test-number-not-active-after-generation (a related Stack Overflow question) + - https://www.reddit.com/r/WhatsappBusinessAPI/comments/1ni3dxr/whatsapp_api_current_issue_invalid_test_phone/ (Reddit, "apparently some others in the meta developer community") + The dated threads (Sep 2024, Dec 2025) and the many recent ones show the problem is not new. Before the PR is opened, re-open a few of these and confirm they still say what is written here, and whether anyone from Meta has answered. +2. **Alternative if no new number is created (developer's idea, not built):** all three environments (production, staging, local) share the one production number and each answers only its own messages: no prefix means production, a `!s ` prefix means staging, a `!d ` prefix means local. Each environment ignores messages meant for the others, so the production service must be changed first to ignore the prefixed ones. Downsides to state: every environment's app receives all messages for the number, including real users' (a privacy cost for a laptop), the replies come from the production number, and a missing rule in one environment means a double or wrong answer. Today only the `!d ` rule exists, in staging. +3. **BSUID logging:** the legacy service has a commented-out, opt-in log line for reading a developer's own BSUID; it must stay commented out in production. + #### Acceptance Criteria - [ ] With the flag on, users get the notice and no chat call is made. From 95e3033d7170ed702d0568879d2dc11e50525129 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 08:52:35 +0300 Subject: [PATCH 042/247] chore(porch): 321 acknowledge context refresh --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 7ab5bb13..7ea6b05a 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-25T16:14:17.697Z' +updated_at: '2026-10-27T05:52:35.517Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -55,3 +55,4 @@ context_refreshes: acknowledged_at: '2026-10-25T14:59:15.993Z' - boundary: plan-phase:environments_and_docs at: '2026-10-25T16:14:17.696Z' + acknowledged_at: '2026-10-27T05:52:35.516Z' From 8f3d9971b63daf3ae0a65b20e28d0ed18adf10be Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:01:20 +0300 Subject: [PATCH 043/247] chore(porch): 321 implement build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 7ea6b05a..c2067341 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T05:52:35.517Z' +updated_at: '2026-10-27T06:01:20.122Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From 282081135536ea5c18c05d3c897dc0aa5c555ce4 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:09:30 +0300 Subject: [PATCH 044/247] [Spec 321][Phase: environments_and_docs] fix: address phase review (docs accuracy, Sentry tests, python pin, dead branch) --- apps/whatsapp/.env.example | 9 +-- apps/whatsapp/.python-version | 1 + apps/whatsapp/README.md | 5 +- .../services/whatsapp_conversation_manager.py | 10 +-- .../src/ansari_whatsapp/utils/app_logger.py | 9 ++- apps/whatsapp/tests/README.md | 2 +- apps/whatsapp/tests/test_railway_logging.py | 19 +++-- apps/whatsapp/tests/test_thread_lifecycle.py | 72 +++++++++++++++++++ .../321-rfc-whatsapp-support-in-the-mo.md | 8 ++- ...321-environments_and_docs-iter1-claude.txt | 47 ++++++++++++ 10 files changed, 154 insertions(+), 28 deletions(-) create mode 100644 apps/whatsapp/.python-version create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-environments_and_docs-iter1-claude.txt diff --git a/apps/whatsapp/.env.example b/apps/whatsapp/.env.example index 2aabe81f..57c844b6 100644 --- a/apps/whatsapp/.env.example +++ b/apps/whatsapp/.env.example @@ -29,7 +29,7 @@ AUTH_WHATSAPP_API_KEY="your_generated_secret_key_here" ###################################### Related to CORS ###################################### # CORS settings - URLs that are allowed to access ansari-whatsapp -ORIGINS="https://api.ansari.chat,https://ansari-api.au.ngrok.io,https://web.whatsapp.com" +ORIGINS="https://api.ansari.chat,https://web.whatsapp.com" # Explained in WhatsApp comments below META_WEBHOOK_ZROK_SHARE_TOKEN="<>" @@ -94,13 +94,6 @@ META_API_VERSION="v22.0" # (3) you sent a message with an incorrect format. Check source 3; 26:10->26:28, click on `messages` to see how it should be sent META_BUSINESS_PHONE_NUMBER_ID="your_phone_number_id_here" -# WhatsApp Business Account ID (WABA ID) -# NOTE: This is for check_subscriptions.py only, so it's not used in the rest of the codebase -# and so it should only be used in .env and not pushed to GitHub Actions or AWS -# To get the WABA ID of your respective app on developers.facebook.com, -# check docs\assets\where-to-get-WABA-IDs.png -META_WABA_ID="your_waba_id_here" - # System User Access Token (from Meta Dashboard) # NOTE 1: check video in source 3 above from 30:45 to 32:15 to see where we get the access token # NOTE 2: Contact the team to see their 3 access tokens -> one for prod. app (Ansari), one for staging app (Ansari - Staging), and one for local/dev. app (Ansari - Test) diff --git a/apps/whatsapp/.python-version b/apps/whatsapp/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/apps/whatsapp/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/apps/whatsapp/README.md b/apps/whatsapp/README.md index 42efa5d2..3e18596d 100644 --- a/apps/whatsapp/README.md +++ b/apps/whatsapp/README.md @@ -127,7 +127,8 @@ The Docker image has not been built or run by the author of this change. Do that ### Known gaps to confirm before production -- That `from_user_id` is present in every webhook, and that replying to a BSUID is accepted by Meta. Neither has been tried against live Meta. -- `apps/auth`'s `/whatsapp` endpoint contract is assumed (route, header and `AUTH_WHATSAPP_API_KEY`); nothing here has run against a live `apps/auth` or `apps/api`. +- A real phone's webhook has never reached this service. Meta's dashboard sample webhooks did (signature verified, `from_user_id` present), and one production-number send to a real BSUID was accepted by Meta (HTTP 200, delivery and read statuses followed). That send used a direct HTTP call with the same endpoint and body shape, not `MetaApiServiceReal` itself. +- `apps/auth`'s `/whatsapp` endpoint contract is assumed (route, header and `AUTH_WHATSAPP_API_KEY`) and has not been exercised. The service has run against a real `apps/api` (with a real JWT from `apps/auth` returned by the mock sign-in): a thread was created and the chat stream answered. +- The Docker image has not been built or run. - `apps/api`'s chat routes save every message with `source: "web"`, so WhatsApp messages are not marked as such in the history. - Two near-simultaneous first messages from one user can create two threads. Extra `whatsapp` threads are harmless and can be swept manually (keep the newest per user). diff --git a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py index 7037299f..cb7e2584 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/whatsapp_conversation_manager.py @@ -5,7 +5,6 @@ import time from datetime import datetime -import httpx import sentry_sdk from loguru import logger @@ -250,6 +249,7 @@ async def _resolve_thread(self, first_message: str) -> str | None: new_thread_id = result.get("thread_id") if not new_thread_id: logger.error("Thread creation returned no thread id") + self._capture(ThreadCreationError("Thread creation returned no thread id"), "thread_creation_failure") await self.send_whatsapp_message( "An unexpected error occurred while creating a new chat session. Please try again later.." ) @@ -281,14 +281,6 @@ async def handle_text_message(self) -> None: thread_id=thread_id, message=incoming_txt_msg, ) - except httpx.TimeoutException as e: - logger.error(f"Backend timeout while processing message: {e}") - self._capture(e, "backend_timeout") - await self.send_whatsapp_message("The request timed out. Please try sending your message again.") - # Cancel typing indicator if running - if self.typing_indicator_task and not self.typing_indicator_task.done(): - self.typing_indicator_task.cancel() - return except MessageProcessingError as e: logger.error(f"Failed to process message: {e}") self._capture(e, "message_processing_failure") diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py b/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py index ff9ece1e..93423fa9 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/app_logger.py @@ -44,8 +44,13 @@ # Get settings settings = get_settings() -# JSON lines everywhere except local development and GitHub Actions -use_json_logs = settings.DEPLOYMENT_TYPE not in ["local", "development"] and os.getenv("GITHUB_ACTIONS") != "true" + +def should_use_json_logs(deployment_type: str, github_actions: str | None) -> bool: + """JSON lines everywhere except local development and GitHub Actions.""" + return deployment_type not in ["local", "development"] and github_actions != "true" + + +use_json_logs = should_use_json_logs(settings.DEPLOYMENT_TYPE, os.getenv("GITHUB_ACTIONS")) # Railway understands debug/info/warn/error, so Loguru's other levels are mapped onto those RAILWAY_LEVELS = {"TRACE": "debug", "DEBUG": "debug", "INFO": "info", "SUCCESS": "info", "WARNING": "warn"} diff --git a/apps/whatsapp/tests/README.md b/apps/whatsapp/tests/README.md index f1a5333c..37038fe7 100644 --- a/apps/whatsapp/tests/README.md +++ b/apps/whatsapp/tests/README.md @@ -20,5 +20,5 @@ uv run --with ruff ruff check . Notes: - `test_utils.py` holds helpers for the service tests, not tests of its own. -- Everything runs against mocks and in-process fakes of HTTP calls. Nothing has been run against a live `apps/auth`, `apps/api` or Meta. +- Everything in this suite runs against mocks and in-process fakes of HTTP calls, never a live `apps/auth`, `apps/api` or Meta. Live runs were done by hand; see the plan and the README. - To see the service logs while testing, add `-s`. diff --git a/apps/whatsapp/tests/test_railway_logging.py b/apps/whatsapp/tests/test_railway_logging.py index d41ce286..b14d3716 100644 --- a/apps/whatsapp/tests/test_railway_logging.py +++ b/apps/whatsapp/tests/test_railway_logging.py @@ -5,8 +5,7 @@ import pytest -from ansari_whatsapp.utils import app_logger -from ansari_whatsapp.utils.app_logger import build_log_entry, railway_json_sink, request_id_var +from ansari_whatsapp.utils.app_logger import build_log_entry, railway_json_sink, request_id_var, should_use_json_logs def _record(level: str = "INFO", message: str = "hello", exception=None) -> dict: @@ -71,6 +70,16 @@ def test_request_id_variable_defaults_to_none_outside_a_request(): assert request_id_var.get() == "none" -def test_json_logs_are_off_in_local_development_and_in_ci(): - # The test settings use DEPLOYMENT_TYPE=local, so readable logs are expected here - assert app_logger.use_json_logs is False +@pytest.mark.parametrize( + ("deployment_type", "github_actions", "expected"), + [ + ("local", None, False), + ("development", None, False), + ("staging", None, True), + ("production", None, True), + ("staging", "true", False), # CI never emits JSON, whatever the deployment type + ("production", "false", True), + ], +) +def test_json_logs_are_used_only_when_deployed_and_not_in_ci(deployment_type, github_actions, expected): + assert should_use_json_logs(deployment_type, github_actions) is expected diff --git a/apps/whatsapp/tests/test_thread_lifecycle.py b/apps/whatsapp/tests/test_thread_lifecycle.py index 71f935a0..edff07bb 100644 --- a/apps/whatsapp/tests/test_thread_lifecycle.py +++ b/apps/whatsapp/tests/test_thread_lifecycle.py @@ -181,6 +181,78 @@ async def spy_send(self, msg_body, recipient_bsuid=None): assert sent +@pytest.fixture +def sentry_calls(monkeypatch): + """Record (error_type tag, exception) for every Sentry capture instead of sending it.""" + tags, captured = {}, [] + monkeypatch.setattr("sentry_sdk.set_tag", lambda key, value: tags.__setitem__(key, value)) + monkeypatch.setattr("sentry_sdk.set_context", lambda *a, **kw: None) + monkeypatch.setattr("sentry_sdk.capture_exception", lambda error: captured.append((tags.get("error_type"), error))) + return captured + + +def _silence_user_messages(monkeypatch): + async def ignore(self, msg_body, recipient_bsuid=None): + return None + + monkeypatch.setattr(WhatsAppConversationManager, "send_whatsapp_message", ignore) + + +def test_thread_list_failure_is_reported_to_sentry(monkeypatch, sentry_calls): + from ansari_whatsapp.utils.exceptions import ThreadInfoError + + async def failing_list(self, token): + raise ThreadInfoError("apps/api is down") + + monkeypatch.setattr(AnsariClientMock, "get_last_thread_info", failing_list) + _silence_user_messages(monkeypatch) + + assert asyncio.run(_manager()._resolve_thread("hello")) is None + assert [kind for kind, _ in sentry_calls] == ["thread_info_failure"] + + +def test_thread_delete_failure_is_reported_to_sentry(monkeypatch, sentry_calls): + manager = _manager() + old = asyncio.run(manager._resolve_thread("hello")) + _age_thread(old, EXPIRY + 60) + + async def failing_delete(self, token, thread_id): + raise ThreadDeletionError("apps/api is down") + + monkeypatch.setattr(AnsariClientMock, "delete_thread", failing_delete) + _silence_user_messages(monkeypatch) + + assert asyncio.run(manager._resolve_thread("hello again")) is None + assert [kind for kind, _ in sentry_calls] == ["thread_deletion_failure"] + + +def test_thread_create_failure_is_reported_to_sentry(monkeypatch, sentry_calls): + async def no_id(self, token, title): + return {} + + monkeypatch.setattr(AnsariClientMock, "create_thread", no_id) + _silence_user_messages(monkeypatch) + + assert asyncio.run(_manager()._resolve_thread("hello")) is None + assert [kind for kind, _ in sentry_calls] == ["thread_creation_failure"] + + +def test_message_processing_failure_is_reported_to_sentry(monkeypatch, sentry_calls): + from ansari_whatsapp.utils.exceptions import MessageProcessingError + + async def failing_chat(self, token, thread_id, message): + raise MessageProcessingError("stream cut off") + + monkeypatch.setattr(AnsariClientMock, "process_message", failing_chat) + _silence_user_messages(monkeypatch) + manager = _manager() + manager.typing_indicator_task = None + + asyncio.run(manager.handle_text_message()) + + assert [kind for kind, _ in sentry_calls] == ["message_processing_failure"] + + def test_another_users_thread_is_never_touched(): mine = _manager() theirs = _manager(OTHER_BSUID) diff --git a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md index d86d0b53..d06c2636 100644 --- a/codev/plans/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/plans/321-rfc-whatsapp-support-in-the-mo.md @@ -144,7 +144,7 @@ Finish environment behaviour and make the service testable and deployable by oth - The client now uses the `/chat` stream, which reports failures as an explicit `error` event, so the inline `Error:` detection in item (a) is no longer needed; a failed, cut-off or malformed stream is a failure the user is told about. - Logs carry only `request_id` (no user id, so `get_subject` was removed) and are one JSON line per entry on stdout, the shape Railway indexes. -- Thread-list, create, delete, timeout and sign-in failures are captured in Sentry. +- Thread-list, create, delete, message-processing and sign-in failures are captured in Sentry. A backend timeout is captured as a message-processing failure (the real client wraps timeouts into `MessageProcessingError`); there is no separate timeout case. - `/sentry-debug` is kept but registered only outside production; `clean_logs.sh` is removed (logs go to stdout). - The app reads `PORT` (default 8001). - Dockerfile: uv 0.9.13, `uv sync --frozen`, and paths relative to the repo root (the build context RELEASE.md prescribes). `apps/whatsapp/railway.toml` records the Railway settings; Railway does not read it. @@ -174,6 +174,12 @@ No Meta test number could be claimed (see the PR notes below), so the run used M - The `apps/auth` `/whatsapp` contract (route, header, `AUTH_WHATSAPP_API_KEY`) is assumed and was not exercised (a real `apps/api` and the real `apps/auth` were run, see above). - The `whatsapp` CI job has not run on a GitHub runner. Making it a required check on `develop` needs a repo admin; mention how in the PR description rather than doing it here. +#### Phase 4 review (Claude, iteration 1): COMMENT + +All findings were checked against the code. Fixed: the README and `tests/README.md` no longer claim nothing ran against live Meta or `apps/api` (they now say what was and was not done); the unreachable `httpx.TimeoutException` branch and its import were removed; a missing new thread id is now captured in Sentry like the other creation failures; Sentry captures for thread-list, delete, create and message-processing failures each have a test (each fails if its capture call is removed); the JSON-log decision became `should_use_json_logs()` with parametrized cases (the old test passed without its CI clause); `apps/whatsapp/.python-version` pins 3.13, the version the Docker image uses, and the suite passes on it; the dead `META_WABA_ID` block and the legacy `ngrok` host were removed from `.env.example`. + +Decided, no change: the maintenance check runs before the `!d ` filter, so a staging instance in maintenance mode answers a developer's `!d ` message with the maintenance notice. This matches the legacy order and is harmless, so it stays. + #### Notes for the PR description (do not lose these) 1. **Call to action (Waleed):** a new WhatsApp phone number is needed for the staging and test environments to share. Reason: the developer cannot claim a Meta test number ("An error occurred while creating the test account. Please try again."), the account has one live production number, and Meta's free tier allows two business numbers, with a test number counting as one. The failure is widely reported and Meta has not said it is working on a fix, as far as the threads below show. Forum threads (Meta for Developers community unless noted; each was seen listed in search results, and only the titles and snippets were read, not the full threads): diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-environments_and_docs-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-environments_and_docs-iter1-claude.txt new file mode 100644 index 00000000..93c8110c --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-environments_and_docs-iter1-claude.txt @@ -0,0 +1,47 @@ +I'll review the Phase 4 (`environments_and_docs`) implementation. Let me start by reading the key files.I've read the Phase 4 files, run the suite, and checked lint/format. Here's my assessment. + +## What's verified working + +- `uv run pytest tests -q` → **61 passed**; `ruff check .` → clean; `ruff format --check .` → 35 files formatted. (Run from `apps/whatsapp`.) +- **`WA_MAINTENANCE_MODE`** (`config.py:89`, `main.py:337`) is this service's own setting, checked before auth and before any `apps/api` call. `test_environment_behaviour.py:79-108` covers notice-sent / no-chat-call, off-path, and that an exported `MAINTENANCE_MODE` (apps/api's flag) does not leak into it. Good, non-vacuous tests: the chat spy would catch a regression where the check moved after the chat call. +- **`!d ` filter** (`main.py:354`) gated on `DEPLOYMENT_TYPE == "staging"`, with all three cases tested (staging ignores, staging without prefix processes, local with prefix processes). +- **Railway JSON logging**: single-line JSON on stdout, level mapping, no `user_id` field, `request_id` present. `test_railway_logging.py:61-67` properly asserts stderr is empty and that an embedded newline stays inside the JSON — that's the right test for the failure mode. +- **Sentry coverage** for the Phase 3 finding is in: `_capture()` is called for `thread_info_failure`, `thread_deletion_failure`, `thread_creation_failure`, `auth_token_failure`, `message_processing_failure` (`whatsapp_conversation_manager.py:110-115` and callers), and the context carries only `request_id`. +- **Deploy items**: Dockerfile is uv `0.9.13` with `uv sync --frozen` on both syncs and repo-root-relative paths; `/sentry-debug` registered only outside production (`main.py:116`); `clean_logs.sh` gone; `PORT` honoured (`main.py:430`); `railway.toml` present and honest about not being read by Railway; CI job `whatsapp` added. + +## Findings + +**1. The README understates what was verified against live Meta, contradicting the plan's own record.** `README.md:130` says *"That `from_user_id` is present in every webhook, and that replying to a BSUID is accepted by Meta. Neither has been tried against live Meta."* The plan's Phase 4 section records the opposite for half of it: a real send to a BSUID returned 200 with the BSUID echoed as `user_id`, plus delivery/read webhooks, and Meta's own sample webhooks reached the service through zrok carrying `from_user_id`. `README.md:131` likewise says *"nothing here has run against a live `apps/auth` or `apps/api`"* while the plan records a real `apps/api` run that created a thread and answered. This is the docs phase; the next person reading the README will either redo work or distrust the record. Rewrite those two bullets to say precisely what was and wasn't done (BSUID send: verified once via direct HTTP, not through `MetaApiServiceReal`; a real phone's webhook: still untested; `apps/auth` `/whatsapp` route: still assumed). + +**2. `tests/README.md:23`** has the same overreach: *"Nothing has been run against a live `apps/auth`, `apps/api` or Meta."* True of the test suite, false of the project. Scope the sentence to the tests. + +**3. The new Sentry captures have no test.** No test file references `capture_exception`, `_capture`, or Sentry at all. The Phase 4 plan lists adding Sentry to the thread-list/create/delete failures as this phase's work, so the one piece of new behaviour added here is the one piece with no coverage. A `monkeypatch.setattr(sentry_sdk, "capture_exception", ...)` on the existing `test_thread_lifecycle.py` failure case would cost three lines and would catch someone deleting the `_capture` call. Reading the code, I'm confident the calls are correct — my point is only that nothing would notice if they vanished. + +**4. The `httpx.TimeoutException` branch in `handle_text_message` is unreachable.** `AnsariClientReal.process_message` catches `httpx.TimeoutException` at line 240 and re-raises it as `MessageProcessingError`; `AnsariClientMock` never raises it either (its only raises are `ThreadDeletionError`/`MessageProcessingError`). So `whatsapp_conversation_manager.py:284-291` — the `backend_timeout` Sentry tag and the "The request timed out" user message — can't fire. Practical effect is cosmetic (a timeout tells the user "An error occurred" and is tagged `message_processing_failure`), but the plan's Phase 4 outcome note claims "timeout … failures are captured in Sentry" as a distinct case, and it isn't. Either let the real client re-raise timeouts unwrapped, or drop the branch and the claim. + +**5. `test_json_logs_are_off_in_local_development_and_in_ci` (`test_railway_logging.py:74-77`) is vacuous for half its name.** `use_json_logs` is `DEPLOYMENT_TYPE not in [local, development] and GITHUB_ACTIONS != "true"`, and conftest pins `DEPLOYMENT_TYPE=local`. The assertion passes identically whether or not the `GITHUB_ACTIONS` clause exists — delete that clause and the test still goes green. If the CI guard matters, test it by building the expression with a staging deployment type and `GITHUB_ACTIONS=true`. + +**6. Python version drift between CI, dev and the image.** There is no `.python-version` in `apps/whatsapp` and `requires-python = ">=3.10"`, so CI's `uv sync` installs the newest compatible CPython while the Dockerfile runs `python:3.13` and this worktree's venv is 3.10.10 (I confirmed all three). CI can go green on a version that never ships. A `.python-version` pinned to 3.13 (and `setup-uv` reading it) closes that. + +**7. `.env.example` still documents a file that wasn't imported.** Lines 97-102 define `META_WABA_ID` "for `check_subscriptions.py` only" — that script doesn't exist in `apps/whatsapp`, and `META_WABA_ID` isn't a field in `config.py` (so `extra="ignore"` silently drops it). Delete it or say it's vestigial. Minor companion: `ORIGINS` on line 32 still carries an `ngrok` host from the legacy setup. + +**8. Ordering nit, no fix required:** the maintenance check (`main.py:337`) precedes the `!d ` filter (`main.py:354`), so a staging instance in maintenance mode replies to a local developer's `!d ` message with the maintenance notice — the exact double-answer the prefix exists to prevent. Harmless and legacy-faithful; mentioning so it's a choice rather than an accident. + +I verified the things I'd normally flag from training data rather than asserting them: `POST /api/v2/users/login` referenced in `.env.example:180` **does** exist in `apps/api` (legacy pass-through to `apps/auth`, see `apps/api/tests/contract/harness.ts:67`), so that line is correct and I'm not flagging it. + +Nothing here blocks the phase — the deliverables are present, tested and green. Items 1 and 2 should land before the PR description is written, since the PR will cite the same facts. + +--- +VERDICT: COMMENT +SUMMARY: Phase 4 deliverables are complete and green (61 tests, clean lint/format); the issues are documentation accuracy in the very phase that owns docs, plus a few test-depth and version-drift nits. +CONFIDENCE: HIGH +--- + +KEY_ISSUES: +- `README.md:130-131` claims the live-Meta BSUID send and the real `apps/api` run were never done, contradicting the plan's own Phase 4 record — fix before the PR description reuses it +- `tests/README.md:23` overstates the same ("nothing has been run against a live … Meta"); scope it to the test suite +- The Sentry captures added in this phase (`_capture` for thread info/create/delete, auth, timeout) have zero test coverage — nothing would notice their removal +- `handle_text_message`'s `httpx.TimeoutException` branch is unreachable: `AnsariClientReal` wraps timeouts into `MessageProcessingError` and the mock never raises one, so the `backend_timeout` tag and timeout message never fire +- `test_json_logs_are_off_in_local_development_and_in_ci` passes with the `GITHUB_ACTIONS` clause deleted — vacuous for the CI half of its name +- No `.python-version`: CI resolves the newest CPython, the Dockerfile runs 3.13, this worktree runs 3.10.10 +- `.env.example:97-102` documents `META_WABA_ID` for `check_subscriptions.py`, a file that was not imported and a setting `config.py` does not define \ No newline at end of file From 725503555817b98710677d5ecafdbf15867f1baa Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:09:40 +0300 Subject: [PATCH 045/247] =?UTF-8?q?chore(porch):=20321=20all=20plan=20phas?= =?UTF-8?q?es=20complete=20=E2=86=92=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../321-rfc-whatsapp-support-in-the-mo/status.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index c2067341..f41ef1d1 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -1,7 +1,7 @@ id: '321' title: rfc-whatsapp-support-in-the-mo protocol: spir -phase: implement +phase: review plan_phases: - id: import_service title: Import the Python service as is @@ -14,8 +14,8 @@ plan_phases: status: complete - id: environments_and_docs title: Maintenance mode, staging filter, local testing and docs - status: in_progress -current_plan_phase: environments_and_docs + status: complete +current_plan_phase: null gates: spec-approval: status: approved @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: true +build_complete: false history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T06:01:20.122Z' +updated_at: '2026-10-27T06:09:40.030Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -56,3 +56,5 @@ context_refreshes: - boundary: plan-phase:environments_and_docs at: '2026-10-25T16:14:17.696Z' acknowledged_at: '2026-10-27T05:52:35.516Z' + - boundary: enter:review + at: '2026-10-27T06:09:40.030Z' From b0ab0659c243bebc887214ac5e15b2fa7afc2ae4 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:22:14 +0300 Subject: [PATCH 046/247] chore(porch): 321 acknowledge context refresh --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index f41ef1d1..5d1aa499 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T06:09:40.030Z' +updated_at: '2026-10-27T06:22:14.281Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -58,3 +58,4 @@ context_refreshes: acknowledged_at: '2026-10-27T05:52:35.516Z' - boundary: enter:review at: '2026-10-27T06:09:40.030Z' + acknowledged_at: '2026-10-27T06:22:14.280Z' From 474db46ceb87dbb29a554ebc2f076b96095c9098 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:25:22 +0300 Subject: [PATCH 047/247] [Spec 321] docs: review document, architecture and lessons updates --- codev/resources/arch.md | 2 + codev/resources/lessons-learned.md | 12 ++ .../321-rfc-whatsapp-support-in-the-mo.md | 135 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 codev/reviews/321-rfc-whatsapp-support-in-the-mo.md diff --git a/codev/resources/arch.md b/codev/resources/arch.md index 16c39721..162086f9 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -62,6 +62,8 @@ The repo is a pnpm workspace of `apps/*` + `packages/*`, driven by Turborepo. **The frontend app (`apps/frontend`, since #262).** The former SDK 54 prototype (outside the workspace until #262) is now the workspace member `ansari-frontend`, on Expo SDK 57 / RN 0.86 / React 19.2, with a vitest suite that CI runs in the `frontend` job (job name frozen). Its store identity is the **legacy app's** (`legacy/frontend-app/app.json`): name "Ansari", slug `ansari-chat`, `chat.ansari.app`, EAS project `e2f465a7-…`, **scheme `ansarichat`**. That scheme is coupled to `packages/auth`'s Better Auth `trustedOrigins` (`'ansarichat://'`); change both together, or native sign-in fails origin validation later with nothing failing at build time. `auth.http.test.ts` pins the pair: it reads `apps/frontend/app.json` and asserts `${expo.scheme}://` is trusted, and it rejects the old `askansari://`. `packages/auth/turbo.json` declares that manifest as a `test` input (`$TURBO_ROOT$/apps/frontend/app.json`), because a file outside the package is not hashed by default and a warm cache would replay the guard instead of running it. The app's whole env surface is `EXPO_PUBLIC_API_URL` (default api-staging), the only ARG in `Dockerfile.web`. Uniwind is installed and wired (metro `withUniwindConfig`, `src/global.css`, `gen:types`) but **unused**: the UI is StyleSheet-only, and `app/_layout.tsx` deliberately does not import `global.css` because Tailwind's preflight reset would restyle the web. `vendor/api-client-react/` keeps only `custom-fetch.ts` (the fetch runtime) and `generated/api.schemas.ts` (types); retiring both is #264. +**The WhatsApp service (`apps/whatsapp`, spec 321).** A Python (uv, FastAPI) service outside the pnpm/turbo graph, with its own CI job (`whatsapp`) and its own Dockerfile built from the repo root (`-f apps/whatsapp/Dockerfile .`). It identifies a user by BSUID only (`from_user_id`; it never reads or forwards the phone number), asks apps/auth for a JWT (`POST /api/whatsapp/session`, header `X-Whatsapp-Api-Key`, body `{bsuid}`: this contract is ASSUMED and the route does not exist yet, so sign-in is mocked in practice), then calls apps/api with that JWT: `GET/POST/DELETE /api/v2/threads` and the SSE `POST /api/v2/threads/{id}/chat`. Each user has one thread (`source: whatsapp`), deleted and recreated after `WHATSAPP_CHAT_RETENTION_HOURS` (3) of silence; a race between two first messages can create two (accepted, swept by hand). It logs one JSON line per entry on stdout with only `request_id` (Railway indexes `@request_id:`), and never logs phone numbers, BSUIDs, message text or the webhook verify token. It reads `PORT` (default 8001) and its Railway settings are recorded in `apps/whatsapp/railway.toml` (not read by Railway). Its environments share a Meta setup: each environment needs its own Meta app credentials, and the staging `!d ` prefix keeps a developer's local messages from being answered by staging. + **Health endpoint.** `/api/health` returns `service: 'ansari-backend'` — a pinned public contract (spec 3), unrelated to the `apps/api` directory name. It performs a live DB ping with a 2s cap, so a cold process can legitimately 503 on its first request. ## Frontend chat display (apps/frontend) diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index a56098e3..e59bd8fc 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -220,3 +220,15 @@ See `codev/reviews/300-better-auth-migration.md` for the full context. - **An inference about infrastructure is not a finding.** A Cloudflare egress-IP claim was recorded as a finding from reasoning alone, then had to be withdrawn when a measurement contradicted it. Label it provisional, probe both paths (via the CDN and direct, IPv4 and IPv6), and derive tests from the measured fixture rather than pinning provisional values, which break when the real measurements arrive. - **Expo / React Native traps (frontend, 10b/10c):** expo-router re-syncs URL params (`?token=`) back from navigation state; React Native defines `window` without `location`/`history`, so a web-only change to the app entry can red-box iOS (launch the native app after any app-entry change); react-native-web trims email inputs itself; a rejection escaping `act` drops state updates. - **Next App Router treats `_folder` as private.** A route at `/_diag` lives under `%5Fdiag/`. + +## WhatsApp service port (spec 321) + +See `codev/reviews/321-rfc-whatsapp-support-in-the-mo.md` for the full context. + +- **Never log a webhook verify token, a phone number, a BSUID or message text, and prove it with a test.** A local-only debug branch printed the verify token. The guard is a test that sets a distinctive token, calls the handshake with a right and a wrong value, and asserts neither appears in the captured log; it was shown to fail when the line was put back. +- **A manual run against real services finds configuration errors mocks cannot.** The legacy `.env` mixed three Meta apps (a token for one, an app ID for another, a subscription on a third), and a 15-minute JWT expired mid-session. Check which app a token belongs to (`debug_token`) and which app an ID and secret pair belongs to before debugging code. +- **A test of a flag needs a case for each part of the condition.** The JSON-log decision was tested only with the value it already had, so deleting its CI clause kept the test green. Extract the decision into a function and parametrize it. +- **Do not claim a code path exists that cannot run.** A timeout branch and a note saying timeouts were reported separately were both wrong, because the client wrapped timeouts before the branch could see them. Read what the callee actually raises. +- **Keep a spec's success criteria next to a test or an owner.** The "answers over 4000 characters are split" criterion had neither, and was only noticed while writing the review. +- **Meta test numbers can fail to provision for weeks.** "An error occurred while creating the test account" is widely reported; a business with one live number may be unable to claim one. Ask for a spare number early rather than discovering this at the real-Meta test. +- **Windows, native:** `afx spawn` assumes `/bin/bash`, porch's whole-monorepo test check cannot pass, and `consult` detects a builder worktree by a forward-slash path. Each needed a local workaround; the full list is for an upstream Codev report. diff --git a/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md b/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md new file mode 100644 index 00000000..3f0734f6 --- /dev/null +++ b/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md @@ -0,0 +1,135 @@ +# Review: WhatsApp support in the monorepo (spec 321) + +## Summary + +The legacy Python WhatsApp service now lives in the monorepo as `apps/whatsapp`. It identifies users by their business-scoped user ID (BSUID), gets a JWT for them from `apps/auth`, and talks to `apps/api`, keeping one thread per user that is replaced after 3 hours of silence. Four phases (import, BSUID/JWT, thread lifecycle, environments and docs) were built and reviewed; 70 tests pass. It has run against a real `apps/api` and `apps/auth`, and Meta accepted one real send to a BSUID, but a real phone's message has never reached the new service, the Docker image has never been built, and the `apps/auth` `/whatsapp` route does not exist yet (the sign-in is mocked by owner decision). + +## Spec Compliance + +Met means covered by an automated test unless stated. "Live" means checked by hand against a real service, not by the suite. + +- [x] A phone-visible user's message is answered through the new stack. Tested with mocks and a mock transport (phases 2, 4); run live against a real `apps/api` with sample webhooks. +- [x] A phone-hidden user (BSUID only) is answered; no phone number reaches `apps/auth` or `apps/api`. Tested, including a request-body check that no phone or BSUID is sent to `apps/api` (phase 2); live with Meta's sample webhook shape (phase 4). +- [x] The same BSUID is the same user and thread; a new BSUID is a new user (phases 2, 3). Tested with the mock sign-in. +- [x] `apps/api` authenticates WhatsApp requests only via the `apps/auth` JWT; no WhatsApp login logic in `apps/api` (phase 2). No change was made to `apps/api`. +- [x] After more than 3 hours of silence the next message starts a new thread and the old one is deleted (phase 3). Tested, including a real-shaped `...Z` timestamp. "Exactly one thread per user" holds except for a race on two near-simultaneous first messages (see Deviations). +- [ ] Answers longer than 4000 characters are delivered as multiple messages in order. **Not covered by any test.** The splitter (`whatsapp_message_splitter.py`, limit 4000) is the legacy code imported unchanged and used by the conversation manager; no test was written or kept for it in this project. Unverified here. +- [x] Meta always gets a success response immediately, and its signature is verified (phase 1: missing and wrong signature rejected with 403). The "always 200" behaviour is the `ALWAYS_RETURN_OK_TO_META` setting, which tests deliberately turn off. +- [x] Status updates, webhooks for other numbers and unsupported message types do not trigger a chat answer. Status and other-number webhooks were seen being ignored live; the unsupported-type path is covered by the imported legacy test and logic. +- [x] With `WA_MAINTENANCE_MODE` on, users get the notice and no chat call is made, independent of `apps/api`'s `MAINTENANCE_MODE` (phase 4, tested both ways). +- [x] In staging, messages starting with `!d ` are ignored and others processed (phase 4, tested in staging and local). +- [x] No legacy users or threads were migrated. +- [x] Tests use mock Meta and mock backend clients, as in the legacy service. + +Not in the spec's list but part of the owner's Phase 4 requirement: real services. Done by hand on 2026-10-26 (see the plan): real `apps/auth` and `apps/api` with a real JWT; Meta's dashboard sample webhooks through zrok with a valid signature; one real production-number send to a real BSUID, accepted by Meta (HTTP 200, delivery and read statuses followed). Not done: a real phone's webhook arriving at the new service (no Meta test number could be claimed), the Docker image, and the `apps/auth` `/whatsapp` route. + +## Deviations from Plan + +- **Phase 4, `/chat` instead of the raw route.** The client moved to `POST /api/v2/threads/{id}/chat`, which reports failures as an explicit `error` event, so the planned "detect the trailing `Error:` text" work became unnecessary. +- **Phase 4, no per-user lock.** The lock added in phase 3 was removed by the owner. A race on two near-simultaneous first messages can create two threads, and two overlapping answers can interleave in a thread's history. Both are accepted, documented in `_resolve_thread`, and cleaned up by an occasional manual sweep (keep the newest `whatsapp` thread per user). +- **Phase 4, `WA_MAINTENANCE_MODE`.** Renamed from `WHATSAPP_UNDER_MAINTENANCE` and made independent of `apps/api`'s `MAINTENANCE_MODE` (owner decision); the spec was amended. +- **Phase 4, logging.** Logs carry only `request_id` and are one JSON line per entry on stdout, the shape Railway indexes. This replaced the CloudWatch-shaped sink and the user-id tracking, and removed `get_subject`. +- **Phase 4, extra deploy fixes.** The app reads `PORT`; the Dockerfile uses repo-root paths, uv 0.9.13 and `uv sync --frozen`; `apps/whatsapp/railway.toml` records settings (not read by Railway); a `.python-version` pins 3.13. +- **Phase 4, `MOCK_AUTH_TOKEN`.** A test-only setting so the mocked sign-in can return a real JWT to a real `apps/api`. Not in the plan. +- **Real-Meta test.** The plan assumed a Meta test number; none could be claimed (a widespread Meta-side failure), so Meta's sample webhooks and one production-number send were used instead. See the PR description for the request for a shared staging and test number. + +## Consultation Feedback + +Only Claude was consulted (a decision for this environment, since the other models' tooling is unusable on native Windows). Five reviews were run across four phases. + +### import_service Phase (Round 1) , REQUEST_CHANGES + +#### Claude +- **Concern**: no CI job for `apps/whatsapp` → **Addressed**: a `whatsapp` job was added to `ci.yml`, with the action pinned to a commit SHA. +- **Concern**: `ruff` configured but not declared → **Addressed**: added to the `dev` extra and locked. +- **Concern**: READMEs reference files that do not exist → **Addressed** in phase 4 (both READMEs rewritten). +- **Concern**: no test for a missing or invalid Meta signature → **Addressed**: two tests added, shown to fail when wrong. +- **Concern**: CWD-relative `logs/` directory created at import; phone numbers logged and sent to Sentry → **Addressed** later (phase 2 redaction; the `logs/` directory and old logger were removed in phase 4). + +### import_service Phase (Round 2) , APPROVE + +#### Claude +- **Concern**: Dockerfile pins uv 0.5.11 against a `revision = 3` lockfile and its second sync is not `--frozen` → **Addressed** in phase 4. +- **Concern**: `conftest.py` comment wrong about `.env` precedence → **Addressed** (comment fixed). +- **Concern**: `test_webhook_message_basic` asserts only HTTP 200 → **N/A**: legacy test kept as imported; not tightened. + +### auth_token_exchange Phase (Round 1) , COMMENT + +#### Claude +- **Concern**: `get_subject()` can raise outside its try block → **Addressed** (fixed and tested); the function was later removed in phase 4. +- **Concern**: token exchange timeout of 15 s exceeds Meta's window → **Addressed**: lowered to 5 s. +- **Concern**: typing indicator never cancelled when sign-in fails → **Addressed**: scheduled only after sign-in succeeds. +- **Concern**: no assertion that no phone reaches `apps/api` → **Addressed**: test added, shown to fail when a phone is injected. +- **Concern**: mid-stream `apps/api` errors relayed unlogged as `Error:` text → **Addressed** in phase 4 by the `/chat` event stream. +- **Concern**: dependency on `from_user_id` and an untested BSUID send → **Addressed** in phase 4 as far as possible: `from_user_id` is present in Meta's sample webhooks, and a real send to a BSUID succeeded once (see Spec Compliance for the limits). +- **Concern**: reviewer did not run `pytest`/`ruff` → **N/A**: run by the builder and CI. + +### thread_lifecycle Phase (Round 1) , APPROVE + +#### Claude +- **Concern**: lock covers resolution only → **Rebutted** by owner decision: the lock was removed entirely and the cases accepted and documented. +- **Concern**: `...Z` timestamps untested → **Addressed** (test added). +- **Concern**: a create response without a thread id leaves the user un-notified → **Addressed** (user is told); Sentry capture added in phase 4. +- **Concern**: thread info/create/delete failures not captured in Sentry → **Addressed** in phase 4, with tests. +- **Concern**: single-replica assumption → **N/A**: no longer applies once the lock was removed. + +### environments_and_docs Phase (Round 1) , COMMENT + +#### Claude +- **Concern**: README and `tests/README.md` contradict the recorded live runs → **Addressed**: rewritten to state what was and was not verified. +- **Concern**: Sentry captures untested → **Addressed**: four tests, each shown to fail when its capture is removed. +- **Concern**: unreachable `TimeoutException` branch → **Addressed**: branch and import removed; timeouts are captured as message-processing failures. +- **Concern**: vacuous JSON-log test → **Addressed**: decision extracted into `should_use_json_logs()` with parametrized tests, shown to fail without the CI clause. +- **Concern**: Python version drift across CI, dev and the image → **Addressed**: `.python-version` pins 3.13; the suite passes on it. +- **Concern**: dead `META_WABA_ID` documentation and a legacy `ngrok` host → **Addressed**: removed. +- **Concern**: maintenance check runs before the `!d ` filter → **N/A**: matches the legacy order and is harmless; recorded in the plan. + +## Lessons Learned + +### What Went Well + +- Negative controls. Every test that expects a failure was proven able to fail by briefly breaking the code and restoring it. A first version of the `/chat` error-event test passed with the handling disabled; this practice caught it. +- Reading the real contracts. The `apps/api` thread and chat routes were read in the code rather than assumed, and the client matched them. The Phase 4 real run then confirmed the main path against a live `apps/api`. +- A manual real-service run found problems the mocks could not: an expired 15-minute test JWT (apps/api answered 401 and the failure path told the user), an app ID that belonged to a different Meta app than the secret, and a log line that printed the webhook verify token in local mode. + +### Challenges Encountered + +- Windows. `afx spawn` is unusable (hard-coded `/bin/bash`), porch's whole-monorepo test check cannot pass, and `consult` misdetects the builder worktree. All were worked around locally; the list is kept for an upstream report. +- Meta test numbers. No test number could be claimed, so a real phone's webhook could not reach the new service. The workaround was Meta's sample webhooks plus one production-number send to a developer's own BSUID, which needed a temporary, now commented-out, log line in the legacy production service. +- Claims in docs drifting from facts. The README said nothing had run against live Meta after it had; the review caught it. + +### What Would Be Done Differently + +- Test the 4,000-character splitting. The criterion was never given a test or an owner and slipped through to the end. +- Decide earlier how a real phone's message would reach the service in a non-production environment. The shared-number routing idea (see the PR description) would have solved this at the start. + +### Methodology Improvements + +- Porch's review phase runs a `pr_exists` check before the builder signals completion, but the review file and PR are only written in this phase; the order is confusing for a first-time reader. +- Every phase boundary produces a "refresh your context" task that needs a live builder terminal; it should be skippable when none exists. +- A plan item that no phase owns (here the splitter test, found from the spec's success criteria) should be flagged when the plan is approved, by checking each success criterion against a phase. + +## Architecture Updates + +- Routed: cold, `codev/resources/arch.md` ("Monorepo layout, build & deploy") — `apps/whatsapp` is a Python service in the repo: identifies users by BSUID only, mints a JWT through `apps/auth` (route and header assumed, sign-in mocked until `apps/auth` has the route), calls `apps/api`'s `/chat`, logs one JSON line per entry on stdout with only `request_id`; deployed by Dockerfile `apps/whatsapp/Dockerfile` with the repo root as build context. +- No hot-tier change: both hot files are at their 10-fact cap and nothing here changes behaviour for builders working in other apps. Not demoted. + +## Lessons Learned Updates + +- Routed: cold, `codev/resources/lessons-learned.md` (new "WhatsApp service port (spec 321)" section) — do not log a webhook verify token or any user identifier, and prove that with a test that fails when the line is restored; a manual run against real services finds config errors (wrong app ID for a secret, expired tokens) that mocks cannot. +- No hot-tier change: the existing hot lesson "prefer failures that are loud over checks that are quiet" already covers the negative-control practice. + +## Flaky Tests + +No flaky tests encountered in `apps/whatsapp`. On native Windows, porch's whole-monorepo `npm test` check could not pass, so it was narrowed locally to `apps/whatsapp` through a git-ignored `.codev/config.json`; CI on Linux runs the stock checks for the other apps. + +## Follow-up Items + +- Create a new WhatsApp phone number shared by staging and test (call to action for Waleed; see the PR description), since a Meta test number cannot be claimed. +- Test the 4,000-character splitting; it has no test. +- Confirm the `apps/auth` `/whatsapp` contract with Amr (route `POST /api/whatsapp/session`, header `X-Whatsapp-Api-Key`, variable `AUTH_WHATSAPP_API_KEY`, body `{bsuid}`, response `{token}`), then replace the mocked sign-in. +- Build and run the Docker image; run the staging smoke test in `apps/whatsapp/README.md`. +- Raise with the `apps/api` owner: the chat routes save every message with `source: 'web'` even for WhatsApp threads. +- Make the `whatsapp` CI job a required check on `develop` (needs a repo admin; the job has not yet run on a GitHub runner). +- Occasional manual sweep: delete all but the newest `whatsapp` thread per user. +- Remove the dead legacy `ORIGINS` and CORS middleware if no browser calls this service. From 458ebec9daa8f82ca5f9d25c6e4b96b871c62a02 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:47:00 +0300 Subject: [PATCH 048/247] chore(porch): 321 review build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 5d1aa499..3b970b61 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: import_service @@ -39,7 +39,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T06:22:14.281Z' +updated_at: '2026-10-27T06:47:00.031Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From aa570ea355ae4034e3a8bd41c9d8fd63785c2191 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:58:15 +0300 Subject: [PATCH 049/247] [Spec 321] fix: address PR review (mock-token guard, whatsapp-only thread lookup, no Meta error body in logs) --- apps/whatsapp/.env.example | 2 + apps/whatsapp/README.md | 2 +- apps/whatsapp/src/ansari_whatsapp/app/main.py | 2 +- .../services/ansari_client_real.py | 24 ++----- .../services/meta_api_service_real.py | 2 - .../src/ansari_whatsapp/utils/config.py | 11 +++- apps/whatsapp/tests/test_bsuid_auth_flow.py | 35 +++++++++- .../tests/test_meta_sender_logging.py | 42 ++++++++++++ apps/whatsapp/tests/test_thread_lifecycle.py | 25 ++++++- .../321-review-iter1-claude.txt | 66 +++++++++++++++++++ .../321-review-iter1-rebuttals.md | 33 ++++++++++ .../321-rfc-whatsapp-support-in-the-mo.md | 16 ++++- 12 files changed, 232 insertions(+), 28 deletions(-) create mode 100644 apps/whatsapp/tests/test_meta_sender_logging.py create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-claude.txt create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md diff --git a/apps/whatsapp/.env.example b/apps/whatsapp/.env.example index 57c844b6..d6eb79d6 100644 --- a/apps/whatsapp/.env.example +++ b/apps/whatsapp/.env.example @@ -172,6 +172,8 @@ MOCK_AUTH_CLIENT="False" # Optional, only with MOCK_AUTH_CLIENT=True: a real apps/auth JWT that the mock returns for every BSUID, # so a mocked sign-in can talk to a real apps/api. Get one by logging in through apps/api (POST /api/v2/users/login). # Leave unset to get fake tokens (which a real apps/api rejects). +# Every user would share this one account and thread, so the service refuses to start with it +# unless DEPLOYMENT_TYPE="local". # MOCK_AUTH_TOKEN="" # If True, use mock Meta API service instead of making real HTTP calls to Meta WhatsApp API diff --git a/apps/whatsapp/README.md b/apps/whatsapp/README.md index 3e18596d..6074422a 100644 --- a/apps/whatsapp/README.md +++ b/apps/whatsapp/README.md @@ -74,7 +74,7 @@ Staging shares the same test number, so start every message you send while testi | `WHATSAPP_CHAT_RETENTION_HOURS` | How long a conversation continues before a fresh thread (default 3) | | `SENTRY_DSN` | Empty disables Sentry | | `MOCK_ANSARI_CLIENT`, `MOCK_AUTH_CLIENT`, `MOCK_META_API` | Replace the real services with fakes | -| `MOCK_AUTH_TOKEN` | With `MOCK_AUTH_CLIENT=True`: a real JWT returned for every BSUID, so a real `apps/api` accepts it | +| `MOCK_AUTH_TOKEN` | With `MOCK_AUTH_CLIENT=True`: a real JWT returned for every BSUID, so a real `apps/api` accepts it. Every user would share one account and thread, so the service refuses to start with it unless `DEPLOYMENT_TYPE=local` | Meta credentials differ per Meta app (Test, Staging, production). A token from one app will not work with another app's secret. diff --git a/apps/whatsapp/src/ansari_whatsapp/app/main.py b/apps/whatsapp/src/ansari_whatsapp/app/main.py index 598bef0f..fc7233ec 100644 --- a/apps/whatsapp/src/ansari_whatsapp/app/main.py +++ b/apps/whatsapp/src/ansari_whatsapp/app/main.py @@ -157,7 +157,7 @@ async def root(): @app.get("/whatsapp/v2") -async def verification_webhook(request: Request) -> str | None: +async def verification_webhook(request: Request) -> HTMLResponse: """ Handles the WhatsApp webhook verification request. diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py index 5e2b4143..fdcd493d 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py @@ -130,7 +130,9 @@ async def get_last_thread_info(self, token: str) -> dict: """ Get information about the user's most recently active thread. - apps/api lists a user's threads newest-first by `updated_at`, which it bumps on every message. + apps/api lists a user's threads newest-first by `updated_at`, which it bumps on every message. Only threads + this service created (`source` is `whatsapp`) count, so an expired one is never mistaken for, or deleted + in place of, a thread from another client. Args: token (str): The user's JWT from apps/auth. @@ -144,7 +146,7 @@ async def get_last_thread_info(self, token: str) -> dict: try: response = await self.client.get(f"{self.base_url}/api/v2/threads", headers=self._auth(token)) response.raise_for_status() - threads = response.json() + threads = [t for t in response.json() if t.get("source") == "whatsapp"] if not threads: return {"thread_id": None, "last_message_time": None} return {"thread_id": threads[0]["thread_id"], "last_message_time": threads[0]["updated_at"]} @@ -218,24 +220,6 @@ async def process_message(self, token: str, thread_id: str, message: str) -> str logger.warning("Received empty response from backend") return "" - # # TEMPORARY: Save response to file for testing/mocking purposes - # # Comment out this block when you don't want to update the sample response file - # try: - # from pathlib import Path - - # # Create the directory if it doesn't exist - # sample_dir = Path.cwd() / "docs" / "sample_backend_responses" - # sample_dir.mkdir(parents=True, exist_ok=True) - - # # Write the response to the file - # sample_file = sample_dir / "sample_ansari_llm_response.txt" - # with open(sample_file, "w", encoding="utf-8") as f: - # f.write(full_response) - # logger.info(f"Saved backend response to {sample_file}") - # except Exception as e: - # logger.warning(f"Failed to save sample response: {e}") - # # END TEMPORARY CODE - return full_response except httpx.TimeoutException as e: logger.error(f"Timeout processing message: {e}") diff --git a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py index 670255a1..a0affa04 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/meta_api_service_real.py @@ -63,7 +63,6 @@ async def send_typing_indicator(self, recipient_phone: str, message_id: str) -> except httpx.HTTPStatusError as e: logger.error(f"HTTP error sending typing indicator: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") # Only capture 5xx errors (Meta's fault, not ours) if e.response.status_code >= 500: sentry_sdk.set_tag("error_type", "meta_api_failure") @@ -140,7 +139,6 @@ async def send_message(self, recipient_phone: str, message_parts: list[str]) -> except httpx.HTTPStatusError as e: logger.error(f"HTTP error sending message: {e.response.status_code}") - logger.error(f"Response body: {e.response.text}") # Only capture 5xx errors (Meta's fault, not ours) if e.response.status_code >= 500: sentry_sdk.set_tag("error_type", "meta_api_failure") diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/config.py b/apps/whatsapp/src/ansari_whatsapp/utils/config.py index 2b60c163..63df502d 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/config.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/config.py @@ -3,7 +3,7 @@ from functools import lru_cache -from pydantic import HttpUrl, SecretStr, Field, field_validator +from pydantic import HttpUrl, SecretStr, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -104,6 +104,15 @@ def META_API_URL(self) -> str: ########### Validators ########### + @model_validator(mode="after") + def refuse_shared_mock_token_outside_local(self): + """One token for every BSUID would put every WhatsApp user in the same account and thread.""" + if self.MOCK_AUTH_TOKEN and self.MOCK_AUTH_TOKEN.get_secret_value() and self.DEPLOYMENT_TYPE != "local": + raise ValueError( + "MOCK_AUTH_TOKEN gives every user the same account; it is only allowed when DEPLOYMENT_TYPE=local" + ) + return self + @field_validator("ORIGINS", mode="before") def parse_origins(cls, v): """Parse ORIGINS from a comma-separated string or list.""" diff --git a/apps/whatsapp/tests/test_bsuid_auth_flow.py b/apps/whatsapp/tests/test_bsuid_auth_flow.py index 41d1a15b..b1266580 100644 --- a/apps/whatsapp/tests/test_bsuid_auth_flow.py +++ b/apps/whatsapp/tests/test_bsuid_auth_flow.py @@ -132,6 +132,37 @@ def test_mock_auth_same_bsuid_same_user_new_bsuid_new_user(): assert first != other +@pytest.mark.parametrize(("deployment_type", "allowed"), [("local", True), ("staging", False), ("production", False)]) +def test_shared_mock_token_is_refused_outside_local(monkeypatch, deployment_type, allowed): + from ansari_whatsapp.utils.config import WhatsAppSettings + + monkeypatch.setenv("DEPLOYMENT_TYPE", deployment_type) + monkeypatch.setenv("MOCK_AUTH_TOKEN", "a.real.jwt") + get_settings.cache_clear() + try: + if allowed: + assert WhatsAppSettings().MOCK_AUTH_TOKEN.get_secret_value() == "a.real.jwt" + else: + with pytest.raises(ValueError, match="MOCK_AUTH_TOKEN"): + WhatsAppSettings() + finally: + monkeypatch.undo() + get_settings.cache_clear() + + +def test_an_empty_mock_token_is_fine_in_any_environment(monkeypatch): + from ansari_whatsapp.utils.config import WhatsAppSettings + + monkeypatch.setenv("DEPLOYMENT_TYPE", "production") + monkeypatch.setenv("MOCK_AUTH_TOKEN", "") + get_settings.cache_clear() + try: + assert WhatsAppSettings().DEPLOYMENT_TYPE == "production" + finally: + monkeypatch.undo() + get_settings.cache_clear() + + def test_mock_auth_returns_the_configured_real_token_when_one_is_set(monkeypatch): monkeypatch.setenv("MOCK_AUTH_TOKEN", "a.real.jwt") get_settings.cache_clear() @@ -152,7 +183,9 @@ def test_ansari_client_calls_api_with_jwt_and_reads_the_event_stream(monkeypatch def handler(request: httpx.Request) -> httpx.Response: calls.append((request.method, request.url.path, request.headers.get("authorization"))) if request.url.path == "/api/v2/threads" and request.method == "GET": - return httpx.Response(200, json=[{"thread_id": "t1", "updated_at": "2026-10-25T10:00:00.000Z"}]) + return httpx.Response( + 200, json=[{"thread_id": "t1", "source": "whatsapp", "updated_at": "2026-10-25T10:00:00.000Z"}] + ) if request.url.path == "/api/v2/threads": return httpx.Response(200, json={"thread_id": "t2"}) return httpx.Response( diff --git a/apps/whatsapp/tests/test_meta_sender_logging.py b/apps/whatsapp/tests/test_meta_sender_logging.py new file mode 100644 index 00000000..52014f95 --- /dev/null +++ b/apps/whatsapp/tests/test_meta_sender_logging.py @@ -0,0 +1,42 @@ +"""The Meta sender must not write Meta's error body to the logs (spec 321, phase 4 review).""" + +import asyncio + +import httpx +import pytest +from loguru import logger + +from ansari_whatsapp.services.meta_api_service_real import MetaApiServiceReal + +BSUID = "US.13491208655302741918" +ECHOED_BODY = '{"error":{"message":"Recipient US.13491208655302741918 is not allowed"}}' + +_RealAsyncClient = httpx.AsyncClient + + +def _failing_meta(monkeypatch): + monkeypatch.setattr( + "ansari_whatsapp.services.meta_api_service_real.httpx.AsyncClient", + lambda **kw: _RealAsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(400, text=ECHOED_BODY)), **kw), + ) + + +@pytest.mark.parametrize("method", ["send_message", "send_typing_indicator"]) +def test_a_failed_send_logs_the_status_but_never_meta_error_body(monkeypatch, method): + _failing_meta(monkeypatch) + service = MetaApiServiceReal() + captured = [] + sink_id = logger.add(lambda m: captured.append(str(m)), level="DEBUG") + try: + with pytest.raises(httpx.HTTPStatusError): + if method == "send_message": + asyncio.run(service.send_message(BSUID, ["hello"])) + else: + asyncio.run(service.send_typing_indicator(BSUID, "wamid.T")) + finally: + logger.remove(sink_id) + + log_text = "\n".join(captured) + assert "400" in log_text + assert BSUID not in log_text + assert "is not allowed" not in log_text diff --git a/apps/whatsapp/tests/test_thread_lifecycle.py b/apps/whatsapp/tests/test_thread_lifecycle.py index edff07bb..ce7edf2d 100644 --- a/apps/whatsapp/tests/test_thread_lifecycle.py +++ b/apps/whatsapp/tests/test_thread_lifecycle.py @@ -147,7 +147,7 @@ def test_expired_thread_flow_against_the_real_client_makes_the_expected_calls(mo def handler(request: httpx.Request) -> httpx.Response: seen.append((request.method, request.url.path, request.headers.get("authorization"))) if request.method == "GET": - return httpx.Response(200, json=[{"thread_id": "old", "updated_at": four_hours_ago}]) + return httpx.Response(200, json=[{"thread_id": "old", "source": "whatsapp", "updated_at": four_hours_ago}]) if request.method == "DELETE": return httpx.Response(200, json={"message": "Thread deleted successfully"}) return httpx.Response(200, json={"thread_id": "new"}) @@ -164,6 +164,29 @@ def handler(request: httpx.Request) -> httpx.Response: ] +def test_only_whatsapp_threads_count_so_another_clients_thread_is_never_deleted(monkeypatch): + seen = [] + threads = [ + {"thread_id": "web-newest", "source": "web", "updated_at": "2026-10-27T10:00:00.000Z"}, + {"thread_id": "wa", "source": "whatsapp", "updated_at": "2026-10-26T10:00:00.000Z"}, + ] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, request.url.path)) + return httpx.Response(200, json=threads) + + client = _real_client(monkeypatch, handler) + + assert asyncio.run(client.get_last_thread_info("jwt"))["thread_id"] == "wa" + assert asyncio.run( + _real_client(monkeypatch, lambda r: httpx.Response(200, json=threads[:1])).get_last_thread_info("jwt") + ) == { + "thread_id": None, + "last_message_time": None, + } + assert [m for m, _ in seen] == ["GET"] + + def test_creation_response_without_a_thread_id_is_a_failure_the_user_hears_about(monkeypatch): manager = _manager() sent = [] diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-claude.txt new file mode 100644 index 00000000..fa8d048b --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-claude.txt @@ -0,0 +1,66 @@ +I'll review this PR by reading the diff and the actual files on disk.A `.venv` exists in the worktree — let me check gitignore coverage, and read the spec/plan/review.I've reviewed the PR by reading the files on disk, cross-checking the Python client's assumptions against the actual `apps/api` routes, and verifying the git/branch state. I did not run `pytest`/`ruff` or mutate anything in the tree. + +## What's solid + +- **Branch hygiene**: 0 behind / 46 ahead of `origin/develop`. `.venv` is correctly gitignored (43 tracked files under `apps/whatsapp`, none from the venv). Commits are consistently `[Spec 321][Phase: x]`, atomic, well-described. +- **Contract verification is real, not assumed.** I independently confirmed every cross-service assumption the Python client makes: + - `apps/api/src/app/api/v2/threads/route.ts` — `createThreadSchema` has `source: z.string().optional()`, so `"whatsapp"` is accepted. + - `packages/types/src/attribution.ts` — `askansari-whatsapp` matches `CLIENT_ID_PATTERN` and isn't allowlist-gated. + - `apps/api/lib/db/threads.ts:65` — `orderBy(desc(threads.updatedAt))`, so the client's `threads[0]`-is-newest assumption holds. + - `.../threads/[id]/chat/route.ts` — emits exactly `{type:'text',content}`, `{type:'error',message}`, `{type:'done'}`, matching the parser in `ansari_client_real.py:195-215`. +- **Signature verification** uses `hmac.compare_digest` and rejects missing/malformed headers with 403. +- **Tests** sidestep `pytest-asyncio` via `asyncio.run` (no undeclared dep), drive the real app through `TestClient` with genuine HMAC signatures, and the review documents negative controls per test. +- **The review document is unusually honest** — it leaves the 4000-char criterion as `[ ]`, and names the unbuilt image, the missing `apps/auth` route, and the race it chose not to fix. + +## Issues + +**1. (High) Nothing stops `MOCK_AUTH_CLIENT=True` + `MOCK_AUTH_TOKEN` outside `local`.** + +`auth_client_mock.py:17-18` returns the *same* JWT for every BSUID when `MOCK_AUTH_TOKEN` is set. All WhatsApp users then collapse into one `apps/api` account and one thread — every user reads and continues every other user's conversation. + +This isn't hypothetical: the PR's stated plan is to deploy with the sign-in mocked until `apps/auth` has the route, so `MOCK_AUTH_CLIENT=True` *is* the intended deployed config, and `MOCK_AUTH_TOKEN` is exactly what makes a real `apps/api` accept it. The builder clearly knows the hazard — `conftest.py:19` says "A real token in a developer's .env would make every BSUID the same user" — but that defence lives only in the test fixture, never in `config.py`. A `model_validator` on `WhatsAppSettings` raising when `DEPLOYMENT_TYPE != "local"` and any `MOCK_*` is truthy closes it in ~6 lines, and matches your own hot lesson: *prefer failures that are loud over checks that are quiet*. + +**2. (Medium) `_resolve_thread` deletes a thread it never confirmed is a WhatsApp thread.** + +`get_last_thread_info` (`ansari_client_real.py:150`) takes `threads[0]` with no `source` filter, and `_resolve_thread` deletes it on expiry. `create_thread` tags `source: "whatsapp"`, and `GET /api/v2/threads` already returns `source`. Safe today because BSUID accounts hold only whatsapp threads — but the moment the real `/whatsapp` route maps a BSUID onto an account that also has web threads, a WhatsApp message 3 hours later silently deletes the user's web thread *and its messages*. The follow-up list already describes the sweep as "keep each user's newest `whatsapp` thread," so filtering is the intended semantics anyway. + +**3. (Medium) `e.response.text` is logged raw, against a documented guarantee.** + +`meta_api_service_real.py:66` and `:143` log Meta's raw error body. `README.md:100` states unconditionally: "Logs deliberately contain no phone numbers, BSUIDs or message text." Meta's send-failure bodies can echo the recipient. Either reduce to `{name, code}` (your hot lesson on raw error objects) or qualify the README claim — right now the doc asserts something the code doesn't enforce. + +**4. (Low) Dead debug block.** `ansari_client_real.py:221-237` — a commented-out "TEMPORARY: Save response to file" block. Straight "no debug code" PR-readiness item; not mentioned in the review doc. + +**5. (Low) Nits.** `pytest` is in `[project].dependencies` rather than the `dev` extra (ships into the image); `requires-python = ">=3.10"` / ruff `target-version = "py310"` while `.python-version` and the Dockerfile pin 3.13 (the review calls this drift "addressed" — narrowed, not closed); `main.py:251` `await request.json()` sits outside the try/except so a signature-valid non-JSON body 500s through the global handler; `verification_webhook` is annotated `-> str | None` but returns `HTMLResponse`. + +**On the untested splitter:** I read all four paths. The length bound *is* sound — `split_by_fixed_chunks` is the terminal fallback on every branch — and ordering is preserved. So this gap is lower-risk than the review implies, though the follow-up test is still worth writing. + +**Not verified by me:** I didn't run `pytest`/`ruff`, so "70 tests pass, ruff clean" is unconfirmed; the `whatsapp` CI job also hasn't run on a runner yet, so nothing independent backs it. I count 57 `def test_` functions — 70 is plausible with the parametrized cases. The Dockerfile remains the deploy artifact and has never been built; worth one `docker build && docker run` before the Railway service is created, since `CMD ["python", "src/..."]` with `PYTHONPATH=/app` depends on `uv sync` having installed the src-layout project into the venv. + +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Well-executed port with honest docs and verified cross-service contracts, but the mocked sign-in can silently merge all WhatsApp users into one account if deployed as planned, and thread deletion isn't scoped by source. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- No config guard prevents `MOCK_AUTH_CLIENT=True` + `MOCK_AUTH_TOKEN` outside `local`; since deploying with a mocked sign-in is the stated plan, this collapses every WhatsApp user into one account and thread (cross-user conversation exposure). Add a `model_validator` in `WhatsAppSettings`. +- `get_last_thread_info` doesn't filter by `source`, yet `_resolve_thread` deletes what it finds — latent data loss of a user's web threads once BSUID→account linking exists. One-line filter; `GET /api/v2/threads` already returns `source`. +- `meta_api_service_real.py:66,143` log Meta's raw `e.response.text`, contradicting README:100's unconditional "no phone numbers, BSUIDs or message text in logs." +- Dead commented-out debug block at `ansari_client_real.py:221-237`. +- Dockerfile (the deploy artifact) has never been built or run; `whatsapp` CI job has never run on a GitHub runner. + +PR_SUMMARY: | + ## Summary + Moves the legacy Python WhatsApp service into the monorepo as `apps/whatsapp`. Users are identified solely by their business-scoped user ID (BSUID) — never a phone number — exchanged for a JWT via `apps/auth`, which is then used to call `apps/api`'s `/chat` event stream. Each user keeps exactly one thread, replaced after 3 hours of silence. No `apps/api` or `apps/auth` code changes. + + ## Key Changes + - `apps/whatsapp`: BSUID identity, `apps/auth` token exchange behind one client, `apps/api` `/chat` SSE aggregation, one-thread-per-user lifecycle, `WA_MAINTENANCE_MODE`, staging `!d ` filter, Railway-shaped single-line JSON logs carrying only `request_id`, Sentry capture for thread and sign-in failures, `PORT` support. + - Deploy scaffolding: `Dockerfile` (repo-root build context), `railway.toml` (git record only), `.python-version` pinned to 3.13. + - `.github/workflows/ci.yml`: a `whatsapp` job running lint, format check and tests, with actions pinned to commit SHAs. + - `codev/`: spec, plan, per-phase reviews, review document, and cold-tier arch/lessons updates. + + ## Test Plan + - `cd apps/whatsapp && uv sync --frozen --extra dev && uv run ruff check . && uv run ruff format --check . && uv run pytest tests/ -v` + - Confirm the `whatsapp` CI job goes green on a GitHub runner (it has not yet run). + - Build and run the Docker image — never exercised, and it is the deploy artifact. + - Run the staging smoke test in `apps/whatsapp/README.md` once a shared staging/test number exists. + - Before any non-local deploy: verify that setting `MOCK_AUTH_CLIENT=True` with a `MOCK_AUTH_TOKEN` cannot route two different BSUIDs to the same thread. \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md new file mode 100644 index 00000000..5bcd3763 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md @@ -0,0 +1,33 @@ +# Rebuttal: review phase (PR #327), iteration 1 + +Review: `321-review-iter1-claude.txt` (REQUEST_CHANGES). The four code findings are accepted and fixed; nothing is rebutted. Each new test was shown to fail when its fix is undone. + +## Must fix + +**1. Nothing stopped `MOCK_AUTH_CLIENT=True` with `MOCK_AUTH_TOKEN` outside `local`. Accepted, fixed.** +`WhatsAppSettings` now refuses to load when `MOCK_AUTH_TOKEN` is set and `DEPLOYMENT_TYPE` is not `local`, so the service will not start in staging or production with it. Tests: local allows it, staging and production refuse it, and an empty value is fine anywhere. They fail (2 tests) when the guard is disabled. +One consequence the review's premise misses: a mocked sign-in without `MOCK_AUTH_TOKEN` hands out fake tokens that a real `apps/api` rejects, so a mocked sign-in cannot serve real users anywhere. A deployment against a real `apps/api` needs the real `apps/auth` `/whatsapp` route. The README, `.env.example` and the PR description now say so. + +**2. `_resolve_thread` could delete a thread that is not a WhatsApp thread. Accepted, fixed.** +`get_last_thread_info` now considers only threads whose `source` is `whatsapp` (the list endpoint returns `source`). Test: a newer `web` thread is ignored, and a user whose only thread is `web` counts as having none. It fails when the filter is removed. The existing real-client fixtures now include `source`, as the real endpoint does. + +**3. Meta's raw error body was logged, against the README's "no phone numbers or BSUIDs in logs". Accepted, fixed.** +Both send paths now log only the status code. Test: a failing send (the mock error body echoes a BSUID) must log the status but neither the BSUID nor the body, for the message and typing-indicator calls. It fails when the body logging is put back. + +**4. Dead commented-out debug block. Accepted, removed** from `ansari_client_real.py`. + +## Also fixed (nits) + +- `verification_webhook` was annotated `-> str | None` but returns `HTMLResponse`; corrected. + +## Not changed, with reasons + +- **`pytest` in the runtime dependencies, and `requires-python`/ruff `py310` against a 3.13 image.** Both are imported legacy packaging. Moving `pytest` to the `dev` extra changes the lockfile and the Docker install; narrowing the Python range changes supported versions. Left for a follow-up and listed in the review document; the suite passes on 3.13. +- **`await request.json()` outside the try block (a signature-valid non-JSON body returns 500).** Legacy behaviour; Meta sends JSON and the signature is checked first. Listed as a follow-up. +- **Docker image never built; CI job never run on a runner.** Unchanged and stated in the PR. Neither can be done from this machine (no Docker daemon); the first PR run is the CI job's first real test. + +## Review's own caveat on counts + +The review counted 57 `def test_` functions against "70 tests"; parametrized cases account for the difference. After these changes the suite is 77 tests. + +Verification: ruff clean, format clean, 77 passed. diff --git a/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md b/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md index 3f0734f6..1be0dddc 100644 --- a/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md @@ -2,7 +2,7 @@ ## Summary -The legacy Python WhatsApp service now lives in the monorepo as `apps/whatsapp`. It identifies users by their business-scoped user ID (BSUID), gets a JWT for them from `apps/auth`, and talks to `apps/api`, keeping one thread per user that is replaced after 3 hours of silence. Four phases (import, BSUID/JWT, thread lifecycle, environments and docs) were built and reviewed; 70 tests pass. It has run against a real `apps/api` and `apps/auth`, and Meta accepted one real send to a BSUID, but a real phone's message has never reached the new service, the Docker image has never been built, and the `apps/auth` `/whatsapp` route does not exist yet (the sign-in is mocked by owner decision). +The legacy Python WhatsApp service now lives in the monorepo as `apps/whatsapp`. It identifies users by their business-scoped user ID (BSUID), gets a JWT for them from `apps/auth`, and talks to `apps/api`, keeping one thread per user that is replaced after 3 hours of silence. Four phases (import, BSUID/JWT, thread lifecycle, environments and docs) were built and reviewed; 77 tests pass. It has run against a real `apps/api` and `apps/auth`, and Meta accepted one real send to a BSUID, but a real phone's message has never reached the new service, the Docker image has never been built, and the `apps/auth` `/whatsapp` route does not exist yet (the sign-in is mocked by owner decision). ## Spec Compliance @@ -84,6 +84,18 @@ Only Claude was consulted (a decision for this environment, since the other mode - **Concern**: dead `META_WABA_ID` documentation and a legacy `ngrok` host → **Addressed**: removed. - **Concern**: maintenance check runs before the `!d ` filter → **N/A**: matches the legacy order and is harmless; recorded in the plan. +### PR review (PR #327, Round 1) , REQUEST_CHANGES + +#### Claude +- **Concern**: nothing stops `MOCK_AUTH_CLIENT=True` with `MOCK_AUTH_TOKEN` outside `local`, which would put every user in one account and thread → **Addressed**: the settings refuse to load with `MOCK_AUTH_TOKEN` unless `DEPLOYMENT_TYPE=local`; tested, and the tests fail when the guard is disabled. A mocked sign-in without the token hands out fake tokens a real `apps/api` rejects, so it cannot serve real users anywhere; a real deployment needs the `apps/auth` `/whatsapp` route. +- **Concern**: `_resolve_thread` could delete a thread from another client → **Addressed**: only `whatsapp` threads count; tested, fails without the filter. +- **Concern**: Meta's raw error body was logged, against the README → **Addressed**: only the status code is logged; tested for both send paths, fails with the body logging restored. +- **Concern**: dead commented-out debug block → **Addressed**: removed. +- **Concern**: wrong return annotation on `verification_webhook` → **Addressed**. +- **Concern**: `pytest` in runtime dependencies; `requires-python` and ruff `py310` against a 3.13 image; `await request.json()` outside the try block → **N/A** here: legacy packaging and behaviour, listed as follow-ups. +- **Concern**: Docker image never built; CI never run on a runner → **N/A**: stated in the PR; neither can be done from this machine. +See `codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md`. + ## Lessons Learned ### What Went Well @@ -133,3 +145,5 @@ No flaky tests encountered in `apps/whatsapp`. On native Windows, porch's whole- - Make the `whatsapp` CI job a required check on `develop` (needs a repo admin; the job has not yet run on a GitHub runner). - Occasional manual sweep: delete all but the newest `whatsapp` thread per user. - Remove the dead legacy `ORIGINS` and CORS middleware if no browser calls this service. +- Packaging clean-up: move `pytest` to the `dev` extra, align `requires-python` and ruff `target-version` with the 3.13 image, and move `await request.json()` inside the webhook's try block (a signature-valid non-JSON body currently returns 500). +- A real deployment needs the real `apps/auth` `/whatsapp` route: the mocked sign-in cannot serve real users outside `local` (fake tokens are rejected by `apps/api`, and a shared real token is refused by the settings). From f852ce9aa1815b7436cb807e8d9f8964800f611f Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:58:28 +0300 Subject: [PATCH 050/247] chore(porch): 321 review re-iter (iter 2) --- .../321-rfc-whatsapp-support-in-the-mo/status.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 3b970b61..494042cd 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -27,8 +27,8 @@ gates: status: pending verify-approval: status: pending -iteration: 1 -build_complete: true +iteration: 2 +build_complete: false history: - iteration: 1 plan_phase: import_service @@ -38,8 +38,15 @@ history: verdict: REQUEST_CHANGES file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt + - iteration: 1 + build_output: '' + reviews: + - model: claude + verdict: REQUEST_CHANGES + file: >- + D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-review-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T06:47:00.031Z' +updated_at: '2026-10-27T06:58:28.797Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From 9892b748fb05cb5cdc12e4d634dea5f2ddff2490 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 09:58:56 +0300 Subject: [PATCH 051/247] chore(porch): 321 review build-complete --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 494042cd..20313444 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -28,7 +28,7 @@ gates: verify-approval: status: pending iteration: 2 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: import_service @@ -46,7 +46,7 @@ history: file: >- D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-review-iter1-claude.txt started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T06:58:28.797Z' +updated_at: '2026-10-27T06:58:56.617Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From e304861f95832b5c5f469675d792f22fd119f262 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 10:04:42 +0300 Subject: [PATCH 052/247] [Spec 321] fix: address PR review iteration 2 (no apps/api error body in logs, staging filter on unsupported types) --- apps/whatsapp/src/ansari_whatsapp/app/main.py | 11 ++- .../services/ansari_client_real.py | 5 +- .../utils/whatsapp_webhook_utils.py | 2 +- apps/whatsapp/tests/test_bsuid_auth_flow.py | 18 +++++ .../tests/test_environment_behaviour.py | 31 +++++++++ .../321-review-iter2-claude.txt | 67 +++++++++++++++++++ .../321-review-iter2-rebuttals.md | 0 .../321-rfc-whatsapp-support-in-the-mo.md | 12 +++- 8 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-claude.txt create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md diff --git a/apps/whatsapp/src/ansari_whatsapp/app/main.py b/apps/whatsapp/src/ansari_whatsapp/app/main.py index fc7233ec..9d50e1f4 100644 --- a/apps/whatsapp/src/ansari_whatsapp/app/main.py +++ b/apps/whatsapp/src/ansari_whatsapp/app/main.py @@ -219,7 +219,7 @@ async def main_webhook( - Status message: Returns 200 with status acknowledgment - Maintenance mode: Sends maintenance message and returns 200 - Staging filter: Returns 200 if message prefixed with "!d" in staging - - Old message: Returns 200 after notifying user + - Old message: Ignored (nothing is sent to the user) - Authentication failure: Returns 500 error response - Unsupported media: Sends unsupported message and returns 200 @@ -351,7 +351,12 @@ async def main_webhook( # and not for the staging server. # This is done by prefixing the message with "!d " (e.g., "!d what is ansari?") # NOTE: Obviously, this temp. solution will be removed when we get a dedicated testing number for staging testing. - if get_settings().DEPLOYMENT_TYPE == "staging" and incoming_msg_body.get("body", "").startswith("!d "): + # (An unsupported message type arrives as a list, not a dict, so only look inside a dict.) + if ( + get_settings().DEPLOYMENT_TYPE == "staging" + and isinstance(incoming_msg_body, dict) + and incoming_msg_body.get("body", "").startswith("!d ") + ): logger.debug("Incoming message is meant for a dev who's testing locally now, so will not process it in staging...") return create_response_for_meta( success=False, message="Message filtered for local development", status_code=202, error_code="DEV_FILTER" @@ -361,7 +366,7 @@ async def main_webhook( # If so, send a message to the user and return if is_message_too_old(message_unix_time): return create_response_for_meta( - success=False, message="Message too old, notified user", status_code=422, error_code="MESSAGE_TOO_OLD" + success=False, message="Message too old, ignored", status_code=422, error_code="MESSAGE_TOO_OLD" ) # Exchange the sender's BSUID for a JWT with apps/auth diff --git a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py index fdcd493d..2c7962bb 100644 --- a/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py +++ b/apps/whatsapp/src/ansari_whatsapp/services/ansari_client_real.py @@ -187,9 +187,8 @@ async def process_message(self, token: str, thread_id: str, message: str) -> str # Use stream=True to receive the response as a stream async with self.client.stream("POST", url, json=data, headers=self._auth(token)) as response: if response.status_code != 200: - error_detail = await response.aread() - logger.error(f"Error from backend API: {error_detail}") - raise MessageProcessingError(f"Backend returned HTTP {response.status_code}: {error_detail}") + logger.error(f"Error from backend API: HTTP {response.status_code}") + raise MessageProcessingError(f"Backend returned HTTP {response.status_code}") # Accumulate the answer from the `text` events full_response = "" diff --git a/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py b/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py index 1ed812c8..5d061be3 100644 --- a/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py +++ b/apps/whatsapp/src/ansari_whatsapp/utils/whatsapp_webhook_utils.py @@ -185,7 +185,7 @@ async def parse_meta_payload( raise Exception(error_msg) if "phone_number_id" not in value["metadata"]: - error_msg = f"Missing phone_number_id in webhook payload metadata:\n{value['metadata']}" + error_msg = f"Missing phone_number_id in webhook payload metadata:\n{_redact_for_logging(value['metadata'])}" logger.error(error_msg) raise Exception(error_msg) diff --git a/apps/whatsapp/tests/test_bsuid_auth_flow.py b/apps/whatsapp/tests/test_bsuid_auth_flow.py index b1266580..0eef5f35 100644 --- a/apps/whatsapp/tests/test_bsuid_auth_flow.py +++ b/apps/whatsapp/tests/test_bsuid_auth_flow.py @@ -268,6 +268,24 @@ def test_ansari_client_malformed_event_is_a_failure(monkeypatch): asyncio.run(AnsariClientReal().process_message("jwt", "t1", "hi")) +def test_ansari_client_error_body_never_reaches_logs_or_the_raised_error(monkeypatch): + from loguru import logger + + body = '{"detail":"echo of the user: a-very-distinctive-question"}' + _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", lambda r: httpx.Response(400, text=body)) + captured = [] + sink_id = logger.add(lambda m: captured.append(str(m)), level="DEBUG") + try: + with pytest.raises(MessageProcessingError) as raised: + asyncio.run(AnsariClientReal().process_message("jwt", "t1", "hi")) + finally: + logger.remove(sink_id) + + assert "400" in str(raised.value) + assert "a-very-distinctive-question" not in str(raised.value) + assert "a-very-distinctive-question" not in "\n".join(captured) + + def test_ansari_client_error_status_raises(monkeypatch): _use_transport(monkeypatch, "ansari_whatsapp.services.ansari_client_real", lambda r: httpx.Response(500, text="boom")) diff --git a/apps/whatsapp/tests/test_environment_behaviour.py b/apps/whatsapp/tests/test_environment_behaviour.py index 00860a26..648f48c1 100644 --- a/apps/whatsapp/tests/test_environment_behaviour.py +++ b/apps/whatsapp/tests/test_environment_behaviour.py @@ -128,6 +128,37 @@ def test_staging_processes_messages_without_the_dev_prefix(monkeypatch): assert chat_calls == ["what is ansari?"] +def test_staging_handles_an_unsupported_message_type_without_crashing(monkeypatch): + """Meta sends an unsupported type (video note, poll...) as an `errors` list, not a dict with a body.""" + monkeypatch.setattr(get_settings(), "DEPLOYMENT_TYPE", "staging") + chat_calls = _spy_on_chat_calls(monkeypatch) + settings = get_settings() + value = { + "metadata": { + "phone_number_id": settings.META_BUSINESS_PHONE_NUMBER_ID.get_secret_value(), + "display_phone_number": "+1234567890", + }, + "messages": [ + { + "from_user_id": BSUID, + "id": "wamid.TEST", + "timestamp": str(int(time.time())), + "type": "unsupported", + "errors": [{"code": 131051, "title": "Message type is not supported"}], + } + ], + } + payload = {"object": "whatsapp_business_account", "entry": [{"changes": [{"value": value}]}]} + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + secret = settings.META_ANSARI_APP_SECRET.get_secret_value().encode("utf-8") + signature = hmac.new(secret, body, hashlib.sha256).hexdigest() + + response = client.post("/whatsapp/v2", content=body, headers={"X-Hub-Signature-256": f"sha256={signature}"}) + + assert response.json()["error_code"] == "UNSUPPORTED_MESSAGE_TYPE" + assert chat_calls == [] + + def test_local_processes_messages_with_the_dev_prefix(monkeypatch): monkeypatch.setattr(get_settings(), "DEPLOYMENT_TYPE", "local") chat_calls = _spy_on_chat_calls(monkeypatch) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-claude.txt b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-claude.txt new file mode 100644 index 00000000..ef921730 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-claude.txt @@ -0,0 +1,67 @@ +I'll start by reading the diff file and then examining the actual source files.The diff file is empty (0 bytes), so I'll read the actual files from disk as my review instructions require.Fixes #1 confirmed in config.py. Now checking the client implementations for fixes #2, #3, #4.All four iteration-1 fixes verified in code. Now checking cross-service contracts and looking for new issues.The CI job has no `env:` block, yet `WhatsAppSettings` has required fields and `whatsapp_webhook_utils.py` calls `get_settings()` at **module import time**. Let me check how tests bootstrap env.The review document is unusually thorough and honest. Now let me verify the new tests actually exist and are non-vacuous.I have verified all four iteration-1 fixes in code and confirmed each has a non-vacuous test. Here is my assessment. + +## Verification of iteration-1 fixes + +| # | Finding | Status | +|---|---|---| +| 1 | `MOCK_AUTH_TOKEN` outside `local` | **Fixed** — `config.py:107-114` model validator; test at `test_bsuid_auth_flow.py:135` parametrized over local/staging/production, plus an empty-value test. Asserts the specific `ValueError` match, so it can't pass vacuously. | +| 2 | `_resolve_thread` deleting non-WhatsApp threads | **Fixed** — `ansari_client_real.py:149` filters `t.get("source") == "whatsapp"`; `test_thread_lifecycle.py:167` proves a *newer* `web` thread is skipped in favour of an older `whatsapp` one, and that a web-only user reads as having no thread. | +| 3 | Meta error body in logs | **Fixed** — `meta_api_service_real.py:65,141` log only `status_code`; `test_meta_sender_logging.py` asserts `"400" in log_text` **and** BSUID/body absent, so it fails both if the body returns and if nothing is logged at all. | +| 4 | Dead commented debug block | **Fixed** — removed. | + +Branch is 0 commits behind `develop`, 49 ahead, commits follow `[Spec 321][Phase: X]`, review document is present and unusually honest (it lists the 4000-char splitter as explicitly untested rather than claiming coverage). + +## New findings (none blocking) + +**1. The raw-error-body fix was applied to the Meta sender but not to the apps/api client.** `ansari_client_real.py:190-192`: + +```python +error_detail = await response.aread() +logger.error(f"Error from backend API: {error_detail}") +raise MessageProcessingError(f"Backend returned HTTP {response.status_code}: {error_detail}") +``` + +That exception reaches `_capture(e, "message_processing_failure")` in `whatsapp_conversation_manager.py:286`, so the body goes to **Sentry** as well as the logs. Today it leaks nothing — apps/api's error bodies are controlled (`createErrorResponse` with zod issue messages, `'Thread not found'`), and never contain a BSUID. But it's the same pattern you just removed from the Meta sender, and it contradicts apps/api's own stated convention ("No user content in logs or Sentry"). Per your own hot lesson about fixing a defect everywhere rather than where you happen to be editing, this is the one remaining instance. + +**2. The staging `!d ` filter crashes on unsupported media types.** `main.py:354`: + +```python +if get_settings().DEPLOYMENT_TYPE == "staging" and incoming_msg_body.get("body", "").startswith("!d "): +``` + +For unsupported types, `parse_meta_payload` (line 222-223) sets `incoming_msg_type = "errors"` and `incoming_msg_body = incoming_msg["errors"]` — which, per your own code comment on line 220-221 and Meta's schema, is a **list**, not a dict. `.get` on a list raises `AttributeError`. This filter sits at line 354, *before* the `incoming_msg_type != "text"` guard at line 379, so in staging a video note / giphy / poll produces an unhandled `AttributeError` → the generic handler returns 500 → Meta retries for up to 24 hours. Production is unaffected (the `== "staging"` check short-circuits first). I confirmed by reading that no test exercises the `errors` path — `grep` for `errors|unsupported` across `tests/` returns only unrelated matches. Fix is `isinstance(incoming_msg_body, dict) and incoming_msg_body.get(...)`. + +**3. One unredacted payload interpolation.** `whatsapp_webhook_utils.py:188` is the only error path that formats payload data without `_redact_for_logging`. It contains only the business's own `display_phone_number`, not a user's, so nothing leaks — but it's inconsistent with lines 176, 183, 206 and 225, which all redact. + +**4. Nit.** `main.py:364` returns `"Message too old, notified user"` and the docstring (line 222) says "Returns 200 after notifying user", but no `background_tasks.add_task` sends anything. The string only reaches Meta, so this is cosmetic. + +## On deferred items + +`pytest` in runtime deps, `requires-python >= 3.10` vs the 3.13 image, and `await request.json()` outside the try block are all correctly deferred and listed in the review document's follow-ups — I'm not re-raising them. The CI job genuinely bootstraps without an `env:` block because `tests/conftest.py` sets the required settings via `os.environ.setdefault` before any module-level `get_settings()` runs; I traced that and it holds. + +--- +VERDICT: COMMENT +SUMMARY: All four iteration-1 findings are genuinely fixed with non-vacuous tests; two small one-liners remain — an incomplete propagation of the error-body fix to the apps/api client, and a staging-only crash when the `!d` filter meets a list-shaped `errors` payload. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `ansari_client_real.py:190-192` logs and Sentry-reports the raw apps/api error body — the same pattern removed from the Meta sender in this iteration. No leak today (apps/api bodies are controlled), but inconsistent; log only the status code. +- `main.py:354` calls `.get()` on `incoming_msg_body`, which is a **list** for Meta's unsupported-type payloads (`incoming_msg["errors"]`). Staging-only `AttributeError` → 500 → Meta retry loop. No test covers the `errors` path. Guard with `isinstance(..., dict)`. +- `whatsapp_webhook_utils.py:188` is the only payload interpolation that skips `_redact_for_logging` (business metadata only, so no user data leaks). +- Nit: `main.py:364` claims "notified user" but schedules no notification task. + +PR_SUMMARY: | + ## Summary + Ports the standalone Python WhatsApp service into the monorepo as `apps/whatsapp`. Users are identified solely by their Meta business-scoped user ID (BSUID) — never a phone number — which the service exchanges with `apps/auth` for a short-lived JWT, then uses to drive `apps/api`'s `/chat` SSE stream. Each user keeps exactly one thread, replaced after 3 hours of silence. No `apps/api` or `apps/auth` code changed. + + ## Key Changes + - `apps/whatsapp`: BSUID identity, `apps/auth` token exchange, `apps/api` `/chat` event-stream client, one-thread-per-user lifecycle scoped to `source: 'whatsapp'`, `WA_MAINTENANCE_MODE`, the staging `!d ` filter, Railway-style single-line JSON logs carrying only `request_id`, Sentry capture for thread and sign-in failures. + - Privacy by construction: webhook payloads are structurally redacted before logging (`_redact_for_logging`), Meta error bodies are never logged, and a settings-level guard refuses `MOCK_AUTH_TOKEN` unless `DEPLOYMENT_TYPE=local` so a shared token can never merge users into one account. + - `apps/whatsapp/Dockerfile` (repo-root build context, uv pinned to the lockfile's revision), `railway.toml` as a reviewable record, `.python-version` pinned to 3.13. + - `.github/workflows/ci.yml`: a `whatsapp` job running ruff lint, format check and pytest. + + ## Test Plan + - `cd apps/whatsapp && uv sync --frozen --extra dev && uv run ruff check . && uv run ruff format --check . && uv run pytest tests/ -v` — 77 tests, no external services required (`tests/conftest.py` supplies mock-mode env). + - Confirm the first CI run of the `whatsapp` job goes green on a GitHub runner, then ask a repo admin to make it a required check on `develop`. + - Before any non-local deploy: implement the `apps/auth` `POST /api/whatsapp/session` route (header `X-Whatsapp-Api-Key`, body `{bsuid}`, response `{token}`) and switch off the mocked sign-in — a mocked sign-in cannot serve real users outside `local`. + - Still unverified and tracked as follow-ups: a real phone's webhook reaching the new service (blocked on a Meta test number), the Docker image build, and the 4000-character answer splitter. \ No newline at end of file diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md new file mode 100644 index 00000000..e69de29b diff --git a/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md b/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md index 1be0dddc..92a5c9ca 100644 --- a/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md +++ b/codev/reviews/321-rfc-whatsapp-support-in-the-mo.md @@ -2,7 +2,7 @@ ## Summary -The legacy Python WhatsApp service now lives in the monorepo as `apps/whatsapp`. It identifies users by their business-scoped user ID (BSUID), gets a JWT for them from `apps/auth`, and talks to `apps/api`, keeping one thread per user that is replaced after 3 hours of silence. Four phases (import, BSUID/JWT, thread lifecycle, environments and docs) were built and reviewed; 77 tests pass. It has run against a real `apps/api` and `apps/auth`, and Meta accepted one real send to a BSUID, but a real phone's message has never reached the new service, the Docker image has never been built, and the `apps/auth` `/whatsapp` route does not exist yet (the sign-in is mocked by owner decision). +The legacy Python WhatsApp service now lives in the monorepo as `apps/whatsapp`. It identifies users by their business-scoped user ID (BSUID), gets a JWT for them from `apps/auth`, and talks to `apps/api`, keeping one thread per user that is replaced after 3 hours of silence. Four phases (import, BSUID/JWT, thread lifecycle, environments and docs) were built and reviewed; 79 tests pass. It has run against a real `apps/api` and `apps/auth`, and Meta accepted one real send to a BSUID, but a real phone's message has never reached the new service, the Docker image has never been built, and the `apps/auth` `/whatsapp` route does not exist yet (the sign-in is mocked by owner decision). ## Spec Compliance @@ -96,6 +96,16 @@ Only Claude was consulted (a decision for this environment, since the other mode - **Concern**: Docker image never built; CI never run on a runner → **N/A**: stated in the PR; neither can be done from this machine. See `codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter1-rebuttals.md`. +### PR review (PR #327, Round 2) , COMMENT + +#### Claude +- **Concern**: all four round-1 findings → **Verified fixed** by the reviewer, each with a non-vacuous test. +- **Concern**: the apps/api client still logged and reported the raw error body → **Addressed**: only the status code; tested, fails with the body restored. +- **Concern**: the staging `!d ` filter crashed on an unsupported message type (list-shaped body), giving a 500 and a Meta retry loop in staging only → **Addressed**: guarded with an `isinstance` check; tested, fails without it. +- **Concern**: one payload interpolation skipped redaction → **Addressed**. +- **Concern**: "Message too old, notified user" while nothing notifies the user → **Addressed**: text and docstring corrected. +See `codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md`. + ## Lessons Learned ### What Went Well From 6a54d228efd5ab3ece7869941cdac30560406e4c Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 10:05:15 +0300 Subject: [PATCH 053/247] [Spec 321] docs: iteration 2 rebuttal and review context --- .../321-review-iter2-context.md | 46 +++++++++++++++++++ .../321-review-iter2-rebuttals.md | 10 ++++ 2 files changed, 56 insertions(+) create mode 100644 codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-context.md diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-context.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-context.md new file mode 100644 index 00000000..5756a354 --- /dev/null +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-context.md @@ -0,0 +1,46 @@ +### Iteration 1 Reviews +- claude: REQUEST_CHANGES — Well-executed port with honest docs and verified cross-service contracts, but the mocked sign-in can silently merge all WhatsApp users into one account if deployed as planned, and thread deletion isn't scoped by source. + +### Builder Response to Iteration 1 +# Rebuttal: review phase (PR #327), iteration 1 + +Review: `321-review-iter1-claude.txt` (REQUEST_CHANGES). The four code findings are accepted and fixed; nothing is rebutted. Each new test was shown to fail when its fix is undone. + +## Must fix + +**1. Nothing stopped `MOCK_AUTH_CLIENT=True` with `MOCK_AUTH_TOKEN` outside `local`. Accepted, fixed.** +`WhatsAppSettings` now refuses to load when `MOCK_AUTH_TOKEN` is set and `DEPLOYMENT_TYPE` is not `local`, so the service will not start in staging or production with it. Tests: local allows it, staging and production refuse it, and an empty value is fine anywhere. They fail (2 tests) when the guard is disabled. +One consequence the review's premise misses: a mocked sign-in without `MOCK_AUTH_TOKEN` hands out fake tokens that a real `apps/api` rejects, so a mocked sign-in cannot serve real users anywhere. A deployment against a real `apps/api` needs the real `apps/auth` `/whatsapp` route. The README, `.env.example` and the PR description now say so. + +**2. `_resolve_thread` could delete a thread that is not a WhatsApp thread. Accepted, fixed.** +`get_last_thread_info` now considers only threads whose `source` is `whatsapp` (the list endpoint returns `source`). Test: a newer `web` thread is ignored, and a user whose only thread is `web` counts as having none. It fails when the filter is removed. The existing real-client fixtures now include `source`, as the real endpoint does. + +**3. Meta's raw error body was logged, against the README's "no phone numbers or BSUIDs in logs". Accepted, fixed.** +Both send paths now log only the status code. Test: a failing send (the mock error body echoes a BSUID) must log the status but neither the BSUID nor the body, for the message and typing-indicator calls. It fails when the body logging is put back. + +**4. Dead commented-out debug block. Accepted, removed** from `ansari_client_real.py`. + +## Also fixed (nits) + +- `verification_webhook` was annotated `-> str | None` but returns `HTMLResponse`; corrected. + +## Not changed, with reasons + +- **`pytest` in the runtime dependencies, and `requires-python`/ruff `py310` against a 3.13 image.** Both are imported legacy packaging. Moving `pytest` to the `dev` extra changes the lockfile and the Docker install; narrowing the Python range changes supported versions. Left for a follow-up and listed in the review document; the suite passes on 3.13. +- **`await request.json()` outside the try block (a signature-valid non-JSON body returns 500).** Legacy behaviour; Meta sends JSON and the signature is checked first. Listed as a follow-up. +- **Docker image never built; CI job never run on a runner.** Unchanged and stated in the PR. Neither can be done from this machine (no Docker daemon); the first PR run is the CI job's first real test. + +## Review's own caveat on counts + +The review counted 57 `def test_` functions against "70 tests"; parametrized cases account for the difference. After these changes the suite is 77 tests. + +Verification: ruff clean, format clean, 77 passed. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md index e69de29b..32c326dc 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/321-review-iter2-rebuttals.md @@ -0,0 +1,10 @@ +# Rebuttal: review phase (PR #327), iteration 2 + +Review: `321-review-iter2-claude.txt` (COMMENT). All four iteration-1 fixes were confirmed. The reviewer's four new findings are accepted and fixed; nothing is rebutted. Each new test was shown to fail when its fix is undone. + +1. **The apps/api error body was logged and sent to Sentry. Accepted, fixed.** `process_message` now logs and raises with only the HTTP status code. Test: a failing `/chat` (the mock body echoes user text) must raise an error that names the status but not the body, and log neither. It fails when the body is put back into the raised error. +2. **The staging `!d ` filter crashed on unsupported message types. Accepted, fixed, and a good catch.** Meta sends an unsupported type as an `errors` list, so `.get` on it raised in staging only, giving a 500 and a Meta retry loop. The filter now looks inside the body only when it is a dict. Test: a staging webhook with an `errors` list gets `UNSUPPORTED_MESSAGE_TYPE` and no chat call. It fails when the guard is removed. +3. **One unredacted payload interpolation. Accepted, fixed.** The missing-`phone_number_id` message now goes through `_redact_for_logging` like the others. No test: it only formats business metadata and nothing reaches it in normal flows; the change is one line. +4. **`"Message too old, notified user"` while nothing notifies the user. Accepted, fixed.** The response text and the docstring now say the message is ignored. + +Verification: ruff clean, format clean, 79 passed. From ee5d4909dd6f4a97de20d5f978d2432dfba77820 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 10:05:18 +0300 Subject: [PATCH 054/247] chore(porch): 321 pr gate-requested --- .../status.yaml | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 20313444..94d012a7 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -25,28 +25,14 @@ gates: approved_at: '2026-10-25T08:16:16.104Z' pr: status: pending + requested_at: '2026-10-27T07:05:18.575Z' verify-approval: status: pending -iteration: 2 -build_complete: true -history: - - iteration: 1 - plan_phase: import_service - build_output: '' - reviews: - - model: claude - verdict: REQUEST_CHANGES - file: >- - D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-import_service-iter1-claude.txt - - iteration: 1 - build_output: '' - reviews: - - model: claude - verdict: REQUEST_CHANGES - file: >- - D:\CS\projects\ansari-projects\ansari\.builders\spir-321\codev\projects\321-rfc-whatsapp-support-in-the-mo\321-review-iter1-claude.txt +iteration: 1 +build_complete: false +history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T06:58:56.617Z' +updated_at: '2026-10-27T07:05:18.576Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -66,3 +52,4 @@ context_refreshes: - boundary: enter:review at: '2026-10-27T06:09:40.030Z' acknowledged_at: '2026-10-27T06:22:14.280Z' +pr_ready_for_human: true From f8bd0ebf3e2fbd559dabf56e4d4dddb7e2ddad1e Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 10:46:54 +0300 Subject: [PATCH 055/247] chore(porch): 321 pr gate-approved --- .../321-rfc-whatsapp-support-in-the-mo/status.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 94d012a7..70a823ac 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -24,15 +24,16 @@ gates: status: approved approved_at: '2026-10-25T08:16:16.104Z' pr: - status: pending + status: approved requested_at: '2026-10-27T07:05:18.575Z' + approved_at: '2026-10-27T07:46:54.822Z' verify-approval: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T07:05:18.576Z' +updated_at: '2026-10-27T07:46:54.823Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' @@ -52,4 +53,4 @@ context_refreshes: - boundary: enter:review at: '2026-10-27T06:09:40.030Z' acknowledged_at: '2026-10-27T06:22:14.280Z' -pr_ready_for_human: true +pr_ready_for_human: false From 11a15e755e54b3ac7aa55ff3fb0a5d910beea8c7 Mon Sep 17 00:00:00 2001 From: OdyAsh Date: Tue, 27 Oct 2026 10:47:04 +0300 Subject: [PATCH 056/247] chore(porch): 321 verify phase-transition --- codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml index 70a823ac..44e46773 100644 --- a/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml +++ b/codev/projects/321-rfc-whatsapp-support-in-the-mo/status.yaml @@ -1,7 +1,7 @@ id: '321' title: rfc-whatsapp-support-in-the-mo protocol: spir -phase: review +phase: verify plan_phases: - id: import_service title: Import the Python service as is @@ -33,7 +33,7 @@ iteration: 1 build_complete: false history: [] started_at: '2026-10-25T06:51:50.273Z' -updated_at: '2026-10-27T07:46:54.823Z' +updated_at: '2026-10-27T07:47:04.774Z' context_refreshes: - boundary: enter:plan at: '2026-10-25T08:08:13.901Z' From 2cb863aeb4cba1fffbc5750b8fc151452a70fcf9 Mon Sep 17 00:00:00 2001 From: macbook pro Date: Tue, 27 Oct 2026 11:07:29 +0100 Subject: [PATCH 057/247] [Plan 324] Add implementation plan for native Google and Apple authentication Defines the 5-phase implementation plan covering backend Better Auth ID token verification, mobile native module dependencies (expo-apple-authentication, react-native-nitro-google-signin), client auth layer, AuthSheet UI wiring, and automated verification. --- codev/plans/324-native-google-apple-auth.md | 254 ++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 codev/plans/324-native-google-apple-auth.md diff --git a/codev/plans/324-native-google-apple-auth.md b/codev/plans/324-native-google-apple-auth.md new file mode 100644 index 00000000..3d83c112 --- /dev/null +++ b/codev/plans/324-native-google-apple-auth.md @@ -0,0 +1,254 @@ +# Plan: Native Google and Apple Authentication (Mobile & Web) + +**Specification**: [codev/specs/324-native-google-apple-auth.md](../specs/324-native-google-apple-auth.md) + +--- + +## Executive Summary + +This plan implements the ratified specification for native Google and Apple authentication across mobile (`apps/frontend`) and web, backed by `packages/auth` and `apps/auth`. + +Key architectural principles: +- **Native Modules on Mobile (Non-Negotiable)**: On native iOS and Android, authentication uses native OS dialogs. Apple Sign-In uses `expo-apple-authentication` (iOS). Google Sign-In uses `react-native-nitro-google-signin` (powered by Nitro Modules / JSI, integrating Android Credential Manager and native iOS Google Sign-In SDK). Web redirects (`expo-web-browser` / Custom Tabs) on native mobile are prohibited. +- **Web Social Auth**: Standard desktop and mobile web browsers use Better Auth's standard web redirect flow (`authClient.signIn.social`). +- **Two-Token Protocol Parity**: Third-party logins produce a Better Auth `sessionToken` on `apps/auth`. The frontend exchanges this for a 15-minute EdDSA JWT via `GET /api/auth/token` for stateless `apps/api` access, preserving 401 single-flight token refresh. +- **Zero Schema Migrations**: The `auth.account` table in `packages/auth/src/schema.ts` already contains all necessary columns (`provider_id`, `account_id`, `id_token`, `access_token`, etc.) with `unique(user_id, provider_id)`. +- **Account Linking & Collision Safety**: Verified emails automatically link provider accounts under existing `auth.users` rows without creating duplicates or disrupting password authentication. + +--- + +## Phases (Machine Readable) + +```json +{ + "phases": [ + { + "id": "phase_1", + "title": "Backend environment, Better Auth social providers, and native ID token verification" + }, + { + "id": "phase_2", + "title": "Frontend native module dependencies and EAS prebuild configuration" + }, + { + "id": "phase_3", + "title": "Frontend auth client layer and native token exchange" + }, + { + "id": "phase_4", + "title": "AuthSheet UI wiring, cancellation UX, and error handling" + }, + { + "id": "phase_5", + "title": "Automated tests, contract verification, and workspace verification" + } + ] +} +``` + +--- + +## Phase Breakdown + +### Phase 1: Backend Environment, Better Auth Social Providers, and Native ID Token Verification + +**Dependencies**: None + +#### Objective +Enable Google and Apple social providers in `packages/auth` with native ID token verification, multi-audience support (iOS bundle ID `chat.ansari.app` vs web client ID), and account linking. + +#### Files to Create / Modify +- `packages/auth/src/env.ts`: + - Add optional OAuth schema keys to `envSchema`: + - `GOOGLE_CLIENT_ID`: Web Client ID (audience for Android and web ID tokens). + - `GOOGLE_CLIENT_SECRET`: Client secret for web OAuth flow. + - `GOOGLE_IOS_CLIENT_ID`: Optional client ID for iOS Google Sign-In if configured separately. + - `APPLE_CLIENT_ID`: Services ID for web sign-in. + - `APPLE_CLIENT_SECRET`: Generated client secret JWT or secret for web flow. + - `APPLE_BUNDLE_ID`: Defaults to `'chat.ansari.app'` (audience for iOS native Apple ID tokens). + - `APPLE_TEAM_ID` & `APPLE_KEY_ID`: For Apple web client secret generation if needed. +- `apps/auth/.env.example`: Document the new Google and Apple environment variables. +- `turbo.json`: Add new environment variables to `globalEnv`. +- `packages/auth/src/index.ts`: + - Configure `socialProviders` in `createAuth()`: + - `google`: `clientId: env.GOOGLE_CLIENT_ID`, `clientSecret: env.GOOGLE_CLIENT_SECRET`. Configure `idToken` verification or `verifyIdToken` override accepting audiences `[env.GOOGLE_CLIENT_ID, env.GOOGLE_IOS_CLIENT_ID]`. + - `apple`: `clientId: env.APPLE_CLIENT_ID`, `clientSecret: env.APPLE_CLIENT_SECRET`. Configure `idToken` verification accepting audiences `[env.APPLE_CLIENT_ID, env.APPLE_BUNDLE_ID]`. + - Ensure `POST /api/auth/sign-in/social` handles `{ provider, idToken: { token, nonce, user } }`: + - Validates token against Apple JWKS (`https://appleid.apple.com/auth/keys`) or Google certs (`https://www.googleapis.com/oauth2/v3/certs`). + - Maps claims (`email`, `emailVerified`, `name`). + - On first Apple sign-in, persists `name` (`firstName`, `lastName`) from `idToken.user.name`. + - Handles Apple Private Relay emails (`@privaterelay.appleid.com`). + - Handles account linking when `auth.users` row already exists with the same verified email. + - User creation hook (`databaseHooks.user.create.before`) records `registered_via` from `X-Ansari-Client` header. +- `packages/auth/tests/config/social-auth.test.ts` (new): + - Unit tests using PGlite verifying ID token signature verification, audience checking, account linking, and error codes (401 on bad/expired tokens). + +#### Deliverables +- [ ] `packages/auth/src/env.ts` validates optional Google and Apple OAuth credentials. +- [ ] `packages/auth/src/index.ts` configures `socialProviders` with native `idToken` verification and multiple audience support. +- [ ] First sign-in Apple name capture and Private Relay email persistence implemented. +- [ ] Account linking tested and verified in PGlite. + +#### Acceptance Criteria +- [ ] Unset OAuth env vars do not prevent service startup or break existing email/password auth. +- [ ] Valid Google ID token sent to `POST /api/auth/sign-in/social` returns 200 with `{ token, user }`. +- [ ] Valid Apple ID token with `aud: 'chat.ansari.app'` returns 200 with `{ token, user }`. +- [ ] Invalid/expired ID token returns 401 `INVALID_TOKEN`. +- [ ] Token with unknown audience returns 401 `INVALID_TOKEN`. + +--- + +### Phase 2: Frontend Native Module Dependencies and EAS Prebuild Configuration + +**Dependencies**: Phase 1 + +#### Objective +Install and configure `expo-apple-authentication` and `react-native-nitro-google-signin` in `apps/frontend`, updating `app.json` config plugins and native entitlements. + +#### Files to Create / Modify +- `apps/frontend/package.json`: + - Add `expo-apple-authentication` (`~57.0.x`). + - Add `react-native-nitro-google-signin` and peer dependency `react-native-nitro-modules`. +- `apps/frontend/app.json`: + - Add `"expo-apple-authentication"` to `expo.plugins`. + - Add iOS entitlement: `"ios.entitlements": { "com.apple.developer.applesignin": ["Default"] }` (or via config plugin). + - Add `"react-native-nitro-google-signin"` to `expo.plugins`. + - Add `googleServicesFile` entries under `android` (`./google-services.json`) and `ios` (`./GoogleService-Info.plist`), with fallback/conditional handling for local development where files are omitted. +- `apps/frontend/app.config.js` or `apps/frontend/plugins/` (if custom plugin logic needed for optional credentials). + +#### Deliverables +- [ ] `pnpm install` succeeds across the workspace. +- [ ] `npx expo config --type public` in `apps/frontend` resolves without error. +- [ ] Web export (`pnpm --filter ansari-frontend build:web`) continues to compile cleanly. + +#### Acceptance Criteria +- [ ] Workspace typecheck (`pnpm typecheck`) and lint (`pnpm lint`) pass with 0 errors. +- [ ] Native module imports do not crash web bundling (`react-native-web` compatibility). + +--- + +### Phase 3: Frontend Auth Client Layer and Native Token Exchange + +**Dependencies**: Phase 1, Phase 2 + +#### Objective +Implement the native sign-in adapters for Apple and Google, and connect them to `lib/auth/api.ts` and `lib/auth/context.tsx` to preserve the Two-Token Protocol. + +#### Files to Create / Modify +- `apps/frontend/lib/auth/nativeApple.ts` & `apps/frontend/lib/auth/nativeApple.web.ts`: + - Platform-split native Apple helper: + - Native (`.ts` / `.native.ts`): checks `AppleAuthentication.isAvailableAsync()`, invokes `signInAsync({ requestedScopes: [FULL_NAME, EMAIL] })`, extracts `identityToken`, `fullName`, and calls auth API. + - Web (`.web.ts`): falls back to web social redirect. +- `apps/frontend/lib/auth/nativeGoogle.ts` & `apps/frontend/lib/auth/nativeGoogle.web.ts`: + - Platform-split native Google helper: + - Native: configures `GoogleOneTapSignIn.configure({ webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID })`, invokes `GoogleOneTapSignIn.signIn()`, extracts `idToken`, and calls auth API. + - Web: falls back to web social redirect. +- `apps/frontend/lib/auth/api.ts`: + - Add `signInWithIdToken(provider: 'apple' | 'google', idToken: string, user?: { name?: { firstName?: string; lastName?: string } })`: + - Sends `POST /api/auth/sign-in/social` with the Better Auth ID token payload. + - Returns `{ token: string, user: AuthUser }`. +- `apps/frontend/lib/auth/context.tsx`: + - Expose `signInWithApple()` and `signInWithGoogle()` on `AuthContext`. + - When provider returns `sessionToken`, invoke `startSession({ sessionToken, user })` which: + 1. Calls `authApi.mintJwt(sessionToken)` (`GET /api/auth/token`). + 2. Persists `accessToken` and `sessionToken` in `expo-secure-store` / `localStorage`. + 3. Clears React Query cache and sets user state. + +#### Deliverables +- [ ] Platform-split native Apple and Google auth services implemented. +- [ ] `lib/auth/api.ts` implements `signInWithIdToken`. +- [ ] `lib/auth/context.tsx` provides unified social sign-in methods adhering to Two-Token Protocol. + +#### Acceptance Criteria +- [ ] Calling `signInWithIdToken` against `apps/auth` establishes a valid session and mints an EdDSA JWT for `apps/api`. +- [ ] 401 token refresh functions identically to email/password sessions. + +--- + +### Phase 4: AuthSheet UI Wiring, Cancellation UX, and Error Handling + +**Dependencies**: Phase 3 + +#### Objective +Connect `ProviderButton` in `AuthSheet.tsx` to the native/web social flows, replacing the "unavailable" stub with real sign-in, smooth cancellation handling, and inline error reporting. + +#### Files to Create / Modify +- `apps/frontend/components/AuthSheet.tsx`: + - Remove `unavailable` local state stub and message. + - Wire Apple `onPress` to `handleAppleSignIn`. + - Wire Google `onPress` to `handleGoogleSignIn`. + - Handle user cancellation: + - Apple: ignore `ERR_REQUEST_CANCELED` (code 1001) without displaying an error. + - Google: ignore user cancellation without displaying an error. + - On network or verification failure, display inline error banner using existing error styling. + - On success, close the sheet via `closeAuthSheet()`. +- `apps/frontend/components/AuthSheet.test.tsx`: + - Update unit tests: remove tests expecting "not available yet" stub notice. + - Add tests verifying provider button click triggers `signInWithApple` / `signInWithGoogle`. + - Add tests verifying cancellation does not set error state. + - Add tests verifying backend error sets inline error message. + +#### Deliverables +- [ ] `AuthSheet.tsx` buttons wired to real authentication handlers. +- [ ] Cancellation UX leaves sheet open with no errors. +- [ ] Success UX dismisses sheet cleanly. +- [ ] Updated component test suite in `apps/frontend/components/AuthSheet.test.tsx`. + +#### Acceptance Criteria +- [ ] Pressing Apple on iOS launches native Apple dialog. +- [ ] Pressing Google launches native Google dialog via Credential Manager on Android and native SDK on iOS. +- [ ] Cancelling native dialog shows no error alert or toast. +- [ ] Component tests pass in `vitest`. + +--- + +### Phase 5: Automated Tests, Contract Verification, and Workspace Verification + +**Dependencies**: Phase 1, Phase 2, Phase 3, Phase 4 + +#### Objective +Execute full test suites across all packages, verify contract invariants, and update operator self-hosting documentation. + +#### Files to Create / Modify +- `packages/auth/tests/config/social-auth.test.ts`: + - Full end-to-end integration tests in PGlite: + - ID token verification $\to$ session creation $\to$ `GET /api/auth/token` JWT minting $\to$ JWKS validation. + - Account linking: user registered with email/password signs in with Google ID token sharing the same email; asserts single `auth.users` row and linked `auth.account` row. + - Negative tests: tampered JWT signature, expired token, mismatched audience, missing email claim. +- `docs/self-hosting.md`: + - Document setup steps for Google Cloud Console (OAuth Web Client ID, Android package name, SHA-1 fingerprint) and Apple Developer Portal (Services ID, Bundle ID, Key). +- Monorepo gate verification. + +#### Deliverables +- [ ] Complete automated test suite covering positive and negative cases. +- [ ] Documentation updated in `docs/self-hosting.md`. +- [ ] All workspace checks pass cleanly. + +#### Acceptance Criteria +- [ ] `pnpm --filter @ansari/auth test` passes (all PGlite tests green). +- [ ] `pnpm --filter ansari-frontend test` passes. +- [ ] `pnpm --filter ansari-frontend build:web` succeeds. +- [ ] Workspace-wide commands pass: `pnpm typecheck`, `pnpm lint`, `pnpm test`. + +--- + +## Verification Checklist + +| Layer | Check | Command | Target Outcome | +| :--- | :--- | :--- | :--- | +| **Auth Package** | PGlite social auth tests | `pnpm --filter @ansari/auth test` | All tests pass, zero regressions on email auth | +| **Frontend Unit** | AuthSheet & component tests | `pnpm --filter ansari-frontend test` | All tests pass | +| **Web Build** | Metro web export | `pnpm --filter ansari-frontend build:web` | Compiles `dist/` cleanly | +| **Workspace Lint** | ESLint across packages | `pnpm lint` | 0 errors | +| **Workspace Types** | TypeScript typecheck | `pnpm typecheck` | 0 errors | +| **Workspace Tests** | Full test suite | `pnpm test` | All suites green | + +--- + +## Rollback & Failure Modes + +- **Invalid ID Token**: Returns 401 `INVALID_TOKEN`. Client displays inline error: "Unable to verify sign-in credentials. Please try again." +- **Unverified Provider Email**: Fails closed with 401 `USER_EMAIL_NOT_FOUND`. Account is not created. +- **Provider Outage / Network Error**: Client catches network failure, preserves typed fields, and displays inline error notice. +- **Missing Google Play Services (Android)**: Handled gracefully by `react-native-nitro-google-signin`, surfacing an actionable error without crashing the app. +- **OAuth Credentials Unset on Server**: If self-hoster has not provided Google/Apple credentials, `socialProviders` is inactive; email/password authentication and guest mode continue to function normally. From 0696b98a714f0d42d456097d55372b0a0d78201a Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 15:31:56 +1100 Subject: [PATCH 058/247] chore(porch): 329 init spir --- .../status.yaml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/status.yaml diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml new file mode 100644 index 00000000..743679fa --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -0,0 +1,20 @@ +id: '329' +title: apps-auth-service-api-keys-bet +protocol: spir +phase: specify +plan_phases: [] +current_plan_phase: null +gates: + spec-approval: + status: pending + plan-approval: + status: pending + pr: + status: pending + verify-approval: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-28T04:31:56.766Z' +updated_at: '2026-10-28T04:31:56.767Z' From a95f43add5f0a2f5cd598c9eb9c333ad94694f84 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 15:36:56 +1100 Subject: [PATCH 059/247] [Spec 329] Initial specification draft --- .../329-apps-auth-service-api-keys-bet.md | 249 ++++++++++++++++++ codev/state/spir-329_thread.md | 14 + 2 files changed, 263 insertions(+) create mode 100644 codev/specs/329-apps-auth-service-api-keys-bet.md create mode 100644 codev/state/spir-329_thread.md diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md new file mode 100644 index 00000000..e882cdf5 --- /dev/null +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -0,0 +1,249 @@ +# Specification: Service API keys and the WhatsApp token exchange (apps/auth) + +## Problem Statement + +apps/auth knows only one kind of caller: a person who signs in. The only way into apps/api is a user JWT minted from a live Better Auth session. Nothing can act on behalf of a user who never signs in, and apps/auth has no way for a service to prove who it is. + +The WhatsApp service (spec 321, PR #327) needs exactly that. A WhatsApp user is identified only by Meta's business-scoped user id (BSUID). They never visit a sign-in page, yet their messages must reach apps/api as an ordinary user, with threads, limits and deletion, under one stable UUID. Spec 321 assumes an apps/auth endpoint that takes a BSUID and returns a JWT. The endpoint does not exist, and the builder has run nothing against a live apps/auth. #314 (MCP) needs the same service-identity primitive later. + +Two things are missing: + +1. **Service identity**: a credential a service holds, issued and revoked by an operator, hashed at rest, scoped per service and per action, with its own rate limit and daily budget. +2. **A delegation rule**: which users a given service may mint tokens for. For WhatsApp the rule is "only users reachable through a `whatsapp` account row, keyed by BSUID". A WhatsApp key must never yield a token for an email user, an admin, a system identity or another provider's user. + +Affected: the WhatsApp service and its users (no path into apps/api today), the operator (who will hold a new credential type), and the security model of apps/auth (a new credential, table and public route). + +## Current State + +- **Callers.** apps/auth serves Better Auth's router at `/api/auth/*`, the account endpoints at `/api/account/*` (bound to the caller's own JWT and a live `sid`), `/health` and a diag route. There is no service credential of any kind. The leaderboard uses a static shared secret on apps/api; MCP (`/v2/mcp-complete`) has none (#314). +- **Tokens.** apps/api accepts only apps/auth's EdDSA JWT (15 minutes, payload `{sub, sid}` plus `iss`, `aud`, `iat`, `exp`), verified against the public JWKS. On first use, `requireUser` → `resolveUser` fetches `GET /api/account/me` with that JWT, which requires the `sid` to name a live row in `auth.session`, and then inserts `public.users` under the same UUID. A bare signature with no session behind it is therefore useless: first use fails. +- **Reserved addresses.** `@system.ansari.chat` is reserved. Sign-up refuses it with the same 409 as a taken address; the reset sender skips it silently. The `user.create.before` database hook refuses it on every path, because spec 300 says the auth database never holds one (system identities live only as app rows found by `system_key`). apps/api's `resolveUser` also refuses a system-domain address. There is no second reserved domain. +- **Accounts.** `auth.account` carries `unique(user_id, provider_id)` (one credential row per user). There is no `unique(provider_id, account_id)`. Only the `credential` provider exists today (no social providers are configured). +- **Blocked paths.** `BLOCKED_AUTH_PATHS` (the admin plugin's whole surface, change-email, update-user, change-password) goes into `disabledPaths`, which better-auth matches exactly. apps/auth also blocks every case and prefix variant before the handler. Both are pinned by tests over `ADMIN_PATHS`. +- **Rate limits.** `ROUTER_RULES` are all `false`: apps/auth sees apps/api's egress IP on every legacy call, so per-IP rules there would throttle everyone together. Routes outside Better Auth's handler (the account routes) carry no per-IP rule at all; they have a per-user budget. +- **Operator scripts.** `grant-admin.ts` and `ban-user.ts` show an account, act on it, never print secrets, and run from the auth image or a checkout with the auth `DATABASE_URL`. Neither refuses to run outside the image. The issue's "same shape … refuse to run outside the auth image" describes a guard that does not exist yet. +- **`@better-auth/api-key` 1.7.7** (verified from the published package, peer `better-auth ^1.7.7`), the facts the design rests on: + - Its `apikey` model holds `configId` (default `"default"`), `name`, `start` (the first characters, stored by default), `referenceId` (the owning user's id; `references` defaults to `"user"`), `prefix`, `key` (the SHA-256 hash, base64url), `refillInterval`, `refillAmount`, `lastRefillAt`, `enabled`, `rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`, `requestCount`, `remaining`, `lastRequest`, `expiresAt`, `createdAt`, `updatedAt`, `permissions` (JSON text), `metadata` (JSON text, honoured only with `enableMetadata: true`). The owner column is `referenceId`, not `userId` as older docs show. + - HTTP endpoints: `/api-key/create`, `/api-key/get`, `/api-key/list`, `/api-key/update`, `/api-key/delete`. `verifyApiKey` and `deleteAllExpiredApiKeys` are `serverOnly` and have no route. + - `enableSessionForAPIKeys` defaults to `false`. Only when it is true does a before-hook turn an `x-api-key` header into a session for every Better Auth route. + - `verifyApiKey` checks, in order: the hash is found; `enabled`; `expiresAt` (an expired key is **deleted** and refused); `permissions` (missing or insufficient is refused before any budget is spent); `remaining === 0` with no refill (the key is **deleted** and refused with 429 `USAGE_EXCEEDED`). Only then does it consume `remaining` and a rate-limit slot, atomically against the row. Errors are logged through better-auth's logger, with the error object only. + - Default key: 64 random letters plus the configured prefix. + +## Desired State + +### 1. Service identity + +- `packages/auth` carries `@better-auth/api-key` pinned to the core version (1.7.7). Its `apikey` table is written by hand in the existing `auth` schema style (uuid ids, `timestamptz`) and ships as migration `0001`, generated, reviewed as SQL and applied by hand at deploy. Better Auth never changes the database shape. +- **A service is one auth user**, created only by an operator script: an address under the non-interactive domain (Part 3), no credential, no account row, role `user`. One user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates. +- **Keys are issued, listed, rotated and revoked only by operator scripts**, which follow the existing scripts' shape: show the service account, act on its id, print nothing secret except the new key exactly once at creation. Each key has a per-service prefix, a name, `permissions`, `metadata` carrying the partner's client id, a per-key rate limit (`rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`), a daily budget (`remaining`, `refillAmount`, `refillInterval`) and an expiry. Rotate creates a second live key for the same service so the consumer can switch without downtime; revoke deletes one. The scripts refuse to run outside the auth image (Open Question 8). +- **No HTTP surface.** The five `/api-key/*` paths join `BLOCKED_AUTH_PATHS` and are blocked like the admin paths, in every case and prefix variant. `enableSessionForAPIKeys` stays `false`, so a key is never a session anywhere on the router. +- **Verification is `auth.api.verifyApiKey`, in-process**, with the route's required permission passed in. Permissions are per service and per action: `{ whatsapp: ['mint'] }` for the exchange, `{ mcp: ['complete'] }` reserved for #314. A key valid for one service fails at another's route. No verify route at the public origin is built here; #314 adds one. + +### 2. The WhatsApp token exchange: `POST /api/whatsapp/session` + +Mounted outside Better Auth's handler path, like the account routes, so no router per-IP rule sees it (the WhatsApp service calls from a Railway egress IP). `ROUTER_RULES` stays all `false`. The key's own rate limit and daily budget are the throttle. + +**Contract** (replaces the assumption in spec 321 and the `X-Whatsapp-Api-Key` header in #327): + +| | | +|---|---| +| Request | `Authorization: Bearer `; JSON body `{ "bsuid": "" }` | +| 200 | `{ "token": "" }`: the same EdDSA 15-minute `{sub, sid}` token apps/auth issues every client, signed from a live session of the BSUID's user | +| 400 | `{ "message": … }`: body not JSON, `bsuid` missing, not a string or not matching the agreed BSUID pattern | +| 401 | `{ "message": "Unauthorized" }`, byte-identical for a missing, malformed, unknown, revoked, disabled, expired, exhausted or wrong-permission key | +| 403 | the BSUID's user is banned (Open Question 6) | +| 429 | the key's per-window rate limit is exceeded (Open Question 5) | +| 500 | `{ "message": "Internal error" }` for anything else, including an auth-database outage (never 401, so the caller does not read an outage as a revoked key) | + +The key is checked before the body (Open Question 7), so an unauthenticated caller learns nothing about the body rules. + +**Per request:** + +1. Verify the key with permission `whatsapp: mint`. +2. Validate the BSUID against the agreed pattern. A phone number is never accepted, never stored and never logged. +3. Look up `auth.account` where `provider_id = 'whatsapp'` and `account_id = `. The lookup never goes through email. +4. No row: create the user (a non-interactive-domain address that contains neither the BSUID nor any phone number; no credential; `registered_via = 'whatsapp'`; a derived display name) and the account row `{ providerId: 'whatsapp', accountId: , userId }` in **one transaction**. A new `unique(provider_id, account_id)` guarantees a BSUID maps to exactly one user under concurrency; the request that loses the race rolls back its user and uses the winner's. +5. The user is banned: delete their sessions, refuse (step 7 is never reached). +6. Reuse the user's live session when it outlives the JWT about to be minted (it expires more than one JWT lifetime from now); otherwise create one. One session per WhatsApp user, so sessions do not grow per message, and a token is never signed from a session that dies before the token does. +7. Sign the JWT from that session and return it. + +The WhatsApp service caches the token per BSUID for under 15 minutes and calls apps/api with it. apps/api resolves the user on first use exactly as for a web user (`GET /api/account/me` with the token, `public.users` under the same UUID). **apps/api gains no WhatsApp code** (spec 321 success criterion). + +### 3. A non-interactive address domain + +A second reserved domain (name: Open Question 1; working name `@no-login.ansari.chat`) for service users and WhatsApp users: + +- refused by sign-up with the same 409 as a taken address (no oracle), and by the reset-request sender with the same silent success as an unknown address (no mail attempt); +- never created by any Better Auth HTTP path: the `user.create.before` hook refuses it; +- created only by the exchange and by the service-user operator script, neither of which goes through a Better Auth HTTP path. + +A user in this domain has no password and no account row except (for WhatsApp users) the `whatsapp` row, so they cannot sign in anywhere. `GET /api/account/me` answers for them only with their own JWT and a live `sid`, as for everyone. The system domain keeps every rule it has today; the auth database still never holds a system-domain address. + +## Success Criteria + +**Service keys** +- [ ] `@better-auth/api-key` is pinned to exactly the installed `better-auth` version (1.7.7); core is not bumped. +- [ ] The `apikey` table is in the `auth` schema (uuid id, `timestamptz` columns, `reference_id` a uuid referencing `auth.users` with cascade delete), and migration `0001` is the reviewed `drizzle-kit generate` output. The schema parity tests cover the new table. +- [ ] Each of `/api-key/create`, `/api-key/get`, `/api-key/list`, `/api-key/update`, `/api-key/delete` returns the blocked response over HTTP, in every case and prefix variant, as the admin paths do. `/api-key/verify` has no route. +- [ ] `enableSessionForAPIKeys` is `false`. A test sends a valid key as `x-api-key` and as a bearer to `/api/auth/get-session` and `/api/auth/token` and gets no session and no token. +- [ ] A stored key row holds only the hash: the raw key appears nowhere in the database (including `start`) after creation. +- [ ] The create script prints the raw key exactly once. List, rotate and revoke print ids, names, prefixes, permissions, budgets and dates, never a key or a hash. +- [ ] Every script refuses to run outside the auth image, exiting non-zero before connecting to the database. +- [ ] Rotate leaves two live keys for the service, both verifying. Revoke makes the revoked key fail at the exchange with the 401 body on the next request. + +**Exchange: key states** (each returns the identical 401 body, and none creates a user, account or session) +- [ ] no `Authorization` header; non-Bearer scheme; empty bearer; +- [ ] unknown key; revoked (deleted) key; disabled key; expired key; +- [ ] key whose budget is exhausted (with and without refill configured; Open Question 5 decides whether the refill case is 401 or 429); +- [ ] key with `{ mcp: ['complete'] }` only; key with no permissions. + +**Exchange: identity** +- [ ] A new BSUID creates exactly one user, one `whatsapp` account row and one session, and returns a JWT that verifies against apps/auth's JWKS with the same `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, payload `{sub, sid}` naming that user and session. +- [ ] The same BSUID twice returns tokens with the same `sub` and the same `sid`. The session count for the user stays 1. +- [ ] A session that expires within one JWT lifetime is not reused; a new one is created. +- [ ] Two first requests for one BSUID racing produce one user and one account row, and both return a token for that user. The test exercises the conflict path itself, not just sequential calls. +- [ ] **Delegation boundary.** With a seeded admin, a seeded email user and a seeded user of another provider (`provider_id = 'google'`, `account_id` shaped like a BSUID): passing the admin's and email user's ids (and emails) as `bsuid` is refused with 400; passing the other provider's `account_id` as `bsuid` returns a token whose `sub` is a new WhatsApp user, never the seeded user. No exchange response ever carries the `sub` of a user without a `whatsapp` account row. +- [ ] A service user (which has no account row) is never reachable by any BSUID. +- [ ] A banned WhatsApp user is refused, and their sessions are deleted. A JWT minted before the ban then fails `GET /api/account/me` (dead `sid`). + +**Exchange: privacy and logging** +- [ ] A phone-number-shaped `bsuid` (E.164 with and without `+`, or digits only) is refused with 400 before any lookup or log. +- [ ] No created row (user email, name, session IP and user agent, account) contains the BSUID except `account.account_id`, and none contains a phone number. +- [ ] No log line, alert or error response from the exchange or the scripts contains a raw key, a key hash, a BSUID or a driver error object. Errors log `{name, code}` only. A test captures `console.*` and better-auth's logger across every key-state and failure case and asserts none of those values appears. + +**Reserved domain** +- [ ] Sign-up with a non-interactive-domain address (any case, surrounding spaces) returns the same 409 body as a taken address. +- [ ] A reset request for an existing non-interactive-domain user returns the same response as an unknown address, and the mailer is never called. +- [ ] The `user.create.before` hook refuses a non-interactive-domain address on every Better Auth path. The exchange and the service-user script create one. +- [ ] Sign-in with a non-interactive-domain user's address fails exactly as for an unknown address. + +**End to end** +- [ ] In the existing apps/api contract tests (which start a real apps/auth), a JWT from the exchange passes `requireUser`: JWKS verification, first-use `GET /api/account/me`, and a `public.users` row created under the same UUID with `registered_via = 'whatsapp'`. +- [ ] apps/api's source gains no WhatsApp-specific code. +- [ ] Staging: the WhatsApp builder's smoke test (a phone-visible and a phone-hidden user) passes against staging apps/auth and apps/api with a key issued by the create script. + +**Unchanged** +- [ ] `ROUTER_RULES` are all `false`; no per-IP rule applies to the exchange. +- [ ] Every existing apps/auth, packages/auth and apps/api test passes unedited, except tests that enumerate the blocked paths or the schema, which gain the new entries. +- [ ] No new environment variable, or, if one appears, it is added to `turbo.json` `globalEnv` by the four-way derivation and reviewed by main. + +## Constraints + +- **Risk tier High** (auth model, new table, new credential type): 3-way consultation on spec, plan and PR. The owner's decisions are presented to them for approval. +- **Pin, do not bump.** The plugin's newest release requires core 1.7.14; bumping core is a separate, joint change. +- **Schema discipline.** `packages/auth/src/schema.ts` is hand-written; the table is added there in its style. `drizzle-kit generate` → reviewed SQL → applied by a human with `psql -v ON_ERROR_STOP=1 --single-transaction` against the auth database. Never `db:push`. Main numbers migrations: the journal entry is coordinated with `architect:main` before it is committed. The deploy order (migration before code) applies. +- **Two databases.** Everything here is in the auth database and apps/auth. apps/api changes only in tests. No query crosses the two. +- **Tokens.** The exchange's JWT must be indistinguishable from any other apps/auth JWT, and must be signed from a real, live session (apps/api's first use needs a live `sid`). +- **Client IP.** No per-IP rule on the exchange; `ROUTER_RULES` stays all `false`. +- **No secret leaves its moment.** Never log a raw key, a raw driver error, a BSUID or a phone number. The raw key exists outside the creating process only in the create script's single output. +- **Sequencing.** develop is frozen for apps/auth and packages/auth until the spec 300 production cutover and its contract PR land. Spec, plan and implementation proceed; the PR does not merge before then and expects a rebase. The migration is `0001` in `packages/auth/drizzle`, unrelated to the contract PR's app-database migration. +- **Process.** Explicit `git add` only; no AI attribution; commits `[Spec 329][Phase: x] type: message`; `.env` files are never read. + +## Assumptions + +- The spec 300 cutover lands without changing `createAuth`'s plugin list, the account-route mounting or `BLOCKED_AUTH_PATHS` in a way that conflicts with this design. The rebase may reconcile details. +- A BSUID is a short ASCII string of the form `.` (seen in #327: `US.13491208655302741918`, `EG.1234567890123456789`). The exact pattern is agreed with the WhatsApp builder (Open Question 9). +- BSUIDs are stable per user per business portfolio. A user hiding or revealing their phone number does not change their BSUID. +- The WhatsApp service holds one key at a time (two during rotation) and is the only consumer of `whatsapp: mint`. +- PR #327 changes its header to `Authorization: Bearer` and its error handling to the shapes above. Spec 321's amendment lands on its own branch, coordinated with that builder, since spec 321 is not on develop. +- `@better-auth/api-key` 1.7.7's behaviour (above) is what ships. The tests pin the parts the design relies on (no HTTP surface, no session for keys, hashing, permission-before-budget), so a later bump that changes them fails loudly. + +## Solution Approaches + +### Approach 1 (recommended): plugin for keys, exchange writes identity rows directly in one transaction + +Keys come from `@better-auth/api-key` (hashing, permissions, budgets, atomic usage accounting) with its HTTP surface blocked. The exchange verifies with `auth.api.verifyApiKey`, then creates the WhatsApp user and account row with its own Drizzle transaction in `packages/auth` (the same pattern as `banUserByEmail`'s update plus session delete). It reuses or creates the session, and signs with the jwt plugin. + +Because the exchange never calls `internalAdapter.createUser`, the `user.create.before` hook needs no "who is calling" exception. It simply refuses **both** reserved domains on every path it sees. Every Better Auth path runs through it; the exchange and the service-user script do not. This is stronger than the issue's wording ("permitted by the database hook only when the creating context is the exchange route or an operator script"), with the same outcome: no Better Auth HTTP path can create the address, and there is no context check to get wrong. + +- **Pros:** the user and account are atomic in one real transaction (provable against pglite); the reserved-domain hook stays an unconditional rule; the delegation rule is one parameterized query on `(provider_id, account_id)` with the provider fixed in code; no new credential crypto to maintain. +- **Cons:** the exchange writes `auth.users` and `auth.account` rows itself, so it must match Better Auth's row shape (the hand-written schema already pins it, and tests read the rows back through Better Auth). The session may be created through `internalAdapter.createSession` so its token shape stays Better Auth's. +- **Risk:** low to medium. The concurrency path needs a deliberate test, because pglite serializes connections. + +### Approach 2: plugin for keys, identity through Better Auth's internal adapter with a context marker + +As Approach 1, but users and accounts are created through `internalAdapter.createUser` / `createAccount`. The `user.create.before` hook permits the non-interactive domain only when a marker (set by the exchange or the script) is present in the hook's context. + +- **Pros:** every row is written by Better Auth's own code. +- **Cons:** the internal adapter's calls are not one transaction, so a crash between user and account leaves an orphan user that no BSUID reaches. The hook's safety then rests on a marker that no HTTP path can forge, which is a subtle invariant across better-auth upgrades. This is weaker than Approach 1's unconditional refusal. +- **Risk:** medium. + +### Approach 3: hand-rolled service keys (no plugin) + +A small table of hashed keys with permissions and budgets, verified by apps/auth's own code. + +- **Pros:** no dependency, and no blocked-surface to police. +- **Cons:** re-implements atomic budget and rate-limit accounting, expiry, refill and rotation that the plugin already does and tests. It diverges from the issue's design and from #314's expected consumer model. +- **Risk:** medium (new crypto-adjacent code). Rejected. + +**Recommendation: Approach 1.** + +## Open Questions + +**Critical (owner decisions; recommendation given, not settled)** + +1. **The non-interactive domain name.** Recommendation: `@no-login.ansari.chat`. It says what the address is, sits under a domain the project controls, and cannot be confused with `@system.ansari.chat`. +2. **Store the BSUID raw in `account.account_id`, or HMAC it with an apps/auth secret.** Recommendation: raw. The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a rotation story that re-keys every row, and a new env var. Revisit if a second identifier ever lands in the auth database beside it. +3. **Daily budget and per-window limit for the WhatsApp key**, sized from the legacy WhatsApp service's traffic. Recommendation: the create script requires both explicitly (no defaults), so the sizing is a deliberate operator act, recorded in the runbook. A starting point is about twice the legacy peak per minute and per day. One exchange per BSUID per 15 minutes means the budget scales with active conversations, not messages. +4. **The leaderboard: migrate from its static secret now or later.** Recommendation: later. Nothing forces it, and this spec ships one consumer. + +**Important (shape the design; builder recommendation, for the architect or owner)** + +5. **Throttle statuses.** The issue lists "budget-exhausted" under the single 401. The plugin itself returns 429 for an exhausted key and for a rate-limit hit. Recommendation: per-window rate limit → 429 with `Retry-After`, so the WhatsApp service backs off instead of treating its key as revoked. An exhausted daily budget follows the issue (401), unless the owner prefers 429 there too. +6. **The banned-user response.** The issue says "refused" but gives no status. Recommendation: 403 `{ message }`, distinct from 401, so the WhatsApp service can tell "this user is banned" (a reply to the user, or none) from "my key is bad" (page the operator). The caller is a trusted service holding a valid key, so this is no oracle to the public. +7. **Order of checks.** Recommendation: key first, then body, so an unauthenticated caller cannot probe the BSUID rules. The cost is that a valid key's budget is spent on a malformed body. The plugin consumes budget at verification. +8. **How "refuse to run outside the auth image" is detected.** No existing script has this guard. Recommendation: a marker file written by the auth Dockerfile at build time. A file needs no new environment variable or `turbo.json` change, and cannot be set by accident in a shell. The guard runs before the database connection. Also: should `grant-admin.ts` and `ban-user.ts` gain it? Recommendation: not in this spec (they are documented to run from a checkout too). +9. **The exact BSUID pattern**, agreed with the WhatsApp builder (#327). Proposal: `^[A-Z]{2}\.[0-9]{10,32}$`, matching both samples in #327. Meta's documentation is sparse here, so the pattern is a contract both sides test against. A bare phone number (E.164, with or without `+`) can never match, because it has no country-code-and-dot prefix. The pattern cannot stop a caller that deliberately disguises one, which is why the WhatsApp service's own "no phone number" test is part of the contract. + +**Nice-to-know** + +10. **Should the plugin store `start`?** It keeps the first characters of the key in clear. With a per-service prefix those are mostly the prefix. Recommendation: turn it off; the prefix and name identify a key in the list output. +11. **Session sliding.** The reused session is 30 days and slides daily on use. A WhatsApp user active at least once a month keeps one session indefinitely; one who goes quiet gets a fresh one. No change recommended. +12. **Deleting a WhatsApp user.** `DELETE /api/account` with the user's own JWT (held by the WhatsApp service) cascades the account row away, so the next message from that BSUID creates a new user. This is the intended outcome; a user-facing delete flow for WhatsApp is out of scope. + +## Test Scenarios + +**Functional (pglite, `pnpm --filter @ansari/auth test` and apps/auth tests)** +1. Happy path: new BSUID → 200, token verifies, `sub` and `sid` match the created user and session, rows as specified. +2. Repeat: same BSUID → same `sub`, same `sid`, one session. +3. Near-expiry session: the user's only session expires in less than 15 minutes → a new session is created and its id is the `sid`. +4. Race: the conflict path on `unique(provider_id, account_id)`. Force a second insert for the same BSUID between lookup and insert (a seam, since pglite serializes); one user survives, and both callers get its token. +5. Every key state listed in the success criteria → identical 401 body, and no rows written. +6. Rate limit hit → the decided status (Open Question 5); budget exhausted → the decided status. +7. Delegation boundary: seeded admin, email user and `google` user, as in the success criteria. +8. Banned WhatsApp user → refused, sessions deleted, an earlier JWT dead at `/api/account/me`. +9. Body errors: non-JSON, missing `bsuid`, non-string, empty, too long, lower-case country code, phone-shaped → 400 (after a valid key). +10. Blocked surface: the five `/api-key/*` paths in mixed case, with trailing slashes and with suffixes → blocked; `/api-key/verify` → 404. +11. Keys never sessions: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` → no session, no JWT. +12. Reserved domain: sign-up 409 identical to taken; reset request identical to unknown, mailer not called; hook refusal on a Better Auth path; sign-in identical to unknown. +13. Scripts: create (prints once; row holds the hash only), list (no secrets), rotate (two live keys), revoke (next exchange 401); each refuses outside the image. + +**Non-functional** +14. Logging capture across scenarios 1–13: no raw key, hash, BSUID, phone-shaped value or driver error object in any output. +15. Outage: the auth database stopped under a running apps/auth → the exchange answers 500, never 401 (production-shaped: `NODE_ENV=production`). +16. End to end in apps/api's contract harness: exchange token → `requireUser` → `public.users` row. +17. Staging smoke (manual, WhatsApp builder): phone-visible and phone-hidden users get answers. + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| A WhatsApp key mints a token for a non-WhatsApp user | Low | Critical | Provider fixed in code; lookup only on `(provider_id='whatsapp', account_id)`; the BSUID pattern refuses UUIDs and emails; delegation-boundary tests with seeded admin, email and other-provider users | +| A leaked key creates unlimited users | Low | High | Per-key rate limit and daily budget; rotate and revoke scripts; created users are inert (no credential, no sign-in); an alertable count of new WhatsApp users is a plan consideration | +| The plugin's HTTP surface reachable through a variant path | Low | Critical | Same exact plus case-insensitive prefix blocking as the admin paths, with variant tests; `enableSessionForAPIKeys` pinned false with a test | +| A raw key or BSUID in logs (including better-auth's logger) | Medium | High | `{name, code}`-only error logging; log-capture test across every case; the create script's single print is the only output of the key | +| Token signed from a session that dies first, so apps/api's first use fails | Medium | Medium | Reuse only sessions that outlive the JWT; end-to-end test through `requireUser` | +| Concurrent first requests create two users for one BSUID | Medium | High | `unique(provider_id, account_id)` plus transaction rollback on conflict; a deliberate conflict-path test | +| Migration `0001` collides with the cutover's numbering, or is shipped unapplied | Medium | High | Main numbers it before the journal is committed; human-applied before deploy; the parity tests and deploy order apply | +| Rebase conflicts after the spec 300 cutover | High | Low | The PR waits for the cutover; the rebase is expected; the tests pin the behaviour, not line positions | +| A future plugin or core bump changes deletion-on-expiry, permission order or the endpoint list | Medium | Medium | Tests pin each behaviour relied on; the plugin is pinned to the core version | +| A phone number reaches apps/auth through a misbehaving caller | Low | High | The BSUID pattern cannot match a phone number; refused before lookup or log; the "no phone number" test extends to the exchange | + +## References + +- Issue #329 (this spec); spec 321 / PR #327 (WhatsApp, the first consumer); issue #314 (MCP, the second consumer) +- `codev/specs/300-better-auth-migration.md`: two databases, reserved addresses, account endpoints, rate limits and client IP +- `codev/resources/arch.md`: Authentication & Authorization +- `codev/resources/lessons-learned.md`: Better Auth migration (spec 300); Auth hardening (spec 4) +- `@better-auth/api-key` 1.7.7 package source (`dist/index.mjs`): schema, endpoints, `validateApiKey` order +- `docs/self-hosting.md` (to be updated: service keys, scripts, deploy order) diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md new file mode 100644 index 00000000..108aeb1f --- /dev/null +++ b/codev/state/spir-329_thread.md @@ -0,0 +1,14 @@ +# spir-329 thread: service API keys + WhatsApp token exchange (issue #329) + +## 2026-10-28: specify + +- Read @better-auth/api-key 1.7.7 from the published tarball. Facts that differ from the issue/docs: + owner column is `referenceId` (plus `configId`), not `userId`; `enableMetadata` defaults false; + verify DELETES expired keys and exhausted no-refill keys; permission check precedes budget spend; + plugin returns 429 for exhausted/rate-limited; verifyApiKey + deleteAllExpiredApiKeys are serverOnly. +- No existing operator script has a "refuse outside the auth image" guard; the spec raises its mechanism as an open question + (a Dockerfile marker file is recommended, so no new env var is needed). +- apps/api resolveUser refuses only @system.ansari.chat, so the new no-login domain passes first use unchanged. +- Recommended approach: the exchange writes user+account in its own Drizzle tx, so the user.create.before hook refuses BOTH + reserved domains unconditionally (stronger than the issue's "permit by context"). +- PR #327 BSUIDs look like `US.13491208655302741918`; the pattern is proposed, to be agreed with the WhatsApp builder. From a66c58c6a85814daa1a0e199a0c33bcf5cd83874 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 15:36:59 +1100 Subject: [PATCH 060/247] chore(porch): 329 specify build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 743679fa..f69c6bfb 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -14,7 +14,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T04:31:56.767Z' +updated_at: '2026-10-28T04:36:59.617Z' From c62d7b65cbd6b1b602e3b56a38e7d39c12593334 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 15:42:21 +1100 Subject: [PATCH 061/247] [Spec 329] Specification with multi-agent review --- .../329-apps-auth-service-api-keys-bet.md | 262 ++++++++++-------- codev/state/spir-329_thread.md | 12 + 2 files changed, 157 insertions(+), 117 deletions(-) diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index e882cdf5..89b0460a 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -8,8 +8,8 @@ The WhatsApp service (spec 321, PR #327) needs exactly that. A WhatsApp user is Two things are missing: -1. **Service identity**: a credential a service holds, issued and revoked by an operator, hashed at rest, scoped per service and per action, with its own rate limit and daily budget. -2. **A delegation rule**: which users a given service may mint tokens for. For WhatsApp the rule is "only users reachable through a `whatsapp` account row, keyed by BSUID". A WhatsApp key must never yield a token for an email user, an admin, a system identity or another provider's user. +1. **Service identity**: a credential a service holds, issued and revoked by an operator, hashed at rest, scoped per service and per action, with its own throttle. +2. **A delegation rule**: which users a given service may mint tokens for. For WhatsApp the rule is "only users reachable through a `whatsapp` account row, keyed by BSUID, by a key owned by the WhatsApp service". A WhatsApp key must never yield a token for an email user, an admin, a system identity, a service user or another provider's user. Affected: the WhatsApp service and its users (no path into apps/api today), the operator (who will hold a new credential type), and the security model of apps/auth (a new credential, table and public route). @@ -17,31 +17,51 @@ Affected: the WhatsApp service and its users (no path into apps/api today), the - **Callers.** apps/auth serves Better Auth's router at `/api/auth/*`, the account endpoints at `/api/account/*` (bound to the caller's own JWT and a live `sid`), `/health` and a diag route. There is no service credential of any kind. The leaderboard uses a static shared secret on apps/api; MCP (`/v2/mcp-complete`) has none (#314). - **Tokens.** apps/api accepts only apps/auth's EdDSA JWT (15 minutes, payload `{sub, sid}` plus `iss`, `aud`, `iat`, `exp`), verified against the public JWKS. On first use, `requireUser` → `resolveUser` fetches `GET /api/account/me` with that JWT, which requires the `sid` to name a live row in `auth.session`, and then inserts `public.users` under the same UUID. A bare signature with no session behind it is therefore useless: first use fails. -- **Reserved addresses.** `@system.ansari.chat` is reserved. Sign-up refuses it with the same 409 as a taken address; the reset sender skips it silently. The `user.create.before` database hook refuses it on every path, because spec 300 says the auth database never holds one (system identities live only as app rows found by `system_key`). apps/api's `resolveUser` also refuses a system-domain address. There is no second reserved domain. -- **Accounts.** `auth.account` carries `unique(user_id, provider_id)` (one credential row per user). There is no `unique(provider_id, account_id)`. Only the `credential` provider exists today (no social providers are configured). +- **Reserved addresses.** `@system.ansari.chat` is reserved. Sign-up refuses it with the same 409 as a taken address; the reset sender skips it silently. The `user.create.before` database hook refuses it on every path, because spec 300 says the auth database never holds one (system identities live only as app rows found by `system_key`). apps/api's `resolveUser` refuses a system-domain address and nothing else, so a second reserved domain passes apps/api's first use unchanged. There is no second reserved domain. +- **Accounts.** `auth.account` carries `unique(user_id, provider_id)` (one row per provider per user). There is no `unique(provider_id, account_id)`. Only the `credential` provider exists today (no social providers are configured). - **Blocked paths.** `BLOCKED_AUTH_PATHS` (the admin plugin's whole surface, change-email, update-user, change-password) goes into `disabledPaths`, which better-auth matches exactly. apps/auth also blocks every case and prefix variant before the handler. Both are pinned by tests over `ADMIN_PATHS`. - **Rate limits.** `ROUTER_RULES` are all `false`: apps/auth sees apps/api's egress IP on every legacy call, so per-IP rules there would throttle everyone together. Routes outside Better Auth's handler (the account routes) carry no per-IP rule at all; they have a per-user budget. -- **Operator scripts.** `grant-admin.ts` and `ban-user.ts` show an account, act on it, never print secrets, and run from the auth image or a checkout with the auth `DATABASE_URL`. Neither refuses to run outside the image. The issue's "same shape … refuse to run outside the auth image" describes a guard that does not exist yet. -- **`@better-auth/api-key` 1.7.7** (verified from the published package, peer `better-auth ^1.7.7`), the facts the design rests on: - - Its `apikey` model holds `configId` (default `"default"`), `name`, `start` (the first characters, stored by default), `referenceId` (the owning user's id; `references` defaults to `"user"`), `prefix`, `key` (the SHA-256 hash, base64url), `refillInterval`, `refillAmount`, `lastRefillAt`, `enabled`, `rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`, `requestCount`, `remaining`, `lastRequest`, `expiresAt`, `createdAt`, `updatedAt`, `permissions` (JSON text), `metadata` (JSON text, honoured only with `enableMetadata: true`). The owner column is `referenceId`, not `userId` as older docs show. - - HTTP endpoints: `/api-key/create`, `/api-key/get`, `/api-key/list`, `/api-key/update`, `/api-key/delete`. `verifyApiKey` and `deleteAllExpiredApiKeys` are `serverOnly` and have no route. - - `enableSessionForAPIKeys` defaults to `false`. Only when it is true does a before-hook turn an `x-api-key` header into a session for every Better Auth route. - - `verifyApiKey` checks, in order: the hash is found; `enabled`; `expiresAt` (an expired key is **deleted** and refused); `permissions` (missing or insufficient is refused before any budget is spent); `remaining === 0` with no refill (the key is **deleted** and refused with 429 `USAGE_EXCEEDED`). Only then does it consume `remaining` and a rate-limit slot, atomically against the row. Errors are logged through better-auth's logger, with the error object only. - - Default key: 64 random letters plus the configured prefix. +- **Operator scripts.** `grant-admin.ts` shows an account, then acts only on an explicit `--confirm `. It never prints secrets, and runs from the auth image or from a checkout with the auth `DATABASE_URL`. No script refuses to run outside the image, and none prints the database host. The issue's "refuse to run outside the auth image" describes a guard that does not exist; a marker could not prove the location anyway. This spec drops it (a correction to the issue, directed by the security architect). +- **Better Auth's logger.** `createAuth` sets no `logger`, so better-auth logs error objects as they are. +- **`@better-auth/api-key` 1.7.7.** Peer `better-auth ^1.7.7`. Read from the published package and **proven against pglite** (throwaway probe in the specify phase, with the plugin wired to the real `auth` schema): + - The `apikey` model: `configId` (default `"default"`), `name`, `start`, `referenceId` (the owning user's id, since `references` defaults to `"user"`), `prefix`, `key` (SHA-256, base64url), `refillInterval`, `refillAmount`, `lastRefillAt`, `enabled`, `rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`, `requestCount`, `remaining`, `lastRequest`, `expiresAt`, `createdAt`, `updatedAt`, `permissions` (JSON text), `metadata` (JSON text, kept only with `enableMetadata: true`). The owner column is `referenceId`, not `userId` as older docs show. + - HTTP endpoints: `/api-key/create`, `/api-key/get`, `/api-key/list`, `/api-key/update`, `/api-key/delete`. `verifyApiKey` and `deleteAllExpiredApiKeys` are `serverOnly`, with no route. + - `enableSessionForAPIKeys` defaults to `false`. Only when it is true does a before-hook turn an `x-api-key` header into a session on every Better Auth route. + - `verifyApiKey` order: hash found → `enabled` → `expiresAt` → `permissions` → exhausted → consume `remaining` and a rate-limit slot, atomically against the row. Probe results: + + | Case | Result | Row | + |---|---|---| + | `remaining` reaches 0, **no refill** | refused `USAGE_EXCEEDED` | **deleted** on the next verify | + | `remaining` reaches 0, **with refill** | refused `USAGE_EXCEEDED` until the interval passes, then refilled (first refill from a null `lastRefillAt` and later cycles both work) | kept | + | per-window rate limit exceeded | refused `RATE_LIMITED` | kept | + | expired | refused `KEY_EXPIRED` | **deleted** | + | wrong permission | refused `KEY_NOT_FOUND`, `remaining` untouched | kept | + + - Every failure is caught and returned as `{ valid: false, error }`. An error that is not an `APIError`, such as a database driver error, comes back as `INVALID_API_KEY`, the same code as an unknown key. **An outage is therefore indistinguishable from a bad key** in the result. Before returning, the plugin logs the caught error object through better-auth's logger, raw driver errors included. + - `start` keeps the first 6 characters in clear (with the prefix `wa_`: the prefix plus 3 random characters). A default key is the prefix plus 64 random letters. ## Desired State ### 1. Service identity -- `packages/auth` carries `@better-auth/api-key` pinned to the core version (1.7.7). Its `apikey` table is written by hand in the existing `auth` schema style (uuid ids, `timestamptz`) and ships as migration `0001`, generated, reviewed as SQL and applied by hand at deploy. Better Auth never changes the database shape. -- **A service is one auth user**, created only by an operator script: an address under the non-interactive domain (Part 3), no credential, no account row, role `user`. One user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates. -- **Keys are issued, listed, rotated and revoked only by operator scripts**, which follow the existing scripts' shape: show the service account, act on its id, print nothing secret except the new key exactly once at creation. Each key has a per-service prefix, a name, `permissions`, `metadata` carrying the partner's client id, a per-key rate limit (`rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`), a daily budget (`remaining`, `refillAmount`, `refillInterval`) and an expiry. Rotate creates a second live key for the same service so the consumer can switch without downtime; revoke deletes one. The scripts refuse to run outside the auth image (Open Question 8). +- `packages/auth` carries `@better-auth/api-key` pinned to the core version (1.7.7). Its `apikey` table is written by hand in `schema.ts`. It mirrors the plugin's model exactly, with the column names better-auth's generator would produce, in the existing `auth` schema style (uuid ids, `timestamptz`, `reference_id` a uuid referencing `auth.users` with cascade delete). The schema comment records the departures, as it does for the other tables. It ships as migration `0001`, generated, reviewed as SQL and applied by hand at deploy. Better Auth never changes the database shape. +- **A service is one auth user**, created only by an operator script. It has an address under the non-interactive domain (Part 3), no credential, role `user`, and exactly one account row `{ providerId: 'service', accountId: }`. That row is how the service user is resolved (never by email), as system identities are resolved by `system_key`. One user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates. A service user cannot sign in: there is no `service` provider in Better Auth, and it has no credential. +- **Permissions are fixed per service, in code.** The script has no free-form permission input: the `whatsapp` service always gets `{ whatsapp: ['mint'] }`, and `mcp-faithful-answers` will get `{ mcp: ['complete'] }` (#314). A key's permission and its owner always agree. +- **Keys are issued, listed, rotated and revoked only by operator scripts**, in `grant-admin.ts`'s posture: + - refuse to run without a database URL; + - show the service account first, and act only on an explicit `--confirm ` of that account; + - print the database host before any write; + - log which key id they acted on; + - print nothing secret, except the new key exactly once at creation. + + Each key has a per-service prefix, a name, `metadata` carrying the partner's client id, a per-window rate limit (`rateLimitTimeWindow`, `rateLimitMax`, required, no defaults), a daily budget (`remaining` with `refillAmount` and `refillInterval`, Open Question 3) and an expiry. **The script refuses `remaining` without a refill**: proven above, that configuration deletes the key the first time a burst exhausts it. Rotate creates a second live key for the same service, so the consumer can switch without downtime; revoke deletes one by id. The plugin pruning an expired key on verify is accepted: the scripts' key-id logging leaves the trail. - **No HTTP surface.** The five `/api-key/*` paths join `BLOCKED_AUTH_PATHS` and are blocked like the admin paths, in every case and prefix variant. `enableSessionForAPIKeys` stays `false`, so a key is never a session anywhere on the router. -- **Verification is `auth.api.verifyApiKey`, in-process**, with the route's required permission passed in. Permissions are per service and per action: `{ whatsapp: ['mint'] }` for the exchange, `{ mcp: ['complete'] }` reserved for #314. A key valid for one service fails at another's route. No verify route at the public origin is built here; #314 adds one. +- **Verification is `auth.api.verifyApiKey`, in-process**, with the route's required permission passed in. **The route then also binds the key's `referenceId` to its own service user** (resolved by the `service` account row). A key for another service fails twice over: by permission and by owner. No verify route at the public origin is built here; #314 adds one. +- **Better Auth's logger is redacted.** `createAuth` configures better-auth's `logger` so an error argument is reduced to `{name, code}` and nothing else, keeping the plugin's catch-and-log from emitting driver errors. This applies to every better-auth log, which is the existing rule ("never log a raw driver error") made true for the library too. ### 2. The WhatsApp token exchange: `POST /api/whatsapp/session` -Mounted outside Better Auth's handler path, like the account routes, so no router per-IP rule sees it (the WhatsApp service calls from a Railway egress IP). `ROUTER_RULES` stays all `false`. The key's own rate limit and daily budget are the throttle. +Mounted outside Better Auth's handler path, like the account routes, so no router per-IP rule sees it (the WhatsApp service calls from a Railway egress IP). `ROUTER_RULES` stays all `false`. The key's rate limit and daily budget are the throttle. **Contract** (replaces the assumption in spec 321 and the `X-Whatsapp-Api-Key` header in #327): @@ -49,23 +69,22 @@ Mounted outside Better Auth's handler path, like the account routes, so no route |---|---| | Request | `Authorization: Bearer `; JSON body `{ "bsuid": "" }` | | 200 | `{ "token": "" }`: the same EdDSA 15-minute `{sub, sid}` token apps/auth issues every client, signed from a live session of the BSUID's user | -| 400 | `{ "message": … }`: body not JSON, `bsuid` missing, not a string or not matching the agreed BSUID pattern | -| 401 | `{ "message": "Unauthorized" }`, byte-identical for a missing, malformed, unknown, revoked, disabled, expired, exhausted or wrong-permission key | -| 403 | the BSUID's user is banned (Open Question 6) | -| 429 | the key's per-window rate limit is exceeded (Open Question 5) | -| 500 | `{ "message": "Internal error" }` for anything else, including an auth-database outage (never 401, so the caller does not read an outage as a revoked key) | +| 400 | `{ "message": "Invalid request" }`: body not JSON, `bsuid` missing, not a string or not matching the BSUID pattern | +| 401 | `{ "message": "Unauthorized" }`, byte-identical for **every** key refusal: missing, malformed, unknown, revoked, disabled, expired, budget-exhausted, rate-limited, wrong permission, or owned by another service | +| 403 | `{ "message": "Forbidden" }`: the BSUID's user is banned | +| 500 | `{ "message": "Internal error" }` for anything else, including an auth-database outage. Never 401, so the caller does not read an outage as a revoked key | -The key is checked before the body (Open Question 7), so an unauthenticated caller learns nothing about the body rules. +Every `valid: false` from the plugin, including its own 429s, maps to the one 401. Because the plugin reports an outage as `INVALID_API_KEY`, the route **checks that the database answers before it returns 401**; if that check fails, the answer is 500. **Per request:** -1. Verify the key with permission `whatsapp: mint`. -2. Validate the BSUID against the agreed pattern. A phone number is never accepted, never stored and never logged. -3. Look up `auth.account` where `provider_id = 'whatsapp'` and `account_id = `. The lookup never goes through email. -4. No row: create the user (a non-interactive-domain address that contains neither the BSUID nor any phone number; no credential; `registered_via = 'whatsapp'`; a derived display name) and the account row `{ providerId: 'whatsapp', accountId: , userId }` in **one transaction**. A new `unique(provider_id, account_id)` guarantees a BSUID maps to exactly one user under concurrency; the request that loses the race rolls back its user and uses the winner's. -5. The user is banned: delete their sessions, refuse (step 7 is never reached). -6. Reuse the user's live session when it outlives the JWT about to be minted (it expires more than one JWT lifetime from now); otherwise create one. One session per WhatsApp user, so sessions do not grow per message, and a token is never signed from a session that dies before the token does. -7. Sign the JWT from that session and return it. +1. Verify the key with permission `whatsapp: mint`. A refusal → database check → 401 (or 500). The key's `referenceId` must be the WhatsApp service user, else 401. +2. Validate the body and the BSUID pattern → 400. The key comes first, so an unauthenticated caller learns nothing about the body rules. A phone number is never accepted, stored or logged. +3. Look up `auth.account` where `provider_id = 'whatsapp'` and `account_id = `. The provider is fixed in code; the lookup never goes through email. +4. No row: create the user and the account row `{ providerId: 'whatsapp', accountId: , userId }` in **one transaction**. The user has no credential, `registered_via = 'whatsapp'`, the display name `WhatsApp user`, and the address `wa-@`. The UUID is generated server-side and is independent of the BSUID, so the address carries neither the BSUID nor a phone number. A new `unique(provider_id, account_id)` makes a BSUID map to exactly one user under concurrency: the losing request rolls back and uses the winner's user. An address collision (a duplicate random UUID) is a 500, never a retry onto another user. +5. If the user is banned (the admin plugin's `banned`, honouring `banExpires`), delete all their sessions and answer 403. +6. **Exactly one session per WhatsApp user.** Session work for one user is serialized (a lock on that user's row, held for the step). A live session that outlives the JWT about to be minted (it expires more than one JWT lifetime from now) is reused. Otherwise the user's sessions are deleted and one new session is created. The session carries no IP address and no user agent. Concurrent requests for one user therefore end with one session, and a token is never signed from a session that dies before the token does. +7. Sign the JWT from that session (the jwt plugin's issuer, audience, lifetime and `{sub, sid}` payload) and return it. The WhatsApp service caches the token per BSUID for under 15 minutes and calls apps/api with it. apps/api resolves the user on first use exactly as for a web user (`GET /api/account/me` with the token, `public.users` under the same UUID). **apps/api gains no WhatsApp code** (spec 321 success criterion). @@ -73,171 +92,180 @@ The WhatsApp service caches the token per BSUID for under 15 minutes and calls a A second reserved domain (name: Open Question 1; working name `@no-login.ansari.chat`) for service users and WhatsApp users: -- refused by sign-up with the same 409 as a taken address (no oracle), and by the reset-request sender with the same silent success as an unknown address (no mail attempt); -- never created by any Better Auth HTTP path: the `user.create.before` hook refuses it; -- created only by the exchange and by the service-user operator script, neither of which goes through a Better Auth HTTP path. +- refused by sign-up with the same 409 as a taken address (no oracle); +- skipped by the reset-request sender with the same silent success as an unknown address (no mail attempt); +- never created by any Better Auth path: the `user.create.before` hook refuses both reserved domains unconditionally; +- created only by the exchange and by the service-user script, which write their rows directly in a transaction and never pass through a Better Auth path. -A user in this domain has no password and no account row except (for WhatsApp users) the `whatsapp` row, so they cannot sign in anywhere. `GET /api/account/me` answers for them only with their own JWT and a live `sid`, as for everyone. The system domain keeps every rule it has today; the auth database still never holds a system-domain address. +A user in this domain has no password, so they cannot sign in anywhere. A WhatsApp user's only account row is `whatsapp`; a service user's only one is `service`. `GET /api/account/me` answers for them only with their own JWT and a live `sid`, as for everyone. The system domain keeps every rule it has today; the auth database still never holds a system-domain address. ## Success Criteria **Service keys** - [ ] `@better-auth/api-key` is pinned to exactly the installed `better-auth` version (1.7.7); core is not bumped. -- [ ] The `apikey` table is in the `auth` schema (uuid id, `timestamptz` columns, `reference_id` a uuid referencing `auth.users` with cascade delete), and migration `0001` is the reviewed `drizzle-kit generate` output. The schema parity tests cover the new table. +- [ ] The `apikey` table mirrors the plugin's model with generator column names, in the `auth` schema (uuid id, `timestamptz`, `reference_id` uuid → `auth.users` on delete cascade). Migration `0001` is the reviewed `drizzle-kit generate` output, numbered by main. The schema tests cover the table. - [ ] Each of `/api-key/create`, `/api-key/get`, `/api-key/list`, `/api-key/update`, `/api-key/delete` returns the blocked response over HTTP, in every case and prefix variant, as the admin paths do. `/api-key/verify` has no route. -- [ ] `enableSessionForAPIKeys` is `false`. A test sends a valid key as `x-api-key` and as a bearer to `/api/auth/get-session` and `/api/auth/token` and gets no session and no token. -- [ ] A stored key row holds only the hash: the raw key appears nowhere in the database (including `start`) after creation. -- [ ] The create script prints the raw key exactly once. List, rotate and revoke print ids, names, prefixes, permissions, budgets and dates, never a key or a hash. -- [ ] Every script refuses to run outside the auth image, exiting non-zero before connecting to the database. -- [ ] Rotate leaves two live keys for the service, both verifying. Revoke makes the revoked key fail at the exchange with the 401 body on the next request. - -**Exchange: key states** (each returns the identical 401 body, and none creates a user, account or session) +- [ ] `enableSessionForAPIKeys` is `false`. A valid key sent as `x-api-key` and as a bearer to `/api/auth/get-session` and `/api/auth/token` yields no session and no token. +- [ ] A stored key row holds only the hash; the raw key, and any random part of it, appears nowhere in the database (`start` not stored). +- [ ] The create script prints the raw key exactly once. List, rotate and revoke print ids, names, prefixes, permissions, limits and dates, never a key or a hash. +- [ ] Every script refuses to run without a database URL, shows the account and writes only with a matching `--confirm ` (a mismatch writes nothing), prints the database host before writing, and logs the key id it acted on. +- [ ] The create script refuses `remaining` without `refillAmount` and `refillInterval`, and refuses a missing rate limit. +- [ ] The service-user script creates the user and its `service` account row in one transaction, and refuses an existing service name. +- [ ] Rotate leaves two live keys, both verifying. Revoke makes the revoked key fail at the exchange with the 401 body on the next request. +- [ ] Exhausting a refilled budget refuses the key and keeps the row; after the refill interval the same key verifies again (pins the probe result). + +**Exchange: key refusals** (each returns the identical 401 body, and none creates a user, account or session) - [ ] no `Authorization` header; non-Bearer scheme; empty bearer; -- [ ] unknown key; revoked (deleted) key; disabled key; expired key; -- [ ] key whose budget is exhausted (with and without refill configured; Open Question 5 decides whether the refill case is 401 or 429); -- [ ] key with `{ mcp: ['complete'] }` only; key with no permissions. +- [ ] unknown key; revoked key; disabled key; expired key; +- [ ] budget exhausted; per-window rate limit exceeded; +- [ ] key with `{ mcp: ['complete'] }` only; key with no permissions; +- [ ] a key carrying `{ whatsapp: ['mint'] }` but owned by a different service user (seeded directly, since the script cannot produce it). **Exchange: identity** -- [ ] A new BSUID creates exactly one user, one `whatsapp` account row and one session, and returns a JWT that verifies against apps/auth's JWKS with the same `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, payload `{sub, sid}` naming that user and session. -- [ ] The same BSUID twice returns tokens with the same `sub` and the same `sid`. The session count for the user stays 1. -- [ ] A session that expires within one JWT lifetime is not reused; a new one is created. -- [ ] Two first requests for one BSUID racing produce one user and one account row, and both return a token for that user. The test exercises the conflict path itself, not just sequential calls. -- [ ] **Delegation boundary.** With a seeded admin, a seeded email user and a seeded user of another provider (`provider_id = 'google'`, `account_id` shaped like a BSUID): passing the admin's and email user's ids (and emails) as `bsuid` is refused with 400; passing the other provider's `account_id` as `bsuid` returns a token whose `sub` is a new WhatsApp user, never the seeded user. No exchange response ever carries the `sub` of a user without a `whatsapp` account row. -- [ ] A service user (which has no account row) is never reachable by any BSUID. -- [ ] A banned WhatsApp user is refused, and their sessions are deleted. A JWT minted before the ban then fails `GET /api/account/me` (dead `sid`). +- [ ] A new BSUID creates exactly one user, one `whatsapp` account row and one session. It returns a JWT that verifies against apps/auth's JWKS with the same `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, payload `{sub, sid}` naming that user and session. +- [ ] The same BSUID twice returns the same `sub` and the same `sid`; the user has one session. +- [ ] A session expiring within one JWT lifetime is replaced: the old one is deleted, and the token's `sid` names the new one. +- [ ] Concurrent first requests for one BSUID produce one user and one account row, and all return a token for that user. Concurrent requests for an existing user leave exactly one session. The tests exercise the conflict and lock paths themselves (pglite serializes connections, so sequential calls would prove nothing). +- [ ] **Delegation boundary.** Seed an admin, an email user, a `google` user whose `account_id` is BSUID-shaped, and a service user. Passing the admin's and email user's ids and emails as `bsuid` is refused with 400. Passing the `google` user's `account_id` returns a token for a new WhatsApp user, never the seeded one. Passing the service name is refused with 400, or reaches no service user. No exchange response ever carries the `sub` of a user without a `whatsapp` account row. +- [ ] A banned WhatsApp user gets 403, and all their sessions are deleted; a JWT minted before the ban then fails `GET /api/account/me`. An expired ban (`banExpires` in the past) is not refused. +- [ ] Database stopped under a running apps/auth (production-shaped: `NODE_ENV=production`) → the exchange answers 500 for a valid key and for an unknown key, never 401. A request with no bearer at all is 401 without touching the database. **Exchange: privacy and logging** -- [ ] A phone-number-shaped `bsuid` (E.164 with and without `+`, or digits only) is refused with 400 before any lookup or log. -- [ ] No created row (user email, name, session IP and user agent, account) contains the BSUID except `account.account_id`, and none contains a phone number. -- [ ] No log line, alert or error response from the exchange or the scripts contains a raw key, a key hash, a BSUID or a driver error object. Errors log `{name, code}` only. A test captures `console.*` and better-auth's logger across every key-state and failure case and asserts none of those values appears. +- [ ] A phone-number-shaped `bsuid` (E.164 with and without `+`, digits only) is refused with 400, after the key and before any lookup or log. +- [ ] No created row (user email and name, session IP and user agent, account) contains the BSUID except `account.account_id`, and none contains a phone number. +- [ ] No log line, alert or response from the exchange, the scripts or better-auth's logger contains a raw key, a key hash, a BSUID or a driver error object; errors log `{name, code}` only. A test captures `console.*` across every refusal, failure and outage case and asserts none of those values appears. **Reserved domain** - [ ] Sign-up with a non-interactive-domain address (any case, surrounding spaces) returns the same 409 body as a taken address. - [ ] A reset request for an existing non-interactive-domain user returns the same response as an unknown address, and the mailer is never called. -- [ ] The `user.create.before` hook refuses a non-interactive-domain address on every Better Auth path. The exchange and the service-user script create one. +- [ ] The `user.create.before` hook refuses both reserved domains on every Better Auth path. - [ ] Sign-in with a non-interactive-domain user's address fails exactly as for an unknown address. **End to end** -- [ ] In the existing apps/api contract tests (which start a real apps/auth), a JWT from the exchange passes `requireUser`: JWKS verification, first-use `GET /api/account/me`, and a `public.users` row created under the same UUID with `registered_via = 'whatsapp'`. +- [ ] In the existing apps/api contract tests (which start a real apps/auth), a JWT from the exchange passes `requireUser`: JWKS verification, first-use `GET /api/account/me`, and a `public.users` row under the same UUID with `registered_via = 'whatsapp'`. - [ ] apps/api's source gains no WhatsApp-specific code. - [ ] Staging: the WhatsApp builder's smoke test (a phone-visible and a phone-hidden user) passes against staging apps/auth and apps/api with a key issued by the create script. **Unchanged** - [ ] `ROUTER_RULES` are all `false`; no per-IP rule applies to the exchange. - [ ] Every existing apps/auth, packages/auth and apps/api test passes unedited, except tests that enumerate the blocked paths or the schema, which gain the new entries. -- [ ] No new environment variable, or, if one appears, it is added to `turbo.json` `globalEnv` by the four-way derivation and reviewed by main. +- [ ] No new environment variable. If one appears, it is added to `turbo.json` `globalEnv` by the four-way derivation and reviewed by main. ## Constraints -- **Risk tier High** (auth model, new table, new credential type): 3-way consultation on spec, plan and PR. The owner's decisions are presented to them for approval. +- **Risk tier High** (auth model, new table, new credential type): porch's consultation on spec, plan and PR, plus the security architect's 3-way review at each gate. The owner answers decisions 1–4 at the spec-approval gate. - **Pin, do not bump.** The plugin's newest release requires core 1.7.14; bumping core is a separate, joint change. -- **Schema discipline.** `packages/auth/src/schema.ts` is hand-written; the table is added there in its style. `drizzle-kit generate` → reviewed SQL → applied by a human with `psql -v ON_ERROR_STOP=1 --single-transaction` against the auth database. Never `db:push`. Main numbers migrations: the journal entry is coordinated with `architect:main` before it is committed. The deploy order (migration before code) applies. +- **Schema discipline.** `schema.ts` is hand-written; the table is added there in its style. `drizzle-kit generate` → reviewed SQL → applied by a human with `psql -v ON_ERROR_STOP=1 --single-transaction` against the auth database. Never `db:push`. Main numbers migrations: `architect:main` is messaged before the journal entry is committed. Migration before code at deploy. - **Two databases.** Everything here is in the auth database and apps/auth. apps/api changes only in tests. No query crosses the two. -- **Tokens.** The exchange's JWT must be indistinguishable from any other apps/auth JWT, and must be signed from a real, live session (apps/api's first use needs a live `sid`). +- **Tokens.** The exchange's JWT must be indistinguishable from any other apps/auth JWT, and must be signed from a real, live session. - **Client IP.** No per-IP rule on the exchange; `ROUTER_RULES` stays all `false`. -- **No secret leaves its moment.** Never log a raw key, a raw driver error, a BSUID or a phone number. The raw key exists outside the creating process only in the create script's single output. +- **No secret leaves its moment.** Never log a raw key, a key hash, a raw driver error, a BSUID or a phone number. Outside the creating process, the raw key exists only in the create script's single output. - **Sequencing.** develop is frozen for apps/auth and packages/auth until the spec 300 production cutover and its contract PR land. Spec, plan and implementation proceed; the PR does not merge before then and expects a rebase. The migration is `0001` in `packages/auth/drizzle`, unrelated to the contract PR's app-database migration. - **Process.** Explicit `git add` only; no AI attribution; commits `[Spec 329][Phase: x] type: message`; `.env` files are never read. ## Assumptions -- The spec 300 cutover lands without changing `createAuth`'s plugin list, the account-route mounting or `BLOCKED_AUTH_PATHS` in a way that conflicts with this design. The rebase may reconcile details. -- A BSUID is a short ASCII string of the form `.` (seen in #327: `US.13491208655302741918`, `EG.1234567890123456789`). The exact pattern is agreed with the WhatsApp builder (Open Question 9). +- The spec 300 cutover lands without changing `createAuth`'s plugin list, the account-route mounting or `BLOCKED_AUTH_PATHS` in a way that conflicts with this design. The rebase reconciles details. +- A BSUID is `.` (seen in #327: `US.13491208655302741918`, `EG.1234567890123456789`). The pattern below is the contract unless the WhatsApp builder shows a BSUID it refuses. - BSUIDs are stable per user per business portfolio. A user hiding or revealing their phone number does not change their BSUID. - The WhatsApp service holds one key at a time (two during rotation) and is the only consumer of `whatsapp: mint`. -- PR #327 changes its header to `Authorization: Bearer` and its error handling to the shapes above. Spec 321's amendment lands on its own branch, coordinated with that builder, since spec 321 is not on develop. -- `@better-auth/api-key` 1.7.7's behaviour (above) is what ships. The tests pin the parts the design relies on (no HTTP surface, no session for keys, hashing, permission-before-budget), so a later bump that changes them fails loudly. +- PR #327 changes its header to `Authorization: Bearer` and its error handling to the shapes above (including 403 for a banned user). Spec 321 is not on develop, so its amendment lands on #327's branch, coordinated with that builder. +- `@better-auth/api-key` 1.7.7 behaves as probed. The tests pin every behaviour the design relies on (no HTTP surface, no session for keys, hashing, permission before budget, refilled keys kept, outage reported as `INVALID_API_KEY`), so a later bump that changes one fails loudly. ## Solution Approaches -### Approach 1 (recommended): plugin for keys, exchange writes identity rows directly in one transaction +### Approach 1 (recommended): plugin for keys; identity rows written directly in one transaction -Keys come from `@better-auth/api-key` (hashing, permissions, budgets, atomic usage accounting) with its HTTP surface blocked. The exchange verifies with `auth.api.verifyApiKey`, then creates the WhatsApp user and account row with its own Drizzle transaction in `packages/auth` (the same pattern as `banUserByEmail`'s update plus session delete). It reuses or creates the session, and signs with the jwt plugin. +Keys come from `@better-auth/api-key` (hashing, permissions, budgets, atomic usage accounting), with its HTTP surface blocked and its logger redacted. The exchange verifies with `auth.api.verifyApiKey`, binds the owner, then creates the WhatsApp user and account row in its own Drizzle transaction in `packages/auth`, the same pattern as `banUserByEmail`'s update plus session delete. It manages the one session under a per-user lock and signs with the jwt plugin. The service-user script writes its user and `service` row the same way. -Because the exchange never calls `internalAdapter.createUser`, the `user.create.before` hook needs no "who is calling" exception. It simply refuses **both** reserved domains on every path it sees. Every Better Auth path runs through it; the exchange and the service-user script do not. This is stronger than the issue's wording ("permitted by the database hook only when the creating context is the exchange route or an operator script"), with the same outcome: no Better Auth HTTP path can create the address, and there is no context check to get wrong. +Because nothing here calls `internalAdapter.createUser`, the `user.create.before` hook needs no "who is calling" exception. It refuses **both** reserved domains on every path it sees. That is stronger than the issue's wording ("permitted by the database hook only when the creating context is the exchange route or an operator script"), with the same outcome: no Better Auth path can create such an address, and there is no context check to get wrong. -- **Pros:** the user and account are atomic in one real transaction (provable against pglite); the reserved-domain hook stays an unconditional rule; the delegation rule is one parameterized query on `(provider_id, account_id)` with the provider fixed in code; no new credential crypto to maintain. -- **Cons:** the exchange writes `auth.users` and `auth.account` rows itself, so it must match Better Auth's row shape (the hand-written schema already pins it, and tests read the rows back through Better Auth). The session may be created through `internalAdapter.createSession` so its token shape stays Better Auth's. -- **Risk:** low to medium. The concurrency path needs a deliberate test, because pglite serializes connections. +- **Pros:** user and account are atomic in one real transaction (provable against pglite). The reserved-domain hook stays unconditional. The delegation rule is one parameterized lookup with the provider fixed in code, plus an owner check. No credential crypto is maintained here. +- **Cons:** the exchange writes `auth.users` and `auth.account` rows itself, so it must match Better Auth's row shape. The hand-written schema pins it, and tests read the rows back through Better Auth. Sessions keep Better Auth's token shape. +- **Risk:** low to medium. The concurrency paths need deliberate tests. -### Approach 2: plugin for keys, identity through Better Auth's internal adapter with a context marker +### Approach 2: plugin for keys; identity through Better Auth's internal adapter with a context marker -As Approach 1, but users and accounts are created through `internalAdapter.createUser` / `createAccount`. The `user.create.before` hook permits the non-interactive domain only when a marker (set by the exchange or the script) is present in the hook's context. +As Approach 1, but users and accounts are created through `internalAdapter.createUser` / `createAccount`. The hook permits the non-interactive domain only when a marker set by the exchange or the script is present in its context. - **Pros:** every row is written by Better Auth's own code. -- **Cons:** the internal adapter's calls are not one transaction, so a crash between user and account leaves an orphan user that no BSUID reaches. The hook's safety then rests on a marker that no HTTP path can forge, which is a subtle invariant across better-auth upgrades. This is weaker than Approach 1's unconditional refusal. +- **Cons:** the internal adapter's calls are not one transaction, so a crash between user and account leaves an orphan user that no BSUID reaches. The hook's safety rests on a marker no HTTP path can forge, a subtle invariant across better-auth upgrades. - **Risk:** medium. ### Approach 3: hand-rolled service keys (no plugin) -A small table of hashed keys with permissions and budgets, verified by apps/auth's own code. +A small table of hashed keys with permissions and limits, verified by apps/auth's own code. -- **Pros:** no dependency, and no blocked-surface to police. -- **Cons:** re-implements atomic budget and rate-limit accounting, expiry, refill and rotation that the plugin already does and tests. It diverges from the issue's design and from #314's expected consumer model. -- **Risk:** medium (new crypto-adjacent code). Rejected. +- **Pros:** no dependency; no surface to block; outage and refusal are distinguishable by construction. +- **Cons:** re-implements the atomic accounting, expiry, refill and rotation the plugin already does and tests. Diverges from the issue's design and from #314's expected consumer model. +- **Risk:** medium (new credential code). Rejected. The plugin's two weaknesses found here (outage as `INVALID_API_KEY`, raw error logging) are closed by the database check and the redacted logger. **Recommendation: Approach 1.** ## Open Questions -**Critical (owner decisions; recommendation given, not settled)** +**Critical: owner decisions, answered at the spec-approval gate (recommendation given, not settled)** 1. **The non-interactive domain name.** Recommendation: `@no-login.ansari.chat`. It says what the address is, sits under a domain the project controls, and cannot be confused with `@system.ansari.chat`. -2. **Store the BSUID raw in `account.account_id`, or HMAC it with an apps/auth secret.** Recommendation: raw. The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a rotation story that re-keys every row, and a new env var. Revisit if a second identifier ever lands in the auth database beside it. -3. **Daily budget and per-window limit for the WhatsApp key**, sized from the legacy WhatsApp service's traffic. Recommendation: the create script requires both explicitly (no defaults), so the sizing is a deliberate operator act, recorded in the runbook. A starting point is about twice the legacy peak per minute and per day. One exchange per BSUID per 15 minutes means the budget scales with active conversations, not messages. +2. **Store the BSUID raw in `account.account_id`, or HMAC it with an apps/auth secret.** Recommendation: raw. The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a new env var and a rotation story that re-keys every row. +3. **The WhatsApp key's limits**, sized from the legacy WhatsApp service's traffic. The probe shows a refilled daily budget is safe: an exhausted key is refused and kept, and verifies again after the refill. So the decision is two numbers: a per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow`, e.g. per minute) and a daily budget (`refillAmount` per 24 h). Recommendation: both, at about twice the legacy peak. The create script requires both explicitly (no defaults), and the values are recorded in the runbook. One exchange per BSUID per 15 minutes means the budget scales with active conversations, not messages. If the owner prefers a single cap, the per-window ceiling alone is safe; a budget without refill is not offered, because it destroys the key when exhausted. 4. **The leaderboard: migrate from its static secret now or later.** Recommendation: later. Nothing forces it, and this spec ships one consumer. -**Important (shape the design; builder recommendation, for the architect or owner)** +**Resolved in this revision** (by the security architect's direction or by builder decision; recorded so the plan does not re-decide them) -5. **Throttle statuses.** The issue lists "budget-exhausted" under the single 401. The plugin itself returns 429 for an exhausted key and for a rate-limit hit. Recommendation: per-window rate limit → 429 with `Retry-After`, so the WhatsApp service backs off instead of treating its key as revoked. An exhausted daily budget follows the issue (401), unless the owner prefers 429 there too. -6. **The banned-user response.** The issue says "refused" but gives no status. Recommendation: 403 `{ message }`, distinct from 401, so the WhatsApp service can tell "this user is banned" (a reply to the user, or none) from "my key is bad" (page the operator). The caller is a trusted service holding a valid key, so this is no oracle to the public. -7. **Order of checks.** Recommendation: key first, then body, so an unauthenticated caller cannot probe the BSUID rules. The cost is that a valid key's budget is spent on a malformed body. The plugin consumes budget at verification. -8. **How "refuse to run outside the auth image" is detected.** No existing script has this guard. Recommendation: a marker file written by the auth Dockerfile at build time. A file needs no new environment variable or `turbo.json` change, and cannot be set by accident in a shell. The guard runs before the database connection. Also: should `grant-admin.ts` and `ban-user.ts` gain it? Recommendation: not in this spec (they are documented to run from a checkout too). -9. **The exact BSUID pattern**, agreed with the WhatsApp builder (#327). Proposal: `^[A-Z]{2}\.[0-9]{10,32}$`, matching both samples in #327. Meta's documentation is sparse here, so the pattern is a contract both sides test against. A bare phone number (E.164, with or without `+`) can never match, because it has no country-code-and-dot prefix. The pattern cannot stop a caller that deliberately disguises one, which is why the WhatsApp service's own "no phone number" test is part of the contract. +5. Throttle statuses: every key refusal, including the plugin's 429s, is the one 401 (architect). +6. Banned user: 403 `{ "message": "Forbidden" }`, so the WhatsApp service can tell "this user is banned" from "my key is bad". The caller is a trusted service holding a valid key, so this is no public oracle. +7. Order: key first, then body. +8. Image guard: dropped. The scripts follow `grant-admin.ts`'s posture plus a printed database host (architect). +9. BSUID pattern: `^[A-Z]{2}\.[0-9]{10,32}$`, matching both samples in #327. A bare phone number (E.164, with or without `+`) cannot match, because it has no country-code-and-dot prefix. The pattern cannot stop a caller that deliberately disguises one, which is why the WhatsApp service's own "no phone number" test is part of the contract. The WhatsApp builder is asked to confirm it against real webhooks before the PR. +10. `start` is not stored. **Nice-to-know** -10. **Should the plugin store `start`?** It keeps the first characters of the key in clear. With a per-service prefix those are mostly the prefix. Recommendation: turn it off; the prefix and name identify a key in the list output. -11. **Session sliding.** The reused session is 30 days and slides daily on use. A WhatsApp user active at least once a month keeps one session indefinitely; one who goes quiet gets a fresh one. No change recommended. -12. **Deleting a WhatsApp user.** `DELETE /api/account` with the user's own JWT (held by the WhatsApp service) cascades the account row away, so the next message from that BSUID creates a new user. This is the intended outcome; a user-facing delete flow for WhatsApp is out of scope. +11. **Session sliding.** The reused session is 30 days and slides daily on use. A WhatsApp user active at least monthly keeps one session indefinitely; a quiet one gets a fresh one. No change. +12. **Deleting a WhatsApp user.** `DELETE /api/account` with the user's own JWT (held by the WhatsApp service) cascades the account row away, so the next message from that BSUID creates a new user. This is intended; a user-facing delete flow for WhatsApp is out of scope. +13. **New-user volume as a signal.** A leaked key could create users up to its budget. An alert on WhatsApp user creations per window would surface that; it is a plan-level option, not a requirement. ## Test Scenarios -**Functional (pglite, `pnpm --filter @ansari/auth test` and apps/auth tests)** -1. Happy path: new BSUID → 200, token verifies, `sub` and `sid` match the created user and session, rows as specified. +**Functional (pglite: packages/auth and apps/auth suites)** +1. Happy path: new BSUID → 200; token verifies; `sub` and `sid` match the created user and session; rows as specified. 2. Repeat: same BSUID → same `sub`, same `sid`, one session. -3. Near-expiry session: the user's only session expires in less than 15 minutes → a new session is created and its id is the `sid`. -4. Race: the conflict path on `unique(provider_id, account_id)`. Force a second insert for the same BSUID between lookup and insert (a seam, since pglite serializes); one user survives, and both callers get its token. -5. Every key state listed in the success criteria → identical 401 body, and no rows written. -6. Rate limit hit → the decided status (Open Question 5); budget exhausted → the decided status. -7. Delegation boundary: seeded admin, email user and `google` user, as in the success criteria. -8. Banned WhatsApp user → refused, sessions deleted, an earlier JWT dead at `/api/account/me`. -9. Body errors: non-JSON, missing `bsuid`, non-string, empty, too long, lower-case country code, phone-shaped → 400 (after a valid key). -10. Blocked surface: the five `/api-key/*` paths in mixed case, with trailing slashes and with suffixes → blocked; `/api-key/verify` → 404. -11. Keys never sessions: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` → no session, no JWT. +3. Near-expiry session: the user's only session expires in under 15 minutes → it is deleted, a new one is created, and its id is the `sid`. +4. Races, through test seams (pglite serializes connections): a second insert for the same BSUID lands between lookup and insert, and one user survives with both callers getting its token. Two session steps for one user run concurrently, leaving one session. +5. Every key refusal in the success criteria → the identical 401 body, no rows written. +6. Owner binding: a `whatsapp: mint` key owned by another service user → 401. +7. Delegation boundary with the seeded admin, email user, `google` user and service user. +8. Banned WhatsApp user → 403, sessions deleted, an earlier JWT dead at `/api/account/me`; an expired ban is let through. +9. Body errors after a valid key: non-JSON, missing `bsuid`, non-string, empty, too long, lower-case country code, phone-shaped → 400. +10. Blocked surface: the five `/api-key/*` paths in mixed case, with trailing slashes and suffixes → blocked; `/api-key/verify` → 404. +11. Keys are never sessions: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` → no session, no JWT. 12. Reserved domain: sign-up 409 identical to taken; reset request identical to unknown, mailer not called; hook refusal on a Better Auth path; sign-in identical to unknown. -13. Scripts: create (prints once; row holds the hash only), list (no secrets), rotate (two live keys), revoke (next exchange 401); each refuses outside the image. +13. Scripts: no database URL → refused; show without `--confirm` → nothing written; a mismatched `--confirm` → nothing written. Create prints the key once and the row holds only the hash. `remaining` without refill → refused. List prints no secrets. Rotate → two live keys. Revoke → next exchange 401. The database host is printed before each write, and the key id is logged. +14. Refilled budget: exhaust → refused, row kept; advance past the interval → verifies. **Non-functional** -14. Logging capture across scenarios 1–13: no raw key, hash, BSUID, phone-shaped value or driver error object in any output. -15. Outage: the auth database stopped under a running apps/auth → the exchange answers 500, never 401 (production-shaped: `NODE_ENV=production`). -16. End to end in apps/api's contract harness: exchange token → `requireUser` → `public.users` row. -17. Staging smoke (manual, WhatsApp builder): phone-visible and phone-hidden users get answers. +15. Log capture across scenarios 1–14 (console and better-auth's logger): no raw key, hash, BSUID, phone-shaped value or driver error object. +16. Outage (production-shaped): database stopped under a running apps/auth → 500, never 401, for a valid key and for an unknown key. +17. End to end in apps/api's contract harness: exchange token → `requireUser` → `public.users` row. +18. Staging smoke (manual, WhatsApp builder): phone-visible and phone-hidden users get answers. ## Risks and Mitigation | Risk | Probability | Impact | Mitigation | |------|-------------|--------|------------| -| A WhatsApp key mints a token for a non-WhatsApp user | Low | Critical | Provider fixed in code; lookup only on `(provider_id='whatsapp', account_id)`; the BSUID pattern refuses UUIDs and emails; delegation-boundary tests with seeded admin, email and other-provider users | -| A leaked key creates unlimited users | Low | High | Per-key rate limit and daily budget; rotate and revoke scripts; created users are inert (no credential, no sign-in); an alertable count of new WhatsApp users is a plan consideration | -| The plugin's HTTP surface reachable through a variant path | Low | Critical | Same exact plus case-insensitive prefix blocking as the admin paths, with variant tests; `enableSessionForAPIKeys` pinned false with a test | -| A raw key or BSUID in logs (including better-auth's logger) | Medium | High | `{name, code}`-only error logging; log-capture test across every case; the create script's single print is the only output of the key | -| Token signed from a session that dies first, so apps/api's first use fails | Medium | Medium | Reuse only sessions that outlive the JWT; end-to-end test through `requireUser` | -| Concurrent first requests create two users for one BSUID | Medium | High | `unique(provider_id, account_id)` plus transaction rollback on conflict; a deliberate conflict-path test | -| Migration `0001` collides with the cutover's numbering, or is shipped unapplied | Medium | High | Main numbers it before the journal is committed; human-applied before deploy; the parity tests and deploy order apply | -| Rebase conflicts after the spec 300 cutover | High | Low | The PR waits for the cutover; the rebase is expected; the tests pin the behaviour, not line positions | -| A future plugin or core bump changes deletion-on-expiry, permission order or the endpoint list | Medium | Medium | Tests pin each behaviour relied on; the plugin is pinned to the core version | -| A phone number reaches apps/auth through a misbehaving caller | Low | High | The BSUID pattern cannot match a phone number; refused before lookup or log; the "no phone number" test extends to the exchange | +| A WhatsApp key mints a token for a non-WhatsApp user | Low | Critical | Provider fixed in code; lookup only on `(provider_id='whatsapp', account_id)`; key owner bound to the WhatsApp service user; the BSUID pattern refuses UUIDs and emails; delegation-boundary tests | +| An outage answered as 401, so the WhatsApp service treats its key as revoked | Medium | High | Database check before every 401; production-shaped outage test | +| The plugin logs a raw driver error | High without mitigation | High | Better Auth's logger redacted to `{name, code}`; log-capture test through an outage | +| A burst deletes the production key | High if `remaining` were set without refill (probed) | High | The script refuses `remaining` without a refill; refilled exhaustion pinned as refused-and-kept | +| A leaked key creates many users | Low | High | Per-window ceiling and daily budget; rotate and revoke scripts; created users are inert; optional creation alert (Open Question 13) | +| The plugin's HTTP surface reachable through a variant path | Low | Critical | Exact plus case-insensitive prefix blocking as for the admin paths, with variant tests; `enableSessionForAPIKeys` pinned false with a test | +| A token signed from a session that dies first, failing apps/api's first use | Medium | Medium | Reuse only sessions that outlive the JWT; end-to-end test through `requireUser` | +| Concurrent requests create two users for one BSUID, or several sessions for one user | Medium | High | `unique(provider_id, account_id)` with rollback on conflict; per-user lock around the session step; seam-driven concurrency tests | +| Migration `0001` collides with the cutover's numbering, or ships unapplied | Medium | High | Main numbers it before the journal is committed; human-applied before deploy; deploy order | +| Rebase conflicts after the spec 300 cutover | High | Low | The PR waits for the cutover; tests pin behaviour, not line positions | +| A plugin or core bump changes deletion, permission order, error mapping or the endpoint list | Medium | Medium | Each relied-on behaviour is pinned by a test; the plugin is pinned to the core version | +| A phone number reaches apps/auth through a misbehaving caller | Low | High | The pattern cannot match a bare phone number; refused before lookup or log; the "no phone number" test extends to the exchange | ## References @@ -245,5 +273,5 @@ A small table of hashed keys with permissions and budgets, verified by apps/auth - `codev/specs/300-better-auth-migration.md`: two databases, reserved addresses, account endpoints, rate limits and client IP - `codev/resources/arch.md`: Authentication & Authorization - `codev/resources/lessons-learned.md`: Better Auth migration (spec 300); Auth hardening (spec 4) -- `@better-auth/api-key` 1.7.7 package source (`dist/index.mjs`): schema, endpoints, `validateApiKey` order +- `@better-auth/api-key` 1.7.7 package source (`dist/index.mjs`: schema, endpoints, `validateApiKey`, `consumeRemaining`, `verifyApiKey`'s catch), and the specify-phase pglite probe whose results are tabled above - `docs/self-hosting.md` (to be updated: service keys, scripts, deploy order) diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index 108aeb1f..1c1e8fd4 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -12,3 +12,15 @@ - Recommended approach: the exchange writes user+account in its own Drizzle tx, so the user.create.before hook refuses BOTH reserved domains unconditionally (stronger than the issue's "permit by context"). - PR #327 BSUIDs look like `US.13491208655302741918`; the pattern is proposed, to be agreed with the WhatsApp builder. + +## 2026-10-28: spec iteration 1 (codex REQUEST_CHANGES; gemini skipped, agy not installed) + +- Probed api-key 1.7.7 against pglite (throwaway test, reverted): remaining→0 WITHOUT refill DELETES the key on the next verify; + WITH refill it is refused and kept, and the refill works (first one from a null lastRefillAt, and later cycles); rate-limited is kept; expired is deleted; + a wrong permission spends no budget. +- verifyApiKey catches everything: a DB driver error comes back as INVALID_API_KEY (indistinguishable from an unknown key) and is logged raw + through better-auth's logger. The spec adds a DB check before any 401 (outage → 500) and redacts better-auth's logger to {name, code}. +- Architect directions folded in: every valid:false → one 401; image guard dropped (scripts follow grant-admin's posture + print the DB host); + schema mirrors the plugin model with generator column names. +- Codex points folded in: key referenceId bound to the WhatsApp service user (resolved by a `service` account row, never by email); + one session per user under a per-user lock, near-expiry sessions replaced; wa-@domain address; 403 for banned. From 3eef81b51abe9d1c7b02658bc57d694017d55951 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 15:42:34 +1100 Subject: [PATCH 062/247] chore(porch): 329 spec-approval gate-requested --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index f69c6bfb..dc16a4e9 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -7,6 +7,7 @@ current_plan_phase: null gates: spec-approval: status: pending + requested_at: '2026-10-28T04:42:34.533Z' plan-approval: status: pending pr: @@ -17,4 +18,4 @@ iteration: 1 build_complete: true history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T04:36:59.617Z' +updated_at: '2026-10-28T04:42:34.533Z' From 42a964ac9ffe2d22cfc66c31991f82ffe1e040e1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 15:42:43 +1100 Subject: [PATCH 063/247] [Spec 329] Specify iteration 1 reviews and rebuttal --- .../329-specify-iter1-codex.txt | 22 +++++++++++++++++++ .../329-specify-iter1-gemini.txt | 10 +++++++++ .../329-specify-iter1-rebuttals.md | 22 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-gemini.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-rebuttals.md diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-codex.txt new file mode 100644 index 00000000..061768c1 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-codex.txt @@ -0,0 +1,22 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Strong security-oriented specification, but unresolved contract decisions and concurrency gaps prevent reliable planning. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- Resolve the open questions that alter requirements: domain, BSUID storage/pattern, throttle statuses, banned response, and image guard. +- Verification checks only `whatsapp:mint`; it must also bind `referenceId` to the designated WhatsApp service user. Add a cross-service-owner negative test. +- “One session per WhatsApp user” is not guaranteed: replacing a near-expiry session does not delete the old one, and concurrent exchanges can create multiple sessions. Specify serialization/replacement behavior and concurrent-session tests. +- Specify the privacy-safe email/name derivation and collision handling for generated WhatsApp users. +- Define how plugin-originated errors are classified/redacted so database outages remain 500 and Better Auth’s logger cannot emit driver objects or sensitive identifiers. + +--- + +## ⚠️ WORKING TREE CHANGED during the codex review (#1649). +A consultant reviews the tree; it must never write to it. 3 paths changed while this lane was running: + - packages/auth/package.json + - packages/auth/tests/probe-apikey.test.ts + - pnpm-lock.yaml +Check `git status` and `git diff` before trusting this review or shipping the branch. +Note: under cmap the lanes run concurrently in one worktree, so another lane — or you — +may be the writer. This warning names what moved, not who moved it. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-rebuttals.md new file mode 100644 index 00000000..4a8ca34b --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter1-rebuttals.md @@ -0,0 +1,22 @@ +# Spec 329, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (lane skipped: the `agy` CLI is not installed; no content to address). +Revision: commit c62d7b6. + +## Codex key issues + +1. **Resolve open questions that alter requirements.** Partly accepted. + - Resolved in the spec, now under "Resolved in this revision": throttle statuses (every key refusal is the one 401, per the security architect); banned user (403 `Forbidden`); check order (key, then body); image guard (dropped, per the security architect; the scripts follow `grant-admin.ts`'s posture plus a printed database host); BSUID pattern (fixed as `^[A-Z]{2}\.[0-9]{10,32}$`, with the WhatsApp builder asked to confirm it against real webhooks); `start` not stored. + - Not resolved, deliberately: the domain name, raw vs HMAC BSUID storage, the key's limits and leaderboard timing. These are the owner's decisions by the architect's instruction, answered at the spec-approval gate. Each has a recommendation, and the requirements are written against the recommended default (working domain name, raw storage), so planning is not blocked. Decision 3 was reshaped by a pglite probe: a refilled budget is safe (refused and kept), while a budget without refill deletes the key, so the script refuses that configuration. + +2. **Bind `referenceId` to the WhatsApp service user.** Accepted. Service users are resolved by a `service` account row (never by email). Permissions are fixed per service in code (the script takes no free-form permissions). The exchange checks the verified key's `referenceId` against the WhatsApp service user, so a mis-owned key is refused with 401. A negative test seeds a `whatsapp: mint` key under another service user. + +3. **"One session per WhatsApp user" not guaranteed.** Accepted. Session work for one user is serialized under a lock on the user's row. A near-expiry session is deleted when it is replaced, and the session carries no IP or user agent. The tests drive concurrent session steps through a seam, since pglite serializes connections. + +4. **Privacy-safe email/name derivation and collisions.** Accepted. Address `wa-@` (independent of the BSUID); name `WhatsApp user`. An address collision is a 500, never a retry onto another user. + +5. **Plugin error classification and logger redaction.** Accepted, and confirmed in the plugin source: `verifyApiKey` returns a driver error as `INVALID_API_KEY`, the same code as an unknown key, after logging the raw error object. The route checks that the database answers before returning 401 (outage → 500). Better Auth's logger is configured to reduce error arguments to `{name, code}`. Tests: a production-shaped outage (500 for valid and unknown keys) and log capture through the outage. + +## Working-tree warning in the codex output + +The three changed paths (`packages/auth/package.json`, `pnpm-lock.yaml`, `packages/auth/tests/probe-apikey.test.ts`) were my own throwaway pglite probe of the plugin, run during the review. It was reverted afterwards; the branch carries none of it. From e7366dc68968746d3145c7c8d0e4fc2a8851b8df Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 15:53:25 +1100 Subject: [PATCH 064/247] [Spec 329] Specification with user feedback: noreply.askansari.ai addresses --- .../329-apps-auth-service-api-keys-bet.md | 25 +++++++++++++------ codev/state/spir-329_thread.md | 5 ++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index 89b0460a..d0739008 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -45,7 +45,7 @@ Affected: the WhatsApp service and its users (no path into apps/api today), the ### 1. Service identity - `packages/auth` carries `@better-auth/api-key` pinned to the core version (1.7.7). Its `apikey` table is written by hand in `schema.ts`. It mirrors the plugin's model exactly, with the column names better-auth's generator would produce, in the existing `auth` schema style (uuid ids, `timestamptz`, `reference_id` a uuid referencing `auth.users` with cascade delete). The schema comment records the departures, as it does for the other tables. It ships as migration `0001`, generated, reviewed as SQL and applied by hand at deploy. Better Auth never changes the database shape. -- **A service is one auth user**, created only by an operator script. It has an address under the non-interactive domain (Part 3), no credential, role `user`, and exactly one account row `{ providerId: 'service', accountId: }`. That row is how the service user is resolved (never by email), as system identities are resolved by `system_key`. One user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates. A service user cannot sign in: there is no `service` provider in Better Auth, and it has no credential. +- **A service is one auth user**, created only by an operator script. Its address is `service-@noreply.askansari.ai` (Part 3; e.g. `service-whatsapp@noreply.askansari.ai`), and it has no credential, role `user`, and exactly one account row `{ providerId: 'service', accountId: }`. That row is how the service user is resolved (never by email), as system identities are resolved by `system_key`. One user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates. A service user cannot sign in: there is no `service` provider in Better Auth, and it has no credential. - **Permissions are fixed per service, in code.** The script has no free-form permission input: the `whatsapp` service always gets `{ whatsapp: ['mint'] }`, and `mcp-faithful-answers` will get `{ mcp: ['complete'] }` (#314). A key's permission and its owner always agree. - **Keys are issued, listed, rotated and revoked only by operator scripts**, in `grant-admin.ts`'s posture: - refuse to run without a database URL; @@ -81,7 +81,7 @@ Every `valid: false` from the plugin, including its own 429s, maps to the one 40 1. Verify the key with permission `whatsapp: mint`. A refusal → database check → 401 (or 500). The key's `referenceId` must be the WhatsApp service user, else 401. 2. Validate the body and the BSUID pattern → 400. The key comes first, so an unauthenticated caller learns nothing about the body rules. A phone number is never accepted, stored or logged. 3. Look up `auth.account` where `provider_id = 'whatsapp'` and `account_id = `. The provider is fixed in code; the lookup never goes through email. -4. No row: create the user and the account row `{ providerId: 'whatsapp', accountId: , userId }` in **one transaction**. The user has no credential, `registered_via = 'whatsapp'`, the display name `WhatsApp user`, and the address `wa-@`. The UUID is generated server-side and is independent of the BSUID, so the address carries neither the BSUID nor a phone number. A new `unique(provider_id, account_id)` makes a BSUID map to exactly one user under concurrency: the losing request rolls back and uses the winner's user. An address collision (a duplicate random UUID) is a 500, never a retry onto another user. +4. No row: create the user and the account row `{ providerId: 'whatsapp', accountId: , userId }` in **one transaction**. The user has no credential, `registered_via = 'whatsapp'`, the display name `WhatsApp user`, and the address `whatsapp-@noreply.askansari.ai`. The exchange generates the user's UUID itself before the insert, and writes the id and the address together in the transaction. The address is therefore unique by construction (ids are the primary key), and carries neither the BSUID nor a phone number. A new `unique(provider_id, account_id)` makes a BSUID map to exactly one user under concurrency: the losing request rolls back and uses the winner's user. 5. If the user is banned (the admin plugin's `banned`, honouring `banExpires`), delete all their sessions and answer 403. 6. **Exactly one session per WhatsApp user.** Session work for one user is serialized (a lock on that user's row, held for the step). A live session that outlives the JWT about to be minted (it expires more than one JWT lifetime from now) is reused. Otherwise the user's sessions are deleted and one new session is created. The session carries no IP address and no user agent. Concurrent requests for one user therefore end with one session, and a token is never signed from a session that dies before the token does. 7. Sign the JWT from that session (the jwt plugin's issuer, audience, lifetime and `{sub, sid}` payload) and return it. @@ -90,7 +90,16 @@ The WhatsApp service caches the token per BSUID for under 15 minutes and calls a ### 3. A non-interactive address domain -A second reserved domain (name: Open Question 1; working name `@no-login.ansari.chat`) for service users and WhatsApp users: +A second reserved domain, **`@noreply.askansari.ai`** (owner decision 1), for service users and WhatsApp users. The kind of account is a prefix of the local part, followed by its id or name: + +| Account | Address | How many | +|---|---|---| +| WhatsApp user | `whatsapp-@noreply.askansari.ai` | one per person who messages Ansari on WhatsApp | +| Service user | `service-@noreply.askansari.ai`, e.g. `service-whatsapp@…`, later `service-mcp-faithful-answers@…` | one per service that holds an API key | + +Prefixes keep each kind together in listings and logs, and let a `LIKE 'whatsapp-%'` count use the email index. The reserved check looks only at the domain, and nothing ever finds a user by email, so the prefix carries no security weight. Ordinary `@askansari.ai` addresses (staff, admins) are not reserved and keep working. + +The reserved domain is: - refused by sign-up with the same 409 as a taken address (no oracle); - skipped by the reset-request sender with the same silent success as an unknown address (no mail attempt); @@ -110,7 +119,7 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA - [ ] The create script prints the raw key exactly once. List, rotate and revoke print ids, names, prefixes, permissions, limits and dates, never a key or a hash. - [ ] Every script refuses to run without a database URL, shows the account and writes only with a matching `--confirm ` (a mismatch writes nothing), prints the database host before writing, and logs the key id it acted on. - [ ] The create script refuses `remaining` without `refillAmount` and `refillInterval`, and refuses a missing rate limit. -- [ ] The service-user script creates the user and its `service` account row in one transaction, and refuses an existing service name. +- [ ] The service-user script creates `service-@noreply.askansari.ai` and its `service` account row in one transaction, and refuses an existing service name. - [ ] Rotate leaves two live keys, both verifying. Revoke makes the revoked key fail at the exchange with the 401 body on the next request. - [ ] Exhausting a refilled budget refuses the key and keeps the row; after the refill interval the same key verifies again (pins the probe result). @@ -123,6 +132,7 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA **Exchange: identity** - [ ] A new BSUID creates exactly one user, one `whatsapp` account row and one session. It returns a JWT that verifies against apps/auth's JWKS with the same `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, payload `{sub, sid}` naming that user and session. +- [ ] The created user's address is exactly `whatsapp-@noreply.askansari.ai`, where `` is the token's subject. - [ ] The same BSUID twice returns the same `sub` and the same `sid`; the user has one session. - [ ] A session expiring within one JWT lifetime is replaced: the old one is deleted, and the token's `sid` names the new one. - [ ] Concurrent first requests for one BSUID produce one user and one account row, and all return a token for that user. Concurrent requests for an existing user leave exactly one session. The tests exercise the conflict and lock paths themselves (pglite serializes connections, so sequential calls would prove nothing). @@ -137,6 +147,7 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA **Reserved domain** - [ ] Sign-up with a non-interactive-domain address (any case, surrounding spaces) returns the same 409 body as a taken address. +- [ ] Near misses are not reserved: `someone@askansari.ai`, `x@noreply.askansari.ai.example.com` and `x@notnoreply.askansari.ai` sign up normally. - [ ] A reset request for an existing non-interactive-domain user returns the same response as an unknown address, and the mailer is never called. - [ ] The `user.create.before` hook refuses both reserved domains on every Better Auth path. - [ ] Sign-in with a non-interactive-domain user's address fails exactly as for an unknown address. @@ -153,7 +164,7 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA ## Constraints -- **Risk tier High** (auth model, new table, new credential type): porch's consultation on spec, plan and PR, plus the security architect's 3-way review at each gate. The owner answers decisions 1–4 at the spec-approval gate. +- **Risk tier High** (auth model, new table, new credential type): porch's consultation on spec, plan and PR, plus the security architect's 3-way review at each gate. The owner answers decisions 2–4 at the spec-approval gate (decision 1 is answered). - **Pin, do not bump.** The plugin's newest release requires core 1.7.14; bumping core is a separate, joint change. - **Schema discipline.** `schema.ts` is hand-written; the table is added there in its style. `drizzle-kit generate` → reviewed SQL → applied by a human with `psql -v ON_ERROR_STOP=1 --single-transaction` against the auth database. Never `db:push`. Main numbers migrations: `architect:main` is messaged before the journal entry is committed. Migration before code at deploy. - **Two databases.** Everything here is in the auth database and apps/auth. apps/api changes only in tests. No query crosses the two. @@ -204,9 +215,9 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' ## Open Questions -**Critical: owner decisions, answered at the spec-approval gate (recommendation given, not settled)** +**Critical: owner decisions, answered at the spec-approval gate (decision 1 answered; 2–4 have a recommendation, not settled)** -1. **The non-interactive domain name.** Recommendation: `@no-login.ansari.chat`. It says what the address is, sits under a domain the project controls, and cannot be confused with `@system.ansari.chat`. +1. ~~The non-interactive domain name.~~ **Decided by the owner (2026-10-28):** `@noreply.askansari.ai`, a fixed reserved subdomain with the account kind as a local-part prefix (`whatsapp-`, `service-`). This follows the common convention (GitHub's `users.noreply.github.com`, Apple's `privaterelay.appleid.com`). A hostname per user (e.g. `whatsapp-.askansari.ai`) was considered and rejected: the reserved check would have to pattern-match hostnames instead of testing one fixed suffix, and the id would sit in a DNS label. The bare `@askansari.ai` was rejected because it would refuse real staff addresses. 2. **Store the BSUID raw in `account.account_id`, or HMAC it with an apps/auth secret.** Recommendation: raw. The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a new env var and a rotation story that re-keys every row. 3. **The WhatsApp key's limits**, sized from the legacy WhatsApp service's traffic. The probe shows a refilled daily budget is safe: an exhausted key is refused and kept, and verifies again after the refill. So the decision is two numbers: a per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow`, e.g. per minute) and a daily budget (`refillAmount` per 24 h). Recommendation: both, at about twice the legacy peak. The create script requires both explicitly (no defaults), and the values are recorded in the runbook. One exchange per BSUID per 15 minutes means the budget scales with active conversations, not messages. If the owner prefers a single cap, the per-window ceiling alone is safe; a budget without refill is not offered, because it destroys the key when exhausted. 4. **The leaderboard: migrate from its static secret now or later.** Recommendation: later. Nothing forces it, and this spec ships one consumer. diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index 1c1e8fd4..a1661bc3 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -24,3 +24,8 @@ schema mirrors the plugin model with generator column names. - Codex points folded in: key referenceId bound to the WhatsApp service user (resolved by a `service` account row, never by email); one session per user under a per-user lock, near-expiry sessions replaced; wa-@domain address; 403 for banned. + +## 2026-10-28: owner decision 1 + +- Domain: `@noreply.askansari.ai` (fixed reserved subdomain). Addresses use a kind prefix: `whatsapp-@…` (the user's own UUID, chosen by the exchange + before insert, so the address is unique by construction) and `service-@…`. Considered and rejected: a hostname per user, and the bare @askansari.ai. From 45e7d494f3265d4178c5b8bf04b132642866673c Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 15:59:21 +1100 Subject: [PATCH 065/247] [Spec 329] Specification revised for security review (iteration 2) --- .../329-specify-iter2-input-claude.md | 73 +++ .../329-specify-iter2-input-codex.md | 51 ++ .../329-apps-auth-service-api-keys-bet.md | 464 ++++++++++++------ codev/state/spir-329_thread.md | 10 + 4 files changed, 450 insertions(+), 148 deletions(-) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-claude.md create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-codex.md diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-claude.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-claude.md new file mode 100644 index 00000000..914c7f3d --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-claude.md @@ -0,0 +1,73 @@ +I'll ground this review in the actual code before flagging anything.## Review: spec 329 — service API keys and the WhatsApp token exchange + +Grounded against the live tree on `develop` (spec 300 merged, `2c84795`). Files I read: `packages/auth/src/index.ts`, `schema.ts`, `reserved.ts`, `users.ts`, `limits.ts`, `apps/auth/src/app.ts`, `middleware/blocked-paths.ts`, `routes/account.ts`, `alerts.ts`, `apps/api/lib/auth/resolve-user.ts`, `system-accounts.ts`. I read only; nothing in the tree was changed. + +Where I lean on library behaviour the spec does not record as probed, I say so inline. + +--- + +### 1. WhatsApp key → non-WhatsApp user; delegation boundary + +**1.1 No property check on the resolved user — blocking.** The whole delegation guarantee rests on one unstated premise: that nothing but the exchange ever writes an `auth.account` row with `provider_id='whatsapp'`. The route looks up `(provider_id='whatsapp', account_id=bsuid)` and mints for whatever `user_id` comes back, with no assertion about that user. If a `whatsapp` row ever lands on a pre-existing user — a future WhatsApp/Meta social provider configured with that `providerId` (better-auth links social accounts to an existing user; that linking behaviour is *not* recorded in your probe, so treat it as unverified), a backfill script, a cutover copy, or an operator mistake — the key mints a token for that user, and if the user is an admin that token opens `/api/account/*` and the admin surface, since `users.role` is the only admin truth. The fix is one query inside the transaction you already open: refuse unless the resolved user's address is under the non-interactive domain, `registered_via='whatsapp'`, `role` is not `admin`, and the user's *only* account row is the `whatsapp` one. Note the criteria currently pin the weaker invariant ("no `sub` of a user without a `whatsapp` account row"); the one worth pinning is "only a user whose sole account row is `whatsapp`". + +**1.2 Missing service user presents as a revoked key — should-fix.** The owner check resolves the WhatsApp service user by its `service` account row on every request. The spec never says what happens when that row is absent (service user deleted, cascade from a user delete, or script not yet run): the `referenceId` comparison simply fails and the caller gets the one 401. That is a misconfiguration wearing the costume of a revoked key — the exact confusion the 500 rule exists to prevent. Make "service user not resolvable" a 500 plus an alert, and assert exactly one row (the new unique index gives you that only after migration `0001` is applied). + +**1.3 Key-creation path and hashing unspecified — should-fix.** The spec says keys are issued by operator scripts but never says *how*: direct row insert (which must reproduce the plugin's hash exactly, including any server-side salt the probe did not record) or in-process `auth.api.createApiKey` (whose session/`referenceId`/`enableMetadata`/`permissions` requirements the probe also did not record). This matters because a key created with the wrong hash or a dropped `permissions` field fails at the exchange with the same byte-identical 401 as a revoked key — undiagnosable by design. Record the mechanism and the probe that proves it. + +### 2. Oracle, enumeration, timing + +**2.1 Membership-and-ban oracle under key compromise, with no signal — should-fix.** Any key holder can ask "has this BSUID talked to Ansari, and is that user banned" and get 200 / 403 / new-user. BSUID space is unenumerable, so this is a targeted-confirmation leak, not a harvest — but for a service that answers religious questions, confirming a specific person's use is a serious privacy outcome. The structural problem is that the only proposed detection (Open Question 13, an alert on user creations) is blind to exactly this: probing an *existing* BSUID writes no row. Make the mandatory signal volume-per-key on the exchange (requests, not creations), together with alerts on `USAGE_EXCEEDED` and `RATE_LIMITED`, which are otherwise invisible on both sides since everything collapses to 401. `apps/auth/src/alerts.ts` already has the Sentry pattern, so this is cheap. + +**2.2 Body never echoed, and logging surface wider than `console` — should-fix.** The 400 body is constant and key-first ordering is right, so the public oracle story is clean. But the "no BSUID in any log" criterion only captures `console.*` from the exchange. Sentry breadcrumbs/HTTP integrations and any request-logging middleware are not covered, and a request body is the classic way a BSUID or a disguised phone number escapes. Add: no Sentry event from the exchange carries the request body, and no response echoes the input. + +**2.3 Timing — nit.** New-BSUID (lock + two inserts) vs existing (one select) vs 401 are distinguishable by latency, but only to someone who already knows what they sent. Say so in the spec so the plan does not invent constant-time padding. + +### 3. Concurrency and consistency + +**3.1 Session creation and JWT signing mechanism unspecified — blocking.** `auth.session.token` is `notNull().unique()` (`packages/auth/src/schema.ts:70`) and the bearer plugin accepts an *unsigned* bearer, deliberately (`packages/auth/src/index.ts:344-346`). So every session this route creates carries a 30-day full-account credential — one that can reach `DELETE /api/account`. The spec never says who generates that token, with what entropy, whether the session is written by hand or via `internalAdapter.createSession` (the pattern `apps/auth/src/routes/account.ts:192` already uses), how the JWT is then signed from it, or that the session token must never be logged or returned. For a High-tier auth spec this is the one gap I would not let through to the plan: it invents a new long-lived credential without specifying its generation or handling. Name the mechanism, and add the session token to the never-log list. + +**3.2 The lock cannot be tested on pglite, and the seam test is vacuous — should-fix.** The criteria admit pglite serializes connections, then assert "concurrent requests for an existing user leave exactly one session" via "test seams". A seam in your own code proves call ordering, not mutual exclusion: delete the `FOR UPDATE` and the seam test still passes. By your own lessons ("test transactional/atomic behavior against a real DB, not mocks"; "prefer failures that are loud over checks that are quiet"), these two cases need a real Postgres, or the criterion must state what the seam does and does not prove plus a source-level assertion that the session step runs inside the locking transaction. + +**3.3 Signing must happen under the lock, and the claim needs softening — should-fix.** "A token is never signed from a session that dies before the token does" is not achievable: a concurrent ban (step 5), a `DELETE /api/account`, or a near-expiry replacement can kill the session after signing. Specify that signing happens inside the same transaction/lock, restate the invariant as "the session outlives the token at signing time", and add the missing cross-spec contract item: #327 must re-exchange once on a 401 from apps/api. + +**3.4 User deleted mid-exchange — should-fix.** Open Question 12 makes delete-then-recreate normal. An in-flight exchange that resolved a user id can then insert a session against a cascaded-away user → FK violation → 500. Your spec-300 lesson is literally "guard a first-use insert against a concurrent delete" (and `apps/api/lib/auth/resolve-user.ts:135-160` shows the pattern). Specify the behaviour and add the scenario. + +**3.5 The new unique index is the deploy-blocking step — nit.** `unique(provider_id, account_id)` is global over `auth.account`, which today holds only `credential` rows (`schema.ts:105-110`), so duplicates are unlikely — but "unlikely" belongs in a pre-apply check in the runbook, not in a reviewer's head. Also note the constraint build takes an ACCESS EXCLUSIVE lock on a table every sign-in reads. + +### 4. Availability and failure modes + +**4.1 The pre-401 database check misses the most likely outage — blocking.** If `auth.apikey` is absent or shaped wrong (migration `0001` not applied, or a later bump renaming a column), the plugin catches the relation error and returns `INVALID_API_KEY`; a generic `select 1` liveness probe succeeds; the route answers 401. That is the #165 class — code shipped, migration not run — reproduced inside the new credential path, and the risk table claims it is mitigated when it is not. The check before a 401 must touch the `apikey` relation itself, a boot-time assertion should fail loudly if it is missing (precedent: the boot assertion noted in `packages/auth/src/users.ts`), and the criteria need "relation absent → 500, never 401". + +**4.2 Budget exhaustion is a service-wide outage with no containment — should-fix.** The exchange is the only route outside the better-auth handler with no per-user budget; `limits.ts` and `routes/account.ts` establish the opposite convention (`ACCOUNT_ENDPOINT`, 60/min per user, via the existing `consumeWindow`). One looping conversation or a token-cache bug on one BSUID can therefore consume the entire daily budget and take every WhatsApp user down, reported as 401. Add a per-BSUID budget inside the key's outer bound. Also size the budget against instances × restarts, since spec 321's 15-minute cache is per process. + +**4.3 Key deletion destroys the only trail — should-fix.** Expiry prunes the row on verify, and revoke deletes by id, so the forensic record of a key that mattered is script stdout. Prefer revoke = `enabled=false` (your probe shows disabled keys are refused and kept), record issuance/rotation/revocation somewhere durable (runbook entry or a Sentry event at creation), and either add an expiry-approaching alert or drop `expiresAt` on the production key and drive rotation from the runbook. + +**4.4 Internal `/token` path interactions — nit.** If signing goes through the jwt plugin's endpoint in-process, apps/auth's perimeter hooks and `TOKEN_EXCHANGE_PER_SESSION` apply, its 429 has no mapping in your contract table, and a `rate_limit` row accrues per WhatsApp session. Specify the mapping or the bypass. + +### 5. Untestable criteria and missing scenarios + +**5.1 "Blocked in every case and prefix variant, as the admin paths do" is false as written — should-fix.** In `apps/auth/src/middleware/blocked-paths.ts`, only `/api/auth/admin` gets prefix treatment (lines 17, 55-58); every other `BLOCKED_AUTH_PATHS` entry is matched against the exact canonical path via `BLOCKED_EXACT`. Five hardcoded `/api-key/*` strings therefore get exact matching only, and the suffix variants in Test Scenario 10 would fall through. Worse, a hardcoded list goes stale: derive the paths from `apiKey().endpoints` exactly as `ADMIN_PATHS` derives from `admin().endpoints` (`packages/auth/src/index.ts:140-148`), add an `/api/auth/api-key` prefix rule, and assert the derived list is length 5 today — so a plugin bump that adds an endpoint fails loudly instead of quietly publishing it. + +**5.2 The logger criterion can be satisfied by destroying it — should-fix.** `logger: { disabled: true }` passes every log-capture assertion. Add criteria that a non-error better-auth log still appears with its level and message, that an error argument reduces to exactly `{name, code}`, and that the test feeds a known driver-error object (and a near-miss that must survive). + +**5.3 "`start` not stored" rests on an unrecorded option — should-fix.** Nothing in the probe table records a config that suppresses `start`, so as written this criterion may be unsatisfiable without the script nulling the column after create. State the mechanism, and make the test stronger than "`start` is null": assert no column of the row contains any substring of the raw key. + +**5.4 Missing scenarios — should-fix.** (a) `auth.apikey` absent → 500 (4.1); (b) user deleted mid-exchange (3.4); (c) the exchange rejects a session token and a JWT presented as the bearer — i.e. no auth fallback — and is not reachable under `/api/auth/*`; (d) CORS: `corsFor(CORS_ORIGIN)` is global (`apps/auth/src/app.ts:62`), so the exchange inherits CORS; it should return no allow-origin for any origin, making browser use structurally impossible; (e) concurrent verifies with the *same* key (the probe tested sequential accounting only). + +**5.5 Staging smoke is someone else's gate — nit.** It depends on #327's amendment landing; name who signs it off so it cannot block or silently pass this PR. + +### 6. Standing invariants + +No contradiction found, and two claims I checked out as accurate rather than assumed. The exchange sits outside the handler path exactly as the account router does (`app.ts:62-67`), so `ROUTER_RULES` all-false is genuinely preserved. And the Current State claim that a second reserved domain "passes apps/api's first use unchanged" is correct for a non-obvious reason: apps/api keeps its *own* copy of `isSystemAddress` (`apps/api/lib/auth/system-accounts.ts:12-31`) rather than importing `@ansari/auth`'s. One implementation trap follows from that (should-fix): extend `isReservedAddress` only, and do **not** widen the exported `isSystemAddress` / `SYSTEM_EMAIL_DOMAIN` — those are the system-domain invariant's name and are asserted by existing tests. Two additions to the never-log rule: the session token (3.1) and the raw key, with coverage over Sentry, not just `console`. + +### 7. Scope + +**7.1 Missing: a documentation criterion — should-fix.** The design leans on the runbook for the limit numbers, rotation, expiry policy and the migration-before-code order, and `docs/self-hosting.md` is listed only as "to be updated". At risk tier High, with a new operator-held credential, that belongs in the success criteria with its contents named. + +**7.2 Limit numbers leave their single home — nit.** `packages/auth/src/limits.ts` declares itself "the only place [limit numbers] are written down" and tests import from it. The key's numbers would live only in a DB row and the runbook. Record them there as documented constants the create script validates against, while keeping the no-defaults rule. + +**7.3 Otherwise the scope is tight — nit.** The non-interactive domain, the blocked surface and the redacted logger are all load-bearing; deferring the leaderboard (OQ4) is right. Two guardrails for the plan: do not let #314's verify route creep in, and land the global logger redaction as its own commit, since it changes every better-auth log line in apps/auth. + +--- + +**Verdict: REQUEST CHANGES** — (1) assert properties of the resolved user before minting (sole `whatsapp` account row, non-interactive domain, `registered_via='whatsapp'`, not `admin`); (2) specify session creation, session-token generation and JWT signing, and add the session token to the never-log list; (3) make the pre-401 database check touch the `auth.apikey` relation, with a boot assertion and a "relation absent → 500" criterion. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-codex.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-codex.md new file mode 100644 index 00000000..f483cb15 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter2-input-codex.md @@ -0,0 +1,51 @@ +## 1. Delegation boundary + +- **Blocking — Existing account rows can cross the boundary.** Looking up only `(provider_id='whatsapp', account_id=bsuid)` proves that the user has a WhatsApp account, not that they are exclusively a WhatsApp user. The schema permits the same user to also have `credential`, `service`, or another provider account, and permits their `users.role` to become `admin`. The exchange must atomically require `registered_via='whatsapp'`, the non-interactive domain, a non-admin role, and exactly one account row whose provider is `whatsapp`; otherwise refuse. This eligibility check must occur under the same user lock as session issuance. The current `grant-admin.ts` also refuses only the system domain, so it must refuse the new domain. + +## 2. Oracles, enumeration, and timing + +- **Should-fix — The route exposes BSUID state through timing and mutation.** A valid key gets substantially different work for an existing BSUID versus a missing one—lookup/reuse versus a transaction, inserts, conflict handling, and session creation. A key holder can therefore test whether a BSUID was previously registered, while guesses also create durable users. The intentional 403 separately reveals ban status. If this is accepted because the WhatsApp service is the delegation authority, state that threat assumption explicitly; otherwise add mitigation and monitoring. + +- **Should-fix — Reserved-domain anti-enumeration relies on unprobed library behavior.** “Sign-in exactly as unknown” and reset responses being identical do not establish timing equivalence between an existing passwordless user and an unknown address. The specification records no Better Auth probe for these paths. Test latency/query behavior or explicitly accept that only response-shape equivalence is guaranteed. + +## 3. Concurrency and consistency + +- **Blocking — Ban and token issuance race.** The ban check precedes the user-row lock. `banUserByEmail` updates the user and deletes sessions transactionally, but an exchange can read “not banned,” let the ban delete sessions, then acquire the lock and create a fresh session for the now-banned user. Acquire the user lock first, re-read eligibility and ban state under it, and perform session deletion/creation in that same transaction. + +- **Blocking — “Session outlives JWT” is not precisely guaranteed.** Testing whether a session expires more than 15 minutes from an earlier `now` can still yield a JWT whose actual `exp` is later than the session expiry. Session acceptance must compare against the exact issuance timestamp/expected JWT expiry, preferably with a safety margin, and the transaction must commit before the token is returned. + +- **Should-fix — PGlite seams do not prove PostgreSQL locking.** They can prove conflict-recovery logic but not that `FOR UPDATE`, concurrent account insertion, role changes, and session replacement serialize as claimed. Add a real PostgreSQL concurrency test or narrow the success criterion to what PGlite actually proves. + +## 4. Availability and failure modes + +- **Blocking — The post-refusal health check cannot reliably distinguish outage from refusal.** The plugin discards the original driver error. A failed verification followed by a recovered health check becomes 401, while a genuine unknown key followed by a new outage becomes 500. The sustained-outage test misses both transitions. Preserving the verification operation’s database failure—or relaxing the claimed distinction—is required. + +- **Blocking — Unknown-key traffic is unbudgeted database amplification.** Every well-shaped random key performs plugin database work and then another health query, with no applicable key budget and, correctly, no per-IP rule. This public route therefore offers an unauthenticated database DoS path. Add a non-IP global/circuit-breaker design, cache bounded health information, or otherwise avoid the second query without weakening outage semantics. + +- **Should-fix — Budgets are per key, not per service.** Rotation doubles the service’s effective ceiling, and additional live keys multiply it further. Decide whether limits are intended per credential or per service and test the aggregate behavior. The actual production limits also need resolution before approval rather than remaining “about twice peak.” + +- **Should-fix — Request parsing needs a size and ordering design.** Express JSON middleware normally parses before the handler if mounted conventionally, contradicting “key first,” and no body limit is specified. Define route-local authenticated parsing with a strict byte limit so invalid or oversized bodies cannot consume memory before authorization. + +## 5. Success criteria + +- **Blocking — Cleartext `start` storage contradicts the recorded probe.** The probe says the plugin stores the prefix plus three random characters, while the criteria require `start` not to be stored. The specification names no probed plugin option or implementation step that changes this default. Specify, probe, and test the exact configuration. + +- **Blocking — JWT issuance from a hand-created session is unspecified and unprobed.** The specification does not identify a supported Better Auth API that accepts the created session and emits the normal JWT, including how the session token is generated and supplied. Tests can detect a mismatch, but the proposed implementation path is not established by the recorded probes. + +- **Should-fix — The logging criterion is broader than its test.** Capturing `console.*` cannot prove that no Sentry event, platform access log, Better Auth logger sink, or other alert contains an Authorization header, BSUID, or driver object. Scope the assertion to controlled application sinks and separately configure/test Sentry request-header redaction. The custom Better Auth logger contract itself should also be probed. + +- **Should-fix — Missing security scenarios.** Add tests for a WhatsApp account attached to an email, service, or admin user; promotion after initial creation; the ban/issuance race; transient database failure/recovery; invalid-key flooding; multiple-key aggregate budgets; and a database URL containing credentials to prove “print host” never prints userinfo. + +## 6. Standing invariants + +- **Blocking — Admin isolation is incomplete.** Admin truth remains correctly located in `users.role`, but that makes the missing role check especially consequential: a WhatsApp-linked user promoted through the existing operator script would receive an ordinary JWT and then be recognized as an admin by the established HTTP check. Refusing the new domain in `grant-admin.ts` and rechecking `users.role` during every exchange are required. + +- **Should-fix — Secret-safe host output needs a precise definition.** “Print the database host” must mean parsed hostname and optional port only—not the raw URL, username, password, query string, or database URL parse error. Apart from this and the admin-boundary issue, the two-database, public-origin, system-domain, per-IP, and role-source invariants are preserved. + +## 7. Scope + +- **Blocking — Key delivery and storage are missing and contradict “no secret leaves its moment.”** The WhatsApp service must receive and persist the raw key, so it cannot exist only in the creating process’s output. Specify the secure operator-to-Railway handoff, consumer secret storage, rotation procedure, and observability redaction. Printing once is not itself a complete secret-lifecycle design. + +- **Should-fix — Operational documentation is promised but not required.** `docs/self-hosting.md` and the runbook are mentioned, yet absent from the success criteria. Make key issuance, secure handoff, rotation overlap, revocation, expiry/pruning, budget sizing, and migration/deploy order required deliverables. The generic service-key primitive is otherwise justified; migrating the leaderboard remains correctly out of scope. + +**VERDICT: REQUEST CHANGES (close account/role delegation, ban and expiry races, outage classification and invalid-key DoS, cleartext `start`, JWT issuance feasibility, aggregate budgets, and secret handoff/storage).** \ No newline at end of file diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index d0739008..6686fb28 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -4,64 +4,117 @@ apps/auth knows only one kind of caller: a person who signs in. The only way into apps/api is a user JWT minted from a live Better Auth session. Nothing can act on behalf of a user who never signs in, and apps/auth has no way for a service to prove who it is. -The WhatsApp service (spec 321, PR #327) needs exactly that. A WhatsApp user is identified only by Meta's business-scoped user id (BSUID). They never visit a sign-in page, yet their messages must reach apps/api as an ordinary user, with threads, limits and deletion, under one stable UUID. Spec 321 assumes an apps/auth endpoint that takes a BSUID and returns a JWT. The endpoint does not exist, and the builder has run nothing against a live apps/auth. #314 (MCP) needs the same service-identity primitive later. +The WhatsApp service (spec 321, PR #327) needs exactly that. A WhatsApp user is identified only by Meta's business-scoped user id (BSUID). They never visit a sign-in page, yet their messages must reach apps/api as an ordinary user (threads, limits, deletion) under one stable UUID. Spec 321 assumes an apps/auth endpoint that takes a BSUID and returns a JWT. The endpoint does not exist, and the builder has run nothing against a live apps/auth. #314 (MCP) needs the same service-identity primitive later. Two things are missing: 1. **Service identity**: a credential a service holds, issued and revoked by an operator, hashed at rest, scoped per service and per action, with its own throttle. -2. **A delegation rule**: which users a given service may mint tokens for. For WhatsApp the rule is "only users reachable through a `whatsapp` account row, keyed by BSUID, by a key owned by the WhatsApp service". A WhatsApp key must never yield a token for an email user, an admin, a system identity, a service user or another provider's user. +2. **A delegation rule**: which users a given service may mint tokens for. For WhatsApp the rule is "only a user whose sole account row is `whatsapp`, keyed by BSUID, and only for a key owned by the WhatsApp service". A WhatsApp key must never yield a token for an email user, an admin, a system identity, a service user, another provider's user, or a user who has gained any second account row or role. Affected: the WhatsApp service and its users (no path into apps/api today), the operator (who will hold a new credential type), and the security model of apps/auth (a new credential, table and public route). ## Current State -- **Callers.** apps/auth serves Better Auth's router at `/api/auth/*`, the account endpoints at `/api/account/*` (bound to the caller's own JWT and a live `sid`), `/health` and a diag route. There is no service credential of any kind. The leaderboard uses a static shared secret on apps/api; MCP (`/v2/mcp-complete`) has none (#314). +- **Callers.** apps/auth serves Better Auth's router at `/api/auth/*`, the account endpoints at `/api/account/*` (bound to the caller's own JWT and a live `sid`), `/health` and a diag route. `corsFor(CORS_ORIGIN)` is mounted globally, ahead of every route. `express.json` is not global. There is no service credential of any kind. The leaderboard (the IslamicMMLU benchmark harness, driving `POST /v1/chat/completions` on apps/api) uses a static shared secret; MCP (`/v2/mcp-complete`) has none (#314). - **Tokens.** apps/api accepts only apps/auth's EdDSA JWT (15 minutes, payload `{sub, sid}` plus `iss`, `aud`, `iat`, `exp`), verified against the public JWKS. On first use, `requireUser` → `resolveUser` fetches `GET /api/account/me` with that JWT, which requires the `sid` to name a live row in `auth.session`, and then inserts `public.users` under the same UUID. A bare signature with no session behind it is therefore useless: first use fails. -- **Reserved addresses.** `@system.ansari.chat` is reserved. Sign-up refuses it with the same 409 as a taken address; the reset sender skips it silently. The `user.create.before` database hook refuses it on every path, because spec 300 says the auth database never holds one (system identities live only as app rows found by `system_key`). apps/api's `resolveUser` refuses a system-domain address and nothing else, so a second reserved domain passes apps/api's first use unchanged. There is no second reserved domain. -- **Accounts.** `auth.account` carries `unique(user_id, provider_id)` (one row per provider per user). There is no `unique(provider_id, account_id)`. Only the `credential` provider exists today (no social providers are configured). -- **Blocked paths.** `BLOCKED_AUTH_PATHS` (the admin plugin's whole surface, change-email, update-user, change-password) goes into `disabledPaths`, which better-auth matches exactly. apps/auth also blocks every case and prefix variant before the handler. Both are pinned by tests over `ADMIN_PATHS`. -- **Rate limits.** `ROUTER_RULES` are all `false`: apps/auth sees apps/api's egress IP on every legacy call, so per-IP rules there would throttle everyone together. Routes outside Better Auth's handler (the account routes) carry no per-IP rule at all; they have a per-user budget. -- **Operator scripts.** `grant-admin.ts` shows an account, then acts only on an explicit `--confirm `. It never prints secrets, and runs from the auth image or from a checkout with the auth `DATABASE_URL`. No script refuses to run outside the image, and none prints the database host. The issue's "refuse to run outside the auth image" describes a guard that does not exist; a marker could not prove the location anyway. This spec drops it (a correction to the issue, directed by the security architect). +- **Sessions are credentials.** `auth.session.token` is unique, and the bearer plugin accepts an *unsigned* session token as a bearer (kept deliberately for the compatibility layer). A session token is therefore a 30-day account credential that can reach `/api/auth/token`, `/api/account/*` and `DELETE /api/account`. The jwt plugin's `/token` endpoint is wrapped by apps/auth's perimeter hook (`TOKEN_EXCHANGE_PER_SESSION`, a `rate_limit` row per session). Its `signJWT` is `serverOnly` and has no route. +- **Reserved addresses.** `@system.ansari.chat` is reserved through `isReservedAddress`, which today is just `isSystemAddress`: + - sign-up refuses it with the same 409 as a taken address; + - the reset sender skips it silently; + - the `user.create.before` hook refuses it on every path (spec 300: the auth database never holds one). + + apps/api keeps its **own** copy of `isSystemAddress` / `SYSTEM_EMAIL_DOMAIN` and refuses only the system domain at first use. `grant-admin.ts` refuses the system domain and nothing else. +- **Accounts.** `auth.account` carries `unique(user_id, provider_id)`. There is no `unique(provider_id, account_id)`. Only the `credential` provider exists today. +- **Blocked paths.** `BLOCKED_AUTH_PATHS` goes into `disabledPaths`, which better-auth matches exactly. apps/auth's `blocked-paths` middleware canonicalizes the path, then gives **prefix** matching only to `/api/auth/admin`; every other blocked path is matched exactly. `ADMIN_PATHS` is derived from `admin().endpoints`, so a plugin bump cannot silently publish a route. +- **Limits.** `ROUTER_RULES` are all `false` (apps/auth sees apps/api's egress IP on legacy calls). Routes outside Better Auth's handler carry no per-IP rule. The account routes have a per-user budget (`ACCOUNT_ENDPOINT`, through `consumeWindow`). `packages/auth/src/limits.ts` is the single home of limit numbers. +- **Alerts.** `apps/auth/src/alerts.ts` sends Sentry events that carry counts and hash prefixes, never identifiers. +- **Boot.** apps/auth runs no schema assertion at boot. `users.ts`'s header mentions one, but none exists. +- **Operator scripts.** `grant-admin.ts` shows an account, then acts only on an explicit `--confirm `; it prints no secrets and runs from the auth image or a checkout with the auth `DATABASE_URL`. No script refuses to run outside the image, and none prints the database host. The issue's "refuse to run outside the auth image" describes a guard that does not exist, and a marker could not prove it. This spec drops it (correction directed by the security architect). - **Better Auth's logger.** `createAuth` sets no `logger`, so better-auth logs error objects as they are. -- **`@better-auth/api-key` 1.7.7.** Peer `better-auth ^1.7.7`. Read from the published package and **proven against pglite** (throwaway probe in the specify phase, with the plugin wired to the real `auth` schema): - - The `apikey` model: `configId` (default `"default"`), `name`, `start`, `referenceId` (the owning user's id, since `references` defaults to `"user"`), `prefix`, `key` (SHA-256, base64url), `refillInterval`, `refillAmount`, `lastRefillAt`, `enabled`, `rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`, `requestCount`, `remaining`, `lastRequest`, `expiresAt`, `createdAt`, `updatedAt`, `permissions` (JSON text), `metadata` (JSON text, kept only with `enableMetadata: true`). The owner column is `referenceId`, not `userId` as older docs show. - - HTTP endpoints: `/api-key/create`, `/api-key/get`, `/api-key/list`, `/api-key/update`, `/api-key/delete`. `verifyApiKey` and `deleteAllExpiredApiKeys` are `serverOnly`, with no route. - - `enableSessionForAPIKeys` defaults to `false`. Only when it is true does a before-hook turn an `x-api-key` header into a session on every Better Auth route. - - `verifyApiKey` order: hash found → `enabled` → `expiresAt` → `permissions` → exhausted → consume `remaining` and a rate-limit slot, atomically against the row. Probe results: +- **Better Auth transactions.** better-auth 1.7.7's internal adapter resolves its adapter through `getCurrentAdapter`, and `runWithTransaction` binds internal-adapter calls to an open transaction. +- **`@better-auth/api-key` 1.7.7.** Peer `better-auth ^1.7.7`. Read from the published package and **proven against pglite** (throwaway specify-phase probe, the plugin wired to the real `auth` schema): + - The `apikey` model: `configId` (default `"default"`), `name`, `start`, `referenceId` (owning user's id; `references` defaults to `"user"`), `prefix`, `key` (SHA-256, base64url), `refillInterval`, `refillAmount`, `lastRefillAt`, `enabled`, `rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`, `requestCount`, `remaining`, `lastRequest`, `expiresAt`, `createdAt`, `updatedAt`, `permissions` (JSON text), `metadata` (JSON text, kept only with `enableMetadata: true`). The owner column is `referenceId`, not `userId`. + - HTTP endpoints: `/api-key/create`, `/api-key/get`, `/api-key/list`, `/api-key/update`, `/api-key/delete`. `verifyApiKey` and `deleteAllExpiredApiKeys` are `serverOnly`. + - `enableSessionForAPIKeys` defaults to `false`; only when true does an `x-api-key` header become a session on Better Auth's routes. + - `startingCharactersConfig.shouldStore` exists in the 1.7.7 types (default `true`: `start` keeps the first 6 characters, i.e. the prefix plus 3 random characters with `wa_`). + - `verifyApiKey` order: hash found → `enabled` → `expiresAt` → `permissions` → exhausted → consume `remaining` and a rate-limit slot, atomically against the row. | Case | Result | Row | |---|---|---| | `remaining` reaches 0, **no refill** | refused `USAGE_EXCEEDED` | **deleted** on the next verify | - | `remaining` reaches 0, **with refill** | refused `USAGE_EXCEEDED` until the interval passes, then refilled (first refill from a null `lastRefillAt` and later cycles both work) | kept | + | `remaining` reaches 0, **with refill** | refused `USAGE_EXCEEDED` until the interval passes, then refilled (first cycle from a null `lastRefillAt`, and later cycles) | kept | | per-window rate limit exceeded | refused `RATE_LIMITED` | kept | | expired | refused `KEY_EXPIRED` | **deleted** | + | disabled | refused | kept | | wrong permission | refused `KEY_NOT_FOUND`, `remaining` untouched | kept | - - Every failure is caught and returned as `{ valid: false, error }`. An error that is not an `APIError`, such as a database driver error, comes back as `INVALID_API_KEY`, the same code as an unknown key. **An outage is therefore indistinguishable from a bad key** in the result. Before returning, the plugin logs the caught error object through better-auth's logger, raw driver errors included. - - `start` keeps the first 6 characters in clear (with the prefix `wa_`: the prefix plus 3 random characters). A default key is the prefix plus 64 random letters. + - Every failure is caught and returned as `{ valid: false, error }`. An error that is not an `APIError` (a driver error, or a missing `auth.apikey` relation) comes back as `INVALID_API_KEY`, the same code as an unknown key. Before returning, the plugin logs the caught error object raw through better-auth's logger. + - The default key is the prefix plus 64 random letters. ## Desired State ### 1. Service identity -- `packages/auth` carries `@better-auth/api-key` pinned to the core version (1.7.7). Its `apikey` table is written by hand in `schema.ts`. It mirrors the plugin's model exactly, with the column names better-auth's generator would produce, in the existing `auth` schema style (uuid ids, `timestamptz`, `reference_id` a uuid referencing `auth.users` with cascade delete). The schema comment records the departures, as it does for the other tables. It ships as migration `0001`, generated, reviewed as SQL and applied by hand at deploy. Better Auth never changes the database shape. -- **A service is one auth user**, created only by an operator script. Its address is `service-@noreply.askansari.ai` (Part 3; e.g. `service-whatsapp@noreply.askansari.ai`), and it has no credential, role `user`, and exactly one account row `{ providerId: 'service', accountId: }`. That row is how the service user is resolved (never by email), as system identities are resolved by `system_key`. One user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates. A service user cannot sign in: there is no `service` provider in Better Auth, and it has no credential. -- **Permissions are fixed per service, in code.** The script has no free-form permission input: the `whatsapp` service always gets `{ whatsapp: ['mint'] }`, and `mcp-faithful-answers` will get `{ mcp: ['complete'] }` (#314). A key's permission and its owner always agree. -- **Keys are issued, listed, rotated and revoked only by operator scripts**, in `grant-admin.ts`'s posture: - - refuse to run without a database URL; - - show the service account first, and act only on an explicit `--confirm ` of that account; - - print the database host before any write; - - log which key id they acted on; - - print nothing secret, except the new key exactly once at creation. - - Each key has a per-service prefix, a name, `metadata` carrying the partner's client id, a per-window rate limit (`rateLimitTimeWindow`, `rateLimitMax`, required, no defaults), a daily budget (`remaining` with `refillAmount` and `refillInterval`, Open Question 3) and an expiry. **The script refuses `remaining` without a refill**: proven above, that configuration deletes the key the first time a burst exhausts it. Rotate creates a second live key for the same service, so the consumer can switch without downtime; revoke deletes one by id. The plugin pruning an expired key on verify is accepted: the scripts' key-id logging leaves the trail. -- **No HTTP surface.** The five `/api-key/*` paths join `BLOCKED_AUTH_PATHS` and are blocked like the admin paths, in every case and prefix variant. `enableSessionForAPIKeys` stays `false`, so a key is never a session anywhere on the router. -- **Verification is `auth.api.verifyApiKey`, in-process**, with the route's required permission passed in. **The route then also binds the key's `referenceId` to its own service user** (resolved by the `service` account row). A key for another service fails twice over: by permission and by owner. No verify route at the public origin is built here; #314 adds one. -- **Better Auth's logger is redacted.** `createAuth` configures better-auth's `logger` so an error argument is reduced to `{name, code}` and nothing else, keeping the plugin's catch-and-log from emitting driver errors. This applies to every better-auth log, which is the existing rule ("never log a raw driver error") made true for the library too. +**The table.** `packages/auth` carries `@better-auth/api-key` pinned to the core version (1.7.7). Its `apikey` table is hand-written in `schema.ts`: +- it mirrors the plugin's model exactly, with the column names better-auth's generator would produce; +- it follows the `auth` schema style (uuid ids, `timestamptz`, `reference_id` a uuid referencing `auth.users` with cascade delete); +- the schema comment records the departures, as for the other tables. + +It ships as migration `0001`, generated, reviewed as SQL, applied by hand before code, and numbered by main. The same migration adds `unique(provider_id, account_id)` to `auth.account`. Better Auth never changes the database shape. At boot, apps/auth asserts that `auth.apikey` exists and refuses to start if it does not (the #165 class: code shipped before its migration). + +**A service is one auth user**, created only by an operator script: +- address `service-@noreply.askansari.ai` (Part 3), no credential, role `user`; +- exactly one account row `{ providerId: 'service', accountId: }`, which is how it is resolved (never by email); +- one user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates; +- it cannot sign in: there is no `service` provider in Better Auth, and it has no credential. + +**Permissions are fixed per service, in code.** The script takes no free-form permissions. The `whatsapp` service gets `{ whatsapp: ['mint'] }`, and `mcp-faithful-answers` will get `{ mcp: ['complete'] }` (#314). + +**Keys are created in-process with `auth.api.createApiKey`**, server-side, with the service user's id. They are never inserted by hand, so the plugin's own hashing, `permissions` and `metadata` handling apply. `enableMetadata: true`; `startingCharactersConfig.shouldStore: false`. The plan's first probe proves this path: a script-created key verifies at the exchange, its row holds exactly the hash, and no column holds any substring of the raw key. + +**Key settings.** Each key has: +- a per-service prefix (`wa_` for WhatsApp) and a name; +- `metadata` carrying the partner's client id; +- a per-window rate limit (`rateLimitTimeWindow`, `rateLimitMax`) and a daily budget (`remaining` with `refillAmount` and `refillInterval` of 24 h), both required with no defaults. The create script validates them against bounds declared in `limits.ts`, the single home of limit numbers. Values are chosen by the operator from owner decision 3. +- **no expiry.** A production credential must not die silently, so rotation is driven by the runbook. The create script offers no expiry option. + +The script refuses `remaining` without a refill: probed above, that configuration deletes the key the first time a burst exhausts it. **Budgets are per key, not per service:** while two keys are live during a rotation, the service's ceiling doubles. The runbook says so, and rotation overlap is kept short. + +**Operator scripts** (create service user, create key, list, rotate, revoke) follow `grant-admin.ts`'s posture: +- refuse to run without a database URL; +- show the account, then write only with a matching `--confirm ` (a mismatch writes nothing); +- before any write, print the database **hostname and port only**, never the URL, user, password, query string or a URL parse error; +- log the key id acted on; +- print nothing secret, except the new key exactly once at creation; +- **revoke sets `enabled = false` and keeps the row** (refused and kept, as probed), so the trail survives. Rotate creates a second live key; the old one is revoked once the consumer has switched. + +Every create, rotate and revoke also sends a durable Sentry event (service, key id, name, action, no key material), so the record does not live only in a terminal. + +**Where the raw key lives.** In exactly two places, briefly in one and durably in the other: +- the create script's single output, in the operator's shell inside the auth service; +- the consumer's secret variable (the WhatsApp service's Railway variable), into which the operator pastes it directly. + +It is never put in chat, tickets, docs, git or logs. apps/auth stores only its hash. + +**No HTTP surface.** +- The blocked paths are derived from `apiKey().endpoints`, exactly as `ADMIN_PATHS` derives from `admin().endpoints`, with an assertion that the derived list has length 5 today; a plugin bump that adds an endpoint fails loudly. +- They join `BLOCKED_AUTH_PATHS`, and the middleware gains a `/api/auth/api-key` **prefix** rule like `/api/auth/admin`'s, so every canonicalized variant and suffix is blocked. +- `enableSessionForAPIKeys` stays `false`: a key is never a session anywhere on the router. + +**Verification is `auth.api.verifyApiKey`, in-process**, with the route's required permission. The route also binds the key's `referenceId` to its own service user, resolved by the `service` account row: +- a key for another service fails twice over, by permission and by owner; +- if the WhatsApp service user cannot be resolved, or resolves to more than one user, that is a misconfiguration: 500 plus an alert, never 401. + +No verify route at the public origin is built here; #314 adds one. + +**Better Auth's logger is redacted, not silenced.** `createAuth` configures better-auth's `logger` so an argument that is an error object is reduced to exactly `{name, code}`, while the level, the message and non-error arguments survive. This closes the plugin's catch-and-log path. It changes every better-auth log line in apps/auth, so it lands as its own commit. ### 2. The WhatsApp token exchange: `POST /api/whatsapp/session` -Mounted outside Better Auth's handler path, like the account routes, so no router per-IP rule sees it (the WhatsApp service calls from a Railway egress IP). `ROUTER_RULES` stays all `false`. The key's rate limit and daily budget are the throttle. +Mounted outside Better Auth's handler path, like the account routes, so no router per-IP rule sees it (the WhatsApp service calls from a Railway egress IP). `ROUTER_RULES` stays all `false`. +- **No CORS.** No response from the exchange, preflight included, carries `Access-Control-Allow-Origin` for any origin. Browser use is structurally impossible; the global `corsFor` does not apply to this route. +- **No other credential.** A session token or a JWT presented as the bearer is just an unknown key (401). There is no fallback to user authentication, and the route is not reachable under `/api/auth/*`. +- **Body parsing** is route-local, mounted after the key check, with a strict byte limit, so an unauthenticated or oversized body costs no parsing. **Contract** (replaces the assumption in spec 321 and the `X-Whatsapp-Api-Key` header in #327): @@ -69,220 +122,335 @@ Mounted outside Better Auth's handler path, like the account routes, so no route |---|---| | Request | `Authorization: Bearer `; JSON body `{ "bsuid": "" }` | | 200 | `{ "token": "" }`: the same EdDSA 15-minute `{sub, sid}` token apps/auth issues every client, signed from a live session of the BSUID's user | -| 400 | `{ "message": "Invalid request" }`: body not JSON, `bsuid` missing, not a string or not matching the BSUID pattern | +| 400 | `{ "message": "Invalid request" }`: body not JSON, too large, `bsuid` missing, not a string or not matching the BSUID pattern | | 401 | `{ "message": "Unauthorized" }`, byte-identical for **every** key refusal: missing, malformed, unknown, revoked, disabled, expired, budget-exhausted, rate-limited, wrong permission, or owned by another service | | 403 | `{ "message": "Forbidden" }`: the BSUID's user is banned | -| 500 | `{ "message": "Internal error" }` for anything else, including an auth-database outage. Never 401, so the caller does not read an outage as a revoked key | +| 429 | `{ "message": "Too many requests" }` with `X-Retry-After`: this one user's exchange budget is spent (the key itself is fine) | +| 500 | `{ "message": "Internal error" }` for anything else: database outage, `auth.apikey` missing, service user unresolvable, an ineligible user behind a `whatsapp` row, the user deleted mid-exchange | -Every `valid: false` from the plugin, including its own 429s, maps to the one 401. Because the plugin reports an outage as `INVALID_API_KEY`, the route **checks that the database answers before it returns 401**; if that check fails, the answer is 500. +No response echoes any input. + +**Contract duties of the consumer (#327):** send `Authorization: Bearer`; cache a token per BSUID for under 15 minutes; on a 401 **from apps/api**, re-exchange once, because a session can die after signing (ban, deletion), and the token's `sid` then fails first use. Treat 401 from the exchange as "my key is bad" (page the operator), 403 as "this user is banned", 429 as "slow this user down" and 500 as "apps/auth is failing". Spec 321 is not on develop, so its amendment lands on #327's branch, coordinated with that builder. + +**Outage versus refusal.** The plugin reports a database failure as `INVALID_API_KEY`, the same code as an unknown key. So before any 401 the route checks that `auth.apikey` itself answers: a query on that relation, not `select 1`, which would pass with the migration unapplied. If the check fails, the answer is 500. To keep unknown-key floods from doubling database work, a successful check is cached in-process for a few seconds. A sustained 401 volume per window raises an alert. + +**Accepted residuals:** +- an outage that begins between the verify and the check (or inside the cache window) is misclassified as 401; +- a database that recovers between them turns an outage into a 401; +- an unknown-key flood still costs one plugin lookup per request (no budget can attach to a key that does not exist), and is bounded by the cache and surfaced by the 401-volume alert. **Per request:** -1. Verify the key with permission `whatsapp: mint`. A refusal → database check → 401 (or 500). The key's `referenceId` must be the WhatsApp service user, else 401. -2. Validate the body and the BSUID pattern → 400. The key comes first, so an unauthenticated caller learns nothing about the body rules. A phone number is never accepted, stored or logged. -3. Look up `auth.account` where `provider_id = 'whatsapp'` and `account_id = `. The provider is fixed in code; the lookup never goes through email. -4. No row: create the user and the account row `{ providerId: 'whatsapp', accountId: , userId }` in **one transaction**. The user has no credential, `registered_via = 'whatsapp'`, the display name `WhatsApp user`, and the address `whatsapp-@noreply.askansari.ai`. The exchange generates the user's UUID itself before the insert, and writes the id and the address together in the transaction. The address is therefore unique by construction (ids are the primary key), and carries neither the BSUID nor a phone number. A new `unique(provider_id, account_id)` makes a BSUID map to exactly one user under concurrency: the losing request rolls back and uses the winner's user. -5. If the user is banned (the admin plugin's `banned`, honouring `banExpires`), delete all their sessions and answer 403. -6. **Exactly one session per WhatsApp user.** Session work for one user is serialized (a lock on that user's row, held for the step). A live session that outlives the JWT about to be minted (it expires more than one JWT lifetime from now) is reused. Otherwise the user's sessions are deleted and one new session is created. The session carries no IP address and no user agent. Concurrent requests for one user therefore end with one session, and a token is never signed from a session that dies before the token does. -7. Sign the JWT from that session (the jwt plugin's issuer, audience, lifetime and `{sub, sid}` payload) and return it. +1. **Key.** Verify with permission `whatsapp: mint` → on refusal, the relation check → 401 (or 500). Bind `referenceId` to the WhatsApp service user (unresolvable → 500 + alert; another owner → 401). +2. **Body.** Parse (route-local, byte-limited) and validate the BSUID pattern → 400. A phone number is never accepted, stored or logged. +3. **Resolve or create.** Look up `auth.account` where `provider_id = 'whatsapp'` and `account_id = `. The provider is fixed in code; the lookup never goes through email. With no row, create the user and the account row in **one transaction**: + - the exchange generates the user's UUID itself; + - the address is `whatsapp-@noreply.askansari.ai`, the display name `WhatsApp user`, `registered_via = 'whatsapp'`, and there is no credential; + - the account row is `{ providerId: 'whatsapp', accountId: , userId }`. + + `unique(provider_id, account_id)` makes a BSUID map to exactly one user under concurrency: the losing request rolls back and uses the winner's user. +4. **Lock, then decide.** In one transaction: + - **Lock** the user's row (`SELECT … FOR UPDATE`). + - **Re-read under the lock.** The user gone → 500 (deleted mid-exchange; the next message creates a new user). + - **Eligibility**, all required: the user's *only* account row is this `whatsapp` row; the address is under `@noreply.askansari.ai`; `registered_via = 'whatsapp'`; `role` is not `admin`. A violation → 500 plus a Sentry alert (user id only), never a token. + - **Ban** (admin plugin `banned`, honouring `banExpires`) → delete all the user's sessions, commit, 403. + - **Per-user budget** (`consumeWindow`, keyed by the user id, never the BSUID; numbers in `limits.ts`) → 429 when spent. + - **Session.** Reuse the user's live session only if it outlives the token being signed: its `expiresAt` is at least the token's exact `exp` plus a safety margin. Otherwise delete the user's sessions and create one with `internalAdapter.createSession`, the internal-adapter precedent in `routes/account.ts`. The session token comes from better-auth's own generator, is never returned, logged or sent to Sentry, and the session carries no IP address and no user agent. + - **Sign** the JWT with the jwt plugin's server-side `signJWT`, payload `{sub, sid}`, with the plugin's issuer, audience and lifetime. + - **Commit**, then return the token. + + The session write and the signing run inside that same transaction (better-auth's transaction context), so a concurrent ban or deletion cannot slip between the decision and the session. **Hazard the plan must rule out:** `FOR UPDATE` conflicts with the `KEY SHARE` lock that the session insert's foreign key takes. If the insert ran on another connection, the request would wait on its own lock. The plan proves, against a real Postgres, that the session write shares the locking transaction. If better-auth's transaction context cannot carry it, the builder raises the alternative with the architect before choosing one. + - **`/token` is not used.** Signing bypasses `/token`'s perimeter hook, so `TOKEN_EXCHANGE_PER_SESSION` and its `rate_limit` row do not apply. The per-user budget above replaces them for this route. + +The invariant is: **at signing time, the session outlives the token.** A ban, deletion or revocation after signing can still kill the session; the token then fails apps/api's first use, and the consumer re-exchanges once. + +The WhatsApp service caches the token per BSUID for under 15 minutes and calls apps/api with it. apps/api resolves the user on first use exactly as for a web user. **apps/api gains no WhatsApp code** (spec 321 success criterion). -The WhatsApp service caches the token per BSUID for under 15 minutes and calls apps/api with it. apps/api resolves the user on first use exactly as for a web user (`GET /api/account/me` with the token, `public.users` under the same UUID). **apps/api gains no WhatsApp code** (spec 321 success criterion). +**Accepted oracle residual.** A holder of the WhatsApp key can confirm whether a given BSUID has used Ansari, and whether that user is banned, both from the responses and from latency (new, existing and refused take different times). Guessed BSUIDs also create durable, inert users. This is accepted because the WhatsApp service is the delegation authority: it already holds every BSUID it would ask about. BSUIDs are not enumerable, so this is targeted confirmation, not harvesting. The mitigations are detection, not prevention: per-key request-volume alerts, `USAGE_EXCEEDED` and `RATE_LIMITED` alerts (otherwise invisible, since they collapse to 401), and rotation and revocation. No constant-time padding is built. ### 3. A non-interactive address domain -A second reserved domain, **`@noreply.askansari.ai`** (owner decision 1), for service users and WhatsApp users. The kind of account is a prefix of the local part, followed by its id or name: +A second reserved domain, **`@noreply.askansari.ai`** (owner decision 1), for service users and WhatsApp users. The account kind is a prefix of the local part: | Account | Address | How many | |---|---|---| | WhatsApp user | `whatsapp-@noreply.askansari.ai` | one per person who messages Ansari on WhatsApp | | Service user | `service-@noreply.askansari.ai`, e.g. `service-whatsapp@…`, later `service-mcp-faithful-answers@…` | one per service that holds an API key | -Prefixes keep each kind together in listings and logs, and let a `LIKE 'whatsapp-%'` count use the email index. The reserved check looks only at the domain, and nothing ever finds a user by email, so the prefix carries no security weight. Ordinary `@askansari.ai` addresses (staff, admins) are not reserved and keep working. +Prefixes keep each kind together in listings and logs. The reserved check looks only at the domain, and nothing ever finds a user by email, so the prefix carries no security weight. Ordinary `@askansari.ai` addresses (staff, admins) are not reserved. -The reserved domain is: +**Implementation boundary:** `isReservedAddress` is extended to cover both domains. `isSystemAddress` and `SYSTEM_EMAIL_DOMAIN` are not widened: they name the system-domain invariant, apps/api keeps its own copy, and existing tests assert them. -- refused by sign-up with the same 409 as a taken address (no oracle); -- skipped by the reset-request sender with the same silent success as an unknown address (no mail attempt); +The reserved domain is: +- refused by sign-up with the same 409 as a taken address (response shape; see the timing residual below); +- skipped by the reset-request sender with the same response as an unknown address (no mail attempt); - never created by any Better Auth path: the `user.create.before` hook refuses both reserved domains unconditionally; -- created only by the exchange and by the service-user script, which write their rows directly in a transaction and never pass through a Better Auth path. +- refused by `grant-admin.ts`, like the system domain; +- created only by the exchange and the service-user script, which write their rows directly in a transaction. -A user in this domain has no password, so they cannot sign in anywhere. A WhatsApp user's only account row is `whatsapp`; a service user's only one is `service`. `GET /api/account/me` answers for them only with their own JWT and a live `sid`, as for everyone. The system domain keeps every rule it has today; the auth database still never holds a system-domain address. +A user in this domain has no password, so they cannot sign in anywhere. A WhatsApp user's only account row is `whatsapp`; a service user's is `service`. `GET /api/account/me` answers for them only with their own JWT and a live `sid`. The system domain keeps every rule it has today. + +**Timing residual:** sign-in and reset for an existing passwordless noreply user and for an unknown address give the same response shape, but are not guaranteed to take the same time. Only response equivalence is promised. The addresses are server-generated and not guessable from anything public, which bounds the value of the difference. ## Success Criteria -**Service keys** +**Service keys and scripts** - [ ] `@better-auth/api-key` is pinned to exactly the installed `better-auth` version (1.7.7); core is not bumped. -- [ ] The `apikey` table mirrors the plugin's model with generator column names, in the `auth` schema (uuid id, `timestamptz`, `reference_id` uuid → `auth.users` on delete cascade). Migration `0001` is the reviewed `drizzle-kit generate` output, numbered by main. The schema tests cover the table. -- [ ] Each of `/api-key/create`, `/api-key/get`, `/api-key/list`, `/api-key/update`, `/api-key/delete` returns the blocked response over HTTP, in every case and prefix variant, as the admin paths do. `/api-key/verify` has no route. +- [ ] The `apikey` table mirrors the plugin's model with generator column names, in the `auth` schema (uuid id, `timestamptz`, `reference_id` uuid → `auth.users` on delete cascade). Migration `0001` (with `unique(provider_id, account_id)`) is the reviewed `drizzle-kit generate` output, numbered by main. The schema tests cover both. +- [ ] apps/auth refuses to boot when `auth.apikey` is absent. +- [ ] The blocked list is derived from `apiKey().endpoints` and asserted to have length 5. Each path, in every canonicalized variant (case, trailing slash, percent-encoding, doubled slash, dot segments) and with any suffix, gets the block response at `/api/auth/api-key…`. `/api-key/verify` has no route. - [ ] `enableSessionForAPIKeys` is `false`. A valid key sent as `x-api-key` and as a bearer to `/api/auth/get-session` and `/api/auth/token` yields no session and no token. -- [ ] A stored key row holds only the hash; the raw key, and any random part of it, appears nowhere in the database (`start` not stored). +- [ ] A key created by the script verifies at the exchange. Its row holds the hash, and **no column of the row contains any substring (length ≥ 4) of the raw key** (`start` not stored, via `startingCharactersConfig.shouldStore: false`). - [ ] The create script prints the raw key exactly once. List, rotate and revoke print ids, names, prefixes, permissions, limits and dates, never a key or a hash. -- [ ] Every script refuses to run without a database URL, shows the account and writes only with a matching `--confirm ` (a mismatch writes nothing), prints the database host before writing, and logs the key id it acted on. -- [ ] The create script refuses `remaining` without `refillAmount` and `refillInterval`, and refuses a missing rate limit. -- [ ] The service-user script creates `service-@noreply.askansari.ai` and its `service` account row in one transaction, and refuses an existing service name. -- [ ] Rotate leaves two live keys, both verifying. Revoke makes the revoked key fail at the exchange with the 401 body on the next request. -- [ ] Exhausting a refilled budget refuses the key and keeps the row; after the refill interval the same key verifies again (pins the probe result). - -**Exchange: key refusals** (each returns the identical 401 body, and none creates a user, account or session) -- [ ] no `Authorization` header; non-Bearer scheme; empty bearer; -- [ ] unknown key; revoked key; disabled key; expired key; +- [ ] Every script refuses to run without a database URL, writes only with a matching `--confirm `, and logs the key id acted on. Before any write it prints the hostname and port only; tested with a URL carrying a user, a password and a query string, none of which appears in the output. +- [ ] The create script refuses `remaining` without a refill, a missing rate limit, values outside the `limits.ts` bounds, and any expiry option. +- [ ] The service-user script creates `service-@noreply.askansari.ai` and its `service` account row in one transaction, and refuses an existing service name. +- [ ] Rotate leaves two live keys, both verifying. Revoke sets `enabled = false` and keeps the row; the revoked key then fails at the exchange with the 401 body. +- [ ] Create, rotate and revoke each send one Sentry event with service, key id, name and action, and no key material. +- [ ] Exhausting a refilled budget refuses the key and keeps the row; after the refill interval, the same key verifies again. +- [ ] `grant-admin.ts` refuses a `@noreply.askansari.ai` address, as it refuses the system domain. + +**Exchange: key refusals** (identical 401 body; no user, account or session created) +- [ ] no `Authorization` header (answered without touching the database); non-Bearer scheme; empty bearer; a session token as the bearer; a JWT as the bearer; +- [ ] unknown key; revoked (disabled) key; expired key (seeded); - [ ] budget exhausted; per-window rate limit exceeded; - [ ] key with `{ mcp: ['complete'] }` only; key with no permissions; -- [ ] a key carrying `{ whatsapp: ['mint'] }` but owned by a different service user (seeded directly, since the script cannot produce it). +- [ ] a `{ whatsapp: ['mint'] }` key owned by a different service user (seeded directly; the script cannot produce it). -**Exchange: identity** -- [ ] A new BSUID creates exactly one user, one `whatsapp` account row and one session. It returns a JWT that verifies against apps/auth's JWKS with the same `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, payload `{sub, sid}` naming that user and session. -- [ ] The created user's address is exactly `whatsapp-@noreply.askansari.ai`, where `` is the token's subject. -- [ ] The same BSUID twice returns the same `sub` and the same `sid`; the user has one session. -- [ ] A session expiring within one JWT lifetime is replaced: the old one is deleted, and the token's `sid` names the new one. -- [ ] Concurrent first requests for one BSUID produce one user and one account row, and all return a token for that user. Concurrent requests for an existing user leave exactly one session. The tests exercise the conflict and lock paths themselves (pglite serializes connections, so sequential calls would prove nothing). -- [ ] **Delegation boundary.** Seed an admin, an email user, a `google` user whose `account_id` is BSUID-shaped, and a service user. Passing the admin's and email user's ids and emails as `bsuid` is refused with 400. Passing the `google` user's `account_id` returns a token for a new WhatsApp user, never the seeded one. Passing the service name is refused with 400, or reaches no service user. No exchange response ever carries the `sub` of a user without a `whatsapp` account row. -- [ ] A banned WhatsApp user gets 403, and all their sessions are deleted; a JWT minted before the ban then fails `GET /api/account/me`. An expired ban (`banExpires` in the past) is not refused. -- [ ] Database stopped under a running apps/auth (production-shaped: `NODE_ENV=production`) → the exchange answers 500 for a valid key and for an unknown key, never 401. A request with no bearer at all is 401 without touching the database. +**Exchange: failures that are never 401** +- [ ] `auth.apikey` absent (migration unapplied) → 500, never 401, for both a valid key and an unknown key. +- [ ] Database stopped under a running apps/auth (production-shaped, `NODE_ENV=production`) → 500, never 401, for a valid key and an unknown key. +- [ ] WhatsApp service user missing → 500 plus an alert. +- [ ] The resolved user deleted between lookup and lock → 500, no session row, no token. -**Exchange: privacy and logging** -- [ ] A phone-number-shaped `bsuid` (E.164 with and without `+`, digits only) is refused with 400, after the key and before any lookup or log. -- [ ] No created row (user email and name, session IP and user agent, account) contains the BSUID except `account.account_id`, and none contains a phone number. -- [ ] No log line, alert or response from the exchange, the scripts or better-auth's logger contains a raw key, a key hash, a BSUID or a driver error object; errors log `{name, code}` only. A test captures `console.*` across every refusal, failure and outage case and asserts none of those values appears. +**Exchange: identity and delegation** +- [ ] A new BSUID creates exactly one user (address exactly `whatsapp-@noreply.askansari.ai`), one `whatsapp` account row and one session. It returns a JWT that verifies against apps/auth's JWKS with the same `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, payload `{sub, sid}` naming that user and session. +- [ ] The same BSUID twice returns the same `sub` and the same `sid`; the user has one session. +- [ ] For every returned token, the session's `expiresAt` ≥ the token's `exp` + the margin. A session that would fail this is replaced (old one deleted, `sid` names the new one). +- [ ] **Only a user whose sole account row is `whatsapp` is ever minted for.** Each of these gets 500 and an alert, never a token: + - a `whatsapp` row attached to an email user (one with a `credential` row); + - a `whatsapp` row attached to a service user; + - a `whatsapp` row attached to an admin; + - a WhatsApp user later promoted to admin (seeded, since `grant-admin.ts` refuses); + - a WhatsApp user whose address or `registered_via` was changed. +- [ ] Passing an admin's or email user's id or email as `bsuid` is refused with 400. A `google` user whose `account_id` is BSUID-shaped is never reached (a new WhatsApp user is created instead). +- [ ] A banned WhatsApp user gets 403, all their sessions are deleted, and a JWT minted before the ban then fails `GET /api/account/me`. An expired ban is not refused. +- [ ] The per-user budget returns 429 with `X-Retry-After` once spent; another user under the same key is unaffected; the `rate_limit` key contains the user id, not the BSUID. +- [ ] **Concurrency, scoped to what each test proves.** On pglite, which serializes connections: + - a forced conflict on `unique(provider_id, account_id)` leaves one user, and both callers get its token; + - a ban landing between the account lookup and the lock is seen under the lock (403, no session). + + A source-level assertion pins that the eligibility re-read, the ban check, the session write and the signing all run inside the transaction that holds `FOR UPDATE`. The plan's real-Postgres probe shows the session write does not wait on that lock. Concurrent messages for one user are exercised in the staging smoke. + +**Exchange: privacy, logging, transport** +- [ ] A phone-number-shaped `bsuid` (E.164 with and without `+`, digits only) → 400, after the key and before any lookup or log. +- [ ] No created row (user email and name, session IP and user agent, account, `rate_limit`) contains the BSUID except `account.account_id`, and none contains a phone number. +- [ ] Across every refusal, failure and outage case, `console.*` and better-auth's logger contain no raw key, key hash, session token, BSUID or driver error object. +- [ ] Better Auth's logger: a non-error log survives with its level and message; a known driver error passed to it is reduced to exactly `{name, code}`; a near miss (a plain object with `name`/`code`-like fields, and a non-error string argument) survives unchanged. This criterion cannot be met by disabling the logger. +- [ ] Sentry events from the exchange carry no request body and no `Authorization` header (tested on a captured event). +- [ ] No response from the exchange echoes any part of its input. +- [ ] No response from the exchange, `OPTIONS` preflight included, carries `Access-Control-Allow-Origin` for any origin, including the configured `CORS_ORIGIN`. +- [ ] A body over the byte limit → 400 (after the key), and a body sent with no key is never parsed. + +**Alerts** (through `alerts.ts`: counts only, no identifiers) +- [ ] Per-key exchange request volume over a threshold per window. +- [ ] `USAGE_EXCEEDED` and `RATE_LIMITED` refusals (one event per window each). +- [ ] 401 volume per window over a threshold. +- [ ] Eligibility violations and an unresolvable service user (each occurrence). **Reserved domain** -- [ ] Sign-up with a non-interactive-domain address (any case, surrounding spaces) returns the same 409 body as a taken address. +- [ ] Sign-up with a `@noreply.askansari.ai` address (any case, surrounding spaces) returns the same 409 body as a taken address. - [ ] Near misses are not reserved: `someone@askansari.ai`, `x@noreply.askansari.ai.example.com` and `x@notnoreply.askansari.ai` sign up normally. -- [ ] A reset request for an existing non-interactive-domain user returns the same response as an unknown address, and the mailer is never called. +- [ ] A reset request for an existing noreply user returns the same response as an unknown address, and the mailer is never called. - [ ] The `user.create.before` hook refuses both reserved domains on every Better Auth path. -- [ ] Sign-in with a non-interactive-domain user's address fails exactly as for an unknown address. +- [ ] Sign-in with a noreply user's address returns the same response as an unknown address (shape only; see the timing residual). +- [ ] `isSystemAddress` and `SYSTEM_EMAIL_DOMAIN` are unchanged, and their existing tests pass unedited. **End to end** - [ ] In the existing apps/api contract tests (which start a real apps/auth), a JWT from the exchange passes `requireUser`: JWKS verification, first-use `GET /api/account/me`, and a `public.users` row under the same UUID with `registered_via = 'whatsapp'`. - [ ] apps/api's source gains no WhatsApp-specific code. -- [ ] Staging: the WhatsApp builder's smoke test (a phone-visible and a phone-hidden user) passes against staging apps/auth and apps/api with a key issued by the create script. +- [ ] Staging smoke, run and signed off by the WhatsApp builder (#327) against staging apps/auth and apps/api with a key issued by the create script: a phone-visible user, a phone-hidden user, and several concurrent messages from one user (one user, one session afterwards). This is a pre-production gate, not a merge gate for this PR. + +**Documentation** +- [ ] `docs/self-hosting.md` covers the new route, the service-user and key scripts, the `limits.ts` bounds, and the migration-before-code order. +- [ ] The runbook covers: + - issuance (service user, then key, inside the auth service); + - the handoff: paste straight into the consumer's Railway variable, never anywhere else; + - rotation overlap and the doubled ceiling while two keys are live; + - revocation (`enabled = false`), and pruning of expired rows (none expected, since production keys have no expiry); + - budget sizing (owner decision 3); + - the pre-apply check that `auth.account` holds no duplicate `(provider_id, account_id)` before `0001`, noting that adding the constraint takes an ACCESS EXCLUSIVE lock on a table every sign-in reads (apply in a quiet window); + - the alert meanings. +- [ ] `codev/resources/arch.md` (Authentication & Authorization) describes service keys, the exchange and the noreply domain. **Unchanged** - [ ] `ROUTER_RULES` are all `false`; no per-IP rule applies to the exchange. -- [ ] Every existing apps/auth, packages/auth and apps/api test passes unedited, except tests that enumerate the blocked paths or the schema, which gain the new entries. +- [ ] Every existing apps/auth, packages/auth and apps/api test passes unedited, except tests that enumerate the blocked paths, the schema or the reserved check, which gain the new entries. - [ ] No new environment variable. If one appears, it is added to `turbo.json` `globalEnv` by the four-way derivation and reviewed by main. ## Constraints - **Risk tier High** (auth model, new table, new credential type): porch's consultation on spec, plan and PR, plus the security architect's 3-way review at each gate. The owner answers decisions 2–4 at the spec-approval gate (decision 1 is answered). - **Pin, do not bump.** The plugin's newest release requires core 1.7.14; bumping core is a separate, joint change. -- **Schema discipline.** `schema.ts` is hand-written; the table is added there in its style. `drizzle-kit generate` → reviewed SQL → applied by a human with `psql -v ON_ERROR_STOP=1 --single-transaction` against the auth database. Never `db:push`. Main numbers migrations: `architect:main` is messaged before the journal entry is committed. Migration before code at deploy. +- **Schema discipline.** `schema.ts` is hand-written; `drizzle-kit generate` → reviewed SQL → applied by a human with `psql -v ON_ERROR_STOP=1 --single-transaction` against the auth database. Never `db:push`. `architect:main` is messaged before the journal entry is committed. Migration before code at deploy. - **Two databases.** Everything here is in the auth database and apps/auth. apps/api changes only in tests. No query crosses the two. -- **Tokens.** The exchange's JWT must be indistinguishable from any other apps/auth JWT, and must be signed from a real, live session. +- **Tokens.** The exchange's JWT must be indistinguishable from any other apps/auth JWT, and signed from a real session that outlives it at signing time. - **Client IP.** No per-IP rule on the exchange; `ROUTER_RULES` stays all `false`. -- **No secret leaves its moment.** Never log a raw key, a key hash, a raw driver error, a BSUID or a phone number. Outside the creating process, the raw key exists only in the create script's single output. -- **Sequencing.** develop is frozen for apps/auth and packages/auth until the spec 300 production cutover and its contract PR land. Spec, plan and implementation proceed; the PR does not merge before then and expects a rebase. The migration is `0001` in `packages/auth/drizzle`, unrelated to the contract PR's app-database migration. +- **Where secrets live.** The raw key lives in the consumer's secret variable and, once, in the create script's output; apps/auth holds its hash. Session tokens created by the exchange never leave apps/auth. Never log a raw key, a key hash, a session token, a raw driver error, a BSUID or a phone number. +- **Limits.** Every number lives in `packages/auth/src/limits.ts`. +- **Sequencing.** develop is frozen for apps/auth and packages/auth until the spec 300 production cutover and its contract PR land. Spec, plan and implementation proceed; the PR does not merge before then and expects a rebase. The migration is `0001` in `packages/auth/drizzle`. +- **Scope guard.** #314's verify route does not creep in. The leaderboard is not migrated. - **Process.** Explicit `git add` only; no AI attribution; commits `[Spec 329][Phase: x] type: message`; `.env` files are never read. ## Assumptions - The spec 300 cutover lands without changing `createAuth`'s plugin list, the account-route mounting or `BLOCKED_AUTH_PATHS` in a way that conflicts with this design. The rebase reconciles details. -- A BSUID is `.` (seen in #327: `US.13491208655302741918`, `EG.1234567890123456789`). The pattern below is the contract unless the WhatsApp builder shows a BSUID it refuses. -- BSUIDs are stable per user per business portfolio. A user hiding or revealing their phone number does not change their BSUID. +- A BSUID is `.` (seen in #327: `US.13491208655302741918`, `EG.1234567890123456789`). The pattern below is the contract unless the WhatsApp builder shows a real BSUID it refuses. +- BSUIDs are stable per user per business portfolio. Hiding or revealing a phone number does not change a BSUID. - The WhatsApp service holds one key at a time (two during rotation) and is the only consumer of `whatsapp: mint`. -- PR #327 changes its header to `Authorization: Bearer` and its error handling to the shapes above (including 403 for a banned user). Spec 321 is not on develop, so its amendment lands on #327's branch, coordinated with that builder. -- `@better-auth/api-key` 1.7.7 behaves as probed. The tests pin every behaviour the design relies on (no HTTP surface, no session for keys, hashing, permission before budget, refilled keys kept, outage reported as `INVALID_API_KEY`), so a later bump that changes one fails loudly. +- PR #327 adopts the contract above (header, error handling, re-exchange on an apps/api 401). +- `@better-auth/api-key` 1.7.7 behaves as probed, and better-auth 1.7.7's transaction context carries internal-adapter writes. The plan probes the parts not yet proven: `createApiKey` from a script, `shouldStore: false`, `signJWT`'s claims, the logger redaction, and `createSession` inside the locking transaction on a real Postgres. Tests pin every behaviour relied on, so a later bump that changes one fails loudly. ## Solution Approaches -### Approach 1 (recommended): plugin for keys; identity rows written directly in one transaction +### Approach 1 (recommended): plugin for keys; identity rows written directly; one locking transaction for the decision -Keys come from `@better-auth/api-key` (hashing, permissions, budgets, atomic usage accounting), with its HTTP surface blocked and its logger redacted. The exchange verifies with `auth.api.verifyApiKey`, binds the owner, then creates the WhatsApp user and account row in its own Drizzle transaction in `packages/auth`, the same pattern as `banUserByEmail`'s update plus session delete. It manages the one session under a per-user lock and signs with the jwt plugin. The service-user script writes its user and `service` row the same way. +Keys come from `@better-auth/api-key` (hashing, permissions, budgets, atomic usage accounting), created in-process by the scripts, with its HTTP surface blocked by derivation and prefix, and its logger redacted. The exchange: +- verifies the key and binds its owner; +- creates the WhatsApp user and account row in its own transaction, the pattern `banUserByEmail` uses; +- in one locking transaction: re-reads eligibility and ban, applies the per-user budget, manages the one session through `internalAdapter.createSession`, and signs with `signJWT`. -Because nothing here calls `internalAdapter.createUser`, the `user.create.before` hook needs no "who is calling" exception. It refuses **both** reserved domains on every path it sees. That is stronger than the issue's wording ("permitted by the database hook only when the creating context is the exchange route or an operator script"), with the same outcome: no Better Auth path can create such an address, and there is no context check to get wrong. +The `user.create.before` hook refuses both reserved domains unconditionally. That is stronger than the issue's "permitted only when the creating context is the exchange or a script", with the same outcome and no context check to get wrong. -- **Pros:** user and account are atomic in one real transaction (provable against pglite). The reserved-domain hook stays unconditional. The delegation rule is one parameterized lookup with the provider fixed in code, plus an owner check. No credential crypto is maintained here. -- **Cons:** the exchange writes `auth.users` and `auth.account` rows itself, so it must match Better Auth's row shape. The hand-written schema pins it, and tests read the rows back through Better Auth. Sessions keep Better Auth's token shape. -- **Risk:** low to medium. The concurrency paths need deliberate tests. +- **Pros:** + - user and account are atomic; + - the hook stays unconditional; + - the delegation rule is a fixed-provider lookup plus an owner check plus an eligibility check under a lock; + - no credential crypto is maintained here. +- **Cons:** + - the exchange writes `auth.users` and `auth.account` rows itself, so it must match Better Auth's row shape (pinned by the hand-written schema and read back through Better Auth in tests); + - the session write must share the locking transaction (the hazard above). +- **Risk:** medium. The plan's first phase probes the unproven library paths before anything is built on them. -### Approach 2: plugin for keys; identity through Better Auth's internal adapter with a context marker +### Approach 2: identity through Better Auth's internal adapter with a context marker -As Approach 1, but users and accounts are created through `internalAdapter.createUser` / `createAccount`. The hook permits the non-interactive domain only when a marker set by the exchange or the script is present in its context. +Users and accounts are created through `internalAdapter.createUser` / `createAccount`, and the hook permits the noreply domain only when a marker set by the exchange or the script is present. -- **Pros:** every row is written by Better Auth's own code. -- **Cons:** the internal adapter's calls are not one transaction, so a crash between user and account leaves an orphan user that no BSUID reaches. The hook's safety rests on a marker no HTTP path can forge, a subtle invariant across better-auth upgrades. -- **Risk:** medium. +- **Pros:** every row is written by Better Auth's code. +- **Cons:** + - user and account are not one transaction unless wrapped in the same transaction context; + - the hook's safety rests on a marker no HTTP path can forge, a subtle invariant across upgrades. +- **Risk:** medium. Rejected in favour of an unconditional hook. ### Approach 3: hand-rolled service keys (no plugin) A small table of hashed keys with permissions and limits, verified by apps/auth's own code. -- **Pros:** no dependency; no surface to block; outage and refusal are distinguishable by construction. -- **Cons:** re-implements the atomic accounting, expiry, refill and rotation the plugin already does and tests. Diverges from the issue's design and from #314's expected consumer model. -- **Risk:** medium (new credential code). Rejected. The plugin's two weaknesses found here (outage as `INVALID_API_KEY`, raw error logging) are closed by the database check and the redacted logger. +- **Pros:** no dependency and no surface to block; outage and refusal are distinguishable by construction. +- **Cons:** + - re-implements the atomic accounting, refill and rotation the plugin already does and tests; + - diverges from the issue and from #314's consumer model. +- **Risk:** medium (new credential code). Rejected. The plugin's weaknesses found here (outage reported as `INVALID_API_KEY`, raw error logging, deletion on no-refill exhaustion) are closed by the relation check, the redacted logger and the script's refill rule, with the residuals stated. **Recommendation: Approach 1.** ## Open Questions -**Critical: owner decisions, answered at the spec-approval gate (decision 1 answered; 2–4 have a recommendation, not settled)** +**Critical: owner decisions at the spec-approval gate** -1. ~~The non-interactive domain name.~~ **Decided by the owner (2026-10-28):** `@noreply.askansari.ai`, a fixed reserved subdomain with the account kind as a local-part prefix (`whatsapp-`, `service-`). This follows the common convention (GitHub's `users.noreply.github.com`, Apple's `privaterelay.appleid.com`). A hostname per user (e.g. `whatsapp-.askansari.ai`) was considered and rejected: the reserved check would have to pattern-match hostnames instead of testing one fixed suffix, and the id would sit in a DNS label. The bare `@askansari.ai` was rejected because it would refuse real staff addresses. +1. ~~The non-interactive domain name.~~ **Decided by the owner (2026-10-28):** `@noreply.askansari.ai`, a fixed reserved subdomain with the account kind as a local-part prefix (`whatsapp-`, `service-`). This follows the common convention (GitHub's `users.noreply.github.com`, Apple's `privaterelay.appleid.com`). Considered and rejected: + - a hostname per user (e.g. `whatsapp-.askansari.ai`): the reserved check would have to pattern-match hostnames instead of testing one fixed suffix, and the id would sit in a DNS label; + - the bare `@askansari.ai`: it would refuse real staff addresses. 2. **Store the BSUID raw in `account.account_id`, or HMAC it with an apps/auth secret.** Recommendation: raw. The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a new env var and a rotation story that re-keys every row. -3. **The WhatsApp key's limits**, sized from the legacy WhatsApp service's traffic. The probe shows a refilled daily budget is safe: an exhausted key is refused and kept, and verifies again after the refill. So the decision is two numbers: a per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow`, e.g. per minute) and a daily budget (`refillAmount` per 24 h). Recommendation: both, at about twice the legacy peak. The create script requires both explicitly (no defaults), and the values are recorded in the runbook. One exchange per BSUID per 15 minutes means the budget scales with active conversations, not messages. If the owner prefers a single cap, the per-window ceiling alone is safe; a budget without refill is not offered, because it destroys the key when exhausted. +3. **The WhatsApp key's limits**, sized from the legacy WhatsApp service's traffic. The decision is two numbers per key: + - a per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow`, e.g. per minute); + - a daily budget (`refillAmount` per 24 h). + + Recommendation: both, at about twice the legacy peak, sized for instances × restarts, since the consumer's 15-minute token cache is per process. A refilled budget is safe: exhaustion refuses and keeps the key. The `limits.ts` bounds and the per-user exchange budget are set in the plan from the same traffic data. Budgets are per key, so rotation briefly doubles them. 4. **The leaderboard: migrate from its static secret now or later.** Recommendation: later. Nothing forces it, and this spec ships one consumer. -**Resolved in this revision** (by the security architect's direction or by builder decision; recorded so the plan does not re-decide them) +**Resolved** (by the security architect's direction or by builder decision; recorded so the plan does not re-decide them) -5. Throttle statuses: every key refusal, including the plugin's 429s, is the one 401 (architect). -6. Banned user: 403 `{ "message": "Forbidden" }`, so the WhatsApp service can tell "this user is banned" from "my key is bad". The caller is a trusted service holding a valid key, so this is no public oracle. -7. Order: key first, then body. -8. Image guard: dropped. The scripts follow `grant-admin.ts`'s posture plus a printed database host (architect). -9. BSUID pattern: `^[A-Z]{2}\.[0-9]{10,32}$`, matching both samples in #327. A bare phone number (E.164, with or without `+`) cannot match, because it has no country-code-and-dot prefix. The pattern cannot stop a caller that deliberately disguises one, which is why the WhatsApp service's own "no phone number" test is part of the contract. The WhatsApp builder is asked to confirm it against real webhooks before the PR. -10. `start` is not stored. +5. Every key refusal, including the plugin's 429s, is the one 401 (architect). +6. Banned user: 403 `{ "message": "Forbidden" }`, so the consumer can tell "this user is banned" from "my key is bad". This is an accepted oracle toward a trusted key holder. +7. Order: key, then body (route-local parsing after the key). +8. Image guard dropped. The scripts follow `grant-admin.ts`'s posture plus hostname-and-port output (architect). +9. BSUID pattern `^[A-Z]{2}\.[0-9]{10,32}$`, matching both samples in #327. A bare phone number (E.164, with or without `+`) cannot match. The pattern cannot stop a caller that deliberately disguises one, which is why the WhatsApp service's own "no phone number" test is part of the contract. The WhatsApp builder confirms it against real webhooks before the PR. +10. `start` not stored (`shouldStore: false`). +11. Revoke disables and keeps the row. Production keys have no expiry. +12. A per-user exchange budget inside the key's bound, keyed by user id, with its 429. **Nice-to-know** -11. **Session sliding.** The reused session is 30 days and slides daily on use. A WhatsApp user active at least monthly keeps one session indefinitely; a quiet one gets a fresh one. No change. -12. **Deleting a WhatsApp user.** `DELETE /api/account` with the user's own JWT (held by the WhatsApp service) cascades the account row away, so the next message from that BSUID creates a new user. This is intended; a user-facing delete flow for WhatsApp is out of scope. -13. **New-user volume as a signal.** A leaked key could create users up to its budget. An alert on WhatsApp user creations per window would surface that; it is a plan-level option, not a requirement. +13. **Session sliding.** The reused session is 30 days and slides daily on use. An active user keeps one session; a quiet one gets a fresh one. +14. **Deleting a WhatsApp user.** `DELETE /api/account` with the user's own JWT cascades the account row away, so the next message from that BSUID creates a new user. This is intended; a user-facing delete flow for WhatsApp is out of scope. +15. **New-user volume.** An alert on WhatsApp user creations per window would add a second signal beside per-key volume. Plan-level option. ## Test Scenarios +**Probes first (plan phase 1, before anything is built on them)** +1. `auth.api.createApiKey` from a script context: the key verifies; `shouldStore: false` leaves no key substring in any column; `permissions` and `metadata` round-trip. +2. `signJWT` server-side: the claims match a `/api/auth/token` JWT, and `exp` is readable for the session comparison. +3. The redacted logger: non-error logs survive; error objects are reduced to `{name, code}`. +4. Real Postgres: `internalAdapter.createSession` inside the transaction that holds `FOR UPDATE` on the user completes, without waiting on its own lock. + **Functional (pglite: packages/auth and apps/auth suites)** -1. Happy path: new BSUID → 200; token verifies; `sub` and `sid` match the created user and session; rows as specified. -2. Repeat: same BSUID → same `sub`, same `sid`, one session. -3. Near-expiry session: the user's only session expires in under 15 minutes → it is deleted, a new one is created, and its id is the `sid`. -4. Races, through test seams (pglite serializes connections): a second insert for the same BSUID lands between lookup and insert, and one user survives with both callers getting its token. Two session steps for one user run concurrently, leaving one session. -5. Every key refusal in the success criteria → the identical 401 body, no rows written. -6. Owner binding: a `whatsapp: mint` key owned by another service user → 401. -7. Delegation boundary with the seeded admin, email user, `google` user and service user. -8. Banned WhatsApp user → 403, sessions deleted, an earlier JWT dead at `/api/account/me`; an expired ban is let through. -9. Body errors after a valid key: non-JSON, missing `bsuid`, non-string, empty, too long, lower-case country code, phone-shaped → 400. -10. Blocked surface: the five `/api-key/*` paths in mixed case, with trailing slashes and suffixes → blocked; `/api-key/verify` → 404. -11. Keys are never sessions: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` → no session, no JWT. -12. Reserved domain: sign-up 409 identical to taken; reset request identical to unknown, mailer not called; hook refusal on a Better Auth path; sign-in identical to unknown. -13. Scripts: no database URL → refused; show without `--confirm` → nothing written; a mismatched `--confirm` → nothing written. Create prints the key once and the row holds only the hash. `remaining` without refill → refused. List prints no secrets. Rotate → two live keys. Revoke → next exchange 401. The database host is printed before each write, and the key id is logged. -14. Refilled budget: exhaust → refused, row kept; advance past the interval → verifies. +5. Happy path: new BSUID → 200; the token verifies; `sub`/`sid` match; rows as specified; address exact. +6. Repeat: same BSUID → same `sub`, same `sid`, one session. +7. Session margin: a session whose `expiresAt` < token `exp` + margin is replaced. +8. Conflict: a forced second insert for the same BSUID → one user, both callers get its token. +9. Ban under the lock: a ban committed between lookup and lock → 403, no session. +10. Deleted mid-exchange: the user removed between lookup and lock → 500, no session. +11. Every key refusal → the identical 401 body, no rows. +12. Owner binding: a `whatsapp: mint` key under another service user → 401. Service user missing → 500 + alert. +13. Eligibility matrix: `whatsapp` row on an email, service or admin user; a promoted WhatsApp user; an altered address or `registered_via` → 500 + alert, no token. +14. Delegation inputs: admin and email-user ids and emails as `bsuid` → 400; a BSUID-shaped `google` `account_id` → a new WhatsApp user. +15. Ban: 403, sessions deleted, an earlier JWT dead at `/api/account/me`; an expired ban is let through. +16. Per-user budget: 429 with `X-Retry-After`; another user unaffected; the `rate_limit` key holds the user id. +17. Body: non-JSON, oversized, missing, non-string, empty, too long, lower-case country code, phone-shaped → 400 (after a valid key). No key → never parsed. +18. Transport: no CORS allow-origin on any response or preflight; a session token or JWT as the bearer → 401; not reachable under `/api/auth/*`. +19. Blocked surface: the derived list has length 5; every variant and suffix under `/api/auth/api-key` is blocked; `/api-key/verify` → 404. +20. Keys are never sessions: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` → nothing. +21. Reserved domain: sign-up 409 identical to taken; near misses sign up; reset identical to unknown, mailer not called; hook refusal; sign-in identical to unknown; `grant-admin.ts` refuses noreply; `isSystemAddress` unchanged. +22. Scripts: no database URL; show without confirm; mismatched confirm; hostname-and-port only (URL with userinfo and query); create prints once; refuses no-refill, out-of-bounds and expiry; list has no secrets; rotate gives two live keys; revoke disables and keeps; Sentry events with no key material. +23. Refilled budget: exhaust → refused, row kept; past the interval → verifies. +24. Source-level: the eligibility re-read, ban check, session write and signing sit inside the `FOR UPDATE` transaction. **Non-functional** -15. Log capture across scenarios 1–14 (console and better-auth's logger): no raw key, hash, BSUID, phone-shaped value or driver error object. -16. Outage (production-shaped): database stopped under a running apps/auth → 500, never 401, for a valid key and for an unknown key. -17. End to end in apps/api's contract harness: exchange token → `requireUser` → `public.users` row. -18. Staging smoke (manual, WhatsApp builder): phone-visible and phone-hidden users get answers. +25. Log and Sentry capture across 5–23: no raw key, hash, session token, BSUID, phone-shaped value or driver error object; Sentry events carry no body or `Authorization`. +26. Outages: `auth.apikey` absent → 500, and boot refuses; database stopped under a running apps/auth (`NODE_ENV=production`) → 500, never 401. +27. Alerts fire for per-key volume, `USAGE_EXCEEDED`, `RATE_LIMITED`, 401 volume, eligibility violations and a missing service user. +28. End to end in apps/api's contract harness: exchange token → `requireUser` → `public.users` row. +29. Staging smoke (WhatsApp builder signs off): phone-visible, phone-hidden, concurrent messages from one user. ## Risks and Mitigation | Risk | Probability | Impact | Mitigation | |------|-------------|--------|------------| -| A WhatsApp key mints a token for a non-WhatsApp user | Low | Critical | Provider fixed in code; lookup only on `(provider_id='whatsapp', account_id)`; key owner bound to the WhatsApp service user; the BSUID pattern refuses UUIDs and emails; delegation-boundary tests | -| An outage answered as 401, so the WhatsApp service treats its key as revoked | Medium | High | Database check before every 401; production-shaped outage test | -| The plugin logs a raw driver error | High without mitigation | High | Better Auth's logger redacted to `{name, code}`; log-capture test through an outage | -| A burst deletes the production key | High if `remaining` were set without refill (probed) | High | The script refuses `remaining` without a refill; refilled exhaustion pinned as refused-and-kept | -| A leaked key creates many users | Low | High | Per-window ceiling and daily budget; rotate and revoke scripts; created users are inert; optional creation alert (Open Question 13) | -| The plugin's HTTP surface reachable through a variant path | Low | Critical | Exact plus case-insensitive prefix blocking as for the admin paths, with variant tests; `enableSessionForAPIKeys` pinned false with a test | -| A token signed from a session that dies first, failing apps/api's first use | Medium | Medium | Reuse only sessions that outlive the JWT; end-to-end test through `requireUser` | -| Concurrent requests create two users for one BSUID, or several sessions for one user | Medium | High | `unique(provider_id, account_id)` with rollback on conflict; per-user lock around the session step; seam-driven concurrency tests | -| Migration `0001` collides with the cutover's numbering, or ships unapplied | Medium | High | Main numbers it before the journal is committed; human-applied before deploy; deploy order | -| Rebase conflicts after the spec 300 cutover | High | Low | The PR waits for the cutover; tests pin behaviour, not line positions | -| A plugin or core bump changes deletion, permission order, error mapping or the endpoint list | Medium | Medium | Each relied-on behaviour is pinned by a test; the plugin is pinned to the core version | -| A phone number reaches apps/auth through a misbehaving caller | Low | High | The pattern cannot match a bare phone number; refused before lookup or log; the "no phone number" test extends to the exchange | +| A WhatsApp key mints for a non-WhatsApp user (a `whatsapp` row on another user, a promotion, a future social provider) | Low | Critical | Fixed-provider lookup; owner binding; **eligibility under the lock** (sole `whatsapp` row, noreply domain, `registered_via`, not admin), else 500 + alert; `grant-admin.ts` refuses noreply; eligibility matrix tests | +| Ban or deletion races token issuance | Medium | High | Lock first, re-read under it; session and signing in the same transaction; consumer re-exchanges once on an apps/api 401 | +| The session write waits on its own `FOR UPDATE` (self-deadlock) | Medium | High | Session write in the locking transaction via better-auth's transaction context; real-Postgres probe before building; escalate to the architect if infeasible | +| A session token (30-day bearer credential) leaks | Low | Critical | Better-auth's generator; never returned, logged or sent to Sentry; in the log-capture assertions | +| An outage or unapplied migration answered as 401 | Medium | High | Relation-level check before 401; boot assertion; tests for an absent relation and a stopped database; residual window stated | +| An unknown-key flood amplifies database work | Medium | Medium | Cached relation check; 401-volume alert; residual stated | +| The plugin logs raw driver errors | High without mitigation | High | Redacted logger (not disabled), with near-miss tests | +| A burst deletes the production key | High if `remaining` were set without refill (probed) | High | The script refuses no-refill; refilled exhaustion pinned as refused-and-kept; no expiry; revoke disables and keeps | +| One looping user exhausts the key for everyone | Medium | High | Per-user budget inside the key's bound (429) | +| A leaked key confirms BSUID membership and bans, or creates users | Low | High | Accepted residual toward the delegation authority; per-key volume, exhaustion and rate-limit alerts; rotate and revoke | +| The plugin surface reachable through a variant or a new endpoint | Low | Critical | Derived list asserted at length 5; `/api/auth/api-key` prefix rule; `enableSessionForAPIKeys` pinned false | +| CORS lets a browser call the exchange | Low | Medium | No allow-origin on any exchange response, tested | +| A raw key mishandled at handoff | Medium | High | The runbook's direct-to-variable handoff; printed once; Sentry events carry no key material | +| Migration `0001` collides, ships unapplied, or locks `auth.account` at a bad moment | Medium | High | Main numbers it; boot assertion; pre-apply duplicate check and quiet-window guidance in the runbook | +| Rebase conflicts after the spec 300 cutover | High | Low | The PR waits; tests pin behaviour | +| A plugin or core bump changes relied-on behaviour | Medium | Medium | Each behaviour pinned by a test; plugin pinned to core | +| A phone number reaches apps/auth | Low | High | The pattern cannot match a bare number; refused before lookup or log | ## References -- Issue #329 (this spec); spec 321 / PR #327 (WhatsApp, the first consumer); issue #314 (MCP, the second consumer) +- Issue #329 (this spec); spec 321 / PR #327 (WhatsApp, first consumer); issue #314 (MCP, second consumer) - `codev/specs/300-better-auth-migration.md`: two databases, reserved addresses, account endpoints, rate limits and client IP - `codev/resources/arch.md`: Authentication & Authorization - `codev/resources/lessons-learned.md`: Better Auth migration (spec 300); Auth hardening (spec 4) - `@better-auth/api-key` 1.7.7 package source (`dist/index.mjs`: schema, endpoints, `validateApiKey`, `consumeRemaining`, `verifyApiKey`'s catch), and the specify-phase pglite probe whose results are tabled above -- `docs/self-hosting.md` (to be updated: service keys, scripts, deploy order) +- better-auth 1.7.7 `db/internal-adapter.mjs` (`getCurrentAdapter`, `runWithTransaction`) and `plugins/jwt` (`signJWT`, server-only) +- Security architect's consolidated spec review, iteration 2 (inputs kept in `codev/projects/329-apps-auth-service-api-keys-bet/`) diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index a1661bc3..219c1c9b 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -29,3 +29,13 @@ - Domain: `@noreply.askansari.ai` (fixed reserved subdomain). Addresses use a kind prefix: `whatsapp-@…` (the user's own UUID, chosen by the exchange before insert, so the address is unique by construction) and `service-@…`. Considered and rejected: a hostname per user, and the bare @askansari.ai. + +## 2026-10-28: spec iteration 2 (security architect's consolidated review: codex + claude, REQUEST_CHANGES) + +- Folded in B1-B17. Notable: eligibility re-checked under the user-row lock (sole whatsapp row, noreply domain, registered_via, not admin → else 500 + alert); + lock first, then ban/eligibility, session and signJWT in one transaction; relation-level pre-401 check + boot assertion; revoke = disable; + no expiry on production keys; per-user budget (429) keyed by user id; blocked paths derived from apiKey().endpoints + prefix rule. +- Found while revising: FOR UPDATE on users conflicts with the KEY SHARE lock that the session insert's FK takes. If createSession ran on another connection, + the request would block on its own lock. better-auth 1.7.7 has runWithTransaction/getCurrentAdapter; the plan must prove this on real Postgres. +- Corrected my own claim: LIKE 'whatsapp-%' cannot use the default-collation email btree; removed. +- signJWT is serverOnly, so /token's TOKEN_EXCHANGE_PER_SESSION is bypassed; the per-user budget replaces it. From 8522f6f973efb3ef345d47439ddcb0fd188a0f49 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:09:13 +1100 Subject: [PATCH 066/247] [Spec 329] Specification with owner decisions and security review round 2 --- .../329-specify-iter3-input-claude.md | 56 +++++ .../329-specify-iter3-input-codex.md | 45 ++++ .../329-specify-iter3-rebuttals.md | 24 ++ .../329-apps-auth-service-api-keys-bet.md | 205 +++++++++++------- codev/state/spir-329_thread.md | 8 + 5 files changed, 262 insertions(+), 76 deletions(-) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-claude.md create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-codex.md create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-rebuttals.md diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-claude.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-claude.md new file mode 100644 index 00000000..d535ba2f --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-claude.md @@ -0,0 +1,56 @@ +I'll verify the spec's claims against the actual codebase before reviewing.I verified the spec's factual claims against the worktree at `.builders/spir-329` and against the installed `better-auth@1.7.7` / `@better-auth/api-key@1.7.7`. Evidence is cited inline. + +--- + +## (A) The seven blocking items + +1. **Eligibility of the resolved user** — **Closed.** All four conditions (sole account row, noreply domain, `registered_via`, not admin) are re-read under the lock, with an alert and an eligibility matrix covering email/service/admin/promoted/altered users. +2. **Ban check before the lock** — **Closed.** The ban check now sits after the under-lock re-read, and `users.isBanned` (honouring `banExpires`) already exists in `packages/auth/src/users.ts`, so the mechanism is real. +3. **Session creation / token handling / signing** — **Not fully closed.** The session half is specified. The signing half is wrong in one verified detail: `auth.api.signJWT` takes `{payload, overrideOptions}` and never calls `definePayload`; `signJWT` in `dist/plugins/jwt/sign.mjs:107-116` sets `exp`/`iss`/`aud`/`sub` but sets `iat` **only if the caller passes it**, whereas `/api/auth/token` goes through `getJwtToken` (`sign.mjs:118-128`), which injects `iat` and applies `definePayload`. So an exchange token built as the spec describes carries **no `iat`** — distinguishable from every other apps/auth JWT, against this spec's own constraint. It does not break first use (`apps/api/lib/auth/verify-jwt.ts:138` requires only `exp, sub, sid`), so this is a precision fix, not a redesign: pass `iat`, and make the criterion assert **claim-set equality**, not just `alg`/`iss`/`aud`/lifetime. Second-order: hand-building `{sub, sid}` at the exchange duplicates `definePayload` (`packages/auth/src/index.ts:355`) with nothing pinning them together — add a test that both paths produce the same claim set. +4. **Pre-401 outage check missed an unapplied migration** — **Closed** (a query on the relation itself, not `select 1`), with the cache window and the recover-between residual stated honestly. The boot assertion added alongside it opens a new gap (B2). +5. **Missing service user looked like a revoked key** — **Closed.** 500 + alert, never 401; also distinguished from "another owner → 401". `verifyApiKey` returns the key row minus the hash (`index.mjs:~2010`), so `referenceId` is available without a second read. +6. **"`start` not stored" had no mechanism** — **Closed.** `startingCharactersConfig.shouldStore` is real in 1.7.7 and gates the assignment (`index.mjs:808`, default `true` at `:2346`). +7. **Blocked-path prefix claim** — **Closed.** Current State now matches `apps/auth/src/middleware/blocked-paths.ts:56`: only `ADMIN_PREFIX` is prefix-matched, everything else exact. The new `/api/auth/api-key` prefix rule is the right shape. The derivation count is wrong, though — see B5. + +--- + +## (B) New gaps the revision introduces + +**B1. The "No CORS" claim has no mechanism and contradicts the wiring the spec itself records — blocking.** +`app.ts:60` does `app.use(corsFor(getEnv().CORS_ORIGIN))` as the first middleware, and `corsFor`'s origin callback (`middleware/cors.ts:27-31`) decides on **origin membership alone, never path**. A request to `/api/whatsapp/session` carrying `Origin: ` will therefore get `Access-Control-Allow-Origin` echoed, and the `cors` package will answer the `OPTIONS` preflight before the route is reached. The spec asserts "the global `corsFor` does not apply to this route" as if it were a fact; two paragraphs earlier it correctly says the middleware is mounted globally ahead of every route. This is the same species as iteration 1's prefix-matching defect: a claim with no mechanism. The fix isn't free either — re-ordering or path-scoping a global middleware in an auth service has blast radius on sign-in's CORS and on `mergeExposedHeaders`, so the spec should name the mechanism (path-scoped mount, or an exchange-local header-stripping middleware) rather than leave the builder to discover it when the criterion fails. + +**B2. The boot assertion conflates "relation missing" with "database unreachable" — blocking.** +`apps/auth/src/index.ts` deliberately does **zero** database work before `listen()` — the comment at lines 15-17 records the owner amendment that removed the boot admin check precisely so "a stale list can never stop sign-in", and `createDb` connects lazily (`db.ts:9-10`). The spec adds "asserts that `auth.apikey` exists and refuses to start if it does not" with no distinction between a deterministic schema fault and a transient connection fault. A database blip during a deploy would then stop the whole service — sign-in, JWKS, account endpoints — not just the exchange, and under a capped restart policy one blip becomes a permanent outage (this is a named critical lesson in `lessons-critical.md`). Make the assertion fail **only** on a relation-missing error code (`42P01`), treat unreachable as "start and let `/health` report 503", and add the converse success criterion — otherwise the fail-loud guard is strictly a new availability risk to the one thing that must not go down. + +**B3. The per-user budget's executor is unspecified, and the refusal path can roll it back — blocking.** +`consumeWindow` (`packages/auth/src/rate-limit-store.ts:31`) is a single upsert on `auth.rate_limit` against whatever `Db` it is handed; the existing precedent (`routes/account.ts:142`) calls it on the pool, **before** the handler and outside any transaction. The spec places it as step 5 *inside* the locking transaction without saying which executor it gets. If it gets the transaction, then returning 429 (and every other refusal: 403, 500, eligibility) rolls the counter back, so the budget never accumulates and never binds — a limit that passes by not running, which is the exact failure mode `lessons-critical.md` warns about. If it gets the pool, it is correct (no FK to `auth.user`, so no lock interaction) but then it is not protected by the lock, which the spec should say out loud. Pin the executor and add a criterion that the counter survives a refused request. + +**B4. The `FOR UPDATE` / `KEY SHARE` hazard is correctly identified but has no containment, only a probe — should-fix (high).** +The analysis is right: a session insert's FK takes `FOR KEY SHARE` on the parent user row, which conflicts with `FOR UPDATE` held by another connection, and because the holder isn't waiting, Postgres sees no cycle and will not break it — the request hangs until a timeout that the spec never specifies. `createDb` uses a default `pg` pool (`max: 10`), shared by every route, so ten hung exchanges stall sign-in and JWKS too. Two concrete improvements: (a) require a `lock_timeout`/`statement_timeout` on the exchange's transaction so the worst case is a 500 rather than pool exhaustion; (b) consider `SELECT … FOR NO KEY UPDATE` instead of `FOR UPDATE` — it does **not** conflict with `FOR KEY SHARE`, so the hazard disappears by construction, while still conflicting with a concurrent `DELETE` (which takes `FOR UPDATE` strength) and with the ban `UPDATE` (`FOR NO KEY UPDATE`). That preserves every race the spec needs serialized and demotes the probe from gate to confirmation. + +**B5. "Derived from `apiKey().endpoints` and asserted to have length 5" is false — should-fix.** +Probed from the installed plugin: `Object.keys(apiKey().endpoints).length === 7`, and two of them (`verifyApiKey`, `deleteAllExpiredApiKeys`) have `path === undefined` because they are built with `createAuthEndpoint.serverOnly` (`index.mjs:864`, `:1951`), which takes no path. `ADMIN_PATHS` maps `endpoint.path` over all values (`packages/auth/src/index.ts:140`), so copying it "exactly" yields seven entries, two `undefined`, which would flow into both `disabledPaths` and `BLOCKED_EXACT` as garbage. The guard fails loudly in the right direction, so it can't ship silently broken — but the spec should state the filter and assert **both** numbers (7 endpoints, 5 routed), since that pair is what actually detects a plugin bump. Happily, this also confirms the separate criterion "`/api-key/verify` has no route." + +**B6. 500 for an ineligible user leaves a permanently bricked BSUID and mis-signals to the consumer — should-fix.** +The choice is defensible for the oracle reason, but it collides with the consumer contract written three sections earlier: "treat 500 as 'apps/auth is failing'". For an ineligible user the condition is permanent and purely local, so the WhatsApp service will retry and page the operator forever for one user while apps/auth is perfectly healthy — and those 500s will sit in the same bucket an operator watches for real outages. Either give the consumer a documented terminal status for this case, or keep 500 and add two things the spec currently lacks: an explicit statement that these 500s are expected to be non-retryable, and a runbook step for what the operator does when the eligibility alert fires (today the alert has no response procedure, and the user stays bricked). + +**B7. The alerts contract contradicts itself on identifiers — should-fix.** +The Alerts criteria are headed "counts only, no identifiers", yet the eligibility alert carries "user id only" and the script events carry key ids. `apps/auth/src/alerts.ts` establishes the opposite posture deliberately — counts, window, and 8-char hash prefixes, "never an email" — and `arch-critical.md` records "counts and hash prefixes, never identifiers" as a system-shape fact. Pick one and say it: a key id is arguably not personal data, a user UUID is pseudonymous and a departure. If the departure is intended, justify it in the spec (an eligibility violation is unactionable without the id, which is a fair argument) and amend the criteria header rather than leaving two rules that disagree. + +**B8. Unprobed behaviour I am relying on the spec to flag — nit, but name them.** +The spec's assumption list covers `createApiKey`, `shouldStore`, `signJWT`, the logger and `createSession`-in-transaction. Two more are relied on silently: that `internalAdapter.createSession` leaves IP and user agent null when given no request context (asserted as a criterion, never listed as a probe), and that a `serverOnly` endpoint invoked through `auth.api` inherits the ambient transaction context at all — `signJWT` and `verifyApiKey` are both endpoints, not plain functions, so probe 4 should cover `signJWT` as well as `createSession`. + +--- + +## (C) Success criteria that are untestable as written + +- **The session margin has no value and no home.** "`expiresAt` ≥ the token's `exp` + the margin" appears three times; the number appears nowhere, and `limits.ts` is declared the single home of limit numbers. Unmeasurable until it is a named constant. +- **"The session write does not wait on that lock."** A non-blocking assertion has no failure mode unless it is expressed as a deadline — as written, the CI symptom is a hung suite, not a red test. State the timeout. +- **"apps/api's source gains no WhatsApp-specific code."** The scan scope is undefined, and the sibling criterion deliberately *adds* `registered_via = 'whatsapp'` to an apps/api contract test, so a naive grep fails. Per `lessons-critical.md`, the pattern needs a negative test against a known-bad line and a known-good near-miss, plus a hit count. +- **Alert thresholds** (per-key volume, 401 volume) are "over a threshold" with no number; the `limits.ts` bounds criteria likewise depend on open owner decision 3. Expected at this gate, but they are not testable until those land. +- **"No column of the row contains any substring (length ≥ 4) of the raw key."** A 68-character key yields ~65 four-character windows tested against a uuid, a base64url hash and other text columns; chance collisions give this roughly a 0.1–0.5% flake rate per run. Reformulate as: the full key absent from every column, `start` null, and the hash equal to `sha256(key)`. +- **The staging smoke sign-off** names an actor and a scope but no artifact or location, so "signed off" has no checkable state. Say where it is recorded. + +By contrast, two criteria that look soft are genuinely sharp and should be kept verbatim: the logger criterion's "cannot be met by disabling the logger" (the plugin really does log the raw error at `index.mjs:~1987`, so the near-miss cases are the right test), and the production-shaped stopped-database case. + +**REQUEST CHANGES** — B1 (CORS claim has no mechanism and contradicts the global `corsFor` mount), B2 (boot assertion must fail only on relation-missing, not on an unreachable database), B3 (pin the budget's executor so a refusal cannot roll the counter back), A3 (`signJWT` sets no `iat`: pass it and assert full claim-set equality with `/api/auth/token`); plus should-fixes B4–B7 and the C items. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-codex.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-codex.md new file mode 100644 index 00000000..264dc43c --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-input-codex.md @@ -0,0 +1,45 @@ +I verified the current auth sources/config; the iteration-2 spec itself is not present on disk, so I reviewed the supplied revision against them. + +## A. Original blocking findings + +1. **Eligibility check — CLOSED.** Eligibility is re-read under the user lock and covers sole WhatsApp account, reserved domain, `registered_via`, and non-admin role. +2. **Ban before lock — CLOSED.** The lock and re-read now precede the ban decision. +3. **Session/token/JWT mechanics — CLOSED at specification level, but unproven.** It names `internalAdapter.createSession`, keeps the session token internal, and uses server-only `signJWT`; transaction binding, token generation, claims, and exact expiry remain planned probes rather than proven behavior. +4. **Unapplied migration misclassified as 401 — CLOSED in intent.** The relation-level check and boot assertion address it, although their cache creates a new contract contradiction below. +5. **Missing service user looked revoked — CLOSED.** It is explicitly 500 plus an alert. +6. **“Start not stored” mechanism — CLOSED.** `startingCharactersConfig.shouldStore: false` is named and has a planned probe. +7. **False blocked-prefix claim — CLOSED.** The revision accurately distinguishes existing exact matching and adds a canonicalized API-key prefix rule. + +## B. New gaps + +### Blocking + +- **Cached relation check contradicts the promised outage behavior.** A cached successful check permits an unknown key to receive 401 during the cache window after the database fails, which the residuals acknowledge, while success criteria require a stopped database to return “500, never 401.” Separately, missing authorization must be answered without touching the database, contradicting “before any 401” the relation is checked. Define precisely which refusals run the check, the TTL, and outage-test timing; remove the absolute “never 401” claim if the residual remains. + +- **The per-user budget does not protect the shared key budget as claimed.** `verifyApiKey` consumes the key’s remaining/window budget before the body is parsed and the user budget is checked. A looping user can therefore continue receiving 429 while still exhausting the service key for everyone. The per-user budget limits successful exchanges, not key consumption; the risk statement and mitigation must be corrected or the ordering/design changed. + +- **The `FOR UPDATE` resolution is still conditional on unprobed Better Auth behavior.** The current `routes/account.ts` only demonstrates obtaining the internal adapter; it does not demonstrate binding its writes to a Drizzle transaction. If session insertion uses another connection, it can wait forever on the parent-row lock while the locking request awaits that insertion. The real-Postgres probe is correct, but the specification needs a viable fallback that preserves serialization against bans, deletion, and concurrent account insertion—not merely “raise an alternative with the architect.” + +- **The exact-expiry sequence is circular as written.** Session reuse is decided against the JWT’s exact `exp`, but signing occurs afterward. The spec must say whether it signs a candidate first, supplies an explicit expiry, or computes and then verifies the exact claim. This depends on currently unprobed `signJWT` behavior. + +### Should-fix + +- **Boot assertion scope and sequencing are underspecified.** Require the assertion to complete before `listen()`, with redacted failure handling. Checking only `auth.apikey` does not prove the accompanying `(provider_id, account_id)` constraint exists; either assert that constraint too or explicitly justify reliance on the migration’s single-transaction application. + +- **New status details need tightening.** The 403 oracle is explicitly accepted and is coherent. For 429, specify that `X-Retry-After` is an integer number of seconds and how it is derived. This matters to the consumer contract. + +- **500 for an ineligible mapped user is secure but operationally awkward.** It fails closed and makes corruption loud, but it classifies a permanent identity-state violation as a transient service failure, encouraging retries that consume key budget and repeat alerts. Either prescribe no automatic retry for this 500 class or choose a stable non-retry status without exposing further detail. + +- **Alert identifier rules conflict.** Eligibility alerts are said to contain a user id, script events contain service/key identifiers and names, while the Alerts criteria say `alerts.ts` carries “counts only, no identifiers.” Split the policies by alert type and make the assertions consistent. + +## C. Untestable or unreliable success criteria + +- “Every canonicalized variant,” “any suffix,” “every Better Auth path,” and “any origin” quantify over infinite inputs. Define finite equivalence classes or property-test generators. +- “No response echoes any part of its input” is literally impossible/undefined; restrict it to complete sensitive values such as the bearer, BSUID, and request body. +- “No column contains any substring of length ≥4” is probabilistic because unrelated hashes, UUIDs, or metadata can coincidentally share four characters. Assert exact hashing, raw-key absence, and `start` being null instead. +- The stopped-database “500, never 401” criterion is incompatible with the accepted cache-window residual. +- A source-level assertion cannot prove runtime transaction/connection identity; only the real-Postgres probe can. +- “Several concurrent messages” needs a defined concurrency count, synchronization point, and expected responses. +- “Valid key with `auth.apikey` absent” must explicitly mean a formerly valid raw key after dropping the relation from an already-running service, because the boot assertion prevents starting in that state. + +**REQUEST CHANGES (relation-check contract, per-user-budget claim, transactional session fallback, exact-expiry ordering).** \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-rebuttals.md new file mode 100644 index 00000000..13f89356 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-specify-iter3-rebuttals.md @@ -0,0 +1,24 @@ +# Spec 329: response to the security architect's review rounds 1 and 2, plus owner decisions + +Inputs: 329-specify-iter2-input-{codex,claude}.md (round 1, folded into 45e7d49), 329-specify-iter3-input-{codex,claude}.md (round 2), owner decisions D1–D4. + +## Owner decisions (all adopted) +- D1: no strict BSUID regex; sanity bounds only (non-empty, ≤256 characters, no whitespace or control characters). The delegation-input criteria are restated as a property of the lookup. +- D2: BSUID stored raw. +- D3: optional `phone_number`, stored on `auth.users` as an additionalField with `input: false` and `returned: false`; absent from /me; one column; in the never-log list. +- D4: session row written directly in the lock transaction (`generateId(32)`, createSession's generator) under `FOR NO KEY UPDATE`; `runWithTransaction` + `createSession` is the probe-chosen alternative. + +## Round 2: blocking (all accepted) +- C1 CORS: the exchange router is mounted before `corsFor`; the criterion is a finite Origin list plus a middleware-order test. +- C2 boot: fail only on `42P01` / a missing constraint; an unreachable database starts the service and `/health` reports it; added the converse criterion. +- C3 budget executor: pool, outside the transaction, after resolve and before the lock; the counter survives refused requests. +- C4 iat: explicit `{sub, sid, iat, exp}` to signJWT; claim-set equality with /token; definePayload agreement test; `exp` fixed before the session decision (non-circular). +- C5: corrected the claim that the per-user budget protects the key budget; the risk row was rewritten. +- C6: the check runs on every plugin refusal; a missing bearer is answered without the database; `APIKEY_RELATION_CHECK_TTL` in limits.ts; the criterion reads "after the cache window". + +## Round 2: should-fix (all accepted) +- C7: 7 endpoints, 5 routed, filtered on a defined path. C8: lock_timeout and statement_timeout. C9: 500 is not retried per message; runbook repair for eligibility. +- C10: alerts carry counts and hash prefixes; user ids only in server log lines; key ids allowed in script events. C11: signJWT in probe 4, as a note. +- Testability: `SESSION_MARGIN` constant; deadlines on probes; the apps/api scan gets a scope, a known-bad line, a near miss and a hit count; key-storage criterion = full key absent, `start` null, hash equality; staging sign-off recorded on PR #327 with counts (5 messages in 1 s → 5 answers, 1 user, 1 session); finite variant and origin lists; the "no echo" criterion is restricted to the bearer, BSUID, phone and body. +- Codex extras: the boot check also asserts the account unique constraint; `X-Retry-After` is integer seconds from consumeWindow; "auth.apikey absent" means dropped under a running service. +- BSUID regex items: moot (D1). diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index 6686fb28..8c6213fd 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -28,9 +28,13 @@ Affected: the WhatsApp service and its users (no path into apps/api today), the - **Blocked paths.** `BLOCKED_AUTH_PATHS` goes into `disabledPaths`, which better-auth matches exactly. apps/auth's `blocked-paths` middleware canonicalizes the path, then gives **prefix** matching only to `/api/auth/admin`; every other blocked path is matched exactly. `ADMIN_PATHS` is derived from `admin().endpoints`, so a plugin bump cannot silently publish a route. - **Limits.** `ROUTER_RULES` are all `false` (apps/auth sees apps/api's egress IP on legacy calls). Routes outside Better Auth's handler carry no per-IP rule. The account routes have a per-user budget (`ACCOUNT_ENDPOINT`, through `consumeWindow`). `packages/auth/src/limits.ts` is the single home of limit numbers. - **Alerts.** `apps/auth/src/alerts.ts` sends Sentry events that carry counts and hash prefixes, never identifiers. -- **Boot.** apps/auth runs no schema assertion at boot. `users.ts`'s header mentions one, but none exists. +- **Boot.** apps/auth does no database work before `listen()`: the pool connects lazily, and the owner amendment of 2026-10-17 removed the boot admin check so that nothing stale can stop sign-in. `users.ts`'s header mentions a boot assertion, but none exists. The pool is pg's default (10 connections), shared by every route. - **Operator scripts.** `grant-admin.ts` shows an account, then acts only on an explicit `--confirm `; it prints no secrets and runs from the auth image or a checkout with the auth `DATABASE_URL`. No script refuses to run outside the image, and none prints the database host. The issue's "refuse to run outside the auth image" describes a guard that does not exist, and a marker could not prove it. This spec drops it (correction directed by the security architect). - **Better Auth's logger.** `createAuth` sets no `logger`, so better-auth logs error objects as they are. +- **What Meta sends** (Meta's business-scoped user ID documentation, checked by the security architect, 2026-10-28): + - A BSUID is a two-letter ISO 3166 alpha-2 country code, a period, then up to 128 alphanumeric characters (e.g. `US.13491208655302741918`). A parent form `US.ENT.` exists for businesses in linked portfolios; Ansari is not enrolled. + - Meta **regenerates** the BSUID when the user changes phone number and reports it as a system message (`system.type` `user_changed_user_id` or `user_changed_number`, with `system.previous_user_id`). + - The webhook carries `from` and `wa_id` (the phone number) beside `from_user_id` (the BSUID): always for users without a username; for username users only inside a 30-day contact window. #327 currently reads `from_user_id` only. - **Better Auth transactions.** better-auth 1.7.7's internal adapter resolves its adapter through `getCurrentAdapter`, and `runWithTransaction` binds internal-adapter calls to an open transaction. - **`@better-auth/api-key` 1.7.7.** Peer `better-auth ^1.7.7`. Read from the published package and **proven against pglite** (throwaway specify-phase probe, the plugin wired to the real `auth` schema): - The `apikey` model: `configId` (default `"default"`), `name`, `start`, `referenceId` (owning user's id; `references` defaults to `"user"`), `prefix`, `key` (SHA-256, base64url), `refillInterval`, `refillAmount`, `lastRefillAt`, `enabled`, `rateLimitEnabled`, `rateLimitTimeWindow`, `rateLimitMax`, `requestCount`, `remaining`, `lastRequest`, `expiresAt`, `createdAt`, `updatedAt`, `permissions` (JSON text), `metadata` (JSON text, kept only with `enableMetadata: true`). The owner column is `referenceId`, not `userId`. @@ -60,10 +64,13 @@ Affected: the WhatsApp service and its users (no path into apps/api today), the - it follows the `auth` schema style (uuid ids, `timestamptz`, `reference_id` a uuid referencing `auth.users` with cascade delete); - the schema comment records the departures, as for the other tables. -It ships as migration `0001`, generated, reviewed as SQL, applied by hand before code, and numbered by main. The same migration adds `unique(provider_id, account_id)` to `auth.account`. Better Auth never changes the database shape. At boot, apps/auth asserts that `auth.apikey` exists and refuses to start if it does not (the #165 class: code shipped before its migration). +It ships as migration `0001`, generated, reviewed as SQL, applied by hand before code, and numbered by main. The same migration adds `unique(provider_id, account_id)` to `auth.account` and the nullable `phone_number` column to `auth.users` (§3). Better Auth never changes the database shape. **Boot assertion (the #165 class: code shipped before its migration).** Before `listen()`, apps/auth checks the schema this spec needs: the `auth.apikey` relation (a query against it) and the `unique(provider_id, account_id)` constraint (a catalog lookup). +- It refuses to start **only on a definite answer** that one is missing: Postgres `42P01` (undefined table) for the relation, or an empty catalog result for the constraint. +- When the database is **unreachable** (connection refused, timeout, any other error), the service starts as it does today and `/health` reports the database. A blip at deploy must not become a crash loop under the restart policy that takes sign-in down with it. +- Failures log `{name, code}` only. **A service is one auth user**, created only by an operator script: -- address `service-@noreply.askansari.ai` (Part 3), no credential, role `user`; +- address `service-@noreply.askansari.ai` (Part 4), no credential, role `user`; - exactly one account row `{ providerId: 'service', accountId: }`, which is how it is resolved (never by email); - one user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates; - it cannot sign in: there is no `service` provider in Better Auth, and it has no credential. @@ -97,8 +104,8 @@ Every create, rotate and revoke also sends a durable Sentry event (service, key It is never put in chat, tickets, docs, git or logs. apps/auth stores only its hash. **No HTTP surface.** -- The blocked paths are derived from `apiKey().endpoints`, exactly as `ADMIN_PATHS` derives from `admin().endpoints`, with an assertion that the derived list has length 5 today; a plugin bump that adds an endpoint fails loudly. -- They join `BLOCKED_AUTH_PATHS`, and the middleware gains a `/api/auth/api-key` **prefix** rule like `/api/auth/admin`'s, so every canonicalized variant and suffix is blocked. +- The blocked paths are derived from `apiKey().endpoints` as `ADMIN_PATHS` derives from `admin().endpoints`, but **filtered to endpoints with a defined path**: `verifyApiKey` and `deleteAllExpiredApiKeys` are `serverOnly` with no path, and copying `ADMIN_PATHS`' map would inject two `undefined` entries into `disabledPaths`. Two assertions pin today's shape: 7 endpoints, 5 routed. A plugin bump that changes either number fails loudly. +- They join `BLOCKED_AUTH_PATHS`, and the middleware gains a `/api/auth/api-key` **prefix** rule like `/api/auth/admin`'s, so canonicalized variants and suffixes are blocked (the finite test list is under Success Criteria). - `enableSessionForAPIKeys` stays `false`: a key is never a session anywhere on the router. **Verification is `auth.api.verifyApiKey`, in-process**, with the route's required permission. The route also binds the key's `referenceId` to its own service user, resolved by the `service` account row: @@ -112,7 +119,7 @@ No verify route at the public origin is built here; #314 adds one. ### 2. The WhatsApp token exchange: `POST /api/whatsapp/session` Mounted outside Better Auth's handler path, like the account routes, so no router per-IP rule sees it (the WhatsApp service calls from a Railway egress IP). `ROUTER_RULES` stays all `false`. -- **No CORS.** No response from the exchange, preflight included, carries `Access-Control-Allow-Origin` for any origin. Browser use is structurally impossible; the global `corsFor` does not apply to this route. +- **No CORS.** No response from the exchange, preflight included, carries `Access-Control-Allow-Origin`, so browser use is structurally impossible. **Mechanism:** the exchange router is mounted **before** `app.use(corsFor(…))` in `app.ts`. `corsFor` decides by origin alone and answers `OPTIONS` itself, so a route mounted after it would get CORS for the configured origin. Mounted first, the exchange also never passes through `mergeExposedHeaders` or `blockedPaths`, neither of which applies to it. - **No other credential.** A session token or a JWT presented as the bearer is just an unknown key (401). There is no fallback to user authentication, and the route is not reachable under `/api/auth/*`. - **Body parsing** is route-local, mounted after the key check, with a strict byte limit, so an unauthenticated or oversized body costs no parsing. @@ -120,19 +127,24 @@ Mounted outside Better Auth's handler path, like the account routes, so no route | | | |---|---| -| Request | `Authorization: Bearer `; JSON body `{ "bsuid": "" }` | +| Request | `Authorization: Bearer `; JSON body `{ "bsuid": "", "phone_number": ""? }`. `phone_number` is optional: Meta sends it (`from` / `wa_id`) always for users without a username, and for username users only inside a 30-day contact window, so its absence is normal | | 200 | `{ "token": "" }`: the same EdDSA 15-minute `{sub, sid}` token apps/auth issues every client, signed from a live session of the BSUID's user | -| 400 | `{ "message": "Invalid request" }`: body not JSON, too large, `bsuid` missing, not a string or not matching the BSUID pattern | +| 400 | `{ "message": "Invalid request" }`: body not JSON, too large, `bsuid` failing the sanity bounds, or `phone_number` present but failing its sanity bounds | | 401 | `{ "message": "Unauthorized" }`, byte-identical for **every** key refusal: missing, malformed, unknown, revoked, disabled, expired, budget-exhausted, rate-limited, wrong permission, or owned by another service | | 403 | `{ "message": "Forbidden" }`: the BSUID's user is banned | -| 429 | `{ "message": "Too many requests" }` with `X-Retry-After`: this one user's exchange budget is spent (the key itself is fine) | +| 429 | `{ "message": "Too many requests" }` with `X-Retry-After` (an integer number of seconds until the window frees, from `consumeWindow`'s `retryAfter`, as the account endpoints do): this one user's exchange budget is spent (the key itself is fine) | | 500 | `{ "message": "Internal error" }` for anything else: database outage, `auth.apikey` missing, service user unresolvable, an ineligible user behind a `whatsapp` row, the user deleted mid-exchange | -No response echoes any input. +No response contains the bearer, the BSUID, the phone number or any part of the request body. -**Contract duties of the consumer (#327):** send `Authorization: Bearer`; cache a token per BSUID for under 15 minutes; on a 401 **from apps/api**, re-exchange once, because a session can die after signing (ban, deletion), and the token's `sid` then fails first use. Treat 401 from the exchange as "my key is bad" (page the operator), 403 as "this user is banned", 429 as "slow this user down" and 500 as "apps/auth is failing". Spec 321 is not on develop, so its amendment lands on #327's branch, coordinated with that builder. +**Contract duties of the consumer (#327):** send `Authorization: Bearer`; send `phone_number` (Meta's `wa_id`) whenever the webhook carries it, and omit it otherwise; cache a token per BSUID for under 15 minutes; on a 401 **from apps/api**, re-exchange once, because a session can die after signing (ban, deletion), and the token's `sid` then fails first use. Treat 401 from the exchange as "my key is bad" (page the operator), 403 as "this user is banned", and 429 as "slow this user down". A 500 is either an outage or a permanent, local identity fault for that one user (an ineligible user, below): **do not retry a 500 per message**. Send the user the failure notice, page on a sustained 500 rate, and do not page on isolated 500s. Spec 321 is not on develop, so its amendment lands on #327's branch, coordinated with that builder. -**Outage versus refusal.** The plugin reports a database failure as `INVALID_API_KEY`, the same code as an unknown key. So before any 401 the route checks that `auth.apikey` itself answers: a query on that relation, not `select 1`, which would pass with the migration unapplied. If the check fails, the answer is 500. To keep unknown-key floods from doubling database work, a successful check is cached in-process for a few seconds. A sustained 401 volume per window raises an alert. +**Outage versus refusal.** The plugin reports a database failure as `INVALID_API_KEY`, the same code as an unknown key. So the route runs a relation check on **every plugin refusal** (`valid: false`), and only then answers 401: +- The check is a query against `auth.apikey` itself, not `select 1`, which would pass with the relation missing. +- A failed check → 500. +- A missing or malformed bearer never reaches the plugin and is answered 401 without touching the database. +- A successful check is cached in-process for `APIKEY_RELATION_CHECK_TTL` (a constant in `limits.ts`, a few seconds), so an unknown-key flood does not double database work. +- A sustained 401 volume per window raises an alert. **Accepted residuals:** - an outage that begins between the verify and the check (or inside the cache window) is misclassified as 401; @@ -142,24 +154,34 @@ No response echoes any input. **Per request:** 1. **Key.** Verify with permission `whatsapp: mint` → on refusal, the relation check → 401 (or 500). Bind `referenceId` to the WhatsApp service user (unresolvable → 500 + alert; another owner → 401). -2. **Body.** Parse (route-local, byte-limited) and validate the BSUID pattern → 400. A phone number is never accepted, stored or logged. +2. **Body.** Parse (route-local, byte-limited) and apply sanity bounds only → 400. No strict BSUID format is enforced (owner decision: Meta may change it, and the caller is the trusted WhatsApp service). + - `bsuid`: a non-empty string of at most 256 characters, with no whitespace or control characters. + - `phone_number`, when present: a non-empty string of at most 32 characters, digits with an optional leading `+`. 3. **Resolve or create.** Look up `auth.account` where `provider_id = 'whatsapp'` and `account_id = `. The provider is fixed in code; the lookup never goes through email. With no row, create the user and the account row in **one transaction**: - the exchange generates the user's UUID itself; - the address is `whatsapp-@noreply.askansari.ai`, the display name `WhatsApp user`, `registered_via = 'whatsapp'`, and there is no credential; - - the account row is `{ providerId: 'whatsapp', accountId: , userId }`. + - the account row is `{ providerId: 'whatsapp', accountId: , userId }` (the BSUID stored raw, owner decision 2); + - `phone_number` is stored on the user when provided. `unique(provider_id, account_id)` makes a BSUID map to exactly one user under concurrency: the losing request rolls back and uses the winner's user. -4. **Lock, then decide.** In one transaction: - - **Lock** the user's row (`SELECT … FOR UPDATE`). +4. **Per-user budget.** Spend one unit with `consumeWindow`, keyed by the user id (never the BSUID), numbers in `limits.ts`; spent → 429. It runs on the **pool, outside any transaction**, after the user is resolved and before the lock, like the account endpoints' budget. Inside the locking transaction, every refusal (403, 429, 500) would roll the counter back, and the limit would never bind. The budget bounds per-user session churn and per-user probing. **It does not protect the key's budget:** `verifyApiKey` has already spent a unit of the key's `remaining` and window by then. The key's budget is sized to absorb a looping user, and the per-key volume alert catches one. +5. **Lock, then decide.** In one transaction, with `lock_timeout` and `statement_timeout` set on it, so the worst case is a 500 rather than a stalled connection in the pool shared with sign-in and JWKS: + - **Lock** the user's row with `SELECT … FOR NO KEY UPDATE`. It serializes exchanges for one user and conflicts with the ban and the role update (both plain row updates), but not with the `KEY SHARE` lock that a session insert's foreign key takes. So no write, even one on another connection, can wait on the request's own lock. - **Re-read under the lock.** The user gone → 500 (deleted mid-exchange; the next message creates a new user). - - **Eligibility**, all required: the user's *only* account row is this `whatsapp` row; the address is under `@noreply.askansari.ai`; `registered_via = 'whatsapp'`; `role` is not `admin`. A violation → 500 plus a Sentry alert (user id only), never a token. + - **Eligibility**, all required: the user's *only* account row is this `whatsapp` row; the address is under `@noreply.askansari.ai`; `registered_via = 'whatsapp'`; `role` is not `admin`. A violation → 500, never a token. It raises a Sentry alert carrying counts only, and logs a server warning line naming the user id (the precedent: apps/api's "app row refused" warning). The runbook gives the repair: inspect that user's rows, remove the stray account row or role, or delete the user so the next message recreates them. Without that repair the BSUID stays refused. - **Ban** (admin plugin `banned`, honouring `banExpires`) → delete all the user's sessions, commit, 403. - - **Per-user budget** (`consumeWindow`, keyed by the user id, never the BSUID; numbers in `limits.ts`) → 429 when spent. - - **Session.** Reuse the user's live session only if it outlives the token being signed: its `expiresAt` is at least the token's exact `exp` plus a safety margin. Otherwise delete the user's sessions and create one with `internalAdapter.createSession`, the internal-adapter precedent in `routes/account.ts`. The session token comes from better-auth's own generator, is never returned, logged or sent to Sentry, and the session carries no IP address and no user agent. - - **Sign** the JWT with the jwt plugin's server-side `signJWT`, payload `{sub, sid}`, with the plugin's issuer, audience and lifetime. + - **Phone number.** When provided and different from the stored one, update it. When absent, leave the stored one untouched (never clear it). + - **Session.** Fix the token's times first: `iat = now` and `exp = now + JWT lifetime`. Reuse the user's live session only if `expiresAt ≥ exp + SESSION_MARGIN` (a constant in `limits.ts`). Otherwise delete the user's sessions and **write the new session row directly with Drizzle in this transaction**, as the user and account rows are written: + - the token comes from the generator better-auth's own session creation uses (its 32-character id generator); + - `expiresAt` is the configured session lifetime (30 days); + - there is no IP address and no user agent; + - the token is never returned, logged or sent to Sentry. + + `internalAdapter.createSession` inside better-auth's `runWithTransaction` is the alternative, if the plan's real-Postgres probe prefers it. Either way, the session write shares the locking transaction. + - **Sign** with the jwt plugin's server-side `auth.api.signJWT`, passing `{sub, sid, iat, exp}` explicitly, the same `exp` the session was checked against. That keeps the comparison non-circular. It also matters because `signJWT` sets `iat` only when the caller passes it, while `/api/auth/token` goes through `getJwtToken`, which injects `iat` and applies `definePayload`. The exchange's claim set must equal `/token`'s. - **Commit**, then return the token. - The session write and the signing run inside that same transaction (better-auth's transaction context), so a concurrent ban or deletion cannot slip between the decision and the session. **Hazard the plan must rule out:** `FOR UPDATE` conflicts with the `KEY SHARE` lock that the session insert's foreign key takes. If the insert ran on another connection, the request would wait on its own lock. The plan proves, against a real Postgres, that the session write shares the locking transaction. If better-auth's transaction context cannot carry it, the builder raises the alternative with the architect before choosing one. + The ban re-read, the phone update, the session write and the signing run in that one transaction, so a ban, deletion or promotion that commits first is always honoured. A session written directly must be indistinguishable from one better-auth wrote: the bearer plugin, `/api/account/me`'s live-`sid` check and revocation all treat it as their own. Tests read it back through better-auth. - **`/token` is not used.** Signing bypasses `/token`'s perimeter hook, so `TOKEN_EXCHANGE_PER_SESSION` and its `rate_limit` row do not apply. The per-user budget above replaces them for this route. The invariant is: **at signing time, the session outlives the token.** A ban, deletion or revocation after signing can still kill the session; the token then fails apps/api's first use, and the consumer re-exchanges once. @@ -168,7 +190,19 @@ The WhatsApp service caches the token per BSUID for under 15 minutes and calls a **Accepted oracle residual.** A holder of the WhatsApp key can confirm whether a given BSUID has used Ansari, and whether that user is banned, both from the responses and from latency (new, existing and refused take different times). Guessed BSUIDs also create durable, inert users. This is accepted because the WhatsApp service is the delegation authority: it already holds every BSUID it would ask about. BSUIDs are not enumerable, so this is targeted confirmation, not harvesting. The mitigations are detection, not prevention: per-key request-volume alerts, `USAGE_EXCEEDED` and `RATE_LIMITED` alerts (otherwise invisible, since they collapse to 401), and rotation and revocation. No constant-time padding is built. -### 3. A non-interactive address domain +### 3. The phone number (owner decision, reversing spec 321's baked decision 2) + +The exchange stores the WhatsApp user's phone number when the WhatsApp service has it: +- **Rules.** Store it on create. On a later exchange, update it when it is provided and different. Never clear it when it is absent (absence is normal, see Current State). +- **Storage.** One nullable column, `auth.users.phone_number`, declared as a Better Auth additional user field with `input: false` and `returned: false`, like `first_name` but never input and never output. It sits on the user rather than the `whatsapp` account row, beside the identity that the deferred number-change continuity work will need. It is PII. +- **Never exposed.** + - `GET /api/account/me` does not return it (its projection is explicit), so apps/api never sees it, and "apps/api gains no WhatsApp code" still holds. + - No Better Auth path accepts or returns it: `update-user` is already blocked, `input: false` refuses it on every write path, and `returned: false` strips it from every response (`get-session`, sign-in, and so on). + - It never appears in any log, alert, Sentry event or response; it joins the never-log list and the log-capture assertions. + - Rate-limit keys stay on the user id. +- **Scope.** Only the exchange writes it, and only for WhatsApp users. It is never used to find a user: lookup stays on the BSUID alone. + +### 4. A non-interactive address domain A second reserved domain, **`@noreply.askansari.ai`** (owner decision 1), for service users and WhatsApp users. The account kind is a prefix of the local part: @@ -196,11 +230,12 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA **Service keys and scripts** - [ ] `@better-auth/api-key` is pinned to exactly the installed `better-auth` version (1.7.7); core is not bumped. -- [ ] The `apikey` table mirrors the plugin's model with generator column names, in the `auth` schema (uuid id, `timestamptz`, `reference_id` uuid → `auth.users` on delete cascade). Migration `0001` (with `unique(provider_id, account_id)`) is the reviewed `drizzle-kit generate` output, numbered by main. The schema tests cover both. -- [ ] apps/auth refuses to boot when `auth.apikey` is absent. -- [ ] The blocked list is derived from `apiKey().endpoints` and asserted to have length 5. Each path, in every canonicalized variant (case, trailing slash, percent-encoding, doubled slash, dot segments) and with any suffix, gets the block response at `/api/auth/api-key…`. `/api-key/verify` has no route. +- [ ] The `apikey` table mirrors the plugin's model with generator column names, in the `auth` schema (uuid id, `timestamptz`, `reference_id` uuid → `auth.users` on delete cascade). Migration `0001` (with `unique(provider_id, account_id)` and `auth.users.phone_number`) is the reviewed `drizzle-kit generate` output, numbered by main. The schema tests cover both. +- [ ] apps/auth refuses to start when `auth.apikey` is absent (`42P01`) or the `(provider_id, account_id)` constraint is absent, with a `{name, code}` log. +- [ ] Database unreachable at boot → apps/auth starts and listens, and `/health` reports the database as down; when the database returns, `/health` recovers without a restart. +- [ ] The blocked list is derived from `apiKey().endpoints`, filtered to defined paths, with two assertions: 7 endpoints, 5 routed; neither `disabledPaths` nor the middleware contains `undefined`. For each of the 5 paths, this finite list gets the block response: exact; upper-case; mixed case; trailing slash; percent-encoded letter (`%61pi-key`); double-encoded (`%2561pi-key`); doubled slash; `/./` segment; `/x/../` segment; backslash; absolute-form target; a `/suffix` appended. `/api/auth/api-key/verify` and `/api/auth/api-key/delete-all-expired-api-keys` are blocked by the prefix too. - [ ] `enableSessionForAPIKeys` is `false`. A valid key sent as `x-api-key` and as a bearer to `/api/auth/get-session` and `/api/auth/token` yields no session and no token. -- [ ] A key created by the script verifies at the exchange. Its row holds the hash, and **no column of the row contains any substring (length ≥ 4) of the raw key** (`start` not stored, via `startingCharactersConfig.shouldStore: false`). +- [ ] A key created by the script verifies at the exchange. In its row: the full raw key appears in no column; `start` is null (`startingCharactersConfig.shouldStore: false`); `key` equals the plugin's hash of the raw key (`defaultKeyHasher`, SHA-256 base64url). - [ ] The create script prints the raw key exactly once. List, rotate and revoke print ids, names, prefixes, permissions, limits and dates, never a key or a hash. - [ ] Every script refuses to run without a database URL, writes only with a matching `--confirm `, and logs the key id acted on. Before any write it prints the hostname and port only; tested with a URL carrying a user, a password and a query string, none of which appears in the output. - [ ] The create script refuses `remaining` without a refill, a missing rate limit, values outside the `limits.ts` bounds, and any expiry option. @@ -217,46 +252,52 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA - [ ] key with `{ mcp: ['complete'] }` only; key with no permissions; - [ ] a `{ whatsapp: ['mint'] }` key owned by a different service user (seeded directly; the script cannot produce it). -**Exchange: failures that are never 401** -- [ ] `auth.apikey` absent (migration unapplied) → 500, never 401, for both a valid key and an unknown key. -- [ ] Database stopped under a running apps/auth (production-shaped, `NODE_ENV=production`) → 500, never 401, for a valid key and an unknown key. +**Exchange: failures that are not 401** (once the relation-check cache window has elapsed) +- [ ] `auth.apikey` dropped under a running apps/auth (boot passed earlier, then the relation goes): after waiting out `APIKEY_RELATION_CHECK_TTL`, a formerly valid key and an unknown key both get 500. +- [ ] Database stopped under a running apps/auth (production-shaped, `NODE_ENV=production`): after waiting out `APIKEY_RELATION_CHECK_TTL`, a valid key and an unknown key both get 500. Inside the window, a 401 is the stated residual. +- [ ] A missing or malformed bearer gets 401 with zero database queries (counted). - [ ] WhatsApp service user missing → 500 plus an alert. - [ ] The resolved user deleted between lookup and lock → 500, no session row, no token. **Exchange: identity and delegation** - [ ] A new BSUID creates exactly one user (address exactly `whatsapp-@noreply.askansari.ai`), one `whatsapp` account row and one session. It returns a JWT that verifies against apps/auth's JWKS with the same `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, payload `{sub, sid}` naming that user and session. - [ ] The same BSUID twice returns the same `sub` and the same `sid`; the user has one session. -- [ ] For every returned token, the session's `expiresAt` ≥ the token's `exp` + the margin. A session that would fail this is replaced (old one deleted, `sid` names the new one). +- [ ] For every returned token, the session's `expiresAt` ≥ the token's `exp` + `SESSION_MARGIN`. A session that would fail this is replaced (old one deleted, `sid` names the new one). +- [ ] **Claim-set equality.** An exchange JWT and a `/api/auth/token` JWT for the same kind of user have the same claim keys (`sub`, `sid`, `iat`, `exp`, `iss`, `aud`), the same `alg` and `kid` source, the same `iss` and `aud`, and the same `exp − iat`. A test also pins that the exchange's hand-built `{sub, sid}` equals what `definePayload` returns for the same session, so the two cannot drift. - [ ] **Only a user whose sole account row is `whatsapp` is ever minted for.** Each of these gets 500 and an alert, never a token: - a `whatsapp` row attached to an email user (one with a `credential` row); - a `whatsapp` row attached to a service user; - a `whatsapp` row attached to an admin; - a WhatsApp user later promoted to admin (seeded, since `grant-admin.ts` refuses); - a WhatsApp user whose address or `registered_via` was changed. -- [ ] Passing an admin's or email user's id or email as `bsuid` is refused with 400. A `google` user whose `account_id` is BSUID-shaped is never reached (a new WhatsApp user is created instead). +- [ ] Passing an admin's or email user's id or email as `bsuid` is just an unknown BSUID: it creates a new WhatsApp user and never reaches the seeded user. The same holds for a `google` user's `account_id`. This is the lookup's property (fixed provider), not a format check's. - [ ] A banned WhatsApp user gets 403, all their sessions are deleted, and a JWT minted before the ban then fails `GET /api/account/me`. An expired ban is not refused. -- [ ] The per-user budget returns 429 with `X-Retry-After` once spent; another user under the same key is unaffected; the `rate_limit` key contains the user id, not the BSUID. +- [ ] The per-user budget returns 429 with an integer `X-Retry-After` once spent; another user under the same key is unaffected; the `rate_limit` key contains the user id, not the BSUID. +- [ ] **The counter survives a refused request:** after a request refused with 403 (banned) or 500 (ineligible), the user's `rate_limit` count has still increased by one. +- [ ] The locking transaction sets `lock_timeout` and `statement_timeout` (values in `limits.ts`). A test holding the user's row lock from another connection gets a 500 within the timeout, not a hang. - [ ] **Concurrency, scoped to what each test proves.** On pglite, which serializes connections: - a forced conflict on `unique(provider_id, account_id)` leaves one user, and both callers get its token; - a ban landing between the account lookup and the lock is seen under the lock (403, no session). - A source-level assertion pins that the eligibility re-read, the ban check, the session write and the signing all run inside the transaction that holds `FOR UPDATE`. The plan's real-Postgres probe shows the session write does not wait on that lock. Concurrent messages for one user are exercised in the staging smoke. + A source-level assertion pins that the eligibility re-read, the ban check, the phone update, the session write and the signing all run inside the transaction that holds `FOR NO KEY UPDATE`, and that `consumeWindow` runs outside it. The plan's real-Postgres probe shows a ban and a session write behave as specified under that lock, each step **under a test deadline** (a red test on timeout, never a hung suite). Concurrent messages for one user are exercised in the staging smoke. **Exchange: privacy, logging, transport** -- [ ] A phone-number-shaped `bsuid` (E.164 with and without `+`, digits only) → 400, after the key and before any lookup or log. -- [ ] No created row (user email and name, session IP and user agent, account, `rate_limit`) contains the BSUID except `account.account_id`, and none contains a phone number. -- [ ] Across every refusal, failure and outage case, `console.*` and better-auth's logger contain no raw key, key hash, session token, BSUID or driver error object. +- [ ] No created row (user email and name, session IP and user agent, account, `rate_limit`) contains the BSUID except `account.account_id`. +- [ ] **The phone number appears only in `auth.users.phone_number`.** It is stored on create when provided, updated when provided and different, and untouched when absent. It is absent from `GET /api/account/me`, from every Better Auth response (`get-session`, sign-in), and from every other row. +- [ ] Better Auth refuses `phone_number` as input on every path that writes a user (`input: false`), tested on sign-up. +- [ ] Across every refusal, failure and outage case, `console.*` and better-auth's logger contain no raw key, key hash, session token, BSUID, phone number or driver error object. - [ ] Better Auth's logger: a non-error log survives with its level and message; a known driver error passed to it is reduced to exactly `{name, code}`; a near miss (a plain object with `name`/`code`-like fields, and a non-error string argument) survives unchanged. This criterion cannot be met by disabling the logger. -- [ ] Sentry events from the exchange carry no request body and no `Authorization` header (tested on a captured event). -- [ ] No response from the exchange echoes any part of its input. -- [ ] No response from the exchange, `OPTIONS` preflight included, carries `Access-Control-Allow-Origin` for any origin, including the configured `CORS_ORIGIN`. +- [ ] Sentry events from the exchange carry no request body, no `Authorization` header and no phone number (tested on a captured event). +- [ ] No response from the exchange contains the bearer, the BSUID, the phone number or the request body (asserted per case on the raw response bytes). +- [ ] No response from the exchange carries `Access-Control-Allow-Origin` (or any `Access-Control-*` header), for `POST` and `OPTIONS` alike, with `Origin` set to: each configured `CORS_ORIGIN`; `null`; `https://evil.example`; and no `Origin` at all. The exchange router is mounted before `corsFor`, pinned by a test on the middleware order. - [ ] A body over the byte limit → 400 (after the key), and a body sent with no key is never parsed. -**Alerts** (through `alerts.ts`: counts only, no identifiers) -- [ ] Per-key exchange request volume over a threshold per window. +**Alerts** (through `alerts.ts`, in its posture: Sentry events carry counts, windows and hash prefixes, never a personal identifier. An operator-actionable user id goes only in a server log line. Key ids may appear in the scripts' events: they identify a credential, not a person.) +- [ ] Per-key exchange request volume over `WHATSAPP_EXCHANGE_KEY_ALERT` per window. - [ ] `USAGE_EXCEEDED` and `RATE_LIMITED` refusals (one event per window each). -- [ ] 401 volume per window over a threshold. -- [ ] Eligibility violations and an unresolvable service user (each occurrence). +- [ ] 401 volume per window over `EXCHANGE_UNAUTHORIZED_ALERT`. +- [ ] Eligibility violations (count event, plus a server log line with the user id) and an unresolvable service user. +- [ ] The alert thresholds are named constants in `limits.ts`; their values are set with owner decision 3. **Reserved domain** - [ ] Sign-up with a `@noreply.askansari.ai` address (any case, surrounding spaces) returns the same 409 body as a taken address. @@ -268,8 +309,8 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA **End to end** - [ ] In the existing apps/api contract tests (which start a real apps/auth), a JWT from the exchange passes `requireUser`: JWKS verification, first-use `GET /api/account/me`, and a `public.users` row under the same UUID with `registered_via = 'whatsapp'`. -- [ ] apps/api's source gains no WhatsApp-specific code. -- [ ] Staging smoke, run and signed off by the WhatsApp builder (#327) against staging apps/auth and apps/api with a key issued by the create script: a phone-visible user, a phone-hidden user, and several concurrent messages from one user (one user, one session afterwards). This is a pre-production gate, not a merge gate for this PR. +- [ ] apps/api's source gains no WhatsApp-specific code. A scan over `apps/api/src/**` and `apps/api/lib/**` (tests excluded, since the contract test names `registered_via = 'whatsapp'`) for `whatsapp` (case-insensitive) reports its hit count, 0 today and 0 after. Its negative tests: a known-bad line (`if (registeredVia === 'whatsapp')` in a fixture under the scanned glob) is caught; a near miss outside the glob is not. +- [ ] Staging smoke, run and signed off by the WhatsApp builder (#327) against staging apps/auth and apps/api with a key issued by the create script: a phone-visible user, a phone-hidden user, and 5 messages from one user sent within one second. Expected: 5 answers, 1 user and 1 session in apps/auth afterwards, and at most 5 exchanges (fewer if the consumer's token cache serves some). Recorded as a comment on PR #327 by its builder, with the counts. This is a pre-production gate, not a merge gate for this PR. **Documentation** - [ ] `docs/self-hosting.md` covers the new route, the service-user and key scripts, the `limits.ts` bounds, and the migration-before-code order. @@ -281,7 +322,8 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA - budget sizing (owner decision 3); - the pre-apply check that `auth.account` holds no duplicate `(provider_id, account_id)` before `0001`, noting that adding the constraint takes an ACCESS EXCLUSIVE lock on a table every sign-in reads (apply in a quiet window); - the alert meanings. -- [ ] `codev/resources/arch.md` (Authentication & Authorization) describes service keys, the exchange and the noreply domain. +- [ ] `codev/resources/arch.md` (Authentication & Authorization) describes service keys, the exchange, the noreply domain and the alert posture (counts and hash prefixes in Sentry; user ids only in server log lines). +- [ ] The runbook gives the repair for an eligibility alert: find the user from the log line, inspect its account rows, role, address and `registered_via`, then remove the stray row or role, or delete the user so the next message recreates them. **Unchanged** - [ ] `ROUTER_RULES` are all `false`; no per-IP rule applies to the exchange. @@ -290,13 +332,13 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA ## Constraints -- **Risk tier High** (auth model, new table, new credential type): porch's consultation on spec, plan and PR, plus the security architect's 3-way review at each gate. The owner answers decisions 2–4 at the spec-approval gate (decision 1 is answered). +- **Risk tier High** (auth model, new table, new credential type): porch's consultation on spec, plan and PR, plus the security architect's 3-way review at each gate. Decisions 1 and 2 are answered; the owner answers 3 and 4 at the spec-approval gate. - **Pin, do not bump.** The plugin's newest release requires core 1.7.14; bumping core is a separate, joint change. - **Schema discipline.** `schema.ts` is hand-written; `drizzle-kit generate` → reviewed SQL → applied by a human with `psql -v ON_ERROR_STOP=1 --single-transaction` against the auth database. Never `db:push`. `architect:main` is messaged before the journal entry is committed. Migration before code at deploy. - **Two databases.** Everything here is in the auth database and apps/auth. apps/api changes only in tests. No query crosses the two. - **Tokens.** The exchange's JWT must be indistinguishable from any other apps/auth JWT, and signed from a real session that outlives it at signing time. - **Client IP.** No per-IP rule on the exchange; `ROUTER_RULES` stays all `false`. -- **Where secrets live.** The raw key lives in the consumer's secret variable and, once, in the create script's output; apps/auth holds its hash. Session tokens created by the exchange never leave apps/auth. Never log a raw key, a key hash, a session token, a raw driver error, a BSUID or a phone number. +- **Where secrets live.** The raw key lives in the consumer's secret variable and, once, in the create script's output; apps/auth holds its hash. Session tokens created by the exchange never leave apps/auth. Never log a raw key, a key hash, a session token, a raw driver error, a BSUID or a phone number. The phone number is stored in exactly one column and returned by nothing. - **Limits.** Every number lives in `packages/auth/src/limits.ts`. - **Sequencing.** develop is frozen for apps/auth and packages/auth until the spec 300 production cutover and its contract PR land. Spec, plan and implementation proceed; the PR does not merge before then and expects a rebase. The migration is `0001` in `packages/auth/drizzle`. - **Scope guard.** #314's verify route does not creep in. The leaderboard is not migrated. @@ -305,11 +347,11 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA ## Assumptions - The spec 300 cutover lands without changing `createAuth`'s plugin list, the account-route mounting or `BLOCKED_AUTH_PATHS` in a way that conflicts with this design. The rebase reconciles details. -- A BSUID is `.` (seen in #327: `US.13491208655302741918`, `EG.1234567890123456789`). The pattern below is the contract unless the WhatsApp builder shows a real BSUID it refuses. -- BSUIDs are stable per user per business portfolio. Hiding or revealing a phone number does not change a BSUID. +- BSUIDs follow Meta's documented shape (see Current State), but the exchange enforces only sanity bounds, so a format change by Meta does not refuse real users. +- A BSUID is stable per user per business portfolio, until the user changes phone number: Meta then issues a new BSUID, which this spec treats as a new user (continuity is deferred; see Nice-to-know). - The WhatsApp service holds one key at a time (two during rotation) and is the only consumer of `whatsapp: mint`. -- PR #327 adopts the contract above (header, error handling, re-exchange on an apps/api 401). -- `@better-auth/api-key` 1.7.7 behaves as probed, and better-auth 1.7.7's transaction context carries internal-adapter writes. The plan probes the parts not yet proven: `createApiKey` from a script, `shouldStore: false`, `signJWT`'s claims, the logger redaction, and `createSession` inside the locking transaction on a real Postgres. Tests pin every behaviour relied on, so a later bump that changes one fails loudly. +- PR #327 adopts the contract above (header, `phone_number` when present, error handling, re-exchange on an apps/api 401), and drops its "phone number is never sent on" rule for this one call. +- `@better-auth/api-key` 1.7.7 behaves as probed, and better-auth's `returned: false` / `input: false` field attributes behave as their source reads. The plan probes the parts not yet proven: `createApiKey` from a script, `shouldStore: false`, `signJWT`'s claims, the logger redaction, `returned: false` on `phone_number`, and the session write and ban under `FOR NO KEY UPDATE` on a real Postgres. Tests pin every behaviour relied on, so a later bump that changes one fails loudly. ## Solution Approaches @@ -318,7 +360,8 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA Keys come from `@better-auth/api-key` (hashing, permissions, budgets, atomic usage accounting), created in-process by the scripts, with its HTTP surface blocked by derivation and prefix, and its logger redacted. The exchange: - verifies the key and binds its owner; - creates the WhatsApp user and account row in its own transaction, the pattern `banUserByEmail` uses; -- in one locking transaction: re-reads eligibility and ban, applies the per-user budget, manages the one session through `internalAdapter.createSession`, and signs with `signJWT`. +- spends the per-user budget on the pool, outside any transaction; +- in one locking transaction (with lock and statement timeouts): re-reads eligibility and ban, updates the phone number, writes the one session row directly (or through `internalAdapter.createSession` under `runWithTransaction`), and signs with `signJWT`. The `user.create.before` hook refuses both reserved domains unconditionally. That is stronger than the issue's "permitted only when the creating context is the exchange or a script", with the same outcome and no context check to get wrong. @@ -329,7 +372,7 @@ The `user.create.before` hook refuses both reserved domains unconditionally. Tha - no credential crypto is maintained here. - **Cons:** - the exchange writes `auth.users` and `auth.account` rows itself, so it must match Better Auth's row shape (pinned by the hand-written schema and read back through Better Auth in tests); - - the session write must share the locking transaction (the hazard above). + - the exchange writes the session row too, so it must match Better Auth's session shape (token generator, lifetime); tests read it back through Better Auth. - **Risk:** medium. The plan's first phase probes the unproven library paths before anything is built on them. ### Approach 2: identity through Better Auth's internal adapter with a context marker @@ -356,12 +399,12 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' ## Open Questions -**Critical: owner decisions at the spec-approval gate** +**Critical: owner decisions (1 and 2 answered; 3 and 4 open)** 1. ~~The non-interactive domain name.~~ **Decided by the owner (2026-10-28):** `@noreply.askansari.ai`, a fixed reserved subdomain with the account kind as a local-part prefix (`whatsapp-`, `service-`). This follows the common convention (GitHub's `users.noreply.github.com`, Apple's `privaterelay.appleid.com`). Considered and rejected: - a hostname per user (e.g. `whatsapp-.askansari.ai`): the reserved check would have to pattern-match hostnames instead of testing one fixed suffix, and the id would sit in a DNS label; - the bare `@askansari.ai`: it would refuse real staff addresses. -2. **Store the BSUID raw in `account.account_id`, or HMAC it with an apps/auth secret.** Recommendation: raw. The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a new env var and a rotation story that re-keys every row. +2. ~~Store the BSUID raw or HMAC it.~~ **Decided by the owner (2026-10-28): raw**, in `account.account_id`, no HMAC. The original reasoning: The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a new env var and a rotation story that re-keys every row. 3. **The WhatsApp key's limits**, sized from the legacy WhatsApp service's traffic. The decision is two numbers per key: - a per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow`, e.g. per minute); - a daily budget (`refillAmount` per 24 h). @@ -375,24 +418,29 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' 6. Banned user: 403 `{ "message": "Forbidden" }`, so the consumer can tell "this user is banned" from "my key is bad". This is an accepted oracle toward a trusted key holder. 7. Order: key, then body (route-local parsing after the key). 8. Image guard dropped. The scripts follow `grant-admin.ts`'s posture plus hostname-and-port output (architect). -9. BSUID pattern `^[A-Z]{2}\.[0-9]{10,32}$`, matching both samples in #327. A bare phone number (E.164, with or without `+`) cannot match. The pattern cannot stop a caller that deliberately disguises one, which is why the WhatsApp service's own "no phone number" test is part of the contract. The WhatsApp builder confirms it against real webhooks before the PR. +9. No strict BSUID format (owner decision): sanity bounds only, i.e. a non-empty string of at most 256 characters with no whitespace or control characters. Meta may change the format, and the caller is the trusted WhatsApp service. A changed format, or a parent-form `US.ENT.` after an enrolment change, therefore creates new users rather than 400s. That is a known consequence, to be watched through the creation signal (item 18). 10. `start` not stored (`shouldStore: false`). 11. Revoke disables and keeps the row. Production keys have no expiry. 12. A per-user exchange budget inside the key's bound, keyed by user id, with its 429. +13. The phone number is stored on `auth.users` (owner decision), with the rules in Desired State §3. +14. The session row is written directly in the locking transaction, under `FOR NO KEY UPDATE` (architect). +15. The exchange router is mounted before `corsFor`; the boot assertion fails only on a definite missing-schema answer; the per-user budget runs outside the transaction; `iat` and `exp` are passed to `signJWT` explicitly; the relation check runs on every plugin refusal, with a named TTL; a 500 is not retried per message (architect, iteration 3). **Nice-to-know** -13. **Session sliding.** The reused session is 30 days and slides daily on use. An active user keeps one session; a quiet one gets a fresh one. -14. **Deleting a WhatsApp user.** `DELETE /api/account` with the user's own JWT cascades the account row away, so the next message from that BSUID creates a new user. This is intended; a user-facing delete flow for WhatsApp is out of scope. -15. **New-user volume.** An alert on WhatsApp user creations per window would add a second signal beside per-key volume. Plan-level option. +16. **Session sliding.** The reused session is 30 days and slides daily on use. An active user keeps one session; a quiet one gets a fresh one. +17. **Deleting a WhatsApp user.** `DELETE /api/account` with the user's own JWT cascades the account row away, so the next message from that BSUID creates a new user. This is intended; a user-facing delete flow for WhatsApp is out of scope. +18. **New-user volume.** An alert on WhatsApp user creations per window would add a second signal beside per-key volume, and would surface a Meta format or enrolment change (item 9). Plan-level option. +19. **Number-change continuity (deferred, as spec 321 defers it).** When a user changes phone number, Meta issues a new BSUID and sends a system message (`system.type` `user_changed_user_id` or `user_changed_number`, with `system.previous_user_id`). Today the new BSUID becomes a new user. That message is the hook for the deferred work: re-pointing the `whatsapp` account row from the old BSUID to the new one, with the stored phone number as corroboration. ## Test Scenarios **Probes first (plan phase 1, before anything is built on them)** -1. `auth.api.createApiKey` from a script context: the key verifies; `shouldStore: false` leaves no key substring in any column; `permissions` and `metadata` round-trip. -2. `signJWT` server-side: the claims match a `/api/auth/token` JWT, and `exp` is readable for the session comparison. +1. `auth.api.createApiKey` from a script context: the key verifies; the raw key is in no column, `start` is null, and `key` equals `defaultKeyHasher(raw)`; `permissions` and `metadata` round-trip. +2. `auth.api.signJWT` with explicit `{sub, sid, iat, exp}`: the claim set equals a `/api/auth/token` JWT's (with no `iat`, it would differ, per `sign.mjs`). The passed `exp` is honoured, not overridden by the configured lifetime. 3. The redacted logger: non-error logs survive; error objects are reduced to `{name, code}`. -4. Real Postgres: `internalAdapter.createSession` inside the transaction that holds `FOR UPDATE` on the user completes, without waiting on its own lock. +4. Real Postgres, every step under a deadline: under `FOR NO KEY UPDATE` on the user, a directly written session row commits without waiting on its own lock; a concurrent ban waits for the exchange, then deletes its session; `lock_timeout` turns a held lock into an error. A directly written session is accepted by the bearer plugin and `/api/account/me`. `signJWT` does no database write, so whether an endpoint invoked through `auth.api` sees the ambient transaction does not matter to it; recorded as a note. +4a. `returned: false` / `input: false`: `phone_number` is absent from `get-session` and sign-in responses, and ignored when sent to sign-up. **Functional (pglite: packages/auth and apps/auth suites)** 5. Happy path: new BSUID → 200; the token verifies; `sub`/`sid` match; rows as specified; address exact. @@ -406,19 +454,22 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' 13. Eligibility matrix: `whatsapp` row on an email, service or admin user; a promoted WhatsApp user; an altered address or `registered_via` → 500 + alert, no token. 14. Delegation inputs: admin and email-user ids and emails as `bsuid` → 400; a BSUID-shaped `google` `account_id` → a new WhatsApp user. 15. Ban: 403, sessions deleted, an earlier JWT dead at `/api/account/me`; an expired ban is let through. -16. Per-user budget: 429 with `X-Retry-After`; another user unaffected; the `rate_limit` key holds the user id. -17. Body: non-JSON, oversized, missing, non-string, empty, too long, lower-case country code, phone-shaped → 400 (after a valid key). No key → never parsed. -18. Transport: no CORS allow-origin on any response or preflight; a session token or JWT as the bearer → 401; not reachable under `/api/auth/*`. -19. Blocked surface: the derived list has length 5; every variant and suffix under `/api/auth/api-key` is blocked; `/api-key/verify` → 404. +16. Per-user budget: 429 with an integer `X-Retry-After`; another user unaffected; the `rate_limit` key holds the user id; the counter survives a 403 and a 500; it runs on the pool, outside the transaction. +16a. Lock timeout: the user row held from another connection → 500 within `lock_timeout`. +16b. Claim-set equality with `/api/auth/token`; `definePayload` agreement. +17. Body: non-JSON, oversized, `bsuid` missing, non-string, empty, over 256 characters, containing whitespace or a control character → 400; `phone_number` non-string, empty, over 32 characters or non-digits → 400 (all after a valid key). No key → never parsed. A lower-case or unusual but sane `bsuid` is accepted. +17a. Phone number: stored on create; updated when different; untouched when absent; never in `/api/account/me` or any Better Auth response; only in its column. +18. Transport: no `Access-Control-*` header on `POST` or `OPTIONS` for the four `Origin` cases, and the router is mounted before `corsFor`; a session token or JWT as the bearer → 401; not reachable under `/api/auth/*`. +19. Blocked surface: derived from 7 endpoints, 5 routed, no `undefined`; the finite variant list under `/api/auth/api-key` is blocked; `/api-key/verify` → 404. 20. Keys are never sessions: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` → nothing. 21. Reserved domain: sign-up 409 identical to taken; near misses sign up; reset identical to unknown, mailer not called; hook refusal; sign-in identical to unknown; `grant-admin.ts` refuses noreply; `isSystemAddress` unchanged. 22. Scripts: no database URL; show without confirm; mismatched confirm; hostname-and-port only (URL with userinfo and query); create prints once; refuses no-refill, out-of-bounds and expiry; list has no secrets; rotate gives two live keys; revoke disables and keeps; Sentry events with no key material. 23. Refilled budget: exhaust → refused, row kept; past the interval → verifies. -24. Source-level: the eligibility re-read, ban check, session write and signing sit inside the `FOR UPDATE` transaction. +24. Source-level: the eligibility re-read, ban check, phone update, session write and signing sit inside the `FOR NO KEY UPDATE` transaction. **Non-functional** -25. Log and Sentry capture across 5–23: no raw key, hash, session token, BSUID, phone-shaped value or driver error object; Sentry events carry no body or `Authorization`. -26. Outages: `auth.apikey` absent → 500, and boot refuses; database stopped under a running apps/auth (`NODE_ENV=production`) → 500, never 401. +25. Log and Sentry capture across 5–23: no raw key, hash, session token, BSUID, phone number or driver error object; Sentry events carry no body or `Authorization`. +26. Outages: boot refuses on `42P01` or a missing constraint; boot with the database unreachable starts, `/health` reports it and later recovers; `auth.apikey` dropped under a running service → 500 after the cache window; database stopped under a running apps/auth (`NODE_ENV=production`) → 500, never 401. 27. Alerts fire for per-key volume, `USAGE_EXCEEDED`, `RATE_LIMITED`, 401 volume, eligibility violations and a missing service user. 28. End to end in apps/api's contract harness: exchange token → `requireUser` → `public.users` row. 29. Staging smoke (WhatsApp builder signs off): phone-visible, phone-hidden, concurrent messages from one user. @@ -429,21 +480,22 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' |------|-------------|--------|------------| | A WhatsApp key mints for a non-WhatsApp user (a `whatsapp` row on another user, a promotion, a future social provider) | Low | Critical | Fixed-provider lookup; owner binding; **eligibility under the lock** (sole `whatsapp` row, noreply domain, `registered_via`, not admin), else 500 + alert; `grant-admin.ts` refuses noreply; eligibility matrix tests | | Ban or deletion races token issuance | Medium | High | Lock first, re-read under it; session and signing in the same transaction; consumer re-exchanges once on an apps/api 401 | -| The session write waits on its own `FOR UPDATE` (self-deadlock) | Medium | High | Session write in the locking transaction via better-auth's transaction context; real-Postgres probe before building; escalate to the architect if infeasible | +| A write waits on the request's own user lock (self-deadlock), or a held lock stalls the shared pool | Low | High | `FOR NO KEY UPDATE`, which does not conflict with a foreign key's `KEY SHARE`; session written in the same transaction; `lock_timeout` and `statement_timeout` make the worst case a 500; real-Postgres probe under deadlines | | A session token (30-day bearer credential) leaks | Low | Critical | Better-auth's generator; never returned, logged or sent to Sentry; in the log-capture assertions | -| An outage or unapplied migration answered as 401 | Medium | High | Relation-level check before 401; boot assertion; tests for an absent relation and a stopped database; residual window stated | +| An outage or unapplied migration answered as 401 | Medium | High | Relation-level check on every plugin refusal, cached for a named TTL; boot assertion on definite answers only; tests for an absent relation and a stopped database; residual window stated | | An unknown-key flood amplifies database work | Medium | Medium | Cached relation check; 401-volume alert; residual stated | | The plugin logs raw driver errors | High without mitigation | High | Redacted logger (not disabled), with near-miss tests | | A burst deletes the production key | High if `remaining` were set without refill (probed) | High | The script refuses no-refill; refilled exhaustion pinned as refused-and-kept; no expiry; revoke disables and keeps | -| One looping user exhausts the key for everyone | Medium | High | Per-user budget inside the key's bound (429) | +| One looping user exhausts the key for everyone | Medium | High | Not prevented by the per-user budget (the key is spent at verify, before it). The key's budget is sized to absorb a looping user; the per-key volume alert catches one; the per-user budget bounds that user's session churn and probing | | A leaked key confirms BSUID membership and bans, or creates users | Low | High | Accepted residual toward the delegation authority; per-key volume, exhaustion and rate-limit alerts; rotate and revoke | -| The plugin surface reachable through a variant or a new endpoint | Low | Critical | Derived list asserted at length 5; `/api/auth/api-key` prefix rule; `enableSessionForAPIKeys` pinned false | +| The plugin surface reachable through a variant or a new endpoint | Low | Critical | Derived list asserted at 7 endpoints and 5 routed; `/api/auth/api-key` prefix rule; `enableSessionForAPIKeys` pinned false | | CORS lets a browser call the exchange | Low | Medium | No allow-origin on any exchange response, tested | | A raw key mishandled at handoff | Medium | High | The runbook's direct-to-variable handoff; printed once; Sentry events carry no key material | | Migration `0001` collides, ships unapplied, or locks `auth.account` at a bad moment | Medium | High | Main numbers it; boot assertion; pre-apply duplicate check and quiet-window guidance in the runbook | | Rebase conflicts after the spec 300 cutover | High | Low | The PR waits; tests pin behaviour | | A plugin or core bump changes relied-on behaviour | Medium | Medium | Each behaviour pinned by a test; plugin pinned to core | -| A phone number reaches apps/auth | Low | High | The pattern cannot match a bare number; refused before lookup or log | +| The stored phone number leaks (response, log, Better Auth output, apps/api) | Low | High | One column; `input: false` and `returned: false`; absent from `/api/account/me`; never-log list and capture tests; Sentry scrubbing | +| A Meta format or enrolment change splits users | Low | Medium | Sanity bounds only, so real users are never refused; the creation-volume signal surfaces a jump; continuity is deferred work (Nice-to-know 19) | ## References @@ -452,5 +504,6 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' - `codev/resources/arch.md`: Authentication & Authorization - `codev/resources/lessons-learned.md`: Better Auth migration (spec 300); Auth hardening (spec 4) - `@better-auth/api-key` 1.7.7 package source (`dist/index.mjs`: schema, endpoints, `validateApiKey`, `consumeRemaining`, `verifyApiKey`'s catch), and the specify-phase pglite probe whose results are tabled above -- better-auth 1.7.7 `db/internal-adapter.mjs` (`getCurrentAdapter`, `runWithTransaction`) and `plugins/jwt` (`signJWT`, server-only) +- better-auth 1.7.7 `db/internal-adapter.mjs` (`getCurrentAdapter`, `runWithTransaction`, `createSession`'s `generateId(32)` token), `db/schema.mjs` (`returned: false` output filter) and `plugins/jwt` (`signJWT`, server-only) +- Meta, Business-scoped user IDs: developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids - Security architect's consolidated spec review, iteration 2 (inputs kept in `codev/projects/329-apps-auth-service-api-keys-bet/`) diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index 219c1c9b..ccc3d995 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -39,3 +39,11 @@ the request would block on its own lock. better-auth 1.7.7 has runWithTransaction/getCurrentAdapter; the plan must prove this on real Postgres. - Corrected my own claim: LIKE 'whatsapp-%' cannot use the default-collation email btree; removed. - signJWT is serverOnly, so /token's TOKEN_EXCHANGE_PER_SESSION is bypassed; the per-user budget replaces it. + +## 2026-10-28: spec iteration 3 (owner D1-D4 + security review round 2) + +- Owner: no BSUID regex (sanity bounds), BSUID raw, optional phone_number stored on auth.users (input:false, returned:false, never in /me), + session row written directly under FOR NO KEY UPDATE. Decisions 3 and 4 are still open. +- Round 2 fixes: exchange mounted before corsFor; boot assertion only on definite 42P01/missing constraint (unreachable DB still starts); + consumeWindow on the pool outside the tx (else refusals roll it back); explicit iat/exp to signJWT (signJWT omits iat otherwise); + 7 endpoints / 5 routed (serverOnly have no path); lock/statement timeouts; alerts counts-only, user id in log line. From 499e801e09d027dd6f66c083c42cde0340091749 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:09:54 +1100 Subject: [PATCH 067/247] [Spec 329] Fix stale test scenario 14 (sanity bounds, not 400) --- codev/specs/329-apps-auth-service-api-keys-bet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index 8c6213fd..67ed902c 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -452,7 +452,7 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' 11. Every key refusal → the identical 401 body, no rows. 12. Owner binding: a `whatsapp: mint` key under another service user → 401. Service user missing → 500 + alert. 13. Eligibility matrix: `whatsapp` row on an email, service or admin user; a promoted WhatsApp user; an altered address or `registered_via` → 500 + alert, no token. -14. Delegation inputs: admin and email-user ids and emails as `bsuid` → 400; a BSUID-shaped `google` `account_id` → a new WhatsApp user. +14. Delegation inputs: admin and email-user ids and emails, and a `google` user's `account_id`, passed as `bsuid` → each is an unknown BSUID: a new WhatsApp user is created, and the seeded user is never reached. 15. Ban: 403, sessions deleted, an earlier JWT dead at `/api/account/me`; an expired ban is let through. 16. Per-user budget: 429 with an integer `X-Retry-After`; another user unaffected; the `rate_limit` key holds the user id; the counter survives a 403 and a 500; it runs on the pool, outside the transaction. 16a. Lock timeout: the user row held from another connection → 500 within `lock_timeout`. From 2f432fa75379273081214e238e8768ec2e26e193 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:14:50 +1100 Subject: [PATCH 068/247] [Spec 329] Record owner decisions 3 (deferred to plan) and 4 (later) --- codev/specs/329-apps-auth-service-api-keys-bet.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index 67ed902c..022274f6 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -82,7 +82,7 @@ It ships as migration `0001`, generated, reviewed as SQL, applied by hand before **Key settings.** Each key has: - a per-service prefix (`wa_` for WhatsApp) and a name; - `metadata` carrying the partner's client id; -- a per-window rate limit (`rateLimitTimeWindow`, `rateLimitMax`) and a daily budget (`remaining` with `refillAmount` and `refillInterval` of 24 h), both required with no defaults. The create script validates them against bounds declared in `limits.ts`, the single home of limit numbers. Values are chosen by the operator from owner decision 3. +- a per-window rate limit (`rateLimitTimeWindow`, `rateLimitMax`) and a daily budget (`remaining` with `refillAmount` and `refillInterval` of 24 h), both required with no defaults. The create script validates them against bounds declared in `limits.ts`, the single home of limit numbers. Values are chosen by the operator within the bounds the plan sizes from legacy traffic (owner decision 3). - **no expiry.** A production credential must not die silently, so rotation is driven by the runbook. The create script offers no expiry option. The script refuses `remaining` without a refill: probed above, that configuration deletes the key the first time a burst exhausts it. **Budgets are per key, not per service:** while two keys are live during a rotation, the service's ceiling doubles. The runbook says so, and rotation overlap is kept short. @@ -332,7 +332,7 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA ## Constraints -- **Risk tier High** (auth model, new table, new credential type): porch's consultation on spec, plan and PR, plus the security architect's 3-way review at each gate. Decisions 1 and 2 are answered; the owner answers 3 and 4 at the spec-approval gate. +- **Risk tier High** (auth model, new table, new credential type): porch's consultation on spec, plan and PR, plus the security architect's 3-way review at each gate. All four owner decisions are answered (decision 3's numbers are set in the plan). - **Pin, do not bump.** The plugin's newest release requires core 1.7.14; bumping core is a separate, joint change. - **Schema discipline.** `schema.ts` is hand-written; `drizzle-kit generate` → reviewed SQL → applied by a human with `psql -v ON_ERROR_STOP=1 --single-transaction` against the auth database. Never `db:push`. `architect:main` is messaged before the journal entry is committed. Migration before code at deploy. - **Two databases.** Everything here is in the auth database and apps/auth. apps/api changes only in tests. No query crosses the two. @@ -399,18 +399,18 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' ## Open Questions -**Critical: owner decisions (1 and 2 answered; 3 and 4 open)** +**Critical: owner decisions (all answered, 2026-10-28)** 1. ~~The non-interactive domain name.~~ **Decided by the owner (2026-10-28):** `@noreply.askansari.ai`, a fixed reserved subdomain with the account kind as a local-part prefix (`whatsapp-`, `service-`). This follows the common convention (GitHub's `users.noreply.github.com`, Apple's `privaterelay.appleid.com`). Considered and rejected: - a hostname per user (e.g. `whatsapp-.askansari.ai`): the reserved check would have to pattern-match hostnames instead of testing one fixed suffix, and the id would sit in a DNS label; - the bare `@askansari.ai`: it would refuse real staff addresses. 2. ~~Store the BSUID raw or HMAC it.~~ **Decided by the owner (2026-10-28): raw**, in `account.account_id`, no HMAC. The original reasoning: The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a new env var and a rotation story that re-keys every row. -3. **The WhatsApp key's limits**, sized from the legacy WhatsApp service's traffic. The decision is two numbers per key: +3. **The WhatsApp key's limits. Decided by the owner (2026-10-28): deferred to the plan.** The plan sets the key's per-window ceiling and daily budget, the `limits.ts` bounds, the per-user exchange budget and the alert thresholds from the legacy WhatsApp service's traffic data, and records the sizing. The framing below stands: The decision is two numbers per key: - a per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow`, e.g. per minute); - a daily budget (`refillAmount` per 24 h). Recommendation: both, at about twice the legacy peak, sized for instances × restarts, since the consumer's 15-minute token cache is per process. A refilled budget is safe: exhaustion refuses and keeps the key. The `limits.ts` bounds and the per-user exchange budget are set in the plan from the same traffic data. Budgets are per key, so rotation briefly doubles them. -4. **The leaderboard: migrate from its static secret now or later.** Recommendation: later. Nothing forces it, and this spec ships one consumer. +4. **The leaderboard. Decided by the owner (2026-10-28): later, out of scope.** It stays on its static secret. Nothing forces it, and this spec ships one consumer. **Resolved** (by the security architect's direction or by builder decision; recorded so the plan does not re-decide them) From f709c1328e7f0aece2500792435b92f740f921cb Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:23:01 +1100 Subject: [PATCH 069/247] chore(porch): 329 spec-approval gate-approved --- .../projects/329-apps-auth-service-api-keys-bet/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index dc16a4e9..1d8327fe 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -6,8 +6,9 @@ plan_phases: [] current_plan_phase: null gates: spec-approval: - status: pending + status: approved requested_at: '2026-10-28T04:42:34.533Z' + approved_at: '2026-10-28T05:23:01.015Z' plan-approval: status: pending pr: @@ -18,4 +19,4 @@ iteration: 1 build_complete: true history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T04:42:34.533Z' +updated_at: '2026-10-28T05:23:01.015Z' From c1cd6899774af834b7f66887d01a38157eea008a Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:23:11 +1100 Subject: [PATCH 070/247] chore(porch): 329 plan phase-transition --- .../projects/329-apps-auth-service-api-keys-bet/status.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 1d8327fe..0ad4f408 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -1,7 +1,7 @@ id: '329' title: apps-auth-service-api-keys-bet protocol: spir -phase: specify +phase: plan plan_phases: [] current_plan_phase: null gates: @@ -16,7 +16,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: true +build_complete: false history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T05:23:01.015Z' +updated_at: '2026-10-28T05:23:11.075Z' From ace2b24aee384308acd1bdef5f1309b2eccd91f9 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:26:46 +1100 Subject: [PATCH 071/247] chore(porch): 329 plan build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 0ad4f408..3949b94e 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -16,7 +16,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T05:23:11.075Z' +updated_at: '2026-10-28T05:26:46.881Z' From 2a60f246a8d670f5e02af865197cec5be6139820 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:26:56 +1100 Subject: [PATCH 072/247] [Spec 329] Initial implementation plan --- .../329-apps-auth-service-api-keys-bet.md | 413 ++++++++++++++++++ codev/state/spir-329_thread.md | 9 + 2 files changed, 422 insertions(+) create mode 100644 codev/plans/329-apps-auth-service-api-keys-bet.md diff --git a/codev/plans/329-apps-auth-service-api-keys-bet.md b/codev/plans/329-apps-auth-service-api-keys-bet.md new file mode 100644 index 00000000..4481d05a --- /dev/null +++ b/codev/plans/329-apps-auth-service-api-keys-bet.md @@ -0,0 +1,413 @@ +# Plan: Service API keys and the WhatsApp token exchange (apps/auth) + +**Specification**: [codev/specs/329-apps-auth-service-api-keys-bet.md](../specs/329-apps-auth-service-api-keys-bet.md) + +## Executive Summary + +The plan implements the spec's Approach 1: +- `@better-auth/api-key` 1.7.7 for keys, created in-process by operator scripts, with its HTTP surface blocked by derivation and prefix, and better-auth's logger redacted; +- the WhatsApp user, account and session rows written directly with Drizzle; +- the exchange's decision (eligibility, ban, phone number, session, signing) made in one transaction holding `FOR NO KEY UPDATE` on the user row. + +The order follows three rules: +1. **Probe before building.** Phase 1 proves the library paths the spec marks as unproven (`createApiKey` from a script, `shouldStore: false`, `signJWT` claims, `returned: false` / `input: false`, the lock pattern on real Postgres) before anything depends on them. +2. **No commit publishes a surface it does not also guard.** The plugin is wired in the same commit that blocks its routes. The exchange route lands with its CORS placement, key check and timeouts in one commit. +3. **Library before service.** The identity and session core lives in `packages/auth` and is tested on pglite, then the apps/auth route wraps it. + +Seven phases, each one commit in a single PR. The PR does not merge before the spec 300 production cutover and its contract PR; a rebase is expected. + +## Phases (Machine Readable) + +```json +{ + "phases": [ + {"id": "phase_1", "title": "Plugin, schema and migration 0001, blocked key surface, library probes"}, + {"id": "phase_2", "title": "Redacted Better Auth logger"}, + {"id": "phase_3", "title": "Reserved noreply domain"}, + {"id": "phase_4", "title": "Service users and key operator scripts"}, + {"id": "phase_5", "title": "WhatsApp identity and session core"}, + {"id": "phase_6", "title": "The exchange route, boot assertion and alerts"}, + {"id": "phase_7", "title": "End-to-end contract test, source scan and documentation"} + ] +} +``` + +## Limit sizing (owner decision 3, deferred to this plan) + +Every number below is a named constant in `packages/auth/src/limits.ts`. Inputs, from the legacy WhatsApp service over its last 30 days (requested from the owner by the security architect, 2026-10-28): + +| Input | Meaning | +|---|---| +| `P` | peak messages per minute (all users) | +| `D` | messages per day (the 30-day maximum) | +| `U` | daily active WhatsApp users (the 30-day maximum) | +| `N` | WhatsApp service instance count | + +**Why the message rate bounds the exchange rate.** The consumer caches one token per BSUID per process for under 15 minutes. So exchanges per minute are at most the message rate (every message misses the cache, e.g. after a restart, or a different instance handles each message). In steady state they are about `min(messages, 4·N per active user per hour)`. Sizing on messages is therefore the safe upper bound, and needs no knowledge of cache hit rates. + +| Constant | Formula | PROVISIONAL value (until `P`, `D`, `U`, `N` arrive) | +|---|---|---| +| Key per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow` = 60 s), chosen by the operator at creation | `2·P` | 600 / min | +| Key daily budget (`refillAmount` per `refillInterval` = 24 h), chosen by the operator at creation | `2·D` | 200,000 / day | +| `WHATSAPP_EXCHANGE_PER_USER` (per user, via `consumeWindow`) | `max(30, 2·4·N)` per 15 min | 30 per 15 min | +| `WHATSAPP_EXCHANGE_KEY_ALERT` (per-key volume alert) | `1.5·P` per min | 450 / min | +| `EXCHANGE_UNAUTHORIZED_ALERT` (401 volume alert) | fixed: a healthy consumer sees no 401s except briefly during rotation | 20 per 5 min | +| `WHATSAPP_KEY_BOUNDS` (what the create script accepts) | set wide enough to hold either outcome | ceiling 10 to 100,000 per window, window 1 s to 1 h; daily budget 100 to 10,000,000 | + +The two key values are not in code: the operator passes them to the create script, which accepts only values inside `WHATSAPP_KEY_BOUNDS`. The runbook records the formula and the values chosen. When the data arrives, only the PROVISIONAL column and the runbook change; no phase is re-planned. The per-user budget is checked against the case a human cannot exceed: 30 exchanges in 15 minutes means a cache miss on nearly every message of a fast conversation. + +**Other constants** (not traffic-dependent), all in `limits.ts`: + +| Constant | Value | Why | +|---|---|---| +| `APIKEY_RELATION_CHECK_TTL` | 5 s | bounds the pre-401 check under an unknown-key flood; also the length of the misclassification residual | +| `SESSION_MARGIN` | 5 min | the session must outlive the token's `exp` by at least this much | +| `EXCHANGE_LOCK_TIMEOUT` | 2 s | a held user row fails one request, not the pool | +| `EXCHANGE_STATEMENT_TIMEOUT` | 5 s | the same, for any slow statement in the transaction | +| `EXCHANGE_BODY_LIMIT` | 1 KB | `bsuid` ≤ 256 + `phone_number` ≤ 32 + JSON overhead | + +## Phase Breakdown + +### Phase 1: Plugin, schema and migration 0001, blocked key surface, library probes + +**Dependencies**: None + +#### Objective + +Add the plugin and its table, and close its HTTP surface in the same commit. Prove every library behaviour the later phases rely on, so a wrong assumption surfaces before code is built on it. + +#### Files to Create / Modify + +- `packages/auth/package.json`, `pnpm-lock.yaml`: `@better-auth/api-key` pinned to `1.7.7` (exact). +- `packages/auth/src/schema.ts`: the `apikey` table, mirroring the plugin's model with generator column names, uuid id, `timestamptz`, and `reference_id` uuid → `auth.users` on delete cascade. `users.phoneNumber` (`phone_number`, nullable text). `unique('account_provider_id_account_id_unique').on(providerId, accountId)` on `account`. The header comment records the departures. +- `packages/auth/drizzle/0001_*.sql`, `drizzle/meta/_journal.json`, `drizzle/meta/0001_snapshot.json`: generated by `pnpm --filter @ansari/auth db:generate`, SQL reviewed. **Before committing the journal entry, message `architect:main` so main numbers it.** +- `packages/auth/src/index.ts`: + - the `apiKey({ enableMetadata: true, startingCharactersConfig: { shouldStore: false }, enableSessionForAPIKeys: false, rateLimit: { enabled: true } })` plugin; + - `API_KEY_PATHS`, derived from `apiKey().endpoints` and filtered to defined paths; + - `BLOCKED_AUTH_PATHS` including them; + - the `phone_number` additional user field (`input: false`, `returned: false`). +- `apps/auth/src/middleware/blocked-paths.ts`: an `/api/auth/api-key` prefix rule beside `ADMIN_PREFIX`. +- `packages/auth/tests/migration/auth-journal-parity.test.ts`: the "single fresh 0000" assertion becomes "0000 then 0001". `AUTH_DEPLOYED_THROUGH` is untouched (main's). +- `packages/auth/tests/schema/auth-schema.test.ts`: seven tables; `apikey` shape; `account (provider_id, account_id)` unique; `users.phone_number`. +- `packages/auth/tests/config/api-key.test.ts` (new): probes 1, 2 and 4a as permanent pinning tests. +- `apps/auth/tests/blocked-paths.test.ts`: the API-key surface tests. +- `apps/auth/scripts/probes/exchange-lock-probe.ts` (new, operator-run): probe 4 against a throwaway Postgres 18 container. + +#### Deliverables + +- [ ] Plugin pinned and wired; table and migration `0001` generated and reviewed; main consulted before the journal commit. +- [ ] `API_KEY_PATHS` derived with assertions **7 endpoints, 5 routed**, and no `undefined` in `disabledPaths` or the middleware's set. +- [ ] Prefix rule; the finite variant list from the spec blocked for all 5 paths, plus `/verify` and `/delete-all-expired-api-keys` under the prefix. +- [ ] Pinning tests (pglite) for: + - **Probe 1.** `auth.api.createApiKey({ body: { userId, prefix, name, permissions, metadata, rateLimit…, remaining, refillAmount, refillInterval } })`: the key verifies with the right permission; in its row the raw key appears in no column, `start` is null, and `key === defaultKeyHasher(raw)`; `permissions` and `metadata` round-trip. + - **Probe 2.** `auth.api.signJWT({ body: { payload: { sub, sid, iat, exp } } })`: the claim set equals a `/api/auth/token` JWT's (keys, `alg`, `iss`, `aud`, `exp − iat`); the passed `exp` is honoured. Without `iat` the claim sets differ, which pins the reason for passing it. + - **Probe 4a.** `phone_number` is absent from `get-session` and sign-in responses, and ignored when sent to sign-up. + - The probed plugin behaviours: no-refill exhaustion deletes the key; refilled exhaustion refuses and keeps it, and verifies after the interval; disabled is kept; expired is deleted; a wrong permission spends no budget; `valid: false` for an unknown key. + - `enableSessionForAPIKeys: false`: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` yields nothing. +- [ ] **Probe 4** (real Postgres 18, `exchange-lock-probe.ts`, run by the builder against `docker run postgres:18`, output pasted into the review). Every step under a deadline that fails red: + - under `FOR NO KEY UPDATE` on a user row, a session row inserted in the same transaction commits without waiting; + - a ban (`UPDATE users SET banned…; DELETE FROM session…`) on another connection waits for the exchange and then deletes its session; + - a session insert from another connection does not wait on the lock (`KEY SHARE` compatibility); + - `lock_timeout` turns a held `FOR UPDATE` from another connection into error `55P03`. + +#### Acceptance Criteria + +- [ ] `pnpm --filter @ansari/auth test`, `pnpm --filter ansari-auth test`, typecheck and lint pass. +- [ ] The migration SQL creates exactly the `apikey` table, the `account` unique constraint and the `users.phone_number` column, and nothing else. +- [ ] Probe 4's output shows all four results. If any differs, stop and raise it with the security architect before phase 5. + +#### Test Plan + +Pinning tests on pglite as above. The probe 4 script is manual, against a disposable container. The blocked-path tests reuse the admin-path variant helper. + +### Phase 2: Redacted Better Auth logger + +**Dependencies**: Phase 1 (the plugin is the reason, and its catch-and-log path is the test case) + +#### Objective + +Make better-auth's logger obey the repo rule "never log a raw driver error", without silencing it. This is its own commit, because it changes every better-auth log line in apps/auth. + +#### Files to Create / Modify + +- `packages/auth/src/logger.ts` (new): `redactLogArgs`. An `Error` argument becomes exactly `{ name, code }`, where `code` comes from the error's own `code` or its `cause`'s, the same rule as `account.ts`'s `errorMeta`. Every other argument passes through unchanged. A `log(level, message, ...args)` writes through `console` at the matching level. +- `packages/auth/src/index.ts`: `logger: { log: … }` in `createAuth`. +- `packages/auth/tests/unit/logger.test.ts` (new). + +#### Deliverables + +- [ ] The redacting logger, wired into `createAuth`. +- [ ] Tests (probe 3): + - a non-error log survives with its level and message; + - a pg `DatabaseError` (built with `code: '42P01'` and a message containing an email) reduces to exactly `{ name: 'DatabaseError', code: '42P01' }`; + - near misses survive unchanged: a plain object with `name`/`code` fields, and a string argument; + - the plugin's own path: `verifyApiKey` against a pglite database with `auth.apikey` dropped logs only the redacted form, and returns `valid: false` / `INVALID_API_KEY`, which pins the outage mapping phase 6 relies on. + +#### Acceptance Criteria + +- [ ] All suites pass. No existing test that asserts better-auth log output needs editing, or each one that does is listed in the commit message. +- [ ] The criterion cannot be met by disabling the logger: the non-error survival test fails if `disabled: true`. + +#### Test Plan + +Unit tests on the formatter. Integration through `verifyApiKey` with the relation dropped, capturing `console.error`. + +### Phase 3: Reserved noreply domain + +**Dependencies**: None (independent of phases 1 and 2; ordered here so phase 4's scripts can rely on it) + +#### Objective + +Reserve `@noreply.askansari.ai` on every public path, while `isSystemAddress` stays exactly as it is. + +#### Files to Create / Modify + +- `packages/auth/src/reserved.ts`: + - `NOREPLY_EMAIL_DOMAIN = '@noreply.askansari.ai'` and `isNoreplyAddress`; + - `isReservedAddress` = system **or** noreply; + - address builders `whatsappAddress(userId)` and `serviceAddress(name)`. + + `isSystemAddress` and `SYSTEM_EMAIL_DOMAIN` are unchanged. +- `packages/auth/src/reset-email.ts`: the sender skips any reserved address (`isReservedAddress`), not only system ones. +- `packages/auth/src/index.ts`: exports. The hook and `signUpGuard` already call `isReservedAddress`, so they need no logic change. +- `apps/auth/scripts/grant-admin.ts`: refuse a noreply address, as it refuses the system domain. +- Tests: `packages/auth/tests/config/sign-up.test.ts`, `packages/auth/tests/unit/reset-email.test.ts`, `apps/auth/tests/reset.test.ts`, `apps/auth/tests/grant-admin.test.ts`, and a new `packages/auth/tests/unit/reserved.test.ts`. + +#### Deliverables + +- [ ] Reserved check, reset skip, `grant-admin.ts` refusal and address builders. +- [ ] Tests: + - sign-up with a noreply address (mixed case, surrounding spaces) returns the 409 body identical to a taken address; + - near misses `someone@askansari.ai`, `x@noreply.askansari.ai.example.com` and `x@notnoreply.askansari.ai` sign up; + - a reset request for an existing noreply user returns the response an unknown address gets, with the mailer never called; + - the `user.create.before` hook refuses both domains; + - sign-in for a noreply user returns the same response as an unknown address; + - `grant-admin.ts` refuses a noreply address; + - `isSystemAddress` tests pass unedited. + +#### Acceptance Criteria + +- [ ] All suites pass, and `isSystemAddress` / `SYSTEM_EMAIL_DOMAIN` are byte-unchanged in the diff. + +#### Test Plan + +Through `auth.api` and the apps/auth HTTP harness (`startService`), as the existing sign-up and reset tests do. + +### Phase 4: Service users and key operator scripts + +**Dependencies**: Phases 1 and 3 + +#### Objective + +The operator can create a service user and issue, list, rotate and revoke its keys, in `grant-admin.ts`'s posture, with the raw key printed once and a durable record of every action. + +#### Files to Create / Modify + +- `packages/auth/src/limits.ts`: `WHATSAPP_KEY_BOUNDS` (PROVISIONAL, see Limit sizing). +- `packages/auth/src/service-users.ts` (new): `SERVICES`, the fixed map from service name to permissions (`whatsapp → { whatsapp: ['mint'] }`; `mcp-faithful-answers → { mcp: ['complete'] }`, reserved, no route yet). `createServiceUser(db, name)` writes the user (`service-@noreply.askansari.ai`, no credential, role `user`, `registered_via = 'service'`) and its `{ providerId: 'service', accountId: name }` row in one transaction. `findServiceUser(db, name)` resolves by the `service` row and reports zero, one or many. +- `apps/auth/scripts/service-keys.ts` (new): one script, subcommands `create-service `, `create-key --rate-max --rate-window --daily [--name] [--client-id]`, `list `, `rotate …`, `revoke --key `. Each write takes a `--confirm `. + - It refuses to run without a database URL. + - Show first; write only with a matching `--confirm` (a mismatch writes nothing, exit 2). + - Before any write it prints the database hostname and port only (`urlHost`, already in `scripts/cutover/rehearsal-anomalies.ts`, moved to a shared helper). + - It logs the key id acted on. + - `create-key` calls `auth.api.createApiKey` with the service's fixed permissions, prefix `wa_`, no expiry, and the operator's limits validated against `WHATSAPP_KEY_BOUNDS`. It refuses a budget without a refill and offers no expiry flag. It prints the raw key once, framed with the handoff instruction. + - `revoke` sets `enabled = false` and keeps the row. `rotate` is `create-key`, plus a printed reminder to revoke the old key after the consumer switches. + - Each create, rotate and revoke sends one Sentry event (service, key id, name, action; no key material). +- `apps/auth/tests/service-keys.test.ts` (new); `apps/auth/tests/helpers/` as needed. + +#### Deliverables + +- [ ] `service-users.ts` and the script, with all of the above. +- [ ] Tests: + - no database URL → refused; + - show writes nothing; a mismatched confirm writes nothing; + - the output for `postgres://user:secret@db.example:6543/auth?sslmode=require` contains `db.example:6543` and none of `user`, `secret`, `sslmode`; + - the create output contains the raw key exactly once, and the row holds only its hash (probe 1's assertions); + - list prints no key or hash; + - rotate → two live keys, both verify; + - revoke → `enabled = false`, row kept, verify refused; + - refused inputs: a budget without refill, out-of-bounds limits, any expiry flag, an existing service name; + - the Sentry event is captured with no key material; + - the service user's address and `service` row are created in one transaction (pglite, a forced failure leaves neither). + +#### Acceptance Criteria + +- [ ] All suites pass. Running each subcommand by hand from `apps/auth/` against a pglite-backed test database produces the documented output. + +#### Test Plan + +Script functions are tested directly, as `grant-admin.test.ts` tests its parser and actions. Sentry through the existing test transport. + +### Phase 5: WhatsApp identity and session core + +**Dependencies**: Phases 1 (schema, signJWT behaviour, lock probe) and 3 (address builders) + +#### Objective + +The exchange's whole decision as a library function in `packages/auth`, testable on pglite without HTTP: resolve or create the user, then, under the lock, check eligibility and ban, update the phone number, write the session and sign. + +#### Files to Create / Modify + +- `packages/auth/src/whatsapp.ts` (new): + - `resolveOrCreateWhatsappUser(db, bsuid, phone)`: a fixed-provider lookup. With no row, one transaction: generate the UUID, insert the user (`whatsapp-@noreply.askansari.ai`, `WhatsApp user`, `registered_via = 'whatsapp'`, `phone_number` when given) and the `whatsapp` account row. A unique violation on `(provider_id, account_id)` rolls back and re-reads the winner. Returns `{ userId, created }`. + - `issueWhatsappToken(auth, db, { userId, phone })`: one transaction with `SET LOCAL lock_timeout` / `statement_timeout`. + 1. `SELECT … FOR NO KEY UPDATE` on the user; gone → `UserGone`. + 2. Eligibility: sole account row is `whatsapp`, noreply address, `registered_via = 'whatsapp'`, role not admin; else `Ineligible`. + 3. `isBanned` → delete sessions, commit, `Banned`. + 4. The phone number, updated when provided and different. + 5. `iat = now`, `exp = now + JWT lifetime`; reuse a session with `expiresAt ≥ exp + SESSION_MARGIN`, else delete the user's sessions and insert one directly (token `generateId(32)` from better-auth, 30-day `expiresAt`, no IP or user agent). + 6. `auth.api.signJWT({ body: { payload: { sub, sid, iat, exp } } })`. + 7. Commit, return the token. + + Typed outcomes, not thrown strings. +- `packages/auth/src/limits.ts`: `SESSION_MARGIN`, `EXCHANGE_LOCK_TIMEOUT`, `EXCHANGE_STATEMENT_TIMEOUT`, `WHATSAPP_EXCHANGE_PER_USER`. +- `packages/auth/src/users.ts`: fix the stale "boot assertion" header line once phase 6 adds one (or here, worded for phase 6). +- `packages/auth/tests/whatsapp.test.ts` (new); rerun `apps/auth/scripts/probes/exchange-lock-probe.ts` against the real functions. + +#### Deliverables + +- [ ] Both functions, with typed outcomes. +- [ ] Tests (pglite): + - new BSUID → one user (exact address), one account row, one session, with `phone_number` stored when given; + - repeat → same `sub` and `sid`; + - margin → replaced, old deleted; + - phone: updated when different, untouched when absent, only in its column (scan every row and column); + - forced conflict on the unique constraint → one user, both callers get it; + - a ban committed between resolve and lock → `Banned`, no session; + - user deleted between resolve and lock → `UserGone`, no session; + - eligibility matrix (a `whatsapp` row on an email, service or admin user; a promoted user; an altered address; an altered `registered_via`) → `Ineligible`, no token; + - claim-set equality with `/api/auth/token`, and `{sub, sid}` equal to `definePayload`'s output for the same session; + - a directly written session is accepted by the bearer plugin (`/api/auth/token` with its token) and by `/api/account/me`'s live-sid check; + - the BSUID appears only in `account.account_id`. +- [ ] A source-level test: the eligibility re-read, ban check, phone update, session write and `signJWT` sit inside the `FOR NO KEY UPDATE` transaction body. +- [ ] Probe 4 rerun against these functions on real Postgres; output in the review. + +#### Acceptance Criteria + +- [ ] All suites pass; probe 4 green on real Postgres under deadlines. + +#### Test Plan + +pglite with seams (a hook between resolve and lock) for the interleavings, real Postgres for the lock itself, and source-level for transaction scope. Each states what it proves, per the spec. + +### Phase 6: The exchange route, boot assertion and alerts + +**Dependencies**: Phases 1, 2, 4 and 5 + +#### Objective + +`POST /api/whatsapp/session` on apps/auth, exactly to the spec's contract, plus the definite-answer boot assertion and the alerts. + +#### Files to Create / Modify + +- `apps/auth/src/routes/whatsapp.ts` (new): the router. + 1. Bearer → `verifyApiKey({ key, permissions: { whatsapp: ['mint'] } })`. + 2. On `valid: false`, `checkApiKeyRelation` (a query on `auth.apikey`, success cached for `APIKEY_RELATION_CHECK_TTL`) → 401 or 500. A missing or malformed bearer → 401 with no database query. + 3. `findServiceUser('whatsapp')`: not exactly one → 500 + alert; `referenceId` mismatch → 401. + 4. Route-local `express.json({ limit: EXCHANGE_BODY_LIMIT })` after the key; sanity bounds → 400. + 5. `resolveOrCreateWhatsappUser`. + 6. `consumeWindow` on the pool, keyed `sub:|/api/whatsapp/session` → 429 with integer `X-Retry-After`. + 7. `issueWhatsappToken` → 200, 403 or 500; a lock timeout → 500. + + Every error logs `{name, code}` only. No response contains the bearer, BSUID, phone number or body. +- `apps/auth/src/app.ts`: mount `app.use(WHATSAPP_SESSION_PATH, whatsappRouter(…))` **before** `app.use(corsFor(…))`, with a comment on why. +- `apps/auth/src/boot.ts` (new) and `apps/auth/src/index.ts`: `assertSchema(db)` before `listen()`. + - Query `auth.apikey`: `42P01` → refuse. + - Catalog lookup for the constraint: empty → refuse. + - Any other error → log `{name, code}` and continue to `listen()`. +- `apps/auth/src/alerts.ts`: per-key volume, `USAGE_EXCEEDED` and `RATE_LIMITED` (one per window each), 401 volume, eligibility (count) and service user missing. Each carries counts and windows only. The eligibility case also writes one `console.warn` with the user id (the apps/api "app row refused" precedent). +- `packages/auth/src/limits.ts`: `APIKEY_RELATION_CHECK_TTL`, `EXCHANGE_BODY_LIMIT`, `WHATSAPP_EXCHANGE_KEY_ALERT`, `EXCHANGE_UNAUTHORIZED_ALERT`. +- `apps/auth/src/alerts.ts` (where `Sentry.init` lives): a `beforeSend` scrub for the exchange path (no body, no `Authorization`). +- Tests: `apps/auth/tests/whatsapp-session.test.ts` (new), `apps/auth/tests/boot.test.ts` (new), `apps/auth/tests/public-face.test.ts` (route inventory). + +#### Deliverables + +- [ ] Route, mounting, boot assertion, alerts and Sentry scrub. +- [ ] Tests (HTTP through `startService`): + - every key refusal → the byte-identical 401 (missing, non-Bearer, empty, session token, JWT, unknown, disabled, expired, exhausted, rate-limited, `mcp` only, no permissions, another service's `whatsapp: mint` key); + - service user missing → 500 + alert; + - body cases → 400 after a valid key; a body with no key is never parsed (spy); + - happy path, repeat, 403 banned, 429 with integer `X-Retry-After`; + - the counter survives a 403 and a 500; + - another user is unaffected; + - a lock held from another connection → 500 within the timeout; + - CORS: no `Access-Control-*` on `POST` or `OPTIONS` for each `CORS_ORIGIN`, `null`, `https://evil.example` and no `Origin`; a middleware-order test pins the mount before `corsFor`; + - not reachable under `/api/auth/*`; + - `auth.apikey` dropped under the running service → 500 after the TTL elapses; + - log capture across all cases: no raw key, hash, session token, BSUID, phone number or driver error; + - a captured Sentry event has no body or `Authorization`; + - raw response bytes contain none of the request's sensitive values; + - alerts fire at their thresholds with counts only. +- [ ] Boot tests: + - `42P01` → refuses to start with a `{name, code}` log; + - constraint missing → refuses; + - database unreachable → starts, `/health` reports down, and recovers when the database returns. +- [ ] Production-shaped run (manual, recorded in the review): apps/auth with `NODE_ENV=production` against the Postgres 18 container; stop the container under the running service; after `APIKEY_RELATION_CHECK_TTL`, a valid key and an unknown key both get 500; restart the container and the exchange recovers. + +#### Acceptance Criteria + +- [ ] All suites pass. The public-face route inventory lists the new path. `ROUTER_RULES` are unchanged (all `false`). +- [ ] The production-shaped run shows 500, never 401, after the TTL, and recovery. + +#### Test Plan + +HTTP integration against `startService` on pglite. Real Postgres for the outage and the lock timeout. Log, Sentry and response-byte capture as the spec requires. + +### Phase 7: End-to-end contract test, source scan and documentation + +**Dependencies**: Phase 6 + +#### Objective + +Prove an exchange token works in apps/api unchanged, prove apps/api has no WhatsApp code, and give the operator and the consumer everything they need. + +#### Files to Create / Modify + +- `apps/api/tests/contract/whatsapp-exchange.test.ts` (new): start apps/auth through the existing harness, seed a service user and key, exchange a BSUID, and call an apps/api route with the token. `requireUser` passes, first-use `GET /api/account/me` runs, and a `public.users` row exists under the same UUID with `registered_via = 'whatsapp'`. The phone number is in no apps/api row or response. +- `apps/api/tests/source-scan/` (existing scan module): a `whatsapp` scan over `apps/api/src/**` and `apps/api/lib/**` (tests excluded) that reports its hit count (0). Its negative tests: a known-bad fixture line under the scanned glob is caught, and a near miss outside it is not. +- `docs/self-hosting.md`: the route, the scripts, `limits.ts` bounds, the migration-before-code order. +- `docs/cutover/service-keys-runbook.md` (new): + - issuance; + - the handoff (straight into the consumer's Railway variable); + - rotation overlap and the doubled ceiling; + - revocation; + - pruning (none expected); + - the limit sizing formula and the values chosen; + - the pre-apply duplicate check for `(provider_id, account_id)`, with the ACCESS EXCLUSIVE lock note and quiet-window guidance; + - the alert meanings, and the eligibility repair procedure. +- `codev/resources/arch.md` (Authentication & Authorization): service keys, the exchange, the noreply domain, and the alert posture. +- `packages/auth/README.md`: the `apikey` table and migration `0001`. +- Coordination (outside this repo's diff): the contract summary, sent to the #327 builder through the architect, with the spec 321 amendment on their branch: header, `phone_number`, error statuses, no per-message retry on 500, re-exchange once on an apps/api 401, and the staging smoke (5 messages within 1 s) recorded as a comment on PR #327. + +#### Deliverables + +- [ ] The end-to-end test, the source scan with its negative tests, and the four documents. +- [ ] The contract summary sent. + +#### Acceptance Criteria + +- [ ] All suites pass, the root `turbo run test` included; the scan reports 0 hits plus its two negative results. +- [ ] Documentation consistency tests (`self-hosting-docs.test.ts`, `cutover-docs.test.ts`) pass or are extended for the new content. +- [ ] No new environment variable (checked by the four-way derivation); `turbo.json` is unchanged. + +#### Test Plan + +The contract test through the existing harness; scan negative tests; doc consistency tests. + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| A probe contradicts the spec (e.g. `signJWT` overrides `exp`, `returned: false` leaks through an adapter path, the lock pattern waits) | Medium | High | Phase 1 runs every probe first; any difference stops the plan and goes to the security architect before phase 5 | +| Migration numbering collides with the cutover | Medium | High | Message `architect:main` before committing the journal; rebase expected | +| The legacy traffic data never arrives | Medium | Low | Formulas plus PROVISIONAL values; bounds wide enough to hold either outcome; the runbook records the values chosen | +| The logger change alters an existing test's expectations | Low | Low | Its own commit; affected tests listed in the commit message | +| Real-Postgres steps are manual, so they can be skipped | Medium | High | Their output is a review deliverable for phases 1, 5 and 6; the plan's acceptance criteria name them | +| The rebase after the cutover reshapes `index.ts`, `app.ts` or `limits.ts` | High | Low | Tests pin behaviour, not line positions | +| The plugin's 7/5 shape changes on a future bump | Low | Medium | Both counts asserted; the prefix rule covers any new routed path anyway | + +## Documentation Updates + +- `docs/self-hosting.md`, `docs/cutover/service-keys-runbook.md` (new), `codev/resources/arch.md` (Authentication & Authorization), `packages/auth/README.md`. +- `codev/resources/lessons-learned.md`, at review time: plugin behaviours (deletion on no-refill exhaustion, outage reported as `INVALID_API_KEY`, `signJWT` without `iat`), and the `FOR UPDATE` versus `KEY SHARE` hazard. +- Spec 321's amendment lands on PR #327's branch, coordinated with its builder. diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index ccc3d995..e2af880a 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -47,3 +47,12 @@ - Round 2 fixes: exchange mounted before corsFor; boot assertion only on definite 42P01/missing constraint (unreachable DB still starts); consumeWindow on the pool outside the tx (else refusals roll it back); explicit iat/exp to signJWT (signJWT omits iat otherwise); 7 endpoints / 5 routed (serverOnly have no path); lock/statement timeouts; alerts counts-only, user id in log line. + +## 2026-10-28: plan drafted + +- Seven phases: (1) plugin, schema, migration 0001 and the blocked surface, plus probes (the plugin and its route block share one commit, so no commit exposes /api-key/*); + (2) redacted logger as its own commit; (3) noreply domain; (4) operator scripts; (5) identity and session core in packages/auth; + (6) route, boot assertion, alerts; (7) e2e, source scan, docs. +- Decision 3: formulas in P/D/U/N (legacy peak msg/min, daily msgs, DAU, instances), PROVISIONAL values, wide create-script bounds; the data was requested from the owner. +- No committed real-Postgres test infra exists (spec 300's Docker boot test was a manual run), so probe 4 and the outage run are manual against postgres:18, with output in the review. +- auth-journal-parity asserts a "single 0000" journal and schema tests assert six tables; both change in phase 1. AUTH_DEPLOYED_THROUGH stays main's. From cd1ebb170bb3ba3a84ee43d7791c5b0e25fd3335 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:29:21 +1100 Subject: [PATCH 073/247] [Spec 329] Limits sized by judgement (owner decision 3): initial values and update path --- .../329-apps-auth-service-api-keys-bet.md | 47 +++++++++---------- .../329-apps-auth-service-api-keys-bet.md | 4 +- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/codev/plans/329-apps-auth-service-api-keys-bet.md b/codev/plans/329-apps-auth-service-api-keys-bet.md index 4481d05a..203235d9 100644 --- a/codev/plans/329-apps-auth-service-api-keys-bet.md +++ b/codev/plans/329-apps-auth-service-api-keys-bet.md @@ -34,27 +34,24 @@ Seven phases, each one commit in a single PR. The PR does not merge before the s ## Limit sizing (owner decision 3, deferred to this plan) -Every number below is a named constant in `packages/auth/src/limits.ts`. Inputs, from the legacy WhatsApp service over its last 30 days (requested from the owner by the security architect, 2026-10-28): +**Sized by judgement, not data (owner, 2026-10-28).** The owner reports low traffic, one WhatsApp service instance initially, and no specific numbers. The caps exist to bound a **leaked key** and a **looping client**, not to meter normal use. So the values below carry about an order of magnitude of headroom over any plausible low-traffic day. The alerts sit well **below** the caps, so a surge is seen before anything is refused. These are the **initial values: the decision, not placeholders.** Every number is a named constant in `packages/auth/src/limits.ts`. -| Input | Meaning | -|---|---| -| `P` | peak messages per minute (all users) | -| `D` | messages per day (the 30-day maximum) | -| `U` | daily active WhatsApp users (the 30-day maximum) | -| `N` | WhatsApp service instance count | +**Why the message rate bounds the exchange rate.** The consumer caches one token per BSUID per process for under 15 minutes. With one instance, a conversation costs about one exchange per 15 minutes, plus one after each restart. A looping or cache-broken client costs one per message. The caps are set against the second case. -**Why the message rate bounds the exchange rate.** The consumer caches one token per BSUID per process for under 15 minutes. So exchanges per minute are at most the message rate (every message misses the cache, e.g. after a restart, or a different instance handles each message). In steady state they are about `min(messages, 4·N per active user per hour)`. Sizing on messages is therefore the safe upper bound, and needs no knowledge of cache hit rates. - -| Constant | Formula | PROVISIONAL value (until `P`, `D`, `U`, `N` arrive) | +| Constant | Initial value | Reasoning | |---|---|---| -| Key per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow` = 60 s), chosen by the operator at creation | `2·P` | 600 / min | -| Key daily budget (`refillAmount` per `refillInterval` = 24 h), chosen by the operator at creation | `2·D` | 200,000 / day | -| `WHATSAPP_EXCHANGE_PER_USER` (per user, via `consumeWindow`) | `max(30, 2·4·N)` per 15 min | 30 per 15 min | -| `WHATSAPP_EXCHANGE_KEY_ALERT` (per-key volume alert) | `1.5·P` per min | 450 / min | -| `EXCHANGE_UNAUTHORIZED_ALERT` (401 volume alert) | fixed: a healthy consumer sees no 401s except briefly during rotation | 20 per 5 min | -| `WHATSAPP_KEY_BOUNDS` (what the create script accepts) | set wide enough to hold either outcome | ceiling 10 to 100,000 per window, window 1 s to 1 h; daily budget 100 to 10,000,000 | - -The two key values are not in code: the operator passes them to the create script, which accepts only values inside `WHATSAPP_KEY_BOUNDS`. The runbook records the formula and the values chosen. When the data arrives, only the PROVISIONAL column and the runbook change; no phase is re-planned. The per-user budget is checked against the case a human cannot exceed: 30 exchanges in 15 minutes means a cache miss on nearly every message of a fast conversation. +| `WHATSAPP_KEY_INITIAL.rateLimitMax` per `rateLimitTimeWindow` | 60 per 60 s | "tens per minute": far above one instance's normal exchange rate, low enough that a leaked key or a loop is capped within a minute | +| `WHATSAPP_KEY_INITIAL.refillAmount` per `refillInterval` | 3,000 per 24 h | "low thousands": hundreds of active conversations a day at one exchange per 15 minutes, with headroom | +| `WHATSAPP_EXCHANGE_PER_USER` (per user, `consumeWindow`) | 6 per 15 min | "a handful per token lifetime": normal use is 1 (plus a restart), and a looping client for one user hits 429 at once | +| `WHATSAPP_EXCHANGE_KEY_ALERT` (per-key, per minute) | 20 per min | one third of the window cap: a surge alerts before anything is refused | +| `WHATSAPP_EXCHANGE_DAILY_ALERT` (per-key, per day) | 1,000 per day | one third of the daily budget, for the same reason | +| `EXCHANGE_UNAUTHORIZED_ALERT` (401 volume) | 20 per 5 min | a healthy consumer sees no 401s except briefly during rotation | +| `WHATSAPP_KEY_BOUNDS` (what the create and update script accepts) | window cap 1 to 100,000 per window, window 1 s to 24 h; daily budget 1 to 10,000,000 | wide on purpose: raising a value never needs the bound to change, so it needs no code review of the bound | + +**Applying and changing the key's values.** +- The key's two values live in the key's row, set by the operator through the script. The spec's no-defaults rule stands: the script requires them as explicit flags. The runbook's issuance command passes `WHATSAPP_KEY_INITIAL`'s values, so the constant is the recorded decision. +- To change them, the script has an `update-limits` subcommand that calls the plugin's server-side `auth.api.updateApiKey` with the new window cap, window and refill. It is validated against the same bounds, and probed in phase 1. Revoke-and-recreate (a rotation) is the documented fallback. The runbook carries both paths. +- Changing the per-user budget or an alert threshold is a one-line edit to `limits.ts`. **Other constants** (not traffic-dependent), all in `limits.ts`: @@ -101,6 +98,7 @@ Add the plugin and its table, and close its HTTP surface in the same commit. Pro - [ ] Pinning tests (pglite) for: - **Probe 1.** `auth.api.createApiKey({ body: { userId, prefix, name, permissions, metadata, rateLimit…, remaining, refillAmount, refillInterval } })`: the key verifies with the right permission; in its row the raw key appears in no column, `start` is null, and `key === defaultKeyHasher(raw)`; `permissions` and `metadata` round-trip. - **Probe 2.** `auth.api.signJWT({ body: { payload: { sub, sid, iat, exp } } })`: the claim set equals a `/api/auth/token` JWT's (keys, `alg`, `iss`, `aud`, `exp − iat`); the passed `exp` is honoured. Without `iat` the claim sets differ, which pins the reason for passing it. + - **Probe 1b.** `auth.api.updateApiKey` server-side changes `rateLimitMax`, `rateLimitTimeWindow`, `refillAmount` and `refillInterval` on an existing key, and the change takes effect at the next verify. - **Probe 4a.** `phone_number` is absent from `get-session` and sign-in responses, and ignored when sent to sign-up. - The probed plugin behaviours: no-refill exhaustion deletes the key; refilled exhaustion refuses and keeps it, and verifies after the interval; disabled is kept; expired is deleted; a wrong permission spends no budget; `valid: false` for an unknown key. - `enableSessionForAPIKeys: false`: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` yields nothing. @@ -203,9 +201,9 @@ The operator can create a service user and issue, list, rotate and revoke its ke #### Files to Create / Modify -- `packages/auth/src/limits.ts`: `WHATSAPP_KEY_BOUNDS` (PROVISIONAL, see Limit sizing). +- `packages/auth/src/limits.ts`: `WHATSAPP_KEY_BOUNDS` and `WHATSAPP_KEY_INITIAL` (see Limit sizing). - `packages/auth/src/service-users.ts` (new): `SERVICES`, the fixed map from service name to permissions (`whatsapp → { whatsapp: ['mint'] }`; `mcp-faithful-answers → { mcp: ['complete'] }`, reserved, no route yet). `createServiceUser(db, name)` writes the user (`service-@noreply.askansari.ai`, no credential, role `user`, `registered_via = 'service'`) and its `{ providerId: 'service', accountId: name }` row in one transaction. `findServiceUser(db, name)` resolves by the `service` row and reports zero, one or many. -- `apps/auth/scripts/service-keys.ts` (new): one script, subcommands `create-service `, `create-key --rate-max --rate-window --daily [--name] [--client-id]`, `list `, `rotate …`, `revoke --key `. Each write takes a `--confirm `. +- `apps/auth/scripts/service-keys.ts` (new): one script, subcommands `create-service `, `create-key --rate-max --rate-window --daily [--name] [--client-id]`, `list `, `rotate …`, `update-limits --key --rate-max --rate-window --daily`, `revoke --key `. Each write takes a `--confirm `. - It refuses to run without a database URL. - Show first; write only with a matching `--confirm` (a mismatch writes nothing, exit 2). - Before any write it prints the database hostname and port only (`urlHost`, already in `scripts/cutover/rehearsal-anomalies.ts`, moved to a shared helper). @@ -226,6 +224,7 @@ The operator can create a service user and issue, list, rotate and revoke its ke - list prints no key or hash; - rotate → two live keys, both verify; - revoke → `enabled = false`, row kept, verify refused; + - update-limits → the new values are in the row and enforced at the next verify; out-of-bounds values are refused; - refused inputs: a budget without refill, out-of-bounds limits, any expiry flag, an existing service name; - the Sentry event is captured with no key material; - the service user's address and `service` row are created in one transaction (pglite, a forced failure leaves neither). @@ -315,8 +314,8 @@ pglite with seams (a hook between resolve and lock) for the interleavings, real - Query `auth.apikey`: `42P01` → refuse. - Catalog lookup for the constraint: empty → refuse. - Any other error → log `{name, code}` and continue to `listen()`. -- `apps/auth/src/alerts.ts`: per-key volume, `USAGE_EXCEEDED` and `RATE_LIMITED` (one per window each), 401 volume, eligibility (count) and service user missing. Each carries counts and windows only. The eligibility case also writes one `console.warn` with the user id (the apps/api "app row refused" precedent). -- `packages/auth/src/limits.ts`: `APIKEY_RELATION_CHECK_TTL`, `EXCHANGE_BODY_LIMIT`, `WHATSAPP_EXCHANGE_KEY_ALERT`, `EXCHANGE_UNAUTHORIZED_ALERT`. +- `apps/auth/src/alerts.ts`: per-key volume (per minute and per day), `USAGE_EXCEEDED` and `RATE_LIMITED` (one per window each), 401 volume, eligibility (count) and service user missing. Each carries counts and windows only. The eligibility case also writes one `console.warn` with the user id (the apps/api "app row refused" precedent). +- `packages/auth/src/limits.ts`: `APIKEY_RELATION_CHECK_TTL`, `EXCHANGE_BODY_LIMIT`, `WHATSAPP_EXCHANGE_KEY_ALERT`, `WHATSAPP_EXCHANGE_DAILY_ALERT`, `EXCHANGE_UNAUTHORIZED_ALERT`. - `apps/auth/src/alerts.ts` (where `Sentry.init` lives): a `beforeSend` scrub for the exchange path (no body, no `Authorization`). - Tests: `apps/auth/tests/whatsapp-session.test.ts` (new), `apps/auth/tests/boot.test.ts` (new), `apps/auth/tests/public-face.test.ts` (route inventory). @@ -372,7 +371,7 @@ Prove an exchange token works in apps/api unchanged, prove apps/api has no Whats - rotation overlap and the doubled ceiling; - revocation; - pruning (none expected); - - the limit sizing formula and the values chosen; + - the limit values, their judgement-based reasoning, and how to change them (`update-limits`, or revoke-and-recreate); - the pre-apply duplicate check for `(provider_id, account_id)`, with the ACCESS EXCLUSIVE lock note and quiet-window guidance; - the alert meanings, and the eligibility repair procedure. - `codev/resources/arch.md` (Authentication & Authorization): service keys, the exchange, the noreply domain, and the alert posture. @@ -400,7 +399,7 @@ The contract test through the existing harness; scan negative tests; doc consist |------|-------------|--------|------------| | A probe contradicts the spec (e.g. `signJWT` overrides `exp`, `returned: false` leaks through an adapter path, the lock pattern waits) | Medium | High | Phase 1 runs every probe first; any difference stops the plan and goes to the security architect before phase 5 | | Migration numbering collides with the cutover | Medium | High | Message `architect:main` before committing the journal; rebase expected | -| The legacy traffic data never arrives | Medium | Low | Formulas plus PROVISIONAL values; bounds wide enough to hold either outcome; the runbook records the values chosen | +| The judgement-based values prove too low or too high | Medium | Low | Alerts at one third of the caps, so a surge is seen first; `update-limits` changes a key's values without code; bounds are wide, so a raise needs no bound change | | The logger change alters an existing test's expectations | Low | Low | Its own commit; affected tests listed in the commit message | | Real-Postgres steps are manual, so they can be skipped | Medium | High | Their output is a review deliverable for phases 1, 5 and 6; the plan's acceptance criteria name them | | The rebase after the cutover reshapes `index.ts`, `app.ts` or `limits.ts` | High | Low | Tests pin behaviour, not line positions | diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index 022274f6..b233cf0c 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -82,7 +82,7 @@ It ships as migration `0001`, generated, reviewed as SQL, applied by hand before **Key settings.** Each key has: - a per-service prefix (`wa_` for WhatsApp) and a name; - `metadata` carrying the partner's client id; -- a per-window rate limit (`rateLimitTimeWindow`, `rateLimitMax`) and a daily budget (`remaining` with `refillAmount` and `refillInterval` of 24 h), both required with no defaults. The create script validates them against bounds declared in `limits.ts`, the single home of limit numbers. Values are chosen by the operator within the bounds the plan sizes from legacy traffic (owner decision 3). +- a per-window rate limit (`rateLimitTimeWindow`, `rateLimitMax`) and a daily budget (`remaining` with `refillAmount` and `refillInterval` of 24 h), both required with no defaults. The create script validates them against bounds declared in `limits.ts`, the single home of limit numbers. Values are chosen by the operator within wide bounds; the initial values are set in the plan by judgement (owner decision 3), and an `update-limits` script path changes a live key's values. - **no expiry.** A production credential must not die silently, so rotation is driven by the runbook. The create script offers no expiry option. The script refuses `remaining` without a refill: probed above, that configuration deletes the key the first time a burst exhausts it. **Budgets are per key, not per service:** while two keys are live during a rotation, the service's ceiling doubles. The runbook says so, and rotation overlap is kept short. @@ -405,7 +405,7 @@ A small table of hashed keys with permissions and limits, verified by apps/auth' - a hostname per user (e.g. `whatsapp-.askansari.ai`): the reserved check would have to pattern-match hostnames instead of testing one fixed suffix, and the id would sit in a DNS label; - the bare `@askansari.ai`: it would refuse real staff addresses. 2. ~~Store the BSUID raw or HMAC it.~~ **Decided by the owner (2026-10-28): raw**, in `account.account_id`, no HMAC. The original reasoning: The BSUID is already Meta's business-scoped pseudonym and is needed for lookup, as any OAuth provider id is. HMAC defends against correlation after a database leak, but costs a new secret, a new env var and a rotation story that re-keys every row. -3. **The WhatsApp key's limits. Decided by the owner (2026-10-28): deferred to the plan.** The plan sets the key's per-window ceiling and daily budget, the `limits.ts` bounds, the per-user exchange budget and the alert thresholds from the legacy WhatsApp service's traffic data, and records the sizing. The framing below stands: The decision is two numbers per key: +3. **The WhatsApp key's limits. Decided by the owner (2026-10-28): sized by judgement in the plan.** Traffic is low, there is one WhatsApp service instance initially, and there are no specific numbers. The caps bound a leaked key and a looping client, not normal use. The plan sets generous initial values with about an order of magnitude of headroom, alert thresholds well below the caps, and wide `limits.ts` bounds, so raising a value is a one-line change. A live key's values change through the script's `update-limits` (the plugin's `updateApiKey`) or by rotation. The original framing: The decision is two numbers per key: - a per-window ceiling (`rateLimitMax` per `rateLimitTimeWindow`, e.g. per minute); - a daily budget (`refillAmount` per 24 h). From 124d1158e911967cf7028afc9629f2e6f26e56ff Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:32:34 +1100 Subject: [PATCH 074/247] [Spec 329] Plan with multi-agent review --- .../329-apps-auth-service-api-keys-bet.md | 22 ++++++++++++------- .../329-plan-iter1-codex.txt | 20 +++++++++++++++++ .../329-plan-iter1-gemini.txt | 10 +++++++++ .../329-plan-iter1-rebuttals.md | 12 ++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-gemini.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-rebuttals.md diff --git a/codev/plans/329-apps-auth-service-api-keys-bet.md b/codev/plans/329-apps-auth-service-api-keys-bet.md index 203235d9..107cfc46 100644 --- a/codev/plans/329-apps-auth-service-api-keys-bet.md +++ b/codev/plans/329-apps-auth-service-api-keys-bet.md @@ -210,7 +210,7 @@ The operator can create a service user and issue, list, rotate and revoke its ke - It logs the key id acted on. - `create-key` calls `auth.api.createApiKey` with the service's fixed permissions, prefix `wa_`, no expiry, and the operator's limits validated against `WHATSAPP_KEY_BOUNDS`. It refuses a budget without a refill and offers no expiry flag. It prints the raw key once, framed with the handoff instruction. - `revoke` sets `enabled = false` and keeps the row. `rotate` is `create-key`, plus a printed reminder to revoke the old key after the consumer switches. - - Each create, rotate and revoke sends one Sentry event (service, key id, name, action; no key material). + - Each create, rotate, update-limits and revoke sends one Sentry event (service, key id, name, action; no key material). The script initialises Sentry and awaits `Sentry.flush` before exit, so the event is not lost when the process ends. A failed flush is reported on stderr, with the action still recorded in the output. - `apps/auth/tests/service-keys.test.ts` (new); `apps/auth/tests/helpers/` as needed. #### Deliverables @@ -226,7 +226,7 @@ The operator can create a service user and issue, list, rotate and revoke its ke - revoke → `enabled = false`, row kept, verify refused; - update-limits → the new values are in the row and enforced at the next verify; out-of-bounds values are refused; - refused inputs: a budget without refill, out-of-bounds limits, any expiry flag, an existing service name; - - the Sentry event is captured with no key material; + - the Sentry event is captured with no key material, and the script awaits the flush before exit; - the service user's address and `service` row are created in one transaction (pglite, a forced failure leaves neither). #### Acceptance Criteria @@ -249,9 +249,9 @@ The exchange's whole decision as a library function in `packages/auth`, testable - `packages/auth/src/whatsapp.ts` (new): - `resolveOrCreateWhatsappUser(db, bsuid, phone)`: a fixed-provider lookup. With no row, one transaction: generate the UUID, insert the user (`whatsapp-@noreply.askansari.ai`, `WhatsApp user`, `registered_via = 'whatsapp'`, `phone_number` when given) and the `whatsapp` account row. A unique violation on `(provider_id, account_id)` rolls back and re-reads the winner. Returns `{ userId, created }`. - - `issueWhatsappToken(auth, db, { userId, phone })`: one transaction with `SET LOCAL lock_timeout` / `statement_timeout`. + - `issueWhatsappToken(auth, db, { userId, bsuid, phone })`: one transaction with `SET LOCAL lock_timeout` / `statement_timeout`. 1. `SELECT … FOR NO KEY UPDATE` on the user; gone → `UserGone`. - 2. Eligibility: sole account row is `whatsapp`, noreply address, `registered_via = 'whatsapp'`, role not admin; else `Ineligible`. + 2. Eligibility: the user's sole account row is `whatsapp` **with `account_id` equal to this request's BSUID** (so a row re-pointed between resolve and lock is caught), noreply address, `registered_via = 'whatsapp'`, role not admin; else `Ineligible`. 3. `isBanned` → delete sessions, commit, `Banned`. 4. The phone number, updated when provided and different. 5. `iat = now`, `exp = now + JWT lifetime`; reuse a session with `expiresAt ≥ exp + SESSION_MARGIN`, else delete the user's sessions and insert one directly (token `generateId(32)` from better-auth, 30-day `expiresAt`, no IP or user agent). @@ -274,7 +274,8 @@ The exchange's whole decision as a library function in `packages/auth`, testable - forced conflict on the unique constraint → one user, both callers get it; - a ban committed between resolve and lock → `Banned`, no session; - user deleted between resolve and lock → `UserGone`, no session; - - eligibility matrix (a `whatsapp` row on an email, service or admin user; a promoted user; an altered address; an altered `registered_via`) → `Ineligible`, no token; + - eligibility matrix (a `whatsapp` row on an email, service or admin user; a promoted user; an altered address; an altered `registered_via`; the `whatsapp` row's `account_id` changed between resolve and lock) → `Ineligible`, no token; + - an expired ban (`banExpires` in the past) is not refused; - claim-set equality with `/api/auth/token`, and `{sub, sid}` equal to `definePayload`'s output for the same session; - a directly written session is accepted by the bearer plugin (`/api/auth/token` with its token) and by `/api/account/me`'s live-sid check; - the BSUID appears only in `account.account_id`. @@ -303,13 +304,13 @@ pglite with seams (a hook between resolve and lock) for the interleavings, real 1. Bearer → `verifyApiKey({ key, permissions: { whatsapp: ['mint'] } })`. 2. On `valid: false`, `checkApiKeyRelation` (a query on `auth.apikey`, success cached for `APIKEY_RELATION_CHECK_TTL`) → 401 or 500. A missing or malformed bearer → 401 with no database query. 3. `findServiceUser('whatsapp')`: not exactly one → 500 + alert; `referenceId` mismatch → 401. - 4. Route-local `express.json({ limit: EXCHANGE_BODY_LIMIT })` after the key; sanity bounds → 400. + 4. Route-local `express.json({ limit: EXCHANGE_BODY_LIMIT })` after the key, followed by a route-local error handler. It maps the parser's errors (`entity.parse.failed`, `entity.too.large`, a wrong content type) to the exact 400 `{ "message": "Invalid request" }`, so no Express error page and no body fragment is ever sent. Sanity bounds → the same 400. 5. `resolveOrCreateWhatsappUser`. 6. `consumeWindow` on the pool, keyed `sub:|/api/whatsapp/session` → 429 with integer `X-Retry-After`. 7. `issueWhatsappToken` → 200, 403 or 500; a lock timeout → 500. Every error logs `{name, code}` only. No response contains the bearer, BSUID, phone number or body. -- `apps/auth/src/app.ts`: mount `app.use(WHATSAPP_SESSION_PATH, whatsappRouter(…))` **before** `app.use(corsFor(…))`, with a comment on why. +- `apps/auth/src/app.ts`: mount `app.use(WHATSAPP_SESSION_PATH, whatsappRouter(…))` **before** `app.use(corsFor(…))`, with a comment on why. Mounting first is not enough by itself: a request the router does not answer would fall through to `corsFor`, which answers `OPTIONS` preflights. So **the router terminates every request to its path**: `POST` is the exchange; every other method, `OPTIONS` included, gets 404 with an empty body and no `Access-Control-*` header, and never calls `next()`. - `apps/auth/src/boot.ts` (new) and `apps/auth/src/index.ts`: `assertSchema(db)` before `listen()`. - Query `auth.apikey`: `42P01` → refuse. - Catalog lookup for the constraint: empty → refuse. @@ -323,7 +324,12 @@ pglite with seams (a hook between resolve and lock) for the interleavings, real - [ ] Route, mounting, boot assertion, alerts and Sentry scrub. - [ ] Tests (HTTP through `startService`): - - every key refusal → the byte-identical 401 (missing, non-Bearer, empty, session token, JWT, unknown, disabled, expired, exhausted, rate-limited, `mcp` only, no permissions, another service's `whatsapp: mint` key); + - every key refusal → the byte-identical 401 (missing, non-Bearer, empty, session token, JWT, unknown, disabled, expired, exhausted, rate-limited, `mcp` only, no permissions, another service's `whatsapp: mint` key), and after each one the user, account and session row counts are unchanged; + - delegation inputs over HTTP: an admin's and an email user's id and email, and a `google` user's `account_id`, passed as `bsuid` → each creates a new WhatsApp user, never the seeded one; + - an expired ban is let through; + - the `rate_limit` row's key contains the user id and neither the BSUID nor the phone number; + - malformed JSON, a wrong content type and an over-limit body → the exact 400 body, with no HTML and no body fragment; + - `GET`, `PUT` and `OPTIONS` on the path → 404 with no `Access-Control-*` header; - service user missing → 500 + alert; - body cases → 400 after a valid key; a body with no key is never parsed (spy); - happy path, repeat, 403 banned, 429 with integer `X-Retry-After`; diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-codex.txt new file mode 100644 index 00000000..3460eaa8 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-codex.txt @@ -0,0 +1,20 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Strong structure and coverage, but several security-critical implementation details remain unresolved. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- Limit values remain provisional, contrary to the spec constraint that owner decision 3 be finalized from traffic data. Obtain the data or explicit owner approval for final fallback values. +- Mounting before CORS is insufficient unless the exchange router terminates `OPTIONS`; otherwise it falls through to `corsFor`. Specify an explicit no-CORS `OPTIONS` handler. +- Specify route-local JSON error middleware mapping malformed and oversized bodies to the exact 400 contract without Express error pages or body leakage. +- `issueWhatsappToken` receives only `userId`, so it cannot re-check that the sole account is the exact WhatsApp/BSUID row resolved for this request. Pass the expected BSUID/account identity and verify it under the transaction. +- Operator-script Sentry events may be lost on process exit. Require Sentry initialization and awaited `flush`/`close` after create, rotate, and revoke. +- Add explicit tests for expired bans, delegation inputs (admin/email IDs and emails, Google account IDs), per-user rate-limit key contents, and confirmation that every key refusal creates no identity/session rows. + +--- + +## ⚠️ REPOSITORY MOVED during the codex review (#1649). +A consultant reviews the commit in front of it; it must never commit, or switch refs. + - HEAD: 2a60f246a8d670f5e02af865197cec5be6139820 -> cd1ebb170bb3ba3a84ee43d7791c5b0e25fd3335 +The files on disk may look untouched and still not be the ones this review describes. +Check `git reflog` and `git status` before trusting this review or shipping the branch. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-rebuttals.md new file mode 100644 index 00000000..1a0b12ab --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter1-rebuttals.md @@ -0,0 +1,12 @@ +# Plan 329, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (skipped, `agy` not installed). + +1. Provisional limits: resolved by the owner after the review started. Traffic is low, one instance, no numbers; size by judgement. The plan now carries initial values (the decision), alerts at one third of the caps, wide bounds and an `update-limits` path (commit cd1ebb1). The spec's decision 3 is updated to match. +2. CORS via OPTIONS fall-through: accepted. The router terminates every request to its path; non-POST methods, OPTIONS included, get 404 with no CORS headers and never call `next()`. Tests added. +3. JSON parser errors: accepted. A route-local error handler maps parse, size and content-type errors to the exact 400 body. Tests added. +4. Eligibility binds the BSUID: accepted. `issueWhatsappToken` takes the BSUID and requires the sole account row's `account_id` to equal it under the lock. A re-pointed-row case is added to the matrix. +5. Sentry flush in scripts: accepted. Initialise, await `flush` before exit, report a failed flush. +6. Missing tests: accepted. Expired ban (phases 5 and 6), delegation inputs over HTTP, rate-limit key contents (no BSUID or phone), and unchanged row counts after every key refusal. + +Repository-moved warning: the HEAD change was my own commit (cd1ebb1, owner decision 3), made while the review ran. From 062c3afaff74d846382553d1f35139bc015032c7 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:32:34 +1100 Subject: [PATCH 075/247] chore(porch): 329 plan-approval gate-requested --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 3949b94e..896ed82e 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -11,6 +11,7 @@ gates: approved_at: '2026-10-28T05:23:01.015Z' plan-approval: status: pending + requested_at: '2026-10-28T05:32:34.891Z' pr: status: pending verify-approval: @@ -19,4 +20,4 @@ iteration: 1 build_complete: true history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T05:26:46.881Z' +updated_at: '2026-10-28T05:32:34.891Z' From d313e156df7c9b9e568917b72434b947c8c2cac7 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:45:33 +1100 Subject: [PATCH 076/247] [Spec 329] Plan revised for security plan review --- .../329-apps-auth-service-api-keys-bet.md | 125 +++++++++++++++--- .../329-plan-iter2-input-claude.md | 52 ++++++++ .../329-plan-iter2-input-codex.md | 39 ++++++ .../329-plan-iter2-rebuttals.md | 17 +++ .../329-apps-auth-service-api-keys-bet.md | 6 +- 5 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-claude.md create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-codex.md create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-rebuttals.md diff --git a/codev/plans/329-apps-auth-service-api-keys-bet.md b/codev/plans/329-apps-auth-service-api-keys-bet.md index 107cfc46..a4f2d8e9 100644 --- a/codev/plans/329-apps-auth-service-api-keys-bet.md +++ b/codev/plans/329-apps-auth-service-api-keys-bet.md @@ -7,15 +7,20 @@ The plan implements the spec's Approach 1: - `@better-auth/api-key` 1.7.7 for keys, created in-process by operator scripts, with its HTTP surface blocked by derivation and prefix, and better-auth's logger redacted; - the WhatsApp user, account and session rows written directly with Drizzle; -- the exchange's decision (eligibility, ban, phone number, session, signing) made in one transaction holding `FOR NO KEY UPDATE` on the user row. +- the exchange's decision (eligibility, ban, phone number, session) made in one transaction holding `FOR NO KEY UPDATE` on the user row; the JWT is signed **after** that transaction commits, with the `exp` the session was checked against. The order follows three rules: -1. **Probe before building.** Phase 1 proves the library paths the spec marks as unproven (`createApiKey` from a script, `shouldStore: false`, `signJWT` claims, `returned: false` / `input: false`, the lock pattern on real Postgres) before anything depends on them. +1. **Probe before building, with a fail-closed gate before each probe's first consumer.** If a probe's result differs from the plan, work stops at that gate and the builder raises it with the security architect: + - **before phase 4:** the key probes (`createApiKey` from a script, `shouldStore: false`, `updateApiKey`); + - **before phase 5:** the JWT probe (`signJWT` claims and `exp`), the field probe (`returned: false` / `input: false`), the session-token probe, and the real-Postgres lock and pool probes; + - **before phase 6:** the logger probe and the outage-mapping probe (phase 2). 2. **No commit publishes a surface it does not also guard.** The plugin is wired in the same commit that blocks its routes. The exchange route lands with its CORS placement, key check and timeouts in one commit. 3. **Library before service.** The identity and session core lives in `packages/auth` and is tested on pglite, then the apps/auth route wraps it. Seven phases, each one commit in a single PR. The PR does not merge before the spec 300 production cutover and its contract PR; a rebase is expected. +**The green command for every phase** is the root `pnpm test && pnpm typecheck && pnpm lint` (Turborepo, all packages), from a cold cache at least once per phase (`--force`), because shared-package changes in `packages/auth` reach apps/auth and apps/api. + ## Phases (Machine Readable) ```json @@ -53,6 +58,11 @@ Seven phases, each one commit in a single PR. The PR does not merge before the s - To change them, the script has an `update-limits` subcommand that calls the plugin's server-side `auth.api.updateApiKey` with the new window cap, window and refill. It is validated against the same bounds, and probed in phase 1. Revoke-and-recreate (a rotation) is the documented fallback. The runbook carries both paths. - Changing the per-user budget or an alert threshold is a one-line edit to `limits.ts`. +**Where the alert counters live.** Not in `auth.rate_limit`: better-auth's pruner deletes rows older than the longest window it knows (an hour), so a daily counter there would vanish under low traffic, a check that passes by not running. +- **The per-key minute and day alerts read the key's own row**, which `verifyApiKey` returns on success: `requestCount` within the current window, and `refillAmount − remaining` since `lastRefillAt`. The plugin keeps both durably and atomically, and they reset with the window and the refill. An alert fires when the minute count reaches `WHATSAPP_EXCHANGE_KEY_ALERT` or the day count reaches `WHATSAPP_EXCHANGE_DAILY_ALERT`. It fires once per window or refill period, deduplicated in process by the window start or `lastRefillAt`; a restart can re-fire once, which errs loud. +- **The short-window alerts** (401 volume per 5 minutes, `USAGE_EXCEEDED` and `RATE_LIMITED` once per window) are in-process counters. A restart loses at most one window; the service runs one instance. +- A clock-advance test shows the daily alert fires after a day's worth of verifies spread over hours, with pruning running in between. + **Other constants** (not traffic-dependent), all in `limits.ts`: | Constant | Value | Why | @@ -84,7 +94,8 @@ Add the plugin and its table, and close its HTTP surface in the same commit. Pro - `BLOCKED_AUTH_PATHS` including them; - the `phone_number` additional user field (`input: false`, `returned: false`). - `apps/auth/src/middleware/blocked-paths.ts`: an `/api/auth/api-key` prefix rule beside `ADMIN_PREFIX`. -- `packages/auth/tests/migration/auth-journal-parity.test.ts`: the "single fresh 0000" assertion becomes "0000 then 0001". `AUTH_DEPLOYED_THROUGH` is untouched (main's). +- `packages/auth/tests/migration/auth-journal-parity.test.ts`: the "single fresh 0000" assertion becomes `idx` `[0, 1]`. **`AUTH_DEPLOYED_THROUGH`:** after the cutover, main sets it to `0000`. The deployed-state test then signs up against a database migrated through `0000` only, and the `phone_number` additional field makes that insert fail. So this PR bumps it to `0001`, **with main's agreement**, as the declaration that `0001` is applied before deploy; the matching `auth-journal-applied: 0001_…` release record follows main's convention. The bump is raised with main in the same message as the journal numbering, and lands at the rebase after the cutover. +- `packages/auth/src/db.ts`: an explicit pool `max` (`AUTH_POOL_MAX`, 10, pg's default made visible) and `connectionTimeoutMillis` (`AUTH_POOL_CONNECT_TIMEOUT`, 5 s), so a starved pool is a loud error (500), never an unbounded wait during which `lock_timeout` and `statement_timeout` cannot fire. Both are constants in `db.ts`; they are pool settings, not rate limits. - `packages/auth/tests/schema/auth-schema.test.ts`: seven tables; `apikey` shape; `account (provider_id, account_id)` unique; `users.phone_number`. - `packages/auth/tests/config/api-key.test.ts` (new): probes 1, 2 and 4a as permanent pinning tests. - `apps/auth/tests/blocked-paths.test.ts`: the API-key surface tests. @@ -102,17 +113,19 @@ Add the plugin and its table, and close its HTTP surface in the same commit. Pro - **Probe 4a.** `phone_number` is absent from `get-session` and sign-in responses, and ignored when sent to sign-up. - The probed plugin behaviours: no-refill exhaustion deletes the key; refilled exhaustion refuses and keeps it, and verifies after the interval; disabled is kept; expired is deleted; a wrong permission spends no budget; `valid: false` for an unknown key. - `enableSessionForAPIKeys: false`: a valid key as `x-api-key` and as a bearer at `/get-session` and `/token` yields nothing. +- [ ] **Session-token probe:** `generateRandomString(32, 'a-z', 'A-Z', '0-9')` from the public `better-auth/crypto` export produces tokens of the same length and alphabet as a token `internalAdapter.createSession` writes (32 characters, `[A-Za-z0-9]`). This is the generator phase 5 uses. `generateId` itself is not exported from better-auth's entry, and `@better-auth/core` is not a dependency, so no deep import is used. - [ ] **Probe 4** (real Postgres 18, `exchange-lock-probe.ts`, run by the builder against `docker run postgres:18`, output pasted into the review). Every step under a deadline that fails red: - under `FOR NO KEY UPDATE` on a user row, a session row inserted in the same transaction commits without waiting; - a ban (`UPDATE users SET banned…; DELETE FROM session…`) on another connection waits for the exchange and then deletes its session; - a session insert from another connection does not wait on the lock (`KEY SHARE` compatibility); - - `lock_timeout` turns a held `FOR UPDATE` from another connection into error `55P03`. + - `lock_timeout` turns a held `FOR UPDATE` from another connection into error `55P03`; + - **pool starvation:** with the pool's `max` deliberately set to 2, 10 concurrent exchanges (lock, decide, write the session, commit, then sign, which reads `jwks` on the pool) all finish, or fail with the pool's connect timeout as a 500. None hangs. The same run with signing moved *inside* the transaction reproduces the hang, which records why signing is outside. #### Acceptance Criteria -- [ ] `pnpm --filter @ansari/auth test`, `pnpm --filter ansari-auth test`, typecheck and lint pass. +- [ ] The green command passes. - [ ] The migration SQL creates exactly the `apikey` table, the `account` unique constraint and the `users.phone_number` column, and nothing else. -- [ ] Probe 4's output shows all four results. If any differs, stop and raise it with the security architect before phase 5. +- [ ] **Gate before phase 4:** probes 1 and 1b match the plan. **Gate before phase 5:** probes 2 and 4a, the session-token probe and every step of probe 4 match the plan. A mismatch stops work at that gate and goes to the security architect. #### Test Plan @@ -145,6 +158,7 @@ Make better-auth's logger obey the repo rule "never log a raw driver error", wit - [ ] All suites pass. No existing test that asserts better-auth log output needs editing, or each one that does is listed in the commit message. - [ ] The criterion cannot be met by disabling the logger: the non-error survival test fails if `disabled: true`. +- [ ] **Gate before phase 6:** the logger probe and the outage mapping (`auth.apikey` dropped → `valid: false` / `INVALID_API_KEY`) match the plan. #### Test Plan @@ -210,7 +224,7 @@ The operator can create a service user and issue, list, rotate and revoke its ke - It logs the key id acted on. - `create-key` calls `auth.api.createApiKey` with the service's fixed permissions, prefix `wa_`, no expiry, and the operator's limits validated against `WHATSAPP_KEY_BOUNDS`. It refuses a budget without a refill and offers no expiry flag. It prints the raw key once, framed with the handoff instruction. - `revoke` sets `enabled = false` and keeps the row. `rotate` is `create-key`, plus a printed reminder to revoke the old key after the consumer switches. - - Each create, rotate, update-limits and revoke sends one Sentry event (service, key id, name, action; no key material). The script initialises Sentry and awaits `Sentry.flush` before exit, so the event is not lost when the process ends. A failed flush is reported on stderr, with the action still recorded in the output. + - Each create, rotate, update-limits and revoke sends one Sentry event (service, key id, name, action; no key material). The script initialises Sentry and awaits `Sentry.flush` with a timeout before exit. If the flush fails or times out, the write stands, the script prints a loud banner telling the operator to record the key id and action in the runbook's key log, and exits non-zero so it is noticed. - `apps/auth/tests/service-keys.test.ts` (new); `apps/auth/tests/helpers/` as needed. #### Deliverables @@ -226,12 +240,14 @@ The operator can create a service user and issue, list, rotate and revoke its ke - revoke → `enabled = false`, row kept, verify refused; - update-limits → the new values are in the row and enforced at the next verify; out-of-bounds values are refused; - refused inputs: a budget without refill, out-of-bounds limits, any expiry flag, an existing service name; - - the Sentry event is captured with no key material, and the script awaits the flush before exit; + - the Sentry event is captured with no key material, and the script awaits the flush before exit; a failed flush prints the banner and exits non-zero, with the write kept; + - per subcommand (`create-service`, `create-key`, `list`, `rotate`, `update-limits`, `revoke`): refuses without a database URL; refuses a mismatched `--confirm`; prints hostname and port only; logs the key id it acted on (where a key is involved); + - output fields: `list` prints id, name, prefix, permissions, enabled, limits, created and last-request dates per key; `rotate` prints the new key once plus the old key's id; `revoke` prints the key id and `enabled = false`; none prints a key or a hash; - the service user's address and `service` row are created in one transaction (pglite, a forced failure leaves neither). #### Acceptance Criteria -- [ ] All suites pass. Running each subcommand by hand from `apps/auth/` against a pglite-backed test database produces the documented output. +- [ ] The green command passes. A runnable harness, `apps/auth/scripts/probes/service-keys-demo.ts`, builds a pglite database from the journal, runs every subcommand in sequence and prints their outputs, so the documented output is checked by running it, not by reading it. #### Test Plan @@ -254,13 +270,12 @@ The exchange's whole decision as a library function in `packages/auth`, testable 2. Eligibility: the user's sole account row is `whatsapp` **with `account_id` equal to this request's BSUID** (so a row re-pointed between resolve and lock is caught), noreply address, `registered_via = 'whatsapp'`, role not admin; else `Ineligible`. 3. `isBanned` → delete sessions, commit, `Banned`. 4. The phone number, updated when provided and different. - 5. `iat = now`, `exp = now + JWT lifetime`; reuse a session with `expiresAt ≥ exp + SESSION_MARGIN`, else delete the user's sessions and insert one directly (token `generateId(32)` from better-auth, 30-day `expiresAt`, no IP or user agent). - 6. `auth.api.signJWT({ body: { payload: { sub, sid, iat, exp } } })`. - 7. Commit, return the token. + 5. `iat = now`, `exp = now + JWT lifetime`; reuse a session with `expiresAt ≥ exp + SESSION_MARGIN`, else delete the user's sessions and insert one directly. Every column is named: `token` from `generateRandomString(32, 'a-z', 'A-Z', '0-9')` (`better-auth/crypto`), `expiresAt` 30 days, `createdAt` and `updatedAt` (no database default for `updatedAt`), `ipAddress` `''` and `userAgent` `''` (better-auth's own values without a request), `userId`. + 6. Commit. + 7. **After the commit**, `auth.api.signJWT({ body: { payload: { sub, sid, iat, exp } } })` with the same `iat` and `exp`. Signing reads `jwks` on the pool; inside the transaction it would need a second connection and could starve the pool (probe 4). The invariant holds: at signing time the session outlives the token, because `expiresAt` was checked against this `exp` before the commit. A signing failure → 500; the session stays and is reused next time. Typed outcomes, not thrown strings. - `packages/auth/src/limits.ts`: `SESSION_MARGIN`, `EXCHANGE_LOCK_TIMEOUT`, `EXCHANGE_STATEMENT_TIMEOUT`, `WHATSAPP_EXCHANGE_PER_USER`. -- `packages/auth/src/users.ts`: fix the stale "boot assertion" header line once phase 6 adds one (or here, worded for phase 6). - `packages/auth/tests/whatsapp.test.ts` (new); rerun `apps/auth/scripts/probes/exchange-lock-probe.ts` against the real functions. #### Deliverables @@ -277,14 +292,16 @@ The exchange's whole decision as a library function in `packages/auth`, testable - eligibility matrix (a `whatsapp` row on an email, service or admin user; a promoted user; an altered address; an altered `registered_via`; the `whatsapp` row's `account_id` changed between resolve and lock) → `Ineligible`, no token; - an expired ban (`banExpires` in the past) is not refused; - claim-set equality with `/api/auth/token`, and `{sub, sid}` equal to `definePayload`'s output for the same session; + - the session token is 32 characters from `[A-Za-z0-9]`, pinned, so a silent fallback to a weaker generator fails a test (it is a 30-day bearer credential); + - `ipAddress` and `userAgent` are `''` and `updatedAt` is set, as a better-auth-written session; - a directly written session is accepted by the bearer plugin (`/api/auth/token` with its token) and by `/api/account/me`'s live-sid check; - the BSUID appears only in `account.account_id`. -- [ ] A source-level test: the eligibility re-read, ban check, phone update, session write and `signJWT` sit inside the `FOR NO KEY UPDATE` transaction body. +- [ ] A source-level test: the eligibility re-read, ban check, phone update and session write sit inside the `FOR NO KEY UPDATE` transaction body; `signJWT` sits **after** it, and `consumeWindow` is not in it. - [ ] Probe 4 rerun against these functions on real Postgres; output in the review. #### Acceptance Criteria -- [ ] All suites pass; probe 4 green on real Postgres under deadlines. +- [ ] The green command passes; probe 4 (including pool starvation) green on real Postgres under deadlines, rerun against these functions. #### Test Plan @@ -304,7 +321,7 @@ pglite with seams (a hook between resolve and lock) for the interleavings, real 1. Bearer → `verifyApiKey({ key, permissions: { whatsapp: ['mint'] } })`. 2. On `valid: false`, `checkApiKeyRelation` (a query on `auth.apikey`, success cached for `APIKEY_RELATION_CHECK_TTL`) → 401 or 500. A missing or malformed bearer → 401 with no database query. 3. `findServiceUser('whatsapp')`: not exactly one → 500 + alert; `referenceId` mismatch → 401. - 4. Route-local `express.json({ limit: EXCHANGE_BODY_LIMIT })` after the key, followed by a route-local error handler. It maps the parser's errors (`entity.parse.failed`, `entity.too.large`, a wrong content type) to the exact 400 `{ "message": "Invalid request" }`, so no Express error page and no body fragment is ever sent. Sanity bounds → the same 400. + 4. The route first checks `Content-Type` itself: anything but `application/json` → the exact 400. (body-parser 2.x skips an unsupported media type instead of raising, so the error handler alone would never see it, and an unparsed body is treated as 400 too.) Then route-local `express.json({ limit: EXCHANGE_BODY_LIMIT })`, followed by a route-local error handler. It maps the parser's errors (`entity.parse.failed`, `entity.too.large`, a wrong content type) to the exact 400 `{ "message": "Invalid request" }`, so no Express error page and no body fragment is ever sent. Sanity bounds → the same 400. 5. `resolveOrCreateWhatsappUser`. 6. `consumeWindow` on the pool, keyed `sub:|/api/whatsapp/session` → 429 with integer `X-Retry-After`. 7. `issueWhatsappToken` → 200, 403 or 500; a lock timeout → 500. @@ -314,8 +331,10 @@ pglite with seams (a hook between resolve and lock) for the interleavings, real - `apps/auth/src/boot.ts` (new) and `apps/auth/src/index.ts`: `assertSchema(db)` before `listen()`. - Query `auth.apikey`: `42P01` → refuse. - Catalog lookup for the constraint: empty → refuse. + - Every refusal logs `{name, code}` only (the missing-constraint case included). - Any other error → log `{name, code}` and continue to `listen()`. -- `apps/auth/src/alerts.ts`: per-key volume (per minute and per day), `USAGE_EXCEEDED` and `RATE_LIMITED` (one per window each), 401 volume, eligibility (count) and service user missing. Each carries counts and windows only. The eligibility case also writes one `console.warn` with the user id (the apps/api "app row refused" precedent). +- `packages/auth/src/users.ts`: the stale "boot assertion" header line now names `assertSchema`. +- `apps/auth/src/alerts.ts`: per-key volume (per minute and per day), `USAGE_EXCEEDED` and `RATE_LIMITED` (one per window each), 401 volume, eligibility (count) and service user missing. Each carries counts, windows and hash prefixes only, the `alerts.ts` posture. The eligibility case also writes one `console.warn` with the user id (the apps/api "app row refused" precedent). The per-key minute and day counts come from the key row, as described under Limit sizing. - `packages/auth/src/limits.ts`: `APIKEY_RELATION_CHECK_TTL`, `EXCHANGE_BODY_LIMIT`, `WHATSAPP_EXCHANGE_KEY_ALERT`, `WHATSAPP_EXCHANGE_DAILY_ALERT`, `EXCHANGE_UNAUTHORIZED_ALERT`. - `apps/auth/src/alerts.ts` (where `Sentry.init` lives): a `beforeSend` scrub for the exchange path (no body, no `Authorization`). - Tests: `apps/auth/tests/whatsapp-session.test.ts` (new), `apps/auth/tests/boot.test.ts` (new), `apps/auth/tests/public-face.test.ts` (route inventory). @@ -328,7 +347,13 @@ pglite with seams (a hook between resolve and lock) for the interleavings, real - delegation inputs over HTTP: an admin's and an email user's id and email, and a `google` user's `account_id`, passed as `bsuid` → each creates a new WhatsApp user, never the seeded one; - an expired ban is let through; - the `rate_limit` row's key contains the user id and neither the BSUID nor the phone number; - - malformed JSON, a wrong content type and an over-limit body → the exact 400 body, with no HTML and no body fragment; + - malformed JSON, a `text/plain` body, a missing `Content-Type` and an over-limit body → the exact 400 body, with no HTML and no body fragment; + - the exhaustive body cases from the spec (non-JSON, oversized, `bsuid` missing, non-string, empty, over 256 characters, with whitespace, with a control character; `phone_number` non-string, empty, over 32 characters, non-digits) → 400; a sane but unusual `bsuid` is accepted; + - a missing or malformed bearer → 401 with **zero** database queries (a query-counting `Db` wrapper); + - `UserGone` (user deleted between resolve and lock) → 500 over HTTP, with no session row; + - a JWT issued before a ban fails `GET /api/account/me` after the ban; + - Sentry events from the exchange contain no phone number; + - each alert fires once per window, not per request; - `GET`, `PUT` and `OPTIONS` on the path → 404 with no `Access-Control-*` header; - service user missing → 500 + alert; - body cases → 400 after a valid key; a body with no key is never parsed (spy); @@ -369,6 +394,7 @@ Prove an exchange token works in apps/api unchanged, prove apps/api has no Whats #### Files to Create / Modify - `apps/api/tests/contract/whatsapp-exchange.test.ts` (new): start apps/auth through the existing harness, seed a service user and key, exchange a BSUID, and call an apps/api route with the token. `requireUser` passes, first-use `GET /api/account/me` runs, and a `public.users` row exists under the same UUID with `registered_via = 'whatsapp'`. The phone number is in no apps/api row or response. +- `apps/api/tests/auth/resolve-user.test.ts`: one test pinning that apps/api's first-use refusal covers only the system domain. A `whatsapp-@noreply.askansari.ai` profile from `/me` is accepted and its app row created. WhatsApp users must pass it, so a future tightening of `resolve-user.ts` would otherwise break WhatsApp with no failing test. - `apps/api/tests/source-scan/` (existing scan module): a `whatsapp` scan over `apps/api/src/**` and `apps/api/lib/**` (tests excluded) that reports its hit count (0). Its negative tests: a known-bad fixture line under the scanned glob is caught, and a near miss outside it is not. - `docs/self-hosting.md`: the route, the scripts, `limits.ts` bounds, the migration-before-code order. - `docs/cutover/service-keys-runbook.md` (new): @@ -379,8 +405,11 @@ Prove an exchange token works in apps/api unchanged, prove apps/api has no Whats - pruning (none expected); - the limit values, their judgement-based reasoning, and how to change them (`update-limits`, or revoke-and-recreate); - the pre-apply duplicate check for `(provider_id, account_id)`, with the ACCESS EXCLUSIVE lock note and quiet-window guidance; - - the alert meanings, and the eligibility repair procedure. -- `codev/resources/arch.md` (Authentication & Authorization): service keys, the exchange, the noreply domain, and the alert posture. + - the alert meanings, and the eligibility repair procedure; + - the key log: where the operator records key id and action when a script's Sentry flush fails. + + The runbook must not contain a bare `auth-journal-applied:` line outside backticks: the parity test scans `docs/**` for that record. +- `codev/resources/arch.md` (Authentication & Authorization): that apps/api's first-use refusal covers only the system domain, and why WhatsApp users depend on it; service keys, the exchange, the noreply domain, and the alert posture. - `packages/auth/README.md`: the `apikey` table and migration `0001`. - Coordination (outside this repo's diff): the contract summary, sent to the #327 builder through the architect, with the spec 321 amendment on their branch: header, `phone_number`, error statuses, no per-message retry on 500, re-exchange once on an apps/api 401, and the staging smoke (5 messages within 1 s) recorded as a comment on PR #327. @@ -399,12 +428,68 @@ Prove an exchange token works in apps/api unchanged, prove apps/api has no Whats The contract test through the existing harness; scan negative tests; doc consistency tests. +## Criterion → owning phase → test + +Each spec success criterion has exactly one acceptance owner. An earlier probe may pre-test it, but the owner's test is the one that must stay green. + +| Spec criterion (short) | Owner | Test | +|---|---|---| +| Plugin pinned to 1.7.7, core not bumped | 1 | `packages/auth/tests/config/api-key.test.ts` (version read from the lockfile) | +| `apikey` table shape; `0001` with the constraint and `phone_number` | 1 | `tests/schema/auth-schema.test.ts`, `tests/migration/auth-journal-parity.test.ts` | +| Boot refuses on `42P01` or a missing constraint; starts when the database is unreachable | 6 | `apps/auth/tests/boot.test.ts` | +| Blocked list derived, 7 endpoints / 5 routed, finite variant list | 1 | `apps/auth/tests/blocked-paths.test.ts` | +| `enableSessionForAPIKeys` false: no session or token from a key | 1 | `tests/config/api-key.test.ts` | +| Key row: raw key absent, `start` null, hash equality | 1 | `tests/config/api-key.test.ts` (probe 1) | +| Create prints the key once; list, rotate and revoke output fields | 4 | `apps/auth/tests/service-keys.test.ts` | +| Every subcommand: URL refusal, confirm, hostname and port only, key id logged | 4 | `service-keys.test.ts` (per subcommand) | +| Create refuses a budget without refill, out-of-bounds limits, expiry | 4 | `service-keys.test.ts` | +| Service user and `service` row in one transaction; duplicate refused | 4 | `service-keys.test.ts` | +| Rotate: two live keys; revoke disables and keeps | 4 | `service-keys.test.ts` | +| Script Sentry events, no key material; flush failure banner and exit code | 4 | `service-keys.test.ts` | +| Refilled exhaustion refuses and keeps, then verifies after the interval | 1 | `tests/config/api-key.test.ts` | +| `grant-admin.ts` refuses noreply | 3 | `apps/auth/tests/grant-admin.test.ts` | +| Every key refusal → identical 401, no rows | 6 | `apps/auth/tests/whatsapp-session.test.ts` | +| Missing or malformed bearer → 401 with zero database queries | 6 | `whatsapp-session.test.ts` | +| `auth.apikey` dropped / database stopped → 500 after the TTL | 6 | `whatsapp-session.test.ts` (dropped); production-shaped run in the review (stopped) | +| Service user missing → 500 + alert | 6 | `whatsapp-session.test.ts` | +| User deleted mid-exchange → 500 | 5 (library), 6 (HTTP) | `packages/auth/tests/whatsapp.test.ts`; `whatsapp-session.test.ts` owns the HTTP 500 | +| New BSUID: user, account, session, exact address, verifiable JWT | 5 | `whatsapp.test.ts` | +| Same BSUID twice → same `sub` and `sid` | 5 | `whatsapp.test.ts` | +| Session ≥ `exp` + `SESSION_MARGIN`, else replaced | 5 | `whatsapp.test.ts` | +| Claim-set equality with `/token`; `definePayload` agreement | 5 | `whatsapp.test.ts` | +| Eligibility matrix → 500 + alert, never a token | 5 (library), 6 (alert) | `whatsapp.test.ts`; `whatsapp-session.test.ts` owns the alert | +| Delegation inputs are unknown BSUIDs | 6 | `whatsapp-session.test.ts` | +| Ban → 403, sessions deleted, pre-ban JWT fails `/me`; expired ban passes | 6 | `whatsapp-session.test.ts` | +| Per-user 429 with integer `X-Retry-After`; counter survives refusals; `rate_limit` key holds the user id | 6 | `whatsapp-session.test.ts` | +| Lock timeout → 500, not a hang | 6 | `whatsapp-session.test.ts`, plus probe 4 | +| Concurrency (conflict, ban under the lock, source-level scope) | 5 | `whatsapp.test.ts`, plus probe 4 | +| Exhaustive body cases → 400; content type | 6 | `whatsapp-session.test.ts` | +| BSUID only in `account.account_id`; phone only in its column | 5 | `whatsapp.test.ts` (row scan) | +| `phone_number` refused as input, never returned | 1 | `tests/config/api-key.test.ts` (probe 4a) | +| Logs carry no secret, BSUID, phone or driver error | 6 | `whatsapp-session.test.ts` (capture across all cases) | +| Logger: redacted, not silenced; near misses survive | 2 | `packages/auth/tests/unit/logger.test.ts` | +| Sentry from the exchange: no body, `Authorization` or phone | 6 | `whatsapp-session.test.ts` | +| No response contains sensitive input | 6 | `whatsapp-session.test.ts` | +| No CORS for the four Origin cases; mounted before `corsFor` | 6 | `whatsapp-session.test.ts` | +| Alerts at thresholds, once per window, counts only | 6 | `whatsapp-session.test.ts`, plus the clock-advance daily test | +| Reserved domain: sign-up 409, near misses, reset, hook, sign-in | 3 | `tests/config/sign-up.test.ts`, `apps/auth/tests/reset.test.ts` | +| `isSystemAddress` unchanged | 3 | existing tests, unedited | +| End to end through apps/api `requireUser` | 7 | `apps/api/tests/contract/whatsapp-exchange.test.ts` | +| apps/api first-use refusal is system-only | 7 | `apps/api/tests/auth/resolve-user.test.ts` | +| No WhatsApp code in apps/api (scan, negative tests, count) | 7 | `apps/api/tests/source-scan/` | +| Staging smoke | 7 (coordination) | comment on PR #327 by its builder | +| Documentation | 7 | `self-hosting-docs.test.ts`, `cutover-docs.test.ts`, review | +| `ROUTER_RULES` unchanged; no new env var | 6, 7 | `public-face.test.ts`; four-way derivation in the review | + ## Risks and Mitigation | Risk | Probability | Impact | Mitigation | |------|-------------|--------|------------| | A probe contradicts the spec (e.g. `signJWT` overrides `exp`, `returned: false` leaks through an adapter path, the lock pattern waits) | Medium | High | Phase 1 runs every probe first; any difference stops the plan and goes to the security architect before phase 5 | | Migration numbering collides with the cutover | Medium | High | Message `architect:main` before committing the journal; rebase expected | +| Signing inside the transaction starves the shared pool | Low (designed out) | High | Sign after commit; explicit pool `max` and connect timeout; the pool-starvation probe reproduces the hang with signing inside, and passes with it outside | +| The session token generator silently weakens | Low | High | The generator comes from the public `better-auth/crypto`; length and alphabet pinned by a test | +| The lockfile has two `better-call` copies (zod peer variants) | Low | Low | Harmless here: the exchange maps every `valid: false` to 401 and classifies outages by the relation check, not by error class identity | | The judgement-based values prove too low or too high | Medium | Low | Alerts at one third of the caps, so a surge is seen first; `update-limits` changes a key's values without code; bounds are wide, so a raise needs no bound change | | The logger change alters an existing test's expectations | Low | Low | Its own commit; affected tests listed in the commit message | | Real-Postgres steps are manual, so they can be skipped | Medium | High | Their output is a review deliverable for phases 1, 5 and 6; the plan's acceptance criteria name them | diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-claude.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-claude.md new file mode 100644 index 00000000..876410eb --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-claude.md @@ -0,0 +1,52 @@ +I'll start by reading both documents from disk and verifying the codebase facts they rely on.## Review — plan 329 against the approved spec + +Read directly from disk in `.builders/spir-329`: the plan, the spec, and every file the plan touches or reuses (`packages/auth/src/{index,limits,rate-limit-store,schema,db,reserved,users}.ts`, `apps/auth/src/{index,app}.ts`, `apps/auth/src/middleware/blocked-paths.ts`, `apps/auth/src/routes/{account,health}.ts`, `apps/auth/src/alerts.ts`, `apps/auth/scripts/grant-admin.ts`, the test harnesses, and better-auth 1.7.7's dist for `jwt/sign.mjs`, `jwt/index.mjs`, `db/internal-adapter.mjs`, `api/rate-limiter/index.mjs`). Nothing was modified; all commands were read-only. + +### Library behaviour I am relying on that neither document records as probed + +State this first because three findings below depend on it. `@better-auth/api-key@1.7.7` **is not installed** — `packages/auth/node_modules/@better-auth/` contains only `expo`. So the plugin's 7-endpoint/5-routed surface, `startingCharactersConfig.shouldStore:false`, `defaultKeyHasher`'s SHA-256/base64url output, `updateApiKey`'s field semantics, and the collapse of driver errors into `{valid:false, INVALID_API_KEY}` rest entirely on the spec's recorded specify-phase pglite probe plus the plan's phase-1 pinning tests. I could not independently verify any of them. Separately, the plugin's outage-vs-refusal classification depends on `APIError instanceof` working across a *single* copy of `better-call`; the repo resolves `better-call@1.4.0` singly today and the plugin peers `better-call 1.4.0`, so this holds now, but **nothing pins it** — a second copy arriving via a transitive bump turns every outage into a refusal (fail-open on the alerting side) with no test failing. + +--- + +### Blocking + +**1. `signJWT` inside the `FOR NO KEY UPDATE` transaction starves the connection pool, and the spec's timeouts do not bound it.** +The plan signs the JWT inside the locking transaction. That transaction holds one pg connection for its whole life; `auth.api.signJWT` → `resolveSigningKey` queries the `jwks` table through the drizzle adapter on the *same* `Pool` on **every** call, and because the plan uses a raw `db.transaction` rather than better-auth's `runWithTransaction`/`getCurrentAdapter`, the adapter is not bound to the open transaction — it acquires a **second** connection. `createDb()` is `new Pool({ connectionString })` with no `max` and no `connectionTimeoutMillis`, i.e. pg's defaults: 10 connections and an **unbounded** acquisition wait. At ~5–10 concurrent exchanges every connection is held by a transaction waiting for a second connection that can never be issued, and the pool is shared with sign-in, JWKS and `/api/account/*` — so the failure mode is a hang of the whole service, not a slow exchange. `lock_timeout` (55P03) and `statement_timeout` bound *lock waits* and *statement* duration; neither fires while a caller is blocked in `pool.connect()`, which is precisely the gap the spec claims those timeouts close. The spec's note that "signJWT does no database write" is also wrong on the auto-mint path: `resolveSigningKey` calls `createJwk` when no live unexpired key exists. Fix shape: resolve the identity and mint the session inside the transaction, **commit**, then sign outside it — or bind the adapter with `runWithTransaction` and set an explicit `max`/`connectionTimeoutMillis` on the pool. Either way this needs a phase-1 probe (N concurrent exchanges against real Postgres with a deliberately small `max`), because a single-request test passes under the broken design. + +**2. `generateId` is not reachable from `packages/auth`.** +The plan sources the 32-char session token from `generateId(32)`. In 1.7.7 that function lives in `@better-auth/core/utils/id` and is **not** re-exported from the `better-auth` main entry, and `@better-auth/core` is not a declared dependency of `packages/auth` — under pnpm's isolated layout the import will not resolve, so phase 5 cannot build as written. This is cheap to fix (declare `@better-auth/core@1.7.7`, matching the api-key plugin's peer, and deep-import deliberately; or take the spec's `internalAdapter.createSession` alternative, which also gets `token`, `ipAddress:""`, `userAgent:""` for free). I tag it blocking rather than should-fix because the thing being generated is a 30-day bearer credential: a silent fallback to any weaker generator is a security defect, so whichever source is chosen needs a test pinning the token's length and alphabet, not just "it compiles". + +--- + +### Should-fix + +**3. The daily exchange alert is a check that can pass by not running.** +better-auth's DB rate limiter prunes with `deleteExpiredRows(now)`, deleting every row in the rate-limit table whose `lastRequest < now - longestObservedWindow*1000`. The repo feeds that from `rateLimitRetention`'s `LONGEST_WINDOW`, which `limits.ts` computes as the max over a **hand-maintained** array and is 3600s today. The plan specifies a per-key daily counter (1,000/day) but never says where it is stored; if it lands in `auth.rate_limit` like every other counter, it is deleted after one hour of key inactivity — and under the plan's own stated low-traffic posture, hour-long gaps are the normal case, so the daily alert can never fire. Note the trap in the obvious fix: adding 86400 to that array raises retention for the *entire* table, including `sub:` and `alert|` rows. The new per-user 900s window is safe; the daily one is not. The plan should name the storage, and either justify a separate counter outside the pruned table or state and accept the retention change — with a test that advances a clock past the prune cutoff and shows the daily counter survives. + +**4. Nothing pins the single-`better-call` assumption that the outage/refusal split rests on.** +See the disclosure above. A `packages/auth` test asserting exactly one resolved `better-call` version (or that the plugin's thrown error is `instanceof` the `APIError` the service imports) costs one assertion and converts a silent fail-open into a build failure. Phase 1 is the right home, since every later phase's error mapping depends on it. + +**5. Phase 7's arch.md update should record the apps/api-side constraint, not just the apps/auth-side design.** +`apps/api/lib/auth/resolve-user.ts:129` refuses first use only for `isSystemAddress(email)` — the *system* domain. WhatsApp users carry the new noreply domain and therefore pass first use, which the exchange requires. That is load-bearing and invisible from either document: a future tightening of that check to "any reserved domain" breaks WhatsApp with no failing test in this spec's suite. Record it as an invariant in arch.md and add a one-line test in apps/api asserting a noreply-domain user resolves on first use. + +--- + +### Nits + +**6. `session.updatedAt` has no DB default.** The schema declares it `.notNull()` with `$onUpdate` only, so a direct insert that omits it fails at runtime, not at type-check. Trivial, but the plan's phase-5 insert list should name it explicitly. + +**7. "Indistinguishable from a better-auth session" is literally false.** `internalAdapter.createSession` writes `""` for absent `ipAddress`/`userAgent`; a hand-rolled insert writing `null` is distinguishable by a trivial query. The spec sanctions the hand-rolled path, so this is cosmetic — but if the claim is going to stand in docs, write `""` to make it true. + +**8. The parity test needs more than the journal-index edit.** `packages/auth/tests/migration/auth-journal-parity.test.ts` asserts `journal.map(e => e.idx)).toEqual([0])` *and* hard-codes the six-table list. The plan names the first edit but not the second; the `apikey` entry must be added or phase 1 leaves the tree red. + +**9. The new runbook must avoid the apply-record pattern.** While `AUTH_DEPLOYED_THROUGH` is `null`, that same test asserts `recordedApplies()` equals `[]`, scanning `RELEASE.md` and every `docs/**/*.md` for `^auth-journal-applied:\s*(\d{4}_[a-z0-9_]+)$`. A `docs/cutover/service-keys-runbook.md` that shows the apply line as a bare example — rather than inside backticks, which the pattern already rejects — turns phase 7 red for a documentation reason. Worth one sentence in the phase. + +**10. `users.ts`'s stale header line has ambiguous phase ownership.** The plan's "fix it once phase 6 adds one (or here, worded for phase 6)" leaves it owned by nobody. Assign it to phase 6 or drop it. + +--- + +### Dimensions that came back clean + +Coverage and ordering are otherwise sound: every success criterion maps to a phase with a named test, and the probe ordering is right — the unproven-library probes sit in phase 1 ahead of everything built on them, and the plan states the fallback when each fails. Phase independence holds apart from findings 2 and 8. No spec contradictions found: the plan's choice to mount the exchange *before* `corsFor` and terminate every request is **stronger** than the spec, and correct — `createApp` currently puts `corsFor` first, so the spec's wording alone would have leaked CORS headers onto the exchange. I verified and found no weakening of the never-log lists, the single byte-identical 401, the relation check and its TTL, the 42P01-only boot assertion, the per-user budget's placement outside the transaction, or claim-set equality (`jwt/sign.mjs` honours an explicit `exp` and sets `iat` only when passed, so equality with `/api/auth/token` is achievable as described). Migration/deploy posture is right: 0001-numbered-by-main, migration-before-code, the pre-apply duplicate check, and the ACCESS EXCLUSIVE note on the unique constraint are all present. `errorMeta` and `urlHost` reuse are accurate precedents, `SENTRY_DSN` already exists so "no new env var" holds, no `isReservedAddress` logic change is needed in either the `user.create.before` hook or `signUpGuard`, and every reused harness (`startService`, the apps/api contract harness, the source-scan and docs tests) exists as cited. No scope creep. + +REQUEST CHANGES (1: move `signJWT` out of the locking transaction or bind the adapter, bound the pool, and probe concurrency; 2: make the session-token generator resolvable and pin its shape by test; 3: name the daily counter's storage so pruning cannot silence it; 4: pin the single-`better-call` assumption; 5: record the apps/api noreply-domain invariant; 6–10: nits) \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-codex.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-codex.md new file mode 100644 index 00000000..0ec05fdf --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-input-codex.md @@ -0,0 +1,39 @@ +## Findings + +1. **Blocking — Probe failure gates occur too late.** Phase 1’s manual failure rule stops only before Phase 5 (plan lines 115, 406), but Phase 4 already depends on `createApiKey` and `updateApiKey`. Each probe needs an explicit fail-closed gate before its first consumer: key probes before Phase 4, JWT/field/lock probes before Phase 5, and logger/outage-mapping before Phase 6. The current wording could permit building Phase 4 after a contradicted key probe. + +2. **Blocking — Success criteria do not have unique phase ownership.** There is no traceability matrix, and several criteria are duplicated while others lack a named test. Claim equality is tested in Phases 1 and 5; expired-ban behavior in Phases 5 and 6; raw-key hashing in Phases 1 and 4. Missing or incomplete named tests include: + - list/rotate/revoke output fields required by spec line 239; + - every script/subcommand’s database URL, confirmation, hostname, and key-id behavior (line 240); + - zero database queries for missing/malformed bearers (line 258); + - `UserGone` mapped through HTTP to 500 (line 260); + - a pre-ban JWT subsequently failing `/api/account/me` (line 274); + - exhaustive body sanity cases, rather than “body cases” (plan line 334); + - captured Sentry events excluding the phone number (line 290); + - alert one-event-per-window behavior (line 297). + + Add a criterion → owning phase → named test table, distinguishing early probes from the single acceptance-test owner. + +3. **Blocking — Security requirements are weakened in several places.** + - The boot test checks `{name, code}` only for `42P01`; the missing-constraint refusal also requires that log shape (spec line 234, plan lines 347–349). + - The plan says exchange alerts carry “counts and windows only” (plan line 318), while the approved alert posture includes counts, windows, and hash prefixes (spec lines 295 and 325). Either preserve that posture or explicitly amend the spec. + - The Sentry capture test omits phone-number exclusion (plan line 343 versus spec line 290). + - The script test says only “the Sentry event,” rather than independently proving create, rotate, and revoke each emit exactly one event with all required fields (spec line 244). + +4. **Blocking — The promised durable operator audit record is not guaranteed.** The plan lets an action succeed when `Sentry.flush` fails, leaving only terminal output (plan line 213). That contradicts the durable-event requirement at spec line 98. Define the intended durability contract: fail the write before execution, persist an outbox/audit row, or amend the approved requirement. Merely reporting failed delivery on stderr is not durable. + +5. **Should-fix — Per-phase green status is underspecified.** Phase 1 names only the two auth package suites plus lint/typecheck, while root `turbo run test` appears only in Phase 7. “All suites pass” elsewhere is ambiguous. Since every phase is a commit, each should name the same root green command or a justified complete affected-package set. Phase 4’s instruction to run CLI subcommands manually against a “pglite-backed” database also needs a concrete runnable harness; the production CLI otherwise expects a PostgreSQL URL. + +6. **Should-fix — The plan explicitly permits an existing-test edit forbidden by the spec.** Plan line 146 allows Better Auth log-output tests to be edited and merely listed in the commit message. Spec line 330 permits existing-test edits only for blocked-path, schema, and reserved-check enumerations. Remove that allowance or amend the specification. + +7. **Should-fix — Wrong-content-type handling describes the wrong library mechanism.** I verified the installed `body-parser@2.3.0`: an unsupported media type is normally skipped by `express.json`, not raised as an `entity.parse.failed`/`entity.too.large` parser error. Validation can still produce the required 400, and the HTTP test should pin it, but the implementation must not depend on the route-local parser error handler seeing this case. This is the one place my review relies on library behavior neither document records as probed. + +## Migration, deployment, and scope + +The plan adequately covers `0001` numbering by main, generated-and-reviewed SQL, migration-before-code documentation, the duplicate pre-check, and the `ACCESS EXCLUSIVE` quiet-window warning. It does not introduce `db:push`. + +No material scope creep was found. The daily alert and the additional real-Postgres session-insert check are reasonable hardening; `update-limits` is already required by the approved specification. + +The highlighted requirements for the one 401 body, relation check with TTL, CORS-before-`corsFor`, pool-level per-user budget, claim equality, and lock/statement timeouts are otherwise retained. + +**REQUEST CHANGES (probe gates; criterion ownership and missing tests; security assertions; durable Sentry record; per-phase green commands; existing-test contradiction)** \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-rebuttals.md new file mode 100644 index 00000000..d44ed2c2 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-plan-iter2-rebuttals.md @@ -0,0 +1,17 @@ +# Plan 329, security architect plan review (codex + claude on 124d115): response + +All items accepted. + +- P1: sign after commit, with the same exp; explicit pool max and connectionTimeoutMillis in createDb; a pool-starvation probe (max=2, 10 concurrent) that also reproduces the hang with signing inside. +- P2: the session token comes from `generateRandomString(32, 'a-z', 'A-Z', '0-9')` (the public `better-auth/crypto` export, same generator family as createSession's generateId); a probe compares it with a createSession token's shape; a test pins length and alphabet. No new dependency, no deep import. +- P3: fail-closed gates before each probe's first consumer (key probes → phase 4; JWT, field, token, lock and pool → phase 5; logger and outage mapping → phase 6). +- P4: AUTH_DEPLOYED_THROUGH bump to 0001 with main's agreement at the rebase, raised in the journal-numbering message; idx [0, 1] and table-list edits. +- P5: per-key minute and day alerts read the key row (requestCount; refillAmount − remaining since lastRefillAt), not auth.rate_limit (pruned hourly); short-window alerts are in-process; a clock-advance test. +- P6: criterion → owner → test table added, with the missing tests codex listed. +- P7: wording aligned (counts, windows and hash prefixes); the missing-constraint refusal logs {name, code}. +- P8: flush with a timeout; on failure, a banner + key log + non-zero exit, with the write kept; spec amended. +- P9: spec "Unchanged" criterion amended to name log-output tests. +- P10: the route checks Content-Type itself; tests for text/plain and a missing content type. +- P11: an apps/api resolve-user test pins the system-only refusal; recorded in arch.md. +- P12: one green command per phase (root turbo test, typecheck and lint, cold at least once); a runnable demo harness for the phase-4 CLI. +- Nits: session updatedAt named; ipAddress and userAgent written as ''; runbook avoids a bare apply line; users.ts header fix in phase 6; two better-call copies noted in the risks. diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index b233cf0c..88fa0446 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -95,7 +95,7 @@ The script refuses `remaining` without a refill: probed above, that configuratio - print nothing secret, except the new key exactly once at creation; - **revoke sets `enabled = false` and keeps the row** (refused and kept, as probed), so the trail survives. Rotate creates a second live key; the old one is revoked once the consumer has switched. -Every create, rotate and revoke also sends a durable Sentry event (service, key id, name, action, no key material), so the record does not live only in a terminal. +Every create, rotate and revoke also sends a durable Sentry event (service, key id, name, action, no key material), so the record does not live only in a terminal. If that event cannot be sent (the flush fails or times out), the write stands, the script prints a loud banner telling the operator to record the key id and action in the runbook's key log, and exits non-zero. **Where the raw key lives.** In exactly two places, briefly in one and durably in the other: - the create script's single output, in the operator's shell inside the auth service; @@ -241,7 +241,7 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA - [ ] The create script refuses `remaining` without a refill, a missing rate limit, values outside the `limits.ts` bounds, and any expiry option. - [ ] The service-user script creates `service-@noreply.askansari.ai` and its `service` account row in one transaction, and refuses an existing service name. - [ ] Rotate leaves two live keys, both verifying. Revoke sets `enabled = false` and keeps the row; the revoked key then fails at the exchange with the 401 body. -- [ ] Create, rotate and revoke each send one Sentry event with service, key id, name and action, and no key material. +- [ ] Create, rotate and revoke each send one Sentry event with service, key id, name and action, and no key material. A failed send keeps the write, prints the key-log banner and exits non-zero. - [ ] Exhausting a refilled budget refuses the key and keeps the row; after the refill interval, the same key verifies again. - [ ] `grant-admin.ts` refuses a `@noreply.askansari.ai` address, as it refuses the system domain. @@ -327,7 +327,7 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA **Unchanged** - [ ] `ROUTER_RULES` are all `false`; no per-IP rule applies to the exchange. -- [ ] Every existing apps/auth, packages/auth and apps/api test passes unedited, except tests that enumerate the blocked paths, the schema or the reserved check, which gain the new entries. +- [ ] Every existing apps/auth, packages/auth and apps/api test passes unedited, except tests that enumerate the blocked paths, the schema or the reserved check (which gain the new entries), and tests that assert better-auth's log output (which change with the redacted logger, each listed in that commit's message). - [ ] No new environment variable. If one appears, it is added to `turbo.json` `globalEnv` by the four-way derivation and reviewed by main. ## Constraints From fde7794933a7031e5dc220805fc130a305acf3c1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:48:00 +1100 Subject: [PATCH 077/247] [Spec 329] Rename the MCP service account to ansari-mcp (owner) --- codev/plans/329-apps-auth-service-api-keys-bet.md | 2 +- codev/specs/329-apps-auth-service-api-keys-bet.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codev/plans/329-apps-auth-service-api-keys-bet.md b/codev/plans/329-apps-auth-service-api-keys-bet.md index a4f2d8e9..97c73a73 100644 --- a/codev/plans/329-apps-auth-service-api-keys-bet.md +++ b/codev/plans/329-apps-auth-service-api-keys-bet.md @@ -216,7 +216,7 @@ The operator can create a service user and issue, list, rotate and revoke its ke #### Files to Create / Modify - `packages/auth/src/limits.ts`: `WHATSAPP_KEY_BOUNDS` and `WHATSAPP_KEY_INITIAL` (see Limit sizing). -- `packages/auth/src/service-users.ts` (new): `SERVICES`, the fixed map from service name to permissions (`whatsapp → { whatsapp: ['mint'] }`; `mcp-faithful-answers → { mcp: ['complete'] }`, reserved, no route yet). `createServiceUser(db, name)` writes the user (`service-@noreply.askansari.ai`, no credential, role `user`, `registered_via = 'service'`) and its `{ providerId: 'service', accountId: name }` row in one transaction. `findServiceUser(db, name)` resolves by the `service` row and reports zero, one or many. +- `packages/auth/src/service-users.ts` (new): `SERVICES`, the fixed map from service name to permissions (`whatsapp → { whatsapp: ['mint'] }`; `ansari-mcp → { mcp: ['complete'] }`, reserved, no route yet). `createServiceUser(db, name)` writes the user (`service-@noreply.askansari.ai`, no credential, role `user`, `registered_via = 'service'`) and its `{ providerId: 'service', accountId: name }` row in one transaction. `findServiceUser(db, name)` resolves by the `service` row and reports zero, one or many. - `apps/auth/scripts/service-keys.ts` (new): one script, subcommands `create-service `, `create-key --rate-max --rate-window --daily [--name] [--client-id]`, `list `, `rotate …`, `update-limits --key --rate-max --rate-window --daily`, `revoke --key `. Each write takes a `--confirm `. - It refuses to run without a database URL. - Show first; write only with a matching `--confirm` (a mismatch writes nothing, exit 2). diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index 88fa0446..8206dbba 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -72,10 +72,10 @@ It ships as migration `0001`, generated, reviewed as SQL, applied by hand before **A service is one auth user**, created only by an operator script: - address `service-@noreply.askansari.ai` (Part 4), no credential, role `user`; - exactly one account row `{ providerId: 'service', accountId: }`, which is how it is resolved (never by email); -- one user per service: `whatsapp` now; later `mcp-faithful-answers`, and the leaderboard if it migrates; +- one user per service: `whatsapp` now; later `ansari-mcp`, and the leaderboard if it migrates; - it cannot sign in: there is no `service` provider in Better Auth, and it has no credential. -**Permissions are fixed per service, in code.** The script takes no free-form permissions. The `whatsapp` service gets `{ whatsapp: ['mint'] }`, and `mcp-faithful-answers` will get `{ mcp: ['complete'] }` (#314). +**Permissions are fixed per service, in code.** The script takes no free-form permissions. The `whatsapp` service gets `{ whatsapp: ['mint'] }`, and `ansari-mcp` will get `{ mcp: ['complete'] }` (#314). **Keys are created in-process with `auth.api.createApiKey`**, server-side, with the service user's id. They are never inserted by hand, so the plugin's own hashing, `permissions` and `metadata` handling apply. `enableMetadata: true`; `startingCharactersConfig.shouldStore: false`. The plan's first probe proves this path: a script-created key verifies at the exchange, its row holds exactly the hash, and no column holds any substring of the raw key. @@ -209,7 +209,7 @@ A second reserved domain, **`@noreply.askansari.ai`** (owner decision 1), for se | Account | Address | How many | |---|---|---| | WhatsApp user | `whatsapp-@noreply.askansari.ai` | one per person who messages Ansari on WhatsApp | -| Service user | `service-@noreply.askansari.ai`, e.g. `service-whatsapp@…`, later `service-mcp-faithful-answers@…` | one per service that holds an API key | +| Service user | `service-@noreply.askansari.ai`, e.g. `service-whatsapp@…`, later `service-ansari-mcp@…` | one per service that holds an API key | Prefixes keep each kind together in listings and logs. The reserved check looks only at the domain, and nothing ever finds a user by email, so the prefix carries no security weight. Ordinary `@askansari.ai` addresses (staff, admins) are not reserved. From 5361e8da232bc75158508fac673512cd0f78ae99 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:52:12 +1100 Subject: [PATCH 078/247] [Spec 329] Scheduled key rotation and secret-scanning patterns (owner) --- codev/plans/329-apps-auth-service-api-keys-bet.md | 11 ++++++++--- codev/specs/329-apps-auth-service-api-keys-bet.md | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/codev/plans/329-apps-auth-service-api-keys-bet.md b/codev/plans/329-apps-auth-service-api-keys-bet.md index 97c73a73..16f618e0 100644 --- a/codev/plans/329-apps-auth-service-api-keys-bet.md +++ b/codev/plans/329-apps-auth-service-api-keys-bet.md @@ -216,13 +216,13 @@ The operator can create a service user and issue, list, rotate and revoke its ke #### Files to Create / Modify - `packages/auth/src/limits.ts`: `WHATSAPP_KEY_BOUNDS` and `WHATSAPP_KEY_INITIAL` (see Limit sizing). -- `packages/auth/src/service-users.ts` (new): `SERVICES`, the fixed map from service name to permissions (`whatsapp → { whatsapp: ['mint'] }`; `ansari-mcp → { mcp: ['complete'] }`, reserved, no route yet). `createServiceUser(db, name)` writes the user (`service-@noreply.askansari.ai`, no credential, role `user`, `registered_via = 'service'`) and its `{ providerId: 'service', accountId: name }` row in one transaction. `findServiceUser(db, name)` resolves by the `service` row and reports zero, one or many. +- `packages/auth/src/service-users.ts` (new): `SERVICES`, the fixed map from service name to permissions and key prefix (`whatsapp → { whatsapp: ['mint'] }, wa_`; `ansari-mcp → { mcp: ['complete'] }, amcp_`, reserved, no route yet). It also exports `SECRET_SCANNING_PATTERNS`, one regex per prefix (`wa_[A-Za-z]{64}`, `amcp_[A-Za-z]{64}`), the patterns the runbook asks the owner to register in GitHub. `createServiceUser(db, name)` writes the user (`service-@noreply.askansari.ai`, no credential, role `user`, `registered_via = 'service'`) and its `{ providerId: 'service', accountId: name }` row in one transaction. `findServiceUser(db, name)` resolves by the `service` row and reports zero, one or many. - `apps/auth/scripts/service-keys.ts` (new): one script, subcommands `create-service `, `create-key --rate-max --rate-window --daily [--name] [--client-id]`, `list `, `rotate …`, `update-limits --key --rate-max --rate-window --daily`, `revoke --key `. Each write takes a `--confirm `. - It refuses to run without a database URL. - Show first; write only with a matching `--confirm` (a mismatch writes nothing, exit 2). - Before any write it prints the database hostname and port only (`urlHost`, already in `scripts/cutover/rehearsal-anomalies.ts`, moved to a shared helper). - It logs the key id acted on. - - `create-key` calls `auth.api.createApiKey` with the service's fixed permissions, prefix `wa_`, no expiry, and the operator's limits validated against `WHATSAPP_KEY_BOUNDS`. It refuses a budget without a refill and offers no expiry flag. It prints the raw key once, framed with the handoff instruction. + - `create-key` calls `auth.api.createApiKey` with the service's fixed permissions and prefix, no expiry, and the operator's limits validated against `WHATSAPP_KEY_BOUNDS`. It refuses a budget without a refill and offers no expiry flag. It prints the raw key once, framed with the handoff instruction. - `revoke` sets `enabled = false` and keeps the row. `rotate` is `create-key`, plus a printed reminder to revoke the old key after the consumer switches. - Each create, rotate, update-limits and revoke sends one Sentry event (service, key id, name, action; no key material). The script initialises Sentry and awaits `Sentry.flush` with a timeout before exit. If the flush fails or times out, the write stands, the script prints a loud banner telling the operator to record the key id and action in the runbook's key log, and exits non-zero so it is noticed. - `apps/auth/tests/service-keys.test.ts` (new); `apps/auth/tests/helpers/` as needed. @@ -242,6 +242,7 @@ The operator can create a service user and issue, list, rotate and revoke its ke - refused inputs: a budget without refill, out-of-bounds limits, any expiry flag, an existing service name; - the Sentry event is captured with no key material, and the script awaits the flush before exit; a failed flush prints the banner and exits non-zero, with the write kept; - per subcommand (`create-service`, `create-key`, `list`, `rotate`, `update-limits`, `revoke`): refuses without a database URL; refuses a mismatched `--confirm`; prints hostname and port only; logs the key id it acted on (where a key is involved); + - each `SECRET_SCANNING_PATTERNS` entry matches a key freshly created for its service, and does not match a 63-letter tail, the other service's prefix or a 32-character session token; - output fields: `list` prints id, name, prefix, permissions, enabled, limits, created and last-request dates per key; `rotate` prints the new key once plus the old key's id; `revoke` prints the key id and `enabled = false`; none prints a key or a hash; - the service user's address and `service` row are created in one transaction (pglite, a forced failure leaves neither). @@ -406,7 +407,9 @@ Prove an exchange token works in apps/api unchanged, prove apps/api has no Whats - the limit values, their judgement-based reasoning, and how to change them (`update-limits`, or revoke-and-recreate); - the pre-apply duplicate check for `(provider_id, account_id)`, with the ACCESS EXCLUSIVE lock note and quiet-window guidance; - the alert meanings, and the eligibility repair procedure; - - the key log: where the operator records key id and action when a script's Sentry flush fails. + - the key log: where the operator records key id and action when a script's Sentry flush fails; + - scheduled rotation every 6 months per key, owned by the operator, with the rotate, switch, revoke steps; + - secret scanning: the owner registers each `SECRET_SCANNING_PATTERNS` entry as a GitHub organisation custom pattern (Settings → Code security → Secret scanning → Custom patterns), and the runbook says what to do when one fires (revoke at once, rotate, check the alerts for use). The runbook must not contain a bare `auth-journal-applied:` line outside backticks: the parity test scans `docs/**` for that record. - `codev/resources/arch.md` (Authentication & Authorization): that apps/api's first-use refusal covers only the system domain, and why WhatsApp users depend on it; service keys, the exchange, the noreply domain, and the alert posture. @@ -446,6 +449,8 @@ Each spec success criterion has exactly one acceptance owner. An earlier probe m | Service user and `service` row in one transaction; duplicate refused | 4 | `service-keys.test.ts` | | Rotate: two live keys; revoke disables and keeps | 4 | `service-keys.test.ts` | | Script Sentry events, no key material; flush failure banner and exit code | 4 | `service-keys.test.ts` | +| Secret-scanning patterns match real keys and reject near misses | 4 | `service-keys.test.ts` | +| Runbook covers scheduled rotation and secret scanning | 7 | review of `docs/cutover/service-keys-runbook.md` | | Refilled exhaustion refuses and keeps, then verifies after the interval | 1 | `tests/config/api-key.test.ts` | | `grant-admin.ts` refuses noreply | 3 | `apps/auth/tests/grant-admin.test.ts` | | Every key refusal → identical 401, no rows | 6 | `apps/auth/tests/whatsapp-session.test.ts` | diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index 8206dbba..8d34abbe 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -321,7 +321,10 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA - revocation (`enabled = false`), and pruning of expired rows (none expected, since production keys have no expiry); - budget sizing (owner decision 3); - the pre-apply check that `auth.account` holds no duplicate `(provider_id, account_id)` before `0001`, noting that adding the constraint takes an ACCESS EXCLUSIVE lock on a table every sign-in reads (apply in a quiet window); - - the alert meanings. + - the alert meanings; + - **scheduled rotation**: every 6 months per key, owned by the operator. Production keys have no expiry, so nothing else forces a rotation; + - **secret scanning**: a GitHub organisation custom secret-scanning pattern per key prefix (e.g. `wa_` followed by 64 letters), so a key committed to any repository in the organisation is flagged. Configuring it is an owner action in GitHub's settings; the runbook gives the exact patterns. +- [ ] Each documented secret-scanning pattern is tested in the repo: it matches a freshly created key of its service, and it does not match a near miss (a 63-letter tail, another service's prefix, a session token). - [ ] `codev/resources/arch.md` (Authentication & Authorization) describes service keys, the exchange, the noreply domain and the alert posture (counts and hash prefixes in Sentry; user ids only in server log lines). - [ ] The runbook gives the repair for an eligibility alert: find the user from the log line, inspect its account rows, role, address and `registered_via`, then remove the stray row or role, or delete the user so the next message recreates them. From 03dd26e5e869cac2824c0f040c02e32cbed24d55 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:54:02 +1100 Subject: [PATCH 079/247] chore(porch): 329 plan-approval gate-approved --- .../projects/329-apps-auth-service-api-keys-bet/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 896ed82e..418ef733 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -10,8 +10,9 @@ gates: requested_at: '2026-10-28T04:42:34.533Z' approved_at: '2026-10-28T05:23:01.015Z' plan-approval: - status: pending + status: approved requested_at: '2026-10-28T05:32:34.891Z' + approved_at: '2026-10-28T05:54:01.986Z' pr: status: pending verify-approval: @@ -20,4 +21,4 @@ iteration: 1 build_complete: true history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T05:32:34.891Z' +updated_at: '2026-10-28T05:54:01.986Z' From 1aa66cf5d9df4ef2a880b24c26e84583b13effaa Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 16:54:08 +1100 Subject: [PATCH 080/247] chore(porch): 329 implement phase-transition --- .../status.yaml | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 418ef733..b0f2b96c 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -1,9 +1,30 @@ id: '329' title: apps-auth-service-api-keys-bet protocol: spir -phase: plan -plan_phases: [] -current_plan_phase: null +phase: implement +plan_phases: + - id: phase_1 + title: Plugin, schema and migration 0001, blocked key surface, library probes + status: in_progress + - id: phase_2 + title: Redacted Better Auth logger + status: pending + - id: phase_3 + title: Reserved noreply domain + status: pending + - id: phase_4 + title: Service users and key operator scripts + status: pending + - id: phase_5 + title: WhatsApp identity and session core + status: pending + - id: phase_6 + title: The exchange route, boot assertion and alerts + status: pending + - id: phase_7 + title: End-to-end contract test, source scan and documentation + status: pending +current_plan_phase: phase_1 gates: spec-approval: status: approved @@ -18,7 +39,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: true +build_complete: false history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T05:54:01.986Z' +updated_at: '2026-10-28T05:54:08.865Z' From 427f8fd397a225bf92c100a6a2ef9fbea0c8c723 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:13:08 +1100 Subject: [PATCH 081/247] [Spec 329][Phase: phase_1] feat: api-key plugin, schema and migration 0001, blocked key surface, library probes --- apps/auth/src/middleware/blocked-paths.ts | 22 +- apps/auth/tests/blocked-paths.test.ts | 47 +- .../329-apps-auth-service-api-keys-bet.md | 49 ++ codev/state/spir-329_thread.md | 11 + packages/auth/drizzle/0001_api_key_phone.sql | 31 + packages/auth/drizzle/meta/0001_snapshot.json | 769 ++++++++++++++++++ packages/auth/drizzle/meta/_journal.json | 7 + packages/auth/package.json | 1 + .../scripts/probes/exchange-lock-probe.ts | 351 ++++++++ packages/auth/src/db.ts | 20 +- packages/auth/src/index.ts | 45 +- packages/auth/src/schema.ts | 55 +- packages/auth/tests/config/api-key.test.ts | 344 ++++++++ packages/auth/tests/config/options.test.ts | 52 +- .../migration/auth-journal-parity.test.ts | 6 +- .../auth/tests/schema/auth-schema.test.ts | 22 +- pnpm-lock.yaml | 64 +- 17 files changed, 1846 insertions(+), 50 deletions(-) create mode 100644 codev/reviews/329-apps-auth-service-api-keys-bet.md create mode 100644 packages/auth/drizzle/0001_api_key_phone.sql create mode 100644 packages/auth/drizzle/meta/0001_snapshot.json create mode 100644 packages/auth/scripts/probes/exchange-lock-probe.ts create mode 100644 packages/auth/tests/config/api-key.test.ts diff --git a/apps/auth/src/middleware/blocked-paths.ts b/apps/auth/src/middleware/blocked-paths.ts index d885dddb..1c66e3e3 100644 --- a/apps/auth/src/middleware/blocked-paths.ts +++ b/apps/auth/src/middleware/blocked-paths.ts @@ -1,5 +1,6 @@ -// Refuses the admin plugin's HTTP surface, the two profile-changing routes and -// change-password before Better Auth sees the request (spec 300, Hosting and token model). +// Refuses the admin plugin's HTTP surface, the api-key plugin's surface (spec 329), +// the two profile-changing routes and change-password before Better Auth sees the +// request (spec 300, Hosting and token model). // // `disabledPaths` in createAuth() covers the same routes, but better-auth // matches it against the exact normalized path only (dist/api/index.mjs:166-168). @@ -8,14 +9,21 @@ // response: 404 with an empty body. import { posix } from 'node:path'; -import { ADMIN_PATHS, BLOCKED_AUTH_PATHS } from '@ansari/auth'; +import { ADMIN_PATHS, API_KEY_PATHS, BLOCKED_AUTH_PATHS } from '@ansari/auth'; import type { NextFunction, Request, Response } from 'express'; const ADMIN_PREFIX = '/api/auth/admin'; -// Every blocked route that is not an admin path, from the one list createAuth() +// The api-key plugin's whole surface (spec 329): keys are only ever created, +// listed or changed by apps/auth's operator script, so every path under the +// prefix is refused, including any endpoint a future plugin version adds. +const API_KEY_PREFIX = '/api/auth/api-key'; +const PREFIXES = [ADMIN_PREFIX, API_KEY_PREFIX]; +// Every blocked route that is not under a prefix, from the one list createAuth() // also passes to `disabledPaths`, so the two cannot drift apart. const BLOCKED_EXACT = new Set( - BLOCKED_AUTH_PATHS.filter((path) => !ADMIN_PATHS.includes(path)).map((path) => `/api/auth${path}`) + BLOCKED_AUTH_PATHS.filter((path) => !ADMIN_PATHS.includes(path) && !API_KEY_PATHS.includes(path)).map( + (path) => `/api/auth${path}` + ) ); /** An absolute-form request target (`GET http://host/api/auth/...`). */ @@ -53,7 +61,9 @@ export function isBlockedPath(rawUrl: string): boolean { const path = canonicalPath(rawUrl); // Malformed encoding is refused rather than passed through undecided. if (path === null) return true; - return path === ADMIN_PREFIX || path.startsWith(`${ADMIN_PREFIX}/`) || BLOCKED_EXACT.has(path); + return ( + PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)) || BLOCKED_EXACT.has(path) + ); } export function blockedPaths(req: Request, res: Response, next: NextFunction): void { diff --git a/apps/auth/tests/blocked-paths.test.ts b/apps/auth/tests/blocked-paths.test.ts index 1b5e4fa7..63af8f06 100644 --- a/apps/auth/tests/blocked-paths.test.ts +++ b/apps/auth/tests/blocked-paths.test.ts @@ -1,11 +1,11 @@ -// The admin plugin's HTTP surface, change-email, update-user and change-password never answer -// (spec 300; scenario 28). The admin endpoints are +// The admin plugin's HTTP surface, the api-key plugin's surface (spec 329), change-email, +// update-user and change-password never answer (spec 300; scenario 28). The admin endpoints are // enumerated from the installed package, so a better-auth upgrade that adds one // is covered without editing this file. Every request carries a VALID admin // session, so a pass cannot come from a missing or non-admin session. import { request } from 'node:http'; -import { BLOCKED_AUTH_PATHS } from '@ansari/auth'; +import { API_KEY_PATHS, BLOCKED_AUTH_PATHS } from '@ansari/auth'; import { admin } from 'better-auth/plugins'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -109,6 +109,45 @@ describe('the admin surface, from the installed package', () => { }); }); +// The api-key plugin's surface (spec 329): keys are created, listed and changed +// only by apps/auth's operator script, so every path under /api/auth/api-key is +// refused, the two server-only endpoints' would-be paths included. +describe('the api-key surface, from the installed package', () => { + const methodOf: Record = { + '/api-key/create': 'POST', + '/api-key/update': 'POST', + '/api-key/delete': 'POST', + '/api-key/get': 'GET', + '/api-key/list': 'GET', + }; + + it('blocks exactly the five routed api-key endpoints, none undefined', () => { + expect([...API_KEY_PATHS].sort()).toEqual(Object.keys(methodOf).sort()); + expect(BLOCKED_AUTH_PATHS).toEqual(expect.arrayContaining([...API_KEY_PATHS])); + expect(BLOCKED_AUTH_PATHS).not.toContain(undefined); + }); + + it('every api-key endpoint, each variant and a suffixed path get the block response', async () => { + for (const path of API_KEY_PATHS) { + const method = methodOf[path] ?? 'POST'; + for (const variant of [...variants(path), `/api/auth${path}/suffix`]) { + await expectBlocked(service.publicUrl, variant, method); + } + } + }); + + it('the server-only endpoints are blocked under the prefix too', async () => { + for (const path of ['/api-key/verify', '/api-key/delete-all-expired-api-keys', '/api-key']) { + await expectBlocked(service.publicUrl, `/api/auth${path}`, 'POST'); + } + }); + + it('a path that only starts with "api-key" is not blocked', () => { + expect(isBlockedPath('/api/auth/api-keys')).toBe(false); + expect(isBlockedPath('/api/auth/api-key-x')).toBe(false); + }); +}); + describe('change-email, update-user and change-password', () => { it.each(['/change-email', '/update-user', '/change-password'])('%s and its variants get the block response', async (path) => { for (const variant of variants(path)) { @@ -180,6 +219,8 @@ describe('canonicalPath', () => { for (const target of [ `http://localhost:${port}/api/auth/admin/list-users`, `http://localhost:${port}/api/auth/change-password`, + `http://localhost:${port}/api/auth/api-key/create`, + `http://localhost:${port}/api/auth/API-KEY/list`, ]) { const status = await new Promise((resolve, reject) => { const req = request( diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md new file mode 100644 index 00000000..8601d92f --- /dev/null +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -0,0 +1,49 @@ +# Review: Service API keys and the WhatsApp token exchange (spec 329) + +> In progress. Probe outputs are recorded here as each phase runs them; the full +> review is written in the review phase. + +## Probe outputs + +### Phase 1: probe 4 (real Postgres), 2026-10-28 + +Run against a throwaway `postgres:18` container on 127.0.0.1 with +`packages/auth/scripts/probes/exchange-lock-probe.ts` (moved from the plan's +`apps/auth/scripts/probes/` because apps/auth has no direct `pg` / `drizzle-orm` +dependency). The `pg` deprecation warning comes from clients left checked out by +the deliberately hung E2 run. + +``` +Probe 4 against Postgres 18.6 (Debian 18.6-1.pgdg13+2) at 127.0.0.1 (2026-10-28T06:04:19.563Z) +PASS A same-transaction session insert under FOR NO KEY UPDATE: committed without waiting; 1 session +PASS B concurrent ban waits for the exchange, then deletes its session: ban waited: true; ban finished after exchange commit: true; sessions left: 0 +PASS C session insert from another connection does not wait on FOR NO KEY UPDATE: insert completed while the lock was held +PASS C' contrast: FOR UPDATE blocks another connection's session insert (the hazard): other connection's insert: 57014 (57014 = cancelled by statement_timeout, i.e. it waited) +PASS D lock_timeout turns a held lock into 55P03: waiting exchange got: 55P03 +(node:76887) DeprecationWarning: Calling client.query() when the client is already executing a query is deprecated and will be removed in pg@9.0. Use async/await or an external async flow control mechanism instead. +(Use `node --trace-deprecation ...` to show where the warning was created) +PASS E1 signing AFTER commit, pool max 2: 10/10 finished +PASS E2 signing INSIDE the transaction, pool max 2, no connect timeout: deadline: the run HUNG (10000 ms), as expected for signing inside +PASS E2 signing INSIDE the transaction, pool max 2, connect timeout 2000 ms: 1/10 finished; failures: Error (loud failure, no hang) +8/8 steps as expected. +``` + +### Phase 1: pinning probes (pglite, `packages/auth/tests/config/api-key.test.ts`) + +All green (16 tests): +- Probe 1: a script-created key verifies; the raw key appears in no column; `start` is null; `key` equals `defaultKeyHasher(raw)`; permissions and metadata round-trip; no expiry. +- Probe 1b: `auth.api.updateApiKey` server-side changes `rateLimitMax`, the window and the refill, enforced at the next verify. +- Probe 2: `auth.api.signJWT` with explicit `{sub, sid, iat, exp}` has the same claim keys, `alg`, `iss`, `aud` and lifetime as a `/api/auth/token` JWT, and honours the passed `exp`. Without `iat`, the claim set lacks `iat`. +- Probe 4a: `phone_number` is absent from the sign-in and get-session responses. Sign-up with `phone_number` is **refused** with 400 `{"message":"phone_number is not allowed to be set","code":"FIELD_NOT_ALLOWED"}` and nothing is stored (the spec allowed refused or ignored). +- Session-token probe: `internalAdapter.createSession` writes a 32-character `[A-Za-z0-9]` token; `generateRandomString(32, 'a-z', 'A-Z', '0-9')` from `better-auth/crypto` produces the same shape (without the explicit alphabet it also emits `-` and `_`). +- Plugin behaviours: no-refill exhaustion deletes the key; refilled exhaustion refuses and keeps it, and verifies after the interval (both cycles); the window limit refuses and keeps; disabled is refused and kept; expired is refused and deleted; a wrong permission spends no budget; a key is never a session at `/get-session` or `/token`. + +**Gates:** before phase 4 (probes 1, 1b) and before phase 5 (probes 2, 4a, session token, probe 4): all match the plan. + +### Phase 1: blocked-path prefix proven load-bearing + +With `API_KEY_PREFIX` removed from the middleware, the variant and absolute-form tests fail (2 failed); restored, all pass. + +### Phase 1: green command, and a pre-existing timeout under combined load + +The plan's green command, run in sequence (`pnpm turbo run test --force`, then `typecheck --force`, then `lint --force`), passes. Running all three as **one** parallel turbo invocation (`turbo run test typecheck lint --force`, 15 tasks at once) times out pglite `beforeAll` hooks at 30 s. It happened twice on this branch, both times in `@ansari/auth`. It also happens on a clean baseline worktree of this branch's HEAD (no code changes), there in `ansari-auth` (`secrets.test.ts`, `cutover/copy.test.ts`). It is the load-induced class `vitest.config.ts` already records (#290/#292), not something this change introduced. This phase's extra pglite file adds load, so CI's task grouping is worth watching. diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index e2af880a..4b2968f2 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -56,3 +56,14 @@ - Decision 3: formulas in P/D/U/N (legacy peak msg/min, daily msgs, DAU, instances), PROVISIONAL values, wide create-script bounds; the data was requested from the owner. - No committed real-Postgres test infra exists (spec 300's Docker boot test was a manual run), so probe 4 and the outage run are manual against postgres:18, with output in the review. - auth-journal-parity asserts a "single 0000" journal and schema tests assert six tables; both change in phase 1. AUTH_DEPLOYED_THROUGH stays main's. + +## 2026-10-28: implement phase 1 (plugin, schema, 0001, blocked surface, probes) + +- Main approved via security: migration tag 0001_api_key_phone (additive only); AUTH_DEPLOYED_THROUGH bumps to 0001 at the rebase, with a second + auth-journal-applied line in RELEASE.md; merge precondition: owner applies 0001 to STAGING before merge, and to PRODUCTION before promote + (duplicate check, apply, verify). Builders cannot afx send architect:main (router refuses); relay via security. +- Probe 4 on real Postgres 18.6: all 8 steps as planned, incl. the FOR UPDATE hazard reproduced (C'), and pool starvation: sign-after-commit 10/10 on a pool of 2, + sign-inside hangs (no connect timeout) or fails loudly (timeout). Output is in codev/reviews/329-*.md. +- Probe 4a surprise (benign): sign-up with phone_number is REFUSED 400 FIELD_NOT_ALLOWED, not silently ignored; nothing stored. +- The probe script lives in packages/auth/scripts/probes (apps/auth has no direct pg/drizzle dep), a deviation from the plan path. +- The prefix rule was proven load-bearing by removing it (2 tests go red). diff --git a/packages/auth/drizzle/0001_api_key_phone.sql b/packages/auth/drizzle/0001_api_key_phone.sql new file mode 100644 index 00000000..5bfe7e9c --- /dev/null +++ b/packages/auth/drizzle/0001_api_key_phone.sql @@ -0,0 +1,31 @@ +CREATE TABLE "auth"."apikey" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "config_id" text DEFAULT 'default' NOT NULL, + "name" text, + "start" text, + "reference_id" uuid NOT NULL, + "prefix" text, + "key" text NOT NULL, + "refill_interval" integer, + "refill_amount" integer, + "last_refill_at" timestamp with time zone, + "enabled" boolean DEFAULT true, + "rate_limit_enabled" boolean DEFAULT true, + "rate_limit_time_window" integer DEFAULT 86400000, + "rate_limit_max" integer DEFAULT 10, + "request_count" integer DEFAULT 0, + "remaining" integer, + "last_request" timestamp with time zone, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + "permissions" text, + "metadata" text +); +--> statement-breakpoint +ALTER TABLE "auth"."users" ADD COLUMN "phone_number" text;--> statement-breakpoint +ALTER TABLE "auth"."apikey" ADD CONSTRAINT "apikey_reference_id_users_id_fk" FOREIGN KEY ("reference_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "apikey_configId_idx" ON "auth"."apikey" USING btree ("config_id");--> statement-breakpoint +CREATE INDEX "apikey_referenceId_idx" ON "auth"."apikey" USING btree ("reference_id");--> statement-breakpoint +CREATE INDEX "apikey_key_idx" ON "auth"."apikey" USING btree ("key");--> statement-breakpoint +ALTER TABLE "auth"."account" ADD CONSTRAINT "account_provider_id_account_id_unique" UNIQUE("provider_id","account_id"); \ No newline at end of file diff --git a/packages/auth/drizzle/meta/0001_snapshot.json b/packages/auth/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..abc2027e --- /dev/null +++ b/packages/auth/drizzle/meta/0001_snapshot.json @@ -0,0 +1,769 @@ +{ + "id": "200c5f19-adaf-4a6c-a74d-41aab3d151d5", + "prevId": "ef46b800-df6b-416d-829e-ca67e0aab294", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.account": { + "name": "account", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_users_id_fk": { + "name": "account_user_id_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_user_id_provider_id_unique": { + "name": "account_user_id_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider_id" + ] + }, + "account_provider_id_account_id_unique": { + "name": "account_provider_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikey": { + "name": "apikey", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_configId_idx": { + "name": "apikey_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikey_reference_id_users_id_fk": { + "name": "apikey_reference_id_users_id_fk", + "tableFrom": "apikey", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwks": { + "name": "jwks", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.rate_limit": { + "name": "rate_limit", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.session": { + "name": "session", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_users_id_fk": { + "name": "session_user_id_users_id_fk", + "tableFrom": "session", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registered_via": { + "name": "registered_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verification": { + "name": "verification", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "auth": "auth" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/auth/drizzle/meta/_journal.json b/packages/auth/drizzle/meta/_journal.json index 89f324b6..94c23398 100644 --- a/packages/auth/drizzle/meta/_journal.json +++ b/packages/auth/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1792130636316, "tag": "0000_auth_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1793166934571, + "tag": "0001_api_key_phone", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/auth/package.json b/packages/auth/package.json index 099e6a2e..274d52c8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@ansari/types": "workspace:*", + "@better-auth/api-key": "1.7.7", "@better-auth/expo": "1.7.7", "bcrypt": "^6.0.0", "better-auth": "1.7.7", diff --git a/packages/auth/scripts/probes/exchange-lock-probe.ts b/packages/auth/scripts/probes/exchange-lock-probe.ts new file mode 100644 index 00000000..9b27f80e --- /dev/null +++ b/packages/auth/scripts/probes/exchange-lock-probe.ts @@ -0,0 +1,351 @@ +/** + * Probe 4 (spec 329, plan phase 1): the WhatsApp exchange's locking and pool + * behaviour against a REAL Postgres. pglite serializes connections, so none of + * this can be shown there. Operator-run, against a throwaway container only: + * + * docker run --rm -d --name probe-pg -e POSTGRES_PASSWORD=probe -p 55432:5432 postgres:18 + * pnpm --filter ansari-auth exec tsx ../../packages/auth/scripts/probes/exchange-lock-probe.ts \ + * postgres://postgres:probe@127.0.0.1:55432/postgres + * + * The URL is an argument, never read from the environment, so this script can + * never be pointed at a real database by an inherited variable. It refuses any + * host other than 127.0.0.1 or localhost. It drops and recreates the `auth` + * schema from the journal. + * + * Every step runs under a deadline and reports PASS or FAIL; it never hangs. + * The output is pasted into the review (codev/reviews/329-*.md). + */ +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { generateRandomString } from 'better-auth/crypto'; +import { sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Client, Pool } from 'pg'; + +import { createAuth, resetEnvCache, type Db } from '../../src/index'; +import * as schema from '../../src/schema'; + +const DRIZZLE_DIR = fileURLToPath(new URL('../../drizzle/', import.meta.url)); +const DEADLINE_MS = 10_000; + +type Result = { step: string; ok: boolean; detail: string }; +const results: Result[] = []; + +function record(step: string, ok: boolean, detail: string): void { + results.push({ step, ok, detail }); + console.log(`${ok ? 'PASS' : 'FAIL'} ${step}: ${detail}`); +} + +class Deadline extends Error {} + +async function within(ms: number, work: Promise): Promise { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Deadline(`no result within ${ms} ms`)), ms); + }); + try { + return await Promise.race([work, deadline]); + } finally { + clearTimeout(timer); + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function errCode(err: unknown): string { + if (err instanceof Deadline) return 'DEADLINE'; + const e = err as { code?: unknown; name?: unknown; message?: unknown }; + return String(e.code ?? e.name ?? 'unknown'); +} + +async function connect(url: string): Promise { + const client = new Client({ connectionString: url }); + await client.connect(); + return client; +} + +async function resetSchema(url: string): Promise { + const client = await connect(url); + try { + await client.query('DROP SCHEMA IF EXISTS auth CASCADE'); + const journal = JSON.parse(readFileSync(`${DRIZZLE_DIR}meta/_journal.json`, 'utf8')) as { + entries: { idx: number; tag: string }[]; + }; + for (const { tag } of [...journal.entries].sort((a, b) => a.idx - b.idx)) { + const text = readFileSync(`${DRIZZLE_DIR}${tag}.sql`, 'utf8'); + for (const stmt of text.split('--> statement-breakpoint')) if (stmt.trim()) await client.query(stmt); + } + } finally { + await client.end(); + } +} + +async function newUser(client: Client): Promise { + const id = randomUUID(); + await client.query(`INSERT INTO auth.users (id, name, email) VALUES ($1, 'Probe', $2)`, [ + id, + `whatsapp-${id}@noreply.askansari.ai`, + ]); + return id; +} + +const INSERT_SESSION = `INSERT INTO auth.session (token, expires_at, updated_at, user_id, ip_address, user_agent) + VALUES ($1, now() + interval '30 days', now(), $2, '', '')`; +const token = () => generateRandomString(32, 'a-z', 'A-Z', '0-9'); + +async function sessionCount(client: Client, userId: string): Promise { + const { rows } = await client.query<{ n: number }>( + 'SELECT count(*)::int AS n FROM auth.session WHERE user_id = $1', + [userId] + ); + return rows[0]!.n; +} + +/** A: the exchange's own session insert, inside its locking transaction, does not wait. */ +async function stepSameTransaction(url: string): Promise { + const step = 'A same-transaction session insert under FOR NO KEY UPDATE'; + const a = await connect(url); + try { + const userId = await newUser(a); + await within(DEADLINE_MS, (async () => { + await a.query('BEGIN'); + await a.query('SELECT id FROM auth.users WHERE id = $1 FOR NO KEY UPDATE', [userId]); + await a.query(INSERT_SESSION, [token(), userId]); + await a.query('COMMIT'); + })()); + record(step, (await sessionCount(a, userId)) === 1, 'committed without waiting; 1 session'); + } catch (err) { + record(step, false, errCode(err)); + } finally { + await a.end(); + } +} + +/** B: a ban on another connection waits for the exchange, then deletes its session. */ +async function stepBanWaits(url: string): Promise { + const step = 'B concurrent ban waits for the exchange, then deletes its session'; + const a = await connect(url); + const b = await connect(url); + try { + const userId = await newUser(a); + await a.query('BEGIN'); + await a.query('SELECT id FROM auth.users WHERE id = $1 FOR NO KEY UPDATE', [userId]); + let banDoneAt = 0; + const ban = (async () => { + await b.query('BEGIN'); + await b.query('UPDATE auth.users SET banned = true WHERE id = $1', [userId]); + await b.query('DELETE FROM auth.session WHERE user_id = $1', [userId]); + await b.query('COMMIT'); + banDoneAt = Date.now(); + })(); + await sleep(300); + const waitedWhileLocked = banDoneAt === 0; + await a.query(INSERT_SESSION, [token(), userId]); + await a.query('COMMIT'); + const exchangeCommittedAt = Date.now(); + await within(DEADLINE_MS, ban); + const left = await sessionCount(a, userId); + record( + step, + waitedWhileLocked && banDoneAt >= exchangeCommittedAt && left === 0, + `ban waited: ${waitedWhileLocked}; ban finished after exchange commit: ${banDoneAt >= exchangeCommittedAt}; sessions left: ${left}` + ); + } catch (err) { + record(step, false, errCode(err)); + } finally { + await a.end(); + await b.end(); + } +} + +/** C: a session insert from ANOTHER connection is not blocked (FK KEY SHARE vs NO KEY UPDATE). */ +async function stepOtherConnectionInsert(url: string): Promise { + const step = 'C session insert from another connection does not wait on FOR NO KEY UPDATE'; + const a = await connect(url); + const b = await connect(url); + try { + const userId = await newUser(a); + await a.query('BEGIN'); + await a.query('SELECT id FROM auth.users WHERE id = $1 FOR NO KEY UPDATE', [userId]); + await within(2_000, b.query(INSERT_SESSION, [token(), userId])); + await a.query('COMMIT'); + record(step, true, 'insert completed while the lock was held'); + } catch (err) { + await a.query('ROLLBACK').catch(() => undefined); + record(step, false, errCode(err)); + } finally { + await a.end(); + await b.end().catch(() => undefined); + } +} + +/** C': contrast, FOR UPDATE (the strong lock) DOES block the other connection's insert. */ +async function stepForUpdateBlocks(url: string): Promise { + const step = "C' contrast: FOR UPDATE blocks another connection's session insert (the hazard)"; + const a = await connect(url); + const b = await connect(url); + try { + const userId = await newUser(a); + await a.query('BEGIN'); + await a.query('SELECT id FROM auth.users WHERE id = $1 FOR UPDATE', [userId]); + await b.query("SET statement_timeout = '1500ms'"); + let code = 'none'; + try { + await within(5_000, b.query(INSERT_SESSION, [token(), userId])); + } catch (err) { + code = errCode(err); + } + await a.query('ROLLBACK'); + record(step, code === '57014', `other connection's insert: ${code} (57014 = cancelled by statement_timeout, i.e. it waited)`); + } catch (err) { + record(step, false, errCode(err)); + } finally { + await a.end(); + await b.end(); + } +} + +/** D: lock_timeout turns a held row lock into 55P03, not a hang. */ +async function stepLockTimeout(url: string): Promise { + const step = 'D lock_timeout turns a held lock into 55P03'; + const a = await connect(url); + const b = await connect(url); + try { + const userId = await newUser(a); + await a.query('BEGIN'); + await a.query('SELECT id FROM auth.users WHERE id = $1 FOR UPDATE', [userId]); + await b.query('BEGIN'); + await b.query("SET LOCAL lock_timeout = '500ms'"); + let code = 'none'; + try { + await within(5_000, b.query('SELECT id FROM auth.users WHERE id = $1 FOR NO KEY UPDATE', [userId])); + } catch (err) { + code = errCode(err); + } + await b.query('ROLLBACK'); + await a.query('ROLLBACK'); + record(step, code === '55P03', `waiting exchange got: ${code}`); + } catch (err) { + record(step, false, errCode(err)); + } finally { + await a.end(); + await b.end(); + } +} + +function setProbeEnv(url: string): void { + process.env.DATABASE_URL = url; + process.env.BETTER_AUTH_SECRET = 'probe-secret-at-least-32-characters-long-00'; + process.env.BETTER_AUTH_URL = 'http://localhost:3100'; + process.env.CORS_ORIGIN = 'http://localhost:3100'; + process.env.AUTH_LAYER_ORIGIN = 'https://api.probe.example'; + process.env.NODE_ENV = 'test'; + process.env.AUTH_JWT_ISSUER = 'https://auth.probe.example'; + process.env.AUTH_JWT_AUDIENCE = 'ansari-api-probe'; + process.env.RESEND_API_KEY = 're_probe_key_at_least_20_chars'; + process.env.FRONTEND_URL = 'http://localhost:8081'; + resetEnvCache(); +} + +/** + * E: pool starvation. 10 concurrent exchanges against a pool of 2 connections. + * Each locks its user, writes a session and commits, then signs with the real + * `auth.api.signJWT` (which reads auth.jwks through the pool). With signing + * OUTSIDE the transaction all finish. With signing INSIDE, and no connect + * timeout, each transaction holds a connection while waiting for another: the + * deadline fires, which is the hang. With a connect timeout it fails loudly. + */ +async function stepPool(url: string, signInside: boolean, connectTimeout: number | undefined): Promise { + const label = signInside + ? `E2 signing INSIDE the transaction, pool max 2${connectTimeout ? `, connect timeout ${connectTimeout} ms` : ', no connect timeout'}` + : 'E1 signing AFTER commit, pool max 2'; + const pool = new Pool({ connectionString: url, max: 2, connectionTimeoutMillis: connectTimeout }); + pool.on('error', () => undefined); + const db = drizzle({ client: pool, schema }) as unknown as Db; + setProbeEnv(url); + const auth = createAuth(db); + const seed = await connect(url); + const users = await Promise.all(Array.from({ length: 10 }, () => newUser(seed))); + await seed.end(); + // Mint the signing key once, so every exchange only reads it. + await auth.api.signJWT({ body: { payload: { sub: users[0], sid: randomUUID() } } }); + + const exchange = async (userId: string) => { + const iat = Math.floor(Date.now() / 1000); + const exp = iat + 15 * 60; + let sid = ''; + let signed = ''; + await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '2s'`); + await tx.execute(sql`SELECT id FROM auth.users WHERE id = ${userId} FOR NO KEY UPDATE`); + const inserted = await tx.execute<{ id: string }>( + sql`INSERT INTO auth.session (token, expires_at, updated_at, user_id, ip_address, user_agent) + VALUES (${token()}, now() + interval '30 days', now(), ${userId}, '', '') RETURNING id` + ); + sid = inserted.rows[0]!.id; + if (signInside) { + signed = (await auth.api.signJWT({ body: { payload: { sub: userId, sid, iat, exp } } })).token; + } + }); + if (!signInside) { + signed = (await auth.api.signJWT({ body: { payload: { sub: userId, sid, iat, exp } } })).token; + } + return signed; + }; + + const settled = await within( + DEADLINE_MS, + Promise.allSettled(users.map((u) => exchange(u))) + ).catch((err: unknown) => err); + + if (settled instanceof Deadline) { + record(label, signInside, `deadline: the run HUNG (${DEADLINE_MS} ms)${signInside ? ', as expected for signing inside' : ''}`); + } else if (Array.isArray(settled)) { + const ok = settled.filter((s) => s.status === 'fulfilled').length; + const codes = settled + .filter((s): s is PromiseRejectedResult => s.status === 'rejected') + .map((s) => errCode(s.reason)); + const detail = `${ok}/10 finished${codes.length ? `; failures: ${[...new Set(codes)].join(', ')}` : ''}`; + if (signInside) record(label, connectTimeout !== undefined && codes.length > 0, `${detail} (loud failure, no hang)`); + else record(label, ok === 10, detail); + } else { + record(label, false, errCode(settled)); + } + // A hung run leaves clients checked out; do not wait for them. + void pool.end().catch(() => undefined); +} + +async function main(): Promise { + const url = process.argv[2]; + if (!url) { + console.error('Usage: exchange-lock-probe.ts '); + process.exit(2); + } + const host = new URL(url).hostname; + if (host !== '127.0.0.1' && host !== 'localhost') { + console.error(`Refusing to run against ${host}: this probe drops the auth schema. Local containers only.`); + process.exit(2); + } + const server = await connect(url); + const { rows } = await server.query<{ server_version: string }>('SHOW server_version'); + await server.end(); + console.log(`Probe 4 against Postgres ${rows[0]!.server_version} at ${host} (${new Date().toISOString()})`); + + await resetSchema(url); + await stepSameTransaction(url); + await stepBanWaits(url); + await stepOtherConnectionInsert(url); + await stepForUpdateBlocks(url); + await stepLockTimeout(url); + await stepPool(url, false, undefined); + await stepPool(url, true, undefined); + await stepPool(url, true, 2_000); + + const failed = results.filter((r) => !r.ok); + console.log(`\n${results.length - failed.length}/${results.length} steps as expected.`); + process.exit(failed.length === 0 ? 0 : 1); +} + +void main(); diff --git a/packages/auth/src/db.ts b/packages/auth/src/db.ts index 679a5680..5e89eb05 100644 --- a/packages/auth/src/db.ts +++ b/packages/auth/src/db.ts @@ -26,8 +26,26 @@ export function onPoolError(err: Error & { code?: string }): void { console.error('auth database: idle client error', { name: err.name, code: err.code }); } +/** + * pg's default pool size, written down so it is a decision (spec 329). The + * pool is shared by every route: sign-in, the JWKS, the account endpoints and + * the WhatsApp exchange. + */ +export const AUTH_POOL_MAX = 10; + +/** + * How long a query waits for a free pooled connection before failing (pg's + * default is forever). A starved pool is then a loud error, answered as a 500, + * never an unbounded wait during which no statement or lock timeout can fire. + */ +export const AUTH_POOL_CONNECT_TIMEOUT_MS = 5_000; + export function createDb() { - const pool = new Pool({ connectionString: getEnv().DATABASE_URL }); + const pool = new Pool({ + connectionString: getEnv().DATABASE_URL, + max: AUTH_POOL_MAX, + connectionTimeoutMillis: AUTH_POOL_CONNECT_TIMEOUT_MS, + }); pool.on('error', onPoolError); return drizzle({ client: pool, schema }); } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 9588fe7b..6fdbf09b 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -5,6 +5,7 @@ // Every setting below that the cutover depends on is pinned by a test in // tests/ that fails when the setting is removed. import { CLIENT_HEADER, CLIENT_SRC_PARAM, parseClientId } from '@ansari/types/attribution'; +import { apiKey } from '@better-auth/api-key'; import { expo } from '@better-auth/expo'; import { APIError, BASE_ERROR_CODES, betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; @@ -129,9 +130,10 @@ export type AuthExtensions = { }; /** - * Router paths that never answer: the whole admin plugin surface, the two - * profile-changing routes, and change-password (spec 300). The plugin stays - * only for its `role` and ban columns. No client changes a password while + * Router paths that never answer: the whole admin plugin surface, the + * api-key plugin's routes (spec 329), the two profile-changing routes, and + * change-password (spec 300). The admin plugin stays only for its `role` and + * ban columns. No client changes a password while * signed in, and Better Auth's route would skip the strength guard and keep * the other sessions alive; a future feature must add both. better-auth * matches `disabledPaths` exactly, so apps/auth also blocks every variant of @@ -140,8 +142,34 @@ export type AuthExtensions = { export const ADMIN_PATHS: readonly string[] = Object.values(admin().endpoints).map( (endpoint) => endpoint.path ); + +/** + * The api-key plugin's options (spec 329). Keys are created and verified only + * in process (apps/auth's operator script and its exchange route); its HTTP + * surface is blocked below. A key is never a session (`enableSessionForAPIKeys` + * stays false), the first characters of a key are not stored, and metadata + * carries the partner's client id. + */ +export const API_KEY_OPTIONS = { + enableMetadata: true, + enableSessionForAPIKeys: false, + startingCharactersConfig: { shouldStore: false }, + rateLimit: { enabled: true }, +} as const; + +/** + * The api-key plugin's routed endpoints. Two of its seven endpoints + * (`verifyApiKey`, `deleteAllExpiredApiKeys`) are server-only and have no + * path, so they are filtered out rather than mapped to `undefined`. + */ +export const API_KEY_ENDPOINT_COUNT = Object.keys(apiKey(API_KEY_OPTIONS).endpoints).length; +export const API_KEY_PATHS: readonly string[] = Object.values(apiKey(API_KEY_OPTIONS).endpoints) + .map((endpoint) => (endpoint as { path?: string }).path) + .filter((path): path is string => typeof path === 'string'); + export const BLOCKED_AUTH_PATHS: readonly string[] = [ ...ADMIN_PATHS, + ...API_KEY_PATHS, '/change-email', '/update-user', '/change-password', @@ -222,6 +250,15 @@ export function createAuth(db: Db = createDb(), extensions: AuthExtensions = {}) input: false, fieldName: 'registeredVia', }, + // WhatsApp users only, written directly by the token exchange (spec + // 329). PII: no Better Auth path accepts or returns it. + phone_number: { + type: 'string', + required: false, + input: false, + returned: false, + fieldName: 'phoneNumber', + }, }, // The profile copy in the app database cannot follow an email change // until a sync design exists (spec 300 follow-up), so it is off. @@ -358,6 +395,8 @@ export function createAuth(db: Db = createDb(), extensions: AuthExtensions = {}) // Kept for its `role` and ban columns; its HTTP surface is blocked in // apps/auth (spec 300). admin(), + // Service keys (spec 329); HTTP surface blocked, see API_KEY_PATHS. + apiKey(API_KEY_OPTIONS), rateLimitRetention, ], }); diff --git a/packages/auth/src/schema.ts b/packages/auth/src/schema.ts index 25c35e7c..6d4158bd 100644 --- a/packages/auth/src/schema.ts +++ b/packages/auth/src/schema.ts @@ -4,14 +4,18 @@ // UUIDs and shares no Drizzle code with this module. // // Shapes follow better-auth 1.7.7's own generator for this configuration -// (`generateId: 'uuid'`, `user.modelName: 'users'`, the jwt and admin plugins, -// database rate-limit storage, and the additional user fields), with three -// deliberate departures: +// (`generateId: 'uuid'`, `user.modelName: 'users'`, the jwt, admin and api-key +// plugins, database rate-limit storage, and the additional user fields), with +// these deliberate departures: // - every table lives in `pgSchema('auth')`, not `public`; // - every timestamp is `timestamptz`, because the legacy `public.users` // values being copied in are `timestamptz` (success criterion, scenario 12); // - `account` carries `unique(user_id, provider_id)`, so a user has at most -// one credential row. +// one credential row, and `unique(provider_id, account_id)`, so a provider +// id (a WhatsApp BSUID) maps to exactly one user under concurrency (spec 329); +// - `apikey.reference_id` is a uuid referencing `users` with cascade delete, +// where the plugin declares a plain string: a key always belongs to a +// service user (spec 329). // Ids are `uuid` with a `gen_random_uuid()` default: the pg Drizzle adapter // reports `supportsUUIDs`, so better-auth leaves id generation to the database, // and the copy inserts the legacy UUIDs explicitly. @@ -60,6 +64,10 @@ export const users = authSchema.table('users', { firstName: text('first_name'), lastName: text('last_name'), registeredVia: text('registered_via'), + // WhatsApp users only, written by the token exchange (spec 329). PII: + // never input, never returned by Better Auth (`input: false`, + // `returned: false` in createAuth) and absent from /api/account/me. + phoneNumber: text('phone_number'), }); export const session = authSchema.table( @@ -107,6 +115,7 @@ export const account = authSchema.table( (table) => [ index('account_userId_idx').on(table.userId), unique('account_user_id_provider_id_unique').on(table.userId, table.providerId), + unique('account_provider_id_account_id_unique').on(table.providerId, table.accountId), ] ); @@ -145,6 +154,44 @@ export const rateLimit = authSchema.table('rate_limit', { lastRequest: bigint('last_request', { mode: 'number' }).notNull(), }); +// api-key plugin (spec 329). Keys are created only by apps/auth's operator +// script; `key` holds the SHA-256 hash, never the key itself. Defaults are the +// plugin's own (one configuration: rate limit on, 10 per day). +export const apikey = authSchema.table( + 'apikey', + { + id: uuidPk(), + configId: text('config_id').default('default').notNull(), + name: text('name'), + start: text('start'), + referenceId: uuid('reference_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + prefix: text('prefix'), + key: text('key').notNull(), + refillInterval: integer('refill_interval'), + refillAmount: integer('refill_amount'), + lastRefillAt: tstz('last_refill_at'), + enabled: boolean('enabled').default(true), + rateLimitEnabled: boolean('rate_limit_enabled').default(true), + rateLimitTimeWindow: integer('rate_limit_time_window').default(86400000), + rateLimitMax: integer('rate_limit_max').default(10), + requestCount: integer('request_count').default(0), + remaining: integer('remaining'), + lastRequest: tstz('last_request'), + expiresAt: tstz('expires_at'), + createdAt: tstz('created_at').notNull(), + updatedAt: tstz('updated_at').notNull(), + permissions: text('permissions'), + metadata: text('metadata'), + }, + (table) => [ + index('apikey_configId_idx').on(table.configId), + index('apikey_referenceId_idx').on(table.referenceId), + index('apikey_key_idx').on(table.key), + ] +); + export const usersRelations = relations(users, ({ many }) => ({ sessions: many(session), accounts: many(account), diff --git a/packages/auth/tests/config/api-key.test.ts b/packages/auth/tests/config/api-key.test.ts new file mode 100644 index 00000000..155402c8 --- /dev/null +++ b/packages/auth/tests/config/api-key.test.ts @@ -0,0 +1,344 @@ +// Service API keys (spec 329, plan phase 1): the @better-auth/api-key 1.7.7 +// behaviours the later phases build on, pinned against a real (pglite) +// database built from the auth journal. Each block names the plan probe it +// pins; a plugin bump that changes one fails here before anything depends on it. +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import type { PGlite } from '@electric-sql/pglite'; +import { defaultKeyHasher } from '@better-auth/api-key'; +import { generateRandomString } from 'better-auth/crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createAuth, type Auth, type Db } from '../../src/index'; +import { drizzleFor, migrate, setAuthEnv } from '../helpers/auth-db'; +import { BASE, call, decodeJwt, json, mintJwt, signIn } from '../helpers/http'; + +let client: PGlite; +let db: Db; +let auth: Auth; + +const WHATSAPP = { whatsapp: ['mint'] }; +const MCP = { mcp: ['complete'] }; + +beforeAll(async () => { + setAuthEnv(BASE); + client = await migrate(); + db = drizzleFor(client); + auth = createAuth(db); +}); + +afterAll(async () => { + await client?.close(); +}); + +/** A bare service-like user: no credential, as the phase-4 script will create. */ +async function serviceUser(): Promise { + const id = randomUUID(); + await client.query( + `INSERT INTO auth.users (id, name, email) VALUES ($1, 'svc', $2)`, + [id, `service-${id.slice(0, 8)}@noreply.askansari.ai`] + ); + return id; +} + +type KeyBody = Record; + +async function createKey(userId: string, body: KeyBody = {}) { + return auth.api.createApiKey({ + body: { + userId, + prefix: 'wa_', + name: 'test', + permissions: WHATSAPP, + rateLimitEnabled: false, + ...body, + }, + }); +} + +async function verify(key: string, permissions: Record = WHATSAPP) { + return auth.api.verifyApiKey({ body: { key, permissions } }); +} + +async function keyRow(id: string) { + const { rows } = await client.query>( + `SELECT * FROM auth.apikey WHERE id = $1`, + [id] + ); + return rows[0]; +} + +const count = async (id: string) => + (await client.query<{ n: number }>(`SELECT count(*)::int AS n FROM auth.apikey WHERE id = $1`, [id])) + .rows[0]!.n; + +describe('version pin', () => { + it('pins @better-auth/api-key to exactly the better-auth version', () => { + const pkg = JSON.parse( + readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8') + ) as { dependencies: Record }; + expect(pkg.dependencies['@better-auth/api-key']).toBe('1.7.7'); + expect(pkg.dependencies['better-auth']).toBe('1.7.7'); + }); +}); + +describe('probe 1: a key created in process', () => { + it('verifies, and its row holds only the hash: no raw key in any column, start null', async () => { + const userId = await serviceUser(); + const created = await createKey(userId, { + metadata: { clientId: 'whatsapp' }, + rateLimitEnabled: true, + rateLimitTimeWindow: 60_000, + rateLimitMax: 60, + remaining: 3000, + refillAmount: 3000, + refillInterval: 86_400_000, + }); + expect(created.key.startsWith('wa_')).toBe(true); + expect(created.key).toMatch(/^wa_[A-Za-z]{64}$/); + + const result = await verify(created.key); + expect(result.valid).toBe(true); + expect(result.key?.referenceId).toBe(userId); + + const row = await keyRow(created.id); + expect(row).toBeDefined(); + for (const [column, value] of Object.entries(row!)) { + if (typeof value === 'string') expect(value, column).not.toContain(created.key); + } + expect(row!.start).toBeNull(); + expect(row!.key).toBe(await defaultKeyHasher(created.key)); + expect(JSON.parse(row!.permissions as string)).toEqual(WHATSAPP); + expect(JSON.parse(row!.metadata as string)).toEqual({ clientId: 'whatsapp' }); + expect(row!.rate_limit_max).toBe(60); + expect(row!.refill_amount).toBe(3000); + expect(row!.expires_at).toBeNull(); + }); +}); + +describe('probe 1b: limits changed on a live key', () => { + it('updateApiKey changes the window cap and the refill, enforced at the next verify', async () => { + const userId = await serviceUser(); + const created = await createKey(userId, { + rateLimitEnabled: true, + rateLimitTimeWindow: 60_000, + rateLimitMax: 1, + }); + expect((await verify(created.key)).valid).toBe(true); + expect((await verify(created.key)).error?.code).toBe('RATE_LIMITED'); + + await auth.api.updateApiKey({ + body: { + keyId: created.id, + userId, + rateLimitMax: 5, + rateLimitTimeWindow: 60_000, + remaining: 100, + refillAmount: 100, + refillInterval: 86_400_000, + }, + }); + const row = await keyRow(created.id); + expect(row!.rate_limit_max).toBe(5); + expect(row!.refill_amount).toBe(100); + expect((await verify(created.key)).valid).toBe(true); + }); +}); + +describe('the plugin behaviours the exchange relies on', () => { + it('an unknown key is valid:false', async () => { + const result = await verify(`wa_${'x'.repeat(64)}`); + expect(result.valid).toBe(false); + }); + + it('a wrong permission is refused and spends no budget', async () => { + const userId = await serviceUser(); + const created = await createKey(userId, { permissions: MCP, remaining: 5 }); + const result = await verify(created.key, WHATSAPP); + expect(result.valid).toBe(false); + expect((await keyRow(created.id))!.remaining).toBe(5); + }); + + it('a budget with NO refill deletes the key once exhausted (why the script refuses it)', async () => { + const userId = await serviceUser(); + const created = await createKey(userId, { remaining: 1 }); + expect((await verify(created.key)).valid).toBe(true); + const exhausted = await verify(created.key); + expect(exhausted.valid).toBe(false); + expect(exhausted.error?.code).toBe('USAGE_EXCEEDED'); + expect(await count(created.id)).toBe(0); + }); + + it('a refilled budget refuses and keeps the key, then verifies after the interval', async () => { + const userId = await serviceUser(); + const created = await createKey(userId, { remaining: 1, refillAmount: 1, refillInterval: 1000 }); + expect((await verify(created.key)).valid).toBe(true); + const exhausted = await verify(created.key); + expect(exhausted.valid).toBe(false); + expect(exhausted.error?.code).toBe('USAGE_EXCEEDED'); + expect(await count(created.id)).toBe(1); + + // The first refill starts from a null last_refill_at (created_at is used). + await client.query( + `UPDATE auth.apikey SET created_at = now() - interval '1 hour' WHERE id = $1`, + [created.id] + ); + expect((await verify(created.key)).valid).toBe(true); + // A later cycle, from a set last_refill_at. + await client.query( + `UPDATE auth.apikey SET remaining = 0, last_refill_at = now() - interval '1 hour' WHERE id = $1`, + [created.id] + ); + expect((await verify(created.key)).valid).toBe(true); + }); + + it('the window rate limit refuses and keeps the key', async () => { + const userId = await serviceUser(); + const created = await createKey(userId, { + rateLimitEnabled: true, + rateLimitTimeWindow: 60_000, + rateLimitMax: 2, + }); + expect((await verify(created.key)).valid).toBe(true); + expect((await verify(created.key)).valid).toBe(true); + const limited = await verify(created.key); + expect(limited.valid).toBe(false); + expect(limited.error?.code).toBe('RATE_LIMITED'); + expect(await count(created.id)).toBe(1); + }); + + it('a disabled key is refused and kept (revoke)', async () => { + const userId = await serviceUser(); + const created = await createKey(userId); + await client.query(`UPDATE auth.apikey SET enabled = false WHERE id = $1`, [created.id]); + expect((await verify(created.key)).valid).toBe(false); + expect(await count(created.id)).toBe(1); + }); + + it('an expired key is refused and deleted', async () => { + const userId = await serviceUser(); + const created = await createKey(userId); + await client.query( + `UPDATE auth.apikey SET expires_at = now() - interval '1 minute' WHERE id = $1`, + [created.id] + ); + const result = await verify(created.key); + expect(result.valid).toBe(false); + expect(result.error?.code).toBe('KEY_EXPIRED'); + expect(await count(created.id)).toBe(0); + }); +}); + +describe('a key is never a session (enableSessionForAPIKeys: false)', () => { + it('a valid key as x-api-key or as a bearer yields no session and no token', async () => { + const userId = await serviceUser(); + const created = await createKey(userId); + for (const headers of [{ 'x-api-key': created.key }, { authorization: `Bearer ${created.key}` }]) { + const session = await call(auth, '/get-session', { headers }); + expect(session.status).toBe(200); + expect(await session.json()).toBeNull(); + const token = await call(auth, '/token', { headers }); + expect(token.status).toBe(401); + } + }); + + it('the plugin routes are disabled on the router', async () => { + const userId = await serviceUser(); + for (const path of ['/api-key/create', '/api-key/update', '/api-key/delete']) { + const res = await call(auth, path, { body: { userId, keyId: randomUUID() } }); + expect(res.status, path).toBe(404); + } + for (const path of ['/api-key/get', '/api-key/list']) { + expect((await call(auth, path)).status, path).toBe(404); + } + }); +}); + +describe('probe 2: signJWT with explicit claims matches /api/auth/token', () => { + it('the claim sets agree (keys, alg, iss, aud, lifetime) and the passed exp is honoured', async () => { + const email = `jwt-${randomUUID().slice(0, 8)}@example.com`; + const password = 'Probe-Passw0rd!'; + await auth.api.signUpEmail({ body: { name: 'Probe', email, password } }); + const { token: sessionToken } = await signIn(auth, email, password); + const reference = decodeJwt((await json<{ token: string }>(await mintJwt(auth, sessionToken!))).token); + + const { rows } = await client.query<{ id: string; user_id: string }>( + `SELECT id, user_id FROM auth.session WHERE token = $1`, + [sessionToken] + ); + const { id: sid, user_id: sub } = rows[0]!; + const iat = Math.floor(Date.now() / 1000); + const exp = iat + 15 * 60; + const signed = await auth.api.signJWT({ body: { payload: { sub, sid, iat, exp } } }); + const exchange = decodeJwt(signed.token); + + expect(Object.keys(exchange.payload).sort()).toEqual(Object.keys(reference.payload).sort()); + expect(exchange.header.alg).toBe(reference.header.alg); + expect(exchange.payload.iss).toBe(reference.payload.iss); + expect(exchange.payload.aud).toBe(reference.payload.aud); + expect(Number(exchange.payload.exp) - Number(exchange.payload.iat)).toBe( + Number(reference.payload.exp) - Number(reference.payload.iat) + ); + expect(exchange.payload.exp).toBe(exp); + expect(exchange.payload.iat).toBe(iat); + }); + + it('without iat the claim sets differ (why the exchange passes it)', async () => { + const signed = await auth.api.signJWT({ + body: { payload: { sub: randomUUID(), sid: randomUUID() } }, + }); + expect(Object.keys(decodeJwt(signed.token).payload)).not.toContain('iat'); + }); +}); + +describe('probe 4a: phone_number is never input and never returned', () => { + it('is absent from sign-in and get-session responses, and ignored on sign-up', async () => { + const email = `p4a-${randomUUID().slice(0, 8)}@example.com`; + const password = 'Phone-Passw0rd!'; + const signUp = await call(auth, '/sign-up/email', { + body: { name: 'Probe', email, password, phone_number: '15551234567' }, + }); + const { rows } = await client.query<{ id: string; phone_number: string | null }>( + `SELECT id, phone_number FROM auth.users WHERE email = $1`, + [email] + ); + // Refused outright or created without it: either way, never stored. + expect(rows[0]?.phone_number ?? null).toBeNull(); + if (signUp.status !== 200) { + expect(signUp.status).toBe(400); + await auth.api.signUpEmail({ body: { name: 'Probe', email, password } }); + } + + await client.query(`UPDATE auth.users SET phone_number = '15551234567' WHERE email = $1`, [email]); + const signedIn = await signIn(auth, email, password); + const signInBody = await signedIn.res.text(); + expect(signInBody).toContain(email); + expect(signInBody).not.toContain('15551234567'); + expect(signInBody).not.toContain('phone'); + + const session = await call(auth, '/get-session', { bearer: signedIn.token! }); + const text = await session.text(); + expect(text).toContain(email); + expect(text).not.toContain('15551234567'); + expect(text).not.toContain('phone'); + }); +}); + +describe('session-token probe: the generator phase 5 uses', () => { + it('matches the length and alphabet of a token better-auth writes', async () => { + const userId = await serviceUser(); + const { internalAdapter } = await auth.$context; + const created = await internalAdapter.createSession(userId); + expect(created.token).toMatch(/^[A-Za-z0-9]{32}$/); + + for (let i = 0; i < 50; i++) { + expect(generateRandomString(32, 'a-z', 'A-Z', '0-9')).toMatch(/^[A-Za-z0-9]{32}$/); + } + // Without the explicit alphabet, better-auth/crypto's generator also + // emits '-' and '_': the explicit alphabet is what makes the shapes match. + const unrestricted = Array.from({ length: 200 }, () => generateRandomString(32)).join(''); + expect(unrestricted).toMatch(/[-_]/); + }); +}); diff --git a/packages/auth/tests/config/options.test.ts b/packages/auth/tests/config/options.test.ts index ba454911..2bfedc9b 100644 --- a/packages/auth/tests/config/options.test.ts +++ b/packages/auth/tests/config/options.test.ts @@ -2,10 +2,19 @@ // Each behaviour is also proven over HTTP in the sibling suites; this file pins // the settings themselves, so a removal names the setting that moved. import type { PGlite } from '@electric-sql/pglite'; +import { apiKey } from '@better-auth/api-key'; import { admin } from 'better-auth/plugins'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { BLOCKED_AUTH_PATHS, createAuth, JWT_EXPIRATION, type Auth } from '../../src/index'; +import { + API_KEY_ENDPOINT_COUNT, + API_KEY_OPTIONS, + API_KEY_PATHS, + BLOCKED_AUTH_PATHS, + createAuth, + JWT_EXPIRATION, + type Auth, +} from '../../src/index'; import { hashPassword, verifyPassword } from '../../src/password'; import { drizzleFor, @@ -39,10 +48,12 @@ describe('createAuth() options', () => { expect(auth.options.advanced?.database?.generateId).toBe('uuid'); }); - it('declares the additional user fields, registered_via server-only', () => { + it('declares the additional user fields, registered_via server-only, phone_number never in or out', () => { const fields = auth.options.user?.additionalFields ?? {}; - expect(Object.keys(fields).sort()).toEqual(['first_name', 'last_name', 'registered_via']); + expect(Object.keys(fields).sort()).toEqual(['first_name', 'last_name', 'phone_number', 'registered_via']); expect(fields.registered_via?.input).toBe(false); + expect(fields.phone_number?.input).toBe(false); + expect(fields.phone_number?.returned).toBe(false); }); it('uses the dual-verify password functions and revokes sessions on reset', () => { @@ -62,24 +73,47 @@ describe('createAuth() options', () => { expect(auth.options.user?.deleteUser?.enabled).toBe(false); }); - it('runs exactly expo, bearer, jwt, admin and the rate-limit retention rule', () => { + it('runs exactly expo, bearer, jwt, admin, api-key and the rate-limit retention rule', () => { expect((auth.options.plugins as PluginLike[]).map((p) => p.id).sort()).toEqual([ 'admin', 'ansari-rate-limit-retention', + 'api-key', 'bearer', 'expo', 'jwt', ]); }); - it('disables every admin endpoint, change-email, update-user and change-password on the router', () => { + it('disables every admin and api-key endpoint, change-email, update-user and change-password on the router', () => { const adminPaths = Object.values(admin().endpoints).map((e) => e.path); expect(adminPaths).toContain('/admin/impersonate-user'); expect(adminPaths).toContain('/admin/set-role'); - expect(auth.options.disabledPaths).toEqual( - expect.arrayContaining([...adminPaths, '/change-email', '/update-user', '/change-password']) - ); - expect(BLOCKED_AUTH_PATHS).toEqual([...adminPaths, '/change-email', '/update-user', '/change-password']); + const blocked = [...adminPaths, ...API_KEY_PATHS, '/change-email', '/update-user', '/change-password']; + expect(auth.options.disabledPaths).toEqual(expect.arrayContaining(blocked)); + expect(BLOCKED_AUTH_PATHS).toEqual(blocked); + expect(BLOCKED_AUTH_PATHS).not.toContain(undefined); + }); + + it('derives the api-key paths from the installed plugin: 7 endpoints, 5 routed (spec 329)', () => { + // verifyApiKey and deleteAllExpiredApiKeys are server-only and have no + // path. A plugin bump that changes either number fails here, loudly. + expect(API_KEY_ENDPOINT_COUNT).toBe(7); + expect(Object.keys(apiKey().endpoints)).toHaveLength(7); + expect([...API_KEY_PATHS].sort()).toEqual([ + '/api-key/create', + '/api-key/delete', + '/api-key/get', + '/api-key/list', + '/api-key/update', + ]); + }); + + it('configures api-key: metadata on, start not stored, never a session (spec 329)', () => { + // The settings themselves; tests/config/api-key.test.ts proves each one's + // behaviour against the database. + expect(API_KEY_OPTIONS.enableSessionForAPIKeys).toBe(false); + expect(API_KEY_OPTIONS.enableMetadata).toBe(true); + expect(API_KEY_OPTIONS.startingCharactersConfig.shouldStore).toBe(false); }); it('runs the origin check under NODE_ENV=test too (#266)', () => { diff --git a/packages/auth/tests/migration/auth-journal-parity.test.ts b/packages/auth/tests/migration/auth-journal-parity.test.ts index ee58b6b8..5216fe1f 100644 --- a/packages/auth/tests/migration/auth-journal-parity.test.ts +++ b/packages/auth/tests/migration/auth-journal-parity.test.ts @@ -89,9 +89,10 @@ describe('auth migration files', () => { expect(files).toEqual(journal.map((e) => e.tag).sort()); }); - it('the journal is the single fresh 0000 (the #59 scaffold 0000 is gone)', () => { - expect(journal.map((e) => e.idx)).toEqual([0]); + it('the journal is the fresh 0000 (the #59 scaffold 0000 is gone), then 0001 (spec 329)', () => { + expect(journal.map((e) => e.idx)).toEqual([0, 1]); expect(journal[0].tag).not.toBe('0000_better_auth_init'); + expect(journal[1].tag).toBe('0001_api_key_phone'); }); }); @@ -106,6 +107,7 @@ describe('Drizzle schema vs the auth journal', () => { const tables = declaredTables(schema); expect(tables.map((t) => getTableConfig(t).name).sort()).toEqual([ 'account', + 'apikey', 'jwks', 'rate_limit', 'session', diff --git a/packages/auth/tests/schema/auth-schema.test.ts b/packages/auth/tests/schema/auth-schema.test.ts index 1fcfd69f..d7559328 100644 --- a/packages/auth/tests/schema/auth-schema.test.ts +++ b/packages/auth/tests/schema/auth-schema.test.ts @@ -47,13 +47,14 @@ async function insertUser(id: string, email: string): Promise { } describe('auth schema shape (spec success criteria)', () => { - it('has exactly the six Better Auth tables, all in the auth schema', async () => { + it('has exactly the seven Better Auth tables, all in the auth schema', async () => { const { rows } = await client.query<{ table_schema: string; table_name: string }>( `SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')` ); expect(rows.map((r) => `${r.table_schema}.${r.table_name}`).sort()).toEqual([ 'auth.account', + 'auth.apikey', 'auth.jwks', 'auth.rate_limit', 'auth.session', @@ -68,6 +69,7 @@ describe('auth schema shape (spec success criteria)', () => { 'first_name', 'last_name', 'registered_via', + 'phone_number', 'role', 'banned', 'ban_reason', @@ -90,8 +92,8 @@ describe('auth schema shape (spec success criteria)', () => { it('every id is a uuid, and every user reference is a uuid', async () => { const cols = await authColumns(); - const ids = cols.filter((c) => c.column_name === 'id' || c.column_name === 'user_id'); - expect(ids.length).toBe(8); + const ids = cols.filter((c) => ['id', 'user_id', 'reference_id'].includes(c.column_name)); + expect(ids.length).toBe(10); for (const c of ids) expect(`${c.table_name}.${c.column_name}:${c.data_type}`).toMatch(/:uuid$/); }); @@ -112,7 +114,18 @@ describe('auth schema shape (spec success criteria)', () => { await expect(client.query(insert, [id, id])).rejects.toThrow(/account_user_id_provider_id_unique/); }); - it('session and account cascade from auth.users; verification has no user reference', async () => { + it('auth.account has a unique (provider_id, account_id): one BSUID maps to one user (spec 329)', async () => { + const first = '33333333-3333-4333-8333-333333333333'; + const second = '44444444-4444-4444-8444-444444444444'; + await insertUser(first, 'wa-first@example.com'); + await insertUser(second, 'wa-second@example.com'); + const insert = `INSERT INTO auth.account (account_id, provider_id, user_id, updated_at) + VALUES ('US.13491208655302741918', 'whatsapp', $1, now())`; + await client.query(insert, [first]); + await expect(client.query(insert, [second])).rejects.toThrow(/account_provider_id_account_id_unique/); + }); + + it('session, account and apikey cascade from auth.users; verification has no user reference', async () => { const { rows } = await client.query<{ table_name: string; delete_rule: string }>( `SELECT tc.table_name, rc.delete_rule FROM information_schema.referential_constraints rc @@ -122,6 +135,7 @@ describe('auth schema shape (spec success criteria)', () => { ); expect(rows.map((r) => `${r.table_name}:${r.delete_rule}`).sort()).toEqual([ 'account:CASCADE', + 'apikey:CASCADE', 'session:CASCADE', ]); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d460dfaa..2e0084e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,6 +380,9 @@ importers: '@ansari/types': specifier: workspace:* version: link:../types + '@better-auth/api-key': + specifier: 1.7.7 + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.4)(@opentelemetry/api@1.9.1)(@types/pg@8.21.0)(kysely@0.29.5)(pg@8.22.0))(next@15.5.25(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3)) '@better-auth/expo': specifier: 1.7.7 version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.2))(better-auth@1.7.7(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.4)(@opentelemetry/api@1.9.1)(@types/pg@8.21.0)(kysely@0.29.5)(pg@8.22.0))(next@15.5.25(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(expo-constants@57.0.21)(expo-linking@57.0.12)(expo-secure-store@57.0.4(expo@57.0.27))(expo-web-browser@57.0.3(expo@57.0.27)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))) @@ -881,6 +884,14 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@better-auth/api-key@1.7.7': + resolution: {integrity: sha512-XWtZxF37A7BURHh/GvsYh2Vype1p+R1ZxvQHkRzB4tK3uniZpILe2WRIXfVj3m8NIBHdg8Ez8S8sZJY/ewTu7w==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + better-auth: ^1.7.7 + better-call: 1.4.0 + '@better-auth/core@1.7.7': resolution: {integrity: sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==} peerDependencies: @@ -3547,133 +3558,157 @@ packages: resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} cpu: [arm64] os: [android] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-darwin-arm64@0.8.7': resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} cpu: [arm64] os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-darwin-x64@0.8.7': resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} cpu: [x64] os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-freebsd-x64@0.8.7': resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} cpu: [x64] os: [freebsd] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm-gnu@0.8.7': resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm-musl@0.8.7': resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm64-musl@0.8.7': resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-x64-gnu@0.8.7': resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-x64-musl@0.8.7': resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-win32-arm64@0.8.7': resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} cpu: [arm64] os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-win32-x64@0.8.7': resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} cpu: [x64] os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-parser/binding-android-arm64@0.8.7': resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} cpu: [arm64] os: [android] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-darwin-arm64@0.8.7': resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} cpu: [arm64] os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-darwin-x64@0.8.7': resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} cpu: [x64] os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-freebsd-x64@0.8.7': resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} cpu: [x64] os: [freebsd] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm-gnu@0.8.7': resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm-musl@0.8.7': resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm64-gnu@0.8.7': resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm64-musl@0.8.7': resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-x64-gnu@0.8.7': resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-x64-musl@0.8.7': resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-win32-arm64@0.8.7': resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} cpu: [arm64] os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-win32-x64@0.8.7': resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} cpu: [x64] os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-toolchain/types@0.8.7': resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} @@ -8630,6 +8665,14 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/api-key@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(better-auth@1.7.7(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.4)(@opentelemetry/api@1.9.1)(@types/pg@8.21.0)(kysely@0.29.5)(pg@8.22.0))(next@15.5.25(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/utils': 0.4.2 + better-auth: 1.7.7(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.4)(@opentelemetry/api@1.9.1)(@types/pg@8.21.0)(kysely@0.29.5)(pg@8.22.0))(next@15.5.25(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11) + better-call: 1.4.0(zod@4.4.3) + zod: 4.6.5 + '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.2)': dependencies: '@better-auth/utils': 0.4.2 @@ -9019,7 +9062,6 @@ snapshots: - supports-color - typescript - utf-8-validate - optional: true '@expo/cli@57.0.28(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-constants@57.0.21)(expo-font@57.0.4)(expo-router@57.0.25)(expo@57.0.27)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)': dependencies: @@ -9179,7 +9221,6 @@ snapshots: optionalDependencies: react: 19.2.8 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) - optional: true '@expo/dom-webview@57.0.1(expo@57.0.27)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)': dependencies: @@ -9192,7 +9233,6 @@ snapshots: expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@58.0.8)(react-dom@19.2.8(react@19.2.8))(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) react: 19.2.8 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) - optional: true '@expo/env@2.4.3': dependencies: @@ -9283,7 +9323,6 @@ snapshots: react: 19.2.8 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) stacktrace-parser: 0.1.11 - optional: true '@expo/metro-config@57.0.13(expo@57.0.27)(typescript@6.0.3)': dependencies: @@ -9311,7 +9350,7 @@ snapshots: postcss: 8.5.28 resolve-from: 5.0.0 optionalDependencies: - expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.25)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@58.0.8)(react-dom@19.2.8(react@19.2.8))(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) transitivePeerDependencies: - bufferutil - supports-color @@ -9444,7 +9483,6 @@ snapshots: react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - supports-color - optional: true '@expo/router-server@57.0.12(@expo/metro-runtime@57.0.16)(expo-constants@57.0.21)(expo-font@57.0.4)(expo-router@57.0.25)(expo-server@57.0.3)(expo@57.0.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -10463,7 +10501,6 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - optional: true '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': dependencies: @@ -11923,7 +11960,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.25)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@58.0.8)(react-dom@19.2.8(react@19.2.8))(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -13002,7 +13039,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript - optional: true expo-blur@57.0.3(expo@57.0.27)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3): dependencies: @@ -13031,7 +13067,6 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) transitivePeerDependencies: - supports-color - optional: true expo-dev-client@57.0.19(expo@57.0.27)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3)): dependencies: @@ -13073,7 +13108,6 @@ snapshots: dependencies: expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@58.0.8)(react-dom@19.2.8(react@19.2.8))(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) - optional: true expo-font@57.0.4(expo@57.0.27)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3): dependencies: @@ -13088,7 +13122,6 @@ snapshots: fontfaceobserver: 2.3.0 react: 19.2.8 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) - optional: true expo-glass-effect@57.0.4(expo@57.0.27)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3): dependencies: @@ -13127,7 +13160,6 @@ snapshots: dependencies: expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@58.0.8)(react-dom@19.2.8(react@19.2.8))(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) react: 19.2.8 - optional: true expo-linear-gradient@57.0.2(expo@57.0.27)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3): dependencies: @@ -13190,7 +13222,6 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) optionalDependencies: react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) - optional: true expo-modules-jsi@57.1.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3)): dependencies: @@ -13199,7 +13230,6 @@ snapshots: expo-modules-jsi@57.1.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8)): dependencies: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8) - optional: true expo-navigation-bar@57.0.3(expo@57.0.27)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3): dependencies: @@ -13297,7 +13327,7 @@ snapshots: expo-secure-store@57.0.4(expo@57.0.27): dependencies: - expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.25)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 57.0.27(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@58.0.8)(react-dom@19.2.8(react@19.2.8))(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) expo-server@57.0.3: {} @@ -13471,7 +13501,6 @@ snapshots: - supports-color - typescript - utf-8-validate - optional: true exponential-backoff@3.1.3: {} @@ -15558,7 +15587,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): dependencies: From 0b2315e3d28b92dda219f04d2a13a12caf79eeab Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:15:34 +1100 Subject: [PATCH 082/247] chore(porch): 329 implement build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index b0f2b96c..8cc403f6 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T05:54:08.865Z' +updated_at: '2026-10-28T06:15:34.064Z' From 6d9bc6af2e75ab5d8ede35639c91699fd153fcec Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:17:32 +1100 Subject: [PATCH 083/247] [Spec 329][Phase: phase_1] test: probe 1b covers every limit field; absolute-form for all api-key paths --- apps/auth/tests/blocked-paths.test.ts | 2 +- .../329-phase_1-iter1-codex.txt | 8 +++++++ .../329-phase_1-iter1-gemini.txt | 10 ++++++++ .../329-phase_1-iter1-rebuttals.md | 6 +++++ packages/auth/tests/config/api-key.test.ts | 24 +++++++++++++++---- 5 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-rebuttals.md diff --git a/apps/auth/tests/blocked-paths.test.ts b/apps/auth/tests/blocked-paths.test.ts index 63af8f06..89f05354 100644 --- a/apps/auth/tests/blocked-paths.test.ts +++ b/apps/auth/tests/blocked-paths.test.ts @@ -219,7 +219,7 @@ describe('canonicalPath', () => { for (const target of [ `http://localhost:${port}/api/auth/admin/list-users`, `http://localhost:${port}/api/auth/change-password`, - `http://localhost:${port}/api/auth/api-key/create`, + ...API_KEY_PATHS.map((path) => `http://localhost:${port}/api/auth${path}`), `http://localhost:${port}/api/auth/API-KEY/list`, ]) { const status = await new Promise((resolve, reject) => { diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-codex.txt new file mode 100644 index 00000000..5dc63be8 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-codex.txt @@ -0,0 +1,8 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Implementation is sound, but two explicit Phase 1 test deliverables remain incomplete. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- Probe 1b does not prove all four limit fields change: `rateLimitTimeWindow` remains `60_000`, `refillInterval` is not asserted, and refill behavior is not exercised after update. +- The required absolute-form request target is tested for only two of the five routed API-key endpoints, not each endpoint as specified. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-rebuttals.md new file mode 100644 index 00000000..8965b34d --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-rebuttals.md @@ -0,0 +1,6 @@ +# Phase 1, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (skipped, `agy` not installed). + +1. Probe 1b did not prove every field changed: accepted. The update now changes all four (`rateLimitMax` 1→5, `rateLimitTimeWindow` 60 s→30 s, `refillAmount` →2, `refillInterval` →1 s, plus `remaining` →1), asserts each column, and exercises each at the next verify: the window cap, the exhausted budget, then a refill past the new interval (remaining refilled to 2, one spent). +2. The absolute-form target covered 2 of 5 endpoints: accepted. The target list is now every `API_KEY_PATHS` entry plus a mixed-case variant. diff --git a/packages/auth/tests/config/api-key.test.ts b/packages/auth/tests/config/api-key.test.ts index 155402c8..60b6c9cb 100644 --- a/packages/auth/tests/config/api-key.test.ts +++ b/packages/auth/tests/config/api-key.test.ts @@ -134,16 +134,30 @@ describe('probe 1b: limits changed on a live key', () => { keyId: created.id, userId, rateLimitMax: 5, - rateLimitTimeWindow: 60_000, - remaining: 100, - refillAmount: 100, - refillInterval: 86_400_000, + rateLimitTimeWindow: 30_000, + remaining: 1, + refillAmount: 2, + refillInterval: 1000, }, }); const row = await keyRow(created.id); expect(row!.rate_limit_max).toBe(5); - expect(row!.refill_amount).toBe(100); + expect(row!.rate_limit_time_window).toBe(30_000); + expect(row!.refill_amount).toBe(2); + expect(row!.refill_interval).toBe(1000); + expect(row!.remaining).toBe(1); + + // The new window cap is enforced: the key verifies again at once. + expect((await verify(created.key)).valid).toBe(true); + // The new budget is enforced: remaining 1 is now spent. + expect((await verify(created.key)).error?.code).toBe('USAGE_EXCEEDED'); + // The new refill applies: past the 1 s interval, remaining is refilled to 2 (one spent now). + await client.query( + `UPDATE auth.apikey SET created_at = now() - interval '1 hour', last_refill_at = NULL WHERE id = $1`, + [created.id] + ); expect((await verify(created.key)).valid).toBe(true); + expect((await keyRow(created.id))!.remaining).toBe(1); }); }); From 92181c1c53d6067c7841ccfe36b3794839a0097a Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:19:01 +1100 Subject: [PATCH 084/247] chore(porch): 329 implement re-iter (iter 2) --- .../status.yaml | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 8cc403f6..67c81cc1 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -38,8 +38,20 @@ gates: status: pending verify-approval: status: pending -iteration: 1 -build_complete: true -history: [] +iteration: 2 +build_complete: false +history: + - iteration: 1 + plan_phase: phase_1 + build_output: '' + reviews: + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-codex.txt + - model: gemini + verdict: COMMENT + file: >- + /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T06:15:34.064Z' +updated_at: '2026-10-28T06:19:01.342Z' From b1fd08f5da79b19525b57fcdd359f14d599ce02b Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:19:09 +1100 Subject: [PATCH 085/247] chore(porch): 329 implement build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 67c81cc1..f58959f4 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 2 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: phase_1 @@ -54,4 +54,4 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T06:19:01.342Z' +updated_at: '2026-10-28T06:19:09.556Z' From a288348113e0bdd005d28546352619f565898de3 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:20:23 +1100 Subject: [PATCH 086/247] =?UTF-8?q?chore(porch):=20329=20advance=20plan=20?= =?UTF-8?q?phase=20=E2=86=92=20phase=5F2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../status.yaml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index f58959f4..e2699f95 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -5,10 +5,10 @@ phase: implement plan_phases: - id: phase_1 title: Plugin, schema and migration 0001, blocked key surface, library probes - status: in_progress + status: complete - id: phase_2 title: Redacted Better Auth logger - status: pending + status: in_progress - id: phase_3 title: Reserved noreply domain status: pending @@ -24,7 +24,7 @@ plan_phases: - id: phase_7 title: End-to-end contract test, source scan and documentation status: pending -current_plan_phase: phase_1 +current_plan_phase: phase_2 gates: spec-approval: status: approved @@ -38,8 +38,8 @@ gates: status: pending verify-approval: status: pending -iteration: 2 -build_complete: true +iteration: 1 +build_complete: false history: - iteration: 1 plan_phase: phase_1 @@ -54,4 +54,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T06:19:09.556Z' +updated_at: '2026-10-28T06:20:23.009Z' +context_refreshes: + - boundary: plan-phase:phase_2 + at: '2026-10-28T06:20:23.009Z' From 25eee8af6c5f995f9beb268f7b517c4349de94f1 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:20:29 +1100 Subject: [PATCH 087/247] chore(porch): 329 acknowledge context refresh --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index e2699f95..f0c7eb7e 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -54,7 +54,8 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T06:20:23.009Z' +updated_at: '2026-10-28T06:20:29.673Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' + acknowledged_at: '2026-10-28T06:20:29.672Z' From 362247b3a92e939648c70da397ac061f01e65c34 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:27:03 +1100 Subject: [PATCH 088/247] [Spec 329][Phase: phase_2] feat: redact better-auth's logger to {name, code} for Error arguments --- .../329-apps-auth-service-api-keys-bet.md | 12 ++ packages/auth/src/index.ts | 6 + packages/auth/src/logger.ts | 40 ++++++ packages/auth/tests/unit/logger.test.ts | 131 ++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 packages/auth/src/logger.ts create mode 100644 packages/auth/tests/unit/logger.test.ts diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md index 8601d92f..26ce9916 100644 --- a/codev/reviews/329-apps-auth-service-api-keys-bet.md +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -47,3 +47,15 @@ With `API_KEY_PREFIX` removed from the middleware, the variant and absolute-form ### Phase 1: green command, and a pre-existing timeout under combined load The plan's green command, run in sequence (`pnpm turbo run test --force`, then `typecheck --force`, then `lint --force`), passes. Running all three as **one** parallel turbo invocation (`turbo run test typecheck lint --force`, 15 tasks at once) times out pglite `beforeAll` hooks at 30 s. It happened twice on this branch, both times in `@ansari/auth`. It also happens on a clean baseline worktree of this branch's HEAD (no code changes), there in `ansari-auth` (`secrets.test.ts`, `cutover/copy.test.ts`). It is the load-induced class `vitest.config.ts` already records (#290/#292), not something this change introduced. This phase's extra pglite file adds load, so CI's task grouping is worth watching. + +### Phase 2: redacted logger + +- The logger probe matches the plan (gate before phase 6). With `auth.apikey` dropped, `verifyApiKey` returns `valid: false` with `INVALID_API_KEY`, the same code as an unknown key, and the logged argument is `{"name":"DrizzleQueryError"…, "code":"42P01"}`-shaped. It is never an Error object, and never carries the driver message. +- Proven both ways: with `logger: { disabled: true }` the non-error survival test and the outage test fail; with better-auth's default (unredacted) logger they fail too; with the redacted logger they pass. +- Real pg server errors carry `name: 'error'` (node-postgres sets it from the protocol message type), so a raw pg error is logged as `{ name: 'error', code }`. +- Residual: messages are not rewritten. The better-auth paths this service runs pass errors as arguments; the few that interpolate an error into the message string (OAuth discovery, token introspection) are not configured. +- No existing test asserted better-auth log output, so none needed editing. + +## Flaky Tests + +- `apps/auth/tests/secrets.test.ts` and `apps/auth/tests/cutover/copy.test.ts` (spec 300): `beforeAll` hooks time out at 30 s when the whole monorepo's tests run in one turbo invocation (pglite cold starts under load, the class `vitest.config.ts` records as #290/#292). They pass when apps/auth runs alone (261/261) and in porch's checks. The same failure reproduces on a clean worktree of this branch's pre-change HEAD. **Not skipped:** one is the secrets test, so skipping is raised with the architect instead. diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 6fdbf09b..17720d0c 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -16,6 +16,7 @@ import { z } from 'zod'; import { createDb, type Db } from './db'; import { getEnv, type AuthEnv } from './env'; import { DEFAULT_RULE, LONGEST_WINDOW, ROUTER_RULES } from './limits'; +import { logBetterAuth } from './logger'; import { deriveName } from './name'; import { subscribeToNewsletter } from './newsletter'; import { @@ -31,6 +32,7 @@ import * as schema from './schema'; export * as schema from './schema'; export { createDb, pingDatabase, type Db } from './db'; export { getEnv, resetEnvCache, type AuthEnv } from './env'; +export { logBetterAuth, redactError, redactLogArgs } from './logger'; export * as limits from './limits'; export { consumeWindow, keysSeenSince, peekWindow, touchKey, type WindowState } from './rate-limit-store'; export { isSystemAddress, normalizeEmail, SYSTEM_EMAIL_DOMAIN } from './reserved'; @@ -291,6 +293,10 @@ export function createAuth(db: Db = createDb(), extensions: AuthExtensions = {}) session: { expiresIn: 30 * DAY, updateAge: 1 * DAY }, secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, + // Every Error argument is reduced to {name, code} (logger.ts): a driver + // error must never reach a log line, and better-auth logs caught errors + // as they are (spec 329). + logger: { log: logBetterAuth }, rateLimit: { enabled: true, storage: 'database', diff --git a/packages/auth/src/logger.ts b/packages/auth/src/logger.ts new file mode 100644 index 00000000..6916f6eb --- /dev/null +++ b/packages/auth/src/logger.ts @@ -0,0 +1,40 @@ +// Better Auth's logger, redacted (spec 329). Better Auth and its plugins log +// caught errors as they are: the api-key plugin, for one, logs every failed +// verification's error object, a raw driver error included when the database +// fails. A driver error's message can carry query parameters and user content, +// so the repo rule (log only `{name, code}`) is applied to every Error argument +// here. Nothing else changes: the level, the message and every other argument +// survive, so the logger is redacted, never silenced. +// +// Messages are not rewritten. The better-auth paths this service runs pass +// errors as arguments; the few that interpolate an error into the message +// string (OAuth discovery, token introspection) are not configured here. + +export type BetterAuthLogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export type RedactedError = { name: string; code?: unknown }; + +/** `{name, code}` for an Error (the code from the error or its cause), as account.ts logs. */ +export function redactError(err: Error): RedactedError { + const own = (err as { code?: unknown }).code; + const cause = (err.cause as { code?: unknown } | undefined)?.code; + const code = own ?? cause; + return code === undefined ? { name: err.name } : { name: err.name, code }; +} + +/** Every Error argument reduced to `{name, code}`; everything else unchanged. */ +export function redactLogArgs(args: readonly unknown[]): unknown[] { + return args.map((arg) => (arg instanceof Error ? redactError(arg) : arg)); +} + +const WRITERS: Record void> = { + debug: (...args) => console.debug(...args), + info: (...args) => console.info(...args), + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), +}; + +/** The `logger.log` handed to betterAuth(): writes through console at the matching level. */ +export function logBetterAuth(level: BetterAuthLogLevel, message: string, ...args: unknown[]): void { + WRITERS[level](`[Better Auth] ${level.toUpperCase()}: ${message}`, ...redactLogArgs(args)); +} diff --git a/packages/auth/tests/unit/logger.test.ts b/packages/auth/tests/unit/logger.test.ts new file mode 100644 index 00000000..00a0c2f1 --- /dev/null +++ b/packages/auth/tests/unit/logger.test.ts @@ -0,0 +1,131 @@ +// Better Auth's logger, redacted (spec 329, plan phase 2, probe 3). Every Error +// argument becomes exactly {name, code}; the level, the message and every other +// argument survive, so the logger is redacted, not silenced. The plugin's own +// catch-and-log path is exercised with the auth.apikey relation dropped, which +// also pins the outage mapping the exchange relies on (phase 6). +import { randomUUID } from 'node:crypto'; + +import type { PGlite } from '@electric-sql/pglite'; +import { DatabaseError } from 'pg'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAuth, type Auth } from '../../src/index'; +import { logBetterAuth, redactError, redactLogArgs } from '../../src/logger'; +import { drizzleFor, migrate, setAuthEnv } from '../helpers/auth-db'; +import { BASE } from '../helpers/http'; + +const EMAIL = 'leaked.person@example.com'; + +/** A pg server error as node-postgres builds it: its `name` is the protocol message type, 'error'. */ +function driverError(): DatabaseError { + const err = new DatabaseError( + `relation "auth.apikey" does not exist (key)=(${EMAIL})`, + 120, + 'error' + ); + err.code = '42P01'; + return err; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('redactError and redactLogArgs', () => { + it('reduces a pg DatabaseError to exactly {name, code}', () => { + expect(redactError(driverError())).toEqual({ name: 'error', code: '42P01' }); + }); + + it("takes the code from the cause when the error has none (a driver error wrapped by Drizzle)", () => { + const wrapped = new Error(`Failed query: select ... params: ${EMAIL}`, { cause: driverError() }); + wrapped.name = 'DrizzleQueryError'; + expect(redactError(wrapped)).toEqual({ name: 'DrizzleQueryError', code: '42P01' }); + }); + + it('gives {name} alone when there is no code anywhere', () => { + expect(redactError(new TypeError('boom'))).toEqual({ name: 'TypeError' }); + }); + + it('leaves near misses unchanged: a plain object with name/code fields, a string, a number', () => { + const plain = { name: 'NotAnError', code: 'X1', message: 'kept as is' }; + expect(redactLogArgs([plain, 'a string', 42])).toEqual([plain, 'a string', 42]); + }); +}); + +describe('logBetterAuth', () => { + it('writes at the matching level, keeping the message and non-error arguments', () => { + const spies = { + debug: vi.spyOn(console, 'debug').mockImplementation(() => undefined), + info: vi.spyOn(console, 'info').mockImplementation(() => undefined), + warn: vi.spyOn(console, 'warn').mockImplementation(() => undefined), + error: vi.spyOn(console, 'error').mockImplementation(() => undefined), + }; + for (const level of ['debug', 'info', 'warn', 'error'] as const) { + logBetterAuth(level, `a ${level} message`, { detail: 1 }); + expect(spies[level]).toHaveBeenCalledWith(`[Better Auth] ${level.toUpperCase()}: a ${level} message`, { + detail: 1, + }); + } + }); + + it('never passes an Error through: a driver error arrives as {name, code}', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + logBetterAuth('error', 'Failed to validate API key:', driverError()); + expect(error).toHaveBeenCalledWith('[Better Auth] ERROR: Failed to validate API key:', { + name: 'error', + code: '42P01', + }); + expect(JSON.stringify(error.mock.calls)).not.toContain(EMAIL); + }); +}); + +describe('createAuth uses the redacted logger', () => { + let client: PGlite; + let auth: Auth; + + beforeEach(async () => { + setAuthEnv(BASE); + client = await migrate(); + auth = createAuth(drizzleFor(client)); + }); + + afterEach(async () => { + await client?.close(); + }); + + it('a non-error log survives with its level and message (a disabled logger fails this)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { logger } = await auth.$context; + logger.warn('probe: still logging', { kept: true }); + expect(warn).toHaveBeenCalledWith('[Better Auth] WARN: probe: still logging', { kept: true }); + }); + + it('auth.apikey dropped: verifyApiKey answers valid:false / INVALID_API_KEY and logs only {name, code}', async () => { + const userId = randomUUID(); + await client.query(`INSERT INTO auth.users (id, name, email) VALUES ($1, 'svc', $2)`, [ + userId, + `service-${userId.slice(0, 8)}@noreply.askansari.ai`, + ]); + const created = await auth.api.createApiKey({ + body: { userId, prefix: 'wa_', permissions: { whatsapp: ['mint'] }, rateLimitEnabled: false }, + }); + await client.query('DROP TABLE auth.apikey'); + + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const result = await auth.api.verifyApiKey({ + body: { key: created.key, permissions: { whatsapp: ['mint'] } }, + }); + + // The outage reads exactly like an unknown key: why phase 6 checks the relation before any 401. + expect(result.valid).toBe(false); + expect(result.error?.code).toBe('INVALID_API_KEY'); + + expect(error).toHaveBeenCalled(); + const args = error.mock.calls.flatMap((call) => call.slice(1)); + for (const arg of args) expect(arg).not.toBeInstanceOf(Error); + const logged = JSON.stringify(error.mock.calls); + expect(logged).toContain('42P01'); + expect(logged).not.toContain('does not exist'); + expect(logged).not.toContain(created.key); + }); +}); From a58f5164f6cb039642ae6fa75f110f0db5a840e5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:27:11 +1100 Subject: [PATCH 089/247] [Spec 329][Phase: phase_2] docs: state only what the logger test verifies --- codev/reviews/329-apps-auth-service-api-keys-bet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md index 26ce9916..2ffb177f 100644 --- a/codev/reviews/329-apps-auth-service-api-keys-bet.md +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -50,7 +50,7 @@ The plan's green command, run in sequence (`pnpm turbo run test --force`, then ` ### Phase 2: redacted logger -- The logger probe matches the plan (gate before phase 6). With `auth.apikey` dropped, `verifyApiKey` returns `valid: false` with `INVALID_API_KEY`, the same code as an unknown key, and the logged argument is `{"name":"DrizzleQueryError"…, "code":"42P01"}`-shaped. It is never an Error object, and never carries the driver message. +- The logger probe matches the plan (gate before phase 6). With `auth.apikey` dropped, `verifyApiKey` returns `valid: false` with `INVALID_API_KEY`, the same code as an unknown key, and the logged arguments contain no Error object, include the code `42P01`, and never carry the driver message ("does not exist") or the key. - Proven both ways: with `logger: { disabled: true }` the non-error survival test and the outage test fail; with better-auth's default (unredacted) logger they fail too; with the redacted logger they pass. - Real pg server errors carry `name: 'error'` (node-postgres sets it from the protocol message type), so a raw pg error is logged as `{ name: 'error', code }`. - Residual: messages are not rewritten. The better-auth paths this service runs pass errors as arguments; the few that interpolate an error into the message string (OAuth discovery, token introspection) are not configured. From bf61f95cdd5286a077a325ba0c0ff0af6182d56e Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 17:29:55 +1100 Subject: [PATCH 090/247] chore(porch): 329 implement build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index f0c7eb7e..aa6fff5c 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: phase_1 @@ -54,7 +54,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T06:20:29.673Z' +updated_at: '2026-10-28T06:29:55.770Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From b1ccfa2f9a4867441bf36b982a23c7dbca17b007 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:09:35 +1100 Subject: [PATCH 091/247] chore(porch): bugfix-330 init bugfix --- .../status.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml diff --git a/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml b/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml new file mode 100644 index 00000000..437a1ef5 --- /dev/null +++ b/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml @@ -0,0 +1,14 @@ +id: bugfix-330 +title: ci-test-root-pnpm-test-is-not- +protocol: bugfix +phase: investigate +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-28T07:09:35.707Z' +updated_at: '2026-10-28T07:09:35.710Z' From 0658aa0249e2175ea3b925fbb3475d500dd311eb Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:15:45 +1100 Subject: [PATCH 092/247] [Spec 329][Phase: 2] chore(test): own 100 s setup deadline for secrets and cutover copy hooks (#292 class, measured) --- apps/auth/tests/cutover/copy.test.ts | 8 +++++++- apps/auth/tests/secrets.test.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/auth/tests/cutover/copy.test.ts b/apps/auth/tests/cutover/copy.test.ts index c29820d7..fb2e71ff 100644 --- a/apps/auth/tests/cutover/copy.test.ts +++ b/apps/auth/tests/cutover/copy.test.ts @@ -129,11 +129,17 @@ function preflightQuery(heading: string): string { let app: PGlite; let auth: PGlite; +// This hook's own deadline, above the config's 30 s (#292's value). Two +// pglite cold starts (the app and auth journals) took 27-53 s when the whole +// monorepo's tests ran at once on a shared machine (measured for spec 329; the +// #290/#292/#301 class). 100 s is about twice the observed maximum. +const SETUP_DEADLINE_MS = 100_000; + beforeEach(async () => { bcryptHash ??= await bcrypt.hash(PASSWORD, 4); app = await appDatabase(); auth = await migrate(); -}); +}, SETUP_DEADLINE_MS); afterEach(async () => { await app?.close(); diff --git a/apps/auth/tests/secrets.test.ts b/apps/auth/tests/secrets.test.ts index a4430b58..02e41495 100644 --- a/apps/auth/tests/secrets.test.ts +++ b/apps/auth/tests/secrets.test.ts @@ -64,6 +64,12 @@ async function keep(res: Promise): Promise { let service: Service; let failingMail: Service; +// This hook's own deadline, above the config's 30 s (#292's value). Two +// services, each a pglite cold start plus the auth journal, took 27-53 s when +// the whole monorepo's tests ran at once on a shared machine (measured for +// spec 329; the #290/#292/#301 class). 100 s is about twice the observed max. +const SETUP_DEADLINE_MS = 100_000; + beforeAll(async () => { service = await startService(); failingMail = await startService({ @@ -72,7 +78,7 @@ beforeAll(async () => { }, }); capture(); -}); +}, SETUP_DEADLINE_MS); afterAll(async () => { for (const undo of restore.reverse()) undo(); From 81028755cc1a7bb8ce4e4aae22ff388a99948c66 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:15:45 +1100 Subject: [PATCH 093/247] [Spec 329][Phase: phase_2] docs: flaky-test measurement and evidence --- .../329-apps-auth-service-api-keys-bet.md | 146 +++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md index 2ffb177f..b92d933a 100644 --- a/codev/reviews/329-apps-auth-service-api-keys-bet.md +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -58,4 +58,148 @@ The plan's green command, run in sequence (`pnpm turbo run test --force`, then ` ## Flaky Tests -- `apps/auth/tests/secrets.test.ts` and `apps/auth/tests/cutover/copy.test.ts` (spec 300): `beforeAll` hooks time out at 30 s when the whole monorepo's tests run in one turbo invocation (pglite cold starts under load, the class `vitest.config.ts` records as #290/#292). They pass when apps/auth runs alone (261/261) and in porch's checks. The same failure reproduces on a clean worktree of this branch's pre-change HEAD. **Not skipped:** one is the secrets test, so skipping is raised with the architect instead. +### `apps/auth/tests/secrets.test.ts` and `apps/auth/tests/cutover/copy.test.ts` (fixed with a targeted deadline) + +Both are spec 300 tests (the secrets guard and the cutover copy). Their setup hooks (`beforeAll` in `secrets.test.ts`, which starts two services; `beforeEach` in `copy.test.ts`, which builds two pglite databases) crossed the 30 s `hookTimeout` when the whole monorepo's tests ran at once. They pass when apps/auth runs alone (261/261). **Not skipped** (security architect): each hook now has its own 100 s deadline, in those two files only (`chore(test)` commit in phase 2). + +**Conditions: a real shared-machine run, no artificial load harness.** The machine runs other builders' sessions and iOS simulators. Load averages (1/5/15 min) sampled during the measurement: 20.75 / 50.01 / 71.33, 54.04 / 46.78 / 55.22, 22.15 / 41.07 / 51.18. Each run is a full root run with `--force --continue`, alternating `turbo run test` and `turbo run test typecheck lint`. + +**Timings (N = 6, hooks given a temporary 600 s deadline and timed):** slowest per run, in ms. + +| Run | Tasks | `secrets` beforeAll | `copy` beforeEach (max) | Both target files | +|---|---|---|---|---| +| 1 | test | 35,465 | 39,005 | pass | +| 2 | test, typecheck, lint | 34,839 | 36,490 | pass | +| 3 | test | 31,703 | 30,088 | pass | +| 4 | test, typecheck, lint | 42,296 | 47,098 | pass | +| 5 | test | 27,097 | 30,239 | pass | +| 6 | test, typecheck, lint | 40,547 | 43,910 | pass | + +Maximum 47 s; the deadline is 100 s (about 2x, above the architect's 60 s floor). + +**Proof: 30 s versus 100 s, same conditions, counted on the two target files only** (the rest of the suite is red from machine load regardless, see below): + +``` +before run 1 [test] deadline=30_000 secrets=RED copy=RED slowest=[copy=9296 copy=32440 ] +before run 2 [test typecheck lint] deadline=30_000 secrets=RED copy=RED slowest=[copy=7926 copy=41851 ] +before run 3 [test] deadline=30_000 secrets=RED copy=green slowest=[copy=29012 secrets=31456 ] +before run 4 [test typecheck lint] deadline=30_000 secrets=RED copy=RED slowest=[copy=10031 copy=41717 ] +before run 5 [test] deadline=30_000 secrets=green copy=green slowest=[copy=25698 secrets=26954 ] +after run 1 [test] deadline=100_000 secrets=green copy=green slowest=[copy=36150 secrets=39634 ] +after run 2 [test typecheck lint] deadline=100_000 secrets=green copy=green slowest=[secrets=47493 copy=53506 ] +after run 3 [test] deadline=100_000 secrets=green copy=green slowest=[secrets=12502 copy=15275 ] +after run 4 [test typecheck lint] deadline=100_000 secrets=green copy=green slowest=[secrets=20134 copy=21117 ] +after run 5 [test] deadline=100_000 secrets=green copy=green slowest=[copy=14771 secrets=14982 ] +``` + +At 30 s: `secrets` red 4/5, `copy` red 3/5. At 100 s: both green 5/5 (slowest hook 53.5 s). + +### Other files that tripped 30 s timeouts in the same runs (evidence for main; not fixed here) + +On the same shared machine, with no harness, **other files hit the 30 s hook or test deadline in 5 of the 6 timing runs** (runs 1, 2, 4, 5 and 6; only run 3 was clean). Extracted from the vitest failure summaries of each run's output (` :: `): + +``` +== timing run 1 +@ansari/auth tests/auth.http.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/auth.integration.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/api-key.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/credentials.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/options.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit-store.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/sign-up.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/tokens.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/migration/auth-journal-parity.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/schema/auth-schema.test.ts :: Hook timed out in 30000ms +ansari-api tests/contract/legacy-users-contract.test.ts :: Hook timed out in 30000ms +ansari-api tests/feedback-upsert.test.ts :: Hook timed out in 30000ms +ansari-api tests/toolcalls-persistence.test.ts :: Hook timed out in 30000ms +ansari-auth tests/account.test.ts :: Hook timed out in 30000ms +ansari-auth tests/blocked-paths.test.ts :: Hook timed out in 30000ms +ansari-auth tests/client-ip.test.ts :: Hook timed out in 30000ms +ansari-auth tests/frontend-client.test.ts :: Hook timed out in 30000ms +ansari-auth tests/grant-admin.test.ts :: Hook timed out in 30000ms +ansari-auth tests/limits.test.ts :: Hook timed out in 30000ms +ansari-auth tests/public-face.test.ts :: Hook timed out in 30000ms +ansari-auth tests/reset.test.ts :: Hook timed out in 30000ms +== timing run 2 +@ansari/auth tests/schema/auth-schema.test.ts :: Hook timed out in 30000ms +ansari-api tests/feedback-upsert.test.ts :: Hook timed out in 30000ms +ansari-api tests/migration/migration-schema-parity.test.ts :: Test timed out in 30000ms +ansari-auth tests/frontend-client.test.ts :: Hook timed out in 30000ms +ansari-auth tests/public-face.test.ts :: Hook timed out in 30000ms +== timing run 3 +== timing run 4 +@ansari/auth tests/auth.http.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/auth.integration.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/api-key.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/credentials.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/options.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit-store.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/sign-up.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/tokens.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/migration/auth-journal-parity.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/schema/auth-schema.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/unit/logger.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/layer.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/require-user.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/resolve-user.test.ts :: Hook timed out in 30000ms +ansari-api tests/contract/legacy-users-contract.test.ts :: Hook timed out in 30000ms +ansari-api tests/feedback-upsert.test.ts :: Hook timed out in 30000ms +ansari-api tests/migration/migration-schema-parity.test.ts :: Test timed out in 30000ms +ansari-api tests/migration/window-migration.test.ts :: Hook timed out in 30000ms +ansari-api tests/thread-get-contract.test.ts :: Hook timed out in 30000ms +ansari-api tests/toolcalls-persistence.test.ts :: Hook timed out in 30000ms +ansari-auth tests/account.test.ts :: Hook timed out in 30000ms +ansari-auth tests/blocked-paths.test.ts :: Hook timed out in 30000ms +ansari-auth tests/client-ip.test.ts :: Hook timed out in 30000ms +ansari-auth tests/frontend-client.test.ts :: Hook timed out in 30000ms +ansari-auth tests/grant-admin.test.ts :: Hook timed out in 30000ms +ansari-auth tests/limits.test.ts :: Hook timed out in 30000ms +ansari-auth tests/public-face.test.ts :: Hook timed out in 30000ms +ansari-auth tests/reset.test.ts :: Hook timed out in 30000ms +== timing run 5 +@ansari/auth tests/config/rate-limit.test.ts :: Test timed out in 30000ms +ansari-api tests/migration/window-migration.test.ts :: Test timed out in 30000ms +== timing run 6 +@ansari/auth tests/auth.http.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/auth.integration.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/api-key.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/credentials.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/options.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit-store.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/rate-limit.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/sign-up.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/config/tokens.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/migration/auth-journal-parity.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/schema/auth-schema.test.ts :: Hook timed out in 30000ms +@ansari/auth tests/unit/logger.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/layer.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/require-user.test.ts :: Hook timed out in 30000ms +ansari-api tests/auth/resolve-user.test.ts :: Hook timed out in 30000ms +ansari-api tests/contract/legacy-users-contract.test.ts :: Hook timed out in 30000ms +ansari-api tests/feedback-upsert.test.ts :: Hook timed out in 30000ms +ansari-api tests/migration/migration-schema-parity.test.ts :: Test timed out in 30000ms +ansari-api tests/toolcalls-persistence.test.ts :: Hook timed out in 30000ms +ansari-auth tests/account.test.ts :: Hook timed out in 30000ms +ansari-auth tests/blocked-paths.test.ts :: Hook timed out in 30000ms +ansari-auth tests/client-ip.test.ts :: Hook timed out in 30000ms +ansari-auth tests/frontend-client.test.ts :: Hook timed out in 30000ms +ansari-auth tests/grant-admin.test.ts :: Hook timed out in 30000ms +ansari-auth tests/limits.test.ts :: Hook timed out in 30000ms +ansari-auth tests/public-face.test.ts :: Hook timed out in 30000ms +ansari-auth tests/reset.test.ts :: Hook timed out in 30000ms +``` + +### Under #301's artificial load (28 busy loops on 14 cores), 3 runs, for comparison + +Without `--continue`, turbo stops at the first failing package, so each run shows only the first package to fail: run 1 the frontend (`lib/worklets.test.ts`, `Test timed out in 5000ms`); run 2 apps/api (`tests/migration/window-migration.test.ts`, `Test timed out in 30000ms`); run 3 twelve `packages/auth` files. Hook times there: `secrets` 37 s, `copy` 36 s and 55 s. + +``` +timing run 1 exit=1 fails=[] times=[] +timing run 2 exit=1 fails=[] times=[copy=5644 copy=5992 copy=6557 copy=6943 copy=7109 copy=7159 copy=7191 copy=7625 copy=7780 copy=7803 copy=7888 copy=8016 copy=8298 copy=8468 copy=8672 copy=9121 copy=10567 copy=11835 copy=11842 copy=14900 copy=15385 copy=36043 secrets=37399 ] +timing run 3 exit=1 fails=[@ansari/auth:test: FAIL tests/auth.http.test.ts [ tests/auth.http.test.ts ];@ansari/auth:test: FAIL tests/auth.integration.test.ts [ tests/auth.integration.test.ts ];@ansari/auth:test: FAIL tests/config/api-key.test.ts [ tests/config/api-key.test.ts ];@ansari/auth:test: FAIL tests/config/credentials.test.ts [ tests/config/credentials.test.ts ];@ansari/auth:test: FAIL tests/config/options.test.ts [ tests/config/options.test.ts ];@ansari/auth:test: FAIL tests/config/rate-limit-store.test.ts [ tests/config/rate-limit-store.test.ts ];@ansari/auth:test: FAIL tests/config/rate-limit.test.ts [ tests/config/rate-limit.test.ts ];@ansari/auth:test: FAIL tests/config/sign-up.test.ts [ tests/config/sign-up.test.ts ];@ansari/auth:test: FAIL tests/config/tokens.test.ts [ tests/config/tokens.test.ts ];@ansari/auth:test: FAIL tests/migration/auth-journal-parity.test.ts > Drizzle schema vs the auth journal;@ansari/auth:test: FAIL tests/schema/auth-schema.test.ts [ tests/schema/auth-schema.test.ts ];@ansari/auth:test: FAIL tests/unit/logger.test.ts > createAuth uses the redacted logger > a non-error log survives with its level and message (a disabled logger fails this);] times=[copy=55167 ] +``` + +This is a monorepo-wide matter (pglite cold starts against 30 s deadlines under contention), passed to the main architect as a follow-up; this PR fixes only the two files that blocked its own green runs. From 940e93bf61fc36f8cf5cfb97010dae66011e3c16 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:15:52 +1100 Subject: [PATCH 094/247] [Spec 329][Phase: phase_1] chore: phase 1 iteration 2 review record --- .../329-phase_1-iter2-codex.txt | 7 +++++++ .../329-phase_1-iter2-context.md | 20 +++++++++++++++++++ .../329-phase_1-iter2-gemini.txt | 10 ++++++++++ 3 files changed, 37 insertions(+) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-context.md create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-gemini.txt diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-codex.txt new file mode 100644 index 00000000..45ad6599 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 1 meets its scoped deliverables, including the corrected limit-update and complete blocked-path probes. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-context.md new file mode 100644 index 00000000..0df7be29 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-context.md @@ -0,0 +1,20 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Implementation is sound, but two explicit Phase 1 test deliverables remain incomplete. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 1, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES; gemini COMMENT (skipped, `agy` not installed). + +1. Probe 1b did not prove every field changed: accepted. The update now changes all four (`rateLimitMax` 1→5, `rateLimitTimeWindow` 60 s→30 s, `refillAmount` →2, `refillInterval` →1 s, plus `remaining` →1), asserts each column, and exercises each at the next verify: the window cap, the exhausted budget, then a refill past the new interval (remaining refilled to 2, one spent). +2. The absolute-form target covered 2 of 5 endpoints: accepted. The target list is now every `API_KEY_PATHS` entry plus a mixed-case variant. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file From 7221dd5b24db35d295a2294acfa676fb244a324a Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:17:36 +1100 Subject: [PATCH 095/247] [Spec 329][Phase: phase_2] chore: phase 2 review record --- .../329-phase_2-iter1-codex.txt | 7 +++++++ .../329-phase_2-iter1-gemini.txt | 10 ++++++++++ 2 files changed, 17 insertions(+) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-gemini.txt diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-codex.txt new file mode 100644 index 00000000..a2148307 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 2 correctly wires and tests a non-silencing, error-redacting Better Auth logger. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_2-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file From 2c4d9d518048c17a66b7f83399920d9738ac9b52 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:17:36 +1100 Subject: [PATCH 096/247] =?UTF-8?q?chore(porch):=20329=20advance=20plan=20?= =?UTF-8?q?phase=20=E2=86=92=20phase=5F3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../329-apps-auth-service-api-keys-bet/status.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index aa6fff5c..39f20855 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -8,10 +8,10 @@ plan_phases: status: complete - id: phase_2 title: Redacted Better Auth logger - status: in_progress + status: complete - id: phase_3 title: Reserved noreply domain - status: pending + status: in_progress - id: phase_4 title: Service users and key operator scripts status: pending @@ -24,7 +24,7 @@ plan_phases: - id: phase_7 title: End-to-end contract test, source scan and documentation status: pending -current_plan_phase: phase_2 +current_plan_phase: phase_3 gates: spec-approval: status: approved @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: true +build_complete: false history: - iteration: 1 plan_phase: phase_1 @@ -54,8 +54,10 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T06:29:55.770Z' +updated_at: '2026-10-28T07:17:36.215Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' acknowledged_at: '2026-10-28T06:20:29.672Z' + - boundary: plan-phase:phase_3 + at: '2026-10-28T07:17:36.215Z' From 8adaec419daa6fe085bfbfb5699daf118c2c0879 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:17:42 +1100 Subject: [PATCH 097/247] chore(porch): 329 acknowledge context refresh --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 39f20855..9623a7f6 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -54,10 +54,11 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T07:17:36.215Z' +updated_at: '2026-10-28T07:17:42.818Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' acknowledged_at: '2026-10-28T06:20:29.672Z' - boundary: plan-phase:phase_3 at: '2026-10-28T07:17:36.215Z' + acknowledged_at: '2026-10-28T07:17:42.817Z' From 6b1a64afb361b2a243d332961437254ecb0f6def Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:28:11 +1100 Subject: [PATCH 098/247] [Spec 329][Phase: phase_3] feat: reserve the noreply.askansari.ai domain on every public path --- apps/auth/scripts/grant-admin.ts | 16 +++++-- apps/auth/tests/grant-admin.test.ts | 11 +++++ apps/auth/tests/reset.test.ts | 19 ++++++++ .../329-apps-auth-service-api-keys-bet.md | 6 +++ codev/state/spir-329_thread.md | 9 ++++ packages/auth/src/index.ts | 11 ++++- packages/auth/src/reserved.ts | 38 +++++++++++++--- packages/auth/src/reset-email.ts | 12 ++--- .../auth/tests/config/credentials.test.ts | 14 ++++++ packages/auth/tests/config/sign-up.test.ts | 36 +++++++++++++++ packages/auth/tests/unit/reserved.test.ts | 45 +++++++++++++++++++ packages/auth/tests/unit/reset-email.test.ts | 10 +++++ 12 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 packages/auth/tests/unit/reserved.test.ts diff --git a/apps/auth/scripts/grant-admin.ts b/apps/auth/scripts/grant-admin.ts index 9d894a7f..835326d7 100644 --- a/apps/auth/scripts/grant-admin.ts +++ b/apps/auth/scripts/grant-admin.ts @@ -18,11 +18,11 @@ * the account the email resolves to grants nothing; the show step exits 2, so * a script that forgets --confirm stops. It warns on a recent or unverified * account (the squat signatures), never creates one, and refuses the system - * domain. A demotion takes effect at + * domain and the noreply domain (spec 329). A demotion takes effect at * once: apps/api asks for the role on each admin request. */ import 'dotenv/config'; -import { createDb, isSystemAddress, normalizeEmail, users, type Db } from '@ansari/auth'; +import { createDb, isNoreplyAddress, isSystemAddress, normalizeEmail, users, type Db } from '@ansari/auth'; export type GrantAdminCommand = | { kind: 'show'; email: string } @@ -66,6 +66,14 @@ export class SystemAddressError extends Error { } } +/** Service and WhatsApp users (spec 329) never sign in and never hold a role. */ +export class NoreplyAddressError extends Error { + constructor() { + super('A noreply-domain address is a service or WhatsApp user and is never granted admin.'); + this.name = 'NoreplyAddressError'; + } +} + /** An account younger than this is flagged: a squatted address is usually a fresh sign-up. */ export const RECENT_ACCOUNT_MS = 7 * 24 * 60 * 60 * 1000; @@ -110,6 +118,7 @@ export async function runGrantAdmin( return { lines: [`Revoked admin from ${email} (role=user).`], exitCode: 0 }; } if (isSystemAddress(email)) throw new SystemAddressError(); + if (isNoreplyAddress(email)) throw new NoreplyAddressError(); const user = await users.findUserByEmail(db, email); if (!user) throw new users.UserNotFoundError(); const warnings = squatWarnings(user, now); @@ -143,7 +152,8 @@ export function scriptError(err: unknown): string { if ( err instanceof users.UserNotFoundError || err instanceof ConfirmMismatchError || - err instanceof SystemAddressError + err instanceof SystemAddressError || + err instanceof NoreplyAddressError ) { return err.message; } diff --git a/apps/auth/tests/grant-admin.test.ts b/apps/auth/tests/grant-admin.test.ts index 5a6304d6..2082376d 100644 --- a/apps/auth/tests/grant-admin.test.ts +++ b/apps/auth/tests/grant-admin.test.ts @@ -11,6 +11,7 @@ import { RECENT_ACCOUNT_MS, runGrantAdmin, scriptError, + NoreplyAddressError, SystemAddressError, } from '../scripts/grant-admin'; import { signUpTokens, startService, type Service } from './helpers/service'; @@ -157,6 +158,15 @@ describe('grant-admin.ts', () => { } }); + it('refuses a noreply-domain address (service and WhatsApp users), before any lookup (spec 329)', async () => { + for (const command of [ + { kind: 'show', email: 'service-whatsapp@noreply.askansari.ai' }, + { kind: 'grant', email: ' WhatsApp-x@NoReply.AskAnsari.AI ', confirmId: 'x' }, + ] as const) { + await expect(runGrantAdmin(service.db, command)).rejects.toBeInstanceOf(NoreplyAddressError); + } + }); + it('revokes back to role user', async () => { const email = nextEmail('revoke'); await signUpTokens(service, email); @@ -205,6 +215,7 @@ describe('grant-admin.ts', () => { expect(scriptError(new users.UserNotFoundError())).toBe('No auth user has this email'); expect(scriptError(new ConfirmMismatchError())).toMatch(/Nothing was granted/); expect(scriptError(new SystemAddressError())).toMatch(/system-domain/); + expect(scriptError(new NoreplyAddressError())).toMatch(/noreply-domain/); }); }); diff --git a/apps/auth/tests/reset.test.ts b/apps/auth/tests/reset.test.ts index 00ca4135..24dce569 100644 --- a/apps/auth/tests/reset.test.ts +++ b/apps/auth/tests/reset.test.ts @@ -2,6 +2,8 @@ // plan phase 5): the emailed link has the legacy shape, an unknown email gets // the identical answer and no mail, a reset kills every session, and the // strength check runs before the password changes. +import { randomUUID } from 'node:crypto'; + import { RESET_EMAIL_FROM, RESET_EMAIL_SUBJECT, resetEmailHtml, resetLink } from '@ansari/auth'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -69,6 +71,23 @@ describe('the reset email', () => { expect(service.mail.slice(before).map((m) => m.to)).toEqual([known]); }); + it('an existing noreply user gets the response an unknown address gets, and no email (spec 329)', async () => { + const id = randomUUID(); + const noreply = `whatsapp-${id}@noreply.askansari.ai`; + await service.client.query(`INSERT INTO auth.users (id, name, email) VALUES ($1, 'WhatsApp user', $2)`, [ + id, + noreply, + ]); + const unknown = nextEmail('unknown-noreply-pair'); + const before = service.mail.length; + + const a = await requestReset(noreply); + const b = await requestReset(unknown); + expect(a.status).toBe(b.status); + expect(await a.json()).toEqual(await b.json()); + expect(service.mail.length).toBe(before); + }); + it('a system address receives no email', async () => { const before = service.mail.length; expect((await requestReset('ai-skill@system.ansari.chat')).status).toBe(200); diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md index b92d933a..c2c4110e 100644 --- a/codev/reviews/329-apps-auth-service-api-keys-bet.md +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -203,3 +203,9 @@ timing run 3 exit=1 fails=[@ansari/auth:test: FAIL tests/auth.http.test.ts [ t ``` This is a monorepo-wide matter (pglite cold starts against 30 s deadlines under contention), passed to the main architect as a follow-up; this PR fixes only the two files that blocked its own green runs. + +## Phase 3: reserved noreply domain (notes) + +- **Spec wording versus the guard order:** the spec's "sign-up with a noreply address (any case, surrounding spaces) returns the 409 body". Validation runs first in `signUpGuard`, so an address with surrounding spaces is an invalid email (400) for every address, reserved or not. The test asserts what holds: a noreply address and a taken address answer identically, padded or not (409 unpadded, 400 padded). There is still no oracle. +- Negative-tested: with the noreply domain removed from `isReservedAddress`, 8 tests go red. +- Green per package (`@ansari/auth` 220, `ansari-auth` 263, `ansari-api` 1113 + 3 skipped); typecheck and lint pass at the root. The root test run on the shared machine (load average 92/74/60 at the time) failed only with timeouts (27 hook, 6 test), across all four packages including the untouched frontend: the monorepo-wide matter recorded under Flaky Tests. diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index 4b2968f2..bae80173 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -67,3 +67,12 @@ - Probe 4a surprise (benign): sign-up with phone_number is REFUSED 400 FIELD_NOT_ALLOWED, not silently ignored; nothing stored. - The probe script lives in packages/auth/scripts/probes (apps/auth has no direct pg/drizzle dep), a deviation from the plan path. - The prefix rule was proven load-bearing by removing it (2 tests go red). + +## 2026-10-28: phases 2 and 3 + +- Phase 2 (redacted logger) approved. The flaky secrets/copy hooks got their own 100 s deadline (measured: max 47 s, N=6; 30 s red 4/5 and 3/5, 100 s green 5/5). + The shared machine (load 20-90 from other builders and simulators) makes the full root run red from timeouts in 5/6 runs across packages: + evidence in the review for main's monorepo-wide follow-up. Per-package runs are the reliable signal here. +- Skipped porch's context-refresh task at the phase_3 boundary (a second porch next moved on). +- Phase 3: isReservedAddress covers @noreply.askansari.ai; isSystemAddress is untouched. The reset sender skips reserved addresses; grant-admin refuses noreply. + The spec's "surrounding spaces → 409" doesn't match the guard order (validation first → 400 for all); the test asserts identical answers instead. diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 17720d0c..be793ba7 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -35,7 +35,16 @@ export { getEnv, resetEnvCache, type AuthEnv } from './env'; export { logBetterAuth, redactError, redactLogArgs } from './logger'; export * as limits from './limits'; export { consumeWindow, keysSeenSince, peekWindow, touchKey, type WindowState } from './rate-limit-store'; -export { isSystemAddress, normalizeEmail, SYSTEM_EMAIL_DOMAIN } from './reserved'; +export { + isNoreplyAddress, + isReservedAddress, + isSystemAddress, + normalizeEmail, + NOREPLY_EMAIL_DOMAIN, + serviceAddress, + SYSTEM_EMAIL_DOMAIN, + whatsappAddress, +} from './reserved'; export * as users from './users'; export { resetEmailHtml, diff --git a/packages/auth/src/reserved.ts b/packages/auth/src/reserved.ts index 25b470aa..cb60f2fe 100644 --- a/packages/auth/src/reserved.ts +++ b/packages/auth/src/reserved.ts @@ -1,13 +1,16 @@ // Addresses that public sign-up must refuse (spec 4, carried into spec 300). // Ported from apps/api/lib/auth/reserved.ts and system-accounts.ts. // -// Reserved = any address under the system domain. The configured-admin +// Reserved = any address under the system domain or, since spec 329, the +// non-interactive domain. The configured-admin // reservation was removed (owner amendment 2026-10-17): the role in auth.users // is the only admin truth, and grant-admin.ts confirms the account by id. // Refusing them with the SAME response as an existing // account keeps sign-up from becoming an oracle for which addresses are // privileged. System accounts live only in the app database (spec 300, Desired -// State item 9), so the auth database must never hold one of these addresses. +// State item 9), so the auth database must never hold a system address. It does +// hold non-interactive addresses, written only by the token exchange and the +// service-user script, never by a Better Auth path. export const SYSTEM_EMAIL_DOMAIN = '@system.ansari.chat'; @@ -22,9 +25,34 @@ export function isSystemAddress(email: string): boolean { } /** - * True when public sign-up must refuse this address. The email is normalized - * here, so no caller can bypass the check with a mixed-case address. + * The non-interactive domain (spec 329, owner decision 1): service users and + * WhatsApp users, who have no password and never sign in. Unlike the system + * domain, the auth database does hold these addresses, but only the token + * exchange and the service-user script create them, never a Better Auth path. + */ +export const NOREPLY_EMAIL_DOMAIN = '@noreply.askansari.ai'; + +/** True when the address is under the non-interactive domain. */ +export function isNoreplyAddress(email: string): boolean { + return normalizeEmail(email).endsWith(NOREPLY_EMAIL_DOMAIN); +} + +/** The address of a WhatsApp user: its own user id, never the BSUID or a phone number. */ +export function whatsappAddress(userId: string): string { + return `whatsapp-${userId}${NOREPLY_EMAIL_DOMAIN}`; +} + +/** The address of a service user, by its service name (e.g. `whatsapp`, `ansari-mcp`). */ +export function serviceAddress(serviceName: string): string { + return `service-${serviceName}${NOREPLY_EMAIL_DOMAIN}`; +} + +/** + * True when public sign-up must refuse this address: the system domain or the + * non-interactive domain. The email is normalized here, so no caller can + * bypass the check with a mixed-case address. */ export function isReservedAddress(email: string): boolean { - return isSystemAddress(normalizeEmail(email)); + const normalized = normalizeEmail(email); + return isSystemAddress(normalized) || isNoreplyAddress(normalized); } diff --git a/packages/auth/src/reset-email.ts b/packages/auth/src/reset-email.ts index 05820e55..a87d72c0 100644 --- a/packages/auth/src/reset-email.ts +++ b/packages/auth/src/reset-email.ts @@ -6,7 +6,7 @@ // Nothing here logs the address, the link, the token or the Resend key. import { Resend } from 'resend'; -import { isSystemAddress } from './reserved'; +import { isReservedAddress } from './reserved'; /** The same sender as apps/api/lib/email.ts. */ export const RESET_EMAIL_FROM = 'Ansari '; @@ -66,14 +66,16 @@ export function resendMailer(apiKey: string): Mailer { } /** - * better-auth's `sendResetPassword`. A system address never receives mail: it - * has no auth row, so better-auth never calls this for one, and the domain is - * refused here as well. The send is not awaited, as in apps/api today, so a + * better-auth's `sendResetPassword`. A reserved address never receives mail. A + * system address has no auth row, so better-auth never calls this for one; a + * non-interactive address (a WhatsApp or service user, spec 329) does have a + * row, so it is skipped here, and the caller gets the same response as for an + * unknown address. The send is not awaited, as in apps/api today, so a * known email answers as fast as an unknown one; a failure logs its error name. */ export function createResetPasswordSender(frontendUrl: string, mailer: Mailer) { return async ({ user, token }: { user: { email: string }; token: string }): Promise => { - if (isSystemAddress(user.email)) return; + if (isReservedAddress(user.email)) return; const html = resetEmailHtml(resetLink(frontendUrl, token)); void mailer({ from: RESET_EMAIL_FROM, diff --git a/packages/auth/tests/config/credentials.test.ts b/packages/auth/tests/config/credentials.test.ts index b6f14e6e..00cf3167 100644 --- a/packages/auth/tests/config/credentials.test.ts +++ b/packages/auth/tests/config/credentials.test.ts @@ -227,3 +227,17 @@ describe('password reset', () => { expect(reused.status).toBe(400); }); }); + +describe('a noreply user cannot sign in (spec 329)', () => { + it('a passwordless noreply user signs in exactly as an unknown address does', async () => { + const id = randomUUID(); + const noreply = `whatsapp-${id}@noreply.askansari.ai`; + await client.query(`INSERT INTO auth.users (id, name, email) VALUES ($1, 'WhatsApp user', $2)`, [id, noreply]); + + const a = (await signIn(auth, noreply, STRONG)).res; + const b = (await signIn(auth, `unknown-${id.slice(0, 8)}@example.com`, STRONG)).res; + expect(a.status).toBe(401); + expect(a.status).toBe(b.status); + expect(await a.json()).toEqual(await b.json()); + }); +}); diff --git a/packages/auth/tests/config/sign-up.test.ts b/packages/auth/tests/config/sign-up.test.ts index f1e02722..5b26093c 100644 --- a/packages/auth/tests/config/sign-up.test.ts +++ b/packages/auth/tests/config/sign-up.test.ts @@ -71,6 +71,8 @@ describe('guard order: validation → reserved/taken → strength', () => { it.each([ ['any @system.ansari.chat address', 'anything@system.ansari.chat'], ['the system domain in mixed case', 'Leaderboard@SYSTEM.Ansari.Chat'], + ['any @noreply.askansari.ai address (spec 329)', 'whatsapp-anything@noreply.askansari.ai'], + ['the noreply domain in mixed case', 'Service-WhatsApp@NoReply.AskAnsari.AI'], ])('reserves %s, and creates no row', async (_label, email) => { const res = await signUp({ email, password: STRONG, name: 'X' }); expect(res.status).toBe(409); @@ -78,6 +80,28 @@ describe('guard order: validation → reserved/taken → strength', () => { expect(await userRow(email.toLowerCase())).toBeUndefined(); }); + it('a noreply address gets the response a taken address gets, with or without surrounding spaces', async () => { + // Validation runs first, so surrounding spaces are an invalid email (400) for + // every address; the reserved and the taken address still answer identically. + for (const pad of ['', ' ']) { + const reserved = await signUp({ email: `${pad}service-whatsapp@noreply.askansari.ai${pad}`, password: STRONG, name: 'X' }); + const taken = await signUp({ email: `${pad}taken@example.com${pad}`, password: STRONG, name: 'X' }); + expect(reserved.status).toBe(pad ? 400 : 409); + expect(reserved.status).toBe(taken.status); + expect(await reserved.json()).toEqual(await taken.json()); + } + }); + + it.each([ + ['someone@askansari.ai'], + ['x@noreply.askansari.ai.example.com'], + ['x@notnoreply.askansari.ai'], + ])('a near miss of the noreply domain signs up normally: %s', async (email) => { + const res = await signUp({ email, password: STRONG, name: 'X' }); + expect(res.status).toBe(200); + expect(await userRow(email)).toBeDefined(); + }); + it('an address that would once have been a configured admin registers like any other (no admin list)', async () => { const res = await signUp({ email: TEST_ADMIN_EMAIL, password: STRONG, name: 'X' }); expect(res.status).toBe(200); @@ -136,6 +160,18 @@ describe('defence in depth: no path creates a reserved address', () => { ).rejects.toMatchObject({ statusCode: 409 }); expect(await userRow('direct@system.ansari.chat')).toBeUndefined(); }); + + it('the user create hook refuses a noreply address too (spec 329)', async () => { + const ctx = await auth.$context; + await expect( + ctx.internalAdapter.createUser({ + email: 'whatsapp-direct@noreply.askansari.ai', + name: 'Direct', + emailVerified: false, + }) + ).rejects.toMatchObject({ statusCode: 409 }); + expect(await userRow('whatsapp-direct@noreply.askansari.ai')).toBeUndefined(); + }); }); describe('names', () => { diff --git a/packages/auth/tests/unit/reserved.test.ts b/packages/auth/tests/unit/reserved.test.ts new file mode 100644 index 00000000..183e0a0f --- /dev/null +++ b/packages/auth/tests/unit/reserved.test.ts @@ -0,0 +1,45 @@ +// The non-interactive domain (spec 329, owner decision 1): `@noreply.askansari.ai` +// is reserved on every public path, beside the system domain, which is unchanged. +import { describe, expect, it } from 'vitest'; + +import { + isNoreplyAddress, + isReservedAddress, + isSystemAddress, + NOREPLY_EMAIL_DOMAIN, + serviceAddress, + SYSTEM_EMAIL_DOMAIN, + whatsappAddress, +} from '../../src/reserved'; + +describe('the noreply domain', () => { + it.each([ + ['whatsapp-3f2a9c1e-0000-4000-8000-000000000000@noreply.askansari.ai', true], + ['service-whatsapp@noreply.askansari.ai', true], + [' Anyone@NoReply.AskAnsari.AI ', true], + ['someone@askansari.ai', false], + ['x@noreply.askansari.ai.example.com', false], + ['x@notnoreply.askansari.ai', false], + ['noreply.askansari.ai@example.com', false], + ])('%s → reserved %s', (email, reserved) => { + expect(isNoreplyAddress(email)).toBe(reserved); + expect(isReservedAddress(email)).toBe(reserved); + }); + + it('builds the two address shapes from an id or a service name, never anything else', () => { + const id = '3f2a9c1e-0000-4000-8000-000000000000'; + expect(whatsappAddress(id)).toBe(`whatsapp-${id}@noreply.askansari.ai`); + expect(serviceAddress('ansari-mcp')).toBe('service-ansari-mcp@noreply.askansari.ai'); + expect(isReservedAddress(whatsappAddress(id))).toBe(true); + expect(isReservedAddress(serviceAddress('whatsapp'))).toBe(true); + expect(NOREPLY_EMAIL_DOMAIN).toBe('@noreply.askansari.ai'); + }); + + it('leaves the system domain as it was: system addresses are reserved, and are not noreply', () => { + expect(SYSTEM_EMAIL_DOMAIN).toBe('@system.ansari.chat'); + expect(isReservedAddress('ai-skill@system.ansari.chat')).toBe(true); + expect(isSystemAddress('ai-skill@system.ansari.chat')).toBe(true); + expect(isNoreplyAddress('ai-skill@system.ansari.chat')).toBe(false); + expect(isSystemAddress('service-whatsapp@noreply.askansari.ai')).toBe(false); + }); +}); diff --git a/packages/auth/tests/unit/reset-email.test.ts b/packages/auth/tests/unit/reset-email.test.ts index 1aceb0f6..ef35478e 100644 --- a/packages/auth/tests/unit/reset-email.test.ts +++ b/packages/auth/tests/unit/reset-email.test.ts @@ -42,6 +42,16 @@ describe('createResetPasswordSender', () => { expect(sent).toHaveLength(0); }); + it('refuses the noreply domain, in any case (spec 329)', async () => { + const sent: EmailMessage[] = []; + const send = createResetPasswordSender(FRONTEND, async (m) => { + sent.push(m); + }); + await send({ user: { email: 'whatsapp-x@noreply.askansari.ai' }, token: 't' }); + await send({ user: { email: ' Service-WhatsApp@NoReply.AskAnsari.AI ' }, token: 't' }); + expect(sent).toHaveLength(0); + }); + it('does not wait for delivery, so a known email answers as fast as an unknown one', async () => { const send = createResetPasswordSender(FRONTEND, () => new Promise(() => undefined)); const result = await Promise.race([ From 06da6bcceb2521e1e0332e2d6fe9dc4fc677b29e Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:32:51 +1100 Subject: [PATCH 099/247] chore(porch): 329 implement build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 9623a7f6..50c77479 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: phase_1 @@ -54,7 +54,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T07:17:42.818Z' +updated_at: '2026-10-28T07:32:51.686Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From f8f1032116a9e7e1a37978cccc222acaed0535b5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:34:40 +1100 Subject: [PATCH 100/247] [Spec 329][Phase: phase_3] docs: spec states the padded-address behaviour of the existing guard order --- .../329-phase_3-iter1-codex.txt | 7 +++++++ .../329-phase_3-iter1-gemini.txt | 10 ++++++++++ .../329-phase_3-iter1-rebuttals.md | 9 +++++++++ codev/specs/329-apps-auth-service-api-keys-bet.md | 2 +- 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-rebuttals.md diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-codex.txt new file mode 100644 index 00000000..11929869 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Reserved-domain coverage is solid, but padded noreply sign-ups violate the explicit 409 contract. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `signUpGuard` validates before normalization, so `" service-whatsapp@noreply.askansari.ai "` returns 400. The spec and phase deliverable require the same 409 response as a taken address. The test currently codifies the deviation instead of the required behavior. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-rebuttals.md new file mode 100644 index 00000000..fc3093b5 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-rebuttals.md @@ -0,0 +1,9 @@ +# Phase 3, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES (one issue); gemini COMMENT (skipped, `agy` not installed). + +**Padded noreply sign-up returns 400, not 409: disagree with changing the code; the spec text is corrected instead.** + +- `signUpGuard` validates the email before the reserved/taken check. That is the legacy register order (validation → reserved or taken → strength), pinned by spec 300 and by apps/api's frozen `/v2/users` contract suite. Trimming before validation would change sign-up for every address, which is outside this spec and touches a frozen contract. +- The property the criterion protects is "no oracle". It holds: a padded noreply address, a padded taken address and a padded fresh address all get the identical 400 `INVALID_EMAIL`, and unpadded noreply and taken addresses get the identical 409. The test asserts both. +- The spec's "(any case, surrounding spaces) → 409" was my drafting error: the existing guard never gave 409 for a padded address, the system domain included. The spec criterion is amended to state the real behaviour (same commit), and the security architect is told. diff --git a/codev/specs/329-apps-auth-service-api-keys-bet.md b/codev/specs/329-apps-auth-service-api-keys-bet.md index 8d34abbe..e51d248e 100644 --- a/codev/specs/329-apps-auth-service-api-keys-bet.md +++ b/codev/specs/329-apps-auth-service-api-keys-bet.md @@ -300,7 +300,7 @@ A user in this domain has no password, so they cannot sign in anywhere. A WhatsA - [ ] The alert thresholds are named constants in `limits.ts`; their values are set with owner decision 3. **Reserved domain** -- [ ] Sign-up with a `@noreply.askansari.ai` address (any case, surrounding spaces) returns the same 409 body as a taken address. +- [ ] Sign-up with a `@noreply.askansari.ai` address (any case) returns the same 409 body as a taken address. With surrounding spaces, sign-up's validation (which runs first, in the legacy register order) answers 400 for every address, reserved or not, so a padded noreply address and a padded taken address still answer identically (amended in phase 3: the original text promised 409 for a padded address, which the existing guard order never gives). - [ ] Near misses are not reserved: `someone@askansari.ai`, `x@noreply.askansari.ai.example.com` and `x@notnoreply.askansari.ai` sign up normally. - [ ] A reset request for an existing noreply user returns the same response as an unknown address, and the mailer is never called. - [ ] The `user.create.before` hook refuses both reserved domains on every Better Auth path. From b1cb3b3e4c74b419c9e8434333571741d4aff56d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:34:48 +1100 Subject: [PATCH 101/247] chore(porch): 329 implement re-iter (iter 2) --- .../status.yaml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 50c77479..8a7ac6d9 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -38,8 +38,8 @@ gates: status: pending verify-approval: status: pending -iteration: 1 -build_complete: true +iteration: 2 +build_complete: false history: - iteration: 1 plan_phase: phase_1 @@ -53,8 +53,20 @@ history: verdict: COMMENT file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_1-iter1-gemini.txt + - iteration: 1 + plan_phase: phase_3 + build_output: '' + reviews: + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-codex.txt + - model: gemini + verdict: COMMENT + file: >- + /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T07:32:51.686Z' +updated_at: '2026-10-28T07:34:48.035Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From 80f6e9f08e15445e4c7b264d4df45608a0943fa3 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:34:58 +1100 Subject: [PATCH 102/247] chore(porch): 329 implement build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 8a7ac6d9..3ed106c2 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 2 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: phase_1 @@ -66,7 +66,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T07:34:48.035Z' +updated_at: '2026-10-28T07:34:58.501Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From eeb026d49da916d02e4fe5a5914ebe240e76bc32 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:35:20 +1100 Subject: [PATCH 103/247] [Spec 329][Phase: phase_3] test: pin no-oracle as a byte comparison across padded and unpadded address classes --- .../329-phase_3-iter2-context.md | 23 ++++++++++++++ packages/auth/tests/config/sign-up.test.ts | 31 +++++++++++++------ 2 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-context.md diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-context.md new file mode 100644 index 00000000..151bf581 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-context.md @@ -0,0 +1,23 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Reserved-domain coverage is solid, but padded noreply sign-ups violate the explicit 409 contract. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 3, iteration 1: rebuttal + +Reviews: codex REQUEST_CHANGES (one issue); gemini COMMENT (skipped, `agy` not installed). + +**Padded noreply sign-up returns 400, not 409: disagree with changing the code; the spec text is corrected instead.** + +- `signUpGuard` validates the email before the reserved/taken check. That is the legacy register order (validation → reserved or taken → strength), pinned by spec 300 and by apps/api's frozen `/v2/users` contract suite. Trimming before validation would change sign-up for every address, which is outside this spec and touches a frozen contract. +- The property the criterion protects is "no oracle". It holds: a padded noreply address, a padded taken address and a padded fresh address all get the identical 400 `INVALID_EMAIL`, and unpadded noreply and taken addresses get the identical 409. The test asserts both. +- The spec's "(any case, surrounding spaces) → 409" was my drafting error: the existing guard never gave 409 for a padded address, the system domain included. The spec criterion is amended to state the real behaviour (same commit), and the security architect is told. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/packages/auth/tests/config/sign-up.test.ts b/packages/auth/tests/config/sign-up.test.ts index 5b26093c..67367d39 100644 --- a/packages/auth/tests/config/sign-up.test.ts +++ b/packages/auth/tests/config/sign-up.test.ts @@ -80,16 +80,27 @@ describe('guard order: validation → reserved/taken → strength', () => { expect(await userRow(email.toLowerCase())).toBeUndefined(); }); - it('a noreply address gets the response a taken address gets, with or without surrounding spaces', async () => { - // Validation runs first, so surrounding spaces are an invalid email (400) for - // every address; the reserved and the taken address still answer identically. - for (const pad of ['', ' ']) { - const reserved = await signUp({ email: `${pad}service-whatsapp@noreply.askansari.ai${pad}`, password: STRONG, name: 'X' }); - const taken = await signUp({ email: `${pad}taken@example.com${pad}`, password: STRONG, name: 'X' }); - expect(reserved.status).toBe(pad ? 400 : 409); - expect(reserved.status).toBe(taken.status); - expect(await reserved.json()).toEqual(await taken.json()); - } + it('the answer depends only on the input shape, never on reserved / taken / fresh (no oracle)', async () => { + // Validation runs first (the legacy register order), so a padded address is + // one identical 400 for every class; unpadded, a noreply and a taken address + // are one identical 409. Compared byte for byte, not as separate statuses. + const fresh = `fresh-${Date.now()}@example.com`; + const answer = async (email: string) => { + const res = await signUp({ email, password: STRONG, name: 'X' }); + return { status: res.status, body: await res.text() }; + }; + const padded = await Promise.all( + ['service-whatsapp@noreply.askansari.ai', 'taken@example.com', fresh].map((e) => answer(` ${e} `)) + ); + expect(padded[0]!.status).toBe(400); + expect(padded[1]).toEqual(padded[0]); + expect(padded[2]).toEqual(padded[0]); + expect(await userRow(fresh)).toBeUndefined(); + + const noreply = await answer('service-whatsapp@noreply.askansari.ai'); + const taken = await answer('taken@example.com'); + expect(noreply.status).toBe(409); + expect(taken).toEqual(noreply); }); it.each([ From 9e2991ef81225621aad016a140de9e988eaaa15a Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:36:09 +1100 Subject: [PATCH 104/247] [Spec 329][Phase: phase_3] chore: phase 3 review record --- .../329-phase_3-iter2-codex.txt | 7 +++++++ .../329-phase_3-iter2-gemini.txt | 10 ++++++++++ 2 files changed, 17 insertions(+) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-gemini.txt diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-codex.txt new file mode 100644 index 00000000..c63c7a69 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 3 correctly reserves the noreply domain across signup, reset, creation hooks, sign-in, and admin grants, with adequate tests. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file From 511ec7ca77833ed14cd78a91e8e3d8a4cb42098c Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:36:09 +1100 Subject: [PATCH 105/247] =?UTF-8?q?chore(porch):=20329=20advance=20plan=20?= =?UTF-8?q?phase=20=E2=86=92=20phase=5F4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../329-apps-auth-service-api-keys-bet/status.yaml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 3ed106c2..174c21f6 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -11,10 +11,10 @@ plan_phases: status: complete - id: phase_3 title: Reserved noreply domain - status: in_progress + status: complete - id: phase_4 title: Service users and key operator scripts - status: pending + status: in_progress - id: phase_5 title: WhatsApp identity and session core status: pending @@ -24,7 +24,7 @@ plan_phases: - id: phase_7 title: End-to-end contract test, source scan and documentation status: pending -current_plan_phase: phase_3 +current_plan_phase: phase_4 gates: spec-approval: status: approved @@ -38,8 +38,8 @@ gates: status: pending verify-approval: status: pending -iteration: 2 -build_complete: true +iteration: 1 +build_complete: false history: - iteration: 1 plan_phase: phase_1 @@ -66,7 +66,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T07:34:58.501Z' +updated_at: '2026-10-28T07:36:09.447Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' @@ -74,3 +74,5 @@ context_refreshes: - boundary: plan-phase:phase_3 at: '2026-10-28T07:17:36.215Z' acknowledged_at: '2026-10-28T07:17:42.817Z' + - boundary: plan-phase:phase_4 + at: '2026-10-28T07:36:09.447Z' From d93945a1be314f67df21b2266b3d17879300c34e Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:37:19 +1100 Subject: [PATCH 106/247] chore(porch): 329 acknowledge context refresh --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 174c21f6..beb2f02c 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -66,7 +66,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T07:36:09.447Z' +updated_at: '2026-10-28T07:37:18.999Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' @@ -76,3 +76,4 @@ context_refreshes: acknowledged_at: '2026-10-28T07:17:42.817Z' - boundary: plan-phase:phase_4 at: '2026-10-28T07:36:09.447Z' + acknowledged_at: '2026-10-28T07:37:18.998Z' From 7561cbf43e1e38b04d90f76ea927bc6c69160a3f Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:53:43 +1100 Subject: [PATCH 107/247] [Spec 329][Phase: phase_4] feat: service users and the service-keys operator script Service users resolved by their service account row; keys created, listed, rotated, re-limited and revoked in process in grant-admin's posture, with the raw key printed once, hostname:port only, and a Sentry record per write (banner and exit 3 when it cannot be sent). urlHost moves to a shared helper. A pglite harness runs every subcommand. --- apps/auth/Dockerfile | 5 +- .../scripts/cutover/rehearsal-anomalies.ts | 12 +- apps/auth/scripts/probes/service-keys-demo.ts | 83 ++ apps/auth/scripts/service-keys.ts | 551 +++++++++++++ apps/auth/scripts/url-host.ts | 23 + apps/auth/tests/cutover/copy.test.ts | 2 +- apps/auth/tests/service-keys.test.ts | 747 ++++++++++++++++++ .../329-apps-auth-service-api-keys-bet.md | 36 + codev/state/spir-329_thread.md | 9 + packages/auth/src/index.ts | 1 + packages/auth/src/limits.ts | 46 ++ packages/auth/src/service-users.ts | 135 ++++ 12 files changed, 1637 insertions(+), 13 deletions(-) create mode 100644 apps/auth/scripts/probes/service-keys-demo.ts create mode 100644 apps/auth/scripts/service-keys.ts create mode 100644 apps/auth/scripts/url-host.ts create mode 100644 apps/auth/tests/service-keys.test.ts create mode 100644 packages/auth/src/service-users.ts diff --git a/apps/auth/Dockerfile b/apps/auth/Dockerfile index 9bd80287..17a4c55a 100644 --- a/apps/auth/Dockerfile +++ b/apps/auth/Dockerfile @@ -4,9 +4,10 @@ # # Same shape as apps/api/Dockerfile, on purpose: single stage, the install CI # verifies, real environment variables injected by Railway at RUNTIME and none -# baked in. The full install is kept (not prod-pruned) because the two +# baked in. The full install is kept (not prod-pruned) because the # operator scripts can run in this image, from /repo/apps/auth: -# ./node_modules/.bin/tsx scripts/grant-admin.ts … (and scripts/ban-user.ts) +# ./node_modules/.bin/tsx scripts/grant-admin.ts … (and scripts/ban-user.ts, +# scripts/service-keys.ts) # They need tsx and the raw-TS @ansari/auth workspace link, and need no psql; # they also run from a checkout with the auth DATABASE_URL. The cutover itself # (copy, audits, migrations) runs on the owner's machine over Tailscale, not diff --git a/apps/auth/scripts/cutover/rehearsal-anomalies.ts b/apps/auth/scripts/cutover/rehearsal-anomalies.ts index 6df82378..51285dbc 100644 --- a/apps/auth/scripts/cutover/rehearsal-anomalies.ts +++ b/apps/auth/scripts/cutover/rehearsal-anomalies.ts @@ -30,6 +30,8 @@ */ import { createRequire } from 'node:module'; +import { urlHost } from '../url-host'; + // apps/auth depends on bcrypt but not on its type package (the library code // that uses it lives in packages/auth); these are the two calls this script makes. const bcrypt = createRequire(import.meta.url)('bcrypt') as { @@ -49,16 +51,6 @@ const forwarderHost = (env: CutoverEnv) => `^[a-z0-9]([a-z0-9-]*[a-z0-9])?-${env}-tailscale-forwarder(-[0-9]+)?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?\\.ts\\.net)?\\.?$` ); -/** The host of a postgres URL, lower-cased, or null when it does not parse. */ -export function urlHost(url: string | undefined): string | null { - if (!url) return null; - try { - return new URL(url).hostname.toLowerCase() || null; - } catch { - return null; - } -} - export function isForwarderHost(host: string | null, env: CutoverEnv): boolean { return host !== null && forwarderHost(env).test(host); } diff --git a/apps/auth/scripts/probes/service-keys-demo.ts b/apps/auth/scripts/probes/service-keys-demo.ts new file mode 100644 index 00000000..03dd4644 --- /dev/null +++ b/apps/auth/scripts/probes/service-keys-demo.ts @@ -0,0 +1,83 @@ +/** + * Runnable harness for the service-keys script (spec 329, plan phase 4): a + * throwaway pglite auth database built from packages/auth's journal, every + * subcommand run in the runbook's order, and each output printed as the + * operator would see it. The documented output is checked by running this, + * not by reading it. Nothing leaves the process: Sentry events go to a + * recorder that prints them. The database URL is the placeholder the test + * env sets; its user, password and path must not appear below. + * + * pnpm --filter ansari-auth exec tsx scripts/probes/service-keys-demo.ts + */ +import { createAuth, limits } from '@ansari/auth'; + +import { drizzleFor, migrate, setAuthEnv } from '../../../../packages/auth/tests/helpers/auth-db'; +import { serviceKeys, type KeyEvent, type Recorder } from '../service-keys'; + +const recorder: Recorder = { + capture(event: KeyEvent) { + console.log(` [sentry event] ${JSON.stringify(event)}`); + }, + flush: async () => true, +}; + +const initial = limits.WHATSAPP_KEY_INITIAL; +const LIMITS = [ + '--rate-max', String(initial.rateLimitMax), + '--rate-window', String(initial.rateLimitTimeWindow / 1000), + '--daily', String(initial.refillAmount), +]; + +async function main(): Promise { + setAuthEnv('http://localhost:3100'); + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error('setAuthEnv sets DATABASE_URL'); + const client = await migrate(); + const db = drizzleFor(client); + const auth = createAuth(db); + const transcript: string[] = []; + + async function step(argv: string[]): Promise { + console.log(`\n$ tsx scripts/service-keys.ts ${argv.join(' ')}`); + const { lines, exitCode } = await serviceKeys(argv, { databaseUrl, connect: () => ({ db, auth }), recorder }); + for (const line of lines) console.log(line); + console.log(`(exit ${exitCode})`); + transcript.push(...lines); + return lines; + } + + const idIn = (lines: string[], pattern: RegExp) => { + const id = lines.map((l) => pattern.exec(l)?.[1]).find(Boolean); + if (!id) throw new Error(`no match for ${pattern} in the output`); + return id; + }; + + await step(['create-service', 'whatsapp']); + await step(['create-service', 'whatsapp', '--confirm', 'wrong']); + await step(['create-service', 'whatsapp', '--confirm', 'whatsapp']); + const shown = await step(['create-key', 'whatsapp', ...LIMITS, '--client-id', 'whatsapp-service']); + const userId = idIn(shown, /^ {2}id {10}([0-9a-f-]{36})$/); + const first = idIn(await step(['create-key', 'whatsapp', ...LIMITS, '--client-id', 'whatsapp-service', '--confirm', userId]), /^Created key ([0-9a-f-]{36})/); + await step(['list', 'whatsapp']); + await step(['rotate', 'whatsapp', ...LIMITS, '--confirm', userId]); + await step(['update-limits', 'whatsapp', '--key', first, '--rate-max', '120', '--rate-window', '60', '--daily', '6000', '--confirm', userId]); + await step(['update-limits', 'whatsapp', '--key', first, '--rate-max', '100001', '--rate-window', '60', '--daily', '6000', '--confirm', userId]); + await step(['create-key', 'whatsapp', ...LIMITS, '--expires-in', '86400', '--confirm', userId]); + await step(['revoke', 'whatsapp', '--key', first]); + await step(['revoke', 'whatsapp', '--key', first, '--confirm', userId]); + await step(['list', 'whatsapp']); + await client.close(); + + const text = transcript.join('\n'); + const { username, password, pathname, protocol } = new URL(databaseUrl); + const leaks = [databaseUrl, `${username}:${password}@`, `${pathname}`, `${protocol}//`].filter((s) => text.includes(s)); + const keys = text.match(/wa_[A-Za-z]{64}/g) ?? []; + console.log(`\nCHECK database printed as hostname:port only: ${leaks.length === 0 ? 'PASS' : `FAIL (${leaks.join(', ')})`}`); + console.log(`CHECK two keys created, each printed once: ${keys.length === 2 && new Set(keys).size === 2 ? 'PASS' : `FAIL (${keys.length})`}`); + process.exit(leaks.length === 0 && keys.length === 2 ? 0 : 1); +} + +main().catch((err: unknown) => { + console.error('demo failed:', err instanceof Error ? err.name : 'unknown'); + process.exit(1); +}); diff --git a/apps/auth/scripts/service-keys.ts b/apps/auth/scripts/service-keys.ts new file mode 100644 index 00000000..a1500a8e --- /dev/null +++ b/apps/auth/scripts/service-keys.ts @@ -0,0 +1,551 @@ +/** + * Service users and their API keys (spec 329). The api-key plugin's HTTP + * surface is blocked, so this script is the only way to create a service user + * or to issue, list, rotate, change or revoke its keys. It runs against the + * AUTH database (`DATABASE_URL` in apps/auth's env) and needs apps/auth's full + * env, since keys are created through Better Auth in process. + * + * Usage (from apps/auth/): + * tsx scripts/service-keys.ts create-service [--confirm ] + * tsx scripts/service-keys.ts create-key --rate-max --rate-window --daily + * [--name ] [--client-id ] [--confirm ] + * tsx scripts/service-keys.ts list + * tsx scripts/service-keys.ts rotate (the create-key flags) + * tsx scripts/service-keys.ts update-limits --key --rate-max --rate-window + * --daily [--confirm ] + * tsx scripts/service-keys.ts revoke --key [--confirm ] + * + * Every write is two steps, as in grant-admin.ts: without `--confirm` the + * script shows the service user and what it would do, writes nothing and + * exits 2; only `--confirm ` writes (for + * create-service, which has no user yet, `--confirm `). A + * mismatch writes nothing and exits 2. + * + * - It prints the database as `hostname:port` only, never the URL. + * - Permissions and the key prefix are fixed per service (SERVICES); limits are + * required flags with no defaults, checked against limits.ts's bounds. The + * budget is daily and always refills: a budget without a refill deletes the + * key the first time it is exhausted. There is no expiry option: a key lives + * until it is rotated and revoked, as the runbook schedules. + * - The raw key is printed once, by create-key or rotate; nothing else prints + * a key or a hash. Revoke sets `enabled = false` and keeps the row. + * - Every write sends one Sentry event (action, service, ids, name; no key + * material). If it cannot be flushed, the write stands, a banner asks for + * the runbook's key log, and the script exits 3. + */ +import 'dotenv/config'; +import * as Sentry from '@sentry/node'; + +import { + createAuth, + createDb, + getEnv, + limits, + serviceAddress, + serviceUsers, + type Auth, + type Db, +} from '@ansari/auth'; + +import { initSentry } from '../src/alerts'; +import { urlHostPort } from './url-host'; + +type ServiceName = serviceUsers.ServiceName; +type KeyLimits = limits.KeyLimits; + +type KeyFlags = { limits: KeyLimits; name: string | null; clientId: string | null }; + +export type ServiceKeysCommand = + | { kind: 'create-service'; service: ServiceName; confirm: string | null } + | ({ kind: 'create-key'; service: ServiceName; confirm: string | null } & KeyFlags) + | ({ kind: 'rotate'; service: ServiceName; confirm: string | null } & KeyFlags) + | { kind: 'list'; service: ServiceName } + | { kind: 'update-limits'; service: ServiceName; keyId: string; limits: KeyLimits; confirm: string | null } + | { kind: 'revoke'; service: ServiceName; keyId: string; confirm: string | null }; + +export const USAGE = [ + 'Usage: tsx scripts/service-keys.ts create-service [--confirm ]', + ' tsx scripts/service-keys.ts create-key --rate-max --rate-window --daily [--name ] [--client-id ] [--confirm ]', + ' tsx scripts/service-keys.ts list ', + ' tsx scripts/service-keys.ts rotate --rate-max --rate-window --daily [--name ] [--client-id ] [--confirm ]', + ' tsx scripts/service-keys.ts update-limits --key --rate-max --rate-window --daily [--confirm ]', + ' tsx scripts/service-keys.ts revoke --key [--confirm ]', + `Services: ${Object.keys(serviceUsers.SERVICES).join(', ')}`, +].join('\n'); + +/** The exit code of a run that wrote nothing on purpose (show step, confirm mismatch). */ +export const EXIT_NOTHING_WRITTEN = 2; +/** The exit code of a write whose Sentry record could not be sent. */ +export const EXIT_UNRECORDED = 3; + +/** How long the script waits for Sentry before it prints the key-log banner. */ +export const SENTRY_FLUSH_TIMEOUT_MS = 5_000; + +const SECOND_MS = 1000; + +/** A refused input or state. Its message is the script's own words, safe to print. */ +export class RefusedError extends Error { + constructor(message: string) { + super(message); + this.name = 'RefusedError'; + } +} + +const NO_EXPIRY = + 'Keys have no expiry option: a production key must not die silently. Rotate and revoke it as the runbook schedules.'; +const NO_BARE_BUDGET = + 'A budget without a refill deletes the key the first time it is exhausted. Use --daily, which always refills every 24 h.'; + +const FLAGS_BY_KIND: Record = { + 'create-service': ['--confirm'], + 'create-key': ['--rate-max', '--rate-window', '--daily', '--name', '--client-id', '--confirm'], + rotate: ['--rate-max', '--rate-window', '--daily', '--name', '--client-id', '--confirm'], + list: [], + 'update-limits': ['--key', '--rate-max', '--rate-window', '--daily', '--confirm'], + revoke: ['--key', '--confirm'], +}; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const isKind = (value: string | undefined): value is ServiceKeysCommand['kind'] => + value !== undefined && Object.hasOwn(FLAGS_BY_KIND, value); + +function positiveInteger(flag: string, value: string): number { + const n = Number(value); + if (!/^\d+$/.test(value) || !Number.isSafeInteger(n)) { + throw new RefusedError(`${flag} must be a whole number.`); + } + return n; +} + +/** + * The limits a key gets from its flags, all three required. The window is + * given in seconds and stored in milliseconds; the budget always refills daily. + */ +function limitsFrom(flags: Map): KeyLimits { + const missing = ['--rate-max', '--rate-window', '--daily'].filter((f) => !flags.has(f)); + if (missing.length > 0) { + throw new RefusedError(`Limits have no defaults; missing ${missing.join(', ')}.`); + } + return { + rateLimitMax: positiveInteger('--rate-max', flags.get('--rate-max')!), + rateLimitTimeWindow: positiveInteger('--rate-window', flags.get('--rate-window')!) * SECOND_MS, + refillAmount: positiveInteger('--daily', flags.get('--daily')!), + refillInterval: limits.KEY_REFILL_INTERVAL, + }; +} + +/** Parses the command line; throws RefusedError with the reason when it is not a valid command. */ +export function parseArgs(argv: readonly string[]): ServiceKeysCommand { + const [kind, service, ...rest] = argv; + if (!isKind(kind) || service === undefined || service.startsWith('--')) throw new RefusedError(USAGE); + if (!serviceUsers.isServiceName(service)) { + throw new RefusedError(`Unknown service "${service}". Services: ${Object.keys(serviceUsers.SERVICES).join(', ')}.`); + } + const flags = new Map(); + for (let i = 0; i < rest.length; i += 2) { + const [flag = '', value] = [rest[i], rest[i + 1]]; + if (/^--expir/.test(flag)) throw new RefusedError(NO_EXPIRY); + if (flag === '--remaining') throw new RefusedError(NO_BARE_BUDGET); + if (!FLAGS_BY_KIND[kind].includes(flag)) throw new RefusedError(`${flag} is not an option of ${kind}.\n${USAGE}`); + if (flags.has(flag)) throw new RefusedError(`${flag} is given twice.`); + if (value === undefined || value.length === 0 || value.startsWith('--')) { + throw new RefusedError(`${flag} needs a value.`); + } + flags.set(flag, value); + } + const confirm = flags.get('--confirm') ?? null; + const keyId = (): string => { + const id = flags.get('--key'); + if (id === undefined) throw new RefusedError(`${kind} needs --key (see list).`); + if (!UUID.test(id)) throw new RefusedError('--key must be a key id as list prints it.'); + return id.toLowerCase(); + }; + switch (kind) { + case 'create-service': + return { kind, service, confirm }; + case 'list': + return { kind, service }; + case 'create-key': + case 'rotate': + return { + kind, + service, + confirm, + limits: limitsFrom(flags), + name: flags.get('--name') ?? null, + clientId: flags.get('--client-id') ?? null, + }; + case 'update-limits': + return { kind, service, keyId: keyId(), limits: limitsFrom(flags), confirm }; + case 'revoke': + return { kind, service, keyId: keyId(), confirm }; + } +} + +/** + * The last check before the plugin is called: inside the service's bounds, + * and a budget only with its daily refill. A key whose `remaining` has no + * refill is deleted by the plugin on exhaustion. + */ +export function checkKeyLimits(service: ServiceName, values: Partial): KeyLimits { + const { rateLimitMax, rateLimitTimeWindow, refillAmount, refillInterval } = values; + if (refillAmount === undefined || refillInterval !== limits.KEY_REFILL_INTERVAL) { + throw new RefusedError(NO_BARE_BUDGET); + } + if (rateLimitMax === undefined || rateLimitTimeWindow === undefined) { + throw new RefusedError('A key needs a rate limit: --rate-max and --rate-window.'); + } + const bounds = serviceUsers.SERVICES[service].bounds; + const outside = (value: number, b: { min: number; max: number }) => value < b.min || value > b.max; + const problems = [ + outside(rateLimitMax, bounds.rateLimitMax) && + `--rate-max must be ${bounds.rateLimitMax.min} to ${bounds.rateLimitMax.max}`, + outside(rateLimitTimeWindow, bounds.rateLimitTimeWindow) && + `--rate-window must be ${bounds.rateLimitTimeWindow.min / SECOND_MS} to ${bounds.rateLimitTimeWindow.max / SECOND_MS} seconds`, + outside(refillAmount, bounds.refillAmount) && + `--daily must be ${bounds.refillAmount.min} to ${bounds.refillAmount.max}`, + ].filter((p): p is string => typeof p === 'string'); + if (problems.length > 0) throw new RefusedError(`Out of bounds (limits.ts): ${problems.join('; ')}.`); + return { rateLimitMax, rateLimitTimeWindow, refillAmount, refillInterval }; +} + +/** One durable record of a write: never key material. */ +export type KeyEvent = { + action: Exclude; + service: ServiceName; + userId: string; + keyId: string | null; + name: string | null; +}; + +export type ScriptResult = { lines: string[]; exitCode: number; events: KeyEvent[] }; + +export type ScriptContext = { db: Db; auth: Auth; now?: Date }; + +const describeLimits = (l: { + rateLimitMax: number | null; + rateLimitTimeWindow: number | null; + refillAmount: number | null; + refillInterval: number | null; +}) => + `${l.rateLimitMax} per ${(l.rateLimitTimeWindow ?? 0) / SECOND_MS} s; ` + + `budget ${l.refillAmount} per ${(l.refillInterval ?? 0) / 3_600_000} h`; + +const iso = (d: Date | null) => (d ? d.toISOString() : 'never'); + +function keyLines(key: serviceUsers.ServiceKey): string[] { + return [ + ` key ${key.id}`, + ` name ${key.name ?? ''}`, + ` prefix ${key.prefix ?? ''}`, + ` permissions ${key.permissions ?? ''}`, + ` enabled ${key.enabled}`, + ` limits ${describeLimits(key)}; remaining ${key.remaining}`, + ` created ${iso(key.createdAt)}`, + ` last request ${iso(key.lastRequest)}`, + ]; +} + +const command = (argv: string) => ` tsx scripts/service-keys.ts ${argv}`; + +/** The service user every key command acts for; refuses none and many. */ +async function serviceUser(db: Db, service: ServiceName): Promise { + const found = await serviceUsers.findServiceUser(db, service); + if (found.kind === 'none') { + throw new RefusedError(`No service user for "${service}". Create it first: create-service ${service}.`); + } + if (found.kind === 'many') { + throw new RefusedError( + `${found.ids.length} users claim service "${service}" (${found.ids.join(', ')}); refusing to act. Resolve this by hand.` + ); + } + return found.user; +} + +const userLines = (service: ServiceName, user: serviceUsers.ServiceUser) => [ + `Service user for ${service}:`, + ` id ${user.id}`, + ` email ${user.email}`, + ` created_at ${user.createdAt.toISOString()}`, +]; + +const nothingWritten = (lines: string[]): ScriptResult => ({ lines, exitCode: EXIT_NOTHING_WRITTEN, events: [] }); + +const mismatch = (expected: string): ScriptResult => + nothingWritten([ + `The --confirm value is not ${expected}. Nothing was written. Run without --confirm to see it.`, + ]); + +/** Key flags in the form a show step tells the operator to rerun. */ +const flagsText = (l: KeyLimits) => + `--rate-max ${l.rateLimitMax} --rate-window ${l.rateLimitTimeWindow / SECOND_MS} --daily ${l.refillAmount}`; + +const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +async function createKey( + ctx: ScriptContext, + service: ServiceName, + userId: string, + flags: KeyFlags +): Promise<{ id: string; key: string; name: string }> { + const checked = checkKeyLimits(service, flags.limits); + const { permissions, prefix } = serviceUsers.SERVICES[service]; + // Default name: the service and the minute it was created, so a rotation's two keys differ. + const name = flags.name ?? `${service} ${(ctx.now ?? new Date()).toISOString().slice(0, 16)}Z`; + const created = await ctx.auth.api.createApiKey({ + body: { + userId, + prefix, + name, + permissions: Object.fromEntries(Object.entries(permissions).map(([k, v]) => [k, [...v]])), + metadata: { clientId: flags.clientId ?? service }, + rateLimitEnabled: true, + rateLimitTimeWindow: checked.rateLimitTimeWindow, + rateLimitMax: checked.rateLimitMax, + remaining: checked.refillAmount, + refillAmount: checked.refillAmount, + refillInterval: checked.refillInterval, + }, + }); + return { id: created.id, key: created.key, name }; +} + +/** The new key, framed so the operator sees it is shown once and where it goes. */ +const rawKeyLines = (key: string) => [ + '================ NEW KEY: SHOWN ONCE, NEVER STORED ================', + key, + "Put it in the service's credential store now, as the runbook says. It cannot be shown again.", + '===================================================================', +]; + +async function ownedKey(db: Db, userId: string, keyId: string): Promise { + const key = (await serviceUsers.listServiceKeys(db, userId)).find((k) => k.id === keyId); + if (!key) throw new RefusedError(`Key ${keyId} is not a key of this service (see list).`); + return key; +} + +/** Runs one command and returns the lines to print, the exit code and the events to record. */ +export async function runServiceKeys(ctx: ScriptContext, cmd: ServiceKeysCommand): Promise { + const { db } = ctx; + if (cmd.kind === 'create-service') { + const found = await serviceUsers.findServiceUser(db, cmd.service); + if (found.kind !== 'none') { + throw new RefusedError(`Service "${cmd.service}" already has a service user; nothing was written.`); + } + if (cmd.confirm === null) { + return nothingWritten([ + `No service user for ${cmd.service}. This would create:`, + ` email ${serviceAddress(cmd.service)} (no credential, role user)`, + ` account provider service, account id ${cmd.service}`, + 'Nothing was written. To create it, run:', + command(`create-service ${cmd.service} --confirm ${cmd.service}`), + ]); + } + if (cmd.confirm !== cmd.service) return mismatch(`the service name (${cmd.service})`); + const user = await serviceUsers.createServiceUser(db, cmd.service); + return { + lines: [`Created service user ${user.id} (${user.email}) for ${cmd.service}.`], + exitCode: 0, + events: [{ action: cmd.kind, service: cmd.service, userId: user.id, keyId: null, name: null }], + }; + } + + const user = await serviceUser(db, cmd.service); + const keys = await serviceUsers.listServiceKeys(db, user.id); + + if (cmd.kind === 'list') { + return { + lines: [...userLines(cmd.service, user), `${keys.length} key(s):`, ...keys.flatMap(keyLines)], + exitCode: 0, + events: [], + }; + } + + if (cmd.kind === 'create-key' || cmd.kind === 'rotate') { + checkKeyLimits(cmd.service, cmd.limits); + const live = keys.filter((k) => k.enabled); + if (cmd.kind === 'rotate' && live.length === 0) { + throw new RefusedError(`Service "${cmd.service}" has no live key to rotate; use create-key.`); + } + const extra = [cmd.name && `--name ${quote(cmd.name)}`, cmd.clientId && `--client-id ${quote(cmd.clientId)}`] + .filter(Boolean) + .join(' '); + if (cmd.confirm === null) { + return nothingWritten([ + ...userLines(cmd.service, user), + `${live.length} live key(s): ${live.map((k) => k.id).join(', ') || 'none'}`, + `This would create a key with ${describeLimits(cmd.limits)}.`, + 'Nothing was written. If this is the service you mean, run:', + command(`${cmd.kind} ${cmd.service} ${flagsText(cmd.limits)}${extra ? ` ${extra}` : ''} --confirm ${user.id}`), + ]); + } + if (cmd.confirm !== user.id) return mismatch(`this service user's id`); + const created = await createKey(ctx, cmd.service, user.id, cmd); + const rotated = + cmd.kind === 'rotate' + ? [ + `Old live key(s): ${live.map((k) => k.id).join(', ')}`, + 'Once the consumer runs on the new key, revoke each old one:', + ...live.map((k) => command(`revoke ${cmd.service} --key ${k.id} --confirm ${user.id}`)), + 'Until then both keys are live and the daily budget is doubled; keep the overlap short.', + ] + : []; + return { + lines: [ + `Created key ${created.id} (${created.name}) for ${cmd.service}: ${describeLimits(cmd.limits)}.`, + ...rawKeyLines(created.key), + ...rotated, + ], + exitCode: 0, + events: [{ action: cmd.kind, service: cmd.service, userId: user.id, keyId: created.id, name: created.name }], + }; + } + + const key = await ownedKey(db, user.id, cmd.keyId); + if (cmd.kind === 'update-limits') { + const checked = checkKeyLimits(cmd.service, cmd.limits); + if (cmd.confirm === null) { + return nothingWritten([ + ...userLines(cmd.service, user), + ...keyLines(key), + `This would set ${describeLimits(checked)}, and today's remaining budget to ${checked.refillAmount}.`, + 'Nothing was written. To apply it, run:', + command(`update-limits ${cmd.service} --key ${key.id} ${flagsText(checked)} --confirm ${user.id}`), + ]); + } + if (cmd.confirm !== user.id) return mismatch(`this service user's id`); + // The new budget starts now: `remaining` is set to it with the refill. + await ctx.auth.api.updateApiKey({ + body: { keyId: key.id, userId: user.id, ...checked, remaining: checked.refillAmount }, + }); + return { + lines: [`Updated key ${key.id} (${key.name ?? ''}): ${describeLimits(checked)}.`], + exitCode: 0, + events: [{ action: cmd.kind, service: cmd.service, userId: user.id, keyId: key.id, name: key.name }], + }; + } + + if (cmd.confirm === null) { + return nothingWritten([ + ...userLines(cmd.service, user), + ...keyLines(key), + 'This would revoke the key (enabled = false; the row is kept).', + 'Nothing was written. To revoke it, run:', + command(`revoke ${cmd.service} --key ${key.id} --confirm ${user.id}`), + ]); + } + if (cmd.confirm !== user.id) return mismatch(`this service user's id`); + await ctx.auth.api.updateApiKey({ body: { keyId: key.id, userId: user.id, enabled: false } }); + return { + lines: [`Revoked key ${key.id} (${key.name ?? ''}): enabled = false; the row is kept.`], + exitCode: 0, + events: [{ action: cmd.kind, service: cmd.service, userId: user.id, keyId: key.id, name: key.name }], + }; +} + +/** Where the durable record goes: Sentry in the script, a spy transport in tests. */ +export type Recorder = { + capture: (event: KeyEvent) => void; + /** True when every captured event was sent within the timeout. */ + flush: (timeoutMs: number) => Promise; +}; + +export const KEY_EVENT_MESSAGE = 'auth: service key operator action'; + +export const sentryRecorder: Recorder = { + capture(event) { + Sentry.captureMessage(KEY_EVENT_MESSAGE, { + level: 'info', + tags: { alert: 'auth-service-key', action: event.action, service: event.service }, + extra: { ...event }, + }); + }, + async flush(timeoutMs) { + try { + return await Sentry.flush(timeoutMs); + } catch { + return false; + } + }, +}; + +function unrecordedBanner(events: KeyEvent[]): string[] { + const bar = '!'.repeat(72); + return [ + bar, + '!! THE SENTRY RECORD OF THIS ACTION WAS NOT SENT. THE WRITE ABOVE STANDS.', + "!! Record it by hand in the runbook's key log now:", + ...events.map( + (e) => `!! action ${e.action}, service ${e.service}, user ${e.userId}, key ${e.keyId ?? '-'}, name ${e.name ?? '-'}` + ), + bar, + ]; +} + +/** A refusal is reported in full; anything else by its name only (no driver detail, no key). */ +export function scriptError(err: unknown): string { + if (err instanceof RefusedError) return err.message; + return err instanceof Error ? err.name : 'unknown'; +} + +export type ScriptDeps = { + databaseUrl: string | undefined; + connect: () => ScriptContext; + recorder: Recorder; + flushTimeoutMs?: number; +}; + +/** + * The whole script: database URL check, parse, `hostname:port`, run, and the + * durable record. Returns every line to print and the exit code. + */ +export async function serviceKeys(argv: readonly string[], deps: ScriptDeps): Promise<{ lines: string[]; exitCode: number }> { + if (!deps.databaseUrl) { + return { lines: ['DATABASE_URL is not set (the AUTH database). Refusing to run.'], exitCode: 1 }; + } + const host = urlHostPort(deps.databaseUrl); + if (host === null) return { lines: ['DATABASE_URL does not parse as a URL. Refusing to run.'], exitCode: 1 }; + const lines = [`Auth database: ${host}`]; + let result: ScriptResult; + try { + const cmd = parseArgs(argv); + result = await runServiceKeys(deps.connect(), cmd); + } catch (err) { + return { lines: [...lines, `service-keys refused: ${scriptError(err)}`], exitCode: 1 }; + } + lines.push(...result.lines); + if (result.events.length === 0) return { lines, exitCode: result.exitCode }; + for (const event of result.events) deps.recorder.capture(event); + if (await deps.recorder.flush(deps.flushTimeoutMs ?? SENTRY_FLUSH_TIMEOUT_MS)) { + return { lines, exitCode: result.exitCode }; + } + return { lines: [...lines, ...unrecordedBanner(result.events)], exitCode: EXIT_UNRECORDED }; +} + +async function main(): Promise { + initSentry(process.env.SENTRY_DSN, process.env.NODE_ENV ?? 'development'); + const { lines, exitCode } = await serviceKeys(process.argv.slice(2), { + databaseUrl: process.env.DATABASE_URL, + connect: () => { + try { + getEnv(); + } catch (err) { + // Names the missing variables, never a value. + throw new RefusedError(err instanceof Error ? err.message : 'apps/auth env is incomplete'); + } + const db = createDb(); + return { db, auth: createAuth(db) }; + }, + recorder: sentryRecorder, + }); + for (const line of lines) console.log(line); + process.exit(exitCode); +} + +if (process.argv[1]?.endsWith('service-keys.ts')) { + main().catch((err: unknown) => { + console.error('service-keys failed:', scriptError(err)); + process.exit(1); + }); +} diff --git a/apps/auth/scripts/url-host.ts b/apps/auth/scripts/url-host.ts new file mode 100644 index 00000000..5ca02404 --- /dev/null +++ b/apps/auth/scripts/url-host.ts @@ -0,0 +1,23 @@ +// What an operator script may say about a database URL: its host, never the +// user, password, path, query string or a parse error (any of which can carry +// a credential). Shared by the cutover rehearsal and the service-keys script. + +/** The host of a postgres URL, lower-cased, or null when it does not parse. */ +export function urlHost(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).hostname.toLowerCase() || null; + } catch { + return null; + } +} + +/** Postgres's port when a URL names none. */ +const DEFAULT_PORT = '5432'; + +/** `hostname:port` of a postgres URL, or null when it does not parse. */ +export function urlHostPort(url: string | undefined): string | null { + const host = urlHost(url); + if (host === null || url === undefined) return null; + return `${host}:${new URL(url).port || DEFAULT_PORT}`; +} diff --git a/apps/auth/tests/cutover/copy.test.ts b/apps/auth/tests/cutover/copy.test.ts index fb2e71ff..e67dcb79 100644 --- a/apps/auth/tests/cutover/copy.test.ts +++ b/apps/auth/tests/cutover/copy.test.ts @@ -28,8 +28,8 @@ import { isForwarderHost, makeHashes, refusal, - urlHost, } from '../../scripts/cutover/rehearsal-anomalies'; +import { urlHost } from '../../scripts/url-host'; import { runPsqlFile } from './psql'; const here = dirname(fileURLToPath(import.meta.url)); diff --git a/apps/auth/tests/service-keys.test.ts b/apps/auth/tests/service-keys.test.ts new file mode 100644 index 00000000..b77ed940 --- /dev/null +++ b/apps/auth/tests/service-keys.test.ts @@ -0,0 +1,747 @@ +// The service-keys operator script (spec 329, plan phase 4): service users, +// and keys issued, listed, rotated, changed and revoked in grant-admin.ts's +// posture, against the auth database on pglite. Script functions are driven +// directly; Sentry through a spy transport. +import { createHash } from 'node:crypto'; + +import * as Sentry from '@sentry/node'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { limits, serviceUsers } from '@ansari/auth'; + +import { + checkKeyLimits, + EXIT_NOTHING_WRITTEN, + EXIT_UNRECORDED, + KEY_EVENT_MESSAGE, + parseArgs, + RefusedError, + sentryRecorder, + serviceKeys, + type KeyEvent, + type Recorder, +} from '../scripts/service-keys'; +import { urlHostPort } from '../scripts/url-host'; +import { startService, type Service } from './helpers/service'; + +/** The plan's URL: a user, a password and a query string, none of which may be printed. */ +const DB_URL = 'postgres://user:secret@db.example:6543/auth?sslmode=require'; +/** + * The same shape with distinctive userinfo: the output says "user" in its own + * words ("Service user for …"), so this one shows the URL's user is not echoed. + */ +const DB_URL_DISTINCT = 'postgres://opsname-zq:pw-zq-9@db.example:6543/authdb-zq?sslmode=verify-full&application_name=zq'; + +const INITIAL = limits.WHATSAPP_KEY_INITIAL; +const LIMIT_FLAGS = ['--rate-max', '60', '--rate-window', '60', '--daily', '3000']; +const WA_KEY = /wa_[A-Za-z]{64}/g; + +let service: Service; + +beforeAll(async () => { + service = await startService(); +}); +afterAll(async () => { + await service.close(); +}); + +beforeEach(async () => { + // Service users are one per name: each test starts without any (keys and + // the `service` rows go with the cascade). + await service.client.query(`delete from auth.users where registered_via = 'service'`); +}); + +/** A recorder that keeps events in memory and flushes as told. */ +function memoryRecorder(flushed = true): Recorder & { events: KeyEvent[]; flushes: number } { + return { + events: [], + flushes: 0, + capture(event) { + this.events.push(event); + }, + async flush() { + this.flushes += 1; + return flushed; + }, + }; +} + +type RunOptions = { url?: string | undefined; recorder?: Recorder; flushTimeoutMs?: number }; + +async function run(argv: string[], opts: RunOptions = {}) { + const connect = vi.fn(() => ({ db: service.db, auth: service.auth })); + const result = await serviceKeys(argv, { + databaseUrl: 'url' in opts ? opts.url : DB_URL, + connect, + recorder: opts.recorder ?? memoryRecorder(), + flushTimeoutMs: opts.flushTimeoutMs, + }); + return { ...result, text: result.lines.join('\n'), connect }; +} + +async function serviceUserId(name = 'whatsapp'): Promise { + const found = await serviceUsers.findServiceUser(service.db, name as serviceUsers.ServiceName); + if (found.kind !== 'one') throw new Error(`expected one service user, got ${found.kind}`); + return found.user.id; +} + +async function createService(name = 'whatsapp'): Promise { + const { exitCode } = await run(['create-service', name, '--confirm', name]); + expect(exitCode).toBe(0); + return serviceUserId(name); +} + +/** create-key (or rotate) with --confirm; returns the key's id and the raw key from the output. */ +async function createKey(kind: 'create-key' | 'rotate' = 'create-key', flags = LIMIT_FLAGS, name = 'whatsapp') { + const userId = await serviceUserId(name); + const result = await run([kind, name, ...flags, '--confirm', userId]); + expect(result.exitCode, result.text).toBe(0); + const prefix = serviceUsers.SERVICES[name as serviceUsers.ServiceName].prefix; + const key = result.lines.find((l) => l.startsWith(prefix) && l.length > prefix.length + 60); + const id = /Created key ([0-9a-f-]{36})/.exec(result.text)?.[1]; + if (!key || !id) throw new Error('no key in the output'); + return { ...result, key, id, userId }; +} + +async function keyRow(id: string) { + const { rows } = await service.client.query>('select * from auth.apikey where id = $1', [id]); + return rows[0]; +} + +async function rowCounts() { + const { rows } = await service.client.query<{ users: number; accounts: number; keys: number }>( + `select (select count(*)::int from auth.users where email like 'service-%') as users, + (select count(*)::int from auth.account where provider_id = 'service') as accounts, + (select count(*)::int from auth.apikey) as keys` + ); + return rows[0]; +} + +const verify = (key: string, permissions: Record = { whatsapp: ['mint'] }) => + service.auth.api.verifyApiKey({ body: { key, permissions } }); + +const occurrences = (text: string, needle: string) => text.split(needle).length - 1; + +describe('parseArgs', () => { + it('parses each subcommand, the window in seconds stored as milliseconds', () => { + expect(parseArgs(['create-service', 'whatsapp'])).toEqual({ kind: 'create-service', service: 'whatsapp', confirm: null }); + expect(parseArgs(['list', 'ansari-mcp'])).toEqual({ kind: 'list', service: 'ansari-mcp' }); + expect(parseArgs(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--name', 'wa main', '--client-id', 'wa-svc'])).toEqual({ + kind: 'create-key', + service: 'whatsapp', + confirm: null, + limits: INITIAL, + name: 'wa main', + clientId: 'wa-svc', + }); + const id = '3f2a9c1e-0000-4000-8000-00000000000A'; + expect(parseArgs(['revoke', 'whatsapp', '--key', id, '--confirm', 'u'])).toEqual({ + kind: 'revoke', + service: 'whatsapp', + keyId: id.toLowerCase(), + confirm: 'u', + }); + expect(parseArgs(['update-limits', 'whatsapp', '--key', id, ...LIMIT_FLAGS])).toMatchObject({ + kind: 'update-limits', + limits: INITIAL, + }); + }); + + it.each([ + [['create-key', 'whatsapp', ...LIMIT_FLAGS, '--expires-in', '86400'], /no expiry/], + [['create-key', 'whatsapp', ...LIMIT_FLAGS, '--expiresIn', '86400'], /no expiry/], + [['rotate', 'whatsapp', ...LIMIT_FLAGS, '--expiration', '1'], /no expiry/], + [['create-key', 'whatsapp', '--rate-max', '60', '--rate-window', '60', '--remaining', '100'], /without a refill/], + [['create-key', 'whatsapp', '--rate-max', '60', '--rate-window', '60'], /no defaults; missing --daily/], + [['create-key', 'whatsapp', '--daily', '10'], /missing --rate-max, --rate-window/], + [['create-key', 'whatsapp', ...LIMIT_FLAGS, '--permissions', 'x'], /not an option/], + [['create-key', 'whatsapp', ...LIMIT_FLAGS, '--daily', '5'], /given twice/], + [['create-key', 'whatsapp', '--rate-max', '1.5', '--rate-window', '60', '--daily', '1'], /whole number/], + [['create-key', 'whatsapp', '--rate-max', '-1', '--rate-window', '60', '--daily', '1'], /needs a value|whole number/], + [['create-key', 'leaderboard', ...LIMIT_FLAGS], /Unknown service/], + [['revoke', 'whatsapp'], /needs --key/], + [['revoke', 'whatsapp', '--key', 'not-an-id'], /key id as list prints it/], + [['list', 'whatsapp', '--confirm', 'x'], /not an option of list/], + [['create-service', 'whatsapp', '--confirm'], /needs a value/], + [['delete', 'whatsapp'], /Usage/], + [[], /Usage/], + ])('refuses %j', (argv, reason) => { + expect(() => parseArgs(argv)).toThrow(RefusedError); + expect(() => parseArgs(argv)).toThrow(reason); + }); +}); + +describe('checkKeyLimits', () => { + const bounds = limits.WHATSAPP_KEY_BOUNDS; + + it('accepts the initial values and both ends of every bound', () => { + expect(checkKeyLimits('whatsapp', INITIAL)).toEqual(INITIAL); + for (const edge of ['min', 'max'] as const) { + const values = { + rateLimitMax: bounds.rateLimitMax[edge], + rateLimitTimeWindow: bounds.rateLimitTimeWindow[edge], + refillAmount: bounds.refillAmount[edge], + refillInterval: limits.KEY_REFILL_INTERVAL, + }; + expect(checkKeyLimits('whatsapp', values)).toEqual(values); + } + }); + + it('refuses a budget without a refill, and a key without a rate limit', () => { + const { refillAmount, refillInterval, ...rest } = INITIAL; + expect(() => checkKeyLimits('whatsapp', { ...rest, refillAmount })).toThrow(/without a refill/); + expect(() => checkKeyLimits('whatsapp', { ...rest, refillInterval })).toThrow(/without a refill/); + expect(() => checkKeyLimits('whatsapp', { ...INITIAL, refillInterval: 3_600_000 })).toThrow(/without a refill/); + expect(() => checkKeyLimits('whatsapp', { refillAmount, refillInterval })).toThrow(/needs a rate limit/); + }); + + it.each([ + ['rateLimitMax', bounds.rateLimitMax.min - 1, /--rate-max/], + ['rateLimitMax', bounds.rateLimitMax.max + 1, /--rate-max/], + ['rateLimitTimeWindow', bounds.rateLimitTimeWindow.min - 1, /--rate-window/], + ['rateLimitTimeWindow', bounds.rateLimitTimeWindow.max + 1, /--rate-window/], + ['refillAmount', bounds.refillAmount.min - 1, /--daily/], + ['refillAmount', bounds.refillAmount.max + 1, /--daily/], + ])('refuses %s = %d', (field, value, reason) => { + expect(() => checkKeyLimits('whatsapp', { ...INITIAL, [field]: value })).toThrow(reason); + }); +}); + +describe('urlHostPort', () => { + it('is hostname and port only, with postgres default port, and null when the URL does not parse', () => { + expect(urlHostPort(DB_URL)).toBe('db.example:6543'); + expect(urlHostPort('postgresql://u:p@DB.Example/auth')).toBe('db.example:5432'); + expect(urlHostPort('not a url with secret')).toBeNull(); + expect(urlHostPort(undefined)).toBeNull(); + }); +}); + +describe('every subcommand', () => { + const KEY_ID = '00000000-0000-4000-8000-000000000001'; + /** Each subcommand as an operator would type it, before --confirm. */ + const SUBCOMMANDS: Record = { + 'create-service': ['create-service', 'whatsapp'], + 'create-key': ['create-key', 'whatsapp', ...LIMIT_FLAGS], + list: ['list', 'whatsapp'], + rotate: ['rotate', 'whatsapp', ...LIMIT_FLAGS], + 'update-limits': ['update-limits', 'whatsapp', '--key', KEY_ID, ...LIMIT_FLAGS], + revoke: ['revoke', 'whatsapp', '--key', KEY_ID], + }; + + it.each(Object.keys(SUBCOMMANDS))('%s refuses to run without a database URL, before connecting', async (name) => { + for (const url of [undefined, '']) { + const { exitCode, text, connect } = await run([...SUBCOMMANDS[name]!, '--confirm', 'x'], { url }); + expect(exitCode).toBe(1); + expect(text).toMatch(/DATABASE_URL is not set/); + expect(connect).not.toHaveBeenCalled(); + } + const unparsable = await run(SUBCOMMANDS[name]!, { url: 'postgres://user:secret@[bad' }); + expect(unparsable.exitCode).toBe(1); + expect(unparsable.text).toMatch(/does not parse/); + expect(unparsable.text).not.toMatch(/secret|\[bad/); + expect(unparsable.connect).not.toHaveBeenCalled(); + }); + + it.each(Object.keys(SUBCOMMANDS))('%s refuses a mismatched --confirm and writes nothing', async (name) => { + let argv = SUBCOMMANDS[name]!; + if (name !== 'create-service') await createService(); + if (name === 'rotate' || name === 'update-limits' || name === 'revoke') { + const { id } = await createKey(); + argv = argv.map((arg) => (arg === KEY_ID ? id : arg)); + } + const before = await rowCounts(); + const { rows: keysBefore } = await service.client.query('select * from auth.apikey order by id'); + const recorder = memoryRecorder(); + const otherId = '00000000-0000-4000-8000-0000000000ff'; + const result = await run([...argv, '--confirm', otherId], { recorder }); + if (name === 'list') { + // list writes nothing and takes no --confirm at all. + expect(result.exitCode).toBe(1); + expect(result.text).toMatch(/not an option of list/); + } else { + expect(result.exitCode).toBe(EXIT_NOTHING_WRITTEN); + expect(result.text).toMatch(/Nothing was written/); + } + expect(await rowCounts()).toEqual(before); + expect((await service.client.query('select * from auth.apikey order by id')).rows).toEqual(keysBefore); + expect(recorder.events).toEqual([]); + }); + + it.each(Object.keys(SUBCOMMANDS))( + '%s prints the database as hostname:port only, never the URL, its user, password or query', + async (name) => { + let argv = SUBCOMMANDS[name]!; + if (name !== 'create-service') { + const userId = await createService(); + const { id } = await createKey(); + argv = argv.map((a) => (a === KEY_ID ? id : a)); + if (name !== 'list') argv = [...argv, '--confirm', userId]; + } else { + argv = [...argv, '--confirm', 'whatsapp']; + } + for (const url of [DB_URL, DB_URL_DISTINCT]) { + // A write subcommand runs again against the second URL: undo the first write. + if (url === DB_URL_DISTINCT && name === 'create-service') { + await service.client.query(`delete from auth.users where registered_via = 'service'`); + } + const { text, exitCode } = await run(argv, { url }); + expect(exitCode, text).toBe(0); + expect(text.split('\n')[0]).toBe('Auth database: db.example:6543'); + expect(text).toContain('db.example:6543'); + for (const leak of ['user:secret', 'secret', 'sslmode', '/auth?', 'opsname-zq', 'pw-zq', 'authdb-zq', 'verify-full', 'application_name', 'postgres://']) { + expect(text, leak).not.toContain(leak); + } + } + } + ); + + it.each(['create-key', 'rotate', 'update-limits', 'revoke'])('%s logs the key id it acted on', async (name) => { + const userId = await createService(); + const first = await createKey(); + if (name === 'create-key') { + expect(first.text).toContain(`Created key ${first.id}`); + return; + } + const argv = + name === 'rotate' + ? ['rotate', 'whatsapp', ...LIMIT_FLAGS] + : name === 'update-limits' + ? ['update-limits', 'whatsapp', '--key', first.id, ...LIMIT_FLAGS] + : ['revoke', 'whatsapp', '--key', first.id]; + const { text, exitCode } = await run([...argv, '--confirm', userId]); + expect(exitCode).toBe(0); + expect(text).toContain(first.id); + if (name === 'rotate') expect(text).toMatch(/Created key [0-9a-f-]{36}/); + }); +}); + +describe('create-service', () => { + it('shows what it would create, writes nothing and exits 2', async () => { + const before = await rowCounts(); + const { exitCode, text } = await run(['create-service', 'whatsapp']); + expect(exitCode).toBe(EXIT_NOTHING_WRITTEN); + expect(text).toContain('service-whatsapp@noreply.askansari.ai'); + expect(text).toContain('--confirm whatsapp'); + expect(await rowCounts()).toEqual(before); + }); + + it('creates the user (no credential, role user) and its service row, and records it', async () => { + const recorder = memoryRecorder(); + const { exitCode, text } = await run(['create-service', 'whatsapp', '--confirm', 'whatsapp'], { recorder }); + expect(exitCode).toBe(0); + const id = await serviceUserId(); + expect(text).toContain(id); + const { rows } = await service.client.query>( + `select u.email, u.role, u.registered_via, a.provider_id, a.account_id, a.password + from auth.users u join auth.account a on a.user_id = u.id where u.id = $1`, + [id] + ); + expect(rows).toEqual([ + { + email: 'service-whatsapp@noreply.askansari.ai', + role: 'user', + registered_via: 'service', + provider_id: 'service', + account_id: 'whatsapp', + password: null, + }, + ]); + expect(recorder.events).toEqual([{ action: 'create-service', service: 'whatsapp', userId: id, keyId: null, name: null }]); + }); + + it('refuses an existing service name', async () => { + await createService(); + for (const argv of [['create-service', 'whatsapp'], ['create-service', 'whatsapp', '--confirm', 'whatsapp']]) { + const { exitCode, text } = await run(argv); + expect(exitCode).toBe(1); + expect(text).toMatch(/already has a service user/); + } + expect((await rowCounts())?.users).toBe(1); + }); + + it('writes the user and the service row in one transaction: a failed row leaves neither', async () => { + // Another user already holds the (service, whatsapp) row, so the second insert fails. + const { rows } = await service.client.query<{ id: string }>( + `insert into auth.users (name, email) values ('squatter', 'squatter-${Date.now()}@example.com') returning id` + ); + await service.client.query( + `insert into auth.account (account_id, provider_id, user_id, updated_at) values ('whatsapp', 'service', $1, now())`, + [rows[0]!.id] + ); + await expect(serviceUsers.createServiceUser(service.db, 'whatsapp')).rejects.toThrow(); + const { rows: users } = await service.client.query(`select id from auth.users where email = 'service-whatsapp@noreply.askansari.ai'`); + expect(users).toEqual([]); + await service.client.query('delete from auth.users where id = $1', [rows[0]!.id]); + }); + + it('findServiceUser reports none, one and many', async () => { + expect(await serviceUsers.findServiceUser(service.db, 'whatsapp')).toEqual({ kind: 'none' }); + const id = await createService(); + expect(await serviceUsers.findServiceUser(service.db, 'whatsapp')).toMatchObject({ kind: 'one', user: { id } }); + // Only possible without unique(provider_id, account_id): drop it for this check. + await service.client.query('alter table auth.account drop constraint account_provider_id_account_id_unique'); + try { + const { rows } = await service.client.query<{ id: string }>( + `insert into auth.users (name, email, registered_via) values ('x', 'second-service@noreply.askansari.ai', 'service') returning id` + ); + await service.client.query( + `insert into auth.account (account_id, provider_id, user_id, updated_at) values ('whatsapp', 'service', $1, now())`, + [rows[0]!.id] + ); + expect(await serviceUsers.findServiceUser(service.db, 'whatsapp')).toMatchObject({ kind: 'many' }); + const { exitCode, text } = await run(['list', 'whatsapp']); + expect(exitCode).toBe(1); + expect(text).toMatch(/2 users claim service "whatsapp"; refusing|2 users claim service "whatsapp" \(/); + } finally { + await service.client.query(`delete from auth.users where registered_via = 'service'`); + await service.client.query( + 'alter table auth.account add constraint account_provider_id_account_id_unique unique (provider_id, account_id)' + ); + } + }); + + it('key commands refuse a service with no user', async () => { + const { exitCode, text } = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS]); + expect(exitCode).toBe(1); + expect(text).toMatch(/No service user for "whatsapp"/); + }); +}); + +describe('create-key', () => { + it('shows the service user and the limits, writes nothing and exits 2', async () => { + const userId = await createService(); + const { exitCode, text } = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--name', "wa o'main"]); + expect(exitCode).toBe(EXIT_NOTHING_WRITTEN); + expect(text).toContain(`id ${userId}`); + expect(text).toContain('60 per 60 s; budget 3000 per 24 h'); + expect(text).toContain(`--name 'wa o'\\''main' --confirm ${userId}`); + expect((await rowCounts())?.keys).toBe(0); + }); + + it('prints the raw key exactly once, and the row holds only its hash', async () => { + await createService(); + const { key, id, text, userId } = await createKey('create-key', [...LIMIT_FLAGS, '--client-id', 'wa-partner']); + expect(occurrences(text, key)).toBe(1); + expect(text.match(WA_KEY)).toHaveLength(1); + expect(text).toMatch(/SHOWN ONCE/); + + const row = await keyRow(id); + for (const [column, value] of Object.entries(row!)) { + if (typeof value === 'string') expect(value, column).not.toContain(key.slice(3)); + } + // The plugin's hash: SHA-256, base64url (packages/auth's probe 1 pins it to defaultKeyHasher). + expect(row!.key).toBe(createHash('sha256').update(key).digest('base64url')); + expect(text).not.toContain(row!.key as string); + expect(row).toMatchObject({ + reference_id: userId, + prefix: 'wa_', + start: null, + enabled: true, + rate_limit_enabled: true, + rate_limit_max: INITIAL.rateLimitMax, + rate_limit_time_window: INITIAL.rateLimitTimeWindow, + remaining: INITIAL.refillAmount, + refill_amount: INITIAL.refillAmount, + refill_interval: INITIAL.refillInterval, + expires_at: null, + }); + expect(JSON.parse(row!.permissions as string)).toEqual({ whatsapp: ['mint'] }); + expect(JSON.parse(row!.metadata as string)).toEqual({ clientId: 'wa-partner' }); + expect((await verify(key)).valid).toBe(true); + // A whatsapp key is refused at another service's permission. + expect((await verify(key, { mcp: ['complete'] })).valid).toBe(false); + }); + + it('refuses out-of-bounds limits before writing', async () => { + const userId = await createService(); + const { exitCode, text } = await run([ + 'create-key', 'whatsapp', '--rate-max', '100001', '--rate-window', '60', '--daily', '3000', '--confirm', userId, + ]); + expect(exitCode).toBe(1); + expect(text).toMatch(/Out of bounds.*--rate-max/); + expect((await rowCounts())?.keys).toBe(0); + }); + + it('refuses an expiry option and a bare budget at the command line', async () => { + const userId = await createService(); + for (const extra of [['--expires-in', '86400'], ['--remaining', '10']]) { + const { exitCode, text } = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, ...extra, '--confirm', userId]); + expect(exitCode).toBe(1); + expect(text).toMatch(/no expiry|without a refill/); + } + expect((await rowCounts())?.keys).toBe(0); + }); +}); + +describe('list', () => { + it('prints id, name, prefix, permissions, enabled, limits and dates per key; never a key or a hash', async () => { + await createService(); + const a = await createKey('create-key', [...LIMIT_FLAGS, '--name', 'wa-a']); + const b = await createKey('rotate', [...LIMIT_FLAGS, '--name', 'wa-b']); + await verify(a.key); + const { exitCode, text } = await run(['list', 'whatsapp']); + expect(exitCode).toBe(0); + expect(text).toContain('2 key(s):'); + for (const { id, key } of [a, b]) { + expect(text).toContain(`key ${id}`); + expect(text).not.toContain(key); + expect(text).not.toContain(key.slice(3, 20)); + expect(text).not.toContain((await keyRow(id))!.key as string); + } + for (const field of ['name wa-a', 'name wa-b', 'prefix wa_', 'permissions {"whatsapp":["mint"]}', 'enabled true', 'limits 60 per 60 s; budget 3000 per 24 h', 'created 20', 'last request 20', 'last request never']) { + expect(text).toContain(field); + } + expect(text.match(WA_KEY)).toBeNull(); + }); +}); + +describe('rotate', () => { + it('creates a second live key, both verify; prints the new key once and the old key id', async () => { + await createService(); + const old = await createKey(); + const fresh = await createKey('rotate'); + expect(fresh.id).not.toBe(old.id); + expect(occurrences(fresh.text, fresh.key)).toBe(1); + expect(fresh.text.match(WA_KEY)).toHaveLength(1); + expect(fresh.text).toContain(`Old live key(s): ${old.id}`); + expect(fresh.text).toContain(`revoke whatsapp --key ${old.id}`); + expect((await verify(old.key)).valid).toBe(true); + expect((await verify(fresh.key)).valid).toBe(true); + }); + + it('refuses when there is no live key to rotate', async () => { + const userId = await createService(); + const { exitCode, text } = await run(['rotate', 'whatsapp', ...LIMIT_FLAGS, '--confirm', userId]); + expect(exitCode).toBe(1); + expect(text).toMatch(/no live key to rotate/); + }); +}); + +describe('revoke', () => { + it('shows the key and writes nothing without --confirm', async () => { + await createService(); + const { id } = await createKey(); + const { exitCode, text } = await run(['revoke', 'whatsapp', '--key', id]); + expect(exitCode).toBe(EXIT_NOTHING_WRITTEN); + expect(text).toContain(`key ${id}`); + expect((await keyRow(id))!.enabled).toBe(true); + }); + + it('sets enabled = false and keeps the row; the key no longer verifies; prints the id, no key', async () => { + const userId = await createService(); + const { id, key } = await createKey(); + const { exitCode, text } = await run(['revoke', 'whatsapp', '--key', id, '--confirm', userId]); + expect(exitCode).toBe(0); + expect(text).toContain(`Revoked key ${id}`); + expect(text).toContain('enabled = false'); + expect(text).not.toContain(key); + const row = await keyRow(id); + expect(row).toBeDefined(); + expect(row!.enabled).toBe(false); + const result = await verify(key); + expect(result.valid).toBe(false); + expect(await keyRow(id)).toBeDefined(); + }); + + it("refuses a key that is not the service's", async () => { + const userId = await createService(); + await createService('ansari-mcp'); + const mcp = await createKey('create-key', LIMIT_FLAGS, 'ansari-mcp'); + const { exitCode, text } = await run(['revoke', 'whatsapp', '--key', mcp.id, '--confirm', userId]); + expect(exitCode).toBe(1); + expect(text).toMatch(/not a key of this service/); + expect((await keyRow(mcp.id))!.enabled).toBe(true); + }); +}); + +describe('update-limits', () => { + it('writes the new values, enforced at the next verify', async () => { + const userId = await createService(); + const { id, key } = await createKey(); + const { exitCode, text } = await run([ + 'update-limits', 'whatsapp', '--key', id, '--rate-max', '1', '--rate-window', '30', '--daily', '500', '--confirm', userId, + ]); + expect(exitCode, text).toBe(0); + expect(text).toContain(`Updated key ${id}`); + expect(await keyRow(id)).toMatchObject({ + rate_limit_max: 1, + rate_limit_time_window: 30_000, + refill_amount: 500, + remaining: 500, + refill_interval: limits.KEY_REFILL_INTERVAL, + enabled: true, + }); + expect((await verify(key)).valid).toBe(true); + const second = await verify(key); + expect(second.valid).toBe(false); + expect(second.error?.code).toBe('RATE_LIMITED'); + }); + + it('refuses out-of-bounds values and writes nothing', async () => { + const userId = await createService(); + const { id } = await createKey(); + const before = await keyRow(id); + for (const flags of [ + ['--rate-max', '0', '--rate-window', '60', '--daily', '10'], + ['--rate-max', '10', '--rate-window', '86401', '--daily', '10'], + ['--rate-max', '10', '--rate-window', '60', '--daily', '10000001'], + ]) { + const { exitCode, text } = await run(['update-limits', 'whatsapp', '--key', id, ...flags, '--confirm', userId]); + expect(exitCode).toBe(1); + expect(text).toMatch(/Out of bounds/); + } + expect(await keyRow(id)).toEqual(before); + }); +}); + +describe('SECRET_SCANNING_PATTERNS', () => { + it('has one pattern per service, on its prefix', () => { + expect(Object.keys(serviceUsers.SECRET_SCANNING_PATTERNS).sort()).toEqual(Object.keys(serviceUsers.SERVICES).sort()); + for (const [name, pattern] of Object.entries(serviceUsers.SECRET_SCANNING_PATTERNS)) { + expect(pattern.source.startsWith(serviceUsers.SERVICES[name as serviceUsers.ServiceName].prefix)).toBe(true); + } + }); + + it("matches a key freshly created for its service, and not a 63-letter tail, the other service's key or a session token", async () => { + await createService('whatsapp'); + await createService('ansari-mcp'); + const wa = (await createKey('create-key', LIMIT_FLAGS, 'whatsapp')).key; + const mcp = (await createKey('create-key', LIMIT_FLAGS, 'ansari-mcp')).key; + const { whatsapp, 'ansari-mcp': amcp } = serviceUsers.SECRET_SCANNING_PATTERNS; + const { rows } = await service.client.query<{ token: string }>('select token from auth.session limit 1'); + const sessionToken = rows[0]?.token ?? (await import('better-auth/crypto')).generateRandomString(32); + expect(sessionToken).toHaveLength(32); + + const cases: [RegExp, string, boolean][] = [ + [whatsapp, wa, true], + [amcp, mcp, true], + [whatsapp, wa.slice(0, -1), false], + [amcp, mcp.slice(0, -1), false], + [whatsapp, mcp, false], + [amcp, wa, false], + [whatsapp, sessionToken, false], + [amcp, sessionToken, false], + [whatsapp, `wa_${sessionToken}`, false], + ]; + const hits = cases.map(([pattern, candidate, expected]) => { + expect(pattern.test(candidate), `${pattern.source} vs ${candidate.slice(0, 8)}…`).toBe(expected); + return pattern.test(candidate); + }); + expect(hits.filter(Boolean)).toHaveLength(2); + }); +}); + +describe('the durable record', () => { + const envelopes: unknown[] = []; + let transportFlushes: boolean; + + beforeAll(() => { + Sentry.init({ + dsn: 'https://public@o0.ingest.sentry.io/0', + defaultIntegrations: false, + transport: () => ({ + send: async (envelope) => { + envelopes.push(envelope); + return {}; + }, + flush: async () => transportFlushes, + }), + }); + }); + afterAll(async () => { + await Sentry.close(); + }); + beforeEach(() => { + envelopes.length = 0; + transportFlushes = true; + }); + + it('create, rotate, update-limits and revoke each send one Sentry event with no key material', async () => { + const userId = await createService(); + envelopes.length = 0; + const first = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--name', 'wa-1', '--confirm', userId], { + recorder: sentryRecorder, + }); + const key1 = first.lines.find((l) => l.startsWith('wa_'))!; + const id1 = /Created key ([0-9a-f-]{36})/.exec(first.text)![1]!; + const rotated = await run(['rotate', 'whatsapp', ...LIMIT_FLAGS, '--name', 'wa-2', '--confirm', userId], { + recorder: sentryRecorder, + }); + const key2 = rotated.lines.find((l) => l.startsWith('wa_'))!; + const id2 = /Created key ([0-9a-f-]{36})/.exec(rotated.text)![1]!; + await run(['update-limits', 'whatsapp', '--key', id2, ...LIMIT_FLAGS, '--confirm', userId], { recorder: sentryRecorder }); + await run(['revoke', 'whatsapp', '--key', id1, '--confirm', userId], { recorder: sentryRecorder }); + await run(['list', 'whatsapp'], { recorder: sentryRecorder }); + + expect(envelopes).toHaveLength(4); + const serialized = JSON.stringify(envelopes); + expect(occurrences(serialized, KEY_EVENT_MESSAGE)).toBe(4); + for (const [action, id, name] of [ + ['create-key', id1, 'wa-1'], + ['rotate', id2, 'wa-2'], + ['update-limits', id2, 'wa-2'], + ['revoke', id1, 'wa-1'], + ] as const) { + const event = envelopes.map((e) => JSON.stringify(e)).find((e) => e.includes(`"action":"${action}"`)); + expect(event, action).toBeDefined(); + expect(event).toContain(id); + expect(event).toContain(`"name":"${name}"`); + expect(event).toContain('"service":"whatsapp"'); + } + for (const key of [key1, key2]) { + expect(serialized).not.toContain(key); + expect(serialized).not.toContain(key.slice(3, 20)); + } + for (const id of [id1, id2]) expect(serialized).not.toContain((await keyRow(id))!.key as string); + }); + + it('awaits the flush before it returns', async () => { + const userId = await createService(); + let release!: (ok: boolean) => void; + const recorder: Recorder = { + capture: () => {}, + flush: () => new Promise((resolve) => (release = resolve)), + }; + let settled = false; + const pending = run(['revoke', 'whatsapp', '--key', (await createKey()).id, '--confirm', userId], { recorder }).then((r) => { + settled = true; + return r; + }); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + await new Promise((r) => setTimeout(r, 50)); + expect(settled).toBe(false); + release(true); + expect((await pending).exitCode).toBe(0); + }); + + it('a failed flush keeps the write, prints the key-log banner and exits non-zero', async () => { + const userId = await createService(); + transportFlushes = false; + const { exitCode, text, lines } = await run(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--confirm', userId], { + recorder: sentryRecorder, + flushTimeoutMs: 200, + }); + expect(exitCode).toBe(EXIT_UNRECORDED); + expect(exitCode).not.toBe(0); + expect(text).toMatch(/SENTRY RECORD OF THIS ACTION WAS NOT SENT/); + expect(text).toMatch(/runbook's key log/); + const id = /Created key ([0-9a-f-]{36})/.exec(text)![1]!; + expect(text).toMatch(new RegExp(`action create-key, service whatsapp, user ${userId}, key ${id}`)); + // The write stands, and the key was still shown once (it is not stored anywhere else). + expect(await keyRow(id)).toMatchObject({ enabled: true }); + const key = lines.find((l) => l.startsWith('wa_'))!; + expect(occurrences(text, key)).toBe(1); + expect((await verify(key)).valid).toBe(true); + }); + + it('a recorder that reports an unsent flush is a failure too', async () => { + const userId = await createService(); + const recorder = memoryRecorder(false); + const { exitCode, text } = await run(['revoke', 'whatsapp', '--key', (await createKey()).id, '--confirm', userId], { + recorder, + }); + expect(recorder.flushes).toBe(1); + expect(exitCode).toBe(EXIT_UNRECORDED); + expect(text).toMatch(/NOT SENT/); + }); +}); diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md index c2c4110e..9dca7063 100644 --- a/codev/reviews/329-apps-auth-service-api-keys-bet.md +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -209,3 +209,39 @@ This is a monorepo-wide matter (pglite cold starts against 30 s deadlines under - **Spec wording versus the guard order:** the spec's "sign-up with a noreply address (any case, surrounding spaces) returns the 409 body". Validation runs first in `signUpGuard`, so an address with surrounding spaces is an invalid email (400) for every address, reserved or not. The test asserts what holds: a noreply address and a taken address answer identically, padded or not (409 unpadded, 400 padded). There is still no oracle. - Negative-tested: with the noreply domain removed from `isReservedAddress`, 8 tests go red. - Green per package (`@ansari/auth` 220, `ansari-auth` 263, `ansari-api` 1113 + 3 skipped); typecheck and lint pass at the root. The root test run on the shared machine (load average 92/74/60 at the time) failed only with timeouts (27 hook, 6 test), across all four packages including the untouched frontend: the monorepo-wide matter recorded under Flaky Tests. + +## Phase 4: service users and key operator scripts (notes) + +- **Design decisions within the plan:** + - `create-service` has no user yet, so its `--confirm` takes the service name (`--confirm whatsapp`). Every other write confirms with the service user's id. + - The window flag is in seconds (as every rule in `limits.ts` is) and stored in milliseconds (as the plugin stores it). The budget is `--daily` and always refills every 24 h. A bare budget cannot be expressed: `--remaining` is refused at the command line, and `checkKeyLimits` refuses a budget without `KEY_REFILL_INTERVAL` before the plugin is called. + - `update-limits` also sets `remaining` to the new daily budget: the new budget starts now. + - `rotate` refuses when the service has no live key (use `create-key`). It prints the old key ids and the exact revoke commands. + - `create-service` also sends a Sentry event. The spec names create, rotate and revoke, and the plan adds update-limits. A service user is a durable change too, so every write is recorded. + - Exit codes: 0 done, 1 refused or failed, 2 nothing written (show step, confirm mismatch), 3 written but its Sentry record not sent (banner). Without `SENTRY_DSN`, `Sentry.flush` has no client and returns false, so every write exits 3 with the banner. That is loud on purpose. + - The script prints `Auth database: :` first on every subcommand, list included. `urlHost` moved to `apps/auth/scripts/url-host.ts` beside the new `urlHostPort`. + - `ansari-mcp` borrows `WHATSAPP_KEY_BOUNDS` until #314 sizes its own. +- **The plan's leak test, adjusted:** the plan's URL `postgres://user:secret@db.example:6543/auth?sslmode=require` asks that the output contain none of `user`, `secret` and `sslmode`. The script's own words say "user" ("Service user for whatsapp"), so the test asserts `user:secret`, `secret`, `sslmode`, `/auth?` and `postgres://` absent. A second URL with distinctive userinfo, path and query (`opsname-zq`, `pw-zq-9`, `authdb-zq`, `verify-full`) shows that none of them is echoed. "secret" did catch one real wording problem: the handoff line said "secret store", and now says "credential store". +- **apps/api's source scan (scan 2) caught a literal database URL** in the first draft of the demo harness. The harness now uses the placeholder URL `setAuthEnv` puts in `DATABASE_URL`, and derives its leak checks from the parsed URL. The scan's exclusion list is unchanged. +- **Negative-tested** (`apps/auth/tests/service-keys.test.ts`, 73 tests). Each mutation was applied to the script alone and then reverted: + + | Mutation | Tests red | + |---|---| + | raw key printed twice | 3 | + | database URL appended to the host line | 6 | + | Sentry flush not awaited | 3 | + | revoke leaves `enabled = true` | 1 | + | refill interval not checked | 1 | + +- **Runnable harness** (`pnpm --filter ansari-auth exec tsx scripts/probes/service-keys-demo.ts`, pglite from the journal). It runs every subcommand in the runbook's order, including a confirm mismatch, an out-of-bounds update and an expiry flag. Run 2026-10-28, summary of its 13 steps: + + ``` + 7 (exit 0) + 2 (exit 1) + 4 (exit 2) + 13 Auth database: localhost:5432 + 1 CHECK database printed as hostname:port only: PASS + 1 CHECK two keys created, each printed once: PASS + ``` + +- Green per package: `@ansari/auth` 220 + 1 skipped (the `DEPLOYED_THROUGH` parity check, pending the rebase), `ansari-auth` 336, `ansari-api` 1113 + 3 skipped. Root typecheck and lint pass. diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index bae80173..4ae49014 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -76,3 +76,12 @@ - Skipped porch's context-refresh task at the phase_3 boundary (a second porch next moved on). - Phase 3: isReservedAddress covers @noreply.askansari.ai; isSystemAddress is untouched. The reset sender skips reserved addresses; grant-admin refuses noreply. The spec's "surrounding spaces → 409" doesn't match the guard order (validation first → 400 for all); the test asserts identical answers instead. + +## 2026-10-28: phase 4 (service users and key scripts) + +- packages/auth/src/service-users.ts (SERVICES, SECRET_SCANNING_PATTERNS, createServiceUser in one tx, findServiceUser none/one/many, listServiceKeys without the hash). + limits.ts gains WHATSAPP_KEY_INITIAL (60/60 s, 3000/24 h) and WHATSAPP_KEY_BOUNDS, both in ms, as the plugin stores them. +- apps/auth/scripts/service-keys.ts: create-service | create-key | list | rotate | update-limits | revoke. grant-admin posture. Exit 2 = nothing written, 3 = written but the Sentry record was not sent (banner). + create-service confirms with the service name, since there is no id yet. +- urlHost moved to apps/auth/scripts/url-host.ts (+ urlHostPort). +- Surprise: apps/api's source scan forbids literal DB URLs in apps/auth scripts. The demo harness now uses setAuthEnv's DATABASE_URL instead. diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index be793ba7..aa796116 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -46,6 +46,7 @@ export { whatsappAddress, } from './reserved'; export * as users from './users'; +export * as serviceUsers from './service-users'; export { resetEmailHtml, resetLink, diff --git a/packages/auth/src/limits.ts b/packages/auth/src/limits.ts index ca959584..463a5371 100644 --- a/packages/auth/src/limits.ts +++ b/packages/auth/src/limits.ts @@ -132,3 +132,49 @@ export const ROUTER_RULES: Readonly> = { '/reset-password': false, '/token': false, }; + +// Service keys (spec 329). These limits live in each key's own row (the +// api-key plugin enforces them at verify), not in `rate_limit`, and are set by +// the operator through apps/auth's service-keys script. Unlike the rules above, +// windows here are in MILLISECONDS, as the plugin stores them. + +const SECOND_MS = 1000; +const DAY_MS = 24 * 60 * 60 * SECOND_MS; + +/** A service key's limits, in the plugin's field names and units. */ +export type KeyLimits = { + /** Requests allowed per window. */ + readonly rateLimitMax: number; + /** The window, in milliseconds. */ + readonly rateLimitTimeWindow: number; + /** The daily budget: `remaining` is refilled to this every `refillInterval`. */ + readonly refillAmount: number; + /** Always KEY_REFILL_INTERVAL: the budget is daily. */ + readonly refillInterval: number; +}; + +/** A key's budget refills once a day (spec 329: a daily budget). */ +export const KEY_REFILL_INTERVAL = DAY_MS; + +/** + * The WhatsApp key's initial values (owner decision 3, sized by judgement in + * plan 329): 60 per minute bounds a leaked key or a looping client within a + * minute; 3,000 a day is hundreds of conversations at one exchange per 15 + * minutes, with headroom. The runbook's issuance command passes these. + */ +export const WHATSAPP_KEY_INITIAL: KeyLimits = { + rateLimitMax: 60, + rateLimitTimeWindow: 60 * SECOND_MS, + refillAmount: 3000, + refillInterval: KEY_REFILL_INTERVAL, +}; + +/** + * What the service-keys script accepts for a key (inclusive). Wide on purpose: + * raising a key's values never needs these to change. + */ +export const WHATSAPP_KEY_BOUNDS = { + rateLimitMax: { min: 1, max: 100_000 }, + rateLimitTimeWindow: { min: SECOND_MS, max: DAY_MS }, + refillAmount: { min: 1, max: 10_000_000 }, +} as const; diff --git a/packages/auth/src/service-users.ts b/packages/auth/src/service-users.ts new file mode 100644 index 00000000..b4f2c1bc --- /dev/null +++ b/packages/auth/src/service-users.ts @@ -0,0 +1,135 @@ +// Service users and their keys (spec 329). A service (the WhatsApp service, +// later the MCP server) is one auth user with no credential, resolved by its +// `{ providerId: 'service', accountId: }` account row, never by email. +// Only apps/auth's service-keys script creates one; its keys are created and +// changed in process through the api-key plugin, whose HTTP surface is blocked. +// +// Permissions and key prefixes are fixed here, per service, so the script +// takes no free-form permissions: a key for one service is refused at +// another's route. +import { and, asc, eq } from 'drizzle-orm'; + +import type { Db } from './db'; +import { WHATSAPP_KEY_BOUNDS } from './limits'; +import { serviceAddress } from './reserved'; +import { account, apikey, users } from './schema'; + +/** The account provider that marks a service user. Better Auth has no such provider, so no one signs in with it. */ +export const SERVICE_PROVIDER_ID = 'service'; + +/** `users.registered_via` for a service user. */ +export const SERVICE_REGISTERED_VIA = 'service'; + +/** + * Every service that may hold keys. `ansari-mcp` is reserved for #314: it has + * no route yet, and its limits borrow the WhatsApp bounds until #314 sizes + * its own. + */ +export const SERVICES = { + whatsapp: { permissions: { whatsapp: ['mint'] }, prefix: 'wa_', bounds: WHATSAPP_KEY_BOUNDS }, + 'ansari-mcp': { permissions: { mcp: ['complete'] }, prefix: 'amcp_', bounds: WHATSAPP_KEY_BOUNDS }, +} as const; + +export type ServiceName = keyof typeof SERVICES; + +export function isServiceName(name: string): name is ServiceName { + return Object.hasOwn(SERVICES, name); +} + +/** + * GitHub custom secret-scanning patterns, one per key prefix: the prefix and + * the plugin's 64-letter key. The runbook asks the owner to register exactly + * these (`.source`) for the organisation. + */ +export const SECRET_SCANNING_PATTERNS: Readonly> = { + whatsapp: /wa_[A-Za-z]{64}/, + 'ansari-mcp': /amcp_[A-Za-z]{64}/, +}; + +export type ServiceUser = { id: string; email: string; createdAt: Date }; + +export type ServiceUserLookup = + | { kind: 'none' } + | { kind: 'one'; user: ServiceUser } + /** `unique(provider_id, account_id)` forbids this; reported, never acted on. */ + | { kind: 'many'; ids: string[] }; + +/** The service's user, by its `service` account row. */ +export async function findServiceUser(db: Db, name: ServiceName): Promise { + const rows = await db + .select({ id: users.id, email: users.email, createdAt: users.createdAt }) + .from(account) + .innerJoin(users, eq(users.id, account.userId)) + .where(and(eq(account.providerId, SERVICE_PROVIDER_ID), eq(account.accountId, name))) + .orderBy(asc(users.createdAt)); + const [first] = rows; + if (!first) return { kind: 'none' }; + if (rows.length > 1) return { kind: 'many', ids: rows.map((r) => r.id) }; + return { kind: 'one', user: first }; +} + +/** + * Create the service's user (no credential, role `user`) and its `service` + * account row in one transaction: a failure leaves neither. A second user for + * the same name fails on the address and on `unique(provider_id, account_id)`. + */ +export async function createServiceUser(db: Db, name: ServiceName): Promise { + return db.transaction(async (tx) => { + const [user] = await tx + .insert(users) + .values({ + name: `service-${name}`, + email: serviceAddress(name), + role: 'user', + registeredVia: SERVICE_REGISTERED_VIA, + }) + .returning({ id: users.id, email: users.email, createdAt: users.createdAt }); + if (!user) throw new Error('service user insert returned no row'); + await tx.insert(account).values({ + providerId: SERVICE_PROVIDER_ID, + accountId: name, + userId: user.id, + }); + return user; + }); +} + +/** A key as the script shows it: every column except the hash. */ +export type ServiceKey = { + id: string; + name: string | null; + prefix: string | null; + permissions: string | null; + enabled: boolean | null; + rateLimitMax: number | null; + rateLimitTimeWindow: number | null; + remaining: number | null; + refillAmount: number | null; + refillInterval: number | null; + createdAt: Date; + lastRequest: Date | null; +}; + +const keyColumns = { + id: apikey.id, + name: apikey.name, + prefix: apikey.prefix, + permissions: apikey.permissions, + enabled: apikey.enabled, + rateLimitMax: apikey.rateLimitMax, + rateLimitTimeWindow: apikey.rateLimitTimeWindow, + remaining: apikey.remaining, + refillAmount: apikey.refillAmount, + refillInterval: apikey.refillInterval, + createdAt: apikey.createdAt, + lastRequest: apikey.lastRequest, +}; + +/** Every key the service user owns, oldest first. The hash is never read. */ +export async function listServiceKeys(db: Db, userId: string): Promise { + return db + .select(keyColumns) + .from(apikey) + .where(eq(apikey.referenceId, userId)) + .orderBy(asc(apikey.createdAt), asc(apikey.id)); +} From 9a7e866640c56f0df0abdf3dda095c0aa70b21a9 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:56:38 +1100 Subject: [PATCH 108/247] chore(porch): 329 implement build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index beb2f02c..ce6b4b99 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 1 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: phase_1 @@ -66,7 +66,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T07:37:18.999Z' +updated_at: '2026-10-28T07:56:38.334Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From 9a746f1a855a58e7e46ef3d4726018d66517a6a5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 18:58:38 +1100 Subject: [PATCH 109/247] [Spec 329][Phase: phase_4] fix: print the database host before connecting, each line as it is produced --- apps/auth/scripts/probes/service-keys-demo.ts | 8 +++-- apps/auth/scripts/service-keys.ts | 35 ++++++++++++++----- apps/auth/tests/service-keys.test.ts | 11 ++++-- .../329-apps-auth-service-api-keys-bet.md | 1 + 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/auth/scripts/probes/service-keys-demo.ts b/apps/auth/scripts/probes/service-keys-demo.ts index 03dd4644..42bbe30b 100644 --- a/apps/auth/scripts/probes/service-keys-demo.ts +++ b/apps/auth/scripts/probes/service-keys-demo.ts @@ -39,8 +39,12 @@ async function main(): Promise { async function step(argv: string[]): Promise { console.log(`\n$ tsx scripts/service-keys.ts ${argv.join(' ')}`); - const { lines, exitCode } = await serviceKeys(argv, { databaseUrl, connect: () => ({ db, auth }), recorder }); - for (const line of lines) console.log(line); + const { lines, exitCode } = await serviceKeys(argv, { + databaseUrl, + connect: () => ({ db, auth }), + recorder, + print: (line) => console.log(line), + }); console.log(`(exit ${exitCode})`); transcript.push(...lines); return lines; diff --git a/apps/auth/scripts/service-keys.ts b/apps/auth/scripts/service-keys.ts index a1500a8e..2a010e25 100644 --- a/apps/auth/scripts/service-keys.ts +++ b/apps/auth/scripts/service-keys.ts @@ -493,39 +493,56 @@ export type ScriptDeps = { databaseUrl: string | undefined; connect: () => ScriptContext; recorder: Recorder; + /** Prints one line at once (console.log in the script). */ + print: (line: string) => void; flushTimeoutMs?: number; }; /** * The whole script: database URL check, parse, `hostname:port`, run, and the - * durable record. Returns every line to print and the exit code. + * durable record. Each line is printed as it is produced, so the database + * host is on the operator's screen before anything connects or writes. + * Returns every printed line and the exit code. */ export async function serviceKeys(argv: readonly string[], deps: ScriptDeps): Promise<{ lines: string[]; exitCode: number }> { + const lines: string[] = []; + const emit = (...out: string[]) => { + for (const line of out) { + lines.push(line); + deps.print(line); + } + }; if (!deps.databaseUrl) { - return { lines: ['DATABASE_URL is not set (the AUTH database). Refusing to run.'], exitCode: 1 }; + emit('DATABASE_URL is not set (the AUTH database). Refusing to run.'); + return { lines, exitCode: 1 }; } const host = urlHostPort(deps.databaseUrl); - if (host === null) return { lines: ['DATABASE_URL does not parse as a URL. Refusing to run.'], exitCode: 1 }; - const lines = [`Auth database: ${host}`]; + if (host === null) { + emit('DATABASE_URL does not parse as a URL. Refusing to run.'); + return { lines, exitCode: 1 }; + } + emit(`Auth database: ${host}`); let result: ScriptResult; try { const cmd = parseArgs(argv); result = await runServiceKeys(deps.connect(), cmd); } catch (err) { - return { lines: [...lines, `service-keys refused: ${scriptError(err)}`], exitCode: 1 }; + emit(`service-keys refused: ${scriptError(err)}`); + return { lines, exitCode: 1 }; } - lines.push(...result.lines); + emit(...result.lines); if (result.events.length === 0) return { lines, exitCode: result.exitCode }; for (const event of result.events) deps.recorder.capture(event); if (await deps.recorder.flush(deps.flushTimeoutMs ?? SENTRY_FLUSH_TIMEOUT_MS)) { return { lines, exitCode: result.exitCode }; } - return { lines: [...lines, ...unrecordedBanner(result.events)], exitCode: EXIT_UNRECORDED }; + emit(...unrecordedBanner(result.events)); + return { lines, exitCode: EXIT_UNRECORDED }; } async function main(): Promise { initSentry(process.env.SENTRY_DSN, process.env.NODE_ENV ?? 'development'); - const { lines, exitCode } = await serviceKeys(process.argv.slice(2), { + const { exitCode } = await serviceKeys(process.argv.slice(2), { databaseUrl: process.env.DATABASE_URL, connect: () => { try { @@ -538,8 +555,8 @@ async function main(): Promise { return { db, auth: createAuth(db) }; }, recorder: sentryRecorder, + print: (line) => console.log(line), }); - for (const line of lines) console.log(line); process.exit(exitCode); } diff --git a/apps/auth/tests/service-keys.test.ts b/apps/auth/tests/service-keys.test.ts index b77ed940..99021197 100644 --- a/apps/auth/tests/service-keys.test.ts +++ b/apps/auth/tests/service-keys.test.ts @@ -70,13 +70,15 @@ type RunOptions = { url?: string | undefined; recorder?: Recorder; flushTimeoutM async function run(argv: string[], opts: RunOptions = {}) { const connect = vi.fn(() => ({ db: service.db, auth: service.auth })); + const print = vi.fn<(line: string) => void>(); const result = await serviceKeys(argv, { databaseUrl: 'url' in opts ? opts.url : DB_URL, connect, recorder: opts.recorder ?? memoryRecorder(), + print, flushTimeoutMs: opts.flushTimeoutMs, }); - return { ...result, text: result.lines.join('\n'), connect }; + return { ...result, text: result.lines.join('\n'), connect, print }; } async function serviceUserId(name = 'whatsapp'): Promise { @@ -284,9 +286,14 @@ describe('every subcommand', () => { if (url === DB_URL_DISTINCT && name === 'create-service') { await service.client.query(`delete from auth.users where registered_via = 'service'`); } - const { text, exitCode } = await run(argv, { url }); + const { text, exitCode, print, connect } = await run(argv, { url }); expect(exitCode, text).toBe(0); expect(text.split('\n')[0]).toBe('Auth database: db.example:6543'); + // Printed at once, before the script connects (and so before any write), + // and every line printed is the line returned, in order. + expect(print.mock.calls[0]).toEqual(['Auth database: db.example:6543']); + expect(print.mock.invocationCallOrder[0]!).toBeLessThan(connect.mock.invocationCallOrder[0]!); + expect(print.mock.calls.map(([line]) => line)).toEqual(text.split('\n')); expect(text).toContain('db.example:6543'); for (const leak of ['user:secret', 'secret', 'sslmode', '/auth?', 'opsname-zq', 'pw-zq', 'authdb-zq', 'verify-full', 'application_name', 'postgres://']) { expect(text, leak).not.toContain(leak); diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md index 9dca7063..af3f1219 100644 --- a/codev/reviews/329-apps-auth-service-api-keys-bet.md +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -244,4 +244,5 @@ This is a monorepo-wide matter (pglite cold starts against 30 s deadlines under 1 CHECK two keys created, each printed once: PASS ``` +- **Review iteration 1 (Codex, REQUEST_CHANGES):** the first version buffered every line and printed them after the run, so the database host reached the screen only after the write. Each line is now printed as it is produced, and the host line is printed before the script connects. The per-subcommand host test asserts the call order (`print` before `connect`). Restoring the buffered printing turns 6 tests red. The Gemini lane was skipped (its CLI exited 3). - Green per package: `@ansari/auth` 220 + 1 skipped (the `DEPLOYED_THROUGH` parity check, pending the rebase), `ansari-auth` 336, `ansari-api` 1113 + 3 skipped. Root typecheck and lint pass. From 4ca35c70f6132cffdb8dfaa21df5ae2194ac27fa Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:01:30 +1100 Subject: [PATCH 110/247] chore(porch): 329 implement re-iter (iter 2) --- .../329-phase_4-iter1-rebuttals.md | 15 +++++++++++++++ .../status.yaml | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-rebuttals.md diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-rebuttals.md new file mode 100644 index 00000000..fd47f712 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-rebuttals.md @@ -0,0 +1,15 @@ +# Phase 4, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the database host was printed after the write + +**Accepted and fixed in 9a746f1.** + +`serviceKeys()` used to collect every line and return them, and `main()` printed them only after the run. So the `Auth database: hostname:port` line appeared after `runServiceKeys()` had already written. The spec asks for the host to be printed before any write. + +- `ScriptDeps` now has a `print` callback, and `serviceKeys()` emits each line as it is produced. The host line is emitted before `parseArgs`, `connect()` and `runServiceKeys()`. `main()` passes `console.log` and no longer prints the buffer. +- Test: for every subcommand, the host test asserts that the first `print` call is the host line, that it comes before `connect()` (by `invocationCallOrder`; nothing can write before `connect()`), and that the printed lines equal the returned lines, in order. +- Negative test: putting the buffered behaviour back (print everything after the run) turns 6 tests red. The restored code is green (73/73), and the demo harness still passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped: its CLI (`agy`) exited with code 3. There is nothing to address. diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index ce6b4b99..df8e44b5 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -38,8 +38,8 @@ gates: status: pending verify-approval: status: pending -iteration: 1 -build_complete: true +iteration: 2 +build_complete: false history: - iteration: 1 plan_phase: phase_1 @@ -65,8 +65,20 @@ history: verdict: COMMENT file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_3-iter1-gemini.txt + - iteration: 1 + plan_phase: phase_4 + build_output: '' + reviews: + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-codex.txt + - model: gemini + verdict: COMMENT + file: >- + /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T07:56:38.334Z' +updated_at: '2026-10-28T08:01:30.045Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From 921f885279ce41015021f60d13de67b3ebeb2f5d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:01:40 +1100 Subject: [PATCH 111/247] [Spec 329][Phase: phase_4] chore: phase 4 iteration 1 review record --- .../329-phase_4-iter1-codex.txt | 7 +++++++ .../329-phase_4-iter1-gemini.txt | 10 ++++++++++ 2 files changed, 17 insertions(+) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-codex.txt new file mode 100644 index 00000000..02b61a95 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Phase 4 is largely complete, but the database target is not displayed before writes occur. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `serviceKeys()` performs `runServiceKeys()`—including database writes—before `main()` prints the buffered hostname line. This defeats the required pre-write operator warning. Emit `Auth database: hostname:port` before invoking any write, and test temporal ordering rather than only final line order. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file From 74b94433614f5a773619e483e15c88838e966ec4 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:01:42 +1100 Subject: [PATCH 112/247] chore(porch): 329 implement build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index df8e44b5..8b819411 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 2 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: phase_1 @@ -78,7 +78,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T08:01:30.045Z' +updated_at: '2026-10-28T08:01:41.937Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From 776ac8ae5024537b36e50088bb2b16751847551d Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:05:55 +1100 Subject: [PATCH 113/247] [Spec 329][Phase: phase_4] fix: keep the raw key out of Sentry breadcrumbs (no breadcrumbs, stdout printing) --- apps/auth/scripts/service-keys.ts | 26 ++++- apps/auth/tests/service-keys-sentry.test.ts | 100 ++++++++++++++++++ .../329-phase_4-iter2-codex.txt | 7 ++ .../329-phase_4-iter2-context.md | 29 +++++ .../329-phase_4-iter2-gemini.txt | 10 ++ .../329-phase_4-iter2-rebuttals.md | 21 ++++ .../329-apps-auth-service-api-keys-bet.md | 7 +- codev/state/spir-329_thread.md | 4 + 8 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 apps/auth/tests/service-keys-sentry.test.ts create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-context.md create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-rebuttals.md diff --git a/apps/auth/scripts/service-keys.ts b/apps/auth/scripts/service-keys.ts index 2a010e25..c76a2a04 100644 --- a/apps/auth/scripts/service-keys.ts +++ b/apps/auth/scripts/service-keys.ts @@ -47,7 +47,6 @@ import { type Db, } from '@ansari/auth'; -import { initSentry } from '../src/alerts'; import { urlHostPort } from './url-host'; type ServiceName = serviceUsers.ServiceName; @@ -540,8 +539,29 @@ export async function serviceKeys(argv: readonly string[], deps: ScriptDeps): Pr return { lines, exitCode: EXIT_UNRECORDED }; } +/** + * Sentry options for this script. Breadcrumbs are off: with Sentry's default + * Console integration every console line becomes a breadcrumb on the next + * event, and the line before the operator event is the raw key. + */ +export const SCRIPT_SENTRY_OPTIONS = { + sendDefaultPii: false, + maxBreadcrumbs: 0, + beforeBreadcrumb: () => null, +} as const; + +/** + * The script's printer: straight to stdout, not through `console`, which + * Sentry instruments. Breadcrumbs are off as well; either alone keeps the key + * out of an event. + */ +export function printLine(line: string): void { + process.stdout.write(`${line}\n`); +} + async function main(): Promise { - initSentry(process.env.SENTRY_DSN, process.env.NODE_ENV ?? 'development'); + const dsn = process.env.SENTRY_DSN; + if (dsn) Sentry.init({ dsn, environment: process.env.NODE_ENV ?? 'development', ...SCRIPT_SENTRY_OPTIONS }); const { exitCode } = await serviceKeys(process.argv.slice(2), { databaseUrl: process.env.DATABASE_URL, connect: () => { @@ -555,7 +575,7 @@ async function main(): Promise { return { db, auth: createAuth(db) }; }, recorder: sentryRecorder, - print: (line) => console.log(line), + print: printLine, }); process.exit(exitCode); } diff --git a/apps/auth/tests/service-keys-sentry.test.ts b/apps/auth/tests/service-keys-sentry.test.ts new file mode 100644 index 00000000..e62d1df4 --- /dev/null +++ b/apps/auth/tests/service-keys-sentry.test.ts @@ -0,0 +1,100 @@ +// The service-keys script under Sentry's PRODUCTION defaults (spec 329: no +// Sentry event carries key material). Default integrations stay on, so the +// Console integration turns console lines into breadcrumbs, exactly as in the +// script's own Sentry.init; only the transport is a spy. Its own file: the +// Console integration patches `console` for the whole process. +import * as Sentry from '@sentry/node'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { serviceUsers } from '@ansari/auth'; + +import { + KEY_EVENT_MESSAGE, + printLine, + SCRIPT_SENTRY_OPTIONS, + sentryRecorder, + serviceKeys, +} from '../scripts/service-keys'; +import { startService, type Service } from './helpers/service'; + +const DB_URL = 'postgres://ops:pw@db.example:6543/auth'; +const LIMIT_FLAGS = ['--rate-max', '60', '--rate-window', '60', '--daily', '3000']; + +const envelopes: unknown[] = []; +let service: Service; +let userId: string; + +beforeAll(async () => { + Sentry.init({ + dsn: 'https://public@o0.ingest.sentry.io/0', + ...SCRIPT_SENTRY_OPTIONS, + transport: () => ({ + send: async (envelope) => { + envelopes.push(envelope); + return {}; + }, + flush: async () => true, + }), + }); + service = await startService(); + await serviceUsers.createServiceUser(service.db, 'whatsapp'); + const found = await serviceUsers.findServiceUser(service.db, 'whatsapp'); + if (found.kind !== 'one') throw new Error('no service user'); + userId = found.user.id; +}); +afterAll(async () => { + await Sentry.close(); + await service.close(); +}); +beforeEach(() => { + envelopes.length = 0; +}); + +async function createKey(print: (line: string) => void) { + const result = await serviceKeys(['create-key', 'whatsapp', ...LIMIT_FLAGS, '--confirm', userId], { + databaseUrl: DB_URL, + connect: () => ({ db: service.db, auth: service.auth }), + recorder: sentryRecorder, + print, + }); + expect(result.exitCode).toBe(0); + const key = result.lines.find((l) => /^wa_[A-Za-z]{64}$/.test(l)); + if (!key) throw new Error('no key in the output'); + return key; +} + +function expectNoKey(key: string) { + const serialized = JSON.stringify(envelopes); + expect(occurrences(serialized, KEY_EVENT_MESSAGE)).toBe(1); + expect(serialized).not.toContain(key); + expect(serialized).not.toContain(key.slice(3, 20)); +} + +const occurrences = (text: string, needle: string) => text.split(needle).length - 1; + +describe('service-keys under production Sentry defaults', () => { + it("the script's printer: the key reaches stdout once and no Sentry event", async () => { + const written: string[] = []; + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + let key: string; + try { + key = await createKey(printLine); + } finally { + write.mockRestore(); + } + expect(occurrences(written.join(''), key)).toBe(1); + expectNoKey(key); + }); + + it('even printed through console.log, the key is not a breadcrumb (breadcrumbs are off)', async () => { + const log = vi.spyOn(console, 'log'); + try { + expectNoKey(await createKey((line) => console.log(line))); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-codex.txt new file mode 100644 index 00000000..229f09c3 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: REQUEST_CHANGES +SUMMARY: Phase 4 is complete except the production Sentry configuration can leak newly issued keys through console breadcrumbs. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- `main()` initializes Sentry with its default Console integration, prints the raw key via `console.log`, then captures the operator event. The raw key therefore becomes a breadcrumb attached to that event. The test disables default integrations, masking this leak. Disable/scrub console breadcrumbs for this script or write secret output outside instrumented console APIs, and test with production-shaped Sentry defaults. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-context.md new file mode 100644 index 00000000..f2da509b --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-context.md @@ -0,0 +1,29 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Phase 4 is largely complete, but the database target is not displayed before writes occur. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 4, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the database host was printed after the write + +**Accepted and fixed in 9a746f1.** + +`serviceKeys()` used to collect every line and return them, and `main()` printed them only after the run. So the `Auth database: hostname:port` line appeared after `runServiceKeys()` had already written. The spec asks for the host to be printed before any write. + +- `ScriptDeps` now has a `print` callback, and `serviceKeys()` emits each line as it is produced. The host line is emitted before `parseArgs`, `connect()` and `runServiceKeys()`. `main()` passes `console.log` and no longer prints the buffer. +- Test: for every subcommand, the host test asserts that the first `print` call is the host line, that it comes before `connect()` (by `invocationCallOrder`; nothing can write before `connect()`), and that the printed lines equal the returned lines, in order. +- Negative test: putting the buffered behaviour back (print everything after the run) turns 6 tests red. The restored code is green (73/73), and the demo harness still passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped: its CLI (`agy`) exited with code 3. There is nothing to address. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-rebuttals.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-rebuttals.md new file mode 100644 index 00000000..ee631068 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-rebuttals.md @@ -0,0 +1,21 @@ +# Phase 4, iteration 2: response to review + +## Codex (REQUEST_CHANGES): the raw key could reach Sentry through console breadcrumbs + +**Accepted and fixed.** The leak was real. + +`main()` initialised Sentry with its default integrations and printed with `console.log`. The Console integration recorded the raw key as a breadcrumb, and that breadcrumb was attached to the operator event captured next. The existing test used `defaultIntegrations: false`, which hid the leak. + +Two independent guards: +- **Breadcrumbs are off.** The script now has its own Sentry options, `SCRIPT_SENTRY_OPTIONS`: `maxBreadcrumbs: 0`, `beforeBreadcrumb: () => null`, `sendDefaultPii: false`. They replace the shared `initSentry` in this script. +- **Output does not go through `console`.** The script prints through `printLine`, which calls `process.stdout.write`; Sentry does not instrument it. + +Test (`apps/auth/tests/service-keys-sentry.test.ts`): it uses production-shaped Sentry, meaning default integrations ON, the script's own options, and a spy transport only. Run 1, with the script's printer, asserts the key reaches stdout exactly once and appears in no envelope. Run 2 prints through `console.log` and asserts no envelope carries the key, so the breadcrumb guard holds on its own. + +Negative test: with the breadcrumb options removed, run 2 goes red (the key appears in the envelope) and run 1 stays green. That reproduces the reported leak and shows each guard is load-bearing. + +The full apps/auth suite passes (338). Typecheck and lint are clean, and the demo harness passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped again: `agy` exited with code 3. There is nothing to address. diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md index af3f1219..0105dda4 100644 --- a/codev/reviews/329-apps-auth-service-api-keys-bet.md +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -245,4 +245,9 @@ This is a monorepo-wide matter (pglite cold starts against 30 s deadlines under ``` - **Review iteration 1 (Codex, REQUEST_CHANGES):** the first version buffered every line and printed them after the run, so the database host reached the screen only after the write. Each line is now printed as it is produced, and the host line is printed before the script connects. The per-subcommand host test asserts the call order (`print` before `connect`). Restoring the buffered printing turns 6 tests red. The Gemini lane was skipped (its CLI exited 3). -- Green per package: `@ansari/auth` 220 + 1 skipped (the `DEPLOYED_THROUGH` parity check, pending the rebase), `ansari-auth` 336, `ansari-api` 1113 + 3 skipped. Root typecheck and lint pass. +- **Review iteration 2 (Codex, REQUEST_CHANGES): the raw key could reach Sentry as a breadcrumb.** The script used `initSentry` with Sentry's default integrations and printed with `console.log`. The Console integration records every console line as a breadcrumb, and the breadcrumb trail rides on the next event. The line printed just before the operator event is the raw key. The original test turned default integrations off, and that hid the leak. + - Fixed two ways, each enough on its own. The script now has its own Sentry options (`SCRIPT_SENTRY_OPTIONS`: `maxBreadcrumbs: 0`, `beforeBreadcrumb` returns null), and it prints through `process.stdout.write`, which Sentry does not instrument. + - New test, `apps/auth/tests/service-keys-sentry.test.ts`. Default integrations stay ON; only the transport is a spy. The test runs create-key once with the script's printer and once printing through `console.log`. + - Negative test: with the breadcrumb options removed, the `console.log` case leaks the key and goes red, and the stdout case still passes. So Codex's leak was real, and either guard alone stops it. + - Lesson: a Sentry test with `defaultIntegrations: false` cannot show what production Sentry sends. +- Green per package: `@ansari/auth` 220 + 1 skipped (the `DEPLOYED_THROUGH` parity check, pending the rebase), `ansari-auth` 338 (after iteration 2), `ansari-api` 1113 + 3 skipped. Root typecheck and lint pass. diff --git a/codev/state/spir-329_thread.md b/codev/state/spir-329_thread.md index 4ae49014..0fb02705 100644 --- a/codev/state/spir-329_thread.md +++ b/codev/state/spir-329_thread.md @@ -85,3 +85,7 @@ create-service confirms with the service name, since there is no id yet. - urlHost moved to apps/auth/scripts/url-host.ts (+ urlHostPort). - Surprise: apps/api's source scan forbids literal DB URLs in apps/auth scripts. The demo harness now uses setAuthEnv's DATABASE_URL instead. +- Phase 4 review iter 1 (codex): the host line printed after the write (buffered output). Lines now stream, and the host line comes before connect(). +- Phase 4 review iter 2 (codex): a REAL leak. Sentry's default Console integration turned the console.log'd raw key into a breadcrumb on the operator event. + The test used defaultIntegrations:false and so hid it. Fix: the script's own Sentry options (no breadcrumbs) + stdout.write printing; test with production defaults. + Gemini lane skipped both rounds (agy exit 3). From 8bed1768aab142d5b83e46c5f2b8d2745196b5e0 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:08:11 +1100 Subject: [PATCH 114/247] chore(porch): 329 implement re-iter (iter 3) --- .../status.yaml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index 8b819411..efca32f4 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -38,8 +38,8 @@ gates: status: pending verify-approval: status: pending -iteration: 2 -build_complete: true +iteration: 3 +build_complete: false history: - iteration: 1 plan_phase: phase_1 @@ -77,8 +77,20 @@ history: verdict: COMMENT file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter1-gemini.txt + - iteration: 2 + plan_phase: phase_4 + build_output: '' + reviews: + - model: codex + verdict: REQUEST_CHANGES + file: >- + /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-codex.txt + - model: gemini + verdict: COMMENT + file: >- + /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T08:01:41.937Z' +updated_at: '2026-10-28T08:08:11.230Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From 81b8bcae2daa202bf410817d3ead49d7949019c2 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:08:18 +1100 Subject: [PATCH 115/247] chore(porch): 329 implement build-complete --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index efca32f4..f2e2711f 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -39,7 +39,7 @@ gates: verify-approval: status: pending iteration: 3 -build_complete: false +build_complete: true history: - iteration: 1 plan_phase: phase_1 @@ -90,7 +90,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T08:08:11.230Z' +updated_at: '2026-10-28T08:08:18.482Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' From 151aebf6fb476ca7068658a241f6c23dee3b3976 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:09:33 +1100 Subject: [PATCH 116/247] [Spec 329][Phase: phase_4] chore: phase 4 review record (iteration 3, approved) --- .../329-phase_4-iter3-codex.txt | 7 +++ .../329-phase_4-iter3-context.md | 57 +++++++++++++++++++ .../329-phase_4-iter3-gemini.txt | 10 ++++ 3 files changed, 74 insertions(+) create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-codex.txt create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-context.md create mode 100644 codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-gemini.txt diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-codex.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-codex.txt new file mode 100644 index 00000000..e45aa132 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-codex.txt @@ -0,0 +1,7 @@ +--- +VERDICT: APPROVE +SUMMARY: Phase 4 meets the specified operator-script, security, Sentry, and test requirements. +CONFIDENCE: HIGH +--- +KEY_ISSUES: +- None \ No newline at end of file diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-context.md b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-context.md new file mode 100644 index 00000000..65fce706 --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-context.md @@ -0,0 +1,57 @@ +### Iteration 1 Reviews +- codex: REQUEST_CHANGES — Phase 4 is largely complete, but the database target is not displayed before writes occur. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 1 +# Phase 4, iteration 1: response to review + +## Codex (REQUEST_CHANGES): the database host was printed after the write + +**Accepted and fixed in 9a746f1.** + +`serviceKeys()` used to collect every line and return them, and `main()` printed them only after the run. So the `Auth database: hostname:port` line appeared after `runServiceKeys()` had already written. The spec asks for the host to be printed before any write. + +- `ScriptDeps` now has a `print` callback, and `serviceKeys()` emits each line as it is produced. The host line is emitted before `parseArgs`, `connect()` and `runServiceKeys()`. `main()` passes `console.log` and no longer prints the buffer. +- Test: for every subcommand, the host test asserts that the first `print` call is the host line, that it comes before `connect()` (by `invocationCallOrder`; nothing can write before `connect()`), and that the printed lines equal the returned lines, in order. +- Negative test: putting the buffered behaviour back (print everything after the run) turns 6 tests red. The restored code is green (73/73), and the demo harness still passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped: its CLI (`agy`) exited with code 3. There is nothing to address. + + +### Iteration 2 Reviews +- codex: REQUEST_CHANGES — Phase 4 is complete except the production Sentry configuration can leak newly issued keys through console breadcrumbs. +- gemini: COMMENT — Gemini lane skipped — agy exited with code 3 + +### Builder Response to Iteration 2 +# Phase 4, iteration 2: response to review + +## Codex (REQUEST_CHANGES): the raw key could reach Sentry through console breadcrumbs + +**Accepted and fixed.** The leak was real. + +`main()` initialised Sentry with its default integrations and printed with `console.log`. The Console integration recorded the raw key as a breadcrumb, and that breadcrumb was attached to the operator event captured next. The existing test used `defaultIntegrations: false`, which hid the leak. + +Two independent guards: +- **Breadcrumbs are off.** The script now has its own Sentry options, `SCRIPT_SENTRY_OPTIONS`: `maxBreadcrumbs: 0`, `beforeBreadcrumb: () => null`, `sendDefaultPii: false`. They replace the shared `initSentry` in this script. +- **Output does not go through `console`.** The script prints through `printLine`, which calls `process.stdout.write`; Sentry does not instrument it. + +Test (`apps/auth/tests/service-keys-sentry.test.ts`): it uses production-shaped Sentry, meaning default integrations ON, the script's own options, and a spy transport only. Run 1, with the script's printer, asserts the key reaches stdout exactly once and appears in no envelope. Run 2 prints through `console.log` and asserts no envelope carries the key, so the breadcrumb guard holds on its own. + +Negative test: with the breadcrumb options removed, run 2 goes red (the key appears in the envelope) and run 1 stays green. That reproduces the reported leak and shows each guard is load-bearing. + +The full apps/auth suite passes (338). Typecheck and lint are clean, and the demo harness passes both of its checks. + +## Gemini (COMMENT) + +The Gemini lane was skipped again: `agy` exited with code 3. There is nothing to address. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-gemini.txt b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-gemini.txt new file mode 100644 index 00000000..610eddda --- /dev/null +++ b/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter3-gemini.txt @@ -0,0 +1,10 @@ +--- +VERDICT: COMMENT +SUMMARY: Gemini lane skipped — agy exited with code 3 +CONFIDENCE: LOW +--- + +The Gemini (Antigravity `agy`) reviewer was skipped: agy exited with code 3. +This is a non-blocking skip; the remaining reviewers still apply. To enable the +Gemini lane, install the CLI (https://antigravity.google/cli/install.sh) and run +`agy` once to sign in. \ No newline at end of file From 744fc2104d3cc8fb48e53e5543a322dd5a3dc174 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:09:33 +1100 Subject: [PATCH 117/247] =?UTF-8?q?chore(porch):=20329=20advance=20plan=20?= =?UTF-8?q?phase=20=E2=86=92=20phase=5F5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../329-apps-auth-service-api-keys-bet/status.yaml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index f2e2711f..ca2ad5e3 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -14,17 +14,17 @@ plan_phases: status: complete - id: phase_4 title: Service users and key operator scripts - status: in_progress + status: complete - id: phase_5 title: WhatsApp identity and session core - status: pending + status: in_progress - id: phase_6 title: The exchange route, boot assertion and alerts status: pending - id: phase_7 title: End-to-end contract test, source scan and documentation status: pending -current_plan_phase: phase_4 +current_plan_phase: phase_5 gates: spec-approval: status: approved @@ -38,8 +38,8 @@ gates: status: pending verify-approval: status: pending -iteration: 3 -build_complete: true +iteration: 1 +build_complete: false history: - iteration: 1 plan_phase: phase_1 @@ -90,7 +90,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T08:08:18.482Z' +updated_at: '2026-10-28T08:09:33.292Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' @@ -101,3 +101,5 @@ context_refreshes: - boundary: plan-phase:phase_4 at: '2026-10-28T07:36:09.447Z' acknowledged_at: '2026-10-28T07:37:18.998Z' + - boundary: plan-phase:phase_5 + at: '2026-10-28T08:09:33.292Z' From 0554169daf17b16a6192d5499288281905f44525 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:09:37 +1100 Subject: [PATCH 118/247] chore(porch): 329 acknowledge context refresh --- codev/projects/329-apps-auth-service-api-keys-bet/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml index ca2ad5e3..324d204a 100644 --- a/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml +++ b/codev/projects/329-apps-auth-service-api-keys-bet/status.yaml @@ -90,7 +90,7 @@ history: file: >- /Users/amrmohamed/repos/iaser/ansari/.builders/spir-329/codev/projects/329-apps-auth-service-api-keys-bet/329-phase_4-iter2-gemini.txt started_at: '2026-10-28T04:31:56.766Z' -updated_at: '2026-10-28T08:09:33.292Z' +updated_at: '2026-10-28T08:09:37.188Z' context_refreshes: - boundary: plan-phase:phase_2 at: '2026-10-28T06:20:23.009Z' @@ -103,3 +103,4 @@ context_refreshes: acknowledged_at: '2026-10-28T07:37:18.998Z' - boundary: plan-phase:phase_5 at: '2026-10-28T08:09:33.292Z' + acknowledged_at: '2026-10-28T08:09:37.188Z' From c563d13bd89e7645228696e76d55b85833917715 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:11:42 +1100 Subject: [PATCH 119/247] chore(porch): bugfix-330 fix phase-transition --- .../bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml b/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml index 437a1ef5..7a84d37e 100644 --- a/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml +++ b/codev/projects/bugfix-330-ci-test-root-pnpm-test-is-not-/status.yaml @@ -1,7 +1,7 @@ id: bugfix-330 title: ci-test-root-pnpm-test-is-not- protocol: bugfix -phase: investigate +phase: fix plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-10-28T07:09:35.707Z' -updated_at: '2026-10-28T07:09:35.710Z' +updated_at: '2026-10-28T08:11:42.166Z' From 90aff8cc3490e768b471a0277efc5287aa3db1b3 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Wed, 28 Oct 2026 19:13:30 +1100 Subject: [PATCH 120/247] [Spec 329][Phase: phase_5] chore: gate before phase 5, probes rerun on real Postgres Probes 2, 4a and the session-token probe now also run against real Postgres (library-probes.ts); probe 4 rerun. Outputs pasted verbatim in the review. Shared probe setup extracted to probe-db.ts. --- .../329-apps-auth-service-api-keys-bet.md | 65 +++++++ codev/state/spir-329_thread.md | 8 + .../scripts/probes/exchange-lock-probe.ts | 58 +----- .../auth/scripts/probes/library-probes.ts | 182 ++++++++++++++++++ packages/auth/scripts/probes/probe-db.ts | 79 ++++++++ 5 files changed, 338 insertions(+), 54 deletions(-) create mode 100644 packages/auth/scripts/probes/library-probes.ts create mode 100644 packages/auth/scripts/probes/probe-db.ts diff --git a/codev/reviews/329-apps-auth-service-api-keys-bet.md b/codev/reviews/329-apps-auth-service-api-keys-bet.md index 0105dda4..fc1ff524 100644 --- a/codev/reviews/329-apps-auth-service-api-keys-bet.md +++ b/codev/reviews/329-apps-auth-service-api-keys-bet.md @@ -40,6 +40,71 @@ All green (16 tests): **Gates:** before phase 4 (probes 1, 1b) and before phase 5 (probes 2, 4a, session token, probe 4): all match the plan. +### Gate before phase 5: rerun on real Postgres, 2026-10-28 + +At the security architect's request, every gate-before-phase-5 probe was rerun +on real Postgres (a fresh `postgres:18` container on 127.0.0.1) before any phase 5 +code, with the output pasted verbatim. Probes 2, 4a and the session-token probe +had only run on pglite; `packages/auth/scripts/probes/library-probes.ts` now runs them +against Postgres too. It prints claim names, headers and shapes, never a token. +The setup shared with probe 4 (URL guard, schema reset, env) moved to +`packages/auth/scripts/probes/probe-db.ts`. Both scripts are run as +`pnpm --filter ansari-auth exec tsx ../../packages/auth/scripts/probes/