Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68800815c4 | |||
| 67b806fc5c | |||
| b0d2069b15 | |||
| ca704a55a0 | |||
| 2f6b1e23ec | |||
| b4fb7b3881 | |||
| f4a35afbc9 | |||
| 5bc899eef3 | |||
| 0c51ddc4df | |||
| 85ddf5ea22 | |||
| ca08f93eeb | |||
| 5b66c75917 | |||
| 8fd229641a | |||
| c2b907b544 | |||
| 376dc3d59a | |||
| e32e54e7aa | |||
| 6b451d4de7 | |||
| 7e16b33ff6 | |||
| 922219748e | |||
| 38b3e07cfe | |||
| b489c59980 | |||
| f34959026a | |||
| 38d8e2c4b9 | |||
| a46ecf142a | |||
| 809ce3bda8 | |||
| c25e42c005 | |||
| a62acf8241 | |||
| e1ef420b2d | |||
| 51a030fa60 | |||
| f5dcb12a93 | |||
| e090be2f8d | |||
| 27aaa313b4 | |||
| 9759a0e21e | |||
| fe9ece1cc9 | |||
| bad56988b7 | |||
| e5855ac800 | |||
| f4f6d8b9c9 | |||
| 61139c61bf | |||
| 9f727427a7 | |||
| 151a2ffff8 | |||
| cb3af9d06a | |||
| 89bff0c36a | |||
| 89e21eb4b2 | |||
| 3332a7f264 | |||
| 6b0b20cee1 | |||
| 0c5fe12d0a | |||
| 8684d9d60c | |||
| 7f7d39b748 | |||
| e3978d4df2 | |||
| 579fc6b105 | |||
| c2158af6d3 | |||
| 0592c9993d | |||
| f831839a90 | |||
| f0ad84fe74 | |||
| 1e6d035d6d | |||
| 1a88ab3199 |
@@ -43,6 +43,57 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
|
||||
49. **D-052 tooling conclusions** — coverage: pytest-cov (Python) + c8 wrapped around existing `tsx --test` (no runner swap, no vitest); markers `fast`/`real_server`/`userns` under `--strict-markers`, unmarked defaults to fast, fast run = `-m "not real_server and not userns"`; pytest-timeout `signal` method, 60s global / 180s real-server+userns via collection hook (D-054); CLI fresh-clone E2E env-gated (`CLI_E2E=1`) into `cli:test:e2e` with a 900s budget (D-055); `filterwarnings = ["error", <narrow module+message-scoped upstream ignores>]` (D-056); mypy relaxed-first baseline (`check_untyped_defs`, `warn_unused_ignores`, no full strict) (D-058); recharts 2→3 verified by tsc + static build + existing tests (D-059); shared Python helpers in `tests/helpers/` package, fixtures stay in conftest (D-060); pyproject.toml is the single version source, `FastAPI(version=)` reads package metadata (D-061).
|
||||
50. **D-062 v0.7 wave order = dependency-ordered: wiring before composition before elimination before hardening** — P1 harness wiring (coverage/timeout/markers/turbo tasks/leak fixes) MUST precede P2 suite composition because the cut/consolidate decisions are evidence-based (P1's coverage data tells P2 what to cut); P3 warning elimination is independent of P1/P2 but ordered after them to avoid rebasing `session.exec()` conversions over moved test files; P4 lint hygiene (ESLint + noqa RUF100) after P3 so the eslint-disable resolutions happen under a warning-clean tree; P5 type+dependency hardening last (mypy adoption and recharts 3 are the riskiest changes — they run against a fully-stabilized harness, and recharts gets the fresh coverage/ESLint net as its safety net); P6 final review. ESLint adoption (D-048) rides P4 because the orphaned eslint-disable comments only become meaningful once a config exists.
|
||||
|
||||
### v0.9 Payment Engine Architecture (Phase 0 RESEARCH — Payment Engine, D-075..D-084 binding)
|
||||
|
||||
**Module:** `ai_service/billing/` (7th domain module, D-031 pattern — extend ai-service, no new apps). Boundary rule (family discipline): `billing/` never imports `agents/` or `api/`; it imports `config` + its own `base.py` only. Files: `base.py` (PaymentProvider protocol + pydantic contracts + normalized event names), `store.py` (7th D-027 SQLite store), `mock.py` (deterministic provider, Stripe-shaped ids/signatures per R-36/R-48), `stripe.py` (Stripe-compatible REST impl — form-encoded bodies, Basic auth, Idempotency-Key per R-33/R-49), `factory.py` (env-gated selection, UnknownPaymentProviderError → G-11 loud mock fallback), `catalog.py` (seeded catalog constant + idempotent seed).
|
||||
|
||||
**Provider protocol (D-075):** `create_checkout_session(spec) -> CheckoutSession`, `retrieve_session(id)`, `cancel_subscription(id, *, at_period_end)`, `update_subscription(id, new_price_ref, ...)` (resolves subscription_item id inside the provider — R-47 dual-charge guard), `verify_webhook(raw_body: bytes, signature_header: str, *, now=None) -> VerifiedEvent` (HMAC-SHA256 over `"{t}.{raw_body}"`, compare_digest, all-v1 iteration for secret rolls, ±300s replay window, ignore non-v1 schemes — R-38..R-40), `normalize_event(provider_event) -> CanonicalBillingEvent` (R-43 mapping).
|
||||
|
||||
**Store (D-080, 7 tables):** `packages` (one-time: posts_limit, price_cents, duration_days), `plans` (recurring: interval, price_cents), `purchases` (posts_limit/posts_used/expires_at snapshot + price snapshot — D-078), `subscriptions` (state machine: trialing/active/past_due/cancelled/expired — G-305: no `upgraded` status, upgrade is a plan_id transition within `active` recorded as a billing_event; expires_at, provider ref), `subscription_events` (provider event log, verbatim ids, UniqueConstraint event_id — dedupe), `checkout_sessions` (client_reference_id reconciliation), `billing_events` (full audit: provider events + manual admin actions with provenance). Same SQLite file (`Settings().db_path`), WAL pragmas, Protocol + SQLiteBillingStore pair, main.py lifespan wiring with state-injection override.
|
||||
|
||||
**Routes:** `api/billing.py` (`/v1/billing/catalog` public GET; `/v1/billing/webhook` public POST — raw-body-first HMAC verify, then parse, then idempotent process, 200-fast) + `api/billing_admin.py` (`/v1/billing/admin/*` — require_admin dep verbatim R-51: checkout on behalf of employer (D-076), entitlement view, manual grant/revoke, event log query) + mock-only confirm route (D-082 as amended by G-301: mounted only when provider=mock AND composed with require_admin — no unauthenticated completion route on a payment surface). Entitlement enforcement composes into `create_posting` AFTER require_admin: billing store's `consume_posting(employer_id)` → domain error → route translates to `HTTPException(402, detail={reason, posts_used, posts_limit, upgrade_cta})` (dict-detail precedent = require_verified_age).
|
||||
|
||||
**Settings (R-53):** `payment_provider: str = "mock"`, `payment_api_key: SecretStr`, `webhook_secret: str = ""` (unset → per-boot random → webhooks fail closed — correct for a payment surface). Env: `AI_PAYMENT_PROVIDER`, `AI_PAYMENT_API_KEY`, `AI_WEBHOOK_SECRET`.
|
||||
|
||||
**Web (REQ-9-002):** pricing page → server shell (metadata retained, A-411/pins) + `PricingClient` component fetching `/v1/billing/catalog` (A-406 board-client pattern, AbortController + honest loading/error/empty states); `engine-client.ts` gains `listBillingCatalog()` + `PaymentRequiredError` (402 dict-detail discrimination); types in `packages/types/billing.ts` dual-schema-pinned in pins.test.ts. Admin entitlement view: moderation surface gains per-employer entitlement state (client fetch, same pattern).
|
||||
|
||||
**Money (D-081):** integer cents end-to-end; pydantic `int ge=0`; TS `number` documented as cents; currency `usd` fixed.
|
||||
|
||||
### v0.10 B2B Employer Network Architecture (Phase 0 RESEARCH — B2B Employer Network, D-086..D-097 binding)
|
||||
|
||||
**Module:** `ai_service/employers/` (8th domain module, D-031 pattern — extend ai-service, no new apps). Boundary rule (family discipline): `employers/` never imports `agents/` or `api/`; it imports `config` + its own files only. Files: `base.py` (pydantic contracts: EmployerAccount, EmployerClaim, session/token response shapes — dual-schema-pinned against packages/types/employer.ts, D-093; PostingSummary reused from marketplace.ts, never duplicated), `store.py` (8th D-027 SQLite store, 4 tables, D-095), `passwords.py` (stdlib scrypt KDF core — the billing/webhooks.py crypto-core precedent; D-086 params N=2^14/r=8/p=1/dklen=32 as module constants pinned by test, self-describing `scrypt$16384$8$1$<salt_b64>$<hash_b64>` rows, `hmac.compare_digest` verify; per-password `secrets.token_bytes(16)` salt; zero new deps), `claim_evidence.py` (stdlib registrable-domain normalization — urlsplit hostname + suffix match; the advisory match signal, D-089). HTTP surface in `api/employers.py` (`/v1/employers/*` — session-gated) + `api/employers_admin.py` (`/v1/employers/admin/*` — require_admin imported from marketplace_admin, the billing_admin.py:27 precedent, D-094). No service.py — routes compose the store via `deps.py` app.state DI (`get_employer_store`, the "routes never import stores directly" territory rule).
|
||||
|
||||
**Store (D-087/D-089/D-095, 4 tables on the shared `Settings().db_path`):** `employer_accounts` (id PK, email UNIQUE indexed, password_hash self-describing scrypt string, employer_id UNIQUE nullable-until-claim-approval, created_at), `employer_sessions` (id PK, token_hash UNIQUE — stores ONLY sha256(token), never the token; account_id indexed, created_at, expires_at = login + SESSION_TTL_DAYS), `employer_claims` (id, account_id, employer_id indexed, status pending|approved|rejected, evidence JSON `{account_email_domain, entity_website_domain, match}`, created_at, decided_by, decided_at), `employer_audit_events` (auto id PK, account_id/employer_id nullable indexed, kind/name, detail JSON, operator, created_at — the BillingEventRow audit shape). Mechanics mirror billing/store.py exactly (WAL / synchronous=NORMAL / busy_timeout / foreign_keys pragmas, `sa_type=String/JSON` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` tz re-labeling, Protocol + SQLiteEmployerStore pair, surfaced IntegrityError, check-first unique patterns — the insert_application precedent; G-304-style single-statement writes where concurrency matters). The `employer_` prefix is mandatory — bare `employer` is marketplace's EmployerRow table. `SQLModel.metadata.create_all` is idempotent: zero migration, the 4 tables join existing DBs at next boot (the proven billing-store compat).
|
||||
|
||||
**Session flow (D-087/D-090):** register (email+password, UNVERIFIED per D-097 — no learner gates, no email verification; scrypt hash via passwords.py) → login mints `secrets.token_urlsafe(32)`; the row stores `sha256(token)` + expires_at = now + 7-day module constant (lazy TTL at read — G-306, no timer); the client persists the opaque token in sessionStorage `nc-employer-session` (the G-308 adminToken wrapper pattern) and attaches `Authorization: Bearer <token>` on every fetch (CSRF-immune by construction — no ambient credential; D-039 static export has no Next middleware). The `require_employer_session` dependency (the require_admin extract→verify→return shape): **401** on missing/expired/unknown token (credential failure), **403** reserved for cross-entity authorization; returns session + account + claimed employer_id. logout DELETEs the row + audit event (revoke = lookup miss → 401); multiple concurrent sessions per account allowed. Token namespaces cannot collide: admin routes compare against the env token, employer routes hash-lookup.
|
||||
|
||||
**Auth throttling (D-088, G-5 in-memory pattern):** (a) per-EMAIL failed-login window — Settings knob, default 5 failures / rolling 15 min → 429, successful login clears the email's window; (b) global per-min cap on login+register attempts (the `_check_global_create_rate` deque) → 429. Keyed by SUBMITTED EMAIL, never client IP (behind HAProxy the proxy owns request.client.host). Both copy the proven deque/monotonic-window mechanics (identity _SubmitRateLimiter + sandboxes global cap); test fixtures reset the module state.
|
||||
|
||||
**Claim flow (D-089):** authenticated `POST /v1/employers/claims {employer_id}` → claim row status=pending with computed evidence (stdlib domain normalization; every seeded employer website is an example.com placeholder → match=false corpus-wide — the signal is advisory, A-304) → operator reviews in the admin queue → approve sets employer_accounts.employer_id (UNIQUE — one account : one entity) + decided_by/decided_at + audit event; reject frees the entity for a new claim. One PENDING claim per entity + one APPROVED owner per entity enforced check-first in the store. Auto-approval deferred: without email verification the match is forgeable — the operator decision activates.
|
||||
|
||||
**Entitlement composition (D-091) — one shared helper, two principal gates:**
|
||||
|
||||
```
|
||||
admin arm: require_admin ──▶ consume_posting_entitlement(store, employer_id, admin-CTA) ──▶ insert
|
||||
employer arm: require_employer_session ──▶ claim check (403) ──▶ same helper(session employer, employer-CTA) ──▶ insert
|
||||
|
||||
helper: store-missing → 503 fail-closed
|
||||
NoActivePackage → 402 {reason: no_active_package, posts_used: 0, posts_limit: 0, upgrade_cta}
|
||||
PackageExhausted → 402 {reason: posting_limit_reached, posts_used: 0, posts_limit: 0, upgrade_cta}
|
||||
(posts_used/posts_limit hardcoded 0 in BOTH arms — the v0.9 wire contract, byte-identical;
|
||||
UpgradeRequiredError consumes reason + upgrade_cta unchanged; gate runs BEFORE insert)
|
||||
```
|
||||
|
||||
`consume_posting` (G-304 single conditional UPDATE) is untouched; the helper lives in api/ beside both callers (no billing/→api/ boundary violation).
|
||||
|
||||
**Employer checkout (D-091, transposes billing_admin.create_checkout):** `POST /v1/employers/billing/checkout {item_id}` — session-gated; unclaimed → 403 + claim-CTA; buyer = the session's claimed entity (supersedes D-076 operator mediation); catalog snapshot + unique-pending placeholder + CheckoutSessionSpec metadata employer_id=claimed entity, operator_ref→account provenance; success/cancel URLs from public_base_url (G-310); employer mock-complete arm mounted only under provider=mock, employer-session-gated, completing one's OWN session (403 cross-session), minting the signed event through the REAL webhook path (D-082 transposed). The v0.9 provider protocol/webhooks/store grants serve employer checkouts verbatim — only principal derivation changes.
|
||||
|
||||
**Web (D-092/D-093, engine-client employer section — no new client module):** `engine-client.ts` gains register/login/logout/me, claim, own-postings CRUD/publish/close, own-checkout, own-entitlement + a `SessionRequiredError` (401 discrimination — parseError today has no 401 arm; honest logged-out state, never a retry loop) + the `nc-employer-session` sessionStorage wrapper (the adminToken pattern; logout clears storage + calls revoke; AbortController everywhere). Conversion scope: postings page (own-postings lifecycle), overview page (real active-postings count + per-posting applicant counts from the applications table; talent-match/placement metrics stay mock-labeled), employer profile (claimed entity via the existing public GET), new /employer/login + /employer/register (greenfield — no login page exists in any route group); **talent search + candidate profile stay the v0.1 mock** (no candidate backend — D-092). Wire types in `packages/types/employer.ts` dual-schema-pinned in pins.test.ts (the billing.ts pin block template, D-093). Pricing CTAs convert from contact-path to employer-login path (supersedes D-079).
|
||||
|
||||
**Admin network overview (D-094):** the moderation page (app/(admin)/admin/moderation/page.tsx — where the v0.9 EmployerEntitlementPanel landed) gains sibling sections below it: employer accounts list (email, claimed entity, entitlement state), claim queue (evidence + domain-match signal, approve/reject), audit — fed by operator routes in api/employers_admin.py (require_admin + audit events): GET /v1/employers/admin/accounts, GET …/claims, POST …/claims/{id}/approve|reject, GET …/audit; web transport via the existing adminToken() G-308 wrapper + honest 403 locked states (the entitlement panel's locked-state pattern).
|
||||
|
||||
**Settings/wiring (D-095/R-60):** throttle knobs as Settings ints (the identity_submits_per_min shape) + .env.example entries beside AI_ADMIN_TOKEN; session TTL is a module constant (no env). main.py: employer_store lifespan wiring (the billing_store getattr-guard template, ~line 182-190) + close() in the finally block; employers + employers_admin routers mount beside the billing block (inline-import pattern); CORS `allow_headers` gains "Authorization" (main.py:341) with a tests/api/test_cors.py preflight pin — the E87 lesson applied before it ships this time. scrypt probe-verified in the shipped venv (3.11.2): ~50 ms/hash at D-086 params, default maxmem admits the 16 MiB working set.
|
||||
|
||||
### v0.8 Distribution & Release Architecture (Phase 0 RESEARCH — Ship Anywhere, D-063..D-072 binding)
|
||||
|
||||
**Runner fleet topology (CT 113, 10.0.0.113):** three org-level gitea-runner containers (official image `docker.io/gitea/runner:3` — the Docker Hub `gitea/act_runner` repo is deprecated, renamed `gitea/runner`) in **sibling-container mode**: each mounts the host `/var/run/docker.sock`, has its OWN `/data` volume holding the `.runner` registration file (identity + credentials; shared state = re-registration), registers once against `https://git.coreci.dev` (Gitea 1.26.1, probe-verified) with the founder-provided org-level registration token (chat-only, reset after fleet green). Hybrid labels via `GITEA_RUNNER_LABELS`: `node` → `docker://node:24-bookworm`, `python` → `docker://python:3.11-bookworm`, `release` → `docker://node:24-bookworm`, `ubuntu-latest` → `docker://docker.gitea.com/runner-images:ubuntu-latest` (label format `name[:schema[:args]]`; a job's `runs-on` matches label names, the `docker://` image selects the job container). Per-runner `runner.capacity: 1` (default; also caps per-job docker subnet consumption) → fleet concurrency 3. Cache: each runner's built-in cache server must be pinned to a job-reachable `cache.host` + fixed published port (dockerized runners otherwise create per-job networks the cache address is unreachable in — `actions/cache` fails ETIMEDOUT); 32 GB disk guarded by `cache.repo_size_limit`, `health_check.min_free_disk_space_mb`, and a host-side `docker system prune` cron. Workspace-path alignment inside/outside the runner container is NOT needed for our jobs (no `container.bind_workdir`; `docker build` TARS the context over the API) — documented as the known sibling-mode gotcha if a future workflow bind-mounts.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"phase": 5,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.8",
|
||||
"milestone_name": "ship-anywhere",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-29T16:30:00Z"
|
||||
}
|
||||
@@ -1,3 +1,48 @@
|
||||
# Nextcraft v0.9 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS (REVISE → record G-301..G-310, then SHIP) · **Confidence:** 0.85 · **Date:** 2026-09-29
|
||||
|
||||
## Summary
|
||||
|
||||
The v0.9 Payment Engine plan (5 requirement phases + final, D-074 locked) is feasible, correctly scoped, and hermetically testable — the operator-mediated checkout model is coherent end-to-end and entitlement needs no employer auth. But red-teaming against the working tree (not the plan's description of it) found 10 binding defects: a mock-complete route mounted on every default-config production box (D-082's "never on a real deployment" rationale is **false** — mock IS the default), webhook handlers that would trust forgeable event payloads (leaked secret = free entitlements), a check-then-increment race in consume_posting, event-id-only dedupe defeated by Stripe's dual invoice events, a phantom `upgraded` state with no transition rows, an un-pinned ingest bypass that would 402 the seeded corpus, a checkout route naming contradiction between REQUIREMENTS/ROADMAP and binding D-076, an admin panel requiring a browser token entry that exists nowhere in apps/web, and a stripe checkout that cannot create sessions (success_url never populated — Happy Path 5's "zero code changes" was false). Two plan citations proved wrong in-tree (the "sitemap env-gated routes" precedent does not exist; admin surface has zero admin-token plumbing). Two Gs bite inside P1's Task 1-1-02 — recording them now prevents store-schema retrofit churn after P1 ships. Nothing re-architects: every G amends an existing task, must-have, or requirement sentence; the phase/wave structure is untouched.
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
| # | Axis | Verdict | Reason |
|
||||
|---|------|---------|--------|
|
||||
| 1 | Feasibility | PASS | Operator-mediated checkout coherent end-to-end; entitlement composes after require_admin with employer_id as body field; 6-phase shape matches the proven v0.6 cadence |
|
||||
| 2 | Scope creep | CONCERN | REQ-9-003/ROADMAP name a public POST /v1/billing/checkout contradicting D-076 (G-307); admin panel hides a browser-token requirement (G-308); "stripe switch = zero code changes" false without success/cancel URLs (G-310) |
|
||||
| 3 | Security | CONCERN | Mock-complete route unauthenticated on default deployments (G-301); webhook state resolution unspecified — payload trust = forged entitlements (G-302) |
|
||||
| 4 | Correctness | CONCERN | consume_posting check-then-increment race (G-304); dual invoice events defeat event-id dedupe (G-303); `upgraded` state hole (G-305); out-of-order events against missing targets + grace timing unspecified (G-306) |
|
||||
| 5 | Testability | PASS | Hermetic by construction: MockTransport byte-contracts, now-injection, direct-handler-call mock-complete — provided G-306 keeps grace lazy |
|
||||
| 6 | Backward compat | PASS | Fixture strategy workable (~9 test sites grant unlimited to legacy seeded employers); pricing metadata pin survives conversion; ingest bypass unpinned → G-309 |
|
||||
| 7 | Cost/benefit | PASS | D-074 stands: payments unblocks employer self-serve; no new deps (hand-rolled REST, no Stripe SDK); challenge declined |
|
||||
| 8 | Plan-vs-reality | PASS (with corrections) | All cited patterns verified in-tree; two rationale citations false (G-301/G-308); suite count 674/625-fast drift noted |
|
||||
|
||||
## Binding Decisions (G-301..G-310 — applied to PLAN/REQUIREMENTS/ROADMAP before execute)
|
||||
|
||||
**G-301 — mock-complete route: admin-gate it; D-082's "never on a real deployment" rationale is false.** The default provider is mock (D-075) and the pilot IS a real deployment — `POST /v1/billing/mock/complete` mounted "only when provider=mock" is mounted on every default-config production box. An unauthenticated completion route on a payment surface is a hole no matter how unguessable `cs_mock_…` ids are. Decision: keep the provider=mock conditional mount AND compose `require_admin` on the route — the operator completing a session after out-of-band payment is exactly the honest pilot flow D-076 describes. Amends PLAN Task 3-2-03, MH-3c; D-082's rationale corrected (ship-notes record: the sitemap "env-gated routes" precedent does not exist — this is the first factory-conditional mount in-tree). Tests: no-token → 403 under mock; route 404s under stripe; E2E green with admin-gated complete. Confidence 0.85.
|
||||
|
||||
**G-302 — webhook events are untrusted ID references; state resolves from local rows.** If checkout.completed reads session status/amount from the event payload, a leaked AI_WEBHOOK_SECRET = free entitlements (forge status=complete, payment_status=paid for any session id). Decision: handlers treat event payloads as ID references only — the session resolves from the local checkout_sessions row (creatable only via the admin route); unknown session id → recorded as an `unmatched` billing_event, NO entitlement, 200. Purchase price/name from the stored snapshot (D-078), never the event body; renewal amounts from the local plan row. Amends Task 3-2-02. Tests: signed checkout.completed with no local row → 200, zero purchases, one unmatched event; inflated payload amount → purchase price = snapshot. Confidence 0.85.
|
||||
|
||||
**G-303 — grants are idempotent per session, not per event id.** R-43/D-083 accept invoice.paid AND invoice.payment_succeeded and Stripe's async path fires distinct completed events — different event ids for the same logical completion sail past event-id dedupe and double-grant. Decision: `purchases.checkout_session_id` gets a UniqueConstraint; subscription upsert keys on provider subscription id; entitlement creation is exactly-once per session regardless of event count. Amends Task 1-1-02 (schema), Task 3-2-02; extends MH-3b. Test: two signed checkout.completed events, different event ids, same session → exactly one purchase row (IntegrityError → created=False), one subscription. Confidence 0.85.
|
||||
|
||||
**G-304 — consume_posting is a single conditional UPDATE, not check-then-increment.** insert_application survives concurrency via UniqueConstraint; posts_used has no such backstop — two concurrent create_posting calls both read posts_used=9 and both increment. Decision: consume_posting = one statement `UPDATE purchases SET posts_used = posts_used + 1 WHERE id = <newest non-expired with remaining> AND posts_used < posts_limit` (rowcount-checked; 0 rows → PackageExhausted/NoActivePackage). Amends Task 1-1-02. Test: 1 post remaining, two concurrent consumes on separate connections (WAL) → exactly one succeeds. Confidence 0.80.
|
||||
|
||||
**G-305 — `upgraded` is not a status state.** D-083's "(+ upgraded)" has no transition-matrix rows and REQ-9-005's canonical states don't include it. Decision: upgrade = plan_id transition within `active` (provider update per R-47 with items[0][id] — pinned by MH-3d), recorded as a billing_event with provenance; `upgraded` removed from the status enum — D-083's "(+ upgraded)" reinterpreted as the auditable upgrade ACTION, not a state. Amends Task 5-1-01, MH-5a. Tests: upgrade on active → status stays active, plan_id changes, billing_event exists; stored-status set contains no `upgraded`. Confidence 0.75.
|
||||
|
||||
**G-306 — out-of-order tolerance includes missing targets; grace expiry is lazy, not a timer.** (a) invoice.payment_failed/invoice.paid can arrive before checkout.completed creates the local subscription — handlers never 500 on unknown targets: record as unmatched (G-302 marker), return 200; recovery is the grant or admin manual complement. (b) past_due → expired at 7 days is computed lazily from past_due_at + 7d at read/entitlement time — no background timer, keeping tests hermetic. Amends Task 3-2-02 (tolerance rule), Task 5-1-01 (grace semantics); risks 3-4. Tests: signed invoice.paid for unknown subscription → 200 recorded no crash; entitlement check with injected now at past_due_at+6d → granted, +8d → 402/expired; no sleeps. Confidence 0.80.
|
||||
|
||||
**G-307 — align REQ-9-003/ROADMAP route naming to D-076.** REQUIREMENTS REQ-9-003 and ROADMAP phase-3 say `POST /v1/billing/checkout`; binding D-076, PLAN, and ARCHITECTURE say `/v1/billing/admin/checkout` behind require_admin. Decision: amend the wording at this gate (the requirement text must not name a public un-gated checkout route that will not exist). Test: route-inventory pin — no /v1/billing/checkout route exists; /v1/billing/admin/checkout 403s without token. Confidence 0.90.
|
||||
|
||||
**G-308 — define the browser-side admin-token entry; the admin panel is otherwise unreachable.** The admin surface is mock-data driven; no X-Admin-Token exists in apps/web. REQ-9-004's "admin surface shows per-employer entitlement state" hides a new requirement: an operator token entry in the browser — Task 4-2-01's A-406 citation is wrong (A-406 is for public routes). Decision: a small token input in the entitlement panel persisted to sessionStorage, sent by a new adminFetch wrapper in engine-client (X-Admin-Token header); 403 renders an honest locked state with the token prompt; the token is never logged. Amends Task 4-2-01. Tests: stubbed 403 → locked-state render; fetch stub asserts the header; no admin-token string in non-test web source beyond the wrapper. Confidence 0.75.
|
||||
|
||||
**G-309 — pin the ingest bypass: seed ingestion is never entitlement-gated.** Enforcement composes into the admin create_posting route ONLY, but the seeded-corpus ingest upserts ~20 postings directly through the store and must never hit the 402 path. Decision: explicit rule + test — run_ingest (and any future adapter) bypasses consume_posting by design; enforcement exists only in the admin route. Amends Task 4-1-01 (test list), risk 5 companion. Test: seeded ingest on a store with zero purchases → 20 postings upserted, zero 402s, zero consumption rows. Confidence 0.90.
|
||||
|
||||
**G-310 — default success/cancel URLs for the stripe checkout, or delete the "env-only switch" claim.** R-34: success_url is required for hosted checkout; the admin checkout body is {item_id, employer_id} and no task populates it — the stripe path cannot create sessions as planned. Decision: the checkout route defaults success_url = {public_base_url}/marketplace/pricing?checkout=success and cancel_url = …?checkout=cancelled (public_base_url exists at config.py:170); loud G-11 boot warning when stripe is selected and public_base_url is unset. Amends Task 3-2-01 (defaults) + Task 3-1-01 (byte-contract asserts both fields). Tests: MockTransport byte-contract shows success_url/cancel_url present; factory-selection test shows the loud warning when stripe + empty origin. Confidence 0.80.
|
||||
|
||||
---
|
||||
|
||||
# Nextcraft v0.6 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS · **Confidence:** 0.72 · **Date:** 2026-09-21
|
||||
@@ -218,3 +263,133 @@ None. All four seams remain founder-locked via D-016 / REQ-5-001..007; every fin
|
||||
- a-202: `GITEA_TOKEN` naming collision (automatic job token vs repo secret of the same name) — Gitea precedent lowest-level-wins; keep the repo secret name `GITEA_TOKEN` per R-13 but document the collision in the workflow comments.
|
||||
- a-203: cache.host on CT 113 IP — if the LXC ever changes IP, cache breaks; fleet-as-code (G-207) documents the IP as a bring-up variable.
|
||||
- a-204: image `latest` tag vs immutable release tags — `latest` is convenience-only; runbook always pins the release tag.
|
||||
|
||||
---
|
||||
|
||||
## v0.10 Grill (Phase 0 GRILL, 2026-09-30)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** SHIP-WITH-BINDINGS · **Confidence:** 0.82 · **Date:** 2026-09-30
|
||||
|
||||
**Verdict summary.** The v0.10 B2B Employer Network plan (6 phases, 15 waves, 30 tasks) is feasible and correctly sequenced — every load-bearing tree citation re-verified against the working code and held (require_admin shape, the 402 gate order + hardcoded zeros, billing_admin checkout/mock-complete mechanics, the four main.py wiring sites, the web census, both throttle precedents, the tests/billing layout, scrypt probe). The researcher's six open risks are each carried by a plan task. But red-teaming found 10 binding defects: a REQUIREMENTS-vs-PLAN gate-order contradiction (REQ-10-004 literally says sessions compose AFTER the entitlement check — backwards, the G-307 class); "web render tests" specified against a harness that does not exist in-tree (D-059 declined it — the tests as written are undeliverable without smuggling in jsdom, violating R-66); a suite-breaking throttle trap (the global 30/min deque is module state shared across every tests/employers/ file — the whole fast suite runs inside one 60s window and 429s itself around test ~16); a credential-stuffing hole hiding in an ambiguous throttle-append rule (if failed passwords don't consume global budget, D-088's stated rationale is void); an EmailStr trap (email-validator is NOT installed — the natural pydantic choice is a boot-time ImportError and the first new dependency); a TOCTOU race on one-pending-claim-per-entity with no UNIQUE backing it (insert_application's check-first is safe only because a real constraint backs it); employer postings that would inherit source="admin" provenance if the admin route is copied; a re-tag fork between admin and employer edit paths (D-052 drift); a dual-schema pin pointed at contracts no task creates (the own-postings response has no named pydantic model); and a UX criterion inviting the executor to "fix" the 402 wire zeros (risk 7 stated as acceptance criteria). All ten amend existing tasks — nothing re-architects, no scope cuts required.
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
| # | Axis | Verdict | Reason |
|
||||
|---|------|---------|--------|
|
||||
| 1 | Feasibility | CONCERN → fixed | All citations verified in-tree; but 4 tasks assume things the tree doesn't have: a render harness (G-312), EmailStr's dependency (G-315), a backing constraint for the claim 409 (G-316), a named own-postings pydantic contract for the MH-3a pin (G-319) |
|
||||
| 2 | Scope | PASS | Big but REQ-mandated, heavy precedent reuse (the v0.6 46-task cadence); talent pages honestly left mock; no half-built surface found; no CUTs |
|
||||
| 3 | Security | CONCERN → fixed | New trust-boundary code mostly well-specced (hashed tokens, lazy TTL, email-keyed throttles, 422 scrub, own-session mock-complete); defects: throttle-append ambiguity voids the stuffing bound (G-314), throttle-vs-KDF order unspecified (G-320a), claim insert race (G-316) |
|
||||
| 4 | Honesty | CONCERN → fixed | "Render tests" would present source pins as render tests or force a harness dep (G-312); employer postings inheriting source="admin" is provenance dishonesty (G-317); UX criterion 3 contradicts the byte-stable 402 zeros (G-320b) |
|
||||
| 5 | Drift | CONCERN → fixed | D-091 pins strong (payload-parity test + unchanged v0.9 suite); but the own-postings dual-schema pin has no pydantic contract to pin against (G-319), and employer edit would fork the admin re-tag semantics (G-318) |
|
||||
| 6 | Sequencing | PASS | Wave order verified dependency-sound (P1 module → P2 claims → P3 surface → P4 writes → P5 money); pins land against base.py contracts, not pages — no hidden dependency |
|
||||
| 7 | Researcher open risks | PASS | All six (scrypt burst, CORS preflight, 422 echo, sessionStorage, mock-complete own-session, example.com evidence) carried by named tasks with pins |
|
||||
| 8 | Test coverage | CONCERN → fixed | Several must-haves unverifiable AS WORDED (render tests, G-312); the employers suite self-DOSes without a conftest-wide reset (G-313); stuffing untested (G-314) |
|
||||
| 9 | One-entity/one-account races | CONCERN → fixed | One-APPROVED-owner is structurally sound (employer_accounts.employer_id UNIQUE → IntegrityError, pinned); one-PENDING-per-entity is check-first with NO backing constraint — TOCTOU (G-316) |
|
||||
| 10 | Token entropy + storage | PASS | token_urlsafe(32) = 256-bit CSPRNG, sha256-at-rest, lazy 7-day TTL, revoke-on-logout, concurrent sessions — matches D-087 exactly; no finding |
|
||||
|
||||
## Binding Decisions (G-311..G-320 — applied to PLAN/REQUIREMENTS before execute)
|
||||
|
||||
**G-311 — REQ-10-004's gate-order wording is backwards; the session gate composes BEFORE the entitlement check.** REQ-10-004 says "employer sessions compose AFTER the existing entitlement enforcement" — literally: an unauthenticated caller hits the 402 gate before the 401, leaking entitlement state to anonymous callers and breaking the web client's SessionRequiredError-vs-UpgradeRequiredError discrimination (a 402 on an expired session renders an upgrade CTA to a logged-out visitor — a lie). The plan (Task 4-1-02, R-58) has the correct order; the requirement text contradicts it (the G-307 class: requirement prose naming a composition that must not exist). Decision: amend REQ-10-004 to "the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the R-58 admin-gate-first order preserved)". Applies to: REQUIREMENTS REQ-10-004; PLAN Task 4-1-02 (amendment note). Test: unauthenticated create → 401 (never 402); unclaimed-session create → 403 (never 402). Confidence 0.85.
|
||||
|
||||
**G-312 — no render harness exists in-tree: page/component tests are fetch-stub + source-pin, never "render tests".** Tasks 3-3-01/3-3-02 say "fetch-stub render tests" / "web render tests" — D-059 explicitly declined jsdom/happy-dom (v0.7, founder-scoped), the billing-entitlement.test.ts template's own header says "no render harness in-tree", and adding one is a new dependency violating R-66's zero-dep claim. As written, the executor either smuggles in jsdom or ships tests whose names lie about what they verify (the A-304 class). Decision: every "render test" is reworded to the D-059/E87 discipline — fetch-stub tests over engine-client employer functions (401 → SessionRequiredError, token header attached, logout revoke+clear) + source pins for component honest states (logged-out/claim-pending/loading/error branches present in source, the pins.test.ts discipline); jsdom/happy-dom are BANNED this milestone. Applies to: PLAN Tasks 3-3-01, 3-3-02 (amendment notes; 4-2-01/5-2-01/5-2-02 already say "web fetch-stub" — their loose "render" phrasing is covered by this G); MH-3b/3c/3e and MH-5c/5e read under this discipline. Confidence 0.85.
|
||||
|
||||
**G-313 — both throttle limiters reset via a SUITE-WIDE autouse fixture in tests/employers/conftest.py.** The global cap (default 30/min) is module-level process state (R-63); the entire tests/employers/ suite — register+login flows across test_auth, test_claims, test_claims_admin, test_postings_read, test_postings_write, test_checkout, test_checkout_e2e — runs in ONE process in well under 60 seconds, i.e. inside ONE window: ~90+ auth attempts against a 30-cap → guaranteed mid-suite 429s cascading through every downstream test. The plan places reset fixtures only in test_throttle.py's test list. Decision: tests/employers/conftest.py (a NEW file — the sanctioned deviation from billing's no-conftest layout, R-64) carries an autouse fixture clearing BOTH limiter module states before/after EVERY employers test; the per-file fixtures in test_throttle.py stay for its own window-math. Applies to: PLAN Task 1-2-02 (amendment note), D-096 operationalization. Test: the suite itself (it cannot pass without this — the trap is self-revealing but only after a day of flaky archaeology). Confidence 0.85.
|
||||
|
||||
**G-314 — the global cap counts every well-formed attempt, including failed passwords — or D-088's rationale is void.** The plan's "append only after ALL checks pass (a rejected attempt never consumes budget)" is copied from the sandbox create-cap, where "rejected" means server-rejected-by-validation. Read against auth, "all checks pass" plausibly includes password verification — under that reading, failed logins consume nothing and credential stuffing with rotating emails (the exact attack D-088 names: "bounding cross-email credential-stuffing volume") is UNBOUNDED, and the planned test "rejected attempts don't consume the global budget" would PIN the hole. Decision: the global deque appends for every well-formed login+register request that passes body validation, BEFORE the KDF runs — successes and failed passwords both consume budget; only 422-invalid bodies and the cap's own 429s are exempt; the per-email window keeps failures-only semantics. The test is reworded: "422-invalid attempts consume nothing; FAILED-PASSWORD attempts DO count; N distinct emails inside the window → the (cap+1)-th 429s (stuffing test, knob set low)". Applies to: PLAN Task 1-2-02 (amendment note), MH-1d. Confidence 0.75.
|
||||
|
||||
**G-315 — email fields are plain str with stdlib validation; pydantic EmailStr is BANNED.** email-validator is NOT in the venv (pyproject carries only pydantic/pydantic-settings; no in-tree email-validation precedent exists — identity keys on learner_id). Using pydantic's EmailStr in RegisterEmployerRequest/LoginEmployerRequest — the natural choice for "email + password, honest validation" — raises at model-build/validation time ("email-validator is not installed"), and fixing it means the FIRST new dependency, breaking R-66/D-086's zero-dep claim and the mypy zero-override regime. Decision: email is `str` with stdlib-only validation (non-empty, exactly one "@", non-empty domain, lowercased + stripped on normalize — mirrored in the store's _validate hook); the evidence domain extraction (R-65) already handles the parse. Applies to: PLAN Task 1-1-01 (amendment note). Test: register accepts "User@Example.COM" (normalized), rejects "not-an-email" with an honest 422 that never echoes the password (rides the G-312/1-2-03 pin). Confidence 0.85.
|
||||
|
||||
**G-316 — one-pending-claim-per-entity gets a structural backstop, not just check-first.** insert_application's check-first insert is race-safe ONLY because UniqueConstraint(posting_id, learner_id) backs it; "one PENDING claim per entity" is a CONDITIONAL uniqueness (rejected claims on the same employer_id are legal — "rejection frees the entity"), so no table-level UNIQUE exists and two concurrent claim POSTs both pass the check and both insert. Two threads reach the store (FastAPI sync endpoints run in a threadpool; the store uses check_same_thread=False per-call Sessions) — the 409 promise is TOCTOU. The one-APPROVED-owner arm IS structurally sound (employer_accounts.employer_id UNIQUE → IntegrityError, already pinned) — this G covers only the pending arm. Decision: `employer_claims.__table_args__` gains `Index("uq_pending_claim_per_entity", "employer_id", unique=True, sqlite_where=text("status = 'pending'"))` — SQLite supports partial indexes and SQLAlchemy emits them via create_all (the D-095 one-time path); insert_claim keeps its check-first 409 fast-path and SURFACES the IntegrityError as the same honest 409 under race. Applies to: PLAN Tasks 1-1-02 (schema), 2-1-02 (amendment note), MH-2c. Test: pin the partial index exists in sqlite_master; second concurrent-pending insert → 409 (store test surfaces IntegrityError, never a duplicate pending row). Confidence 0.75.
|
||||
|
||||
**G-317 — employer-created postings stamp their own provenance, never source="admin".** Task 4-1-02 mirrors the admin create route, whose Posting construction hardcodes `source="admin"`, `id=f"adm-…"`, `external_id=f"adm-…"` (marketplace_admin.py:221-243). Copied wholesale, an employer-authored posting presents as operator-authored — corpus provenance lies (A-304: the v0.6 ingest contract means source distinguishes seeded vs admin-authored; a third principal class silently inheriting the admin label makes "who wrote this" unanswerable and muddies the audit story the milestone sells). Decision: employer create stamps a distinct source (e.g., `source="employer"`) with its own id/external_id schemes (`emp-…`), keeping the (source, external_id) upsert identity unambiguous across all three authorship classes; the admin routes are untouched. Applies to: PLAN Task 4-1-02 (amendment note). Test: employer-created posting persists with the employer source stamp (pinned); admin routes still stamp "admin". Confidence 0.80.
|
||||
|
||||
**G-318 — employer edit composes the SAME re-tag path as the admin edit; no tag-semantics fork.** The v0.9 admin edit (PUT /v1/marketplace/admin/postings/{id}) calls update_posting_fields then re-tags via tag_posting — D-052: tags are DERIVED data, overwritten on edit. Task 4-1-02's employer PATCH specifies the own-drafts guard but is silent on re-tagging: an executor shipping edit-without-re-tag leaves employer-edited postings with stale tags — derived data drifting from content, a D-052 violation and a search-facet integrity hole (the exact drift class D-091 exists to prevent, on the tagging axis). Decision: the employer edit route composes the identical update_posting_fields + tag_posting path (tag failure honest — edit stands, tags stay, warning logs — the admin contract), with the own-drafts-only guard evaluated BEFORE the mutation (published/closed → 409 without side effects); PATCH-vs-PUT method naming is free choice. Applies to: PLAN Task 4-1-02 (amendment note). Test: employer edit on a mock provider re-tags (tags updated or TaggingUnavailable-swallowed-pinned); edit published → 409 AND no field/tag mutation occurred. Confidence 0.70.
|
||||
|
||||
**G-319 — the own-postings response is a NAMED pydantic contract; applicant data is COUNTS ONLY.** MH-3a/G-108 pins assert employer.ts against "ai_service/employers/base.py's pydantic contracts — both schemas", but no task's contract list includes the own-postings response: Task 1-1-01 names Account/Claim/Register/Login/Token/Me; Task 3-1-01 describes the route's payload in prose. The pin as specified either pins against nothing (a weak dict-shape grep) or blocks on a contract nobody was told to write. The route also needs a data source the plan doesn't name: applicant_count per posting, while the marketplace store's only read is list_applications(posting_id) — a per-posting N+1 across the employer's postings. Decision: (a) Task 3-1-01 adds named contracts to employers/base.py — `OwnPostingSummary` (PostingSummary reused from marketplace, PLUS applicant_count) and the facets/list response — so the MH-3a pin is schema↔schema; (b) the count rides a new in-pattern marketplace store read (e.g., `count_applications_by_posting(posting_ids) -> dict[str, int]`), never an N+1; (c) explicitly pinned: employer-facing responses expose applicant COUNTS ONLY — learner identifiers NEVER appear in any /v1/employers/* response (PII boundary, security-auditor P3/P5 review scope). Applies to: PLAN Tasks 1-1-01 (contract list), 3-1-01 (amendment note), 3-2-01, MH-3a. Confidence 0.80.
|
||||
|
||||
**G-320 — throttle-before-KDF order (a) and the 402 zeros are wire-only, never displayed truth (b).** (a) R-55's open risk (login bursts stack ~50 ms scrypt) is bounded ONLY if both throttle checks run BEFORE the derivation — a 429'd or globally-capped attempt must never burn KDF CPU. The plan never states the check order inside the login route. (b) UX acceptance criterion 3 says "limits shown are the store's truth" while the 402 payload hardcodes posts_used=0/posts_limit=0 (byte-stable per D-091/MH-4a) — as written the criterion invites the executor to "improve" the zeros (risk 7) or to render "0/0" as real limits (a lie). Decision: (a) route order is body-validation → global cap → per-email window → scrypt verify → failure-append (pinned: a 429'd attempt performs zero derivations); (b) criterion 3 reworded — the 402 payload carries reason + CTA only; real limits render exclusively from GET /v1/employers/billing/entitlement (the entitlement view is the limits truth source, Task 5-1-01). Applies to: PLAN Task 1-2-02 (a, amendment note), Task 4-2-01 + UX criterion 3 (b, amendment note). Tests: (a) a capped email's login attempt completes in ≪ KDF time / no derivation side-effect observable — pinned as "429 before KDF" via the throttle test's knob math; (b) web source pin: the 402 branch renders reason + CTA and fetches nothing from the payload's posts_used/posts_limit. Confidence 0.80.
|
||||
|
||||
## Scope Cuts
|
||||
|
||||
None. Every phase maps to a REQ-10-001..005 row; the one candidate (employer draft-edit, the least-essential P4 arm) is named by REQ-10-004 and is cheap once G-318 forces admin-parity — cutting it would unilaterally narrow a locked requirement. Talent pages staying mock is the honest deferral the plan already makes (D-092).
|
||||
|
||||
## Advisories (recorded, no plan edit)
|
||||
|
||||
- **a-311:** Employer mock-complete "own session" = checkout rows whose employer_id equals the session's claimed entity — including operator-created rows for that entity (same buyer, D-076 fallback path; not a G-301 violation). Pin the test's fixture accordingly so the 403 arm uses a DIFFERENT entity's row, not an admin-created same-entity row.
|
||||
- **a-312:** Per-email throttle inherently permits targeted lockout DoS (5 failures → victim's email 429s for 15 min). Accepted within D-088 (email-keying is locked, never re-litigated); success-clears-window is the mitigation; surface copy need not mention it.
|
||||
- **a-313:** .env.example throttle entries are documentation, not "default-off" — the Settings ints are always active with defaults 5/30 (the R-60d shape). Write the comments that way.
|
||||
- **a-314:** Logout on an already-revoked token → 401 (strict) is fine; do not "fix" it to 204 — idempotent-logout would let a stolen-tab replay confirm revocation state.
|
||||
- **a-315:** Register's 409 duplicate-email necessarily reveals account existence — standard and unavoidable; the login 401 uniformity (already specified) is the enumeration boundary that matters.
|
||||
|
||||
## Plan Edits Applied
|
||||
|
||||
1. G-311 → REQUIREMENTS REQ-10-004 wording + PLAN Task 4-1-02 amendment note
|
||||
2. G-312 → PLAN Tasks 3-3-01, 3-3-02 amendment notes
|
||||
3. G-313/G-314/G-320a → PLAN Task 1-2-02 amendment note
|
||||
4. G-315 → PLAN Task 1-1-01 amendment note
|
||||
5. G-316 → PLAN Task 2-1-02 amendment note
|
||||
6. G-317/G-318/G-311 → PLAN Task 4-1-02 amendment note (single combined note)
|
||||
7. G-319 → PLAN Task 3-1-01 amendment note
|
||||
8. G-320b → PLAN Task 4-2-01 amendment note + UX criterion 3 clause
|
||||
|
||||
---
|
||||
|
||||
## v0.11 Grill (Phase 0 GRILL, 2026-10-01)
|
||||
|
||||
### Run: 2026-10-01 03:00 (mode: mechanical, focus: all — v0.11 placement tracking)
|
||||
|
||||
### Verdict: Proceed (confidence: 0.82)
|
||||
|
||||
**Verdict summary.** The v0.11 plan (6 phases, 15 waves, ~30 tasks) is feasible, correctly sequenced, and — rare for a plan this audit-flavored — unusually honest about its own limits (R-67's three methodology gaps are pinned into the export). Every load-bearing tree citation re-verified held: the billing transition-matrix shape, the `decide_claim` same-transaction precedent, the `submitted`-only application status (zero migration), the G-313 conftest reset pattern, the G-319 counts-only pin, all four main.py wiring sites, the engine-client error-class rails, and the overview mock block D-109 replaces. The two genuinely novel mechanisms (the hash chain, the export) each carry dedicated adversarial tests (tamper/fork/determinism), and the one coherence gap research found (R-80 re-hire) is pinned to a route. But red-teaming found **8 binding defects**: the export's verification story overclaims by omission (the chain detects in-place mutation of an intact sequence — it cannot detect a wholesale DB rewrite, and no instruction tells the auditor how to use prior exports as the anchor); **the learner status panel cannot find the learner's application on revisit** (the read route is keyed by `application_id`, no route exists to discover it for a posting, and the plan never says how the panel obtains it — the happy path only works in the same browser session); the apply route's idempotent arm returns a 200 "applied" confirmation for a withdrawn (terminal) application — a lie D-104's withdraw creates; `role_title` accepts the empty string (the 422 arm pins absence, not emptiness); revoked placements are counted but their evidence bundles are unspecified (an unverifiable `revoked_count`); the re-hired PlacementRow's `decided_at` is unspecified; the operator route's `to` has no 422-vs-409 discrimination; and nothing pins the live UI wording against "CIRR-verified" theater. All eight amend existing tasks — nothing re-architects, no scope cuts required.
|
||||
|
||||
### Per-Axis Findings
|
||||
|
||||
| # | Axis | Verdict | Reason |
|
||||
|---|------|---------|--------|
|
||||
| 1 | Feasibility | PASS | Outcome is unmeasurable without this milestone (no hire state exists in-tree); every prerequisite shipped; 6-phase shape matches the two shipped precedents |
|
||||
| 2 | Scope | PASS | Explicit out-of-scope list; R-67's three pilot gaps pre-disclosed in the methodology (the hidden-requirement class handled better than any prior milestone); the one boundary change (G-319 supersession) is decisioned with a pin split |
|
||||
| 3 | Architecture | CONCERN → fixed | Chain NOT over-engineered (it IS the deliverable; the cheap version) but UNDER-scoped (G-321: the walk cannot detect a wholesale DB rewrite — export-to-export prefix continuity is the anchor); the mine-read gap strands the learner status panel on revisit (G-322); D-100 cross-store transaction verified sound (one Session, one commit — the two-commit alternative voids the tamper-evidence) |
|
||||
| 4 | People | PASS | Roster matches the work; the security-auditor's four review points cover exactly the novel-risk surfaces |
|
||||
| 5 | Timeline | PASS | Consistent with v0.9/v0.10 identical-shape precedents; the flush-then-hash-then-commit append is the one new mechanism, mitigated by R-70's in-tree template + tamper/fork arms |
|
||||
| 6 | Budget | PASS | Zero new deps, zero knobs, zero workflow changes; chain verification is O(n) sha256 over tiny rows |
|
||||
| 7 | Risks | CONCERN → fixed | Pre-mortem done (R-80 found + pinned by research itself; all 10 open risks map 1:1 to tasks); NEW cross-decision defect found: apply → withdraw → re-apply returns a 200 "applied" lie (G-323 — D-043 × D-099 × D-104 interaction nobody mapped) |
|
||||
| 8 | Governance | CONCERN → fixed | Decisions traceable + discipline enforced (the R-80 escalation flow is the pattern working); MISSING: live-surface wording discipline — "CIRR-verified" theater drift unpinned (G-328); the operator route's 422-vs-409 split unspecified (G-327) |
|
||||
| 9 | Adoption | CONCERN → fixed | Export is honestly-minimal (dataset + disclosure, not theater) PROVIDED G-321/G-328 hold; counting gameable only via operator-gated claim approval — honestly disclosed (a-321); empty-string role_title accepted (G-324) |
|
||||
| M | Meta | CONCERN → fixed | Revoked placements' evidence bundles unspecified — an auditor handed revoked_count with no evidence has an unverifiable number, and correction history is what an auditor most wants to inspect (G-325); the re-hired row's decided_at unpinned (G-326); simplest-80% version = this plan (no cut found) |
|
||||
|
||||
### Binding Decisions (G-321..G-328 — applied to PLAN before execute)
|
||||
|
||||
**G-321 — the export methodology states the chain's proof scope, its limit, and the auditor's real anchor.** The walk proves in-place mutation/fork detection of an intact sequence; it does NOT detect a wholesale DB rewrite (delete all events, re-append a fresh plausible chain from GENESIS — it walks green; GENESIS is a constant with no external anchor). PlacementRow itself is not hash-chained — a raw-SQL status flip leaves a chain-consistent ledger with a lying row. Decision: the methodology's `verification` block + the summary's verification instructions state (a) what the chain proves, (b) what it does not (wholesale replacement — the auditor's defense is export-to-export continuity: a prior export's `ledger.events` must appear as an exact prefix of a later export's, and `head_hash` at the prior export must equal the hash at that prefix position), and (c) an event↔row cross-consistency check (every active placement has `placement.created`; every revoked has `placement.revoked`; revoked_count matches the `placement.revoked` events). Amends Tasks 4-1-01, 4-1-02; asserted present by 4-1-03. Confidence 0.80.
|
||||
|
||||
**G-322 — the mine point-read: the learner status panel must never depend on client-persisted application_ids.** The read route is keyed by application_id, but no route discovers a learner's application for a posting (the idempotent apply return yields the id only in-session; there is no by-(posting, learner) read anywhere in-tree) — REQ-11-005's outcome visibility breaks on revisit, the common case for a job board. Decision: store `get_application_by_posting_learner(posting_id, learner_id)` (the insert_application unique key — the get_posting point-read shape) + route `GET /v1/marketplace/applications/mine?posting_id=&learner_id=` on the unchanged D-043 gate stack, returning `{application_id, status}` or 404. The job-detail panel composes: mine-read → found? render the status panel via D-104's read : not found? render the apply CTA. Amends D-104 (additive route), Tasks 5-1-01 + 5-2-01; tests in test_learner_status.py (own/foreign/unverified/unknown arms + 404-when-none). Confidence 0.85.
|
||||
|
||||
**G-323 — apply on an existing terminal application → 409, never a 200 "applied" confirmation.** insert_application's idempotent arm (marketplace/store.py:341-344) returns the EXISTING application with 200 — after D-104's withdraw makes it terminal, re-apply tells the learner they applied to a job they can never re-enter the pipeline for. Decision: the apply route's idempotent arm gains a terminal-state guard: returned application in `withdrawn`/`rejected`/`hired` → 409 `{"reason": "application_terminal", "status": …}` (the posting_not_published 409 shape, marketplace.py:366-373), never a 200; no new application row (the unique key stands); recovery is visibility (G-322's mine-read + status panel), not resurrection. Amends Task 2-2-01's test list (apply → withdraw → re-apply → 409). Confidence 0.75.
|
||||
|
||||
**G-324 — `role_title = Field(min_length=1, max_length=200)`; start_date keeps no semantic range.** Pydantic "required" rejects absence, not emptiness — the house Field shape (marketplace/base.py:68-71) is not specified for the hire body and `role_title: ""` would enter the hashed ledger. Decision: min_length=1 (pinned beside the missing-fields 422 arm), max_length=200 (bounds absurd payloads out of the hashed rows). start_date stays a bare pydantic date — retroactive + future legal per D-103; garbage dates surface as anomalous by_cohort_month buckets in the operator overview and are corrected via the revoke/re-hire lane; the methodology's attestation note covers date integrity. Amends Tasks 1-1-01, 2-1-01. Confidence 0.80.
|
||||
|
||||
**G-325 — evidence_bundles cover EVERY placement (active + revoked), each with the full event history.** An auditor handed `revoked_count: 3` with no evidence for the three revocations has an unverifiable number — and the correction history (hired → revoked → rehired) is precisely what an auditor most wants to inspect. Decision: bundles cover active AND revoked placements with the full event history incl. placement.revoked/placement.rehired; `cohort` stays active-only; revoked_count equals the placement.revoked events (cross-checkable, rides G-321(c)). Amends Tasks 4-1-01, 4-1-03 (completeness test: every placement active-or-revoked has a bundle). Confidence 0.80.
|
||||
|
||||
**G-326 — the re-hired PlacementRow's decided_at = the re-hire event's store-stamped created_at.** D-102 stamps decided_at from "the hired transition's created_at" — a re-hire has no hired transition; an executor will guess. Decision: the same `datetime.now(UTC)` stamp rule extends to the re-hire arm; stated in the module docstring + asserted in the revoke→re-hire test arm. Amends Task 2-2-02. Confidence 0.85.
|
||||
|
||||
**G-327 — the operator transition route's `to` is a pydantic Literal of the seven D-099 statuses.** Unknown value → 422 (body validation), matrix-illegal known value → 409 InvalidTransition — the route never sees a status outside the vocabulary (no 500s, no silent acceptance). Test arm in test_admin_lane.py (garbage `to` → 422, never 500/409). Amends Task 2-2-02. Confidence 0.85.
|
||||
|
||||
**G-328 — live surfaces never present attestation as verification.** Nothing pins the admin placements block / overview copy against the theater drift — "CIRR-verified placements" or "verified: 1,000" on a live surface presents attestation as third-party verification (A-304), the exact self-certification trap the milestone could fall into. Decision: live surfaces label counts as recorded placements ("distinct learners placed", "placements recorded"); "CIRR" NEVER appears as a verification claim on any live surface — "CIRR-style" describes the export format in release notes/methodology only; the summary's title renders the attestation language ("employer-attested, operator-overseen"). Amends Task 4-2-01 (copy) + UX criterion 5's test arm (source pin: no "verified" placement wording on any new surface). Confidence 0.75.
|
||||
|
||||
### Advisories (recorded, no action)
|
||||
|
||||
- a-321: No technical self-hire guard exists (learner handles and employer accounts are disjoint namespaces); the anti-gaming control is operator claim-approval diligence + per-employer overview detection + the revoke lane — already disclosed as R-67 gap (c); do not add a theater cross-check.
|
||||
- a-322: Withdraw shares the apply-rate window (5/60s per learner, D-104) — a learner withdrawing 5 applications quickly locks themselves out of applying for the window's remainder. Honest, bounded, no new knob.
|
||||
- a-323: The matrix arm (read status → validate) is check-then-act, but the terminal guard makes a concurrent loser's write a 409-conflict on re-read; the D-111 concurrent-transition test covers the shape; no pessimistic locking at pilot volumes.
|
||||
- a-324: The employer pipeline list necessarily carries learner_id per row (own postings only) — the G-319 pin split's application-scoped arm working as decisioned; the P2 security review confirms the list route's cross-entity arms return 403 with no data, same as the transitions.
|
||||
|
||||
### Escalations
|
||||
|
||||
None. All eight findings resolve with evidence-backed defaults (each names its amends-task and its test); nothing re-architects, no axis falls below 0.60.
|
||||
|
||||
### Plan Edits Recorded (applied by the orchestrator)
|
||||
|
||||
1. G-321/G-325/G-328 → PLAN Tasks 4-1-01, 4-1-02, 4-1-03, 4-2-01 amendment notes + UX criterion 5 arm
|
||||
2. G-322 → PLAN Tasks 5-1-01, 5-2-01 amendment notes (additive route per D-104)
|
||||
3. G-323 → PLAN Task 2-2-01 amendment note (apply-route terminal guard + test arm)
|
||||
4. G-324 → PLAN Tasks 1-1-01, 2-1-01 amendment notes
|
||||
5. G-326/G-327 → PLAN Task 2-2-02 amendment notes
|
||||
|
||||
+54
-51
@@ -2,18 +2,17 @@
|
||||
|
||||
## Persona Roster
|
||||
|
||||
> **v0.8 update (RESEARCH, lead-developer assessment):** milestone = Ship Anywhere (Feature — release/distribution rails: runner fleet, docker image, deb/arm64 packaging, CLI onboarding glue, admin guides). **Activated:** devops-engineer (NEW custom persona — the runner fleet on CT 113, docker-compose + labels + cache hygiene, .gitea/workflows push + tag lanes, Dockerfile + registry push, pre-commit config; the whole release-consistency surface REQ-8-001/002), cli-engineer (re-activated from its v0.4/v0.4.6 territory — build-binary.mjs arch parameter + arm64 node base, install.sh arch-resolve, deb packaging scripts, doctor container/deb-channel awareness, `guide` embedded text, bootstrap/deploy/dev container refusal — REQ-8-003/004), sandbox-engineer (re-activated for the D-068 capability probe promotion into sandbox/capability.py + the 503 degrade arm in api/sandboxes.py — its unshare_backend/manager/api-surface territory), lead-developer (coordination + wave order + the P5 admin-guide assembly), security-auditor (phase-specific: release secrets handling REGISTRY_TOKEN/GITEA_TOKEN/registration token, image non-root + healthcheck, sibling-mode blast radius, deb postinst). **Deactivated:** backend-engineer (no config/api surface work beyond what sandbox-engineer owns — the D-068 wiring lives in sandbox+main.py boot notice, owned by sandbox-engineer this milestone), frontend-engineer (no web-app changes; apps/web is only a BUILD INPUT to the image), ai-engineer, data-engineer, identity-engineer, voice-engineer, design-system-engineer.
|
||||
> **v0.11 update (RESEARCH, lead-developer assessment):** milestone = Placement Tracking and Audit (Feature — ai_service/placements/ module: 9th D-027 store with the hash-chained placement_event ledger, the application state machine via one transactional transition_application, /v1/employers/applications per-target routes, learner withdraw/status routes in marketplace.py, the placements_admin operator lane + export, four web surfaces). **Activated:** lead-developer (coordination + the R-80 re-hire coherence review + the G-319 pin-split review), backend-engineer (owns ai_service/placements/* + api/placements_admin.py + the employer application routes + learner routes in marketplace.py + deps.py get_placement_store + main.py wiring — REQ-11-001..005 core), data-engineer (owns the 2-table placement store schema: PlacementRow partial-unique + PlacementEventRow global hash chain flush-then-hash-then-commit, canonical serialization, the D-102 revoke semantics — REQ-11-001/002), frontend-engineer (owns the four D-109 surfaces: /employer/applications page, overview placements section, job-detail status panel, admin placements block + engine-client placements section + packages/types/placement.ts — REQ-11-003/005), security-auditor (phase-specific: the hash-chain tamper evidence, the G-319 supersession PII review, gate-order pins, export integrity — REQ-11-001/002/004). **Deactivated:** cli-engineer, devops-engineer, sandbox-engineer, ai-engineer, identity-engineer (learner gates compose unchanged — D-104), voice-engineer, design-system-engineer (all unchanged surfaces this milestone).
|
||||
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Coordinates the v0.8 Ship Anywhere milestone (fleet → container → debian → onboarding → guides waves); owns the admin-guide assembly (P5) as the single cross-channel runbook; owns wave-order + the release-asset matrix consistency check (every release: x64/arm64 binaries + debs + image tag per D-064/D-065)
|
||||
reason: Coordinates the v0.11 Placement Tracking milestone (store → state machine → reporting → export → visibility waves); owns wave order + the R-80 re-hire coherence decision's implementation review (the placement.rehired route/event shape) + the G-319 pin-split review (counts-only stays on own-postings lists; learner_id on application-scoped routes only) + the placement.ts ↔ placements/base.py dual-schema pin review + the D-100 cross-store transaction discipline review (one transition_application, no second status writer)
|
||||
domain: coordination
|
||||
frameworks:
|
||||
- turborepo
|
||||
- pnpm
|
||||
- gitea-actions
|
||||
- docker
|
||||
constraints:
|
||||
- pragmatic
|
||||
- battle-tested defaults
|
||||
@@ -21,15 +20,14 @@ constraints:
|
||||
territory:
|
||||
- "**/package.json"
|
||||
- "**/turbo.json"
|
||||
- "deploy/README.md"
|
||||
- "README.md"
|
||||
- "apps/web/tests/pins.test.ts"
|
||||
```
|
||||
|
||||
### devops-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: NEW v0.8 custom persona — owns the entire release-consistency surface: 3 org-level gitea/runner:3 containers on CT 113 (sibling mode, host docker.sock, per-runner /data, GITEA_RUNNER_LABELS, capacity 1, cache.host pinning + 32 GB prune cron — REQ-8-001), .gitea/workflows push lane (node/python lanes with the repo's own scripts, D-063 fast-only test lanes) + tag lane (exclusive release owner D-064), multi-stage Dockerfile + git.coreci.dev/coreci/nextcraft push (semver + latest per D-065 — REQ-8-002), root .pre-commit-config.yaml (D-070)
|
||||
reason: v0.11 DEACTIVATED — no infra changes; the placements module rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
|
||||
domain: devops
|
||||
frameworks:
|
||||
- docker
|
||||
@@ -39,23 +37,23 @@ frameworks:
|
||||
- bash
|
||||
constraints:
|
||||
- sibling-mode-socket-trust (org-scoped private repos only; accepted-risk documented)
|
||||
- secrets-via-actions-secrets-or-founder (registration token never in files; REGISTRY_TOKEN self-serve PUT first, founder UI fallback)
|
||||
- job-token-cannot-publish-packages (upstream-confirmed; registry push needs the PAT secret)
|
||||
- cache-host-pinning (dockerized runner cache server must be job-reachable or actions/cache ETIMEDOUTs)
|
||||
- secrets-via-actions-secrets-or-founder
|
||||
- job-token-cannot-publish-packages
|
||||
- cache-host-pinning
|
||||
- fast-lanes-only-in-ci (real_server/userns never in job containers, D-063)
|
||||
territory:
|
||||
- ".gitea/workflows/**"
|
||||
- "Dockerfile"
|
||||
- ".dockerignore"
|
||||
- ".pre-commit-config.yaml"
|
||||
- "deploy/runner/**" # compose + config for CT 113 (admin-guide artifacts, not CT state)
|
||||
- "deploy/runner/**"
|
||||
```
|
||||
|
||||
### cli-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.8 re-activation — owns the distribution artifacts of its own binary: build-binary.mjs arch parameter (--arch x64|arm64; arm64 node-base download replacing process.execPath copy, postject unchanged — D-069 QEMU-free cross-build), scripts/build-deb.sh + postinst advisory doctor (D-067), install.sh arch-gate→arch-resolve (x86_64/aarch64, sha256+version gates byte-identical), doctor container/deb-channel awareness (real userns probe + NC_CONTAINER//.dockerenv//usr/bin/nextcraft detection — REQ-8-004/D-066), `nextcraft guide` offline-first embedded text (D-072), bootstrap/deploy/dev in-container refusal (D-066/D-071), verify container-mode branch
|
||||
reason: v0.11 DEACTIVATED — no CLI changes; the placement surface is server+web only. Roster + v0.8 territory retained.
|
||||
domain: cli
|
||||
frameworks:
|
||||
- node
|
||||
@@ -82,9 +80,9 @@ territory:
|
||||
|
||||
### sandbox-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.8 re-activation — owns the D-068 degrade detection in its sandbox fabric: promote tests/helpers/userns.py's probe into ai_service/sandbox/capability.py (verbatim semantics), the boot notice in main.py lifespan (the G-11 mount-less-warning pattern), the per-create 503 + remediation arm (SandboxUnavailableError mapping beside PoolFullError in api/sandboxes.py) — the honest container-degrade path for REQ-8-002; real_server/userns test tiers stay OUT of CI lanes per D-063
|
||||
reason: v0.11 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
|
||||
domain: infra
|
||||
frameworks:
|
||||
- python
|
||||
@@ -108,34 +106,34 @@ territory:
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.8 re-activated (phase-specific) — P1 release secrets review (registration token flow, REGISTRY_TOKEN/GITEA_TOKEN Actions secrets, the .env-only resolution of release-assets.sh preserved as manual fallback), P2 image review (non-root user, healthcheck without curl, state volume ownership, sibling-mode socket blast-radius row), P3 deb review (postinst never fails dpkg, empty Depends + honest Recommends), P6 final review (channel consistency: every recipe backed by a test or live-box evidence)
|
||||
reason: v0.11 re-activated (phase-specific) — P1 ledger review: the hash-chain construction (canonical JSON rules, GENESIS pinned, UNIQUE(prev_hash) fork backstop, flush-then-hash-then-commit single append path, no delete path — R-70), partial-unique active-placement guard (G-316 shape); P2 gate review: G-311 gate order on application transitions (401 → 403-claim → 403-cross-entity → 409), the G-319 supersession PII review (learner_id = opaque handle, application-scoped only, anti-enumeration preserved — R-79); P4 export integrity (determinism rules, generated_at exclusion, methodology honesty gaps stated — R-67/R-68/R-73); P6 final review (no PII/credentials in logs or releases)
|
||||
domain: security
|
||||
frameworks:
|
||||
- pytest
|
||||
- httpx
|
||||
constraints:
|
||||
- STRIDE-classified
|
||||
- secrets-never-in-workflow-env (Actions secrets only; never files)
|
||||
- secrets-via-env-only (D-014)
|
||||
- secrets-never-logged
|
||||
- registration-token-reset-after-fleet-green (founder directive)
|
||||
- fail-closed-degrades (unprivileged default; privileged variant documented opt-in)
|
||||
- fail-closed-on-unconfigured
|
||||
- never-display-forgeable-as-verified (A-304 — the export methodology states its own limits)
|
||||
- tamper-evidence-tested (an audit ledger nobody has tried to break is theater, D-111)
|
||||
territory:
|
||||
- ".gitea/workflows/**"
|
||||
- "Dockerfile"
|
||||
- "scripts/build-deb.sh"
|
||||
- "scripts/release-assets.sh"
|
||||
- "apps/cli/src/commands/deploy.ts"
|
||||
- "apps/ai-service/ai_service/placements/**"
|
||||
- "apps/ai-service/ai_service/api/placements_admin.py"
|
||||
- "apps/ai-service/tests/placements/**"
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: false
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — the only backend-adjacent work (D-068 probe wiring + boot notice) is owned by sandbox-engineer whose fabric it extends; config.py/pyproject need zero changes (NC_CONTAINER is a CLI-layer marker; the AI_* env surface is untouched). Roster retained; the v0.7 pytest/mypy territory is stable.
|
||||
reason: v0.11 ACTIVATED — owns the placements module core: ai_service/placements/base.py (pydantic contracts + the D-099 transition matrix + event-name vocabulary), store.py wiring via deps.py get_placement_store; the ONE transactional transition_application method (status write + ledger append + placement-on-hired, one commit — D-100) + the R-70 flush-then-hash-then-commit append shape; api routes: employer per-target transitions under /v1/employers/applications (G-311 gate order — D-105), learner withdraw/status in marketplace.py (D-043 stack unchanged — D-104), placements_admin.py operator lane (generic transition + revoke + R-80 re-hire route + overview/export reads — D-106/D-108); main.py wiring (placement_store lifespan + close, placements_admin mount); no new config knobs (no new throttles)
|
||||
domain: backend
|
||||
frameworks:
|
||||
- fastapi
|
||||
- pydantic-settings
|
||||
- httpx
|
||||
- bash
|
||||
- turborepo
|
||||
- pnpm
|
||||
@@ -143,22 +141,22 @@ constraints:
|
||||
- secrets-via-env-only (D-014)
|
||||
- idempotent-scripts
|
||||
- fail-closed-admin
|
||||
- never-call-cloud-in-tests (zero network by construction, D-111)
|
||||
- one-status-writer (placements store is the ONLY post-creation writer of application.status — R-78)
|
||||
- no-cross-family-foreign-keys (G-302 ID references at read)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/config.py"
|
||||
- "apps/ai-service/ai_service/placements/**"
|
||||
- "apps/ai-service/ai_service/main.py"
|
||||
- "apps/ai-service/ai_service/api/**"
|
||||
- "apps/ai-service/scripts/**"
|
||||
- "apps/ai-service/.env.example"
|
||||
- "apps/ai-service/tests/placements/**"
|
||||
- "apps/ai-service/pyproject.toml"
|
||||
- "package.json"
|
||||
- "turbo.json"
|
||||
```
|
||||
|
||||
### frontend-engineer
|
||||
```yaml
|
||||
active: false
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — no apps/web changes; the web app is only a BUILD INPUT to the docker image (NEXT_PUBLIC_AI_SERVICE_URL=self export, unchanged since D-039). ESLint/c8/pins territory stable from v0.7.
|
||||
reason: v0.11 ACTIVATED — owns the four D-109 surfaces: new /employer/applications page (own pipeline with status badges + per-target transition CTAs + hire form {role_title, start_date}), employer overview placements section (replaces the mock-labeled placement metrics block — D-092 never-fake-convert), job-detail-client application-status panel (learner history + placement confirmation via D-104 read, EngineError.status discrimination, no new error classes), admin moderation placements block (overview counts + per-employer lists + export links, adminToken transport); engine-client placements section (employer/learner/admin functions, AbortController everywhere, SessionRequiredError reuse — D-110); packages/types/placement.ts dual-schema pinned (D-110); talent badge stays absent (A-304)
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- react
|
||||
@@ -168,16 +166,20 @@ constraints:
|
||||
- component-first
|
||||
- server-components-default
|
||||
- no-build-time-db-reads (D-039 export build stays repo-only)
|
||||
- honest-states (loading/error/empty/logged-out never mocked)
|
||||
- abortcontroller-everywhere (A-406)
|
||||
- session-gated-shell (401 → honest logged-out state, never a retry loop)
|
||||
territory:
|
||||
- "apps/web/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/types/placement.ts"
|
||||
```
|
||||
|
||||
### ai-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — no LLM-facing work; the provider layers ship unchanged inside the docker image (G-11 fallbacks are the only runtime-config behavior).
|
||||
reason: v0.11 DEACTIVATED — no LLM-facing work; placement flows need no tagging/enrichment/embedding changes (hire transitions record decisions; no AI surface is touched).
|
||||
domain: ai
|
||||
frameworks:
|
||||
- pydantic
|
||||
@@ -196,9 +198,9 @@ territory:
|
||||
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: false
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — zero schema/store changes; the ~/.nextcraft/data/nextcraft.db path rides into the docker volume via config.py's existing home-dir default (no code change).
|
||||
reason: v0.11 ACTIVATED — owns the 2-table 9th store schema (D-112): placements/store.py with PlacementRow (id, learner/employer/posting/application refs, role_title, start_date DATE, status active|revoked, created_at, decided_at; partial UNIQUE application_id WHERE status='active' — the G-316 shape, D-102) + PlacementEventRow (append-only global ledger: nullable indexed application_id + placement_id, event_type, actor_type employer|learner|operator|system, actor_ref, payload, prev_hash UNIQUE + hash, created_at — D-101); the canonical serialization rules (sort_keys + separators, _as_utc(dt).isoformat() pinned, no floats — R-79/R-69); flush-then-hash-then-commit append (R-70); the R-80 placement.rehired event vocabulary; cross-store transaction (one Session writes ApplicationRow + both placement tables — D-100); tz discipline + create_all idempotent compat (D-095 shape); _validate/_normalize + surfaced IntegrityError
|
||||
domain: data
|
||||
frameworks:
|
||||
- sqlmodel
|
||||
@@ -208,19 +210,20 @@ constraints:
|
||||
- schema-first
|
||||
- type-safe
|
||||
- dual-schema-sync
|
||||
- idempotency-by-constraint (partial UNIQUE + check-first)
|
||||
- audit-append-only (the ledger has NO delete path — R-70)
|
||||
- canonical-serialization-pinned (one normalization, test-pinned)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/marketplace/base.py"
|
||||
- "apps/ai-service/ai_service/marketplace/store.py"
|
||||
- "apps/ai-service/ai_service/marketplace/ingest.py"
|
||||
- "apps/ai-service/ai_service/placements/store.py"
|
||||
- "apps/ai-service/tests/placements/test_store.py"
|
||||
- "packages/types/**"
|
||||
- "packages/mock-data/**"
|
||||
```
|
||||
|
||||
### identity-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 custom persona — module shipped complete; no v0.8 identity-domain work.
|
||||
reason: v0.11 stays DEACTIVATED — the milestone composes the EXISTING learner gates unchanged (D-104: the withdraw/status routes ride the D-043 stack — G-5 allowlist → verified 18+ → apply-rate window); no identity-module changes. Reactivation is a founder call if/when a real KYC vendor lands.
|
||||
domain: identity
|
||||
frameworks:
|
||||
- fastapi
|
||||
@@ -239,7 +242,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 territory — voice shipped complete; no v0.8 voice work.
|
||||
reason: v0.5 territory — voice shipped complete; no v0.11 voice work.
|
||||
domain: ai-media
|
||||
frameworks:
|
||||
- httpx
|
||||
@@ -260,7 +263,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: No design-token or primitive work in v0.8; roster retained.
|
||||
reason: No design-token or primitive work in v0.11 (the applications page reuses existing Badge/Button/Card primitives); roster retained.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- tailwindcss
|
||||
@@ -276,7 +279,7 @@ territory:
|
||||
|
||||
| Persona | Phases | Removed After |
|
||||
|---------|--------|---------------|
|
||||
| security-auditor | 1 (runner + secrets), 2 (image non-root/volumes), 3 (deb postinst), 6 (final channel-consistency review) | milestone complete |
|
||||
| security-auditor | 1 (store/ledger review: hash chain, canonical serialization, partial unique, no-delete path), 2 (gate order + G-319 supersession PII review on application routes), 4 (export integrity: determinism rules, methodology honesty), 6 (final review: no PII in logs/releases) | milestone complete |
|
||||
|
||||
All other personas span the milestone. Deactivated personas receive no tasks.
|
||||
|
||||
@@ -284,9 +287,9 @@ All other personas span the milestone. Deactivated personas receive no tasks.
|
||||
|
||||
| Conflict | Resolution |
|
||||
|----------|------------|
|
||||
| devops-engineer vs cli-engineer (build-binary.mjs invocation) | devops-engineer owns the WORKFLOW steps that invoke `pnpm --filter @nextcraft/cli build:binary`; cli-engineer owns the SCRIPT itself (arch parameter, arm64 base, sidecars) — the workflow never patches build scripts |
|
||||
| devops-engineer vs sandbox-engineer (D-068 in Dockerfile) | devops-engineer owns the Dockerfile layers (base, user, healthcheck, volume); sandbox-engineer owns the runtime capability probe + degrade semantics the image merely runs; NC_CONTAINER marker name is a shared contract (devops sets it in the entrypoint, cli-engineer + sandbox-engineer read it) |
|
||||
| cli-engineer vs devops-engineer (doctor container facts) | cli-engineer owns doctor/verify/guide implementations; devops-engineer supplies the documented facts (volume path, privileged variant recipe) via deploy/README.md |
|
||||
| sandbox-engineer vs backend-engineer (main.py boot notice) | sandbox-engineer owns the D-068 boot notice + api/sandboxes.py degrade arm this milestone; backend-engineer is deactivated but its config.py territory is NOT modified (NC_CONTAINER lives in the CLI layer, not Settings) |
|
||||
| cli-engineer vs security-auditor (postinst/secrets) | cli-engineer implements; security-auditor reviews fail-never-postinst + token resolution and may patch security defects directly in scripts/build-deb.sh + release-assets.sh (its territory) |
|
||||
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
|
||||
| backend-engineer vs frontend-engineer (engine-client.ts) | backend-engineer owns the API contract shapes the client consumes; frontend-engineer owns engine-client.ts itself (the placements section) — the existing identity/marketplace/billing/employer sections are frozen contracts neither side may re-shape; the new wire types land in packages/types/placement.ts (data-engineer dual-schema-syncs, lead-developer reviews the pins) |
|
||||
| backend-engineer vs security-auditor (api routes) | backend-engineer implements the routes/deps/gates; security-auditor reviews and may patch security DEFECTS directly in api/placements_admin.py + placements/store.py (its territory) — implementation vs review stays with backend |
|
||||
| backend-engineer vs data-engineer (placements/store.py) | data-engineer owns the schema + store methods (rows, chain, canonical serialization); backend-engineer owns routes/deps/wiring that compose it; the transition_application METHOD contract is data territory (the transaction + hash append), the routes calling it are backend territory |
|
||||
| backend-engineer vs data-engineer (application.status column) | the placements store is the ONLY post-creation status writer (R-78) — marketplace/store.py's insert_application writes it once at creation; no route or future store adds a second writer outside the one-transaction discipline |
|
||||
| frontend-engineer vs security-auditor (PII surfaces) | frontend-engineer implements the application surfaces (learner_id visible on own applicants); security-auditor reviews the G-319 pin split (counts-only stays on own-postings lists) and pins the cross-entity 403 negative arms; neither patches the other's files — findings flow through lead-developer |
|
||||
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
|
||||
+157
-198
@@ -1,239 +1,198 @@
|
||||
# Nextcraft — PLAN.md (v0.8 Ship Anywhere)
|
||||
# Nextcraft — PLAN.md (v0.11 CIRR-Style Placement Tracking and Audit)
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone:** v0.8 — Ship Anywhere (Feature): nextcraft installable on any Debian-based Linux server via every mainstream channel — docker image from the Gitea registry, .deb packages, one-liner binary (x64 + arm64) — with release consistency enforced by pre-commit hooks + Gitea Actions pipelines, and the CLI carrying the full suite for zero → running onboarding. Admin guides document all of it.
|
||||
**Tag line:** phase tags advance the v0.7.x line (P0 → v0.7.0 … P6 → v0.7.6 = milestone release), continuing from the v0.6.6 milestone release
|
||||
**Branch:** milestone/v0.8-ship-anywhere
|
||||
**Milestone:** v0.11 — CIRR-Style Placement Tracking and Audit (Feature, D-098): the machinery that makes the guiding outcome measurable and auditable — the placement record model (the 9th D-027 store: `PlacementRow` with the partial-unique one-active-placement-per-application guard + the append-only `PlacementEventRow` ledger, one global sha256 hash chain with a `UNIQUE(prev_hash)` fork backstop), the employer-side application state machine extending the v0.6 `submitted`-only status (the forward-only D-099 matrix, per-target employer routes under `/v1/employers/applications`, learner withdraw, the operator lane incl. revoke + the R-80 re-hire), placement reporting (distinct-learner counts cumulative toward ≥1,000, per employer, per start-date cohort month), and the audit-grade export (self-contained JSON + human summary generated from the same document, methodology stating its own three pilot gaps, re-export determinism). Mock-first doctrine holds: no third-party auditor integration (the export IS the deliverable), tests never call a cloud, zero new dependencies (D-112).
|
||||
**Tag line:** phase tags advance the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release), continuing from the v0.10 milestone release (v0.9.6)
|
||||
**Branch:** milestone/v0.11-placement-tracking (phase work on phase/NN-* branches; Phase 0 on phase/00-pre-execution)
|
||||
|
||||
**Phase list (requirement mapping):**
|
||||
|
||||
| Phase | Requirement | Focus |
|
||||
|-------|-------------|-------|
|
||||
| P1 | REQ-8-001 | Release-consistency rails: runner fleet on CT 113, push/tag workflows, pre-commit, hello-world verification |
|
||||
| P2 | REQ-8-002 | Docker image: multi-stage build, unprivileged runtime + honest sandbox degrade, registry push on every release |
|
||||
| P3 | REQ-8-003 | Debian-native: arm64 cross-build, .deb packages (x64+arm64), install.sh arch-resolve, release assets extended |
|
||||
| P4 | REQ-8-004 | CLI onboarding: doctor channel awareness, `nextcraft guide`, in-container refusals, per-channel E2E |
|
||||
| P5 | REQ-8-005 | Admin guides: the full deploy runbook (three channels × install/upgrade), README quickstart |
|
||||
| P6 | — | Final review + audit + milestone ship (v0.7.6 = milestone release) |
|
||||
| P1 | REQ-11-001 | Placement store + evidence ledger: placements/ module (PlacementRow + PlacementEventRow global hash chain, partial UNIQUE active guard), the D-099 matrix + event vocabulary in base.py, the ONE transactional transition_application (flush-then-hash-then-commit), main.py wiring — zero web changes |
|
||||
| P2 | REQ-11-002 | Application state machine: employer per-target routes (G-311 gate order: 401 → 403-claim → 403-cross-entity → 409), learner withdraw (D-043 stack), placements_admin operator lane (generic transition + revoke + the R-80 re-hire), G-319 pin split |
|
||||
| P3 | REQ-11-003 | Placement reporting: admin overview (distinct learners, by_employer, by_cohort_month), per-employer placement view, placement.ts wire types + engine-client placements section, /employer/applications page + overview placements section replacing the mock block |
|
||||
| P4 | REQ-11-004 | Audit export: the self-contained JSON + summary-from-same-JSON, methodology with the three pilot gaps, determinism rules, admin web placements block + export links, the full-chain E2E proof |
|
||||
| P5 | REQ-11-005 | Learner visibility: GET application-status read, job-detail status panel + placement confirmation, badge honesty |
|
||||
| P6 | — | Final review + audit + milestone ship (v0.10.6 = milestone release) |
|
||||
|
||||
**Wave order (binding): rails before artifacts before onboarding before guides.** P1 builds the rails everything else ships through — P2/P3 extend the release workflow P1 creates; P4 onboarding needs all artifact channels to exist; P5 guides document the final shape. Within P1, the runner fleet gates the workflow verification (workflows are inert config until a runner registers). P2 precedes P3 for founder priority (docker is the headline channel), not technical force — both consume P1's release workflow as their extension point (G-202).
|
||||
**Wave order (binding): ledger before machine before reporting before export before visibility.** P1 builds the store + chain every later phase composes (P2's transitions append to it in one transaction; P3's counts read the rows it writes; P4's export embeds the chain it maintains; P5's panel renders the events it records). P2 precedes P3 because reporting counts placements that only the hired transition creates — a report over an un-exercised machine proves nothing. P3 precedes P4 because the export's summary block is the D-107 computation (one truth source — the summary is generated from the same JSON) and the admin web block consumes both the overview and the export. P4 precedes P5 by requirements priority (REQ-11-004 critical ships before REQ-11-005 medium; no dependency runs the other way — the learner panel composes only P1/P2 machinery). Each phase is a vertical slice: store/schema → routes → web wiring → tests green in the same wave → ship.
|
||||
|
||||
**Research decisions R-1..R-32 (RESEARCH.md) and clarify decisions D-063..D-072 (PROJECT.md) are binding design contracts.** This plan operationalizes them; it does not re-litigate them.
|
||||
**Binding contracts:** clarify decisions D-099..D-112 (PROJECT.md) and research conclusions R-67..R-80 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates. Module layout is decided: `ai_service/placements/{__init__,base,store}.py` — NO service.py (D-112/R-74 — routes compose the store via `app.state` DI); routes: the applications section of api/employers.py, learner routes in api/marketplace.py, `api/placements_admin.py`; `tests/placements/`; `packages/types/placement.ts`.
|
||||
|
||||
**Environment facts (probe-verified):** Gitea 1.26.1 at git.coreci.dev; OCI registry `/v2/` live (401 unauth); Actions enabled, zero runs to date; CT 113 gitea-runner LXC (10.0.0.113, 4c/4GB/32GB, root SSH, founder-provided credentials — never committed); registration token founder-provided in chat, reset after fleet green; latest release v0.6.6 (id 909) carries nextcraft-linux-x64 + .sha256; runner image is `docker.io/gitea/runner:3` (act_runner renamed, R-1); jobs run as siblings via host docker.sock; v0.7 toolchain (eslint flat config, ruff + RUF100, mypy relaxed-first, pytest markers fast/real_server/userns with `ai:test:fast`, c8, turbo pipeline) is the CI surface.
|
||||
**Environment facts (probe-verified, R-67..R-80):** the hash covers the row's own autoincrement id → the append must be flush-then-hash-then-commit inside one Session, ALL appends funneling through the one transition_application (R-70; decide_claim is the all-or-nothing template employers/store.py:600-652; the ledger must have NO delete path — INTEGER PRIMARY KEY reuses max+1 if the top row is deleted; verification is O(n) sha256 over tiny rows); the matrix precedent is billing's `_VALID_TRANSITIONS` with empty-tuple terminals (billing/store.py:84-90; the 409-matrix test trio tests/billing/test_store.py:400-418; zero migration — existing rows are all `submitted`, marketplace/store.py:211); the shared-DB multi-engine topology is shipped (8 stores, identical pragmas; global SQLModel metadata means create_all on any engine creates every registered table, employers/store.py:400; cross-module import is family-sanctioned employers/base.py:39; NO cross-family foreign keys — G-302 ID references at read); export determinism needs pinned orderings + total generated_at exclusion + float absence (R-73; the list_postings ordering discipline marketplace/store.py:940-948); main.py wiring sites verified (lifespan getattr-guard 182-190/195-200, close() in the finally 335-336, routers beside the employers block 400-411, CORS already carries Authorization 357, G-11 admin boot warning 251-256, deps.py:98); gates verified (require_employer_session employers.py:173-191, _require_claimed :467-484, _own_posting_or_404 :487-503, per-target route idiom :635/:687, provenance `"account:{id}"` :792; learner stack marketplace.py:46-52 → :358 → :359 with window :66-76, `_APPLY_TIMES` :58, cap :63; operator lane employers_admin.py:47 + OPERATOR :54, guard marketplace_admin.py:62-85); web rails verified (engine-client sections — SessionRequiredError :126-131, ClaimRequiredError :136, parseError :146, placements section appends after employer billing :1114; pins.test.ts employer dual-schema block :267-374 + G-319 block :376+; the four surfaces — employer-overview-client.tsx :162/:190-230 real vs mock block :335-372 labelled :351, job-detail-client.tsx apply :95-115 + EngineError 409/404 :103-105 + confirmation :253, moderation page :3/:35 → employer-network-panel.tsx :56 + adminToken/locked-state :28-40); test templates verified (make_client state-injection tests/marketplace/conftest.py:80-120; conftest resets :123-131 + tests/employers/conftest.py:31-33; partial-index sqlite_master pin test_claims_store.py:75-88; gate pins test_postings_write.py:225-234/:315 + test_marketplace_api.py:188; counts-only pin test_postings_read.py:216); two WAL facts (insert_application :873-901 never touches status post-creation; the tamper test's second engine sees the mutation only after commit); three clocks, three serialization rules (start_date date-only ISO YYYY-MM-DD with cohort month `strftime("%Y-%m")`; created_at naive-stored re-labeled via `_as_utc` marketplace/store.py:263-267 — `isoformat()` emits +00:00 for aware, nothing for naive, so the chain pins ONE normalization; generated_at export-only; learner_id is an opaque G-5 handle the 422 scrubber's pii_fields main.py:439 never sees).
|
||||
|
||||
**Secrets policy (binding, v1.8 lesson):** tokens never in shell env, never committed, never echoed. Registration token = compose env at bring-up only (R-6). REGISTRY_TOKEN = Gitea Actions secret (self-serve `PUT /repos/coreci/nextcraft/actions/secrets/REGISTRY_TOKEN` attempt with the `.ciagent/.env.secrets` GITEA_TOKEN first; founder UI fallback — R-13). The automatic job `GITEA_TOKEN` cannot publish packages (R-13, upstream-confirmed). CT 113 root credentials: use once at P1 bring-up, install repo-owned access thereafter, founder rotates the password.
|
||||
**Verification commands (phase gates — the repo's own scripts):**
|
||||
|
||||
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to the four channels + guides.
|
||||
- `pnpm lint` (turbo lint — eslint across all TS workspaces) and `pnpm ai:lint` (ruff on ai-service)
|
||||
- `pnpm typecheck` (turbo typecheck — tsc workspaces) and `pnpm ai:typecheck` (mypy — zero new overrides; stdlib-only, D-112 keeps the D-086 regime intact)
|
||||
- `pnpm ai:test:fast` (FAST=1 — `-m "not real_server and not userns"` per D-063; tests/placements/ joins the fast lane)
|
||||
- `pnpm web:test` (node:test incl. pins.test.ts) + `pnpm cli:test` (unchanged surface, regression guard)
|
||||
- `pnpm --filter web build` (static export, D-039 unchanged) on web-touching phases (P3, P4, P5)
|
||||
|
||||
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py/main.py wiring, tests/placements/, packages/types/placement.ts, the employer/job-detail/admin web surfaces.
|
||||
|
||||
**Secrets policy (binding):** no new secrets this milestone — AI_ADMIN_TOKEN stays env-only (D-014); the placements admin lane inherits require_admin's fail-closed behavior + the G-11 boot warning free (R-74); the export carries no credentials and no identity PII (learner_id is an opaque allowlisted handle, R-79 — name/email/DOB are never stored, identity/base.py:25-33); tests use tmp_path DBs with no credential values; no secret appears in any committed file, log line, or release asset.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Release-Consistency Rails (REQ-8-001)
|
||||
## Phase 1: Placement Store + Evidence Ledger (REQ-11-001)
|
||||
|
||||
**Goal:** Three org-level runner containers green on CT 113; push-lane workflows (node/python lanes per D-063) and a tag-triggered release workflow live; pre-commit hooks enforce local hygiene; the fleet proven by hello-world dispatch across all three labels.
|
||||
**Goal:** The placements module exists and is proven: the 2-table 9th D-027 store (immutable-at-creation PlacementRow + the append-only global hash-chain PlacementEventRow), the D-099 matrix + event vocabulary in base.py, the ONE transactional transition_application with flush-then-hash-then-commit, main.py wiring — tamper-tested, fork-tested, zero web changes, zero new dependencies.
|
||||
|
||||
### Wave 1-1: Runner fleet bring-up on CT 113 (devops-engineer)
|
||||
### Wave 1-1: Module core — contracts + matrix + schema (backend-engineer + data-engineer, sequential)
|
||||
|
||||
- **Task 1-1-01** (devops-engineer): CT 113 bring-up probes — SSH to root@10.0.0.113 (founder creds, use once), verify docker daemon (`docker info`), LXC nesting health (cgroup/ns errors), egress (`curl -fsSL https://git.coreci.dev/api/v1/version` → 200; docker.io reachability for `gitea/runner:3` + job images; github.com reachability for actions/checkout per R-8 — record verdict, it selects the checkout strategy). Install an ed25519 deploy key for subsequent phases; founder rotates root password after this task — **rotation is a Wave 1-1 completion condition (G-206): if unconfirmed by Wave 1-2, commit `escalation: ct_root_password_still_live` and carry it in the ship notes — never silently.**
|
||||
- **Task 1-1-01b** (devops-engineer): **Fleet-as-code (G-207)** — the compose file, per-runner config.yaml snippets, and the prune cron land in-repo under `deploy/ci/` (compose with `${REGISTRATION_TOKEN}` env interpolation, configs with `${CT_IP}` documented as bring-up variables); CT 113 deployment = copy + `docker compose up -d`. The runbook's fleet recipe references these committed files, never box memory.
|
||||
- **Task 1-1-02** (devops-engineer): Fleet deployment per R-1..R-5 — `/srv/nc-ci/docker-compose.yaml` with three services (nc-runner-node / nc-runner-python / nc-runner-release), image `docker.io/gitea/runner:3`, per-runner `./data-runner-*` volumes (never shared — lock refusal), host `/var/run/docker.sock` mount, `GITEA_INSTANCE_URL=https://git.coreci.dev`, `GITEA_RUNNER_NAME`, `GITEA_RUNNER_LABELS` per the R-3 hybrid map (node → node:24-bookworm; python → python:3.11-bookworm; release → node:24-bookworm; ubuntu-latest → docker.gitea.com/runner-images:ubuntu-latest, carried by runner-node), registration token via compose env (from founder chat, never a file). Per-runner config.yaml with `capacity: 1`, `cache.enabled/host: 10.0.0.113/port` published per runner (R-5 — else actions/cache ETIMEDOUTs), `health_check.min_free_disk_space_mb: 4096`. systemd unit for the compose stack (`restart: always`, reboot-persistent — house pattern).
|
||||
- **Task 1-1-03** (devops-engineer): Fleet verification — all three runners visible + idle at org level (`/org/coreci/settings/actions/runners`); registration token reset by founder after this task (ask; do not hold the pipeline — the token's job is done once three `.runner` files exist); host-side cache-hygiene cron (daily `docker system prune -af --filter until=168h` + `docker volume prune -f`).
|
||||
- **Task 1-1-01** (backend-engineer): `ai_service/placements/__init__.py` + `base.py` — pydantic contracts + constants: `TRANSITION_MATRIX` per D-099 (snake_case `submitted → in_review → interview → offer → hired|rejected|withdrawn`, forward-only with skip-forward — any non-terminal may move to any LATER stage or any terminal, terminals have empty-tuple outgoing edges, the billing/store.py:84-90 shape); `APPLICATION_STATUSES`; actor vocabulary `employer|learner|operator|system` (the `_BILLING_EVENT_KINDS` shape billing/store.py:92, D-101); event-name vocabulary per D-101 + **R-80's placement lane**: application-stage events (NO backfill — `application.submitted` is never fabricated for v0.6 rows, D-101/A-304; one event per transition: in_review/interview/offer/rejected/withdrawn/hired) + placement-level events (`placement.created` on hired, `placement.revoked` on the D-102 correction, `placement.rehired` on the R-80 operator re-hire); `PlacementRead`/`PlacementEventRead`/`EmployerApplicationSummary` response contracts (dual-schema-pinned P3 per D-110); `HireTransitionRequest {role_title, start_date}` (D-103: start_date is a pydantic `date` — never a datetime, future + past allowed, timezone-free by construction; **G-324: role_title = Field(min_length=1, max_length=200) — empty-string → 422, the house Field shape marketplace/base.py:68-71**); `GENESIS_HASH = "0" * 64` module constant (D-101, test-pinned). No service.py — routes compose the store via app.state DI (D-112).
|
||||
- **Task 1-1-02** (data-engineer): `placements/store.py` — the 9th D-027 store (D-112/R-72): tables `placement` (id PK, learner_id, employer_id, posting_id, application_id indexed, role_title, start_date stored ISO `YYYY-MM-DD` — a calendar date, no datetime coercion in its path, R-79; status `active|revoked`, created_at, decided_at) + `placement_event` (auto-increment id PK, application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload JSON, prev_hash **UNIQUE**, hash, created_at). Bare `placement` is FREE (D-095's prefix rule was collision-driven — marketplace owns posting/employer/application, no placement table exists). D-102 semantics: role_title/start_date/decided_at/learner_id/employer_id/posting_id/application_id are set once at the hired transition and NEVER rewritten (a silent rewrite is exactly the tamper the chain detects); `status` is the ONLY mutable column with exactly one transition `active → revoked`; decided_at = the hired transition's store-stamped created_at (`datetime.now(UTC)`); the one-active-per-application guard is a **partial UNIQUE index `uq_active_placement_per_application` ON application_id WHERE status='active'** (the G-316 `uq_pending_claim_per_entity` shape, employers/store.py:161-167 — revoked rows stay in history; a re-hire after a mistaken revoke creates a fresh row). Mechanics mirror employers/store.py exactly (D-112): `_sqlite_connect` pragmas WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON, `sa_type` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` re-labeling, Protocol `PlacementStore` + `SQLitePlacementStore` pair, surfaced IntegrityError, `_constraint_error` helper; global-metadata `create_all` idempotent (checkfirst adds both tables to every existing DB at next boot — the shipped D-095 behavior, employers/store.py:398-400); NO cross-family foreign keys (employer/learner/posting/application are ID references resolved at read/export, G-302); **NO delete method and no DELETE SQL anywhere in the module** (R-70: deleting the top row makes INTEGER PRIMARY KEY reuse max+1 — chain-integrity hole; source-pinned). Canonical serialization per D-101/R-69/R-79: `_canonical_event_json` = `json.dumps({...}, sort_keys=True, separators=(",", ":"))` with created_at normalized via **always `_as_utc(dt).isoformat()` — ONE pinned normalization** (the R-79 pitfall: isoformat emits `+00:00` for aware, nothing for naive — drift between the hashing and reading paths breaks the chain on first re-read) and **no floats in payload ever** (str/int/bool/None only, R-69); `hash = hashlib.sha256` hexdigest over the canonical JSON + prev_hash. Tests `tests/placements/test_store.py`: GENESIS_HASH pinned; table existence via sqlite_master (2 tables) + the partial-index SQL pinned (the test_claims_store.py:75-88 template); pragma pins; duplicate prev_hash → IntegrityError surfaced; no-delete-path source pin; tz round-trip (naive stored, aware read, canonical form stable).
|
||||
- **Task 1-1-03** (data-engineer): the chain append + `transition_application` — D-100/R-70: `transition_application(application_id, to_status, actor_type, actor_ref, role_title=None, start_date=None)` — ONE Session on the placements engine covering both tables (R-72: the marketplace `application` row is written through it — the sanctioned D-100 deliberate exception; both tables + the status write land in ONE commit, the decide_claim all-or-nothing precedent employers/store.py:634-652 — the two-commit alternative's crash window voids the tamper-evidence the milestone sells): get_application (marketplace/store.py gains ONLY this point read, the get_posting shape :578-584 — insert_application and every other marketplace seam unchanged) → matrix validation (module InvalidTransition → 409 at routes) → update `application.status` → read chain head (`ORDER BY id DESC LIMIT 1`) → construct the event row with prev_hash=head → **`session.flush()` to obtain the autoincrement id → compute hash → set the column → on hired: create the PlacementRow (decided_at = this transition's created_at; role_title/start_date REQUIRED, D-103) + append `placement.created` with placement_id set → `session.commit()`** — flush-then-hash-then-commit, and ALL ledger appends funnel through this one method (a second append path races prev_hash; the UNIQUE backstop catches it as a lost event). Column ownership stated in the module docstring (R-78): marketplace insert_application (:873-901) writes status once at creation; the placements store is the ONLY post-creation writer of application.status — no route or future store adds a second writer outside the one-transaction discipline. Store methods: `verify_chain` (recompute the walk from GENESIS_HASH → head; O(n) sha256 over tiny rows — milliseconds at pilot scale, R-70), `get_placement`, `get_application_events` (id-ordered), `list_active_placements`, `revoke_placement` (the one status-only mutation + `placement.revoked` event, one transaction, operator-only by route), `rehire_placement` (R-80: fresh PlacementRow + `placement.rehired` event WITHOUT touching the application's terminal status — revoked rows don't match the partial WHERE, the exact G-316 property employers/store.py:159-167). Tests: chain-walk verification green after N appends; hired → PlacementRow + both events chained (application_id event + placement_id event); **tamper test** — direct row mutation via a second engine on the same file (mutation commits before the verification walk reads, R-78 WAL snapshot isolation) → verify_chain fails; **fork test** — a second row claiming the same prev_hash → IntegrityError surfaced; **concurrent-transition arm** (the test_concurrent_consumes_one_post_left shape, tests/billing/test_store.py:335): two sessions transition the same application → one winner commits, the loser surfaces IntegrityError/InvalidTransition — never two events on one prev_hash; revoke → status revoked + event; rehire after revoke → fresh active row + three-event ledger sequence (hired, placement.revoked, placement.rehired).
|
||||
|
||||
### Wave 1-2: Workflows + secrets + hello-world (devops-engineer)
|
||||
### Wave 1-2: main.py wiring + deps + conftest (backend-engineer)
|
||||
|
||||
- **Task 1-2-01** (devops-engineer): `.gitea/workflows/ci.yaml` — `on: push` (all branches), two jobs: node lane (`runs-on: node`: corepack pnpm 12.3.4 — probe corepack-on-node24, `npm i -g pnpm@12.3.4` fallback per R-11 — `pnpm install --frozen-lockfile`, lint + typecheck via `pnpm --filter` over the 5 TS workspaces (never wholesale `turbo lint` per R-8), `pnpm web:test`, `pnpm cli:test` with E2E env-gated out per D-063) and python lane (`runs-on: python`: bootstrap.sh → lint.sh → typecheck.sh → `FAST=1 test.sh`). checkout via `actions/checkout@v4` if Task 1-1-01 found github.com egress; else raw `git fetch` steps with the job token (R-8). pnpm store + pip cache via `actions/cache@v4` (R-12; volume-mount fallback if cache server unreachable).
|
||||
- **Task 1-2-02** (devops-engineer): `.gitea/workflows/release.yaml` — `on: push: tags: ['v*']`, `runs-on: release`: build the x64 SEA binary stamped `${{ gitea.ref_name }}` (`pnpm --filter @nextcraft/cli build:binary $TAG`), sha256 sidecar, attach both to the tag's release via the REST API (`scripts/release-assets.sh` mechanics per R-14 — call the API directly in the workflow step; the script itself stays untouched as the manual fallback with its .env-only resolution, per D-064/R-14). **Within-phase proof (G-203):** at verify time push a temporary `v0.7.1-rc1`-style tag to the phase-branch head, observe the workflow build + attach green (draft/pre-release or immediately-deleted release), then delete the tag — the phase ship tag remains the real artifact.
|
||||
- **Task 1-2-03** (devops-engineer): `.gitea/workflows/hello.yaml` — `workflow_dispatch` one-step echo job × 3 (`runs-on: node|python|ubuntu-latest`) — the REQ-8-001 fleet proof. Dispatch all three, observe green in the Actions tab.
|
||||
- **Task 1-2-04** (devops-engineer): REGISTRY_TOKEN setup per G-205 — the secret is a **founder-minted PAT with package-write scope** (write:package + write:repository); first attempt the self-serve PUT (`PUT /repos/coreci/nextcraft/actions/secrets/REGISTRY_TOKEN` with the `.ciagent/.env.secrets` token, R-13); if the token's scope cannot mint it, escalate to the founder with the exact scope needed — a founder question, never a silent wait. P2's registry push blocks on this secret existing; P1 does not.
|
||||
- **Task 1-2-05** (devops-engineer): `.pre-commit-config.yaml` at repo root (D-070): directory-scoped hooks — ruff (ai-service globs), eslint flat-config run (web/cli/packages globs), tsc `--noEmit` per TS workspace, basic hygiene (trailing whitespace, merge conflicts, large files). Local-only, no network, fail-loud on missing toolchain (per D-070 — CI backstops). Document the opt-in install AND the no-toolbox skip path in the README dev section (G-209 — a contributor without ruff gets a loud pointer, not a broken commit flow).
|
||||
- **Task 1-2-01** (backend-engineer): wiring per D-112/R-74: `deps.py` `get_placement_store` beside get_employer_store (deps.py:98); main.py lifespan getattr-guard `placement_store` directly after the employers block (main.py:195-200 template) + `close()` in the finally block (main.py:336); CORS already carries Authorization (main.py:357 — no change); the G-11 admin boot warning (main.py:251-256) covers the placements admin free. Zero new config knobs, zero .env.example changes (no new throttle — D-104 rides the existing window; the router file + mounts land with its first routes in P2, the main.py:400-411 pattern). `tests/placements/conftest.py` NOW (D-111/G-313/open-risk-4): the make_client state-injection fixture (the tests/marketplace/conftest.py:80-120 shape — tmp_path DB, create_app, `app.state.placement_store` injected, hermetic zero-network by construction — no MockTransport needed, no external calls exist) + the **autouse `_APPLY_TIMES` reset (tests/marketplace/conftest.py:123-131 pattern) before/after EVERY placements test — the withdraw route rides that limiter in P2; without the reset the suite self-429s (the G-313 lesson applied before it ships)**.
|
||||
|
||||
### Wave 1-3: P1 verification (lead-developer + security-auditor)
|
||||
### Wave 1-3: P1 security review + phase gate (security-auditor + lead-developer)
|
||||
|
||||
- **Task 1-3-01** (security-auditor): Secrets audit of P1 — no token/credential in any committed file (compose, workflows, pre-commit, docs); registration token lifecycle honored (brought up via env, reset requested); CT root password absent from repo + rotated by founder; workflow env scrubbed (no `GITEA_TOKEN` echo steps).
|
||||
- **Task 1-2-05 → verification hook** (lead-developer): push-lane proof — a deliberate commit touching both lanes lands green runs in the Actions tab; hello-world dispatch green × 3 labels; pre-commit blocks a deliberately broken lint file and passes a clean tree (both directions proven locally).
|
||||
- **Task 1-3-01** (security-auditor): ledger review (P1 scope, D-111's "an audit ledger nobody has tried to break is theater"): canonical serialization pinned to ONE normalization (the hashing/reading drift pitfall caught by tamper tests), GENESIS pinned, UNIQUE(prev_hash) fork backstop real (IntegrityError surfaced never swallowed), flush-then-hash-then-commit with a single append funnel (no second writer), no-delete-path pinned, partial-unique active-placement guard emitted by create_all (G-316 shape), actor vocabulary closed, no floats in payload, no PII in payload fields. Findings recorded; P0s fixed in-phase.
|
||||
- **Task 1-3-02** (lead-developer): P1 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` (zero new mypy overrides — stdlib only) + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (additive module — marketplace/billing/employers suites untouched); the D-100 discipline check (one transition_application, no second status writer, no second append path); test counts recorded.
|
||||
|
||||
**Verification hooks P1:** ci.yaml green on a fresh push (both lanes); hello.yaml green × 3 labels; release.yaml exercised at P1 ship (the v0.7.1 tag's release carries binary + checksum, built by the workflow — not by the local ship path); pre-commit both-direction proof; secrets audit clean; runners idle-registered at org level.
|
||||
## Phase 2: Application State Machine (REQ-11-002)
|
||||
|
||||
**Goal:** The hiring pipeline is live: employers drive per-target transitions on their own claimed-entity applications (G-311 gate order, audit-logged, ledger-chained in the same transaction), learners withdraw their own, the operator lane transitions/revoke/re-hire with provenance — every matrix arm tested, the G-319 pin split enforced.
|
||||
|
||||
### Wave 2-1: Employer application routes + G-319 pin split (backend-engineer)
|
||||
|
||||
- **Task 2-1-01** (backend-engineer): api/employers.py gains `GET /v1/employers/applications?status=` (own claimed-entity pipeline — session → claimed → applications resolved through own postings via a new in-pattern marketplace grouped read, the count_applications_by_posting discipline store.py:915-932, never per-row N+1; the `EmployerApplicationSummary` contract with **learner_id visible per the G-319 supersession, D-105: application-scoped reads expose learner_id ONLY for applications on the session's own claimed-entity postings**) + the per-target action routes `POST /v1/employers/applications/{application_id}/in-review | /interview | /offer | /reject | /hire` (the per-target idiom employers.py:635/:687 — the matrix stays OUT of request bodies; hire body `{role_title, start_date}` per D-103, both REQUIRED; **G-324: role_title min_length=1/max_length=200 — the empty-string 422 arm pinned beside the missing-fields arm**). Gate order G-311 (D-105/R-75): **401** (require_employer_session employers.py:173-191) → **403** claim (_require_claimed :467-484) → **403** cross-entity (fetch application → posting → `posting.employer_id != claimed` — the _own_posting_or_404 shape :487-503 applied through the application's posting) → **409** transition (never a 409 before a 403). Every route calls the ONE transition_application with `actor_type="employer"`, `actor_ref=f"account:{account.id}"` (the :792 provenance shape) + appends an `employer_audit_events` row (the D-089 one-queryable lane). Tests `tests/placements/test_employer_transitions.py`: gate-order pins (no token → 401, unclaimed → 403, foreign application → 403 — never a 409 first; the test_postings_write.py:225-234/315 pin shape); matrix 409 arms (every illegal move: backward `offer → in_review`, terminal-source `hired → anything`/`rejected → anything`/`withdrawn → anything`); skip-forward arms legal (`submitted → hired` fast-track, `submitted → interview`, `in_review → reject` — R-71); hire → 201 + PlacementRow + chained events + audit row; hire without role_title/start_date → honest 422 with the D-103 required-fields detail; **G-324 arm: role_title="" → 422**.
|
||||
- **Task 2-1-02** (backend-engineer): the **G-319 pin split** (R-77/D-105, binding): the own-postings LIST stays counts-only — tests/employers/test_postings_read.py:216 green UNCHANGED (the v0.10 boundary holds there); learner_id appears ONLY on application-scoped responses for own-posting applications; cross-entity → 403 with NO application data (anti-enumeration preserved). Pins in test_employer_transitions.py: the list response carries no learner identifiers; the application-scoped GET carries learner_id on own postings; a foreign application's transition/list never returns any application payload.
|
||||
|
||||
### Wave 2-2: Learner withdraw + operator lane incl. the R-80 re-hire (backend-engineer)
|
||||
|
||||
- **Task 2-2-01** (backend-engineer): api/marketplace.py gains `POST /v1/marketplace/applications/{application_id}/withdraw` body `{learner_id}` (D-104): the **D-043 composition unchanged** — `_enforce_allowlist` (marketplace.py:46-52) → `require_verified_age(18)` (:358) → `_check_apply_rate` (:359; window :66-76, `_APPLY_TIMES` :58, cap 5/rolling-60s :63 — the EXISTING window, no new throttle knob); 403 when the application is not the caller's, 409 when terminal (D-099: `withdrawn` is learner- or operator-triggerable, NEVER an employer session — employers reject instead); calls the same transition_application with `actor_type="learner"`; composes the placements store via app.state DI (deps.py). **G-323 (binding): the apply route's idempotent arm gains a terminal-state guard** — a returned application in `withdrawn`/`rejected`/`hired` → 409 `{"reason": "application_terminal", "status": …}` (the posting_not_published 409 shape, marketplace.py:366-373), never a 200 "applied" confirmation; no new application row (the unique key stands); recovery is visibility (G-322's mine-read + status panel), not resurrection. Tests `tests/placements/test_learner_withdraw.py`: unallowlisted → 403, unverified → 403, foreign application → 403, own+submitted → 200 `withdrawn` + ledger event actor learner, terminal → 409; **apply → withdraw → re-apply → 409 application_terminal (never a 200)**; the conftest reset proves no self-429 (5 withdraws inside the window → the 6th 429s honestly, reset between tests).
|
||||
- **Task 2-2-02** (backend-engineer): `api/placements_admin.py` (D-106): prefix `/v1/placements/admin`, `require_admin` IMPORTED from marketplace_admin (the billing_admin.py:27 import precedent, employers_admin.py:47 — token namespaces cannot collide, D-090); router mounts in main.py beside the employers block (main.py:400-411). Routes: `POST /v1/placements/admin/applications/{application_id}/transition` `{to, role_title?, start_date?}` — the generic operator remediation route validated against the SAME D-099 matrix (role_title/start_date required iff `to=hired`); **G-327 (binding): `to` is a pydantic Literal of the seven D-099 statuses — unknown value → 422 (body validation), matrix-illegal known value → 409 InvalidTransition; the route never sees a status outside the vocabulary (no 500s, no silent acceptance; test arm: garbage `to` → 422)**; `actor_type="operator"`, `OPERATOR = "operator"` route-level provenance (employers_admin.py:54 — no per-operator accounts, D-094); the operator can drive ANY matrix-legal transition including `withdrawn` on a learner's behalf (remediation with provenance, the D-083 lane). `POST /v1/placements/admin/placements/{placement_id}/revoke` (D-102: the one `active → revoked` correction). **THE R-80 RE-HIRE ROUTE (pinned explicitly): `POST /v1/placements/admin/placements/by-application/{application_id}/re-hire` `{role_title, start_date}`** — after the first hire the application is terminal (`hired → hired` is a 409 on every matrix-validated surface — the R-80 coherence gap); the re-hire creates the FRESH PlacementRow + appends `placement.rehired` (actor operator) WITHOUT touching the application's terminal status — the ledger reads hired → revoke → rehire as three events, never fabricating a second application transition (the rejected matrix-amendment and revoke+reapplication shapes stay rejected per R-80); structurally safe because revoked rows don't match the partial WHERE status='active' (the exact G-316 property). **G-326 (binding): the re-hired PlacementRow's `decided_at` = the re-hire event's store-stamped created_at (D-102's `datetime.now(UTC)` stamp rule extended) — stated in the module docstring + asserted in the revoke→re-hire test arm.** Reporting/export reads land P3/P4. Tests `tests/placements/test_admin_lane.py`: fail-closed 403 on every route (no/missing/invalid token — the R-56 matrix); generic transition every legal arm + the 409 arms + **garbage `to` → 422 (G-327)**; revoke → placement revoked (→ excluded from counts, asserted P3); **revoke → re-hire → fresh active placement + the three-event ledger sequence pinned + decided_at = the re-hire event's stamp (G-326) + second active placement on the same application without revoke → the IntegrityError surfaced as an honest 409**.
|
||||
|
||||
### Wave 2-3: P2 security review + phase gate (security-auditor + lead-developer)
|
||||
|
||||
- **Task 2-3-01** (security-auditor): gate + PII review (P2 scope): G-311 gate order on every transition route (401 → 403-claim → 403-cross-entity → 409, pinned); the G-319 supersession (R-79): learner_id is an opaque G-5 handle, application-scoped only, anti-enumeration preserved (cross-entity 403 carries no data); employer sessions can never withdraw (D-099); operator provenance on every admin action incl. re-hire; the audit trail answers every decision (employer_audit_events + ledger actor refs). Findings recorded; P0s fixed in-phase.
|
||||
- **Task 2-3-02** (lead-developer): P2 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (existing marketplace/employers suites green unmodified); the R-80 coherence review recorded (placements, not applications, are the correction lane — the route shape vs D-099/D-102); test counts recorded.
|
||||
|
||||
## Phase 3: Placement Reporting (REQ-11-003)
|
||||
|
||||
**Goal:** The guiding outcome is measurable: distinct-learner counts computed from rows at query time (per employer, per start-date cohort month, cumulative toward 1,000), the per-employer placement view live, wire types dual-schema-pinned, the employer surfaces converted (the applications page + the overview placements section replacing the mock block).
|
||||
|
||||
### Wave 3-1: Reporting reads (backend-engineer)
|
||||
|
||||
- **Task 3-1-01** (backend-engineer): reporting per D-107 — counts computed from rows at query time, NEVER denormalized counters (G-302/G-306): `GET /v1/placements/admin/overview` → `{total_active_placements, distinct_learners_placed, guiding_goal: 1000, by_employer: [{employer_id, count}], by_cohort_month: [{month, count}]}` — **the guiding-outcome metric is DISTINCT LEARNERS with ≥1 active placement, all-time cumulative toward 1,000 (a learner placed twice counts ONCE — the outcome's noun is learners, PROJECT.md line 7); revoked excluded from every count (D-102); cohort month = `strftime("%Y-%m")` over start_date (D-103/R-79 — employment-start month, not hired month, not application month)**; by_employer ordered count desc then employer_id, by_cohort_month month ascending (R-73); joins through existing stores (employer identity via employer_accounts/EmployerRow, posting via postings, learner via application rows); batched grouped reads (the count_applications_by_posting discipline store.py:915-932 — never per-row N+1). Plus `GET /v1/employers/placements` — the per-employer placement view (session-gated; own claimed-entity hired applications with role_title/start_date, honest empty state; 403 + claim-CTA unclaimed; 401 no token) — D-109 surface 2's data source. Tests `tests/placements/test_reporting.py`: distinct-learner rule (one learner, two employers, two placements → distinct_learners_placed=1, total_active_placements=2); cohort bucketing (two starts in one month → one bucket; a future-dated start lands in its own month); revoke exclusion (revoked → gone from every count; a learner whose only placement was revoked → back to 0); per-employer view own-rows-only + cross-entity 403 + unclaimed 403.
|
||||
|
||||
### Wave 3-2: Wire types + engine-client placements section (frontend-engineer + data-engineer)
|
||||
|
||||
- **Task 3-2-01** (frontend-engineer, data-engineer dual-schema-sync): `packages/types/placement.ts` (D-110 — the fourth types module, the employer.ts precedent): ApplicationStatus union (regex-pinned the D-093 status-union way), Placement, PlacementEvent, EmployerApplicationSummary, transition request/response shapes, HireTransitionRequest, PlacementOverview (+ PlacementExport added with the export shapes in P4) + `export * from './placement'` in index.ts. Dual-schema pins in `apps/web/tests/pins.test.ts` (the employer.ts block :267-374 template): **every field asserted both sides against `ai_service/placements/base.py`** — lead-developer reviews the pins (the G-108 discipline from day one).
|
||||
- **Task 3-2-02** (frontend-engineer): engine-client.ts **placements section** (D-110 — appended after employer billing :1114; ONE client module, no fork): listEmployerApplications, the employer transition functions (inReview/interview/offer/reject/hire with `{role_title, start_date}`), listEmployerPlacements, getPlacementOverview (learner status/withdraw + export functions land P5/P4 with their routes) — every fetch AbortController-equipped (A-406), 401 → SessionRequiredError REUSE (engine-client.ts:126-131), 403 claim → ClaimRequiredError reuse (:136), **NO new error classes** (403 cross-entity renders from EngineError.status). Tests: web fetch-stub tests (the billing-entitlement.test.ts template) — 401 → SessionRequiredError, token attached on every fetch, transition dispatch with the exact body.
|
||||
|
||||
### Wave 3-3: Employer web surfaces (frontend-engineer)
|
||||
|
||||
- **Task 3-3-01** (frontend-engineer): D-109 surface 1 — the NEW `/employer/applications` page (beside postings/login/register/billing/talent): the hiring pipeline — own applications with status badges (the seven D-099 states), per-target transition CTAs, the hire form `{role_title, start_date}` (a date input, future dates allowed — D-103), learner_id shown (own applicants, G-319 supersession); honest pending/empty/403-claim states (the A-406 board-client discipline); session-gated shell (SessionRequiredError → honest logged-out state, never a retry loop). Tests: web fetch-stub + source pins (the G-312 discipline — no render harness): loading/error/empty/claim states present in source, status badge per status, hire form dispatches the transition, withdraw NEVER offered to employers (D-099).
|
||||
- **Task 3-3-02** (frontend-engineer): D-109 surface 2 — the employer overview page's real placements section **REPLACES the mock-labeled placement metrics block** (employer-overview-client.tsx :335-372, the "(mock — no backend computes this)" label :351 — D-092: never leave a convertible surface on mock): hired applications with role_title/start-date via listEmployerPlacements, honest empty state; the talent-match/applicant-pipeline mock metrics STAY mock-labeled (no backend computes them). Tests: pins — the overview grep-clean of the placement mock block + the retained mock labels intact; fetch-stub placements render + empty state; `pnpm --filter web build` (static export) green.
|
||||
|
||||
### Wave 3-4: P3 phase gate (lead-developer)
|
||||
|
||||
- **Task 3-4-01** (lead-developer): P3 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` (web-touching phase) green; the D-110 pin review verdict recorded (placement.ts ↔ placements/base.py field-for-field); counts-discipline check (no denormalized counters anywhere); test counts recorded.
|
||||
|
||||
## Phase 4: Audit Export (REQ-11-004)
|
||||
|
||||
**Goal:** The third-party-auditor deliverable exists: the self-contained JSON export + the human summary generated from the same document, methodology stating its own three pilot gaps, byte-deterministic re-exports, the admin web placements block + export links, the full-chain E2E proof.
|
||||
|
||||
### Wave 4-1: Export + summary routes + methodology + E2E (backend-engineer)
|
||||
|
||||
- **Task 4-1-01** (backend-engineer): `GET /v1/placements/admin/export` (D-108) — one self-contained JSON document via a single store-level builder function: `{schema_version, generated_at, summary: {distinct_learners_placed, total_active_placements, revoked_count, by_cohort_month}, cohort: [placement records with employer/posting/application refs + role_title + start_date], evidence_bundles: [per-placement {posting_record, application_record, transition_history[], decision_provenance} — **G-325 (binding): bundles cover EVERY placement (active AND revoked), each with the full event history incl. placement.revoked/placement.rehired — the correction history is precisely what an auditor most wants to inspect; `cohort` stays active-only; `revoked_count` equals the placement.revoked events (cross-checkable, rides G-321(c))** — each resolved from SOURCE ROWS at export time (G-302: the export EMBEDS resolved source content — snapshot-at-export, which is what self-contained means — while live rows stay the only write path; **NO backfill**: pre-v0.11 `submitted` applications carry only their `created_at` in the bundle — honest absence, never fabricated history, D-101/A-304)], methodology: {placement_definition, counting_rules, verification, scope}, ledger: {events: [{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at, prev_hash, hash}], head_hash}}`. Methodology per R-68 (written from in-tree facts) with **the three R-67 pilot gaps stated explicitly — (a) no minimum-employed-duration check (a placement = a recorded hire, not a 90-day sustained role), (b) no salary (D-098), (c) verification is employer-principal attestation + operator oversight, not third-party employment verification — an audit methodology that omits its own verification limits is A-304-class dishonesty**: `placement_definition` = a PlacementRow created by the hired transition, employer-attested role_title + start_date, active at export time, one active placement per application (the G-316 partial unique); `counting_rules` = distinct learners with ≥1 active placement, all-time cumulative toward 1,000, revoked excluded, cohort month = start_date calendar month (D-107); **`verification` per G-321 (binding): states (a) what the chain proves (in-place mutation/fork detection of an intact sequence), (b) what it does NOT (wholesale DB replacement undetectable by the walk alone — the auditor's defense is export-to-export continuity: a prior export's ledger.events must appear as an exact prefix of a later export's, with the prior head_hash equal to the hash at that prefix position), and (c) the event↔row cross-consistency check (every active placement has placement.created; every revoked has placement.revoked; revoked_count matches the placement.revoked events)**; `scope` = pilot-bounded, every placement in the DB unbounded by date, evidence class = employer-principal attestation; the cohort divergence documented deliberately (employment-start month, not graduation cohort — no graduation events exist in a marketplace, R-68); **CIRR kept as naming, not citation — the text stands on its own definitions (R-67)**. Determinism rules (R-73, binding): events ordered by id (chain order); `by_cohort_month` month ascending; cohort/evidence lists ordered placement `created_at` then id (the list_postings discipline, marketplace/store.py:940-948) — pydantic declaration-order serialization is deterministic but set-derived lists would not be, so every list is built with an explicit sort; **`generated_at` is export-only metadata (a function of the request, not the rows — R-79) and is excluded from determinism comparison**; **float absence — every export value is str/int/bool, no ratios or percentages (counts are integers by construction, D-107)**; `head_hash` = the last event's hash = `verify_chain()`'s computed head. pydantic + `JSONResponse` only — zero new deps (D-112). Tests land with Task 4-1-03.
|
||||
- **Task 4-1-02** (backend-engineer): `GET /v1/placements/admin/export/summary` (D-108) — `text/markdown` rendered **FROM the same JSON document (one builder, two renderings — the summary can never drift from the export, D-108's anti-drift rule)**: totals table (distinct learners placed, total active placements, revoked count, the guiding-goal progress line toward 1,000), per-cohort month table, the methodology prose (rendered from the export's methodology block — **G-321: incl. the chain's proof scope, its wholesale-rewrite limit, and the export-to-export prefix-continuity anchor**), **the head hash + chain-walk verification instructions** (recompute `hashlib.sha256` over the canonical JSON + prev_hash from GENESIS_HASH event-by-event — the walk is offline: the export embeds every event, so the auditor needs nothing else, R-70); **G-328 (binding): the summary's title carries the attestation language ("employer-attested, operator-overseen") — never a "CIRR-verified" claim**. Tests: summary totals equal the export's summary block on the same DB state (the same-builder pin); content-type pinned; 403 fail-closed without the admin token.
|
||||
- **Task 4-1-03** (backend-engineer): `tests/placements/test_export.py` — **re-export determinism: two exports on an unchanged DB → identical `ledger.events` chains + identical `head_hash`, with generated_at excluded (parsed field-by-field comparison or key-strip — R-73's TOTAL-exclusion rule)**; **chain-head consistency: `head_hash` = the last event's `hash` = `verify_chain()`'s output** (the walk re-computes every hash from GENESIS_HASH); **methodology completeness: the three R-67 gaps + all four methodology keys asserted present + the G-321 verification-scope statements (proof scope, wholesale-rewrite limit, export-to-export continuity, event↔row cross-check) asserted present**; **evidence-bundle completeness per G-325: every placement (active OR revoked) has a bundle with the resolved posting_record + application_record + transition_history (revoked bundles carry placement.revoked; re-hired carry placement.rehired)**; revoked placements absent from cohort but present in revoked_count (D-108); 403 fail-closed on both routes. Plus **the full-chain E2E proof `tests/placements/test_e2e_audit.py`** (the milestone's machine-checked reason-to-exist, D-111): employer register → claim → admin approve → create draft (direct package grant, the grant_posts precedent test_entitlement_enforcement.py:70-85) → publish → verified learner applies (seed_verified_identity, SUITE_LEARNERS) → employer drives in_review → interview → offer → hire `{role_title, start_date}` → PlacementRow created → ledger events chained (`verify_chain` green) → export → identical chain + head on re-export → overview counts the learner ONCE toward 1,000.
|
||||
|
||||
### Wave 4-2: Admin placements block + export links + security review + phase gate (frontend-engineer + security-auditor + lead-developer)
|
||||
|
||||
- **Task 4-2-01** (frontend-engineer): D-109 surface 4 — the moderation page (app/(admin)/admin/moderation/page.tsx:3/:35, the EmployerNetworkPanel area employer-network-panel.tsx:56) gains a placements block: aggregate counts (distinct learners placed, total active placements, guiding-goal progress) + per-employer placement lists via `GET /v1/placements/admin/overview`, transported over the existing `adminToken()` G-308 sessionStorage wrapper (engine-client.ts:815; the locked-state :28-40 pattern — **403 renders the honest locked state with the token prompt, never a retry loop**); export links — both fetched via the admin transport (token header attached, AbortController-equipped): **the summary opens in-tab** (the markdown rendered in the panel) and **the JSON export downloads** (blob download from the fetched document); honest empty state at zero placements. **G-328 (binding): counts are labeled as recorded placements ("distinct learners placed", "placements recorded") — "CIRR" NEVER appears as a verification claim on any live surface; "CIRR-style" describes the export format in release notes/methodology only (source pin: no "verified" placement wording on any new surface).** Tests: web fetch-stub — locked state on 403, counts + per-employer lists render, export links dispatch with the admin header, empty state; source pins for the locked-state branches + the G-328 wording pin.
|
||||
- **Task 4-2-02** (security-auditor): export-integrity review (P4 scope) — determinism rules honored (orderings pinned, generated_at total exclusion, no floats), **no PII beyond opaque handles** (learner_id is a G-5 roster handle, not name/email/DOB — identity PII is never stored, R-79; the export + 422 scrubber surface carry nothing else), methodology honesty (the three gaps stated; attestation never presented as third-party verification — A-304, the never-display-forgeable-as-verified constraint), head-hash truth (the export's head equals verify_chain's), evidence bundles resolved from source rows (G-302), both export gates fail-closed. Findings recorded; P0s fixed in-phase.
|
||||
- **Task 4-2-03** (lead-developer): P4 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` green (web-touching phase); the determinism + methodology-honesty verdicts recorded; test counts recorded.
|
||||
|
||||
## Phase 5: Learner Placement Visibility (REQ-11-005)
|
||||
|
||||
**Goal:** The learner sees their outcome on the surface where they applied: current status + full timestamped history + placement confirmation when hired (one read, one truth source), withdraw within reach; the badge stays honest everywhere.
|
||||
|
||||
### Wave 5-1: Learner application-status read route (backend-engineer)
|
||||
|
||||
- **Task 5-1-01** (backend-engineer): `GET /v1/marketplace/applications/{application_id}?learner_id=` (D-104, in api/marketplace.py — the existing apply surface): gated by the **D-043 composition** (G-5 allowlist marketplace.py:46-52 → verified 18+ :358 — the P2 withdraw additionally rides the existing apply-rate window; no new throttle knob, D-088 untouched); returns `{application, events[], placement|null}` — current status + the full id-ordered timestamped ledger history for THIS application (scoped query, never another learner's events) + the placement record when hired (REQ-11-005's outcome visibility + placement confirmation in one read, D-104); composes the placements store via `app.state` DI (deps.py); the response is a named pydantic contract in placements/base.py (pinned in placement.ts per D-110). **G-322 (binding): the mine point-read — store `get_application_by_posting_learner(posting_id, learner_id)` (the insert_application unique key — the get_posting point-read shape, marketplace/store.py:578-584) + route `GET /v1/marketplace/applications/mine?posting_id=&learner_id=` on the unchanged D-043 gate stack, returning `{application_id, status}` or 404 — the panel NEVER depends on client-persisted application_ids; without this route the status panel cannot find the application on revisit (the common case for a job board).** Tests `tests/placements/test_learner_status.py`: own application → 200 with status + events[] (id-ordered, this application only); hired → placement payload with role_title/start_date/employer refs; foreign learner_id → 403; unverified → 403; unallowlisted → 403; unknown application → 404; **mine-read arms (G-322): own → 200 {application_id, status}; none → 404; foreign learner_id → 403; unverified → 403**; the G-313 conftest reset holds (no self-429).
|
||||
|
||||
### Wave 5-2: Job-detail status panel (frontend-engineer)
|
||||
|
||||
- **Task 5-2-01** (frontend-engineer): D-109 surface 3 — job-detail-client.tsx (the existing apply surface: apply :95-115, EngineError 409/404 discrimination :103-105, confirmation render :253) gains the application-status panel: **G-322 composition: mine-read (`GET /applications/mine?posting_id=&learner_id=`) first → found? render the status panel via D-104's read : not found? render the apply CTA — never client-persisted application_ids**; current status badge (the seven D-099 states), timestamped history rows (events[]), placement confirmation when hired (role title, employer, start date — D-104's read), and the withdraw CTA (the learner's ONLY edit, D-104 — 409 terminal copy honest, and G-323's apply → 409 application_terminal copy renders the honest terminal state); **EngineError.status discrimination through the existing checks — NO new error classes** (403 foreign → the honest "not your application" state; 404 unknown; 401/403 gate arms render their existing states); AbortController everywhere (A-406). Badge honesty (A-304/D-109): **a placement badge renders ONLY in this learner's-own application surface where the data is real; the talent profile pages carry NO badge (v0.1 mock imports — a badge on a mock page is fabricated placement, D-092 binding)**. Tests: web fetch-stub + source pins (the G-312 discipline) — each status state renders, history rows, placement-confirmation payload render, withdraw dispatch + terminal 409 copy, foreign-403 copy, **mine-read composition (mine 404 → apply CTA; mine 200 → status panel)**; pins — talent-profile pages grep-clean of placement-badge additions; pins.test.ts placement blocks green.
|
||||
|
||||
### Wave 5-3: P5 phase gate (lead-developer)
|
||||
|
||||
- **Task 5-3-01** (lead-developer): P5 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` green (web-touching phase); the A-304 badge-honesty census recorded (no badge on mock talent pages, no fabricated placement anywhere, mock labels retained where no backend computes them); test counts recorded.
|
||||
|
||||
## Phase 6: Final Review + Ship (milestone release, v0.10.x line)
|
||||
|
||||
**Goal:** Multi-persona review of all v0.11 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.10.6 (final v0.10.x patch = milestone release), full asset matrix + registry image via the existing release lane (no infra changes — the module rides D-064/D-065), milestone completion commits, branch cleanup.
|
||||
|
||||
### Wave 6-1: Multi-persona final review (lead-developer + security-auditor)
|
||||
|
||||
- **Task 6-1-01** (lead-developer → all active personas): full milestone-branch diff review — the **D-100 one-transaction discipline holds end-to-end** (one transition_application, no second status writer, no second append path — R-78), the **R-80 re-hire coherence verdict** (placements, not applications, are the correction lane; the ledger reads hired → revoke → rehire as three events, never a fabricated second application transition), the **G-319 pin-split verdict** (counts-only stays on the own-postings list — test_postings_read.py:216 unchanged; learner_id application-scoped only), the **D-110 wire-pin review** (placement.ts ↔ placements/base.py field-for-field + the status-union regex), the D-092 honest-surface census (the overview mock block replaced, talent mocks retained + labeled, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase; P1+ findings dispositioned with recorded follow-ups — never loop back to EXECUTE.
|
||||
- **Task 6-1-02** (security-auditor): final review — no credentials or identity PII in logs/releases/assets (learner_id is an opaque handle everywhere, R-79); gate orders pinned on every principal class (employer/learner/operator); the ledger's no-delete path intact; tamper/fork/concurrency arms green; export determinism + methodology honesty re-verified; no secret echoed by any exception path.
|
||||
|
||||
### Wave 6-2: Audit + ship (lead-developer)
|
||||
|
||||
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the D-112 addition census: ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py get_placement_store, the marketplace store's get_application point read + grouped reads, main.py wiring, tests/placements/, packages/types/placement.ts + index.ts export, engine-client placements section, /employer/applications page, overview placements section, job-detail panel, admin placements block; branch/commit discipline — feat: scoped to the placement surface) + ship: merge milestone → main; tag v0.10.6; Gitea release with full summary + the complete asset matrix + registry image (the existing tag-triggered lane — zero workflow changes); delete milestone branches (tags preserve history); mark REQ-11-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Docker Image Channel (REQ-8-002)
|
||||
|
||||
**Goal:** The full stack as one unprivileged container — deps pre-loaded, single-port topology preserved, honest sandbox degrade, pushed to the Gitea registry on every phase release.
|
||||
|
||||
### Wave 2-1: Image + degrade (devops-engineer + sandbox-engineer)
|
||||
|
||||
- **Task 2-1-01** (devops-engineer): Multi-stage Dockerfile per R-16..R-19 — web-export stage (node:24-bookworm, corepack pnpm, `NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build`, outputs `apps/web/out`), venv stage (python:3.11-bookworm, bootstrap.sh mechanics), runtime stage (python:3.11-slim-bookworm, non-root `nextcraft` user, uvicorn :8420, `AI_WEB_STATIC_DIR` → export, `NC_CONTAINER=1` marker, `EXPOSE 8420`, `HEALTHCHECK` GET /health, state volume `/home/nextcraft/.nextcraft`). CLI embedded per D-066 (`doctor`/`verify`/`guide` usable in-container; image tag stamped `NEXTCRAFT_VERSION_STAMP`).
|
||||
- **Task 2-1-02** (sandbox-engineer): Honest degrade per D-068/R-27..R-29 — `ai_service/sandbox/capability.py` promoting `tests/helpers/userns.py::userns_available()` verbatim (real probe, truth source: attempt userns + report), boot notice at startup when degraded (main.py G-11 loud-warning pattern), 503 arm beside PoolFullError in sandboxes.py (env creation rejected with remediation payload; web surfaces already render honest errors — additive only). **Remediation text is a single tested constant (G-208):** names the unprivileged default, the seccomp constraint, the documented privileged/seccomp-override variant (exact docker run flags), and the host-side alternative — the boot notice and the 503 payload carry the same constant. Tests: degraded-mode unit tests (probe mocked false → 503 + notice asserted); real userns path unchanged.
|
||||
- **Task 2-1-03** (cli-engineer): In-container CLI refusals per D-066/R-30 — `bootstrap`/`deploy`/`dev` detect `NC_CONTAINER` (or `.dockerenv` + marker) and refuse with honest exit 1 + remediation text (image is immutable; upgrade = docker pull per D-071); `doctor` gains container-mode branch (skips repo-root checks, verifies mount + /health); `verify` container semantics (probe the live listener + export presence).
|
||||
|
||||
### Wave 2-2: Registry push + release integration (devops-engineer)
|
||||
|
||||
- **Task 2-2-01** (devops-engineer): Release workflow registry step per R-20/D-065 — `docker build` on the tag ref, `docker login git.coreci.dev` with `secrets.REGISTRY_TOKEN`, push `git.coreci.dev/coreci/nextcraft:$TAG` + `:latest`. Blocks on Task 1-2-04's secret existing (escalation if still pending: ship proceeds, push step marked `continue-on-error: false` + release notes note the missing image).
|
||||
- **Task 2-2-02** (devops-engineer): `docker run` smoke gate in the workflow — five pinned assertions (G-204): container starts → `/health` 200 within timeout → `GET /` is content-type text/html AND contains a known page marker (not merely non-JSON-404) → degrade boot notice present in container logs → fabric env-create returns 503 with the remediation payload → clean stop. A broken image = failed release job.
|
||||
- **Task 2-2-03** (security-auditor): Image audit — non-root confirmed, no secrets in layers (`.env*`/`.ciagent/` excluded via `.dockerignore`), healthcheck present, minimal runtime (no build toolchain in the final layer).
|
||||
|
||||
**Verification hooks P2:** image builds locally + on the runner; pushed to the registry under the phase tag + latest; smoke gate green (HTML at /, degraded fabric reported honestly); degrade tests green in the fast pytest lane; in-container doctor/verify/refusals green (CLI tests); `.dockerignore` excludes secrets; all existing suites stay green (no regressions from capability.py promotion — the real userns suites unchanged).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Debian-Native Channel (REQ-8-003)
|
||||
|
||||
**Goal:** arm64 binaries + .deb packages for x64 + arm64, attached to every release; install.sh resolves arch; every channel ends at doctor/verify green.
|
||||
|
||||
### Wave 3-1: arm64 cross-build (cli-engineer)
|
||||
|
||||
- **Task 3-1-01** (cli-engineer): `build-binary.mjs` arch parameter per R-25 — `--arch x64|arm64`; the ONLY arch-specific step (copy of `process.execPath`) replaced by the official arm64 node tarball download (probe-verified 200/30MB) for arm64; esbuild bundle + postject injection unchanged (arch-agnostic file surgery); output `nextcraft-linux-arm64` + `.sha256`. **Decided fallback ladder (G-201):** probe QEMU-free cross-arch SEA on the runner first; if the blob fails under emulation, fall back to binfmt+qemu for FULL arm64 builds; if the runner cannot install binfmt, ship v0.8 x64-only (arm64 deferral recorded in ship notes + REQUIREMENTS) — never leave the release lane red or blocked.
|
||||
- **Task 3-1-02** (cli-engineer): Binary contract tests — arm64 binary `--version` reports the stamped tag (under emulation in CI); x64 path byte-identical to the v0.4 pipeline (checksum + version gates unchanged).
|
||||
|
||||
### Wave 3-2: deb packages + installer + release extension (cli-engineer + devops-engineer)
|
||||
|
||||
- **Task 3-2-01** (cli-engineer): `scripts/build-deb.sh` per R-22/R-23 — `dpkg-deb --root-owner-group -Zxz`, package `nextcraft` Version = tag minus `v`, empty Depends + `Recommends: nodejs, python3, git, util-linux`, contents: `/usr/bin/nextcraft` (arch-matched binary) + `/usr/share/doc/nextcraft/` (README + guide); postinst = advisory doctor (`|| echo` → always exit 0, never fails dpkg per D-067); outputs `nextcraft_<ver>_<arch>.deb` + sidecars.
|
||||
- **Task 3-2-02** (cli-engineer): `install.sh` arch-resolve per R-24 — `uname -m` x86_64 → x64 asset, aarch64/arm64 → arm64 asset; checksum + version integrity gates byte-identical for both; degradation messaging unchanged.
|
||||
- **Task 3-2-03** (devops-engineer): Release workflow extension — arm64 binary build + both debs join the release-asset attach step (x64 binary, arm64 binary, 2× sha256, 2× deb — full asset matrix per D-064); smoke: deb installs in a fresh debian container, `nextcraft --version` + advisory doctor run. **Proof via the G-203 temporary-rc-tag mechanism.**
|
||||
- **Task 3-2-04** (security-auditor): Asset audit — all 6 release assets checksum-verified, version stamps match the tag, no secrets in debs.
|
||||
|
||||
**Verification hooks P3:** arm64 binary + 2 debs build in CI (release lane green on the phase tag); install.sh fetches the right asset per arch (fixture-server test extended, tamper-rejection unchanged); deb installs → binary on PATH → advisory doctor runs; x64 pipeline byte-identical (no regression); asset matrix complete on the v0.7.3 release.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: CLI Onboarding (REQ-8-004)
|
||||
|
||||
**Goal:** `nextcraft` alone takes an admin zero → running — channel-aware doctor, `guide`, honest refusals, per-channel E2E.
|
||||
|
||||
### Wave 4-1: Channel awareness + guide (cli-engineer)
|
||||
|
||||
- **Task 4-1-01** (cli-engineer): Doctor channel awareness per R-30 — container-mode branch (from P2), deb-install detection (`dpkg -s nextcraft` or binary-at-/usr/bin heuristics; report channel + its upgrade semantics per D-071), binary-install default. Each channel's doctor outputs the right next-step line (deploy --service vs docker pull + recreate).
|
||||
- **Task 4-1-02** (cli-engineer): `nextcraft guide` per D-072 — offline-first embedded man-page text (no runtime fetches): sections per channel (first install, upgrade, troubleshooting pointers), forge URLs as pointers. `guide [topic]` optional arg (guide deploy / guide docker / guide deb / guide troubleshooting).
|
||||
- **Task 4-1-03** (cli-engineer): CLI tests for the new surface — dispatch tests (guide routing, refusals, doctor branches per detected channel via env/fixtures); exit-code contract unchanged (0 ok / 1 failure / 2 usage).
|
||||
|
||||
### Wave 4-2: Per-channel E2E (cli-engineer + devops-engineer)
|
||||
|
||||
- **Task 4-2-01** (cli-engineer): Docker-channel E2E (env-gated like the v0.4 E2E): pull the released image, run, in-container doctor/verify green, refusals honest, teardown. Wired as `cli:test:e2e:docker`, excluded from default lanes.
|
||||
- **Task 4-2-02** (cli-engineer): deb-channel E2E (env-gated): fresh debian container, install the released deb, advisory doctor, `nextcraft guide`, teardown. `cli:test:e2e:deb`.
|
||||
- **Task 4-2-03** (lead-developer): The binary channel E2E already exists (fresh-clone, v0.4) — verify it still passes with the arch-resolve installer; wire the three E2E names into the docs so admins can reproduce them.
|
||||
|
||||
**Verification hooks P4:** all CLI suites green at current counts; guide renders offline (no network mock asserted); doctor reports the right channel in all three modes (fixture tests); docker + deb E2E green when env-gated on; existing binary E2E green; no regression in the 61-test CLI baseline surface (net new tests allowed).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Admin Guides (REQ-8-005)
|
||||
|
||||
**Goal:** The admin runbook — every channel's first-install + upgrade recipe, evidence-backed, one entry point.
|
||||
|
||||
### Wave 5-1: The runbook (lead-developer + cli-engineer)
|
||||
|
||||
- **Task 5-1-01** (lead-developer): `deploy/README.md` → the full admin runbook per R-31/R-32 — structure: pick-your-channel matrix → docker channel (docker run, volumes, privileged variant for full in-container sandboxes, upgrade = pull + recreate per D-071) → deb channel (dpkg -i, advisory doctor, deploy --service integration) → binary channel (install.sh one-liner, existing recipe preserved) → upgrade matrix table → HAProxy/systemd recipes (existing content preserved) → troubleshooting tables (runner/registry/docker-degrade/deb — extended from the CLI README's) → evidence appendix (which test or live probe backs each recipe).
|
||||
- **Task 5-1-02** (cli-engineer): `nextcraft guide` content parity — the runbook's channel sections are the canonical text, extracted to a single shared source (G-210); the guide renders from that source; divergence = parity test failure (never doc drift).
|
||||
- **Task 5-1-03** (lead-developer): README quickstart updated — three channels listed with one command each; the v0.8 milestone line added to the history table.
|
||||
|
||||
### Wave 5-2: Guide verification (lead-developer)
|
||||
|
||||
- **Task 5-2-01** (lead-developer): Every runbook recipe evidence-checked — each recipe carries a pointer to its backing test (E2E name, workflow job, or live-box probe from prior phases); the parity test proves guide ↔ runbook agreement; doc lint (markdown structure) if cheap.
|
||||
|
||||
**Verification hooks P5:** runbook complete (all channels, upgrade matrix, troubleshooting, evidence appendix); parity test green; README quickstart accurate; no unevidenced recipe claims (each has a pointer); existing deploy recipes (systemd/HAProxy) preserved verbatim-accurate.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Final Review + Ship (milestone release, v0.7.x line)
|
||||
|
||||
- **Task 6-1-01** (lead-developer → ci-review personas): multi-persona review of the full milestone branch diff (all phases — workflows correctness, Dockerfile hardening, deb packaging, CLI surface, docs accuracy); security-auditor confirms secrets policy end-to-end (no token in any layer/asset/workflow), image non-root, deb postinst advisory-only.
|
||||
- **Task 6-2-01** (ci-audit): reconstruction test (git log ↔ .ciagent/ files), file/branch/commit discipline, tag hygiene; critical fixes in-phase.
|
||||
- **Task 6-2-02** (lead-developer → ci-ship): merge milestone → main; tag the milestone release (final v0.7.x phase tag = v0.7.6); Gitea release with full summary + the complete asset matrix (x64/arm64 binaries + checksums + 2 debs) + registry image tags; delete milestone branches; mark REQ-8-001..005 complete; clear checkpoint.
|
||||
|
||||
---
|
||||
|
||||
## Must-Haves (per phase — concrete, falsifiable, test-linked)
|
||||
|
||||
### Phase 1
|
||||
|
||||
- **MH-1a**: Three runners visible + idle at org level (`/org/coreci/settings/actions/runners`) — named nc-runner-node/python/release, hybrid labels declared per the R-3 map; registration token reset requested from founder after green fleet.
|
||||
- **MH-1b**: `ci.yaml` green on a fresh push — node lane (lint+typecheck via per-workspace filters, web:test, cli:test) + python lane (bootstrap/lint/typecheck/FAST test) both observed in the Actions tab.
|
||||
- **MH-1c**: `hello.yaml` dispatch green × 3 labels (node/python/ubuntu-latest) — the fleet proof.
|
||||
- **MH-1d**: `release.yaml` exercised via the G-203 temporary rc tag on the phase branch — workflow observed building binary + checksum and attaching them green; the phase ship release (v0.7.1) carries the same machine-made assets.
|
||||
- **MH-1e**: pre-commit proven both directions — blocks a deliberately broken lint file, passes a clean tree; config is local-only (no network hooks); the no-toolbox skip path is documented in README dev (G-209).
|
||||
- **MH-1f**: Secrets audit clean — grep-verifiable: no token/credential in any committed file; CT root password used once, founder rotation requested as a Wave 1-1 completion condition and confirmed or escalated (G-206); workflows carry no shell-env token echoes.
|
||||
|
||||
### Phase 2
|
||||
|
||||
- **MH-2a**: Image builds (local + release lane) and pushes to `git.coreci.dev/coreci/nextcraft` under the phase tag + `latest`; `/v2/` manifest probe-verified after push.
|
||||
- **MH-2b**: Smoke gate green with the five pinned assertions (G-204) — container start → /health 200 → `GET /` HTML with known marker → degrade notice in logs → 503 with remediation on env-create → clean stop; a broken image fails the release job.
|
||||
- **MH-2c**: Degrade is honest — capability probe is a real userns attempt (not an env-var guess); degraded boot notice logged once with remediation text; env-create rejected 503 with remediation payload; real userns path unchanged (userns suites green locally).
|
||||
- **MH-2d**: In-container CLI semantics — doctor/verify/guide work; bootstrap/deploy/dev refuse with remediation (exit 1); all pinned by tests.
|
||||
- **MH-2e**: Image hygiene — non-root runtime, `.dockerignore` excludes `.env*` + `.ciagent/`, no build toolchain in the final layer (auditor-verified).
|
||||
|
||||
### Phase 3
|
||||
|
||||
- **MH-3a**: arm64 binary builds per the G-201 ladder — QEMU-free cross-arch SEA if the probe passes; binfmt+qemu full build if not; x64-only deferral (recorded) if binfmt is impossible — the ladder's outcome is recorded either way, `--version` reports the stamped tag under emulation, sha256 sidecar matches.
|
||||
- **MH-3b**: Both debs build + install in fresh debian containers — `nextcraft --version` + advisory doctor run; postinst never fails dpkg (proven with missing-Recommends environment).
|
||||
- **MH-3c**: install.sh arch-resolve — x86_64 → x64 asset, aarch64 → arm64 asset, checksum + version gates byte-identical; fixture-server tests extended to both arches.
|
||||
- **MH-3d**: v0.7.3 release carries the complete asset matrix: 2 binaries + 2 checksums + 2 debs (6 assets), all verified — or the recorded G-201 deferral outcome (x64-only) with the arm64 rows explicitly marked deferred, never silently missing.
|
||||
|
||||
### Phase 4
|
||||
|
||||
- **MH-4a**: Doctor reports the detected channel (container/deb/binary) with the channel-correct next-step line, in fixture tests covering all three.
|
||||
- **MH-4b**: `nextcraft guide` renders offline (no network calls — asserted), topics route (`guide docker|deb|troubleshooting|deploy`), exit-code contract intact.
|
||||
- **MH-4c**: Refusals honest — in-container bootstrap/deploy/dev exit 1 with remediation text (pinned by tests).
|
||||
- **MH-4d**: Both new E2Es (docker, deb) exist env-gated, green when run; the binary-channel E2E still green; guide ↔ runbook parity test green.
|
||||
|
||||
### Phase 5
|
||||
|
||||
- **MH-5a**: Runbook covers all three channels (first-install + upgrade each), the upgrade matrix, HAProxy/systemd recipes, and the four troubleshooting tables — every recipe carries an evidence pointer (test name / workflow job / live probe).
|
||||
- **MH-5b**: Parity test green — `nextcraft guide` content and the runbook agree section-by-section.
|
||||
- **MH-5c**: README quickstart lists all three channels accurately; milestone history updated.
|
||||
|
||||
### Phase 6
|
||||
|
||||
- **MH-6a**: Multi-persona review of the full diff clean (P0s fixed in-phase); secrets policy confirmed end-to-end by security-auditor.
|
||||
- **MH-6b**: ci-audit reconstruction + discipline checks green.
|
||||
- **MH-6c**: Milestone shipped — v0.7.6 tagged, merged to main, release carries the full asset matrix + image tags, REQ-8-001..005 complete, branches deleted, checkpoint cleared.
|
||||
|
||||
## Milestone Must-Haves
|
||||
|
||||
- **MH-M1**: All five requirements complete per their REQUIREMENTS.md rows (REQ-8-001..005).
|
||||
- **MH-M2**: A push to any branch runs lint + typecheck + fast tests on the runner fleet, green (the release-consistency rail the founder asked for).
|
||||
- **MH-M3**: Every phase release from v0.7.1 onward is machine-made the same way: tag push → workflow builds → binary + checksum (+ image from P2, + arm64/deb from P3) attached + registry pushed.
|
||||
- **MH-M4**: Each channel takes a fresh Debian server to a running verified stack: docker run → /health + HTML; dpkg -i → doctor → deploy --service; install.sh → deploy --service (proven by E2E/workflow smoke or live-box evidence).
|
||||
- **MH-M5**: The admin runbook exists as one entry point with per-channel recipes and upgrade paths, every recipe evidence-backed; `nextcraft guide` mirrors it offline.
|
||||
- **MH-M6**: Zero silent failures: honest sandbox degrade (loud notice + 503 remediation), honest in-container refusals, honest channel reporting in doctor — the v0.5+ honesty pattern extended to distribution.
|
||||
- REQ-11-001..005 all complete per their REQUIREMENTS.md rows.
|
||||
- The E2E machine-checked proof (the milestone's reason to exist, tests/placements/test_e2e_audit.py): employer registers → claims → operator approves → posts → learner (verified 18+) applies → employer drives in_review → interview → offer → hire `{role_title, start_date}` → PlacementRow created → the global chain walks green GENESIS_HASH → head → the export reproduces identical chains + head_hash on re-export → the overview counts the learner ONCE toward 1,000 — every step pinned in tests/placements/.
|
||||
- Zero new dependencies (D-112 — the chain is stdlib hashlib/json; export is pydantic + JSONResponse), zero workspace changes, zero mypy overrides, zero .env.example changes (no new knobs — recorded explicitly).
|
||||
- The v0.6/v0.9/v0.10 wire contracts unchanged: marketplace/billing/employers suites green unmodified (the only marketplace seam changes are the get_application point read + grouped reads); the own-postings counts-only pin green unchanged (G-319); UpgradeRequiredError and every existing engine-client contract byte-stable.
|
||||
- Wire pins green: placement.ts dual-schema pinned field-for-field against placements/base.py (D-110), status-union regex pinned.
|
||||
- Suite counts: pytest fast lane + web + cli all green, zero warnings (the v0.7 regime holds).
|
||||
- Docs: REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — recorded explicitly); the mock-first doctrine intact (no external systems, no third-party auditor integration — the export IS the deliverable; tests never call a cloud).
|
||||
|
||||
## Risks
|
||||
|
||||
- **CT 113 single point of failure** (fleet host) — mitigation: compose + config + cron committed as documentation-in-repo (the runbook reproduces the fleet from scratch); founder access path recorded.
|
||||
- **Registration token expiry mid-P1** — mitigation: bring-up is Wave 1-1's first task; the token's job (3 registrations) completes before anything else depends on it; reset requested immediately after.
|
||||
- **Cache-server reachability from job containers** (R-5) — mitigation: cache.host + published ports verified at bring-up; volume-mounted pnpm/pip stores are the deterministic fallback (R-12).
|
||||
- **actions/checkout needs CT egress to github.com** (R-8) — mitigation: probe at 1-1-01; raw git fetch steps as the fallback lane.
|
||||
- **SEA blob cross-arch behavior** (R-26) — mitigation: probe-first in P3; binfmt fallback scoped to blob generation.
|
||||
- **Registry push auth failure** (REGISTRY_TOKEN 401/403 on self-serve) — mitigation: founder UI fallback (escalation commits, P2 marks push step as the only blocked piece; ship still completes).
|
||||
- **docker.io pulls on the release lane** (R-21 note) — bookworm ships docker.io; accepted for v0.8, pre-baked release image documented as the future fix.
|
||||
- **Runner 3.x config drift** (cache.host/bind_workdir/health_check keys) — mitigation: `gitea-runner generate-config` is the authority at bring-up (R-9 risk 9).
|
||||
- **Image size growth over releases** (~400-500MB budget, R-18) — mitigation: budget asserted in the smoke gate; multi-stage keeps toolchains out of the runtime layer.
|
||||
- **Existing v0.4 binary pipeline regression** — mitigation: x64 path byte-identical (checksums + install.sh gates unchanged, pinned by the existing fixture tests).
|
||||
1. **Re-hire-after-revoke coherence gap (R-80)** — `hired → hired` is a 409 on every matrix-validated surface, so the re-hire is pinned as the placement-level operator route `POST /v1/placements/admin/placements/by-application/{application_id}/re-hire` + the `placement.rehired` ledger event, never an application transition — Task 2-2-02; the three-event sequence + the partial-unique IntegrityError arm tested.
|
||||
2. **Hash-after-flush write shape (R-70)** — flush-then-hash-then-commit inside one transaction, ALL appends through the one transition_application, the no-delete-path pin — Task 1-1-03 (+ the 1-1-02 source pin); tamper/fork/concurrency tests green.
|
||||
3. **created_at canonical serialization (R-79/R-69)** — ONE pinned normalization (`_as_utc(dt).isoformat()`) in `_canonical_event_json`; the tamper tests catch hashing/reading drift before the chain breaks on its first re-read — Task 1-1-02.
|
||||
4. **_APPLY_TIMES limiter reset (R-77)** — tests/placements/conftest.py replicates the autouse reset from day one (the G-313 lesson applied before it ships, not after flaky archaeology) — Task 1-2-01.
|
||||
5. **Methodology honesty block (R-67/R-68)** — the three pilot gaps (no duration check, no salary, attestation-not-third-party-verification) stated explicitly in the export methodology — Task 4-1-01; asserted present by Task 4-1-03.
|
||||
6. **Export determinism rules (R-73)** — pinned list orderings (events by id; cohort month ascending; placements created_at then id), total generated_at exclusion, float absence — Task 4-1-01; the re-export test is the proof.
|
||||
7. **Status-column ownership (R-78)** — the placements store is the ONLY post-creation writer of application.status; stated column-level in the module docstring + enforced by the D-100 discipline check at the P1 gate (Task 1-3-02).
|
||||
8. **G-319 supersession pin split (R-77)** — counts-only stays on the own-postings list (test_postings_read.py:216 unchanged); learner_id appears on application-scoped routes only, cross-entity 403 carries no data — Task 2-1-02 pins both arms.
|
||||
9. **Concurrent-transition arm (R-78)** — one-winner-one-IntegrityError on truly concurrent transition_application calls (the billing concurrent-consumption shape, tests/billing/test_store.py:335) — Task 1-1-03.
|
||||
10. **CIRR specifics from knowledge, not live sources (R-67)** — the methodology text stands on its own definitions; CIRR is naming, not citation; the summary renders the prose an auditor reads without external references — Tasks 4-1-01/4-1-02.
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
1. **Gitea Actions tab** — every push runs the lanes; every tag builds a release (visible, green, machine-made).
|
||||
2. **`docker run git.coreci.dev/coreci/nextcraft`** — the whole stack, one port, /health, honest degrade notice.
|
||||
3. **Release downloads** — x64 + arm64 binaries, checksums, .deb packages — every release.
|
||||
4. **`nextcraft guide`** — offline man-page for admins: channel recipes, upgrade paths, troubleshooting.
|
||||
5. **`nextcraft doctor`** — channel-aware (container/deb/binary) with the right next-step line.
|
||||
6. **`deploy/README.md`** — the one-entry-point admin runbook.
|
||||
7. **Pre-commit** (opt-in for devs) — local hygiene mirroring CI.
|
||||
1. **/employer/applications (NEW)** — the hiring pipeline: own applications with status badges, per-target transition CTAs, the hire form `{role_title, start_date}` (a date input, future dates allowed); honest pending/empty/claim states.
|
||||
2. **Employer dashboard — overview placements section** — real hired placements with role/start-date (the mock block REPLACED); talent-match/applicant-pipeline metrics stay mock-labeled.
|
||||
3. **Job detail — application-status panel** — the learner's current status + timestamped history + placement confirmation when hired + withdraw CTA; honest 403/404/409 states.
|
||||
4. **Admin moderation page — placements block** — aggregate counts toward the 1,000 guiding goal + per-employer lists + export links (summary in-tab, JSON download); 403 → honest locked state with token prompt.
|
||||
5. **Talent search + candidate profile** — remain the v0.1 mock, honestly labeled, with NO placement badge (A-304 — no badge on mock pages).
|
||||
6. **API surfaces** — `/v1/employers/applications/*` (session-gated transitions + own pipeline list), `/v1/marketplace/applications/{id}` + `/withdraw` (learner-gated, D-043 stack), `/v1/placements/admin/*` (operator-gated: transition, revoke, re-hire, overview, export, export/summary).
|
||||
7. **Release notes** — the v0.10.x line ships placement tracking through the existing automated lane (no admin-box changes required).
|
||||
|
||||
## Happy Path (end-to-end, written BEFORE execute)
|
||||
|
||||
1. **Founder/rails:** tag push → release workflow builds binary + checksum, attaches to the release, pushes the image (from P2), adds arm64 + debs (from P3) — no human touches an artifact.
|
||||
2. **Admin, docker channel:** fresh Debian box → `docker run -d -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:v0.7.x` → /health green, site HTML at :8420, boot notice explains the sandbox degrade + the privileged variant pointer.
|
||||
3. **Admin, deb channel:** `dpkg -i nextcraft_0.7.x_amd64.deb` → advisory doctor → `nextcraft deploy --service` → verified single-port stack.
|
||||
4. **Admin, binary channel:** `curl … install.sh | sh` → `nextcraft doctor` → `nextcraft deploy --service` (the existing path, unchanged).
|
||||
5. **Admin, any channel, stuck:** `nextcraft guide` → offline recipe + troubleshooting pointers → resolved or forge link.
|
||||
6. **Upgrades:** docker = pull + recreate; deb = new deb + deploy --service; binary = install.sh re-run — all idempotent per D-071.
|
||||
7. **Developer:** clone → pre-commit install → push → CI lanes green before review.
|
||||
1. **Employer onboards (v0.10 rails unchanged):** register → claim their entity → operator approves → the account owns the entity.
|
||||
2. **Employer posts:** create draft (package granted) → publish → the posting is live on the job board.
|
||||
3. **Learner applies:** a verified 18+ learner applies from the job-detail page → application `submitted` → the status panel shows the timestamped entry.
|
||||
4. **Employer drives the pipeline:** /employer/applications → in_review → interview → offer → the learner's panel reflects each step with its event history.
|
||||
5. **Hire:** the employer submits `{role_title: "AI Orchestration Engineer", start_date: next month}` → hired (terminal) → the PlacementRow is created in the same transaction as the ledger events → the learner's panel shows the placement confirmation (role, employer, start date).
|
||||
6. **Operator oversight:** the admin overview counts the learner ONCE toward 1,000; a mistaken hire → operator revoke → re-hire creates a fresh placement with a three-event ledger trail — the learner is never erased from the count by a correction.
|
||||
7. **Auditor exports:** the admin placements block → the JSON export downloads (self-contained: cohort + evidence bundles + methodology + chain with head hash) + the summary opens in-tab → re-export reproduces the identical chain + head → the chain-walk instructions let a third party recompute every hash offline.
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
1. No silent failures anywhere in the new surface: image can't sandbox → loud notice + 503 remediation; in-container miscommands refuse with remediation; doctor names the channel and the correct next step.
|
||||
2. Every channel ends at the same place: doctor/verify green on a running stack — the admin doesn't need to know the internals.
|
||||
3. Releases are uniform and machine-made: same asset matrix, same gates (checksum + version), no manual artifact steps.
|
||||
4. The guide works offline and agrees with the runbook (parity-tested — divergence is a test failure, not a doc drift).
|
||||
5. Docs match reality: every recipe carries an evidence pointer; stale docs are a P0 at final review, not a footnote.
|
||||
1. **Honest states everywhere:** every new surface renders real loading/error/empty states — no spinners that lie, no silent failures, no fake data.
|
||||
2. **Gate order never surfaces a 409 before a 403:** unauthenticated → 401, unclaimed → 403 claim prompt, foreign application → 403 with no data, only then matrix 409s — pinned on every transition route (G-311).
|
||||
3. **401 → honest logged-out state:** SessionRequiredError renders the login CTA on employer surfaces — never a retry loop, never a silent redirect; admin surfaces render the token-prompt locked state on 403.
|
||||
4. **Counts honesty:** the overview counts DISTINCT learners toward 1,000 (a learner placed twice counts once); revoked placements excluded from every count; counts render from the store's rows at query time — never a denormalized or cached counter.
|
||||
5. **Export self-verification:** the summary carries the head hash + offline chain-walk instructions; the methodology states its own three pilot gaps AND the chain's proof scope + wholesale-rewrite limit + export-to-export continuity anchor (G-321) — employer attestation is never presented as third-party verification (A-304); **G-328: no "verified" placement wording on any live surface (source-pinned)**.
|
||||
6. **Badge honesty:** a placement badge renders only on the learner's own application surface where the data is real; no badge until a placement exists; none ever on the mock talent pages.
|
||||
7. **Mock labels retained where no backend exists:** talent-match/applicant-pipeline metrics stay mock-labeled; talent pages stay the v0.1 mock — never fake-convert, never leave a convertible surface on mock (D-092).
|
||||
+82
-1
@@ -16,7 +16,84 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
---
|
||||
|
||||
## Milestone v0.8 — Ship Anywhere (ACTIVE, Feature, started 2026-09-27)
|
||||
## Milestone v0.11 — CIRR-Style Placement Tracking and Audit (ACTIVE, Feature, started 2026-10-01)
|
||||
|
||||
**Scope (D-098, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-10-01):** Activates **REQ-F-020 — CIRR-style placement tracking and audit** as REQ-11-001..005. The guiding outcome ("≥1,000 learners placed in AI-orchestration roles within 36 months, **verified by CIRR-style third-party audit**") is the school's single north star — v0.11 builds the machinery that makes it measurable and auditable. Every prerequisite is shipped: the learner identity spine (v0.5), the credential pipeline (v0.3), the marketplace applications flow (v0.6, `ApplicationRow`), the employer principals (v0.10, claimed entities + audit trail). v0.11 adds: the placement record model (learner + employer + role + start date, evidenced, immutable event log), the employer-side application state machine extending the v0.6 `submitted`-only status (hiring-stages transitions with audit), placement reporting surfaces (per-employer, per-learner, aggregate), and the **audit-grade export** — the evidence-backed dataset a third-party auditor can verify (cohort snapshots, per-placement evidence chains, tamper-evident event ledger, methodology documentation). Rationale: D-085's closing sentence names CIRR tracking as what the employer network "unlocks"; the guiding outcome is unmeasurable without it; no other deferred requirement (REQ-F-019 is medium and adds a surface, not the outcome). Confidence 0.80 ≥ threshold 0.60 (D-007).
|
||||
|
||||
**Out of scope:** human tutor marketplace (REQ-F-019, deferred), external third-party auditor integration (the export is the deliverable — no CIRR org exists to integrate with), learner-side employment verification (self-report stays out; employer-side state changes are the evidence), compensation/salary tracking (role + start date + employer suffice for placement counting; salary is out per CIRR-adjacent minimalism), public placement-stats marketing pages (internal + export only).
|
||||
|
||||
**Success (milestone-level):** every application in the marketplace has an auditable state history (submitted → in_review → interview → offer → hired/rejected/withdrawn, transitions employer-principal-driven and audit-logged); a placement record exists for every hired application with evidence (posting ref, application ref, timestamps, decision provenance); aggregate placement counts are computable from the ledger (per employer, per cohort month, cumulative toward ≥1,000); the audit export produces a self-contained, evidence-linked dataset (JSON + human-readable summary) whose placement claims are each traceable to immutable events; the mock-first doctrine holds (no external systems; tests never call a cloud). a placement record exists for every hired application with evidence (posting ref, application ref, timestamps, decision provenance); aggregate placement counts are computable from the ledger (per employer, per cohort month, cumulative toward ≥1,000); the audit export produces a self-contained, evidence-linked dataset (JSON + human-readable summary) whose placement claims are each traceable to immutable events; the mock-first doctrine holds (no external systems; tests never call a cloud).
|
||||
|
||||
## Clarify decisions (D-099+, CLARIFY stage full autonomy, 2026-10-01)
|
||||
|
||||
| ID | Decision | Rationale | Confidence |
|
||||
|----|----------|-----------|------------|
|
||||
| D-099 | Application status vocabulary = **snake_case set `submitted → in_review → interview → offer → hired / rejected / withdrawn`, forward-only with skip-forward, terminals have no outgoing edges**: the validated matrix (billing's `_VALID_TRANSITIONS` shape, billing/store.py:84-90) allows from any non-terminal status (a) any LATER stage (a fast-track `submitted → hired` is a legal hire — CIRR counts the hire, not the ceremony) and (b) any terminal (`rejected`/`withdrawn`); NO backward moves (no `offer → in_review`; the posting lifecycle draft→published→closed is strictly forward, store.py:289/609-632 — hiring inherits the discipline); `hired`/`rejected`/`withdrawn` are terminal — re-transition → 409 (`InvalidTransition`, the set_posting_status route contract); `withdrawn` is triggerable by the learner (own application, D-104) OR the operator (remediation, actor recorded — never by an employer session, who may `reject` instead); value naming is snake_case `in_review` (REQ-11-002's own text; billing's `past_due` precedent). Existing v0.6 rows are all `submitted` (store.py:211) — a valid matrix start, zero data migration. | The tree's two transition machines (posting store.py:609-632, subscription billing/store.py:84-90) both pin exact matrices with terminal empty-tuples; G-305's lesson (no phantom states, no un-matrixed moves) applies verbatim; skipping stages is honest pipeline reality (REQ-11-002 names the stages but no rule forbids acceleration, and an operator correcting a mis-entered pipeline must be able to jump forward). | 0.85 |
|
||||
| D-100 | The state machine lives in **`ai_service/placements/` — the placements store owns ONE `transition_application(application_id, to_status, actor_type, actor_ref, role_title=None, start_date=None)` method that updates the marketplace `application` row + appends the ledger event + creates the `PlacementRow` on hired, ALL IN ONE TRANSACTION** (the decide_claim all-or-nothing precedent, employers/store.py:636-651: "the audit row rides the SAME transaction"); the transition matrix constant lives in `placements/base.py` (billing/base.py `CANONICAL_EVENT_NAMES` placement); the marketplace store KEEPS application insert/read ownership (insert_application unchanged; gains only a `get_application(application_id)` point read, the get_posting shape at store.py:578-584) — the placements store's cross-family write to the `application` table is the sanctioned deliberate exception: both stores share the one SQLite file, so one Session on the placements engine covers both tables atomically. The rejected alternative — route composes `marketplace.set_application_status` + `placements.append_event` as two commits — leaves a crash window where the status changed but no ledger row exists, which voids REQ-11-002's "every transition appends the placement ledger" (the exact tamper-evidence the milestone sells). | REQ-11-002's ledger-append requirement is only provable under one transaction; decide_claim is the in-tree proof that decision + audit event must land together; cross-module reuse is family-sanctioned (employers/base.py:22 subclasses marketplace.base.Posting — D-093); D-027 table ownership bends here because the feature boundary (application lifecycle) crosses the v0.6 table boundary, and the bend is one column-write executed by one named store method, never a route-side two-step. | 0.80 |
|
||||
| D-101 | Hash-chain construction = **ONE GLOBAL chain over `PlacementEventRow` ordered by autoincrement id: each row stores `prev_hash` (the prior event's hash; `GENESIS_HASH = "0" * 64` module constant, test-pinned) + its own `hash` = `hashlib.sha256` (hex digest) over canonical JSON `{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at}` + `prev_hash`, serialized `json.dumps(..., sort_keys=True, separators=(",", ":"))`, created_at as ISO-8601 UTC** (deterministic; stdlib-only — the D-086 zero-dep doctrine, the D-077 HMAC-SHA256 crypto precedent); **`UNIQUE(prev_hash)`** makes a chain fork structurally impossible (two events claiming the same predecessor surface IntegrityError — the G-316 structural-backstop class); event rows carry BOTH a nullable indexed `application_id` (application-stage transitions log before any placement exists) and nullable `placement_id` (set from hired onward); **NO backfill**: pre-v0.11 applications have no `submitted` event — the export's evidence bundle carries the application record's own `created_at` (honest absence, never fabricated history, A-304; the G-302 doctrine cuts both ways). Actor vocabulary: `employer \| learner \| operator \| system` (the billing `_BILLING_EVENT_KINDS` shape, billing/store.py:92). | REQ-11-004 names "the event-ledger chain with head hash" — singular: one global chain with one head is the simplest verifiable shape (a single linear walk re-computes every hash; per-placement chains would need N heads and per-chain genesis bookkeeping for nothing at pilot scale); canonical sorted-key JSON is the standard deterministic-serialization answer and makes the re-export integrity test exact; SQLite's single-writer serialization plus the UNIQUE backstop closes the two-appenders-same-prev race the G-316 lesson demands. | 0.80 |
|
||||
| D-102 | PlacementRow semantics = **immutable at creation; `status` is the ONLY mutable column with exactly one transition `active → revoked` (operator-only correction, the D-083 manual-remediation lane)**: `role_title`/`start_date`/`decided_at`/`learner_id`/`employer_id`/`posting_id`/`application_id` are set once at the hired transition and NEVER rewritten (a silent field rewrite is exactly the tamper the ledger exists to detect; the D-053 append-only enrichment doctrine); `decided_at` = the hired transition's created_at (store-stamped, `datetime.now(UTC)`, the insert_application precedent); the one-placement-per-application guard is a **partial UNIQUE index on `application_id` WHERE status='active'** (the G-316 `uq_pending_claim_per_entity` shape, employers/store.py:161-167) — a revoked placement stays in history, and a re-hire after a mistaken revoke creates a fresh placement for the same application, so an operator fat-finger never permanently erases a learner from the ≥1,000 count; `employer_id`/`learner_id`/`posting_id` are ID REFERENCES resolved from source rows at read/export (G-302 — never trusted denormalized copies). | REQ-11-001's plain "UniqueConstraint (application_id)" would make a mistaken hire uncorrectable forever (terminal application + unique placement = no re-hire path), which is operationally dishonest for an audit-grade dataset that must survive human error; the partial-index precedent exists in-tree for exactly this conditional-uniqueness shape; revoked placements are excluded from counts (D-107) so the correction is counting-honest too. | 0.75 |
|
||||
| D-103 | `start_date` = **the employment start DATE (first day of work), a pydantic `date` — not the offer date, not a datetime**: the offer moment is already audit-truth as the hired transition's `decided_at` (D-102), so `start_date` answers "when did/will employment begin" (the CIRR question); timezone-free by construction (a calendar date has no tz — the cohort month D-107 keys on it); **future dates allowed** (hired today, starts next month — the normal case) and past dates allowed (retroactive recording of a hire that began last week); REQUIRED in the hire request body alongside `role_title` (employer-supplied at transition time, REQ-11-002's "hired creates the PlacementRow + role_title + start_date payload"); `role_title` is free text (defaults to nothing — never silently the posting title; the employer names the actual role). | A datetime would fabricate precision no employer supplies (nobody's start is "2026-11-03T14:22:51Z"); date-only matches every CIRR-adjacent reporting convention (monthly cohort tables); making both fields explicit-request-body requirements keeps the placement record employer-attested (the A-304 doctrine: the record carries what the principal actually asserted, never an inferred copy). | 0.85 |
|
||||
| D-104 | Learner routes live in **`api/marketplace.py` (the existing apply surface) with the D-043 composition unchanged — G-5 allowlist → verified 18+ → `_check_apply_rate`**: `POST /v1/marketplace/applications/{application_id}/withdraw` (body `{learner_id}` — 403 when the application is not the caller's, 409 when terminal, 422-free) and `GET /v1/marketplace/applications/{application_id}?learner_id=` returning `{application, events[], placement|null}` (current status + full timestamped ledger history + the placement record when hired — REQ-11-005's outcome visibility + placement confirmation in one read); the withdraw rides the EXISTING apply-rate window (marketplace.py:66-76 — 5/rolling-60s per learner, no new throttle knob, D-088's auth-only throttle scope untouched); the routes compose the placements store via `app.state` DI (the territory rule, deps.py) for the history/placement sections and call the SAME `transition_application` store method (D-100) with `actor_type="learner"`. Learner-initiated edits go NO further: only `withdrawn` (REQ-11-005's "no learner-initiated edits… the withdrawn transition is the exception"). | D-097's binding left the learner gates untouched and pinned them to learner surfaces — the marketplace module IS the learner application surface (apply at marketplace.py:345-382 with this exact gate stack); the G-5 allowlist stays the pilot guard on every learner-gated route (A-306); riding the existing limiter avoids a new knob and its G-313 conftest-reset trap is already solved in tests/marketplace (conftest.py:123-131, mirrored into tests/placements per D-111). | 0.85 |
|
||||
| D-105 | Employer transition routes = **per-target action routes under `/v1/employers/applications`** — `GET /v1/employers/applications?status=` (own pipeline), `POST .../{application_id}/in-review`, `.../interview`, `.../offer`, `.../reject`, `.../hire` (hire body `{role_title, start_date}`) — mirroring the posting lifecycle idiom (`/postings/{id}/publish`, `/postings/{id}/close`, employers.py:632-715); gate order (G-311): session 401 (`require_employer_session`, employers.py:173-191) → claim 403 (`_require_claimed`, employers.py:467-484) → cross-entity 403 (fetch application → posting → `posting.employer_id != claimed` → 403, the `_own_posting_or_404` shape at employers.py:487-503 applied through the application's posting) → transition 409 (never a 409 before a 403); every route rides the same `transition_application` method with `actor_type="employer", actor_ref="account:{id}"` (the D-091 checkout-metadata provenance shape) and appends an `employer_audit_events` row (the audit trail stays one queryable lane, D-089). **G-319 scoping (binding):** the v0.10 counts-only PII boundary was scoped to the own-postings LIST surface; v0.11 supersedes it for application-scoped reads/transitions — `learner_id` becomes visible ONLY for applications on the session's own claimed-entity postings (cross-entity → 403, no enumeration); the own-postings list's `applicant_count` stays counts-only. | A hiring pipeline without applicant identity is unworkable (REQ-11-002/003 inherently require it; A-408's "founder contacts employers out-of-band" was the no-employer-principal era's workaround, ended by v0.10); per-target routes keep the matrix OUT of request bodies (each route's store call names its target — one less validation surface, the publish/close precedent); G-319's intent was anti-enumeration/anti-cross-entity, and the 403-on-foreign-application arm preserves exactly that. | 0.80 |
|
||||
| D-106 | Admin oversight = **`api/placements_admin.py`, prefix `/v1/placements/admin`, `require_admin` IMPORTED from marketplace_admin (the billing_admin.py:27 import precedent, employers_admin.py:47) — one generic remediation route `POST /v1/placements/admin/applications/{application_id}/transition` `{to, role_title?, start_date?}`** (validated against the same D-099 matrix; role_title/start_date required iff `to=hired`; `actor_type="operator"` with `OPERATOR = "operator"` route-level provenance, employers_admin.py:54 — no per-operator accounts exist, D-094), **plus `POST .../placements/{placement_id}/revoke`** (D-102's operator-only correction, one transition) and the reporting/export reads (D-107/D-108): `GET .../overview`, `GET .../export`, `GET .../export/summary`; no employer-session gate, no learner gate (a third principal class — D-097); token namespaces cannot collide (D-090); the operator can drive ANY matrix-legal transition including `withdrawn` on a learner's behalf (remediation with provenance — the D-083 manual-grant lane). | REQ-11-002 names "admin routes retain operator oversight (admin transition + audit, the D-094 lane)" — one generic route (vs the employer surface's per-target idiom) is the operator-tooling shape: fewer routes to audit, and the matrix validation lives once in the store (D-100); the export prefix matches REQ-11-004's own `/v1/placements/admin/export`; fail-closed on unset token is inherited free from require_admin (G-11 boot warning already covers it, main.py:251-256). | 0.85 |
|
||||
| D-107 | Reporting semantics = **counts computed from rows at query time (never denormalized counters — G-302), with the guiding-outcome metric = DISTINCT LEARNERS with ≥1 `active` placement, all-time cumulative toward 1,000**: a learner placed twice counts ONCE ("≥1,000 learners placed" — the PROJECT.md guiding outcome is learners, not placements); `revoked` placements are excluded from every count (D-102's correction semantics); **cohort month = the calendar month of `start_date` (`YYYY-MM`)** — not application month, not hired month (CIRR cohorts on employment start); `GET /v1/placements/admin/overview` returns `{total_active_placements, distinct_learners_placed, guiding_goal: 1000, by_employer: [{employer_id, count}], by_cohort_month: [{month, count}]}`; per-employer view = the claimed entity's `hired` applications with role/start-date (REQ-11-003's employer-dashboard placements section); joins run through the existing stores (employer identity via `employer_accounts.employer_id`/EmployerRow, posting via postings, learner via application rows — REQ-11-003's own "counts join through existing stores"); batched reads use the `count_applications_by_posting` grouped-read discipline (store.py:915-932) — never per-row N+1. | The guiding outcome's noun is "learners" (PROJECT.md line 7) — counting placements would inflate the north star; date-only cohort keys make month buckets deterministic (D-103); counting from rows keeps revoke honest without counter-maintenance writes (the G-306 lazy-evaluation lesson: derived state computed at read, never a timer or a stored tally). | 0.85 |
|
||||
| D-108 | Export format = **`GET /v1/placements/admin/export` returns one self-contained JSON document**: `{schema_version, generated_at, summary: {distinct_learners_placed, total_active_placements, revoked_count, by_cohort_month}, cohort: [placement records with employer/posting/application refs + role_title + start_date], evidence_bundles: [per-placement {posting_record, application_record, transition_history[], decision_provenance} — each resolved from SOURCE ROWS at export time (G-302)], methodology: {placement_definition, counting_rules, verification, scope}, ledger: {events: [{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at, prev_hash, hash}], head_hash}}`; **`GET /v1/placements/admin/export/summary`** returns the human-readable rendering (text/markdown: totals table, per-cohort table, methodology prose, head hash, chain-walk verification instructions); scope is honest pilot-bounded (every placement in the DB, unbounded by date — REQ-11-004's own scoping); **re-export determinism: the integrity test asserts identical `ledger.events` chains + identical `head_hash` across two exports, with `generated_at` excluded from comparison** (the chain is a function of stored rows; generated_at is the export's own timestamp, not ledger content); the summary is generated from the same JSON (one truth source — the summary can never drift from the export). | REQ-11-004 names all four dataset components ((a) cohort snapshot, (b) evidence bundles, (c) methodology, (d) chain+head) — this is exactly that shape with names an auditor can consume; "resolved from source rows, never denormalized copies" means snapshot-at-export (the export EMBEDS resolved source content — that's what self-contained means) while the live rows stay the only write path; methodology-as-structured-JSON keeps it versioned and machine-checkable while the summary route renders the prose an auditor reads. | 0.80 |
|
||||
| D-109 | Web conversion scope = **four surfaces, no new client module**: (1) new `/employer/applications` page (the hiring pipeline: own applications with status badges + per-target transition CTAs — honest pending/empty/403-claim states, the A-406 board-client discipline); (2) the employer overview page gains a real placements section (hired applications with role/start-date, honest empty state) — its existing applicant-pipeline/talent-match mock metrics stay mock-labeled (D-092: never fake-convert, never leave a convertible surface on mock); (3) the job-detail client (the existing apply surface, job-detail-client.tsx) gains an application-status panel: current status + timestamped history + placement confirmation when hired, via D-104's learner read (401/403/409 discrimination through existing `EngineError.status` checks — job-detail-client.tsx:103-105 precedent, NO new error classes); (4) the admin moderation page's EmployerNetworkPanel area gains a placements block (aggregate counts + per-employer lists via D-107 overview, adminToken() transport per G-308) + an export link (summary opens in-tab; JSON export downloads). **The talent profile's placement badge stays absent** — talent profile pages remain v0.1 mock (D-092 binding), and a badge on a mock page is A-304 dishonesty; a REAL badge renders only on the learner's own application surface (D-104's placement section) where the data is real. | REQ-11-003 names the employer dashboard + moderation-page surfaces; REQ-11-005 names the learner application surface (the job detail page IS that surface today — apply + confirmation live there, job-detail-client.tsx:50/253); D-092's conversion doctrine (real where a real backend exists, mock where none does) maps cleanly: placements have a backend, talent profiles do not. | 0.80 |
|
||||
| D-110 | Wire discipline = **`packages/types/placement.ts` (ApplicationStatus union, Placement, PlacementEvent, EmployerApplicationSummary, transition request/response shapes, PlacementOverview, PlacementExport) dual-schema pinned field-for-field against `ai_service/placements/base.py` pydantic contracts in `apps/web/tests/pins.test.ts`** (the D-093 template — the employer.ts pin block); engine-client.ts gains a **placements section** (no new client module — D-092's binding: one client module keeps parseError discrimination in one place): employer list/transition/hire functions, learner application-status/withdraw, admin overview/export — every fetch AbortController-equipped (A-406), 401 → SessionRequiredError reuse (engine-client.ts:126-131), no new error classes. | Every prior cross-boundary wire (PostingSummary D-084, employer.ts D-093) rides exactly this rail from day one; the placement surface is a new cross-boundary contract and the pins make drift structurally detectable (G-108 discipline); the 403 cross-entity arm needs no client-side class — components render the honest "not your application" state from EngineError.status. | 0.85 |
|
||||
| D-111 | Testing doctrine = **`tests/placements/` mirroring D-096: real flows against create_app + TestClient, tmp_path DBs, zero network by construction, no MockTransport (no external calls exist), mypy green with no new overrides (stdlib only — D-086)**: state-machine 409 matrix arms (every illegal move incl. backward + terminal-source), gate order pins (employer transition: no-token → 401, unclaimed → 403, foreign application → 403 — never a 409 first; learner withdraw: unallowlisted → 403, unverified → 403, foreign application → 403, terminal → 409), hired E2E (transition → PlacementRow created → ledger events chained → employer dashboard placement visible), revoke + re-hire (partial-unique arm: second active placement on the same application → IntegrityError surfaced), **hash-chain tamper tests** (direct row mutation via a second engine → verification walk fails; fork attempt → UNIQUE(prev_hash) surfaces), **export determinism** (two exports → identical chains + head, generated_at excluded), learner visibility (history + placement confirmation), reporting counts (distinct-learner rule: one learner, two placements → counts once). `tests/placements/conftest.py` carries the G-313-shape autouse reset for the marketplace apply-rate limiter module state (the withdraw route rides it — tests/marketplace/conftest.py:123-131 pattern) — the suite cannot self-429. | The billing/employers suites are the proven template (behavior over the real app, real scrypt, honest assertions); the tamper and determinism tests are THIS milestone's reason to exist — an audit ledger nobody has tried to break is theater; the limiter reset is the G-13 lesson applied before it ships (v0.10 caught it only after flaky archaeology). | 0.85 |
|
||||
| D-112 | Migration/compat = **9th D-027 store, tables `placement` + `placement_event` on the shared DB file, `SQLModel.metadata.create_all` idempotent — zero migration** (the D-095 shape verbatim): `SQLitePlacementStore` mirrors employers/store.py mechanics exactly (WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas at connect, `sa_type` portable columns, `_validate`/`_normalize` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware↔naive normalization, `_constraint_error` helper); bare `placement` is FREE (D-095's prefix rule was collision-driven — marketplace owns `posting`/`employer`/`application`, no `placement` table exists); the marketplace `application` table gains NO new columns (status is already a plain str column, store.py:211 — the D-099 vocabulary extends the validated set, existing `submitted` rows stay valid); lifespan wiring = the getattr-guard template (main.py:195-200) + `close()` in the finally block (main.py:336); routers mount in main.py beside the employers block (main.py:400-411). No new dependencies (hash chain is stdlib `hashlib`/`json`; export is pydantic + `JSONResponse`). | Seven prior stores never broke an existing DB via this exact pattern (D-095's 0.90 confidence carries forward); create_all checkfirst adds the two tables to every existing deployment at next boot; the zero-dep claim stays intact (D-086/R-66) — sha256 + canonical JSON need nothing the venv lacks. | 0.90 |
|
||||
|
||||
## Milestone v0.10 — B2B Employer Network (COMPLETE, Feature, 2026-09-30, shipped as v0.9.6)
|
||||
|
||||
**Scope (D-085, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-09-30):** Real employer auth/accounts and employer self-serve — the milestone every prior decision sequences next: A-407 ("Real employer auth lands post-payments with the B2B employer network" — payments shipped v0.9), A-401 (employer self-serve posting management deferred pending employer auth), D-076/D-079 (operator-mediated checkout + contact-path CTAs were honest *because* no employer principals exist). Employers become principals: registered accounts with sessions, claiming their existing employer entity (the v0.6 `EmployerRow` ingested data), self-serve posting management (draft → published → closed on their own entities), self-serve checkout (the v0.9 billing catalog with employer-principal buyers instead of operator mediation), and the employer dashboard surface converting from the v0.1 mockup to real data. Rationale: the guiding outcome (≥1,000 placements) needs a self-serve employer side; the supply side (v0.6), monetization rails (v0.9), and install-anywhere distribution (v0.8) are all in place — the employer network is the missing B2B surface that unlocks CIRR tracking (REQ-F-020 needs employer-side placement data) and closes the last mock-only surface. Confidence 0.80 ≥ threshold 0.60 (D-007).
|
||||
|
||||
**Out of scope:** CIRR placement tracking (REQ-F-020, deferred), human tutor marketplace (REQ-F-019, deferred), multi-tenant SSO/OIDC enterprise login (email+password at pilot), employer team/multi-seat management, posting analytics beyond the existing enriched views.
|
||||
|
||||
**Success (milestone-level):** an employer can register an account, claim/verify their employer entity, log in, manage their own postings through the full lifecycle, purchase posting packages/talent-access plans through self-serve checkout with entitlement enforcement applying to their own posts, and see real data in the employer dashboard; admin routes retain operator oversight (impersonation-free, audit-logged); all auth is session-based with hashed credentials, rate-limited, and fail-closed on unconfigured secrets; the mock-first doctrine holds (no external auth provider, tests never call a cloud).
|
||||
|
||||
## Clarify decisions (D-086+, CLARIFY stage full autonomy, 2026-09-30)
|
||||
|
||||
| ID | Decision | Rationale | Confidence |
|
||||
|----|----------|-----------|------------|
|
||||
| D-086 | Password KDF = **stdlib `hashlib.scrypt`, zero new dependencies**: per-password 16-byte random salt (`secrets.token_bytes`), OWASP-floor params N=2^14/r=8/p=1, dklen=32, stored self-describing as `scrypt$16384$8$1$<salt_b64>$<hash_b64>` so params can evolve without invalidating stored rows; verification compares derived bytes via `hmac.compare_digest`; params are module constants pinned by test (the D-083 PAST_DUE_GRACE_DAYS shape). | passlib is unmaintained and ships no type stubs — adding it would force the FIRST `ignore_missing_imports` mypy override, breaking the v0.7 pyproject regime ("currently NONE" — pyproject.toml:143-151); bcrypt adds a compiled dep to the deb/docker/arm64 matrix (D-069) for nothing; scrypt IS a library-grade memory-hard KDF (OpenSSL-backed, probe-verified in the shipped venv) and the tree's crypto precedent is stdlib-only end-to-end (HMAC-SHA256 webhooks D-077, ingest tokens v0.4.7, `secrets` minting). pypdf was "the ONLY new v0.6 dependency" — the zero-dep discipline holds. | 0.80 |
|
||||
| D-087 | Employer sessions = **server-side rows in the new `employer_sessions` table (8th D-027 family), opaque token minted per login, stored hashed**: token = `secrets.token_urlsafe(32)` (CSPRNG — per-boot entropy inherent, the factory.py mint precedent); the row stores only `sha256(token)` and lookups re-hash the presented token, so a DB-file leak never yields usable credentials (the G-108 secrets doctrine extended to storage). TTL = fixed 7 days from login (module constant, test-pinned) evaluated LAZILY at read (G-306 — no timer); logout DELETEs the row + audit event (revoke = lookup miss → 401); multiple concurrent sessions per account (per-device rows) are allowed. The agents/ `InMemorySessionStore` is NOT reused — it is LRU-evicted chat context (D-019), a different concern; principal state is a durable D-027 row like everything else persisted. | REQ-10-001 already fixes "server-side store" (signed tokens/JWTs are off-spec); opaque random tokens need no signing key (server-side lookup is the truth source) and no per-boot secret to manage; hashed-at-rest is the standard session-store discipline at zero cost. | 0.85 |
|
||||
| D-088 | Auth throttling = **in-memory sliding windows in api/ (process-local, the G-5 precedent)**: (a) per-EMAIL failed-login window — default 5 failures / rolling 15 min → 429 (settings knob, the `identity_submits_per_min` shape); (b) a global per-min cap on login+register attempts (the `_check_global_create_rate` deque, api/sandboxes.py:123) → 429, bounding cross-email credential-stuffing volume; successful login clears the email's failure window; 429s carry the honest "wait" detail. Keyed by the SUBMITTED EMAIL, not client IP — behind HAProxy every request's `request.client.host` is the proxy (the tree has zero IP-trust machinery; D-039 single-port). | The identity `_SubmitRateLimiter` (api/identity.py:97-116) and sandbox create-cap are the proven in-tree throttle patterns — same module-state acceptability (D-019: one process, one box); IP-based keys would be fake security on this topology; brute-force economics at pilot are bounded by the two windows without any new infra. | 0.80 |
|
||||
| D-089 | Claim/verify = **claim → pending → admin decision; evidence recorded, never auto-activating at pilot**: `POST /v1/employers/claims {employer_id}` (authenticated) creates an `employer_claims` row status=pending with computed evidence `{account_email_domain, entity_website_domain, match}` (normalized registrable-domain comparison — stored as provenance); `employer_accounts.employer_id` is UNIQUE and set on approval (the REQ's "one account : one entity" UniqueConstraint); one PENDING claim per entity + one APPROVED owner per entity enforced check-first in the store (the `insert_application` precedent); rejection frees the entity for a new claim. Admin approve/reject routes (require_admin) set decided_by/decided_at + append `employer_audit_events` (the billing_events provenance shape). Auto-approval on domain match is DEFERRED: without email verification the match is forgeable, so the admin queue DISPLAYS the signal but the operator decision activates. | The seeded corpus websites are example.com placeholders — no real verification surface exists at pilot; REQ-10-002 explicitly retains "admin routes for claim review/approve/reject (operator oversight)"; an auto-approve on forgeable evidence would be a dishonest gate (the A-304 doctrine — never present unverified as verified). | 0.80 |
|
||||
| D-090 | Session transport = **`Authorization: Bearer <session-token>`** with a `require_employer_session` dependency (api/employers.py — the require_admin extract→verify→return shape): 401 on missing/expired/unknown token (the webhook-HMAC credential precedent), 403 stays the authorization failure (cross-entity access); the dep returns the session + account context (claimed employer_id included). NOT a cookie: D-039 static export has no Next middleware — the client attaches the header explicitly on every fetch (A-002); an explicit header is CSRF-immune by construction (no ambient credential, no SameSite machinery). CORS `allow_headers` gains `"Authorization"` (main.py:341 — the E87-class lesson: the v0.3 PUT preflight omission broke every cross-origin Save; tests/api/test_cors.py extended to pin it) for the two-port dev + D-038 network-mode topologies. Token namespaces cannot collide: admin routes compare against the env token, employer routes hash-lookup sessions — a token of one class fails the other's gate. Operator routes live in a separate `api/employers_admin.py` mounting `require_admin` imported from marketplace_admin (the billing_admin.py:27 import precedent). | Parallel to require_admin's dual-header acceptance but single-header for one principal class; same-origin production fetches skip CORS entirely; the dev topology needs the allow-list entry or every employer preflight dies — caught before it ships this time. | 0.85 |
|
||||
| D-091 | Entitlement reuse = **one shared api-layer helper, two principal gates composing it**: extract the 402 gate from marketplace_admin.create_posting (lines 181-220) into a shared api/ function `consume_posting_entitlement(store, employer_id, upgrade_cta)` raising the canonical 402 dict payload `{reason, posts_used, posts_limit, upgrade_cta}`; the admin route passes the admin-checkout CTA, the employer route the employer-checkout CTA — reason/posts_* identical, `UpgradeRequiredError` contract unchanged (D-080/D-084 wire stability). `consume_posting` itself (G-304 single conditional UPDATE) is untouched — no store change, no billing/→api/ boundary violation (the helper lives in api/ beside both callers, D-027/D-048 territory preserved). Employer checkout `POST /v1/employers/billing/checkout {item_id}` mirrors billing_admin.create_checkout with the buyer = the session's claimed entity (403 + claim-CTA when unclaimed; metadata operator_ref → account provenance) and an employer-scoped mock-complete arm mounted only under provider=mock, completing one's OWN session (D-082 transposed to the employer principal; success/cancel URLs from public_base_url per G-310). | REQ-10-004 demands the exact v0.9 check parameterized, not reimplemented — extraction is the only no-drift shape; the billing webhook/provider/store path is reused verbatim (REQ-10-005 "the v0.9 billing module reused"), only the principal derivation changes. | 0.85 |
|
||||
| D-092 | Web conversion = **employer section in engine-client.ts (no new client module) + sessionStorage `nc-employer-session`**: engine-client.ts is THE fetch module (identity/billing/marketplace sections already live there; pins.test.ts pins its content) — the employer section adds register/login/logout/me, claim, own-postings CRUD/publish/close, own-checkout, own-entitlement + a `SessionRequiredError` (401 → the G-308 honest logged-out state, never a retry loop; logout clears storage + calls the revoke route; every fetch aborts via AbortController — the A-406 board-client discipline). Storage = sessionStorage (the G-308 admin-token wrapper is the only in-tree credential-storage pattern; tab-scoped bounds XSS token exposure vs localStorage's persistent copy; no pilot requirement demands cross-tab persistence). Conversion scope: postings page (real own-postings lifecycle), overview page (real active-postings count + per-posting applicant counts from the applications table; talent-match/placement metrics stay mock-labeled — no backend computes them), employer profile (claimed entity via the existing public GET /v1/marketplace/employers/{id}), new /employer/login + /employer/register pages; **talent search + candidate profile pages stay the v0.1 mock** (no candidate backend exists in any milestone — v0.6 built postings/search over POSTINGS; a talent-access search backend is honestly out of scope, their mock imports remain). | One client module keeps parseError/error-class discipline in one place (a second module would fork the 402/401 discrimination); mock stays only where no real data source exists — the honest-surface rule cuts both ways (never fake-convert, never leave a convertible surface on mock). | 0.78 |
|
||||
| D-093 | Wire types = **`packages/types/employer.ts`** (EmployerAccount, EmployerClaim, EmployerPublicProfile reuse where shapes coincide — PostingSummary is reused from marketplace.ts, never duplicated, and the entitlement view reuses the v0.9 shapes) dual-schema pinned field-for-field against `ai_service/employers/base.py`'s pydantic contracts in `apps/web/tests/pins.test.ts` (the G-108 discipline; the billing.ts pin block, pins.test.ts:202-230, is the template — every field asserted both sides). | Every prior cross-boundary wire (PostingSummary, SearchResult, Package/Plan) is pinned this way (D-084); the employer surface is a new cross-boundary contract and gets the same rail from day one. | 0.85 |
|
||||
| D-094 | Admin network overview = **the admin marketplace moderation page gains the network sections** (app/(admin)/admin/moderation/page.tsx — where the v0.9 EmployerEntitlementPanel already landed, REQ-9-004's operator lane): employer accounts list (email, claimed entity, entitlement state columns), claim queue (evidence + domain-match signal, approve/reject actions), fed by new operator routes in api/employers_admin.py (require_admin + audit events): GET /v1/employers/admin/accounts, GET /v1/employers/admin/claims, POST .../claims/{id}/approve, POST .../claims/{id}/reject, GET .../audit — web transport via the existing `adminToken()` G-308 sessionStorage wrapper + honest 403 locked states. | REQ-10-005 names "the operator oversight lane"; the moderation page is the established admin marketplace surface (employer claims are the marketplace-moderation concern — REQ-025's verification queue was its v0.1 mock ancestor); one admin surface keeps the operator's one-glance workflow instead of fragmenting across pages. | 0.80 |
|
||||
| D-095 | Migration/compat = **8th D-027 store on the shared DB file, `SQLModel.metadata.create_all` idempotent — zero migration**: `SQLiteEmployerStore` mirrors billing/store.py mechanics exactly (WAL / synchronous=NORMAL / busy_timeout / foreign_keys pragmas at connect, sa_type portable columns, `_validate`/`_normalize` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware↔naive normalization); `create_all` checkfirst semantics add the 4 tables (`employer_accounts`, `employer_sessions`, `employer_claims`, `employer_audit_events`) to existing DBs at next boot — the billing store shipped the same way against the marketplace-era file (proven compat). Lifespan wiring: the state-injection override (main.py `if getattr(app.state, "employer_store", None) is None: construct from settings.db_path`) + `close()` in the finally block (the billing_store wiring, main.py:182-190/325). The `employer_` prefix is mandatory — the bare `employer` table name is taken by marketplace's EmployerRow (store.py:145). | The 7 prior stores never broke an existing DB via this exact pattern (D-027 family replication is the most-repeated store contract in the tree); REQ-10-001's table names already carry the prefix. | 0.90 |
|
||||
| D-096 | Testing doctrine = **real flows against create_app + TestClient, tmp_path DBs, real KDF, zero network by construction**: `tests/employers/` (the billing suite layout): register/login/logout/me, throttle 429 windows (both knobs), claim → admin approve → own-posting lifecycle → cross-entity 403s, entitlement 402 arms via direct store grants (the test_entitlement_enforcement.py:60 precedent), employer checkout + employer mock-complete E2E through the REAL webhook path (the D-082 proof shape). Password hashing runs the REAL scrypt in tests (N=2^14 ≈ tens of ms per hash — honest, never a fake-hash path; tests never assert hash bytes, only accept/reject behavior; salts random per hash — determinism is at the behavior level, the mock-provider verdict lesson). No MockTransport needed — auth has no external calls; the conftest zero-network guard stays satisfied; mypy green with no new overrides (stdlib only). | The suite's contract is behavior over the real app (billing/billing_admin/entitlement tests are the template); a reduced-KDF test profile would fork the code under test — the exact drift the honest-surface rule forbids. | 0.85 |
|
||||
| D-097 | Gates = **no learner gates on employer routes; email verification + self-serve password reset deferred (honest out-of-scope)**: employer routes compose ONLY the employer session gate — no `require_verified_age`, no G-5 learner allowlist (employers are a third principal class, not learners; D-043's composition on the learner routes — apply/resume/sandbox — is untouched and keeps allowlist → verified 18+ → rate caps). Registration stores email UNVERIFIED (displayed as such; that is why claim evidence is advisory per D-089); no email/notification infra exists (the A-408 out-of-band precedent) so email verification and self-serve password reset are deferred and documented honestly in the surface copy; admin retains remediation authority via the operator routes + `employer_audit_events` trail. | Employer accounts are B2B adults buying posts, not gated minors — the 18+/16+ gates exist to protect learners on learner surfaces (D-043); inventing an email-verification path without any mail transport would be security theater (A-304: never present unverified as verified). | 0.85 |
|
||||
|
||||
---
|
||||
|
||||
## Milestone v0.9 — Payment Engine (COMPLETE, Feature, 2026-09-29 → 2026-09-30, shipped as v0.8.6)
|
||||
|
||||
**Scope (D-074, auto-decided at SPECIFY under full autonomy D-007; founder scope question left open post-v0.8):** Activates **REQ-F-018 — payment processing and subscription management** as REQ-9-001..005. The pricing page (REQ-017, critical since v0.1) gains its real backend: posting packages (single, bundle, enterprise), talent-access plans for employers, and subscription tiers — with checkout, provider webhooks, entitlement enforcement on posting creation, and a subscription lifecycle (renew/cancel/upgrade/downgrade) under a provider-agnostic protocol (D-014 mock-first pattern). Rationale: the guiding outcome (≥1,000 placements via the Nextcraft marketplace) requires a monetized, self-serve employer side; the marketplace engine (v0.6) built the supply side, ship-anywhere (v0.8) made the stack installable anywhere — payments is the only high-priority requirement deferred since v0.3 that directly unblocks employer self-serve (A-407: "real employer auth lands post-payments with the B2B employer network") and CIRR-style placement tracking needs paid postings to track. Confidence 0.80 ≥ threshold 0.60 (D-007).
|
||||
|
||||
**Out of scope (landed in v0.10):** real employer auth/accounts (B2B employer network), multi-currency, tax/VAT handling beyond passthrough, invoice PDF generation, Stripe-specific UI components, CIRR tracking (REQ-F-020, deferred).
|
||||
|
||||
**Success (milestone-level):** an employer (via admin-mediated flow at pilot) can purchase a posting package or talent-access subscription through a real Stripe-compatible checkout; webhooks update entitlements automatically; posting creation enforces package limits honestly; subscription lifecycle transitions (renew/cancel/upgrade) work end-to-end; the pricing page reflects real catalog data; all provider integrations are mock-first (tests never call Stripe). — ACHIEVED, all 5 REQ complete, shipped as v0.8.6 (release with full asset matrix + registry image per D-064).
|
||||
|
||||
## Clarify decisions (D-075+, CLARIFY stage full autonomy, 2026-09-29)
|
||||
|
||||
| ID | Decision | Rationale | Confidence |
|
||||
|----|----------|-----------|------------|
|
||||
| D-075 | Both providers ship in-milestone: `mock` (deterministic, default) and `stripe` (real Stripe-compatible REST via the shared httpx pool — checkout session create/retrieve, subscription cancel/upgrade, event signature verify). Tests exercise the stripe provider ONLY through `httpx.MockTransport` (the openai-audio/embedding precedent — byte-contract pinned, cloud never called); `AI_PAYMENT_PROVIDER=stripe` is env-gated and needs `AI_PAYMENT_API_KEY` — unset key falls back to mock with a loud G-11 boot log (voice/embedding factory pattern, `billing/factory.py` + main.py lifespan catch). | The D-030 openai-audio precedent: a real provider shipped alongside mock with MockTransport byte-contract tests. A protocol without a real implementation is unproven; a real implementation without mock is untestable. Pilot runs mock; the real path is config-away. | 0.80 |
|
||||
| D-076 | Buyer model at pilot = **operator-mediated** (extends A-401): checkout sessions are created via admin-token-gated routes (`/v1/billing/admin/checkout`) on behalf of an employer ref in the request body; no employer principals exist (A-407). Every purchase records `operator_ref` (admin token identity = "operator") + `employer_id`. Webhook route is public (server-to-server) but HMAC-verified (D-077). | Employers are data, not principals (A-407); the admin token dep (`require_admin` returning Settings) is the only operator identity mechanism in the codebase. Self-serve checkout requires employer auth — explicitly post-payments scope. | 0.85 |
|
||||
| D-077 | Webhook signature = **HMAC-SHA256 over `"{timestamp}.{raw_body}"`** (Stripe's own scheme shape), verified with `hmac.compare_digest`; secret from `AI_WEBHOOK_SECRET` env, **unset → per-boot random** (the `telemetry_ingest_secret` pattern) — meaning webhooks fail closed when unconfigured, which is correct for a payment surface. Missing/bad signature → 401, event_id dedupe via UniqueConstraint + check-first (the `insert_application` at-least-once pattern). Raw body read via `await request.body()` (the resume route is the nearest raw-request precedent). | The v0.4.7 ingest-token pattern (per-boot secret, compare_digest, length-prefixed binding) is the proven in-tree HMAC precedent; Stripe's timestamp.body scheme is the industry shape and tolerates clock skew via a replay window (±5 min, pinned by test). | 0.80 |
|
||||
| D-078 | Catalog = **seeded DB tables, not a code constant**: `packages` (one-time: single $49/1 post/30d, bundle $399/10 posts/90d each, enterprise) + `plans` (recurring: starter $99/mo, pro $299/mo, enterprise-talent custom) seeded idempotently at store init from a versioned code constant (the SeededCorpusSource `seed_corpus.json` pattern). Prices = integer cents, currency `usd` fixed. Purchases **snapshot** price/name at buy time (catalog edits never rewrite history). | The pricing page and admin checkout must read ONE source (data-driven tier copy, REQ-9-002); DB tables allow future admin price edits without code changes while snapshots keep financial history immutable. Code-constant-only would make REQ-9-002's "no hardcoded tier copy drifting from backend" harder to prove. | 0.80 |
|
||||
| D-079 | Pricing page = **server shell retained** (static-export metadata stays — A-411) + new client component fetching `/v1/billing/catalog` (the A-406 board-client pattern: AbortController, honest loading/error/empty states). CTA buttons at pilot = **contact path** (mailto + "operator-mediated checkout" copy) — NOT self-serve purchase (no employer auth, D-076). The real checkout trigger lives in the admin surface (operator buys on behalf of an employer). | Self-serve CTAs without employer auth would be a dishonest button; the admin-mediated purchase is the honest pilot flow. The pricing page's job in v0.9 is truth: real catalog data, real states. | 0.80 |
|
||||
| D-080 | Entitlement model: `purchases` table (one-time packages: `posts_limit`/`posts_used`/`expires_at` snapshot) + `subscriptions` (recurring plans). Posting creation consumes 1 post from the **newest non-expired purchase with remaining posts**, else 402 with upgrade-CTA dict payload (the `require_verified_age` dict-detail precedent); enterprise = manual unlimited grant (admin route, provenance-recorded). Store tables (7th D-027 family): `packages`, `plans`, `purchases`, `subscriptions`, `subscription_events`, `checkout_sessions`, `billing_events` — `purchases` + `billing_events` added to the REQ-9-001 list (one-time packages + full audit trail need their own tables; a one-time purchase is NOT a degenerate subscription). | Mixing one-time packs into subscriptions overloads the state machine (REQ-9-005's states don't apply to a 10-post bundle); the identity/store.py + marketplace/store.py replication pattern makes an 7-table store cheap. | 0.75 |
|
||||
| D-081 | Money = **integer cents** end-to-end (pydantic `int` with ge=0, TS `number` documented as cents); no floats anywhere in billing; currency `usd` only (multi-currency out of scope). | Float money is the classic billing bug; Stripe itself is cents-native. The dual-schema pins (pins.test.ts) enforce the int wire type both sides. | 0.90 |
|
||||
| D-082 | Mock checkout completion = **a mock-provider-only confirm route** (`POST /v1/billing/mock/complete`, mounted ONLY when provider=mock): it mints a properly-signed webhook event and POSTs it through the REAL webhook processing path (fetch to self or direct handler call — same code path, proven by test). This is the E2E payment proof without cloud. The route 404s when provider=stripe. | The pipeline must be provable end-to-end in tests (webhook → dedupe → entitlement); a confirm route mounted only in mock mode is the same shape as the sitemap's env-gated routes and can never be hit on a real deployment (factory mounts it conditionally). | 0.70 |
|
||||
| D-083 | Subscription lifecycle: states `trialing → active → past_due → cancelled|expired` (+ `upgraded`), transitions driven ONLY by webhook events or admin manual actions. **Event names are OUR canonical names, normalized at the provider boundary** (R-43: Stripe has no `subscription.renewed`/`checkout.completed`): `checkout.completed` ← `checkout.session.completed`; `invoice.paid` ← `invoice.paid` (also accept `invoice.payment_succeeded`); `subscription.cancelled` ← `customer.subscription.deleted`; `subscription.renewed` ← `invoice.paid` on a renewal invoice (corroborated by `customer.subscription.updated`); provider event ids stored + deduped verbatim (never normalized). Completion predicate `status=complete AND payment_status=paid` (R-37). `past_due` grace = 7 days (module constant, test-pinned) before expiry downgrade; renewals extend `expires_at` idempotently (event_id dedupe makes double-fire safe); manual grant/revoke records `source: "manual"` + operator provenance in `billing_events`. Invalid transitions → `InvalidTransition` → 409 (store precedent). | At-least-once webhooks + idempotent state transitions is the A-309 seq-lease lesson applied to billing; manual remediation must be audit-visible (REQ-9-005's "recorded as manual events with provenance"). Research R-41..R-45 verified the state/event mapping against Stripe's actual enums. | 0.80 |
|
||||
| D-084 | Wire discipline: billing types in `packages/types/billing.ts` + pydantic contracts in `ai_service/billing/base.py`, dual-schema pinned field-for-field in `apps/web/tests/pins.test.ts` (G-108 discipline); `engine-client.ts` gains `listBillingCatalog()` + a `PaymentRequiredError` (402, dict-detail discrimination — the existing `parseError` VerifyRequiredError precedent). | Every prior cross-boundary wire (PostingSummary, SearchResult, facets) is pinned this way; the 402 CTA payload must be machine-readable for the admin surface. | 0.85 |
|
||||
|
||||
## Milestone v0.8 — Ship Anywhere (COMPLETE, Feature, 2026-09-27 → 2026-09-29)
|
||||
|
||||
**Scope (founder directive 2026-09-26, options 1–4 confirmed + scope decisions answered 2026-09-26/27):** Release and deployment guides for admins. nextcraft must be installable on any Debian-based Linux server, via every mainstream channel, with release consistency enforced by automation:
|
||||
|
||||
@@ -27,6 +104,8 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
**Founder scope decisions (2026-09-26/27):** deb + arm64 both added (packaging breadth); docker container unprivileged-by-default with honest sandbox degrade (userns conflict documented, privileged variant documented); Gitea act_runner stood up by this milestone on founder-provided LXC CT 113; one milestone, 6 phases (P0 + P1–P5 + P6 final). Three org-level runners on CT 113 as docker containers (gitea/act_runner, sibling-container mode, host docker.sock, hybrid labels ubuntu-latest + node/python/release).
|
||||
|
||||
**D-073 (founder directive, 2026-09-29, post-milestone):** CT 113 root password + the org runner registration token are **RETAINED for future use — rotation declined**. This supersedes the G-206 rotation request carried in the v0.7.6 ship notes. Both are stored in the sanctioned secrets file (`.ciagent/.env.secrets`: gitignored, 0600, the only copies — verified by tree-wide scan). The live-host deploy key (`nc_ci_deploy` → root@10.0.0.105) is likewise persistent per founder approval. Security posture: the credentials never appear in git history, workflows, container layers, or release assets; access remains SSH-key-gated on both hosts.
|
||||
|
||||
**Out of scope:** Windows/macOS targets, apt repository hosting (debs ship as release assets), Helm/K8s deployment, Postgres migration, browser E2E tooling.
|
||||
|
||||
**Success (milestone-level):** `docker run` / `.deb install` / one-liner binary each take a fresh Debian server to a running verified stack; every release carries binary + checksum + deb + image tag automatically; a push to any branch runs lint/typecheck/tests on the runner fleet; admins have a single deploy guide covering all channels with upgrade + troubleshooting paths.
|
||||
@@ -280,6 +359,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
|
||||
|
||||
| ID | Decision | Rationale | Outcome |
|
||||
|----|----------|-----------|---------|
|
||||
| D-085 | v0.10 = B2B Employer Network (auto-decided at SPECIFY, full autonomy D-007; founder scope confirmed 2026-09-30) | Real employer auth/accounts + self-serve: registered employer accounts with sessions, employer-entity claim/verify, self-serve posting management (draft→published→closed on own entities), self-serve checkout (v0.9 catalog with employer-principal buyers, superseding D-076 operator mediation), employer dashboard real-data conversion. Rationale: A-407 sequences employer auth post-payments (payments shipped v0.9); A-401/D-076/D-079 deferred self-serve precisely pending this; the employer dashboard is the last mock-only surface; CIRR tracking (REQ-F-020) needs employer-side data. Confidence 0.80 ≥ threshold 0.60 (D-007). | Milestone scope locked at SPECIFY; tags on v0.9.x line; feature milestone; branch milestone/v0.10-b2b-employer-network |
|
||||
| D-001 | Milestone v0.1 = UI/UX prototype only, no business logic | Founder directive: validate UX before building backend. Prototype must be agreed upon before proceeding to business logic. | Scope locked to frontend surfaces with mock data |
|
||||
| D-002 | MAJOR 0 until MVP released | Founder directive: remain on v0.x until MVP is validated. Signals pre-release status. | All tags are v0.x.y until MVP agreement |
|
||||
| D-003 | TypeScript monorepo (pnpm/turborepo) + Next.js | Unified codebase for all 4 surfaces. Shared component library, types, mock data. Next.js App Router for route-based surface separation. Python AI services deferred to later milestones. | Monorepo structure with apps/web + packages/* |
|
||||
@@ -296,6 +376,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
|
||||
| D-014 | Provider-agnostic LLM layer; ollama-cloud as initial provider | OpenAI-compatible client abstraction with pluggable providers: ollama-cloud (https://ollama.com/v1, default), local OpenAI-compatible endpoint, deterministic mock (tests/CI). Keys in gitignored .ciagent/.env.secrets, never in code or commits. | apps/ai-service llm package with 3 providers; default=ollama-cloud |
|
||||
| D-015 | All six agents implemented as real LLM services; engines mocked | Coach/Tutor/Mentor fully real. Lab/Assessor/Proctor are real LLM logic over mock inputs (simulated telemetry, pre-baked artifacts) since sandbox fabric, assessment engine, and identity verification are v0.3+. Consistent with v0.1's mock-data approach. | REQ-F-001..006 complete in v0.2; real engines deferred to v0.3+ |
|
||||
| D-016 | v0.4 = Distribution & Bootstrap CLI (founder directive supersedes previously-named v0.4 seams) | Founder directive 2026-09-12: focus this milestone on streamlining install, a bootstrap CLI with a one-liner install script, ongoing release binaries. Real server STT/TTS, KYC, design/simulation envs, seq-lease move to v0.5. | Milestone scope locked at SPECIFY; binary = CLI-only, linux x64 |
|
||||
| D-074 | v0.9 = Payment Engine (auto-decided at SPECIFY, full autonomy; founder scope question unanswered post-v0.8) | Activates REQ-F-018 as REQ-9-001..005: posting packages (single/bundle/enterprise), talent-access plans, subscription tiers — checkout, provider webhooks, entitlement enforcement on posting creation, subscription lifecycle. Provider-agnostic protocol, mock-first (D-014 pattern). Rationale: ≥1,000 placements requires a monetized self-serve employer side; payments is the only high-priority REQ deferred since v0.3 that unblocks employer self-serve (A-407) and paid postings precede CIRR tracking. Confidence 0.80 ≥ threshold 0.60 (D-007). | Milestone scope locked at SPECIFY; tags on v0.8.x line; feature milestone; branch milestone/v0.9-payment-engine |
|
||||
| D-047 | v0.6 = Marketplace Engine (auto-decided at SPECIFY, full autonomy; founder scope question left unanswered 2026-09-21) | Activates REQ-F-011..016 as REQ-6-001..006: real marketplace backend (aggregation, tagging, vector search, enrichment, resume parsing, SEO pages). Rationale: the guiding outcome (≥1,000 placements via the marketplace) runs through the only surface with zero backend; every prior milestone fed the credential side, placement side is the bottleneck. Confidence 0.80 ≥ threshold 0.60 (D-007). External 3M+ job corpus scaled to pilot (adapter contract preserves the full-scale path). | Milestone scope locked at SPECIFY; tags on v0.5.x line; feature milestone |
|
||||
| D-039 | Single-port deploy + unattended dev (v0.3.6 hotfix, founder directive) | Only :8420 reachable behind HAProxy; site was down (domain root hit the API 404). Web app = static export served same-origin by the ai-service (`AI_WEB_STATIC_DIR`); `dev -d`/`stop`/`log` daemonize ops; durable state (DB, sandboxes, pid/log) moves to `~/.nextcraft/` out of the repo. | One port serves UI+API; unattended deploy recipe in deploy/README.md |
|
||||
|
||||
|
||||
+120
-4
@@ -1,6 +1,112 @@
|
||||
# Nextcraft — REQUIREMENTS.md
|
||||
|
||||
## v0.8 Requirements (ACTIVE — Ship Anywhere, Feature)
|
||||
## v0.11 Requirements (Complete — Placement Tracking and Audit, Feature; shipped as v0.10.6)
|
||||
|
||||
**Milestone type: Feature** — CIRR-style placement tracking and audit (REQ-F-020 activated per D-098). Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`. REQ-F-020 activated as REQ-11-001..005: the placement store + evidence model (REQ-11-001), the employer-side application state machine with audit-logged transitions (REQ-11-002), the placement reporting surfaces (REQ-11-003), the audit-grade export (REQ-11-004), learner-side placement visibility (REQ-11-005). Mock-first doctrine holds (no external systems, no third-party auditor integration — the export is the deliverable); tests never call a cloud.
|
||||
|
||||
### Placement Store + Evidence Model
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-001 | Placement store + evidence model: `ai_service/placements/` module (D-031 extend pattern — 9th D-027 store family, shared DB file, `create_all` idempotent): `PlacementRow` (id, learner_id, employer_id, posting_id, application_id, role_title, start_date, status, created_at, decided_at) with partial UNIQUE on `application_id` WHERE status='active' — one ACTIVE placement per application, revoked rows stay in history (D-102, the G-316 conditional-uniqueness shape); `PlacementEventRow` (append-only ledger: application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload, prev_hash, hash, created_at) — one global hash chain, sha256 over canonical JSON + prev_hash, `UNIQUE(prev_hash)` fork backstop (D-101), tamper-evident by append-only discipline + hash chain (the chain makes silent rewrites detectable, G-301-class integrity); evidence links resolved from existing rows only (posting ref from postings store, application ref from applications store, employer ref from the claimed entity — G-302 doctrine: never trust denormalized copies); SQLModel typed access, Protocol + SQLite impl pair (the employer-store pattern), tz-normalization, fail-closed on unknown inputs | critical | 1 | complete (v0.10.1) |
|
||||
|
||||
### Application State Machine
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-002 | Employer-side application state machine: the v0.6 `ApplicationRow.status` extends from `submitted`-only to the hiring lifecycle (submitted → in_review → interview → offer → hired / rejected / withdrawn — forward-only with skip-forward, terminals have no outgoing edges, D-099; withdrawn learner- or operator-initiated, all others employer-principal-driven); transitions via per-target employer routes under `/v1/employers/applications` (`/{id}/in-review`, `/{id}/interview`, `/{id}/offer`, `/{id}/reject`, `/{id}/hire` with `{role_title, start_date}` — D-105) gated by the employer session + claimed-entity authorization (the posting's employer must be the session's claimed entity — 403 otherwise, G-311 gate order); every transition appends the placement ledger in the SAME transaction as the status write (application events + placement events on hired — D-100); rejected/withdrawn/hired are terminal with different semantics (hired creates the PlacementRow + role_title + start_date payload); admin routes retain operator oversight (admin transition + audit, the D-094 lane); learner read access stays the existing surface (their own application status history); G-319 scoping: learner identifiers become visible on employer surfaces only for applications on the session's claimed-entity postings (the own-postings list stays counts-only) | critical | 2 | complete (v0.10.2) |
|
||||
|
||||
### Placement Reporting
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-003 | Placement reporting: aggregate placement counts computed from the ledger (per employer, per cohort month, cumulative toward the ≥1,000 guiding outcome); per-employer placement view (the employer dashboard gains a placements section — their hired applications with role/start date, honest empty states); per-learner view (the learner's application list gains outcome states); admin placement overview (the moderation page's network section gains aggregate counts + per-employer placement lists — the operator lane); counts join through existing stores (employer identity via claimed entities, learner via applications, posting via postings) | high | 3 | complete (v0.10.3) |
|
||||
|
||||
### Audit Export
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-004 | CIRR-style audit export: admin-gated export routes (`GET /v1/placements/admin/export` + `GET /v1/placements/admin/export/summary`, text/markdown — D-108) producing the third-party-auditor dataset: (a) a cohort snapshot (all placements with employer refs, role titles, start dates, evidence chains); (b) per-placement evidence bundles (posting record, application record, state-transition history, decision provenance — each link resolved from source rows, never denormalized copies); (c) a methodology document (how a placement is defined, counted, and verified — the counting rules an auditor needs); (d) the event-ledger chain with head hash (tamper-evidence for the export itself); output as JSON + a human-readable summary; honest scoping (pilot scale: the export covers every placement in the DB, unbounded by date); export integrity test (re-export reproduces identical event chains + head hash, generated_at excluded — D-108) | critical | 4 | complete (v0.10.4) |
|
||||
|
||||
### Learner Placement Visibility
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-005 | Learner-side placement visibility: the learner application surface gains outcome visibility via `GET /v1/marketplace/applications/{application_id}?learner_id=` (current status + full timestamped state history + placement record when hired — D-104) and `POST /v1/marketplace/applications/{application_id}/withdraw` (learner-gated per the D-043 composition: G-5 allowlist → verified 18+ → apply-rate window); placement confirmation (when hired, the learner sees the placement record — role title, employer, start date — sourced from the same ledger); no learner-initiated edits beyond withdrawn (status changes are employer/admin-driven only); the talent profile's placement badge stays absent until a placement exists (honest, the A-304 doctrine — no badge on the v0.1 mock pages) | medium | 5 | complete (v0.10.5) |
|
||||
|
||||
---
|
||||
|
||||
## v0.10 Requirements (Complete — B2B Employer Network, Feature; shipped as v0.9.6)
|
||||
|
||||
**Milestone type: Feature** — real employer auth/accounts + employer self-serve (the B2B employer network; activated per D-085). Shipped on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`. All 5 requirements complete: the employer auth foundation with scrypt credentials + hashed sessions + throttles (REQ-10-001), the entity claim/verify flow with advisory evidence + admin approval (REQ-10-002), the dashboard real-data conversion with honest states (REQ-10-003), self-serve postings through the shared D-091 entitlement gate byte-identical with the admin path (REQ-10-004), self-serve checkout over the v0.9 catalog through the real webhook path + the admin network overview (REQ-10-005). Auth is session-based with hashed credentials (mock-first doctrine: no external auth provider, tests never call a cloud); admin operator routes are retained unchanged (D-043 composition untouched).
|
||||
|
||||
### Employer Auth Foundation
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-10-001 | Employer auth foundation: `ai_service/employers/` module (D-031 extend pattern): employer account store (8th D-027 family — `employer_accounts`, `employer_sessions`, `employer_claims`, `employer_audit_events` tables, D-095) with SQLModel typed access; password hashing via stdlib `hashlib.scrypt` (D-086 — zero new deps, per-password random salt, self-describing params); session tokens as server-side store rows with the token stored hashed, fixed 7-day TTL evaluated lazily, revoke-on-logout (D-087); register/login/logout/me routes (`/v1/employers/auth/*`, Bearer session transport per D-090); rate limiting on auth routes (D-088 — per-email failed-login window + global attempt cap, the G-5 in-memory pattern); credentials + secrets env-only (D-014); fail-closed when unconfigured | critical | 1 | complete |
|
||||
|
||||
### Employer Entity Claim
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-10-002 | Employer-entity claim/verify: an authenticated employer account claims an existing `EmployerRow` entity (the v0.6 ingested data) via a claim route; claims enter a pending state with recorded evidence (email-domain vs entity-website comparison — advisory signal, D-089) and activate via admin approval at pilot; claims recorded with provenance (`employer_claims`); one account : one entity (UniqueConstraint); admin routes for claim review/approve/reject (operator oversight retained, audit-logged per D-089); claimed-entity authorization: employers can only manage postings/purchases for their own entity | critical | 2 | complete |
|
||||
|
||||
### Employer Account Surface
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-10-003 | Employer dashboard real-data conversion: the employer surface switches from v0.1 mock imports to runtime client fetches (A-406 pattern — AbortController, honest loading/error/empty states) via the engine-client employer section + sessionStorage session state (D-092); employer auth UI (login/register pages, session-gated dashboard shell, honest logged-out states); posting management reads the employer's own postings via the authenticated surface (draft → published → closed lifecycle visible); employer profile read from the claimed entity (enrichment retained); talent search + candidate profile pages stay the v0.1 mock (no candidate backend exists — honest out-of-scope, D-092); wire types in `packages/types/employer.ts` dual-schema pinned (G-108 discipline, D-093) | critical | 3 | complete (v0.9.3, release 951) |
|
||||
|
||||
### Self-Serve Posting Management
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-10-004 | Self-serve posting management: employer-principal posting routes (`/v1/employers/postings/*`) — create draft, edit own drafts, publish own drafts, close own postings, list own postings with status facets; authorization: the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the admin-gate-first order preserved, G-311) with the 402 CTA unchanged, D-080 consumption model intact, via the shared api-layer helper extracting the v0.9 gate (D-091 — no duplication); posting creation consumes from the employer's own packages (the v0.9 entitlement check parameterized by the session's employer_id instead of request body); the admin posting routes remain (operator path unchanged, provenance-recorded); 18+ gate N/A (employer surface is not learner-facing; the D-043 learner gates untouched, D-097) | high | 4 | complete (v0.9.4, release 952) |
|
||||
|
||||
### Self-Serve Checkout
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-10-005 | Self-serve checkout + admin network overview: employer-principal checkout (`/v1/employers/billing/checkout`) creating provider sessions for the catalog (the v0.9 billing module reused — provider protocol, webhooks, entitlement grants unchanged; buyer = session employer instead of operator mediation, superseding D-076 while admin routes stay as the operator fallback; D-091 composes the existing checkout route logic with the session principal); entitlement view for the employer's own purchases/subscriptions (current package, posts used/limit, expiry — honest states); pricing page CTAs convert from contact-path to employer-login-path (superseding D-079); admin surface gains the network overview on the marketplace moderation page (employer accounts list, claim queue with evidence signals, per-account entity + entitlement state — the operator oversight lane, D-094) | critical | 5 | complete (v0.9.5, release 953) |
|
||||
|
||||
---
|
||||
|
||||
## v0.9 Requirements (Complete — Payment Engine, Feature; shipped as v0.8.6)
|
||||
|
||||
**Milestone type: Feature** — payment processing and subscription management (REQ-F-018 activated per D-074). Shipped on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`. All 5 requirements complete: the billing module + 7th D-027 store + HMAC webhook core (REQ-9-001), the catalog + pricing surface (REQ-9-002), the checkout/webhook flow with per-session exactly-once grants (REQ-9-003), posting entitlement enforcement with honest 402s (REQ-9-004), the full subscription lifecycle + queryable audit log (REQ-9-005). — payment processing and subscription management (REQ-F-018 activated per D-074). Tags on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`. Provider-agnostic protocol, mock-first (D-014 pattern): tests never call Stripe; the real provider drops in by env config without API changes.
|
||||
|
||||
### Billing Foundation
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-001 | Billing foundation: `ai_service/billing/` module (D-031 pattern — extend ai-service, no new apps): `PaymentProvider` protocol (create checkout session, retrieve session, cancel subscription, upgrade/downgrade subscription, list subscription events) + deterministic mock provider (approves-on-policy, synchronous state machine); SQLite billing store (7th D-027 family — `packages`, `plans`, `subscriptions`, `subscription_events`, `checkout_sessions` tables) with SQLModel typed access; webhook signature verification (HMAC, per-boot secret default — the v0.4.7 ingest-token pattern); provider selection via `AI_PAYMENT_PROVIDER=mock\|stripe` env (keys env-only, never committed); test-only mode is the default (mock) so the stack runs with zero keys | critical | 1 | complete |
|
||||
|
||||
### Catalog + Pricing Surface
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-002 | Package catalog + real pricing surface: seeded package catalog (single posting, bundle ×5, enterprise-unlimited tiers + talent-access plans: basic/pro/enterprise per REQ-017's existing pricing-page tiers) exposed via `/v1/billing/catalog`; the pricing page switches from mock imports to runtime client fetch (A-406 pattern) with honest loading/error states; package features/limits data-driven from the catalog (no hardcoded tier copy drifting from backend); build-time static metadata retained (A-411) | critical | 2 | complete |
|
||||
|
||||
### Checkout Flow
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-003 | Checkout + webhook flow: `POST /v1/billing/admin/checkout` (admin token-gated per D-076 — operator buys on an employer's behalf; buyer recorded as operator ref + employer ref) creates a provider checkout session for a catalog item; mock provider renders an in-app confirmation path; stripe provider redirects to Stripe Checkout (env-gated, never in tests); `POST /v1/billing/webhook` receives provider events with HMAC signature verification (reject 401 on bad signature — never process unsigned), idempotent event processing (event_id dedupe + per-session grant idempotency per G-303), entitlement granted on `checkout.completed` with state resolved from local rows only (G-302 — event payloads are untrusted ID references); webhook replay tolerance (at-least-once semantics — the A-309 seq pattern applied to billing) | critical | 3 | complete |
|
||||
|
||||
### Entitlement Enforcement
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-004 | Posting entitlement enforcement: posting creation (admin API route per A-401) checks the employer's active package — posting limit reached → honest 402 with upgrade-CTA payload (pointing at catalog/checkout); unlimited/enterprise bypass; expired/cancelled package degrades to the same 402 path; entitlement check composed after the admin token gate (operator route — D-043 learner gate composition untouched); admin surface shows per-employer entitlement state (active package, posts used/limit, expiry) in the existing admin marketplace moderation view | high | 4 | complete |
|
||||
|
||||
### Subscription Lifecycle
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-005 | Subscription lifecycle + billing admin: subscription states (trialing → active → past_due → cancelled/expired) with provider-driven transitions (webhook events `invoice.paid`, `invoice.payment_failed`, `subscription.cancelled`, `subscription.renewed`); renewals extend expiry idempotently; admin routes for manual grant/revoke/complement (operator remediation for failed webhooks — recorded as manual events with provenance); billing event log with full audit trail (every provider event + manual action stored, queryable); honest 402 + grace handling on payment failure (past_due grace window before downgrade) | high | 5 | complete |
|
||||
|
||||
## v0.8 Requirements (Complete — Ship Anywhere, Feature; shipped as v0.7.6)
|
||||
|
||||
**Milestone type: Feature** — new distribution channels (docker image, deb packages, arm64 binaries) and release automation (Gitea Actions, pre-commit). Tags on the v0.7.x line (P0 → v0.7.0 … P6 → v0.7.6 = milestone release); branch `milestone/v0.8-ship-anywhere`.
|
||||
|
||||
@@ -32,7 +138,7 @@
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-8-005 | Admin release & deployment guides: deploy/README.md expanded into the full admin runbook — three install channels (docker / deb / binary+install.sh) first-install recipes, per-channel upgrade paths (docker pull + recreate / deb reinstall via dpkg -i + deploy --service / install.sh re-run + deploy --service, all idempotent — D-071), HAProxy + systemd recipes preserved, troubleshooting tables extended (runner, registry, docker degrade, deb), every recipe backed by a test or verified live-box evidence (house rule) | critical | 5 | pending |
|
||||
| REQ-8-005 | Admin release & deployment guides: deploy/README.md expanded into the full admin runbook — three install channels (docker / deb / binary+install.sh) first-install recipes, per-channel upgrade paths (docker pull + recreate / deb reinstall via dpkg -i + deploy --service / install.sh re-run + deploy --service, all idempotent — D-071), HAProxy + systemd recipes preserved, troubleshooting tables extended (runner, registry, docker degrade, deb), every recipe backed by a test or verified live-box evidence (house rule) | critical | 5 | complete |
|
||||
|
||||
## v0.7 Requirements (Complete — Test Harness Review, NFR; shipped as v0.6.6)
|
||||
|
||||
@@ -290,9 +396,9 @@
|
||||
| ID | Description | Priority | Milestone | Status |
|
||||
|----|-------------|----------|-----------|--------|
|
||||
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.5 | activated → complete (REQ-5-003/004) |
|
||||
| REQ-F-018 | Payment processing and subscription management | high | v0.3+ | deferred |
|
||||
| REQ-F-018 | Payment processing and subscription management | high | v0.9 | activated → REQ-9-001..005 |
|
||||
| REQ-F-019 | Human tutor marketplace (third-party courses) | medium | v0.4+ | deferred |
|
||||
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | deferred |
|
||||
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | activated → REQ-11-001..005 |
|
||||
|
||||
---
|
||||
|
||||
@@ -312,6 +418,16 @@
|
||||
|
||||
## Traceability Matrix
|
||||
|
||||
### v0.11 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-11-001 | 1 | complete |
|
||||
| REQ-11-002 | 2 | complete |
|
||||
| REQ-11-003 | 3 | complete |
|
||||
| REQ-11-004 | 4 | complete |
|
||||
| REQ-11-005 | 5 | complete |
|
||||
|
||||
### v0.6 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|
||||
+907
-1
@@ -1,4 +1,910 @@
|
||||
# Nextcraft — RESEARCH.md (v0.8 Phase 0 RESEARCH — Ship Anywhere)
|
||||
# Nextcraft — RESEARCH.md (v0.11 Phase 0 RESEARCH — Placement Tracking and Audit)
|
||||
|
||||
## v0.11 research conclusions (Phase 0 RESEARCH, 2026-10-01)
|
||||
|
||||
In-tree census of the placement-tracking surface, 2026-10-01, plus
|
||||
audit-convention findings (CIRR methodology, hash-chained ledgers, ATS
|
||||
stage models, evidence-bundle formats) grounded in audit practice and
|
||||
marked [convention] vs [pilot adaptation] — no web access in this
|
||||
environment; the methodology text is written to stand on its own
|
||||
definitions with CIRR kept as naming, not citation. Binding clarify
|
||||
decisions D-099..D-112 are NOT re-litigated; this research
|
||||
operationalizes them. Probes: marketplace/store.py (ApplicationRow +
|
||||
insert_application + transition machines), employers/store.py (the
|
||||
8th store + G-316 partial index + decide_claim transaction), billing/
|
||||
(store.py matrix + event rows + concurrent-test precedent), api/
|
||||
employers.py + marketplace.py + employers_admin.py +
|
||||
marketplace_admin.py (gates + provenance + throttles), main.py
|
||||
(lifespan + routers + CORS + boot warning), engine-client.ts +
|
||||
pins.test.ts + packages/types/ (web rails), tests/employers/ +
|
||||
tests/marketplace/ + tests/billing/ (suite layouts + conftest
|
||||
resets), employer/admin web surfaces (D-109's four surfaces).
|
||||
|
||||
**R-67. CIRR methodology transfers as disclosure discipline; three
|
||||
pilot gaps must be stated in the export methodology (R-67/R-68).**
|
||||
[convention] CIRR-style audits rest on four load-bearing parts: a
|
||||
placement definition (in-field role, FTE, exclusions for
|
||||
self-employment), cohort construction with stated denominators,
|
||||
employer-verified evidence sampled by an independent auditor, and
|
||||
disclosure discipline (methodology published beside the numbers,
|
||||
self-reported vs employer-verified labeled separately). [pilot
|
||||
adaptation] v0.11's employer-principal attestation (authenticated
|
||||
`hire` by a claim-verified employer — employers.py:173-191/:467-484,
|
||||
claim approval with provenance employers/store.py:583-655) is a
|
||||
stronger evidence class than learner self-report but is NOT CIRR-grade
|
||||
employer verification. The methodology JSON must state three gaps
|
||||
honestly: (a) no minimum-employed-duration check (a placement = a
|
||||
recorded hire, not a 90-day sustained role), (b) no salary (D-098),
|
||||
(c) verification is employer-attestation + operator oversight, not
|
||||
third-party employment verification. D-107's distinct-learner counting
|
||||
matches CIRR's placed-graduate semantics for a cumulative north star;
|
||||
CIRR reports cohort RATES over denominators, which v0.11 deliberately
|
||||
does not claim at pilot. Confidence 0.75.
|
||||
|
||||
**R-68. The methodology section writes itself from in-tree facts.**
|
||||
`placement_definition` = a PlacementRow created by the hired
|
||||
transition, employer-attested role_title + start_date, active at
|
||||
export time, one active placement per application (partial unique, the
|
||||
G-316 shape employers/store.py:159-167); `counting_rules` = distinct
|
||||
learners with ≥1 active placement, all-time cumulative, revoked
|
||||
excluded, cohort month = start_date calendar month (D-107);
|
||||
`verification` = each evidence bundle carries the source posting +
|
||||
application records + full transition ledger whose global sha256 chain
|
||||
re-walks from GENESIS_HASH to head_hash (D-101); `scope` =
|
||||
pilot-bounded, every placement in the DB, evidence is
|
||||
employer-principal attestation. The methodology text must name the
|
||||
three R-67 gaps explicitly — an audit methodology that omits its own
|
||||
verification limits is A-304-class dishonesty. Cohort divergence is
|
||||
deliberate and documented: employment-start month, not graduation
|
||||
cohort (no graduation events exist in a marketplace). Confidence 0.85.
|
||||
|
||||
**R-69. D-101's global hash chain sits in the well-trodden
|
||||
blockchain-less tamper-evidence lane (CT, git, rekor).** A global
|
||||
linear chain with one head is the right pilot shape — one walk
|
||||
recomputes everything; Merkle trees buy proof-efficiency nobody needs
|
||||
at thousands of events. The UNIQUE(prev_hash) fork backstop has direct
|
||||
prior-art analog; G-316 proves SQLite unique/partial indexes emit
|
||||
correctly through create_all and surface IntegrityError
|
||||
(employers/store.py:398-400). Canonical-serialization rules: sort_keys
|
||||
+ separators pinned; datetime formatting must be NORMALIZED (R-79);
|
||||
avoid floats in payload entirely (str/int/bool/None only); sha256
|
||||
hexdigest is the D-087 token pattern (employers/store.py:230) —
|
||||
stdlib-only, zero new deps. No backfill: pre-v0.11 submitted rows
|
||||
carry only their created_at (marketplace/store.py:873-901) — honest
|
||||
absence per A-304. Confidence 0.85.
|
||||
|
||||
**R-70. The hash covers the row's own autoincrement id → the append
|
||||
must be flush-then-hash-then-commit inside one transaction, and ALL
|
||||
appends funnel through the one transition_application method (D-100).**
|
||||
Within one Session: read head (ORDER BY id DESC LIMIT 1), construct
|
||||
row with prev_hash=head, session.flush() to obtain the id, compute
|
||||
hash = sha256(canonical_json + prev_hash), set the column, commit.
|
||||
Nearest in-tree template: decide_claim (employers/store.py:600-652 —
|
||||
get → guard → mutate → flush → append audit → commit). Corollaries:
|
||||
(1) a second append path would race on prev_hash (the UNIQUE backstop
|
||||
catches it as a lost event — IntegrityError); (2) the ledger must have
|
||||
NO delete path (INTEGER PRIMARY KEY reuses max+1 if the top row is
|
||||
deleted — D-111's tests pin zero DELETE methods + no DELETE SQL
|
||||
anywhere). Verification cost at pilot: O(n) sha256 over tiny rows —
|
||||
milliseconds for thousands of events; the export embeds every event so
|
||||
the auditor's walk is offline. Confidence 0.80.
|
||||
|
||||
**R-71. ATS prior art confirms forward-only-with-skip-forward; the
|
||||
exclusions are genuinely excludable (D-099).** Real ATSs allow backward
|
||||
moves because they manage scheduling and offers — which D-98 excludes.
|
||||
At pilot, `submitted → hired` fast-track matches how small-scale hiring
|
||||
actually proceeds. Terminals-with-no-outgoing-edges is the tree's own
|
||||
discipline (billing/store.py:84-90, marketplace/store.py:609-632,
|
||||
InvalidTransition → 409 at :289). No interview scheduling (no calendar
|
||||
surface exists — A-408), no offers machinery (no document generation),
|
||||
no email (no mail transport — D-097 precedent). The matrix constant
|
||||
belongs in placements/base.py (CANONICAL_EVENT_NAMES shape,
|
||||
billing/base.py:71-78); the 409-matrix test arms mirror billing's
|
||||
test_legal_transitions/test_illegal_transition_raises/test_cancelled_is_terminal
|
||||
trio (tests/billing/test_store.py:400-418) plus skip-forward arms.
|
||||
Zero data migration: existing rows are all `submitted`
|
||||
(marketplace/store.py:211) — a valid matrix start. Confidence 0.85.
|
||||
|
||||
**R-72. D-100's cross-store single-transaction write is mechanically
|
||||
sound on the shared file.** Every D-027 store builds its own engine on
|
||||
the same db_path with identical pragmas (WAL/synchronous=NORMAL/
|
||||
busy_timeout=5000/foreign_keys=ON — marketplace/store.py:277-286);
|
||||
multiple engines on one file is the SHIPPED topology (8 stores).
|
||||
SQLModel's metadata is global — create_all on any engine creates every
|
||||
registered table (employers/store.py:400), so the placements store
|
||||
writes ApplicationRow through one Session on its own engine without
|
||||
bringing up the marketplace store. Importing across modules is
|
||||
family-sanctioned (employers/base.py:39 imports marketplace.base).
|
||||
decide_claim is the exact all-or-nothing precedent
|
||||
(employers/store.py:634-652). Guardrails: NO cross-family foreign keys
|
||||
(G-302 ID references at read); the store must tolerate being
|
||||
constructed against a fresh file (global metadata + idempotent
|
||||
create_all covers it). The two-commit alternative's crash window (status
|
||||
changed, no ledger row) is precisely the tamper-evidence void the
|
||||
milestone sells. Confidence 0.85.
|
||||
|
||||
**R-73. Export determinism needs three pinned rules (D-108).**
|
||||
(1) Ordering — events by id (chain order); cohort/summary lists by
|
||||
pinned sorts (cohort month ascending; placements by created_at then id
|
||||
— the list_postings discipline, marketplace/store.py:940-948); pydantic
|
||||
declaration-order serialization is deterministic but set-derived lists
|
||||
would not be. (2) generated_at exclusion must be TOTAL — compare
|
||||
parsed documents field-by-field or strip the key before comparison.
|
||||
(3) Float absence — every export value str/int/bool; no ratios or
|
||||
percentages (counts are integers by construction, D-107). The summary
|
||||
generated from the same JSON is the anti-drift rule. Zero new deps
|
||||
(JSONResponse + pydantic in-tree; D-112 holds). Confidence 0.80.
|
||||
|
||||
**R-74. Service seams verified for D-112.** Store lifespan wiring:
|
||||
the getattr-guard template at main.py:182-190 (billing)/195-200
|
||||
(employers) — placements slots in beside employers; close() in the
|
||||
finally block main.py:335-336; router mounting beside the employers
|
||||
block main.py:400-411; learner routes mount from the existing
|
||||
marketplace router (no new mount — D-104), placements_admin mounts
|
||||
beside employers_admin; CORS already carries Authorization
|
||||
(main.py:357 — no change); the G-11 admin boot warning main.py:251-256
|
||||
covers the placements admin free. deps.py gains get_placement_store
|
||||
beside get_employer_store (deps.py:98). create_all checkfirst is the
|
||||
probe-verified D-095 shipped behavior (employers/store.py:398-400).
|
||||
Confidence 0.90.
|
||||
|
||||
**R-75. API gates verified for D-104/D-105/D-106.** require_employer_session
|
||||
(employers.py:173-191, 401 class, returns (session, account));
|
||||
_require_claimed (employers.py:467-484, 403 + claim-CTA);
|
||||
_own_posting_or_404 (employers.py:487-503). The application-scoped
|
||||
cross-entity arm is the same shape through the application's posting:
|
||||
fetch application → posting → employer_id != claimed → 403. G-311
|
||||
gate order is test-pinned (tests/employers/test_postings_write.py:225-
|
||||
234). Per-target route idiom: employers.py:635 (publish)/:687 (close).
|
||||
Provenance: `"account:{account.id}"` (employers.py:792). Learner stack:
|
||||
_enforce_allowlist (marketplace.py:46-52) → require_verified_age(18)
|
||||
(:358) → _check_apply_rate (:359; window :66-76, module state
|
||||
_APPLY_TIMES :58, cap 5/60s :63) — withdraw composes the identical
|
||||
stack, actor_type=learner. Operator lane: require_admin imports from
|
||||
marketplace_admin (employers_admin.py:47; guard marketplace_admin.py:
|
||||
62-85, fail-closed dual-header); OPERATOR="operator"
|
||||
(employers_admin.py:54); the applications-review precedent
|
||||
(marketplace_admin.py:420-443) already exposes learner_id to the
|
||||
operator — unchanged. Token namespaces cannot collide (D-090 —
|
||||
hash-lookup vs env-compare, verified in both guards). Confidence 0.90.
|
||||
|
||||
**R-76. Web rails verified for D-109/D-110.** engine-client.ts
|
||||
sections in order: identity :351, marketplace :545 (applyPosting :647-
|
||||
656), search :659, resume :730, billing :773, entitlements :782, admin
|
||||
transport :843 (adminToken :815), employer auth :918 —
|
||||
SessionRequiredError :126-131, ClaimRequiredError :136, parseError
|
||||
:146 (401/403 discrimination); a placements section appends after
|
||||
employer billing (:1114). pins.test.ts: marketplace :48-133, billing
|
||||
:203-230, employer dual-schema :267-374 (field-for-field + status-union
|
||||
regex pins — the D-110 template), G-319 own-postings block :376+.
|
||||
packages/types/ carries billing/employer/marketplace — placement.ts is
|
||||
the fourth. Four surfaces: employer overview (app/(employer)/employer/
|
||||
page.tsx:25 → employer-overview-client.tsx — real postings/counts
|
||||
:162/:190-230, the mock-labeled placement metrics block :335-372
|
||||
labelled "(mock — no backend computes this)" :351 — the real
|
||||
placements section REPLACES that mock block); /employer/applications
|
||||
page is NEW beside postings/login/register/billing/talent; job-detail-
|
||||
client.tsx is the learner apply surface (apply :95-115, EngineError
|
||||
409/404 discrimination :103-105, confirmation render :253 — the
|
||||
status panel composes the same discipline, no new classes); moderation
|
||||
page (app/(admin)/admin/moderation/page.tsx:3/:35 →
|
||||
employer-network-panel.tsx:56, adminToken/locked-state :28-40) gains
|
||||
the placements block. Talent-profile badge stays absent — talent pages
|
||||
are v0.1 mock imports; a badge on a mock page is fabricated placement
|
||||
(A-304). Confidence 0.90.
|
||||
|
||||
**R-77. Test suites verified for D-111.** tests/employers/ (16 files)
|
||||
is the D-096 layout; tests/marketplace/conftest.py:123-131 is the
|
||||
G-313-shape autouse reset (_reset_apply_rate_window clears _APPLY_TIMES
|
||||
before/after every test — the withdraw route rides this limiter, so
|
||||
tests/placements/conftest.py MUST replicate it or the suite
|
||||
self-429s); tests/employers/conftest.py:31-33 same pattern for the two
|
||||
employer throttles. Hermeticity: tmp_path DBs, state-injected stores
|
||||
via create_app (tests/marketplace/conftest.py:80-120 make_client),
|
||||
zero network via root conftest. Matrix arms template:
|
||||
tests/billing/test_store.py:400-418. Partial-index pin template:
|
||||
tests/employers/test_claims_store.py:75-88 (SELECT from sqlite_master
|
||||
asserting the index SQL — applies to the placement partial unique,
|
||||
D-102). Gate-order pins: tests/employers/test_postings_write.py:315
|
||||
(cross-entity 403), tests/marketplace/test_marketplace_api.py:188
|
||||
(invalid transitions 409). G-319 pin SPLIT needed (D-105): counts-only
|
||||
stays on the own-postings list (test_postings_read.py:216); learner_id
|
||||
appears on application-scoped responses for own-posting applications;
|
||||
cross-entity → 403 with no application data. Confidence 0.85.
|
||||
|
||||
**R-78. SQLite two-store cross-write interplay safe at pilot; two WAL
|
||||
facts to pin.** (1) The marketplace store's own application writes are
|
||||
idempotent inserts (insert_application :873-901) that never touch
|
||||
status after creation — the placements store is the ONLY status writer
|
||||
post-creation; state the COLUMN-level ownership in the plan (no second
|
||||
status writer outside the one-transaction discipline). (2) The tamper
|
||||
test's second engine on the same file sees WAL snapshot isolation —
|
||||
the mutation must commit before the verification walk reads it
|
||||
(Session context-managers guarantee). Multi-session contention on one
|
||||
file is already exercised in-tree (test_concurrent_consumes_one_post_left,
|
||||
tests/billing/test_store.py:335). busy_timeout=5000 is generous for
|
||||
hiring-transition volumes (rare events, not hot-path). Confidence 0.80.
|
||||
|
||||
**R-79. Three clocks, three serialization rules.** (1) start_date is
|
||||
a calendar date — timezone-free, ISO YYYY-MM-DD in SQLite, cohort
|
||||
month = strftime("%Y-%m"); no datetime coercion in its path. (2)
|
||||
created_at/decided_at are UTC datetimes stored NAIVE in SQLite,
|
||||
re-labeled tz-aware on read (_as_utc, marketplace/store.py:263-267 —
|
||||
the family pattern). The hash-serialization pitfall: datetime.isoformat()
|
||||
emits +00:00 for aware, nothing for naive — the chain's canonical JSON
|
||||
must pin ONE normalization (always _as_utc(dt).isoformat(), or a fixed
|
||||
YYYY-MM-DDTHH:MM:SSZ format) and the tamper tests catch drift between
|
||||
the hashing and reading paths. (3) generated_at is export-only
|
||||
metadata, excluded from determinism (D-108 — a function of the request,
|
||||
not the rows). Privacy (G-319 supersession): learner_id is an opaque
|
||||
allowlisted handle (the G-5 roster), not name/email/DOB — identity PII
|
||||
never stored (identity/base.py:25-33), the 422 scrubber's pii_fields
|
||||
(main.py:439) never sees learner_id; exposure bounded to opaque handles
|
||||
on the employer's own applicants, cross-entity 403 preserves
|
||||
anti-enumeration. start_date is not PII-class. Confidence 0.85.
|
||||
|
||||
**R-80. Coherence gap found: re-hire after revoke is UNREACHABLE
|
||||
through the D-099 matrix — PLAN must pin the operator re-hire as a
|
||||
placement-level route.** After the first hire the application is
|
||||
`hired` — terminal, no outgoing edges; D-106 validates the operator's
|
||||
generic route against the same matrix, so `hired → hired` is a 409 on
|
||||
every surface; the revoke itself (active → revoked, D-102) leaves the
|
||||
application hired — correct — but no matrix-legal path creates the
|
||||
fresh placement D-102 promises. Candidate shapes: (a) an explicit
|
||||
operator re-hire route (POST /v1/placements/admin/placements/
|
||||
by-application/{application_id}/re-hire {role_title, start_date})
|
||||
creating the fresh PlacementRow + a `placement.rehired` ledger event
|
||||
(actor operator) WITHOUT touching the application's terminal status —
|
||||
audit-coherent: the ledger shows hired → revoke → rehire as three
|
||||
events, never fabricating a second application transition; (b) matrix
|
||||
amendment hired → hired operator-only — REJECTED (breaks the terminals
|
||||
rule, and "the application was hired twice" is false); (c) revoke +
|
||||
full reapplication — REJECTED (a new application may be impossible on
|
||||
a closed posting, and it orphans the learner from the count — exactly
|
||||
what D-102 exists to prevent). Shape (a) is coherent: placements, not
|
||||
applications, are the correction lane (D-102's own framing); the
|
||||
partial unique index makes it safe (revoked rows don't match WHERE
|
||||
status='active' — the exact G-316 property that makes
|
||||
claim-rejection-free-reclaim work, employers/store.py:159-167). The
|
||||
ledger event vocabulary (D-101) admits this event type; D-111's revoke
|
||||
+ re-hire test arm is unaffected. Confidence 0.80.
|
||||
|
||||
## Open risks (v0.11, tracked into PLAN)
|
||||
|
||||
1. **Re-hire-after-revoke coherence gap (R-80)** — pin the operator
|
||||
re-hire as a placement-level route + `placement.rehired` ledger
|
||||
event, never an application transition. Changes route list, event
|
||||
vocabulary, and a test arm.
|
||||
2. **Hash-after-flush write shape (R-70)** — flush-then-hash-then-commit
|
||||
inside one transaction; ALL appends through the one
|
||||
transition_application method; no-delete-path pin.
|
||||
3. **created_at canonical serialization (R-79/R-69)** — pin ONE
|
||||
normalization (_as_utc(dt).isoformat() or fixed Z-format) or the
|
||||
chain breaks on its first re-read.
|
||||
4. **_APPLY_TIMES limiter reset (R-77)** — tests/placements/conftest.py
|
||||
must replicate the autouse reset or the suite self-429s (G-313).
|
||||
5. **Methodology honesty block (R-67/R-68)** — the three pilot gaps
|
||||
stated explicitly in the export methodology.
|
||||
6. **Export determinism rules (R-73)** — pinned list orderings, total
|
||||
generated_at exclusion, float absence.
|
||||
7. **Status-column ownership (R-78)** — placements store is the only
|
||||
post-creation writer of application.status; state it column-level.
|
||||
8. **G-319 supersession pin split (R-77)** — counts-only stays on the
|
||||
own-postings list; learner_id on application-scoped routes only.
|
||||
9. **Concurrent-transition arm (R-78)** — one-winner-one-IntegrityError
|
||||
on truly concurrent transition_application calls (the billing
|
||||
concurrent-consumption test shape).
|
||||
10. **CIRR specifics from knowledge, not live sources (R-67)** — the
|
||||
methodology text stands on its own definitions; CIRR is naming,
|
||||
not citation.
|
||||
|
||||
---
|
||||
|
||||
# Nextcraft — RESEARCH.md (v0.10 Phase 0 RESEARCH — B2B Employer Network)
|
||||
|
||||
## v0.10 research conclusions (Phase 0 RESEARCH, 2026-09-30)
|
||||
|
||||
In-tree census of the employer-auth/self-serve surface, 2026-09-30.
|
||||
Every conclusion carries its evidence (file+line probes, live venv
|
||||
probes). Binding clarify decisions D-086..D-097 are NOT re-litigated;
|
||||
this research operationalizes them (the milestone is almost entirely
|
||||
in-tree precedent — 10 milestones of patterns; the one external fact,
|
||||
scrypt availability/cost in the shipped runtime, was probe-verified).
|
||||
Probes: apps/ai-service/.venv python 3.11.2 (hashlib.scrypt + timing +
|
||||
maxmem); api/marketplace_admin.py (require_admin + create_posting
|
||||
gate), billing/store.py + identity/store.py + marketplace/store.py
|
||||
(the D-027 family), billing_admin.py + billing.py + base.py + mock.py
|
||||
+ factory.py (checkout + webhook paths), main.py (lifespan + routers +
|
||||
CORS), config.py + .env.example (knob shapes), api/identity.py +
|
||||
api/sandboxes.py + api/marketplace.py (throttle precedents),
|
||||
tests/conftest.py + tests/billing/ + tests/marketplace/conftest.py
|
||||
(suite layout), seed_corpus.json (claim-evidence reality),
|
||||
apps/web/lib/engine-client.ts + employer/admin pages + pins.test.ts
|
||||
(web conversion surface).
|
||||
|
||||
**R-55. scrypt in the shipped runtime: available, ~50 ms/hash at
|
||||
D-086 params, default maxmem sufficient — pin nothing below 32 MiB.**
|
||||
Probe against apps/ai-service/.venv (Python 3.11.2, requires-python
|
||||
">=3.11", pyproject.toml:9): `hashlib.scrypt` is a built-in
|
||||
(`<built-in function scrypt>`, OpenSSL-backed). Cost at N=2^14/r=8/p=1/
|
||||
dklen=32: best-of-5 = **47.3 ms per hash** (single ~59 ms) on this box
|
||||
— register and login each perform exactly ONE derivation, inside
|
||||
D-096's "tens of ms, honest" budget; a full register→login→claim test
|
||||
flow stays far under the 60s pytest timeout with the REAL KDF (tests
|
||||
assert accept/reject behavior only, never hash bytes). Memory:
|
||||
128·N·r = **16 MiB** working set; CPython's default maxmem (32 MiB)
|
||||
admits these params with **no maxmem argument** (probe: default OK;
|
||||
explicit `maxmem=32*1024*1024` OK; `maxmem=1024*1024` raises
|
||||
`ValueError: ... memory limit exceeded`). Record for D-086
|
||||
implementation: do NOT pass a maxmem below ~17 MiB; if pinned for
|
||||
explicitness use 33554432. Storage format per D-086:
|
||||
`scrypt$16384$8$1$<salt_b64>$<hash_b64>` (16-byte
|
||||
`secrets.token_bytes` salt, self-describing so params evolve without
|
||||
invalidating rows), verify via `hmac.compare_digest` over derived
|
||||
bytes; params are module constants pinned by test (the
|
||||
`PAST_DUE_GRACE_DAYS` shape, billing/store.py:58-61).
|
||||
|
||||
**R-56. The require_employer_session dep mirrors require_admin's
|
||||
extract→verify→return shape; 401 = credential, 403 = authorization.**
|
||||
`_extract_admin_token` (api/marketplace_admin.py:46-58) is a
|
||||
dual-header extractor — `X-Admin-Token`
|
||||
(`Annotated[str | None, Header(include_in_schema=False)]`) wins, else
|
||||
`Authorization: Bearer <token>` via `partition(" ")` +
|
||||
`scheme.strip().lower() == "bearer"`; None when neither carries a
|
||||
candidate. `require_admin` (61-85) is a FastAPI dependency composed
|
||||
`Depends(_extract_admin_token)` + `Depends(get_settings)` that RETURNS
|
||||
`Settings`; 403 "admin token required" when the configured token is
|
||||
unset OR no candidate (fail-closed, D-057), 403 "invalid admin token"
|
||||
on `secrets.compare_digest` mismatch — compared on UTF-8 encodings
|
||||
(`surrogatepass` on the candidate) so any bytes compare honestly,
|
||||
constant-time, without the TypeError 500 (final-review F-1). No
|
||||
HTTPBearer anywhere — plain Header params are the house pattern. The
|
||||
employer dep (D-090) mirrors with: single `Authorization: Bearer`
|
||||
extract (one principal class), sha256(token) hash-lookup against
|
||||
`employer_sessions` → **401** on missing/expired/unknown (credential
|
||||
failure; TTL evaluated lazily at read — G-306, no timer), **403
|
||||
reserved for cross-entity authorization** (valid session touching
|
||||
another employer's posting), returning session + account + claimed
|
||||
employer_id. Token namespaces cannot collide: admin routes compare
|
||||
against the env token, employer routes hash-lookup — one class fails
|
||||
the other's gate. Second-router import precedent:
|
||||
`from .marketplace_admin import require_admin`
|
||||
(api/billing_admin.py:27). **Security finding:** the app-wide 422
|
||||
scrubber (main.py:398-424) redacts `err["input"]` only for
|
||||
`pii_fields = frozenset({"date_of_birth"})` (line 401) — a pydantic
|
||||
422 on an employer register body would ECHO the raw password into the
|
||||
response/client logs; the employers milestone must add `"password"`
|
||||
(and `"email"`) to the scrub set (open risk 3, security-auditor
|
||||
territory).
|
||||
|
||||
**R-57. The 8th-store recipe is fully specified by the 7th (D-095):
|
||||
billing/store.py mechanics + the `employer_` prefix is MANDATORY.**
|
||||
Replication checklist, all probe-verified: SQLModel table classes in
|
||||
`store.py` with `__tablename__` (bare `employer` is TAKEN by
|
||||
marketplace's EmployerRow, marketplace/store.py:144 — every v0.10
|
||||
table carries the `employer_` prefix); `sa_type=String/JSON`
|
||||
portable columns (bare Literal annotations crash sqlmodel inference —
|
||||
identity/store.py:48-51); `_validate()`/`_normalize()` hooks +
|
||||
module `_as_utc()` tz re-labeling (billing/store.py:362-366);
|
||||
`_sqlite_connect` pragmas WAL / synchronous=NORMAL / busy_timeout=5000
|
||||
/ foreign_keys=ON (369-376); Protocol + SQLite impl pair
|
||||
(`EmployerStore` / `SQLiteEmployerStore`); `__init__` builds the
|
||||
engine (`check_same_thread=False`) + calls `create_schema()`
|
||||
(549-556); `create_schema` = `SQLModel.metadata.create_all`
|
||||
(558-560) — idempotent checkfirst semantics add the 4 tables to
|
||||
existing DBs at next boot (the proven billing-store compat, zero
|
||||
migration); IntegrityError SURFACED never swallowed
|
||||
(identity/store.py:152; billing/store.py:802-804); domain errors as
|
||||
module Exception classes beside the Protocol (billing/store.py:
|
||||
379-388). Unique/check-first patterns to copy: per-key UniqueConstraint
|
||||
+ check-first insert (`insert_application` returns (row, created) —
|
||||
marketplace/store.py:341-347, impl ~883); rowid tie-break ordering
|
||||
(identity/store.py:174-183). 4-table schema (D-087/D-089):
|
||||
`employer_accounts` (id PK `acc-`-style, email UNIQUE + indexed,
|
||||
password_hash str, employer_id UNIQUE nullable-until-claim-approval,
|
||||
created_at); `employer_sessions` (id, token_hash UNIQUE — stores ONLY
|
||||
`sha256(token)`, never the token, account_id indexed, created_at,
|
||||
expires_at = login + SESSION_TTL_DAYS module constant);
|
||||
`employer_claims` (id, account_id, employer_id indexed, status
|
||||
pending|approved|rejected, evidence JSON `{account_email_domain,
|
||||
entity_website_domain, match}`, created_at, decided_by, decided_at);
|
||||
`employer_audit_events` (auto id PK, account_id/employer_id nullable
|
||||
indexed, kind/name, detail JSON, operator, created_at — mirrors
|
||||
BillingEventRow, billing/store.py:337-359). No `service.py` anywhere
|
||||
in-tree — routes compose stores via api/ DI; the KDF lives in a small
|
||||
`passwords.py` (the billing/webhooks.py crypto-core precedent), the
|
||||
claim-evidence helper in base.py or store.py.
|
||||
|
||||
**R-58. The D-091 extraction source: gate AFTER auth, BEFORE insert;
|
||||
the 402 dict hardcodes posts_used=0/posts_limit=0 — preserve exactly.**
|
||||
`create_posting` (api/marketplace_admin.py:157-251) order: `require_admin`
|
||||
dep (162) → billing-store presence guard (183-192:
|
||||
`getattr(request.app.state, "billing_store", None)`; None → **503**
|
||||
"billing entitlement store is unavailable…" — the fail-closed
|
||||
final-review fix, never an ungated 201) → `consume_posting()`
|
||||
(194) → two 402 arms (195-220) → posting insert (221-243). **Gate runs
|
||||
before insert — a 402 consumes nothing, creates nothing.** Canonical
|
||||
402 dict (both arms): `{reason, posts_used, posts_limit, upgrade_cta}`
|
||||
with reason ∈ {`no_active_package`, `posting_limit_reached`} and
|
||||
**posts_used/posts_limit hardcoded 0 in BOTH arms** (lines 200-201,
|
||||
213-214) — the extraction preserves this byte-shape (the wire
|
||||
contract `UpgradeRequiredError`, engine-client.ts:93-101, consumes
|
||||
only reason + upgrade_cta; D-080/D-084 stability). `consume_posting`
|
||||
itself (billing/store.py:968-1027 — G-304 single conditional UPDATE,
|
||||
rowcount-checked; NoActivePackage on zero rows, PackageExhausted when
|
||||
all full/expired) is UNTOUCHED. The D-091 helper
|
||||
`consume_posting_entitlement(store, employer_id, upgrade_cta)` lives
|
||||
in **api/** beside both callers (no billing/→api/ boundary violation),
|
||||
raising `HTTPException(402, detail=<same dict>)`; admin route passes
|
||||
the admin-checkout CTA, employer route the employer-checkout CTA.
|
||||
Employer-arm composition mirrors "admin gate still first"
|
||||
(tests/billing/test_entitlement_enforcement.py:161-170): session gate
|
||||
(401/403) BEFORE any billing decision; same 503 store-missing guard.
|
||||
|
||||
**R-59. Employer checkout mirrors billing_admin.create_checkout
|
||||
verbatim — only principal derivation + metadata provenance change.**
|
||||
Source route (api/billing_admin.py:77-146): `require_admin` →
|
||||
`store.get_catalog_item(item_id)` → **404 "unknown catalog item"** →
|
||||
mode = subscription iff kind==plan → success/cancel URLs from
|
||||
`settings.public_base_url` (G-310:
|
||||
`{origin}/marketplace/pricing?checkout=success|cancelled`; empty under
|
||||
mock — stripe factory validation rejects empty at boot) →
|
||||
`insert_checkout_session` FIRST with unique placeholder
|
||||
`provider_session_id=f"pending-{uuid4().hex[:16]}"` (final-review fix:
|
||||
bare "pending" collided on the UNIQUE constraint when two checkouts
|
||||
raced) → `CheckoutSessionSpec` (billing/base.py:137-164: mode, item_id,
|
||||
price_cents + item_name D-078 snapshot, interval, success_url,
|
||||
cancel_url, `client_reference_id` = LOCAL row id ≤200 chars,
|
||||
`metadata={"employer_id": …, "operator_ref": "operator"}`) →
|
||||
`provider.create_checkout_session(spec)` via
|
||||
`request.app.state.payment_provider` → `bind_provider_session_id` →
|
||||
`CheckoutResponse{session_id, provider_session_id, url, mode}`.
|
||||
Employer arm (D-091): `POST /v1/employers/billing/checkout {item_id}`
|
||||
— session-gated; unclaimed account → **403 + claim-CTA**; buyer = the
|
||||
session's CLAIMED entity; metadata employer_id = claimed entity,
|
||||
operator_ref → the account's provenance (D-076's "operator" becomes
|
||||
account provenance); everything else verbatim. Mock-complete transpose
|
||||
(D-082/G-301): `POST /v1/billing/admin/mock/complete`
|
||||
(billing_admin.py:152-227) — admin-gated + `isinstance
|
||||
MockPaymentProvider` defense-in-depth 404; session resolved from
|
||||
LOCAL rows by provider_session_id (G-302 — payloads are untrusted ID
|
||||
references); `confirm_completion` ValueError → recover via
|
||||
`retrieve_session` (404 unknown-after-restart / 409 not completable /
|
||||
already-complete fall-through); `provider.mint_event(
|
||||
"checkout.completed", {session_id, payment_status})` →
|
||||
`process_webhook_event(store=…, provider=…, raw_body=…,
|
||||
signature_header=…)` (api/billing.py:58-120) — the REAL webhook path,
|
||||
direct handler call. The employer arm mounts ONLY under provider=mock
|
||||
(factory-conditional, the billing precedent), is
|
||||
employer-SESSION-gated, and completes one's OWN session (cross-session
|
||||
completion = the G-301 hole class; open risk 5). Webhook + grant
|
||||
reuse unchanged: `/v1/billing/webhook` (billing.py:329-344) +
|
||||
`_grant_entitlement` (billing.py:249-326; G-303 per-session
|
||||
exactly-once via `purchases.checkout_session_id` UNIQUE) serve
|
||||
employer checkouts verbatim — only session-row provenance differs.
|
||||
Provider factory (billing/factory.py:43-71) untouched.
|
||||
|
||||
**R-60. main.py wiring sites (all four) + the knob shapes.**
|
||||
(a) **Lifespan store block**: the billing template at main.py:182-190
|
||||
(`if getattr(app.state, "billing_store", None) is None: from
|
||||
.billing.store import SQLiteBillingStore; app.state.billing_store =
|
||||
SQLiteBillingStore(db_path=settings.db_path)`) — the `employer_store`
|
||||
wiring lands directly after (~line 191) with the same guard; its
|
||||
`close()` lands in the finally block beside
|
||||
`app.state.billing_store.close()` (line 325). (b) **Router mounting**:
|
||||
feature routers mount with inline imports + comments (identity/
|
||||
marketplace at 366-372, billing at 378-382, sitemap 387-389) —
|
||||
api/__init__.py exports only the v0.2-era routers, so `api/employers.py`
|
||||
+ `api/employers_admin.py` follow the inline pattern beside the
|
||||
billing block. (c) **CORS**: `allow_headers=["Content-Type"]`
|
||||
(main.py:341) gains `"Authorization"` (D-090) for the two-port dev +
|
||||
D-038 network-mode topologies (same-origin production fetches skip
|
||||
CORS); E87-class lesson — v0.3 shipped allow_methods without PUT and
|
||||
every cross-origin Save died preflight — so tests/api/test_cors.py
|
||||
(the policy pin, 82 lines) gains an Authorization preflight test beside
|
||||
the existing `Access-Control-Request-Headers: "Content-Type"` pin
|
||||
(line 79). (d) **Settings knobs**: plain ints with explanatory
|
||||
comments are the shape (`identity_submits_per_min: int = 3`
|
||||
config.py:157; `sandbox_creates_per_min: int = 10` config.py:66) — the
|
||||
D-088 throttle knobs land as Settings ints; the session TTL is a
|
||||
MODULE constant, not a setting (the PAST_DUE_GRACE_DAYS shape,
|
||||
pinned by test, no env entry). `.env.example` entries mirror the
|
||||
AI_ADMIN_TOKEN block (lines 78-83, commented default-off).
|
||||
|
||||
**R-61. Web conversion census: what converts, what stays mock, what is
|
||||
greenfield.** Employer pages at apps/web/app/(employer)/employer/:
|
||||
`page.tsx` (overview) imports `candidates, employers` from
|
||||
'@nextcraft/mock-data' (line 10) + inline METRICS/PIPELINE constants
|
||||
(18-74) — converts to a session-gated client shell with real
|
||||
active-postings count + per-posting applicant counts from the
|
||||
applications table; talent-match/placement metrics STAY mock-labeled
|
||||
(D-092 — no backend computes them). `postings/page.tsx` imports `jobs`
|
||||
(line 2) → `<PostingManager jobs={jobs}>` (19); PostingManager
|
||||
(components/employer/posting-manager.tsx) derives mock
|
||||
statuses/applicants (29-37) — converts to own-postings CRUD/publish/
|
||||
close. `talent/page.tsx` + `talent/[candidateId]/page.tsx` import
|
||||
`candidates` — **STAY MOCK** per D-092 (no candidate backend exists in
|
||||
any milestone). engine-client.ts (apps/web/lib/engine-client.ts — NOT
|
||||
src/lib; 787 lines): section order sandboxes(171)/files(204)/
|
||||
variants(255)/grading(281)/identity(295)/defense(348)/telemetry(413)/
|
||||
lab-proctor(427)/marketplace(489)/search(603)/resume(674)/billing(717)/
|
||||
billing-entitlements(726) — the employer section appends beside
|
||||
billing. Error classes: EngineError/EngineBusyError/VerifyRequired
|
||||
Error/NotAllowlistedError/RateLimitedError/UpgradeRequiredError/
|
||||
NotFoundError — **no 401 discrimination exists today** (parseError
|
||||
143-151 falls through to generic EngineError): the new
|
||||
`SessionRequiredError` (D-092: 401 → honest logged-out state, never a
|
||||
retry loop) joins the parseError chain. Credential-storage precedent:
|
||||
`adminToken()/setAdminToken()` (759-767; sessionStorage
|
||||
`'nc-admin-token'`, window-guarded) is the wrapper template for the
|
||||
`'nc-employer-session'` key (logout clears storage + calls revoke;
|
||||
AbortController on every fetch — the A-406 discipline). **No
|
||||
login/register page exists in ANY route group** (admin auth is the
|
||||
G-308 per-component token input, employer-entitlement-panel.tsx:60-72)
|
||||
— /employer/login + /employer/register are greenfield client pages;
|
||||
the entitlement panel's load/locked/error state machine (29-46,
|
||||
75-80) is the closest fetch pattern. Wire types: new
|
||||
packages/types/employer.ts + `export * from './employer'` in
|
||||
index.ts (the billing.ts precedent); **PostingSummary is REUSED from
|
||||
marketplace.ts:99-117, never duplicated**; the entitlement view
|
||||
reuses the v0.9 shapes (engine-client.ts:730-752). Pin templates: the
|
||||
billing.ts pin block (pins.test.ts:205-231) + the fetch-stub test
|
||||
(billing-entitlement.test.ts:34-70) are what employer pins copy
|
||||
(D-093). Pricing CTAs convert from contact-path to employer-login
|
||||
path in pricing-client.tsx (superseding D-079; the "Contact Sales"
|
||||
enterprise pin at pins.test.ts:250 stays).
|
||||
|
||||
**R-62. Admin network overview lands on the moderation page below the
|
||||
v0.9 entitlement panel.** app/(admin)/admin/moderation/page.tsx
|
||||
renders `<ModerationTabs />` (mock queues: employerVerificationQueue/
|
||||
jobReviewQueue/flaggedContent — components/admin/moderation-tabs.tsx:
|
||||
13-17) + the v0.9 section `<EmployerEntitlementPanel
|
||||
employerId="emp-openai">` (page lines 15-23). D-094's network sections
|
||||
land as sibling `<section>` blocks below: employer accounts list
|
||||
(email, claimed entity, entitlement state columns), claim queue
|
||||
(evidence + domain-match signal, approve/reject actions), audit —
|
||||
fed by operator routes in api/employers_admin.py (D-094): GET
|
||||
/v1/employers/admin/accounts, GET /v1/employers/admin/claims, POST
|
||||
/v1/employers/admin/claims/{id}/approve, POST …/claims/{id}/reject,
|
||||
GET /v1/employers/admin/audit — require_admin + employer_audit_events
|
||||
appends (D-089). Data-fetch pattern to copy: the entitlement panel's
|
||||
`load()` (token input → setAdminToken → fetch → **403 renders an
|
||||
honest locked state with the token prompt, never a retry loop** —
|
||||
employer-entitlement-panel.tsx:29-46, 75-80) via the existing
|
||||
`adminToken()` G-308 wrapper.
|
||||
|
||||
**R-63. Both D-088 throttles copy proven in-tree deque/window
|
||||
mechanics.** (a) Per-key window — `_SubmitRateLimiter`
|
||||
(api/identity.py:97-116): module-level `dict[str, list[float]]`,
|
||||
`time.monotonic()` timestamps, `window[:] = [t for t in window if
|
||||
now - t < 60.0]` slice-trim, `len(window) >= per_min` → **429
|
||||
"…rate exceeded — wait a minute"**, append AFTER the check; the
|
||||
module-level singleton `_rate_limiter` (116) is accepted process-local
|
||||
state (D-019: one process, one box). The per-EMAIL failed-login window
|
||||
copies this keyed on the SUBMITTED EMAIL (D-088: never client IP —
|
||||
behind HAProxy `request.client.host` is the proxy; zero IP-trust
|
||||
machinery in-tree), with two deltas: failures-only append (a
|
||||
successful login CLEARS the email's window) and a 15-minute window
|
||||
(900s — identity hardcodes 60.0, so window seconds parameterize; the
|
||||
cap is a Settings knob, the identity_submits_per_min shape). (b)
|
||||
Global cap — `_check_global_create_rate` (api/sandboxes.py:123-137):
|
||||
module-level `deque[float]` (`_CREATE_TIMES`, line 50), while-popleft
|
||||
older-than-60s, `len >= settings.sandbox_creates_per_min` (config.py:
|
||||
66) → **429 "…retry shortly"**, append only after ALL checks pass
|
||||
(a rejected create never consumes budget); the D-088 global
|
||||
login+register cap copies this deque verbatim. Third precedent for
|
||||
dict-of-deques: `_APPLY_TIMES` (api/marketplace.py:58) +
|
||||
`_check_apply_rate` (66-76) with module constant
|
||||
`_APPLY_PER_LEARNER_PER_MIN = 5` (63). Test hygiene: module state
|
||||
leaks across tests — BOTH employer limiters need the reset-fixture
|
||||
pattern (`_reset_apply_rate_window`, tests/marketplace/conftest.py:
|
||||
123-131, `_APPLY_TIMES.clear()`).
|
||||
|
||||
**R-64. tests/employers/ follows the tests/billing/ layout; the
|
||||
employer_store fixture is state-injection.** tests/billing/ is 9 files
|
||||
(test_api/test_base/test_entitlement_enforcement/test_factory/
|
||||
test_lifecycle_admin/test_mock/test_store/test_stripe_provider/
|
||||
test_webhook_route/test_webhooks) with NO billing/conftest.py —
|
||||
fixtures are module-local `make_client` + tmp_path stores. The pattern
|
||||
to copy (tests/billing/test_entitlement_enforcement.py:28-59): store
|
||||
fixture (tmp_path db, yield, close), `make_client(tmp_path, store)`
|
||||
builds `Settings(provider="mock", learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path/"app.db", sandbox_dir=tmp_path/"sandboxes",
|
||||
admin_token=SecretStr(ADMIN_TOKEN))` → `create_app(settings)` →
|
||||
**`app.state.billing_store = store` state-injection** (the G-9
|
||||
override; main.py's getattr guard adopts it — also
|
||||
tests/marketplace/conftest.py:80-120) → `with TestClient(app) as c`.
|
||||
Root conftest.py supplies the session-scoped
|
||||
`_hermetic_default_db_path` env redirect (58-80), the `settings`
|
||||
fixture (169-183), SUITE_LEARNERS + seed_verified_identity (112-166);
|
||||
the cloud-free MockProvider guard (205-213) stays satisfied — auth
|
||||
has no external calls, zero network by construction. tests/employers/
|
||||
adds: an `employer_store` fixture injected via
|
||||
`app.state.employer_store`, throttle-window reset fixtures for both
|
||||
limiters (R-63), REAL-scrypt auth flows (register+login ≈ 2 hashes ≈
|
||||
100 ms/test — honest per D-096, never a fake-hash path; assertions are
|
||||
accept/reject behavior only), claim→admin-approve→own-posting
|
||||
lifecycle + cross-entity 403s, entitlement 402 arms via direct store
|
||||
grants (the `grant_posts` precedent, test_entitlement_enforcement.py:
|
||||
70-85), and the employer checkout E2E through the REAL webhook path
|
||||
(the D-082 proof shape: mint_event → process_webhook_event). mypy
|
||||
checks tests fully (pyproject files incl. "tests", 129-133) —
|
||||
stdlib-only code needs no new overrides.
|
||||
|
||||
**R-65. Claim evidence: every seeded employer website is an
|
||||
example.com placeholder — the match signal is honestly false
|
||||
corpus-wide at pilot; normalization is stdlib suffix-match.**
|
||||
EmployerRow carries `website: str` (marketplace/store.py:158,
|
||||
sa_type=String) + `social_links: dict` (159, JSON); the public GET
|
||||
/v1/marketplace/employers/{id} (api/marketplace.py:313-339) serves raw
|
||||
+ merged-provenance fields. Seed corpus (marketplace/seed_corpus.json):
|
||||
all employers' websites are `https://example.com/<slug>` (lines 13,
|
||||
30, 47, 64, 81, 98, 115, 132, 149, 166, …) — a real registrant's
|
||||
email domain will NOT match any seeded entity's website domain, so
|
||||
`match` is false corpus-wide (D-089's advisory-display premise
|
||||
confirmed: the admin queue DISPLAYS the signal, the operator decides —
|
||||
auto-approval would be a dishonest gate, A-304). Normalization is
|
||||
stdlib-only (zero-dep discipline): `urllib.parse.urlsplit(website
|
||||
).hostname` (lowercases, strips scheme/path —
|
||||
"https://example.com/openai" → "example.com"), email domain after the
|
||||
last "@" lowercased; registrable-domain comparison = a simple SUFFIX
|
||||
MATCH on the hostname (equal, or one endswith "." + other) — no PSL
|
||||
library, no tldextract; a multi-label public suffix over-matches,
|
||||
acceptable for an advisory signal and recorded in the module
|
||||
docstring. Evidence shape per D-089: `{account_email_domain,
|
||||
entity_website_domain, match}` stored as provenance JSON on the claim
|
||||
row. Store enforcement: one PENDING claim per entity + one APPROVED
|
||||
owner per entity, check-first (the insert_application precedent,
|
||||
marketplace/store.py:341-347) + `employer_accounts.employer_id` UNIQUE
|
||||
set on approval (rejection frees the entity for a new claim). Audit:
|
||||
approve/reject set decided_by/decided_at + append
|
||||
employer_audit_events (the BillingEventRow shape, billing/store.py:
|
||||
337-359).
|
||||
|
||||
**R-66. Packaging/wiring: zero new dependencies, zero workspace
|
||||
changes, two .env.example knobs.** scrypt/secrets/hmac/urllib are
|
||||
stdlib — pyproject.toml:10-32 unchanged (requires-python ">=3.11");
|
||||
the mypy zero-override regime holds (pyproject.toml:143-151 "currently
|
||||
NONE" — D-086's passlib rejection stays valid: no new dep, no first
|
||||
ignore_missing_imports). packages/types/employer.ts is a new file in
|
||||
the EXISTING @nextcraft/types package + one index.ts export (the
|
||||
billing.ts precedent); no package.json/pnpm-workspace/turbo changes
|
||||
(no new apps/packages — D-031 extend pattern). .env.example gains the
|
||||
D-088 throttle knobs beside the AI_ADMIN_TOKEN block (lines 78-83
|
||||
pattern: commented, default-off, AI_ prefix per config.py:24); the
|
||||
session TTL is a module constant (NO env entry — pinned by test).
|
||||
Backend tree additions are exactly: ai_service/employers/, the two
|
||||
api/employers*.py routers, api/employer postings/billing routes, the
|
||||
D-091 api/ helper + its adoption in marketplace_admin.create_posting,
|
||||
config.py knobs, main.py wiring (store + routers + CORS header),
|
||||
tests/employers/, .env.example. Web additions: the engine-client
|
||||
employer section, packages/types/employer.ts + pins, employer page
|
||||
conversions + login/register pages, admin overview sections, pricing
|
||||
CTA conversion.
|
||||
|
||||
## Open risks (v0.10, tracked into PLAN)
|
||||
|
||||
1. **scrypt CPU on concurrent auth** (R-55): ~50 ms/hash
|
||||
single-threaded; a login burst stacks CPU. Bounded by both
|
||||
throttle windows; acceptable at pilot — record the budget in the
|
||||
module docstring.
|
||||
2. **CORS Authorization omission kills every cross-origin employer
|
||||
fetch** (E87 class, R-60): the main.py edit + the
|
||||
tests/api/test_cors.py pin MUST land in the same phase as the
|
||||
client section, not after.
|
||||
3. **422 password echo** (R-56): the app-wide scrubber redacts only
|
||||
date_of_birth (main.py:401) — a register 422 echoes the raw
|
||||
password in `err["input"]`. Add "password" (+ "email") to
|
||||
pii_fields in the auth phase; pin by test (security-auditor).
|
||||
4. **sessionStorage token exposure** (D-092 tradeoff): XSS during a
|
||||
live tab can read 'nc-employer-session'; tab-scoped by design,
|
||||
documented in surface copy; logout + revoke deletes the server row
|
||||
(stored hashed — a DB leak yields nothing).
|
||||
5. **Employer mock-complete must complete one's OWN session**
|
||||
(R-59): a provider_session_id belonging to another buyer is the
|
||||
G-301 hole class — own-session check (403 on cross-session) +
|
||||
provider=mock conditional mount + session gate, pinned by test.
|
||||
6. **example.com seed websites** (R-65): claim evidence match=false
|
||||
corpus-wide; the queue copy must present the signal as advisory
|
||||
(A-304 — never display forgeable evidence as verification).
|
||||
|
||||
---
|
||||
|
||||
## v0.9 Phase 0 RESEARCH (Payment Engine — prior milestone, retained below)
|
||||
|
||||
Web-verified census of the payment/billing surface, 2026-09-29. Every
|
||||
conclusion carries its source (docs.stripe.com page or in-tree file probe).
|
||||
Binding clarify decisions D-075..D-084 are NOT re-litigated; this research
|
||||
operationalizes them. Upstream docs consulted: docs.stripe.com —
|
||||
api/checkout/sessions (create/retrieve/object), webhooks (manual signature
|
||||
verification + replay), api/subscriptions (object/cancel/update),
|
||||
billing/subscriptions/webhooks, billing/revenue-recovery/smart-retries,
|
||||
api/idempotent_requests, api/events/types, keys, billing/testing/test-clocks.
|
||||
In-tree probes grounding the module design: api/marketplace_admin.py
|
||||
(require_admin dep), api/deps.py, marketplace/store.py + identity/store.py
|
||||
(D-027 family), config.py (Settings env trio precedents),
|
||||
telemetry/tokens.py (HMAC), api/telemetry.py (WS ingest verify),
|
||||
marketplace/store.py insert_application (at-least-once dedupe precedent),
|
||||
main.py (lifespan store wiring + inline router mounts + G-11 fallback),
|
||||
voice/factory.py + marketplace/embedding/factory.py (provider factory),
|
||||
apps/web pricing page.tsx (inline JOB_PLANS/TALENT_PLANS constants),
|
||||
lib/engine-client.ts (jsonFetch + parseError), tests/marketplace/conftest.py
|
||||
(make_client + state injection), tests pins.test.ts (dual-schema pins).
|
||||
|
||||
## R-33..R-50 — Stripe-compatible protocol facts (web-verified)
|
||||
|
||||
**R-33 (high).** Checkout Session REST: `POST /v1/checkout/sessions`
|
||||
(create) + `GET /v1/checkout/sessions/{id}` (retrieve). Auth = HTTP Basic
|
||||
(`-u sk_...:`) or Bearer. Body is **form-encoded** with bracket nesting
|
||||
(`line_items[0][price_data][currency]=usd`) — NOT JSON. The stripe provider
|
||||
must encode bodies accordingly; MockTransport byte-contract tests assert the
|
||||
exact encoded body + Authorization header.
|
||||
|
||||
**R-34 (high).** `mode` required, enum `payment|setup|subscription`;
|
||||
`line_items` required for payment/subscription; `success_url` required
|
||||
(hosted); `client_reference_id` optional ≤200 chars — our reconciliation key
|
||||
(checkout_session row id); `metadata` optional map; `expires_at` 30m–24h.
|
||||
|
||||
**R-35 (high).** line item: exactly one of `price` (existing Price ID) or
|
||||
`price_data` {currency (lowercase ISO), unit_amount (int, smallest currency
|
||||
unit), product_data {name}, recurring {interval: day|week|month|year} for
|
||||
subscriptions}. quantity optional. One-time vs recurring differs only by the
|
||||
presence of `price_data[recurring][interval]` — matches D-078's catalog
|
||||
shape (packages vs plans as one table family with an interval field).
|
||||
|
||||
**R-36 (high).** Session response fields we normalize: `id` (`cs_...`),
|
||||
`url` (hosted checkout URL, present only while open), `status`
|
||||
(`open|complete|expired`), `payment_status` (`paid|unpaid|
|
||||
no_payment_required`), `customer` (nullable), `subscription` (nullable,
|
||||
subscription mode only), `mode`, `amount_total`, `currency`, `metadata`,
|
||||
`expires_at`, `livemode`.
|
||||
|
||||
**R-37 (high).** Completion predicate: `status == "complete" AND
|
||||
payment_status == "paid"` — never `status` alone (async methods can leave
|
||||
`complete` with payment still processing; `checkout.session.
|
||||
async_payment_succeeded` fires later). The billing webhook handler uses this
|
||||
predicate on `checkout.session.completed` before granting entitlements.
|
||||
|
||||
**R-38 (high).** Webhook header: `Stripe-Signature: t=<unix_seconds>,
|
||||
v1=<hex>` on one line; multiple `v1` during secret rolls; a fake `v0` scheme
|
||||
exists on test events — ignore all non-`v1` schemes (downgrade protection).
|
||||
|
||||
**R-39 (high).** Signed payload = `"{timestamp}.{raw body}"`; HMAC-SHA256
|
||||
with the endpoint secret (`whsec_...`); constant-time comparison; raw body
|
||||
must be un-parsed bytes at verification time. **D-077's scheme is
|
||||
byte-for-byte Stripe's** — adopt as-is; the in-tree precedent
|
||||
(telemetry/tokens.py) supplies the mint/verify/compare_digest shape.
|
||||
|
||||
**R-40 (high).** Replay tolerance: official default **300 s**; "Don't use a
|
||||
tolerance of 0". Each delivery/retry re-signs (new t + new v1) — attackers
|
||||
cannot alter t without invalidating. D-077's ±5 min = Stripe default.
|
||||
|
||||
**R-41 (high).** Stripe subscription.status enum: `incomplete`,
|
||||
`incomplete_expired` (23h terminal after failed initial payment),
|
||||
`trialing`, `active`, `past_due`, `canceled`, `unpaid`, `paused`.
|
||||
|
||||
**R-42 (high).** State normalization (design choice on verified facts):
|
||||
our `trialing` ← Stripe `trialing`/`incomplete`; `active` ← `active`;
|
||||
`past_due` ← `past_due`; `cancelled` ← `canceled`; `expired` ←
|
||||
`incomplete_expired`/`unpaid`. D-083's five states map cleanly.
|
||||
|
||||
**R-43 (high).** Event normalization — Stripe has no `subscription.renewed`
|
||||
or `checkout.completed` events; D-083's names are OUR canonical names,
|
||||
mapped at the provider boundary:
|
||||
- `checkout.completed` ← `checkout.session.completed`
|
||||
- `invoice.paid` ← `invoice.paid` (canonical provisioning signal;
|
||||
`invoice.payment_succeeded` also fires — accept both, treat as one)
|
||||
- `invoice.payment_failed` ← `invoice.payment_failed`
|
||||
- `subscription.cancelled` ← `customer.subscription.deleted`
|
||||
- `subscription.renewed` ← `invoice.paid` on a renewal invoice
|
||||
(corroborated by `customer.subscription.updated`)
|
||||
**Action recorded against D-083: the provider protocol carries a
|
||||
`normalize_event()` boundary; webhook processing stores + dedupes provider
|
||||
event ids verbatim (never normalized) so dedupe stays truthful.**
|
||||
|
||||
**R-44 (medium).** Dunning: after retries exhaust, subscription becomes
|
||||
`canceled` or `unpaid` per Dashboard settings; Smart Retries default ~8
|
||||
retries/2 weeks. Our mock dunning = D-083's 7-day past_due grace (module
|
||||
constant), independent of Stripe settings.
|
||||
|
||||
**R-45 (high).** Event delivery: NO ordering guarantee; duplicates happen —
|
||||
dedupe by event `id`; return 2xx fast before complex logic; retries
|
||||
exponential backoff (up to 3 days live). Confirms D-077's event_id dedupe +
|
||||
D-083's idempotent transitions; webhook route returns 200 immediately after
|
||||
persisting the event row (processing is synchronous but bounded).
|
||||
|
||||
**R-46 (high).** Cancel: `DELETE /v1/subscriptions/{id}` (immediate;
|
||||
`invoice_now`/`prorate` default false) → status `canceled`; or deferred:
|
||||
POST update `cancel_at_period_end=true` (stays active until period end, then
|
||||
`customer.subscription.deleted`). Protocol `cancel_subscription(id,
|
||||
at_period_end=False)` maps 1:1.
|
||||
|
||||
**R-47 (high).** Upgrade/downgrade: `POST /v1/subscriptions/{id}` with
|
||||
`items[0][id]=<subscription_item_id>` + `items[0][price]=<new price>` —
|
||||
**including the item id is mandatory** (else the new price is ADDED
|
||||
alongside the old → dual-charge bug). `proration_behavior`:
|
||||
`create_prorations` (default) | `none` | `always_invoice`. Failed immediate
|
||||
payment → update succeeds, subscription `past_due`. Protocol
|
||||
`update_subscription(subscription_id, new_price_ref)` must resolve the
|
||||
subscription_item id inside the provider (the route must not have to know
|
||||
it) — recorded as a byte-contract test case.
|
||||
|
||||
**R-48 (high).** `unit_amount` = integer in the smallest currency unit —
|
||||
cents for USD (`unit_amount: 1000` = $10.00). Confirms D-081 (integer cents
|
||||
end-to-end; no floats anywhere in billing).
|
||||
|
||||
**R-49 (high).** `Idempotency-Key` header on all POSTs; Stripe replays the
|
||||
saved response for 24h on key reuse; same key + different params errors;
|
||||
no key on a double-submit creates two sessions. The stripe provider sends
|
||||
`Idempotency-Key: <uuid4>` per logical checkout/subscription operation.
|
||||
|
||||
**R-50 (high).** Test mode (`sk_test_` keys) is an isolated sandbox — card
|
||||
networks never process; every object carries `livemode: false`. Combined
|
||||
with MockTransport-only tests (D-075), no test path can ever reach Stripe.
|
||||
Test clocks exist for time-simulated billing — noted as precedent; our
|
||||
tests use MockTransport + the deterministic mock exclusively.
|
||||
|
||||
## R-51..R-54 — In-tree integration facts (probe-verified)
|
||||
|
||||
**R-51.** Admin gate composition (REQ-9-003/004 operator routes): the
|
||||
`require_admin` FastAPI **dependency** (api/marketplace_admin.py:46-85) —
|
||||
`X-Admin-Token` or `Authorization: Bearer`; fail-closed when
|
||||
`AI_ADMIN_TOKEN` unset (403 even with a candidate token);
|
||||
`secrets.compare_digest` constant-time UTF-8 compare; returns `Settings`.
|
||||
Billing admin routes copy this dep verbatim; billing entitlement check
|
||||
composes AFTER it (REQ-9-004).
|
||||
|
||||
**R-52.** Store wiring: 7th D-027 store = `billing/store.py` mirroring
|
||||
marketplace/store.py — SQLModel table classes in-module (`__tablename__`,
|
||||
UniqueConstraints for idempotency keys, `sa_type=String` + `_validate()`
|
||||
instead of bare Literal annotations, `_as_utc()` re-labeling,
|
||||
`_sqlite_connect` pragmas WAL/NORMAL/busy_timeout/foreign_keys); Protocol +
|
||||
SQLiteImpl pair; `Settings().db_path` shared file; main.py lifespan
|
||||
state-injection override guard + close() in finally; `get_billing_store`
|
||||
dep in api/deps.py ("routes never import stores directly" territory rule).
|
||||
|
||||
**R-53.** Provider factory + settings trio: config.py gains
|
||||
`payment_provider: str = "mock"` + `payment_api_key: SecretStr = SecretStr("")`
|
||||
+ `webhook_secret: str = ""` (per-boot random when unset — the
|
||||
telemetry_ingest_secret shape). `billing/factory.py` mirrors
|
||||
voice/factory.py: `UnknownPaymentProviderError(ValueError)`; main.py
|
||||
lifespan catches factory errors → loud G-11 log → falls back to mock
|
||||
(R-51 boot-warning pattern; voice/embedding precedent at main.py:138-154).
|
||||
|
||||
**R-54.** Pricing page conversion (REQ-9-002): page.tsx currently holds
|
||||
inline `JOB_PLANS`/`TALENT_PLANS` constants (lines 41-111) +
|
||||
`COMPARISON` matrix; conversion = A-406 board-client pattern — page stays a
|
||||
server shell (metadata retained per A-411, pinned by pins.test.ts:189-190
|
||||
+ sitemap STATIC_PAGES), new client component fetches `/v1/billing/catalog`
|
||||
via engine-client `jsonFetch` with honest loading/error/empty states;
|
||||
`listBillingCatalog()` + `PaymentRequiredError` (402 dict-detail) added to
|
||||
engine-client.ts (parseError VerifyRequiredError precedent, lines 90-123);
|
||||
wire types in packages/types/billing.ts dual-schema-pinned in pins.test.ts.
|
||||
|
||||
## Open risks (tracked into PLAN)
|
||||
|
||||
1. **Webhook raw-body discipline**: signature verification needs the exact
|
||||
raw bytes — any FastAPI body-parsing middleware (or pydantic model
|
||||
binding) before verification breaks it. The webhook route reads
|
||||
`await request.body()` FIRST, verifies, then parses JSON. Tracked as a
|
||||
P3 must-have + probe test.
|
||||
2. **Dual-charge on upgrade** (R-47): `items[0][id]` omission is the
|
||||
classic Stripe bug — byte-contract test must assert the item id is
|
||||
present in the update body.
|
||||
3. **Webhook ordering** (R-45): `invoice.payment_failed` can arrive before
|
||||
`invoice.paid` under retries; transitions must be event-driven state
|
||||
machine transitions (InvalidTransition → 409 tolerated as already-reflected
|
||||
duplicates, recorded not raised) — the at-least-once lesson.
|
||||
4. **Clock skew in tests**: signature timestamps use `now` injection
|
||||
(protocol takes `now: float | None = None`) so replay-window tests are
|
||||
deterministic without sleeps.
|
||||
5. **Secret rotation**: multiple `v1` schemes (R-38) — verify() iterates
|
||||
ALL v1 values, accepts any match (supports secret rolls without
|
||||
downtime); test-pinned.
|
||||
|
||||
---
|
||||
|
||||
## v0.8 Phase 0 RESEARCH (Ship Anywhere — prior milestone, retained below)
|
||||
|
||||
Probe-verified census of the release/distribution surface, 2026-09-27.
|
||||
Every conclusion carries evidence (file paths probed in this repo, live HTTP
|
||||
|
||||
+60
-4
@@ -2,7 +2,11 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone v0.7 — Test Harness Review (COMPLETE, shipped as v0.6.6, 2026-09-27).** NFR refinement-only milestone reviewing the entire test harness: coverage/timeouts/markers wired, trivial tests cut with recorded triage, duplicated infra consolidated, 1,716 warnings/run eliminated to zero, ESLint+RUF100+mypy toolchain completed, recharts 3. Founder scope decisions D-048..D-051; clarify D-052..D-061; grill G-101..G-110. Tags on the v0.6.x line (v0.6.0 phase 0 → v0.6.6 final = milestone release); branch `milestone/v0.7-test-harness-review`.
|
||||
**Milestone v0.11 — COMPLETE (shipped as v0.10.6, 2026-10-22).** Feature milestone: CIRR-style placement tracking and audit (REQ-F-020 activated per D-098) — the machinery that makes the guiding outcome (≥1,000 placements, CIRR-style third-party audit) measurable: the placement store + hash-chained evidence ledger, the employer-side application state machine (submitted → in_review → interview → offer → hired/rejected/withdrawn), placement reporting surfaces (per-employer, per-learner, aggregate toward ≥1,000), the audit-grade export (cohort snapshot + evidence bundles + methodology + chain head), learner-side placement visibility. Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`.
|
||||
|
||||
**Milestone v0.10 — B2B Employer Network (COMPLETE, shipped as v0.9.6, 2026-10-01).** Feature milestone: real employer auth/accounts + self-serve (D-085). Employers become principals — registered accounts with sessions, employer-entity claim/verify, the employer dashboard converting from the v0.1 mockup to real data, self-serve posting management with the v0.9 entitlement enforcement intact, and self-serve checkout over the v0.9 billing catalog (superseding D-076 operator mediation; admin operator routes retained as fallback + oversight). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`.
|
||||
|
||||
**Milestone v0.9 — Payment Engine (COMPLETE, shipped as v0.8.6, 2026-09-30).** Payment processing and subscription management (REQ-F-018 activated per D-074): the billing module (provider protocol mock+stripe, 7th D-027 store, HMAC webhooks), catalog + pricing surface, operator-mediated checkout, entitlement enforcement, full subscription lifecycle. Founder scope decision D-074; clarify D-075..D-084; grill G-301+. Tags on the v0.8.x line (v0.8.0 phase 0 → v0.8.6 final = milestone release); branch `milestone/v0.9-payment-engine`.
|
||||
|
||||
**Milestone v0.6 — Marketplace Engine (COMPLETE, shipped as v0.5.6, 2026-09-26).** The real marketplace backend behind the existing surfaces: job posting backend + aggregation adapter (REQ-6-001), AI tagging (REQ-6-002), semantic vector search + match scoring (REQ-6-003), company enrichment + resume parsing (REQ-6-004/005), SEO programmatic pages (REQ-6-006). Scope decision D-047 (auto-decided at SPECIFY, full autonomy). Tags on the v0.5.x line (v0.5.0 phase 0 → v0.5.N final = milestone release); branch `milestone/v0.6-marketplace-engine`.
|
||||
|
||||
@@ -28,9 +32,61 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.9 (Complete — Shipped as v0.8.6, 2026-09-30)
|
||||
|
||||
Payment processing and subscription management (REQ-F-018 activated per D-074, auto-decided at SPECIFY under full autonomy): posting packages (single/bundle/enterprise), talent-access plans, subscription tiers with real checkout, provider webhooks (HMAC-verified, idempotent), posting-creation entitlement enforcement, and a full subscription lifecycle (renew/cancel/upgrade/past_due grace) — all behind a provider-agnostic `PaymentProvider` protocol, mock-first (D-014 pattern; tests never call Stripe). The pricing page (REQ-017) gains its real backend. Out of scope: employer auth/accounts (B2B network — post-payments per A-407), multi-currency, tax/VAT beyond passthrough, invoice PDFs, CIRR tracking (REQ-F-020). Tags on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`.
|
||||
|
||||
## Phase List
|
||||
|
||||
### v0.8 (in progress)
|
||||
### v0.11 (complete — shipped as v0.10.6, 2026-10-22)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | complete (v0.10.0) | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check complete (shipped as v0.10.0) |
|
||||
| 1 | Placement store + evidence ledger | complete (v0.10.1) | 0 | REQ-11-001 | `ai_service/placements/` module: 9th D-027 store (PlacementRow + PlacementEventRow, hash-chained append-only ledger), evidence resolved from source rows only (G-302 doctrine), `create_all` idempotent on the shared DB file, Protocol + SQLite pair |
|
||||
| 2 | Application state machine | complete (v0.10.2) | 1 | REQ-11-002 | Employer routes extend the v0.6 application status from submitted-only to the full hiring lifecycle; claimed-entity authorization (403 cross-entity, G-311 gate order); every transition ledger-logged; hired creates the placement; admin oversight retained |
|
||||
| 3 | Placement reporting | complete (v0.10.3) | 2 | REQ-11-003 | Aggregate counts from the ledger (per employer, per cohort month, cumulative toward ≥1,000); employer dashboard placements section; learner outcome states; admin network overview counts |
|
||||
| 4 | Audit export | complete (v0.10.4) | 3 | REQ-11-004 | Admin-gated export: cohort snapshot + per-placement evidence bundles + methodology doc + chain head hash; JSON + human-readable summary; re-export reproducibility test |
|
||||
| 5 | Learner placement visibility | complete (v0.10.5) | 4 | REQ-11-005 | Learner application outcomes + full state history; hired learners see their placement record; honest no-badge-until-placed (A-304) |
|
||||
| 6 | Final review + ship | complete (v0.10.6) | 1-5 | — | Review SHIP — 0 P0s, 2 P1 doc-level findings dispositioned with recorded follow-ups (the PLAN Must-Have test_e2e_audit.py name vs test_export.py::test_e2e_audit_proof; the D-112 census omitting the employers/store.py two-line audit-vocabulary addition); audit PASS — D-112 reconstruction clean (27 branch commits vs the census), branch/commit discipline green; ship: v0.10.5 tagged at the ship per the recorded deferral (e32e54e7aa), milestone merged to main (PR #6), v0.10.6 tagged at the merge, release lanes fired, milestone branch deleted |
|
||||
|
||||
---
|
||||
|
||||
## Phase List (v0.10 — complete)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | complete (v0.9.0) | — | — | Specify (D-085), clarify (D-086..D-097), research (R-55..R-66), plan, grill (G-311..G-320), MVP/UX check complete (shipped as v0.9.0, release 948) |
|
||||
| 1 | Employer auth foundation | complete (v0.9.1) | 0 | REQ-10-001 | `ai_service/employers/` module: account store (8th D-027 family, 4 tables) + scrypt hashed credentials (params pinned) + hashed server-side sessions (7-day lazy TTL) + register/login/logout/me routes + dual D-088 auth throttles + CORS Authorization preflight + PII scrub; fail-closed secrets; tests never call a cloud (shipped as v0.9.1, release 949) |
|
||||
| 2 | Employer entity claim | complete (v0.9.2) | 1 | REQ-10-002 | Claim route over existing EmployerRow entities with stdlib-only claim_evidence normalization (advisory-only per D-089/A-304); G-316 partial UNIQUE index closes the pending-claim TOCTOU; one account : one entity bound at approve with same-transaction audit provenance; admin claim review/approve/reject — 5 operator routes fail-closed, 403 matrix 20 probes; rejection frees entities; claimed-entity authorization enforced (shipped as v0.9.2, release 950) |
|
||||
| 3 | Employer account surface | complete (v0.9.3) | 2 | REQ-10-003 | Employer dashboard real-data conversion (mock imports → runtime fetches, A-406 pattern); login/register UI; session-gated shell; own-postings view; dual-schema wire pins (shipped as v0.9.3, release 951) |
|
||||
| 4 | Self-serve posting management | complete (v0.9.4) | 3 | REQ-10-004 | Employer-principal posting routes with own-entity authorization; D-091 shared entitlement gate (one helper, byte-identical principal gates); create/edit/publish/close on the claimed entity with G-311 gate order, G-317 provenance stamps, G-318 own-drafts guard + admin re-tag parity; PostingManager honest 402 CTA / 403 claim states per G-320b; admin routes retained (shipped as v0.9.4, release 952) |
|
||||
| 5 | Self-serve checkout + admin overview | complete (v0.9.5) | 4 | REQ-10-005 | Employer-principal checkout over the v0.9 catalog via the REAL webhook path with buyer = the session's claimed entity (supersedes D-076; admin operator routes retained as fallback); own-session mock-complete closes the G-301 class; own entitlement view with honest states; pricing CTAs → employer-login per D-079-supersession with enterprise Contact Sales pin retained; admin network overview — accounts, advisory-evidence claim queue, audit — per D-094/A-304 (shipped as v0.9.5, release 953) |
|
||||
| 6 | Final review + ship | complete (v0.9.6) | 1-5 | — | Final review verdict SHIP — 2 P0s found + fixed (the /me claim-state leak over stale sessions, the dead billing confirm button), 3 P1s dispositioned; audit PASS — reconstruction clean, discipline green, 2 minor notes; ship gates green (1031 ai-service + 101 web tests, build clean); milestone tagged v0.9.6 (final v0.9.x patch = milestone release, release 954); release with full asset matrix + registry image via the tag lane (D-064); milestone merged to main |
|
||||
|
||||
---
|
||||
|
||||
## Phase List (v0.9 — complete)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | complete (v0.8.0) | — | — | Specify (D-074), clarify (D-075+), research (R-33+), plan, grill (G-301+), MVP/UX check complete; shipped as v0.8.0 |
|
||||
| 1 | Billing foundation | complete (v0.8.1) | 0 | REQ-9-001 | `ai_service/billing/` module: PaymentProvider protocol + deterministic mock + SQLite store (7th D-027 family) + HMAC webhook verification + env-gated provider selection; tests never call a real provider |
|
||||
| 2 | Catalog + pricing surface | complete (v0.8.2) | 1 | REQ-9-002 | Seeded catalog (posting packages + talent-access plans) via `/v1/billing/catalog`; pricing page runtime client fetch with honest loading/error states; tier copy data-driven |
|
||||
| 3 | Checkout + webhook flow | complete (v0.8.3) | 2 | REQ-9-003 | `POST /v1/billing/admin/checkout` (admin-gated, D-076) + `POST /v1/billing/webhook` (HMAC-verified, idempotent event processing, at-least-once tolerance, local-row state resolution per G-302); entitlement granted on checkout.completed |
|
||||
| 4 | Entitlement enforcement | complete (v0.8.4) | 3 | REQ-9-004 | Posting creation enforces package limits (honest 402 + upgrade CTA); expired/cancelled degrade to 402; admin surface shows per-employer entitlement state |
|
||||
| 5 | Subscription lifecycle | complete (v0.8.5) | 4 | REQ-9-005 | Subscription state machine (trialing/active/past_due/cancelled/expired); idempotent renewals; manual admin grant/revoke with provenance; full billing event audit log; past_due grace window |
|
||||
| 6 | Final review + ship | complete (v0.8.6) | 1-5 | — | Review clean; audit pass; milestone tagged v0.8.6 (final v0.8.x patch = milestone release); release with full asset matrix + registry image; milestone merged to main |
|
||||
|
||||
---
|
||||
|
||||
## v0.8 (Complete — Shipped as v0.7.6)
|
||||
|
||||
Ship Anywhere: installable on any Debian-based Linux server via docker (Gitea registry image, honest sandbox degrade per D-068), .deb packages (amd64+arm64, advisory postinst per D-067), and arch-aware binary (x64+arm64, R-24 arch-resolve) — with release consistency enforced by pre-commit hooks + Gitea Actions (3-runner fleet on CT 113 as committed fleet-as-code, machine-made releases with the 8-asset matrix + registry image on every release), the CLI carrying the full suite (guide + channel-aware doctor, D-066/D-072), and the evidence-backed admin runbook (G-210 parity-pinned). 7 phases (P0–P6), all 5 requirements (REQ-8-001..005) complete. Tags v0.7.0–v0.7.5 per phase, milestone release v0.7.6 (release 936). Final review CLEAN (G-201..G-210 honored — the smoke gate caught 4 real image bugs pre-ship, each fixed in-phase); audit PASS (reconstruction + discipline green).
|
||||
|
||||
## Phase List
|
||||
|
||||
### v0.8 (complete)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
@@ -39,8 +95,8 @@
|
||||
| 2 | Docker image channel | complete (v0.7.2) | 1 | REQ-8-002 | Multi-stage image (unprivileged, /health, state volume) pushed to the Gitea registry on every phase release; honest sandbox degrade (probe + notice + 503 remediation); in-container CLI semantics; smoke gate in workflow |
|
||||
| 3 | Debian-native channel | complete (v0.7.3) | 2 | REQ-8-003 | arm64 QEMU-free cross-build; .deb x64+arm64 (advisory postinst); install.sh arch-resolve; full asset matrix (2 binaries + 2 checksums + 2 debs) on every release |
|
||||
| 4 | CLI onboarding | complete (v0.7.4) | 3 | REQ-8-004 | Channel-aware doctor; nextcraft guide offline; honest in-container refusals; docker/deb E2Es env-gated green; guide↔runbook parity test |
|
||||
| 5 | Admin guides | pending | 4 | REQ-8-005 | deploy/README.md full runbook (3 channels × install/upgrade, upgrade matrix, troubleshooting, evidence appendix); README quickstart; every recipe evidence-backed |
|
||||
| 6 | Final review + ship | pending | 1-5 | — | Review clean; audit pass; milestone tagged v0.7.6 (final v0.7.x patch = milestone release); release with full asset matrix + image tags; milestone merged to main |
|
||||
| 5 | Admin guides | complete (v0.7.5) | 4 | REQ-8-005 | deploy/README.md full runbook (3 channels × install/upgrade, upgrade matrix, troubleshooting, evidence appendix); README quickstart; every recipe evidence-backed |
|
||||
| 6 | Final review + ship | complete (v0.7.6) | 1-5 | — | Review clean; audit pass; milestone tagged v0.7.6 (final v0.7.x patch = milestone release, release 936); release with full asset matrix + registry image; milestone merged to main | Review clean; audit pass; milestone tagged v0.7.6 (final v0.7.x patch = milestone release); release with full asset matrix + image tags; milestone merged to main |
|
||||
|
||||
### v0.7 (complete)
|
||||
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
"projects": [],
|
||||
"active_project": null,
|
||||
"milestone": {
|
||||
"version": "v0.8",
|
||||
"name": "ship-anywhere",
|
||||
"version": "v0.11",
|
||||
"name": "placement-tracking",
|
||||
"type": "feature",
|
||||
"branch": "milestone/v0.8-ship-anywhere"
|
||||
"branch": "milestone/v0.11-placement-tracking"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,3 +278,100 @@ jobs:
|
||||
|| { echo "FAIL: embedded CLI does not run"; exit 1; }
|
||||
echo "assert 5/5: embedded CLI alive + clean stop"
|
||||
docker rm -f "$CID" >/dev/null && CID=""
|
||||
# -- R1 deploy leg (REQ-v1-003 machinery set c18619; hep APPROVE c18623;
|
||||
# vehicle call c18634: the last mile is THIS pipeline, not an SRE infra
|
||||
# detour). Pipeline-only host refresh of the product host - no manual
|
||||
# host pokes. Provenance = this job's run id + the post-deploy identity
|
||||
# read below. The footer is retired as a freshness marker (it is the
|
||||
# code's own label - c18624 correction, confirmed by hep): freshness
|
||||
# reads are machine-read (/health + / serving HTML + the /v1/placements/
|
||||
# admin route registration in /openapi.json).
|
||||
#
|
||||
# One-time plumbing preconditions (declared for the MR review; NOT a
|
||||
# routing of the refresh to SRE - SRE keeps the R3 re-probe lane only):
|
||||
# * DEPLOY_SSH_KEY - repo Actions secret: the least-scope deploy key
|
||||
# authorized on the product host's deploy account (deploy@10.0.0.105
|
||||
# per deploy/README.md topology). One-time grant via the SRE/console
|
||||
# precedent (deploy/ci README G-206 pattern; self-serve secret PUT
|
||||
# precedent: REGISTRY_TOKEN, HTTP 201 on 2026-09-27).
|
||||
# * (optional hardening) DEPLOY_HOSTKEY - repo Actions secret holding
|
||||
# the product host's complete ssh public host-key line; when set it
|
||||
# is PINNED (StrictHostKeyChecking=yes), else accept-new.
|
||||
# A missing DEPLOY_SSH_KEY at run time fails this leg LOUDLY - a red
|
||||
# Actions tab on a tag is a red deploy by design (deploy/README.md);
|
||||
# it is never silently skipped (set R1: the gate stays clearable on
|
||||
# evidence). The release/asset path is untouched: a deploy-leg
|
||||
# precondition failure cannot damage artifact shipping.
|
||||
deploy:
|
||||
needs: release
|
||||
runs-on: release
|
||||
steps:
|
||||
- name: Preflight - fail loud when the leg is unconfigured
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "deploy preflight: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / sha $GITHUB_SHA"
|
||||
[ -n "$DEPLOY_SSH_KEY" ] || { echo "FATAL: DEPLOY_SSH_KEY unset - deploy leg unconfigured; host NOT refreshed this release; release artifacts stand (release job already green)"; exit 1; }
|
||||
|
||||
- name: Refresh the product host over the declared ssh channel
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_HOSTKEY: ${{ secrets.DEPLOY_HOSTKEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trap 'rm -f /tmp/dk /tmp/kh' EXIT
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null
|
||||
install -m 600 /dev/null /tmp/dk
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > /tmp/dk
|
||||
unset DEPLOY_SSH_KEY
|
||||
if [ -n "${DEPLOY_HOSTKEY:-}" ]; then
|
||||
printf '%s\n' "$DEPLOY_HOSTKEY" > /tmp/kh
|
||||
SSH_PIN="-oUserKnownHostsFile=/tmp/kh -oStrictHostKeyChecking=yes"
|
||||
else
|
||||
SSH_PIN="-oStrictHostKeyChecking=accept-new"
|
||||
fi
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
ssh -i /tmp/dk -o BatchMode=yes $SSH_PIN deploy@10.0.0.105 bash -s "$TAG" << 'REMOTE'
|
||||
set -euo pipefail
|
||||
TAG="$1"
|
||||
if command -v nextcraft >/dev/null 2>&1; then
|
||||
echo "channel: binary/deb - the one-command upgrade (idempotent, self-verifying per deploy/README.md)"
|
||||
nextcraft deploy --service --ref "$TAG"
|
||||
elif [ -n "$(docker ps --format '{{.Names}}' | grep -x nextcraft || true)" ]; then
|
||||
echo "channel: docker - README upgrade (pull + recreate; nextcraft-state persists)"
|
||||
docker pull git.coreci.dev/coreci/nextcraft:"$TAG"
|
||||
docker rm -f nextcraft
|
||||
docker run -d --name nextcraft -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:"$TAG"
|
||||
else
|
||||
echo "FATAL: no nextcraft channel on $(hostname) (no nextcraft on PATH; no docker container named nextcraft) - failing loud"
|
||||
exit 1
|
||||
fi
|
||||
echo "--- channel verify (advisory; the identity read below is canonical)"
|
||||
command -v nextcraft >/dev/null 2>&1 && nextcraft --version || true
|
||||
REMOTE
|
||||
RC=$?
|
||||
echo "host refresh done (rc=$RC); provenance: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / sha $GITHUB_SHA"
|
||||
exit $RC
|
||||
|
||||
- name: Post-deploy identity read - the R1 provenance surface
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
echo "waiting for the stack to settle..."
|
||||
OK=""
|
||||
for i in $(seq 1 12); do
|
||||
CODE_H="$(curl -sS -o /tmp/h -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/health || true)"
|
||||
if [ "$CODE_H" = "200" ] && grep -q '"status"' /tmp/h; then OK=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$OK" ] || { echo "FAIL: /health never returned 200 within the window"; exit 1; }
|
||||
echo "assert 1/3: /health 200 - $(head -c 200 /tmp/h)"
|
||||
CODE_R="$(curl -sS -o /tmp/r -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/)"
|
||||
head -c 64 /tmp/r | tr -d '[:space:]' | head -c 9 | grep -qiE '^<!doctype|^<html' || { echo "FAIL: / is not HTML (code $CODE_R)"; exit 1; }
|
||||
echo "assert 2/3: / serves HTML ($CODE_R)"
|
||||
CODE_O="$(curl -sS -o /tmp/o -m 25 -w '%{http_code}' https://nextcraft.coreci.dev/openapi.json)"
|
||||
grep -q '"/v1/placements/admin' /tmp/o || { echo "FAIL: counting surface NOT registered on the served build (/openapi.json has no /v1/placements/admin routes) - honest red; R2 root-cause owns the diagnosis per c18619"; exit 1; }
|
||||
echo "assert 3/3: /v1/placements/admin registered in /openapi.json"
|
||||
echo "(advisory served version: $(grep -o '"version":"[^"]*"' /tmp/o | head -1))"
|
||||
echo "PROVENANCE (R1): deploy run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / head $GITHUB_SHA - identity reads green"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Nextcraft
|
||||
|
||||
AI-native outcome school + marketplace — graduates prove what they can build, not what they can write.
|
||||
**TL;DR** — AI-native outcome school + marketplace — graduates prove what they can build, not what they can write. This file is the front door: three install channels, dev setup from a clone, and the CLI reference; the admin runbook for standing the stack up on a server lives in [deploy/README.md](deploy/README.md).
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
||||
@@ -83,9 +83,21 @@ AI_VOICE_PROVIDER=mock
|
||||
# AI_ADMIN_TOKEN=
|
||||
# P5 (D-056) placeholder: the deployed origin for sitemap.xml / robots.txt
|
||||
# absolute URLs (e.g. https://nextcraft.example). Unset → SEO phase
|
||||
# serves the empty static-page set with a boot warning (G-105).
|
||||
# serves the empty-page set with a boot warning (G-105).
|
||||
# AI_PUBLIC_BASE_URL=
|
||||
|
||||
# --- Employer auth throttles (REQ-10-001, D-088) ---
|
||||
# Documentation of the ACTIVE defaults (a-313) — both knobs are always
|
||||
# on as Settings ints; these env entries override for ops tuning.
|
||||
# (a) Failed logins allowed per email per rolling 15-min window
|
||||
# (default 5 → 429 with an honest "wait" detail; a successful
|
||||
# login clears the window).
|
||||
# AI_EMPLOYER_LOGIN_FAILURES_PER_WINDOW=5
|
||||
# (b) Well-formed login+register attempts per rolling 60-s window
|
||||
# across all emails (default 30 → 429; failed passwords count —
|
||||
# the credential-stuffing bound, G-314).
|
||||
# AI_EMPLOYER_AUTH_ATTEMPTS_PER_MIN=30
|
||||
|
||||
# --- Semantic search embeddings (REQ-6-003, D-051) ---
|
||||
# 'mock' (default — deterministic 256-dim feature-hash, pure stdlib, no
|
||||
# key needed; tests never call a real embeddings API) or 'openai-embed'.
|
||||
@@ -103,3 +115,18 @@ AI_EMBED_PROVIDER=mock
|
||||
# -H "Authorization: Bearer $AI_EMBED_API_KEY" \
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d '{"model":"'"$AI_EMBED_MODEL"'","input":["Nextcraft probe"]}' | jq -e '.data[0].embedding | length'
|
||||
|
||||
# --- Billing / Payment Engine (REQ-9-001, D-075, D-077) ---
|
||||
# Payment provider: 'mock' (default — deterministic, zero-network; tests
|
||||
# NEVER call a real payment API) or 'stripe' (real Stripe-compatible REST,
|
||||
# P3+). Unknown names raise at the factory; the boot falls back to mock
|
||||
# with a loud log (G-11).
|
||||
# AI_PAYMENT_PROVIDER=mock
|
||||
# Real provider API key (Stripe-style sk_test_... placeholder — NEVER a
|
||||
# real key here; real keys live in .ciagent/.env.secrets, env-only per
|
||||
# D-014, never logged, SecretStr in Settings). 'mock' ignores it.
|
||||
# AI_PAYMENT_API_KEY=
|
||||
# Webhook HMAC secret (whsec_... style; UNSET → per-boot random → signed
|
||||
# webhooks FAIL CLOSED with 401 until configured — correct for a payment
|
||||
# surface). Set a fixed value only when a provider actually delivers.
|
||||
# AI_WEBHOOK_SECRET=
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""/v1/billing — billing API (REQ-9-002/9-003, D-078/D-079/G-302).
|
||||
|
||||
P2: the public catalog read (advertising — no auth).
|
||||
P3: the public webhook receiver. Webhook discipline (risk-1/G-302):
|
||||
`await request.body()` FIRST — the exact un-parsed bytes verify the
|
||||
HMAC before ANY parsing; unsigned/bad signatures 401, never process.
|
||||
Event payloads are UNTRUSTED ID REFERENCES — session/subscription
|
||||
state resolves from LOCAL rows; unknown targets land as `unmatched`
|
||||
billing events (200, no entitlement). Provider event ids dedupe
|
||||
verbatim (at-least-once tolerance, R-45); grants are idempotent per
|
||||
SESSION (G-303 — the store's UniqueConstraints enforce exactly-once).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..billing.base import Package, Plan, WebhookSignatureError
|
||||
from ..billing.store import BillingStore
|
||||
from .deps import get_billing_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/v1/billing", tags=["billing"])
|
||||
|
||||
|
||||
class CatalogResponse(BaseModel):
|
||||
"""The catalog wire shape (REQ-9-002; pinned field-for-field by
|
||||
packages/types/billing.ts via apps/web/tests/pins.test.ts — G-108
|
||||
discipline)."""
|
||||
|
||||
packages: list[Package]
|
||||
plans: list[Plan]
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=CatalogResponse)
|
||||
def get_catalog(
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
) -> CatalogResponse:
|
||||
"""The seeded catalog, price-ascending (D-078 — one source of
|
||||
truth; idempotent seeding happened at app boot)."""
|
||||
packages, plans = store.list_catalog()
|
||||
return CatalogResponse(packages=packages, plans=plans)
|
||||
|
||||
|
||||
def _get_payment_provider(request: Request):
|
||||
"""The selected provider via app.state DI (main.py wires the
|
||||
factory + G-11 fallback)."""
|
||||
return request.app.state.payment_provider
|
||||
|
||||
|
||||
# -- webhook (Task 3-2-02, G-302) ----------------------------------------------
|
||||
|
||||
|
||||
async def process_webhook_event(
|
||||
*,
|
||||
store: BillingStore,
|
||||
provider: object,
|
||||
raw_body: bytes,
|
||||
signature_header: str,
|
||||
) -> dict[str, object]:
|
||||
"""The REAL webhook processing path (G-301's E2E proof runs through
|
||||
here; the HTTP route + the mock-complete route share it).
|
||||
|
||||
Order (risk-1): verify HMAC over the exact bytes FIRST, then parse,
|
||||
then dedupe, then process. 200 fast on every authenticated path —
|
||||
duplicates and unknown targets are recorded, never errors (R-45's
|
||||
at-least-once + G-306's unknown-target tolerance).
|
||||
|
||||
Crash-safety (final-review fix): the event id is recorded AFTER
|
||||
its effects commit. The old order (record id → process) made a
|
||||
crash between the two permanent: every retry deduped as
|
||||
"duplicate" while the grant never happened — the entitlement was
|
||||
lost forever. The new order keeps a bounded replay window: a
|
||||
replay re-runs processing (a no-op for grants — G-303's per-session
|
||||
UniqueConstraint is the exactly-once backstop) and re-records the
|
||||
audit rows for that one event only until its id lands."""
|
||||
from ..billing.base import PaymentProvider
|
||||
|
||||
assert isinstance(provider, PaymentProvider)
|
||||
try:
|
||||
verified = provider.verify_webhook(raw_body, signature_header)
|
||||
except WebhookSignatureError as exc:
|
||||
logger.warning("billing webhook rejected: %s", exc)
|
||||
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
# G-306 (never 500): a correctly-SIGNED event whose type the
|
||||
# provider cannot normalize onto our canonical vocabulary
|
||||
# (Stripe's real streams deliver many event types) is honest
|
||||
# unmatched audit material, never a crashed receiver — Stripe
|
||||
# disables endpoints on error storms. The transport was
|
||||
# AUTHENTICATED (compare_digest passed) before the parse, so the
|
||||
# failure is name-level, not trust-level. WebhookSignatureError
|
||||
# is a ValueError subclass — the specific except above wins for
|
||||
# actual signature failures (401), this arm only sees
|
||||
# normalize_event's unmapped-name raise.
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
"unmapped.event",
|
||||
detail={"reason": f"unmapped provider event: {exc}"},
|
||||
)
|
||||
return {"status": "unmatched", "processed": False}
|
||||
event = verified.event
|
||||
# Dedupe VERBATIM on the provider event id (R-43/D-083) — checked
|
||||
# BEFORE the effects (fast-path a known replay), recorded AFTER
|
||||
# they commit (the crash-safety half of the final-review fix). A
|
||||
# crash between the two leaves the id UN-recorded, so the provider's
|
||||
# retry re-runs the effects — a no-op for grants (G-303's
|
||||
# per-session UniqueConstraint is the exactly-once backstop) — and
|
||||
# lands the key then. The old order (record → process) made that
|
||||
# crash permanent: every retry answered "duplicate" while the grant
|
||||
# never happened.
|
||||
if not store.has_provider_event(verified.provider_event_id):
|
||||
result = _process_canonical_event(store, event.name, dict(event.payload))
|
||||
store.insert_provider_event(
|
||||
verified.provider_event_id, event.name, dict(event.payload)
|
||||
)
|
||||
return {"status": "ok", "processed": result}
|
||||
return {"status": "duplicate", "processed": False}
|
||||
|
||||
|
||||
def _process_canonical_event(
|
||||
store: BillingStore, name: str, payload: dict[str, object]
|
||||
) -> str:
|
||||
"""G-302: the payload is UNTRUSTED — id references only. State
|
||||
resolves from LOCAL rows; unknown targets land as unmatched audit
|
||||
rows (never a 500, G-306)."""
|
||||
if name == "checkout.completed":
|
||||
session_id = str(payload.get("session_id", ""))
|
||||
local = store.get_checkout_session_by_provider_id(session_id)
|
||||
if local is None:
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
name,
|
||||
detail={"reason": "unknown session", "session_id": session_id},
|
||||
)
|
||||
return "unmatched"
|
||||
# R-37 predicate: complete AND paid — NEVER status alone (async
|
||||
# methods can leave a session complete with payment still
|
||||
# processing). Final-review fix: the payload's payment_status
|
||||
# (attacker-immutable: it is inside the HMAC) gates the flip +
|
||||
# the grant; an unpaid/processing completion is recorded, not
|
||||
# granted, and a later paid delivery grants.
|
||||
payment_status = str(payload.get("payment_status", "unpaid"))
|
||||
if payment_status not in ("paid", "no_payment_required"):
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
name,
|
||||
detail={
|
||||
"reason": f"payment_status {payment_status!r} not paid",
|
||||
"session_id": local.id,
|
||||
},
|
||||
)
|
||||
return "processing"
|
||||
# Status resolves from the LOCAL row's snapshot (the route flips
|
||||
# it on completion), never the payload's other fields.
|
||||
store.complete_checkout_session(local.id, payment_status=payment_status)
|
||||
_grant_entitlement(store, local)
|
||||
store.insert_billing_event(
|
||||
"provider",
|
||||
name,
|
||||
employer_id=local.employer_id,
|
||||
detail={"session_id": local.id},
|
||||
)
|
||||
return "granted"
|
||||
if name == "invoice.paid":
|
||||
subscription_ref = str(payload.get("subscription_id", ""))
|
||||
extended = store.extend_subscription_period(subscription_ref, days=30)
|
||||
if extended is None:
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
name,
|
||||
detail={
|
||||
"reason": "unknown subscription",
|
||||
"subscription_id": subscription_ref,
|
||||
},
|
||||
)
|
||||
return "unmatched"
|
||||
store.insert_billing_event(
|
||||
"provider", name, employer_id=extended.employer_id
|
||||
)
|
||||
return "renewed"
|
||||
if name == "invoice.payment_failed":
|
||||
subscription_ref = str(payload.get("subscription_id", ""))
|
||||
subscription = store.get_subscription_by_provider_id(subscription_ref)
|
||||
if subscription is None:
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
name,
|
||||
detail={
|
||||
"reason": "unknown subscription",
|
||||
"subscription_id": subscription_ref,
|
||||
},
|
||||
)
|
||||
return "unmatched"
|
||||
try:
|
||||
store.transition_subscription(subscription.id, "past_due")
|
||||
except Exception:
|
||||
# Already reflected (out-of-order tolerance, G-306) — record.
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
name,
|
||||
detail={"reason": "already past_due", "subscription_id": subscription_ref},
|
||||
)
|
||||
return "already"
|
||||
store.insert_billing_event(
|
||||
"provider", name, employer_id=subscription.employer_id
|
||||
)
|
||||
return "past_due"
|
||||
if name == "subscription.cancelled":
|
||||
subscription_ref = str(payload.get("subscription_id", ""))
|
||||
subscription = store.get_subscription_by_provider_id(subscription_ref)
|
||||
if subscription is None:
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
name,
|
||||
detail={
|
||||
"reason": "unknown subscription",
|
||||
"subscription_id": subscription_ref,
|
||||
},
|
||||
)
|
||||
return "unmatched"
|
||||
try:
|
||||
store.transition_subscription(subscription.id, "cancelled")
|
||||
except Exception:
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
name,
|
||||
detail={"reason": "already cancelled", "subscription_id": subscription_ref},
|
||||
)
|
||||
return "already"
|
||||
store.insert_billing_event(
|
||||
"provider", name, employer_id=subscription.employer_id
|
||||
)
|
||||
return "cancelled"
|
||||
if name == "subscription.renewed":
|
||||
# R-43: renewal = invoice.paid on a recurring subscription; the
|
||||
# corroborating customer.subscription.updated is recorded here.
|
||||
return _process_canonical_event(store, "invoice.paid", payload)
|
||||
store.insert_billing_event(
|
||||
"unmatched", name, detail={"reason": "unhandled canonical name"}
|
||||
)
|
||||
return "unhandled"
|
||||
|
||||
|
||||
def _grant_entitlement(store: BillingStore, session_row: object) -> str:
|
||||
"""Create the purchase or subscription from the LOCAL snapshot
|
||||
(D-078 prices; G-303 exactly-once per session — the store's
|
||||
UniqueConstraint refuses a second grant; the refusal is the
|
||||
already-granted answer, recorded not raised)."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..billing.store import CheckoutSessionRow
|
||||
|
||||
assert isinstance(session_row, CheckoutSessionRow)
|
||||
if session_row.mode == "payment":
|
||||
package, _, _item = store.get_catalog_item(session_row.item_id)
|
||||
if package is not None:
|
||||
try:
|
||||
store.create_purchase_from_session(
|
||||
session_row,
|
||||
posts_limit=package.posts_limit,
|
||||
duration_days=package.duration_days,
|
||||
)
|
||||
return "granted"
|
||||
except IntegrityError:
|
||||
# G-303: a distinct event id announced the SAME
|
||||
# completion — the grant already happened; the 200 is
|
||||
# the honest answer (never a 500, R-45).
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
"checkout.completed",
|
||||
employer_id=session_row.employer_id,
|
||||
detail={
|
||||
"reason": "already granted",
|
||||
"session_id": session_row.id,
|
||||
},
|
||||
)
|
||||
return "already"
|
||||
# Final-review fix: the item vanished from the catalog AFTER the
|
||||
# checkout row snapshotted it (an operator deleted a seed id) —
|
||||
# record the anomaly; the caller's "granted"-shaped answer must
|
||||
# never silently mask a missing grant (the audit log answers
|
||||
# "why does this employer have no entitlement?").
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
"checkout.completed",
|
||||
employer_id=session_row.employer_id,
|
||||
detail={
|
||||
"reason": "catalog item missing at grant time",
|
||||
"item_id": session_row.item_id,
|
||||
"session_id": session_row.id,
|
||||
},
|
||||
)
|
||||
return "no-item"
|
||||
_, plan, _item = store.get_catalog_item(session_row.item_id)
|
||||
if plan is not None:
|
||||
# Upsert keyed on the provider ref — replay-safe by construction.
|
||||
# Final-review fix: the ref is the mock provider's DOCUMENTED
|
||||
# convention (`sub_mock_{session_id without its cs_ prefix}`,
|
||||
# mock.py confirm_completion) — the old `sub_prov_…` spelling
|
||||
# made the store row's provider_subscription_id a key the
|
||||
# provider never knew, so every provider lifecycle call
|
||||
# (cancel/update) resolved None and desynced from local state.
|
||||
store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id=(
|
||||
f"sub_mock_{session_row.provider_session_id.removeprefix('cs_')}"
|
||||
),
|
||||
interval=plan.interval,
|
||||
)
|
||||
return "granted"
|
||||
store.insert_billing_event(
|
||||
"unmatched",
|
||||
"checkout.completed",
|
||||
employer_id=session_row.employer_id,
|
||||
detail={
|
||||
"reason": "catalog item missing at grant time",
|
||||
"item_id": session_row.item_id,
|
||||
"session_id": session_row.id,
|
||||
},
|
||||
)
|
||||
return "no-item"
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def billing_webhook(
|
||||
request: Request,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
) -> Response:
|
||||
"""Public provider webhook receiver (server-to-server; CORS
|
||||
irrelevant). Risk-1 discipline: the RAW BODY is read + verified
|
||||
BEFORE any parsing — a pre-parsed body breaks the HMAC."""
|
||||
raw_body = await request.body()
|
||||
signature = request.headers.get("Stripe-Signature", "")
|
||||
await process_webhook_event(
|
||||
store=store,
|
||||
provider=_get_payment_provider(request),
|
||||
raw_body=raw_body,
|
||||
signature_header=signature,
|
||||
)
|
||||
return Response(content='{"received": true}', media_type="application/json")
|
||||
@@ -0,0 +1,536 @@
|
||||
"""/v1/billing/admin — operator billing API (REQ-9-003, D-076).
|
||||
|
||||
Operator-mediated checkout: at pilot there are no employer principals
|
||||
(A-407 — employers are data, not principals), so the operator buys on
|
||||
an employer's behalf. Every route sits behind `require_admin`
|
||||
(fail-closed, R-51 — copied verbatim from marketplace_admin.py); the
|
||||
checkout records operator_ref + employer_id provenance (D-076).
|
||||
|
||||
The mock-complete route (D-082, amended G-301) is admin-gated AND
|
||||
mounted only under provider=mock — the operator completing a session
|
||||
after out-of-band payment is the honest pilot flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..billing.base import (
|
||||
CheckoutSessionSpec,
|
||||
)
|
||||
from ..billing.store import BillingStore
|
||||
from ..config import Settings
|
||||
from .deps import get_billing_store
|
||||
from .marketplace_admin import require_admin
|
||||
|
||||
router = APIRouter(prefix="/v1/billing/admin", tags=["billing-admin"])
|
||||
|
||||
|
||||
# -- contracts ----------------------------------------------------------------
|
||||
|
||||
|
||||
class CheckoutCreate(BaseModel):
|
||||
"""The operator's checkout request (D-076): which catalog item, on
|
||||
behalf of which employer."""
|
||||
|
||||
item_id: str = Field(min_length=1)
|
||||
employer_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class CheckoutResponse(BaseModel):
|
||||
"""The created session handle + the local reconciliation id."""
|
||||
|
||||
session_id: str
|
||||
provider_session_id: str
|
||||
url: str
|
||||
mode: str
|
||||
|
||||
|
||||
class MockCompleteRequest(BaseModel):
|
||||
"""D-082/G-301: the operator completes a mock session after
|
||||
out-of-band payment. Admin-gated + mock-provider-only."""
|
||||
|
||||
provider_session_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class MockCompleteResponse(BaseModel):
|
||||
completed: bool
|
||||
payment_status: str
|
||||
|
||||
|
||||
class EntitlementResponse(BaseModel):
|
||||
"""The per-employer entitlement summary (REQ-9-004): active
|
||||
purchase (posts used/limit/expiry) + subscription state. The admin
|
||||
surface renders this (G-308's token-gated fetch)."""
|
||||
|
||||
employer_id: str
|
||||
purchase: dict[str, object] | None
|
||||
subscription: dict[str, object] | None
|
||||
|
||||
|
||||
# -- checkout (Task 3-2-01, G-310) ---------------------------------------------
|
||||
|
||||
|
||||
@router.post("/checkout", status_code=201, response_model=CheckoutResponse)
|
||||
async def create_checkout(
|
||||
body: CheckoutCreate,
|
||||
request: Request,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> CheckoutResponse:
|
||||
"""Create a provider checkout session for a catalog item on an
|
||||
employer's behalf (D-076). The catalog item snapshot (name +
|
||||
price_cents, D-078) rides the session and the local reconciliation
|
||||
row; success/cancel URLs default from public_base_url (G-310)."""
|
||||
package, plan, item = store.get_catalog_item(body.item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="unknown catalog item")
|
||||
mode = "subscription" if item["kind"] == "plan" else "payment"
|
||||
interval: str = str(item.get("interval", "") or "")
|
||||
if settings.public_base_url:
|
||||
success_url = (
|
||||
f"{settings.public_base_url}/marketplace/pricing?checkout=success"
|
||||
)
|
||||
cancel_url = (
|
||||
f"{settings.public_base_url}/marketplace/pricing?checkout=cancelled"
|
||||
)
|
||||
else:
|
||||
# Mock pilot path: the mock provider never redirects; empty is
|
||||
# honest (stripe factory validation rejects the empty case —
|
||||
# G-310's loud boot warning covers the stripe selection).
|
||||
success_url = ""
|
||||
cancel_url = ""
|
||||
# The reconciliation key is the LOCAL row id (R-34): persist the
|
||||
# snapshot row FIRST, then mint the spec against it. The provider
|
||||
# session id is bound AFTER the provider call — until then the
|
||||
# placeholder must be UNIQUE (final-review fix: a bare "pending"
|
||||
# collided on the provider_session_id UNIQUE constraint the moment
|
||||
# two checkouts raced — the second admin POST 500'd with
|
||||
# IntegrityError; a fresh uuid per row makes the placeholder a
|
||||
# proper placeholder).
|
||||
row = store.insert_checkout_session(
|
||||
provider_session_id=f"pending-{uuid.uuid4().hex[:16]}",
|
||||
employer_id=body.employer_id,
|
||||
item_id=str(item["id"]),
|
||||
item_name=str(item["name"]),
|
||||
price_cents=int(item["price_cents"]),
|
||||
mode=mode,
|
||||
)
|
||||
spec = CheckoutSessionSpec(
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
item_id=str(item["id"]),
|
||||
price_cents=int(item["price_cents"]),
|
||||
item_name=str(item["name"]),
|
||||
interval=interval,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
client_reference_id=row.id,
|
||||
metadata={
|
||||
"employer_id": body.employer_id,
|
||||
"operator_ref": "operator",
|
||||
},
|
||||
)
|
||||
provider = request.app.state.payment_provider
|
||||
session = await provider.create_checkout_session(spec)
|
||||
# Pin the provider session id onto the local row (upsert by local
|
||||
# id — the row IS the reconciliation record, G-302).
|
||||
store.bind_provider_session_id(row.id, session.id)
|
||||
return CheckoutResponse(
|
||||
session_id=row.id,
|
||||
provider_session_id=session.id,
|
||||
url=session.url,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
|
||||
# -- mock-complete (D-082, amended G-301) ---------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mock/complete", status_code=200, response_model=MockCompleteResponse
|
||||
)
|
||||
async def mock_complete(
|
||||
body: MockCompleteRequest,
|
||||
request: Request,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> MockCompleteResponse:
|
||||
"""Complete a MOCK checkout session after out-of-band payment
|
||||
(D-082/G-301): admin-gated, mounted only under provider=mock —
|
||||
this route 404s on a stripe-configured box (the factory-conditional
|
||||
mount lives in main.py). The completion mints a properly-signed
|
||||
event through the REAL webhook processing path."""
|
||||
from ..billing.mock import MockPaymentProvider
|
||||
|
||||
provider = request.app.state.payment_provider
|
||||
if not isinstance(provider, MockPaymentProvider):
|
||||
# main.py gates the mount; this arm is defense-in-depth.
|
||||
raise HTTPException(
|
||||
status_code=404, detail="mock-complete exists only under provider=mock"
|
||||
)
|
||||
session = store.get_checkout_session_by_provider_id(body.provider_session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="unknown checkout session")
|
||||
try:
|
||||
session_after = await provider.confirm_completion(
|
||||
body.provider_session_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
# G-301 idempotency (the mock's documented contract: the ROUTE
|
||||
# owns it): the provider refuses a re-completion ("not open" —
|
||||
# operator double-click) or lost its in-memory state across a
|
||||
# restart ("unknown session"). The provider's CURRENT view is
|
||||
# authoritative — resolve via retrieve_session and re-run the
|
||||
# REAL webhook path (idempotent per session, G-303); NEVER a
|
||||
# 500 on the payment surface (G-306).
|
||||
recovered = await provider.retrieve_session(body.provider_session_id)
|
||||
if recovered is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
"the provider no longer knows this session (a restart "
|
||||
"clears mock state) — create a new checkout"
|
||||
),
|
||||
) from exc
|
||||
if recovered.status != "complete":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"session is {recovered.status!r}, not completable",
|
||||
) from exc
|
||||
# Already complete at the provider: fall through with the
|
||||
# recovered truth — the re-minted event re-runs the grant path,
|
||||
# and G-303's per-session exactly-once refuses a second grant.
|
||||
session_after = recovered
|
||||
# Mint + process the signed event through the REAL webhook path
|
||||
# (direct handler call — same code path, G-301's E2E proof).
|
||||
from .billing import process_webhook_event
|
||||
|
||||
raw_body, header, event_id = provider.mint_event(
|
||||
"checkout.completed",
|
||||
{
|
||||
"session_id": body.provider_session_id,
|
||||
"payment_status": session_after.payment_status,
|
||||
},
|
||||
)
|
||||
await process_webhook_event(
|
||||
store=store,
|
||||
provider=provider,
|
||||
raw_body=raw_body,
|
||||
signature_header=header,
|
||||
)
|
||||
return MockCompleteResponse(
|
||||
completed=session_after.status == "complete",
|
||||
payment_status=session_after.payment_status,
|
||||
)
|
||||
|
||||
|
||||
# -- entitlements (Task 4-1-02) ------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/entitlements/{employer_id}", status_code=200, response_model=EntitlementResponse
|
||||
)
|
||||
def get_entitlement(
|
||||
employer_id: str,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> EntitlementResponse:
|
||||
"""The employer's entitlement state: active package (posts used/
|
||||
limit/expiry — lazy past_due grace per G-306) + subscription."""
|
||||
entitlement = store.get_entitlement(employer_id)
|
||||
return EntitlementResponse(
|
||||
employer_id=entitlement["employer_id"],
|
||||
purchase=entitlement["purchase"],
|
||||
subscription=entitlement["subscription"],
|
||||
)
|
||||
|
||||
|
||||
# -- subscription lifecycle + manual grants + event log (Task 5-1-02) -------------
|
||||
|
||||
|
||||
class LifecycleResponse(BaseModel):
|
||||
"""A provider-backed or manual lifecycle transition result."""
|
||||
|
||||
subscription_id: str
|
||||
status: str
|
||||
provider_action: str | None = None
|
||||
|
||||
|
||||
class ManualGrantRequest(BaseModel):
|
||||
"""D-080's manual path: the operator grants enterprise unlimited
|
||||
posts or a plan on an employer (remediation for failed webhooks —
|
||||
D-083: recorded with provenance)."""
|
||||
|
||||
note: str = ""
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscriptions/{subscription_id}/cancel",
|
||||
status_code=200,
|
||||
response_model=LifecycleResponse,
|
||||
)
|
||||
async def cancel_subscription(
|
||||
subscription_id: str,
|
||||
request: Request,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> LifecycleResponse:
|
||||
"""Cancel a subscription (R-46): the provider call first (stripe:
|
||||
DELETE / the mock: state-only), then the local transition. Manual
|
||||
cancellation records provenance (D-083)."""
|
||||
subscription = store.get_subscription(subscription_id)
|
||||
if subscription is None:
|
||||
raise HTTPException(status_code=404, detail="unknown subscription")
|
||||
provider_action: str | None = None
|
||||
provider = request.app.state.payment_provider
|
||||
if hasattr(provider, "cancel_subscription"):
|
||||
ref = await provider.cancel_subscription(subscription.provider_subscription_id)
|
||||
provider_action = f"cancel:{ref.status}" if ref else "cancel:unknown"
|
||||
try:
|
||||
updated = store.transition_subscription(subscription_id, "cancelled")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
assert updated is not None
|
||||
store.insert_billing_event(
|
||||
"manual",
|
||||
"cancel",
|
||||
employer_id=subscription.employer_id,
|
||||
operator="operator",
|
||||
detail={"subscription_id": subscription_id},
|
||||
)
|
||||
return LifecycleResponse(
|
||||
subscription_id=subscription_id,
|
||||
status=updated.status,
|
||||
provider_action=provider_action,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscriptions/{subscription_id}/resume",
|
||||
status_code=200,
|
||||
response_model=LifecycleResponse,
|
||||
)
|
||||
def resume_subscription(
|
||||
subscription_id: str,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> LifecycleResponse:
|
||||
"""Resume past_due → active (the recovery transition; only the
|
||||
matrix's legal moves — InvalidTransition is the honest 409)."""
|
||||
subscription = store.get_subscription(subscription_id)
|
||||
if subscription is None:
|
||||
raise HTTPException(status_code=404, detail="unknown subscription")
|
||||
try:
|
||||
updated = store.transition_subscription(subscription_id, "active")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
assert updated is not None
|
||||
store.insert_billing_event(
|
||||
"manual",
|
||||
"resume",
|
||||
employer_id=subscription.employer_id,
|
||||
operator="operator",
|
||||
detail={"subscription_id": subscription_id},
|
||||
)
|
||||
return LifecycleResponse(subscription_id=subscription_id, status=updated.status)
|
||||
|
||||
|
||||
class UpgradeRequest(BaseModel):
|
||||
plan_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscriptions/{subscription_id}/upgrade",
|
||||
status_code=200,
|
||||
response_model=LifecycleResponse,
|
||||
)
|
||||
async def upgrade_subscription(
|
||||
subscription_id: str,
|
||||
body: UpgradeRequest,
|
||||
request: Request,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> LifecycleResponse:
|
||||
"""G-305: upgrade is a plan_id transition WITHIN active (never a
|
||||
status move); the store records the manual billing_event with
|
||||
provenance. Stripe: the provider's update_subscription resolves
|
||||
items[0][id] inside (R-47 — the dual-charge guard)."""
|
||||
subscription = store.get_subscription(subscription_id)
|
||||
if subscription is None:
|
||||
raise HTTPException(status_code=404, detail="unknown subscription")
|
||||
_package, plan, item = store.get_catalog_item(body.plan_id)
|
||||
if plan is None or item is None:
|
||||
raise HTTPException(status_code=404, detail="unknown plan")
|
||||
provider = request.app.state.payment_provider
|
||||
if hasattr(provider, "update_subscription"):
|
||||
await provider.update_subscription(
|
||||
subscription.provider_subscription_id, int(item["price_cents"])
|
||||
)
|
||||
try:
|
||||
updated = store.upgrade_subscription(
|
||||
subscription_id,
|
||||
body.plan_id,
|
||||
str(item["name"]),
|
||||
int(item["price_cents"]),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
assert updated is not None
|
||||
return LifecycleResponse(
|
||||
subscription_id=subscription_id,
|
||||
status=updated.status,
|
||||
provider_action=f"update:{body.plan_id}",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/employers/{employer_id}/grant",
|
||||
status_code=201,
|
||||
response_model=EntitlementResponse,
|
||||
)
|
||||
def manual_grant(
|
||||
employer_id: str,
|
||||
request: Request,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> EntitlementResponse:
|
||||
"""D-080's manual unlimited grant (the enterprise path — operator
|
||||
remediation for failed webhooks; D-083: manual provenance in the
|
||||
audit log)."""
|
||||
session_row = store.insert_checkout_session(
|
||||
# Final-review fix: a per-call unique placeholder — a second
|
||||
# grant for the same employer collided on the
|
||||
# provider_session_id UNIQUE constraint (500) where the route
|
||||
# promises idempotent re-grants.
|
||||
provider_session_id=f"cs_manual_{uuid.uuid4().hex[:16]}",
|
||||
employer_id=employer_id,
|
||||
item_id="pkg-enterprise",
|
||||
item_name="Enterprise (manual grant)",
|
||||
price_cents=0,
|
||||
mode="payment",
|
||||
)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=None, duration_days=None
|
||||
)
|
||||
store.insert_billing_event(
|
||||
"manual",
|
||||
"grant",
|
||||
employer_id=employer_id,
|
||||
operator="operator",
|
||||
detail={"grant": "unlimited"},
|
||||
)
|
||||
entitlement = store.get_entitlement(employer_id)
|
||||
return EntitlementResponse(
|
||||
employer_id=employer_id,
|
||||
purchase=entitlement["purchase"],
|
||||
subscription=entitlement["subscription"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/employers/{employer_id}/revoke",
|
||||
status_code=200,
|
||||
response_model=EntitlementResponse,
|
||||
)
|
||||
def manual_revoke(
|
||||
employer_id: str,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> EntitlementResponse:
|
||||
"""Revoke every purchase grant for the employer (manual remediation
|
||||
— the inverse of the grant; provenance recorded)."""
|
||||
from sqlalchemy import text as _text
|
||||
from sqlmodel import Session as _Session
|
||||
|
||||
# Final-review fix: bind a NAIVE utcnow — the same format the ORM's
|
||||
# sqlite DATETIME processor writes. The old aware bind (+00:00
|
||||
# suffix) left mixed-format TEXT in the column; comparisons stayed
|
||||
# correct in practice (ISO prefix ordering) but the column held two
|
||||
# spellings of the same field. One format, one discipline.
|
||||
with _Session(store._engine) as session: # type: ignore[attr-defined]
|
||||
session.execute(
|
||||
_text(
|
||||
"UPDATE billing_purchases SET expires_at = :past "
|
||||
"WHERE employer_id = :employer_id"
|
||||
),
|
||||
{
|
||||
"past": _utc_now_minus_day().replace(tzinfo=None),
|
||||
"employer_id": employer_id,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
store.insert_billing_event(
|
||||
"manual",
|
||||
"revoke",
|
||||
employer_id=employer_id,
|
||||
operator="operator",
|
||||
detail={"revoke": "all purchases"},
|
||||
)
|
||||
entitlement = store.get_entitlement(employer_id)
|
||||
return EntitlementResponse(
|
||||
employer_id=employer_id,
|
||||
purchase=entitlement["purchase"],
|
||||
subscription=entitlement["subscription"],
|
||||
)
|
||||
|
||||
|
||||
def _utc_now_minus_day():
|
||||
"""Naive-UTC (matches the ORM's sqlite DATETIME storage format —
|
||||
see the revoke route's binding note)."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
return datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1)
|
||||
|
||||
|
||||
class BillingEventRow_(BaseModel):
|
||||
"""The audit-trail wire row (REQ-9-005's queryable log)."""
|
||||
|
||||
id: int
|
||||
employer_id: str | None
|
||||
kind: str
|
||||
name: str
|
||||
detail: dict[str, object]
|
||||
operator: str | None
|
||||
created_at: str | None
|
||||
|
||||
|
||||
class BillingEventListResponse(BaseModel):
|
||||
events: list[BillingEventRow_]
|
||||
count: int
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events", status_code=200, response_model=BillingEventListResponse
|
||||
)
|
||||
def list_events(
|
||||
employer_id: str | None = None,
|
||||
kind: str | None = None,
|
||||
name: str | None = None,
|
||||
limit: int = 100,
|
||||
store: BillingStore = Depends(get_billing_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> BillingEventListResponse:
|
||||
"""The billing event log (REQ-9-005): every provider event, manual
|
||||
action (with operator provenance), and unmatched delivery —
|
||||
filterable by employer/kind/name."""
|
||||
rows = store.list_billing_events(
|
||||
employer_id=employer_id, kind=kind, name=name, limit=limit
|
||||
)
|
||||
return BillingEventListResponse(
|
||||
events=[
|
||||
BillingEventRow_(
|
||||
id=r.id or 0,
|
||||
employer_id=r.employer_id,
|
||||
kind=r.kind,
|
||||
name=r.name,
|
||||
detail=dict(r.detail),
|
||||
operator=r.operator,
|
||||
created_at=r.created_at.isoformat() if r.created_at else None,
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
count=len(rows),
|
||||
)
|
||||
@@ -5,12 +5,15 @@ from fastapi import Request
|
||||
from ..agents.examiner import ExaminerAgent
|
||||
from ..agents.registry import AgentRegistry
|
||||
from ..agents.session import SessionStore
|
||||
from ..billing.store import BillingStore
|
||||
from ..config import Settings
|
||||
from ..employers.store import EmployerStore
|
||||
from ..grading.engine import GradingEngine
|
||||
from ..grading.store import GradeStore
|
||||
from ..llm.base import LLMProvider
|
||||
from ..marketplace.embedding.base import EmbeddingProvider
|
||||
from ..marketplace.store import MarketplaceStore
|
||||
from ..placements.store import PlacementStore
|
||||
from ..sandbox.manager import SandboxManager
|
||||
from ..sandbox.workdir import SandboxDir
|
||||
from ..telemetry.ingest import TraceIntegrityMap
|
||||
@@ -87,6 +90,25 @@ def get_marketplace_store(request: Request) -> MarketplaceStore:
|
||||
return request.app.state.marketplace_store
|
||||
|
||||
|
||||
def get_billing_store(request: Request) -> BillingStore:
|
||||
"""v0.9 billing (D-080): the same app.state DI territory rule —
|
||||
billing routes compose the store, never import it."""
|
||||
return request.app.state.billing_store
|
||||
|
||||
|
||||
def get_employer_store(request: Request) -> EmployerStore:
|
||||
"""v0.10 employers (D-087): the same app.state DI territory rule —
|
||||
employer routes compose the store via app.state, never import it."""
|
||||
return request.app.state.employer_store
|
||||
|
||||
|
||||
def get_placement_store(request: Request) -> PlacementStore:
|
||||
"""v0.11 placements (D-100/D-112): the same app.state DI territory
|
||||
rule — placement routes compose the store via app.state, never
|
||||
import it."""
|
||||
return request.app.state.placement_store
|
||||
|
||||
|
||||
def get_embedding_provider(request: Request) -> EmbeddingProvider:
|
||||
"""v0.6 P3 (D-051): the embedding port via app.state DI — the
|
||||
search route + publish hooks compose it, never import it."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
"""/v1/employers/admin — employer claim operator surface (REQ-10-002, D-089/D-094).
|
||||
|
||||
Every route sits behind `require_admin` IMPORTED from
|
||||
marketplace_admin (the billing_admin.py:27 precedent, D-094 — one
|
||||
guard, never reimplemented): fail-closed 403 on no/missing/invalid
|
||||
token — the R-56 matrix; an UNSET AI_ADMIN_TOKEN 403s every route
|
||||
even with a candidate (the main.py boot warning covers the config
|
||||
class). Operator routes compose NO learner gate (D-097) and no
|
||||
employer-session gate — a different principal class, and the token
|
||||
namespaces cannot collide (this gate compares bytes; the session
|
||||
gate hash-looks-up — D-090).
|
||||
|
||||
D-089: evidence is ADVISORY — the queue returns the stored claim-time
|
||||
provenance snapshot as-is; approval is the EXPLICIT operator decision
|
||||
(auto-approval deferred: without email verification the match is
|
||||
forgeable, A-304 — never present unverified as verified). Every
|
||||
decision appends an employer_audit_events row with operator
|
||||
provenance in the SAME transaction as the claim update (decide_claim
|
||||
— all-or-nothing). The accounts wire contract carries NO password_hash
|
||||
(hash-at-rest, D-087 — never returned anywhere).
|
||||
|
||||
409 discipline (the decide arms' honest constraint 409s — details
|
||||
carry constraint names only, never account identifiers):
|
||||
- claim not pending (decided twice) → "claim is already decided"
|
||||
- the deciding account already holds an entity (the one-account-
|
||||
one-entity guard) → "the claim's account already holds an employer
|
||||
entity"
|
||||
- the entity already owned (the employer_accounts.employer_id UNIQUE
|
||||
surfacing on the approve flush) → "the employer entity is already
|
||||
owned" — surfaced, NEVER silently overwritten.
|
||||
Any OTHER IntegrityError re-raises (a 500) — never mask an unknown
|
||||
constraint violation as a decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..config import Settings
|
||||
from ..employers.base import EmployerClaim
|
||||
from ..employers.store import ClaimRow, EmployerStore
|
||||
from .deps import get_employer_store
|
||||
from .marketplace_admin import require_admin
|
||||
|
||||
router = APIRouter(prefix="/v1/employers/admin", tags=["employers-admin"])
|
||||
|
||||
#: Operator provenance stamped on every decision + audit event (the
|
||||
#: billing_admin "operator" precedent — route-level provenance; no
|
||||
#: per-operator accounts exist at pilot, D-094).
|
||||
OPERATOR = "operator"
|
||||
|
||||
|
||||
# -- contracts ----------------------------------------------------------------
|
||||
|
||||
|
||||
class AdminEmployerAccount(BaseModel):
|
||||
"""The accounts-list wire row (D-094): email, claimed entity,
|
||||
created_at, the UNVERIFIED marker. `email_verified` is
|
||||
`Literal[False]` (D-097 — structurally incapable of True: no email
|
||||
verification exists in v0.10, so the field can never carry it).
|
||||
password_hash is NEVER on the wire (D-087)."""
|
||||
|
||||
id: str
|
||||
email: str
|
||||
employer_id: str | None
|
||||
created_at: str
|
||||
#: D-097: the account is UNVERIFIED — displayed as such, never as
|
||||
#: verified (A-304's doctrine applied to the account surface).
|
||||
email_verified: Literal[False] = False
|
||||
|
||||
|
||||
class AdminAccountsResponse(BaseModel):
|
||||
accounts: list[AdminEmployerAccount]
|
||||
count: int
|
||||
|
||||
|
||||
class AdminClaimsResponse(BaseModel):
|
||||
"""The claim queue (D-089): status, the stored claim-time evidence
|
||||
snapshot — the domain-match signal rides evidence["match"],
|
||||
ADVISORY, never a verification — and the decided state
|
||||
(decided_by/decided_at, None while pending)."""
|
||||
|
||||
claims: list[EmployerClaim]
|
||||
count: int
|
||||
|
||||
|
||||
class ClaimDecisionResponse(BaseModel):
|
||||
"""The decided claim (status approved/rejected + operator
|
||||
provenance) — either decision arm returns the same shape."""
|
||||
|
||||
claim: EmployerClaim
|
||||
|
||||
|
||||
class AdminAuditEvent(BaseModel):
|
||||
"""The audit-trail wire row (D-089's queryable operator trail —
|
||||
the BillingEventRow_ shape, billing_admin.py:488)."""
|
||||
|
||||
id: int
|
||||
account_id: str | None
|
||||
employer_id: str | None
|
||||
kind: str
|
||||
name: str
|
||||
detail: dict[str, object]
|
||||
operator: str | None
|
||||
created_at: str | None
|
||||
|
||||
|
||||
class AdminAuditResponse(BaseModel):
|
||||
events: list[AdminAuditEvent]
|
||||
count: int
|
||||
|
||||
|
||||
def _claim_to_contract(row: ClaimRow) -> EmployerClaim:
|
||||
"""Store row → wire contract (created_at is always set on a row
|
||||
the store returned — insert/decide stamp it)."""
|
||||
assert row.created_at is not None # the store always stamps it
|
||||
return EmployerClaim(
|
||||
id=row.id,
|
||||
account_id=row.account_id,
|
||||
employer_id=row.employer_id,
|
||||
status=row.status, # type: ignore[arg-type]
|
||||
evidence=dict(row.evidence or {}),
|
||||
created_at=row.created_at,
|
||||
decided_by=row.decided_by,
|
||||
decided_at=row.decided_at,
|
||||
)
|
||||
|
||||
|
||||
# -- queue reads (D-094) ---------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=AdminAccountsResponse)
|
||||
def list_accounts(
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
settings: Annotated[Settings, Depends(require_admin)],
|
||||
) -> AdminAccountsResponse:
|
||||
"""The accounts list: email, claimed entity, created_at, and the
|
||||
UNVERIFIED marker (D-097 — account emails are unverified and
|
||||
displayed as such; a claimed entity column shows the APPROVED
|
||||
ownership binding, never account verification)."""
|
||||
rows = store.list_accounts()
|
||||
return AdminAccountsResponse(
|
||||
accounts=[
|
||||
AdminEmployerAccount(
|
||||
id=account.id,
|
||||
email=account.email,
|
||||
employer_id=account.employer_id,
|
||||
created_at=account.created_at.isoformat() if account.created_at else "",
|
||||
email_verified=False, # D-097: UNVERIFIED, always False
|
||||
)
|
||||
for account in rows
|
||||
],
|
||||
count=len(rows),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/claims", response_model=AdminClaimsResponse)
|
||||
def list_claims(
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
settings: Annotated[Settings, Depends(require_admin)],
|
||||
status: str | None = None,
|
||||
) -> AdminClaimsResponse:
|
||||
"""The claim queue: status, the stored evidence snapshot (the
|
||||
domain-match signal is ADVISORY — the signal informs, the operator
|
||||
decides; match=false is the example.com corpus norm, R-65), and
|
||||
the decided state. Optional ?status= facet (the admin queue's
|
||||
consume-free read path)."""
|
||||
rows = store.list_claims(status=status)
|
||||
return AdminClaimsResponse(
|
||||
claims=[_claim_to_contract(row) for row in rows],
|
||||
count=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# -- decisions (D-089 — the explicit operator action) ----------------------------
|
||||
|
||||
|
||||
def _decide(store: EmployerStore, claim_id: str, decision: str) -> EmployerClaim:
|
||||
"""The shared decide path: unknown claim → 404; the store's guard
|
||||
arms + the employer_accounts.employer_id UNIQUE surface as the
|
||||
honest 409s (constraint names only — never account identifiers);
|
||||
decide_claim appends the operator audit event in the SAME
|
||||
transaction as the decision."""
|
||||
try:
|
||||
row = store.decide_claim(claim_id, decision=decision, operator=OPERATOR)
|
||||
except IntegrityError as exc:
|
||||
message = str(getattr(exc, "orig", exc))
|
||||
if "employer_accounts.employer_id already set" in message:
|
||||
# decide_claim's account-already-owner guard — the
|
||||
# one-account-one-entity arm, hand-raised by the store.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=("the claim's account already holds an employer entity"),
|
||||
) from None
|
||||
if "UNIQUE constraint failed: employer_accounts.employer_id" in (message):
|
||||
# The approve flush refused: a DIFFERENT account already
|
||||
# owns the entity — surfaced, never silently overwritten.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="the employer entity is already owned",
|
||||
) from None
|
||||
if "is already" in message:
|
||||
# decide_claim's not-pending guard — deciding a decided
|
||||
# claim, the IntegrityError shape (the honest 409).
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="claim is already decided",
|
||||
) from None
|
||||
# Unmapped IntegrityError — re-raise (a 500): never mask an
|
||||
# unknown constraint violation as a decision.
|
||||
raise
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown claim")
|
||||
return _claim_to_contract(row)
|
||||
|
||||
|
||||
@router.post("/claims/{claim_id}/approve", response_model=ClaimDecisionResponse)
|
||||
def approve_claim(
|
||||
claim_id: str,
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
settings: Annotated[Settings, Depends(require_admin)],
|
||||
) -> ClaimDecisionResponse:
|
||||
"""Approve → the claim's account.employer_id is set (one account :
|
||||
one entity — the UNIQUE activates) + decided_by/decided_at + the
|
||||
operator audit event, all in one transaction. Evidence never
|
||||
auto-activates anything: the operator decision is the action."""
|
||||
row = _decide(store, claim_id, "approved")
|
||||
return ClaimDecisionResponse(claim=row)
|
||||
|
||||
|
||||
@router.post("/claims/{claim_id}/reject", response_model=ClaimDecisionResponse)
|
||||
def reject_claim(
|
||||
claim_id: str,
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
settings: Annotated[Settings, Depends(require_admin)],
|
||||
) -> ClaimDecisionResponse:
|
||||
"""Reject → decided_by/decided_at + the operator audit event (same
|
||||
transaction); the entity is FREED for a new claim (the partial
|
||||
pending index never touches rejected rows, G-316)."""
|
||||
row = _decide(store, claim_id, "rejected")
|
||||
return ClaimDecisionResponse(claim=row)
|
||||
|
||||
|
||||
# -- audit trail (D-089's queryable operator trail) ------------------------------
|
||||
|
||||
|
||||
@router.get("/audit", response_model=AdminAuditResponse)
|
||||
def list_audit(
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
settings: Annotated[Settings, Depends(require_admin)],
|
||||
account_id: str | None = None,
|
||||
employer_id: str | None = None,
|
||||
kind: str | None = None,
|
||||
name: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> AdminAuditResponse:
|
||||
"""The queryable audit trail: every auth/claim event with operator
|
||||
provenance — the claim.submitted/claim.approved/claim.rejected
|
||||
names; decided_by/decided_at ride the claim rows the decision
|
||||
events reference (detail.claim_id joins them)."""
|
||||
rows = store.list_audit_events(
|
||||
account_id=account_id,
|
||||
employer_id=employer_id,
|
||||
kind=kind,
|
||||
name=name,
|
||||
limit=limit,
|
||||
)
|
||||
return AdminAuditResponse(
|
||||
events=[
|
||||
AdminAuditEvent(
|
||||
id=event.id or 0,
|
||||
account_id=event.account_id,
|
||||
employer_id=event.employer_id,
|
||||
kind=event.kind,
|
||||
name=event.name,
|
||||
detail=dict(event.detail or {}),
|
||||
operator=event.operator,
|
||||
created_at=event.created_at.isoformat() if event.created_at else None,
|
||||
)
|
||||
for event in rows
|
||||
],
|
||||
count=len(rows),
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""The D-091 shared posting-entitlement gate (REQ-10-004, R-58).
|
||||
|
||||
ONE helper, TWO principal gates: the admin create route (v0.9,
|
||||
marketplace_admin) and the employer create route (v0.10 Task 4-1-02)
|
||||
both compose THIS — never a re-implemented copy (the no-drift shape;
|
||||
extraction is the only way "the exact v0.9 check parameterized" holds).
|
||||
The helper lives in api/ beside both callers — no billing/→api/
|
||||
boundary violation (billing/ stays ignorant of routes, D-027/D-048).
|
||||
|
||||
Wire contract (R-58 — byte-stable, D-080/D-084): the 402 detail dict is
|
||||
{reason, posts_used: 0, posts_limit: 0, upgrade_cta} with posts_used and
|
||||
posts_limit HARDCODED 0 in BOTH arms. The zeros are the v0.9 wire shape
|
||||
the web client's UpgradeRequiredError ignores (it consumes reason +
|
||||
upgrade_cta only, apps/web/lib/engine-client.ts) — do NOT "improve" them
|
||||
into live counts; the payload-parity test pins the bytes.
|
||||
|
||||
Edge ordering (mirrors the extracted v0.9 gate exactly):
|
||||
1. store-missing (None) → 503 fail-closed BEFORE any consume attempt —
|
||||
a payment gate never silently skips (the final-review fix).
|
||||
2. consume_posting → NoActivePackage → the no_active_package 402 arm.
|
||||
3. consume_posting → PackageExhausted → the posting_limit_reached arm.
|
||||
The gate runs BEFORE the caller's insert — a 402 consumes nothing,
|
||||
creates nothing. `consume_posting` itself (G-304 single conditional
|
||||
UPDATE) is untouched; this helper only maps its exceptions to the
|
||||
honest 402 arms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..billing.store import BillingStore, NoActivePackage, PackageExhausted
|
||||
|
||||
|
||||
def consume_posting_entitlement(
|
||||
store: BillingStore | None,
|
||||
employer_id: str,
|
||||
upgrade_cta: str,
|
||||
) -> None:
|
||||
"""Spend one posting post for `employer_id`, or raise the honest 402.
|
||||
|
||||
Args:
|
||||
store: the billing store from app.state (None = a wiring miss →
|
||||
503 fail-closed — posting creation is refused until the
|
||||
service is restarted, never an ungated insert).
|
||||
employer_id: the entity whose package is charged.
|
||||
upgrade_cta: the caller-principal's checkout pointer
|
||||
("/v1/billing/admin/checkout" from the admin route; the
|
||||
employer route passes its own) — interpolated into the two
|
||||
FIXED per-arm sentences, so the rendered upgrade_cta field
|
||||
differs per principal while reason/posts_used/posts_limit
|
||||
stay byte-identical across both gates (the D-091 parity).
|
||||
|
||||
Raises:
|
||||
HTTPException 503: the store is missing (fail-closed).
|
||||
HTTPException 402: no active package / all posts spent — the
|
||||
canonical dict payload (hardcoded zeros, R-58).
|
||||
"""
|
||||
if store is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"billing entitlement store is unavailable — posting "
|
||||
"creation is refused until the service is restarted "
|
||||
"(the store wires at boot)"
|
||||
),
|
||||
)
|
||||
try:
|
||||
store.consume_posting(employer_id)
|
||||
except NoActivePackage:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"reason": "no_active_package",
|
||||
# R-58: hardcoded zeros — the wire contract, never live
|
||||
# counts (real limits render only from the entitlement
|
||||
# view; the client's UpgradeRequiredError consumes
|
||||
# reason + upgrade_cta alone).
|
||||
"posts_used": 0,
|
||||
"posts_limit": 0,
|
||||
"upgrade_cta": (
|
||||
"This employer has no active posting package — "
|
||||
f"purchase one via {upgrade_cta}."
|
||||
),
|
||||
},
|
||||
) from None
|
||||
except PackageExhausted:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"reason": "posting_limit_reached",
|
||||
"posts_used": 0,
|
||||
"posts_limit": 0,
|
||||
"upgrade_cta": (
|
||||
"This employer's posting package is exhausted — "
|
||||
f"buy more posts or upgrade via {upgrade_cta}."
|
||||
),
|
||||
},
|
||||
) from None
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
@@ -33,6 +34,15 @@ from ..marketplace.resume import (
|
||||
parse_resume,
|
||||
)
|
||||
from ..marketplace.search import FacetFilters, rank_postings
|
||||
from ..placements.base import (
|
||||
InvalidTransition,
|
||||
LearnerApplicationStatusResponse,
|
||||
LearnerApplicationSummary,
|
||||
MineApplicationResponse,
|
||||
PlacementEventRead,
|
||||
PlacementRead,
|
||||
)
|
||||
from ..placements.store import PlacementRow
|
||||
from .deps import get_embedding_provider, get_marketplace_store, get_provider, get_settings
|
||||
from .identity import require_verified_age
|
||||
|
||||
@@ -375,6 +385,19 @@ async def apply_posting(
|
||||
application, _created = store.insert_application(
|
||||
posting_id=body.posting_id, learner_id=body.learner_id
|
||||
)
|
||||
# G-323 (binding): the idempotent arm NEVER confirms a terminal
|
||||
# application — a withdrawn/rejected/hired row returns 409 (the
|
||||
# learner is told the honest state; recovery is visibility via the
|
||||
# application-status surface, not resurrection — no second row is
|
||||
# ever created; the (posting_id, learner_id) unique key stands).
|
||||
if application.status in ("withdrawn", "rejected", "hired"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"reason": "application_terminal",
|
||||
"status": application.status,
|
||||
},
|
||||
)
|
||||
return ApplyResponse(
|
||||
application_id=application.id,
|
||||
posting_id=application.posting_id,
|
||||
@@ -382,6 +405,173 @@ async def apply_posting(
|
||||
)
|
||||
|
||||
|
||||
# -- v0.11 P2: the learner withdraw route (D-104, REQ-11-002) --------------------
|
||||
|
||||
|
||||
class WithdrawBody(BaseModel):
|
||||
"""D-104: the pilot surface passes learner_id in the body (the G-5
|
||||
composition unchanged — the SAME stack as apply)."""
|
||||
|
||||
learner_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/applications/{application_id}/withdraw", response_model=ApplyResponse
|
||||
)
|
||||
async def withdraw_application(
|
||||
application_id: str,
|
||||
body: WithdrawBody,
|
||||
request: Request,
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> ApplyResponse:
|
||||
"""The learner's ONLY edit (D-099: withdrawn is learner- or
|
||||
operator-triggerable, NEVER an employer session). The D-043
|
||||
composition UNCHANGED: G-5 allowlist → verified 18+ → the EXISTING
|
||||
apply-rate window (no new throttle knob, D-088 scope untouched).
|
||||
Own application only (403 foreign); terminal → 409 (the matrix).
|
||||
"""
|
||||
_enforce_allowlist(body.learner_id, settings)
|
||||
await require_verified_age(18, body.learner_id, _identity_store(request))
|
||||
_check_apply_rate(body.learner_id)
|
||||
|
||||
application = store.get_application(application_id)
|
||||
if application is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no application with id {application_id!r}"
|
||||
)
|
||||
if application.learner_id != body.learner_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="this application belongs to another learner",
|
||||
)
|
||||
from .deps import get_placement_store # local import — the territory rule
|
||||
|
||||
placements = get_placement_store(request)
|
||||
try:
|
||||
application, _placement = placements.transition_application(
|
||||
application_id,
|
||||
"withdrawn",
|
||||
actor_type="learner",
|
||||
actor_ref=f"learner:{body.learner_id}",
|
||||
)
|
||||
except InvalidTransition as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return ApplyResponse(
|
||||
application_id=application.id,
|
||||
posting_id=application.posting_id,
|
||||
status=application.status,
|
||||
)
|
||||
|
||||
|
||||
# -- v0.11 P5: the learner status reads (D-104/G-322, REQ-11-005) -------------------
|
||||
|
||||
|
||||
def _placement_read(row: PlacementRow) -> PlacementRead:
|
||||
"""The PlacementRow → PlacementRead conversion (the placements_admin
|
||||
helper mirrored — each route file owns its readers; start_date is
|
||||
the ISO DATE string on the row, a date on the wire, D-103)."""
|
||||
return PlacementRead(
|
||||
id=row.id,
|
||||
learner_id=row.learner_id,
|
||||
employer_id=row.employer_id,
|
||||
posting_id=row.posting_id,
|
||||
application_id=row.application_id,
|
||||
role_title=row.role_title,
|
||||
start_date=date.fromisoformat(row.start_date),
|
||||
status=row.status, # type: ignore[arg-type]
|
||||
created_at=row.created_at or _now(),
|
||||
decided_at=row.decided_at or _now(),
|
||||
)
|
||||
|
||||
|
||||
def _now():
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@router.get("/applications/mine", response_model=MineApplicationResponse)
|
||||
async def mine_application(
|
||||
request: Request,
|
||||
posting_id: str = Query(min_length=1),
|
||||
learner_id: str = Query(min_length=1),
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> MineApplicationResponse:
|
||||
"""G-322 (binding): the mine point-read — the status panel's
|
||||
application DISCOVERY on revisit (the panel NEVER depends on
|
||||
client-persisted application_ids). Gate composition: G-5 allowlist
|
||||
→ verified 18+ (reads are free — the apply-rate window is the
|
||||
WRITE budget, A-408's distinct-action rule; no new throttle knob,
|
||||
D-088 untouched); no row → the honest 404 (the surface renders the
|
||||
apply CTA)."""
|
||||
_enforce_allowlist(learner_id, settings)
|
||||
await require_verified_age(18, learner_id, _identity_store(request))
|
||||
|
||||
application = store.get_application_by_posting_learner(posting_id, learner_id)
|
||||
if application is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"no application by this learner on posting {posting_id!r}",
|
||||
)
|
||||
return MineApplicationResponse(
|
||||
application_id=application.id,
|
||||
status=application.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/applications/{application_id}",
|
||||
response_model=LearnerApplicationStatusResponse,
|
||||
)
|
||||
async def learner_application_status(
|
||||
application_id: str,
|
||||
request: Request,
|
||||
learner_id: str = Query(min_length=1),
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> LearnerApplicationStatusResponse:
|
||||
"""D-104 (REQ-11-005): the learner's-own outcome read — current
|
||||
status + the FULL id-ordered timestamped history for THIS
|
||||
application + the placement record when hired (one read, one
|
||||
truth source). Same gates as the mine-read (reads are free);
|
||||
foreign learner_id → 403 (the scoped query never renders another
|
||||
learner's events); unknown → 404."""
|
||||
_enforce_allowlist(learner_id, settings)
|
||||
await require_verified_age(18, learner_id, _identity_store(request))
|
||||
|
||||
application = store.get_application(application_id)
|
||||
if application is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no application with id {application_id!r}"
|
||||
)
|
||||
if application.learner_id != learner_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="this application belongs to another learner",
|
||||
)
|
||||
from .deps import get_placement_store # local import — the territory rule
|
||||
|
||||
placements = get_placement_store(request)
|
||||
events = [
|
||||
PlacementEventRead.model_validate(event, from_attributes=True)
|
||||
for event in placements.get_application_events(application_id)
|
||||
]
|
||||
row = placements.get_placement_by_application(application_id)
|
||||
return LearnerApplicationStatusResponse(
|
||||
application=LearnerApplicationSummary(
|
||||
id=application.id,
|
||||
posting_id=application.posting_id,
|
||||
learner_id=application.learner_id,
|
||||
status=application.status,
|
||||
created_at=application.created_at,
|
||||
),
|
||||
events=events,
|
||||
placement=_placement_read(row) if row is not None else None,
|
||||
)
|
||||
|
||||
|
||||
# -- P4-B: resume upload + confirm (REQ-6-005, D-054, A-410) --------------------
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import secrets
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import Settings
|
||||
@@ -37,6 +37,7 @@ from .deps import (
|
||||
get_provider,
|
||||
get_settings,
|
||||
)
|
||||
from .entitlement import consume_posting_entitlement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -157,11 +158,30 @@ class AdminApplicationsResponse(BaseModel):
|
||||
@router.post("/postings", status_code=201, response_model=AdminPostingResponse)
|
||||
async def create_posting(
|
||||
body: AdminPostingCreate,
|
||||
request: Request,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> AdminPostingResponse:
|
||||
"""Create a DRAFT posting (founder authoring path, D-050 admin_api
|
||||
adapter — source=admin). Publish is a separate lifecycle call."""
|
||||
adapter — source=admin). Publish is a separate lifecycle call.
|
||||
|
||||
v0.10 P4 (D-091, Task 4-1-01): the inline gate is extracted into the
|
||||
shared api-layer helper `consume_posting_entitlement` (api/entitlement.
|
||||
py) — this admin route and the employer create route (Task 4-1-02)
|
||||
compose the SAME gate, never re-implemented copies. The admin
|
||||
principal passes the admin-checkout pointer; the 402 dict (hardcoded
|
||||
posts_used/posts_limit zeros, R-58) is byte-stable across both
|
||||
gates — the wire contract UpgradeRequiredError consumes unchanged.
|
||||
|
||||
Final-review fix preserved (fail-closed): the store is fetched via
|
||||
getattr(app.state); a wiring miss (None) makes the helper refuse
|
||||
with 503 — never an ungated 201.
|
||||
"""
|
||||
consume_posting_entitlement(
|
||||
getattr(request.app.state, "billing_store", None),
|
||||
body.employer_id,
|
||||
"/v1/billing/admin/checkout",
|
||||
)
|
||||
posting = Posting(
|
||||
id=f"adm-{uuid.uuid4().hex[:16]}",
|
||||
source="admin",
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Operator lane for placement oversight (D-106, REQ-11-002).
|
||||
|
||||
`require_admin` IMPORTED from marketplace_admin (the billing_admin.py
|
||||
import precedent — token namespaces cannot collide, D-090): one
|
||||
generic remediation transition route + revoke + the R-80 re-hire +
|
||||
the reporting/export reads (P3/P4 extend this router). No employer
|
||||
session, no learner gate — a third principal class (D-097).
|
||||
|
||||
OPERATOR = "operator" route-level provenance (employers_admin.py:54 —
|
||||
no per-operator accounts exist, D-094); every action is
|
||||
ledger-recorded with actor_type="operator" (the D-083 manual-grant
|
||||
lane) and answers in employer_audit_events via the P2 routes' own
|
||||
appends (operator transitions record through the placement ledger).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
|
||||
from ..config import Settings
|
||||
from ..marketplace.store import MarketplaceStore
|
||||
from ..placements.base import (
|
||||
GUIDING_GOAL,
|
||||
AdminTransitionRequest,
|
||||
ApplicationTransitionResponse,
|
||||
HireTransitionRequest,
|
||||
PlacementOverview,
|
||||
PlacementRead,
|
||||
)
|
||||
from ..placements.store import PlacementRow, PlacementStore
|
||||
from .deps import get_marketplace_store, get_placement_store
|
||||
from .marketplace_admin import require_admin
|
||||
|
||||
#: D-094: route-level operator provenance — no per-operator accounts.
|
||||
OPERATOR = "operator"
|
||||
|
||||
router = APIRouter(prefix="/v1/placements/admin", tags=["placements-admin"])
|
||||
# The admin-token gate composes on EVERY route handler via the
|
||||
# Annotated[Settings, Depends(require_admin)] house pattern (the
|
||||
# employers_admin idiom — fail-closed; token namespaces cannot collide
|
||||
# with employer session tokens, D-090).
|
||||
AdminGate = Annotated[Settings, Depends(require_admin)]
|
||||
|
||||
|
||||
def _placement_read(row: PlacementRow) -> PlacementRead:
|
||||
return PlacementRead(
|
||||
id=row.id,
|
||||
learner_id=row.learner_id,
|
||||
employer_id=row.employer_id,
|
||||
posting_id=row.posting_id,
|
||||
application_id=row.application_id,
|
||||
role_title=row.role_title,
|
||||
start_date=date.fromisoformat(row.start_date),
|
||||
status=row.status, # type: ignore[arg-type]
|
||||
created_at=row.created_at or _now(),
|
||||
decided_at=row.decided_at or _now(),
|
||||
)
|
||||
|
||||
|
||||
def _now():
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/applications/{application_id}/transition",
|
||||
response_model=ApplicationTransitionResponse,
|
||||
)
|
||||
async def admin_transition_application(
|
||||
application_id: str,
|
||||
body: AdminTransitionRequest,
|
||||
_gate: AdminGate,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> ApplicationTransitionResponse:
|
||||
"""The generic operator remediation route (D-106): ANY matrix-legal
|
||||
transition incl. withdrawn on a learner's behalf (remediation with
|
||||
provenance, the D-083 lane). G-327: `to` is a CLOSED Literal —
|
||||
unknown values die at pydantic (422), matrix-illegal known values
|
||||
reach the store (409 InvalidTransition). role_title/start_date
|
||||
are required iff to=hired (the honest 422 with required-fields
|
||||
detail, D-103)."""
|
||||
role_title: str | None = None
|
||||
start_date: date | None = None
|
||||
if body.to == "hired":
|
||||
if body.role_title is None or body.start_date is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="role_title and start_date are required when to=hired (D-103)",
|
||||
)
|
||||
role_title, start_date = body.role_title, body.start_date
|
||||
try:
|
||||
application, placement = placements.transition_application(
|
||||
application_id,
|
||||
body.to,
|
||||
actor_type="operator",
|
||||
actor_ref=OPERATOR,
|
||||
role_title=role_title,
|
||||
start_date=start_date,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The placements InvalidTransition (409) MUST be matched before
|
||||
# the ValueError arm — InvalidTransition subclasses ValueError.
|
||||
from ..placements.base import InvalidTransition
|
||||
|
||||
if isinstance(exc, InvalidTransition):
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if isinstance(exc, ValueError):
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
raise
|
||||
return ApplicationTransitionResponse(
|
||||
application_id=application.id,
|
||||
status=application.status, # type: ignore[arg-type]
|
||||
placement=_placement_read(placement) if placement is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/placements/{placement_id}/revoke", response_model=PlacementRead)
|
||||
async def admin_revoke_placement(
|
||||
placement_id: str,
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> PlacementRead:
|
||||
"""D-102: the operator correction — the one active → revoked
|
||||
placement transition (operator-only by route). Unknown → 404;
|
||||
non-active → 409 (the matrix)."""
|
||||
try:
|
||||
revoked = placements.revoke_placement(placement_id, OPERATOR)
|
||||
except Exception as exc:
|
||||
from ..placements.base import InvalidTransition
|
||||
|
||||
if isinstance(exc, InvalidTransition):
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
raise
|
||||
if revoked is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no placement with id {placement_id!r}"
|
||||
)
|
||||
return _placement_read(revoked)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/placements/by-application/{application_id}/re-hire",
|
||||
response_model=PlacementRead,
|
||||
)
|
||||
async def admin_rehire_placement(
|
||||
application_id: str,
|
||||
body: HireTransitionRequest,
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> PlacementRead:
|
||||
"""THE R-80 RE-HIRE ROUTE: after the first hire the application is
|
||||
TERMINAL (hired → hired is a 409 on every matrix-validated
|
||||
surface — the coherence gap research found). The re-hire creates
|
||||
the FRESH PlacementRow + the placement.rehired ledger event
|
||||
WITHOUT touching the application's terminal status — the ledger
|
||||
reads hired → revoke → rehire as three events, never fabricating
|
||||
a second application transition. Structurally safe: revoked rows
|
||||
never match the partial WHERE status='active' (the G-316
|
||||
property). G-326: decided_at = the re-hire event's stamp."""
|
||||
try:
|
||||
rehired = placements.rehire_placement(
|
||||
application_id,
|
||||
role_title=body.role_title,
|
||||
start_date=body.start_date,
|
||||
actor_ref=OPERATOR,
|
||||
)
|
||||
except Exception as exc:
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..placements.base import InvalidTransition
|
||||
|
||||
if isinstance(exc, InvalidTransition):
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if isinstance(exc, ValueError):
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if isinstance(exc, IntegrityError):
|
||||
# the partial-unique arm: an ACTIVE placement already exists
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="an active placement already exists for this "
|
||||
"application — revoke it before re-hiring",
|
||||
) from exc
|
||||
raise
|
||||
return _placement_read(rehired)
|
||||
|
||||
|
||||
# -- the reporting read (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107) ---------------
|
||||
|
||||
|
||||
@router.get("/overview", response_model=PlacementOverview)
|
||||
async def placement_overview(
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> PlacementOverview:
|
||||
"""The D-107 computation — counts from ROWS at query time (never
|
||||
denormalized counters, G-302/G-306): the guiding metric is DISTINCT
|
||||
learners with ≥1 active placement, all-time cumulative toward
|
||||
GUIDING_GOAL (a learner placed twice counts ONCE — the outcome's
|
||||
noun is learners); revoked excluded from every count (D-102);
|
||||
cohort month = the calendar month of start_date (YYYY-MM); orderings
|
||||
pinned (R-73: by_employer count desc then employer_id;
|
||||
by_cohort_month month ascending)."""
|
||||
rows = placements.list_placements(status="active")
|
||||
learner_ids = {row.learner_id for row in rows}
|
||||
by_employer: dict[str, int] = {}
|
||||
by_month: dict[str, int] = {}
|
||||
for row in rows:
|
||||
by_employer[row.employer_id] = by_employer.get(row.employer_id, 0) + 1
|
||||
month = row.start_date[:7] # ISO YYYY-MM (the calendar slice)
|
||||
by_month[month] = by_month.get(month, 0) + 1
|
||||
return PlacementOverview(
|
||||
total_active_placements=len(rows),
|
||||
distinct_learners_placed=len(learner_ids),
|
||||
guiding_goal=GUIDING_GOAL,
|
||||
by_employer=[
|
||||
{"employer_id": employer_id, "count": count}
|
||||
for employer_id, count in sorted(
|
||||
by_employer.items(), key=lambda kv: (-kv[1], kv[0])
|
||||
)
|
||||
],
|
||||
by_cohort_month=[
|
||||
{"month": month, "count": count}
|
||||
for month, count in sorted(by_month.items())
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# -- the audit export (v0.11 P4, Tasks 4-1-01/4-1-02 — REQ-11-004, D-108) ----------
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def placement_export(
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> Response:
|
||||
"""GET /v1/placements/admin/export — ONE self-contained JSON
|
||||
document (D-108): {schema_version, generated_at, summary, cohort,
|
||||
evidence_bundles (EVERY placement, active + revoked — G-325),
|
||||
methodology (the G-321 statements + the three R-67 pilot gaps),
|
||||
ledger (every event + head_hash)}. Determinism per R-73: pinned
|
||||
orderings, no floats, generated_at excluded from comparison (the
|
||||
re-export test strips it)."""
|
||||
document = placements.build_export()
|
||||
return Response(
|
||||
content=json.dumps(document, sort_keys=True, separators=(",", ":")),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/summary")
|
||||
async def placement_export_summary(
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> Response:
|
||||
"""GET /v1/placements/admin/export/summary — text/markdown
|
||||
rendered FROM the same JSON document (one builder, two
|
||||
renderings — the anti-drift rule: the summary can never assert a
|
||||
number the export doesn't contain). G-328: the title carries the
|
||||
ATTESTATION language (employer-attested, operator-overseen) —
|
||||
never a verification claim."""
|
||||
document = placements.build_export()
|
||||
summary = document["summary"]
|
||||
methodology = document["methodology"]
|
||||
head = document["ledger"]["head_hash"]
|
||||
lines: list[str] = []
|
||||
lines.append("# Placement Audit Export — Summary")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Employer-attested, operator-overseen placement records (NOT "
|
||||
"third-party employment verification — see Methodology)."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Totals")
|
||||
lines.append("")
|
||||
lines.append("| Metric | Count |")
|
||||
lines.append("|---|---|")
|
||||
lines.append(
|
||||
f"| Distinct learners placed (toward {GUIDING_GOAL}) "
|
||||
f"| {summary['distinct_learners_placed']} |"
|
||||
)
|
||||
lines.append(f"| Total active placements | {summary['total_active_placements']} |")
|
||||
lines.append(f"| Revoked placements | {summary['revoked_count']} |")
|
||||
lines.append("")
|
||||
lines.append("## By cohort month (employment start)")
|
||||
lines.append("")
|
||||
lines.append("| Month | Placements |")
|
||||
lines.append("|---|---|")
|
||||
for bucket in summary["by_cohort_month"]:
|
||||
lines.append(f"| {bucket['month']} | {bucket['count']} |")
|
||||
if not summary["by_cohort_month"]:
|
||||
lines.append("| (none recorded) | 0 |")
|
||||
lines.append("")
|
||||
lines.append("## Methodology")
|
||||
lines.append("")
|
||||
lines.append(f"### Placement definition\n\n{methodology['placement_definition']}\n")
|
||||
lines.append(f"### Counting rules\n\n{methodology['counting_rules']}\n")
|
||||
lines.append(f"### Verification\n\n{methodology['verification']}\n")
|
||||
lines.append(f"### Scope\n\n{methodology['scope']}\n")
|
||||
lines.append("## Verifying the export offline")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Every ledger event is embedded in the JSON export. Re-walk the "
|
||||
"chain: for each event in id order, compute "
|
||||
"`sha256(canonical_json + prev_hash)` where canonical_json is "
|
||||
"the event's {id, application_id, placement_id, event_type, "
|
||||
"actor_type, actor_ref, payload, created_at} serialized with "
|
||||
"sorted keys and compact separators (created_at in UTC ISO-8601), "
|
||||
"and prev_hash starts at the all-zeros GENESIS constant. The "
|
||||
"final computed hash must equal head_hash:"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"`head_hash: {head}`")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Then compare against a PRIOR export: its ledger.events must "
|
||||
"appear as an exact prefix of this export's events, and its "
|
||||
"head_hash must equal the hash at that prefix position "
|
||||
"(export-to-export continuity is the anchor against wholesale "
|
||||
"database replacement — see Verification)."
|
||||
)
|
||||
return Response(
|
||||
content="\n".join(lines),
|
||||
media_type="text/markdown",
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Billing engine package (REQ-9-001, D-075..D-084, v0.9 Payment Engine).
|
||||
|
||||
The 7th domain module inside apps/ai-service (no new apps, no new ports —
|
||||
the D-031 boundary): pydantic contracts + the provider-agnostic
|
||||
`PaymentProvider` protocol (`base.py`), the shared HMAC webhook
|
||||
verification core (`webhooks.py`, D-077), the deterministic mock provider
|
||||
(`mock.py`, D-075 default), and the settings-driven factory
|
||||
(`factory.py`). The D-027 store family's seventh SQLite store
|
||||
(`store.py`), the seeded catalog (`catalog.py`), and the real Stripe
|
||||
provider (`stripe.py`) land with their owning phases.
|
||||
|
||||
Boundary (D-031 family discipline): `billing/` never imports `agents/`
|
||||
or `api/`; this package imports stdlib + pydantic + `config` only.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
CANONICAL_EVENT_NAMES,
|
||||
CanonicalBillingEvent,
|
||||
CanonicalEventName,
|
||||
CheckoutMode,
|
||||
CheckoutSession,
|
||||
CheckoutSessionSpec,
|
||||
CheckoutStatus,
|
||||
Package,
|
||||
PaymentProvider,
|
||||
PaymentStatus,
|
||||
Plan,
|
||||
PlanInterval,
|
||||
SubscriptionRef,
|
||||
SubscriptionStatus,
|
||||
UnknownPaymentProviderError,
|
||||
VerifiedEvent,
|
||||
WebhookSignatureError,
|
||||
)
|
||||
from .factory import payment_provider_from_settings
|
||||
from .mock import MockPaymentProvider
|
||||
|
||||
__all__ = [
|
||||
"CANONICAL_EVENT_NAMES",
|
||||
"CanonicalBillingEvent",
|
||||
"CanonicalEventName",
|
||||
"CheckoutMode",
|
||||
"CheckoutSession",
|
||||
"CheckoutSessionSpec",
|
||||
"CheckoutStatus",
|
||||
"MockPaymentProvider",
|
||||
"Package",
|
||||
"PaymentProvider",
|
||||
"PaymentStatus",
|
||||
"Plan",
|
||||
"PlanInterval",
|
||||
"SubscriptionRef",
|
||||
"SubscriptionStatus",
|
||||
"UnknownPaymentProviderError",
|
||||
"VerifiedEvent",
|
||||
"WebhookSignatureError",
|
||||
"payment_provider_from_settings",
|
||||
]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Billing pydantic contracts + PaymentProvider protocol (REQ-9-001).
|
||||
|
||||
The wire + provider shapes for the payment engine. `Package`/`Plan` are
|
||||
the catalog item shapes (D-078: seeded DB rows; P2 seeds them); the
|
||||
`CheckoutSession*` pair is the provider session seam (R-33..R-36);
|
||||
`CanonicalBillingEvent` is OUR event vocabulary — normalized at the
|
||||
provider boundary, never taken raw from a vendor (D-083, R-43).
|
||||
|
||||
Money (D-081): integer cents end-to-end — `int` with `ge=0`, no floats
|
||||
anywhere in billing, currency `usd` fixed. Stripe itself is cents-native
|
||||
(R-48: `unit_amount: 1000` = $10.00).
|
||||
|
||||
Id honesty (the identity-mock unique-mint lesson, A-305): every id in
|
||||
this module is an opaque handle minted UNIQUELY per creation call —
|
||||
never derived from user input or a deterministic hash of it.
|
||||
|
||||
Boundary (D-031 family): `billing/` never imports `agents/` or `api/`;
|
||||
stdlib + pydantic + config only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
#: One-time posting packages vs recurring talent-access plans: one shape
|
||||
#: family, one table family (D-078/D-080 — a one-time purchase is NOT a
|
||||
#: degenerate subscription).
|
||||
PlanInterval = Literal["month", "year"]
|
||||
|
||||
#: Stripe-compatible checkout modes (R-34): payment = one-time packages,
|
||||
#: subscription = recurring plans. `setup` exists on the wire enum but no
|
||||
#: catalog item maps to it in v0.9.
|
||||
CheckoutMode = Literal["payment", "setup", "subscription"]
|
||||
|
||||
#: Session status vocabulary (R-36): open (awaiting payment) → complete
|
||||
#: (checkout finished) | expired (window elapsed). The GRANT predicate is
|
||||
#: `status == "complete" AND payment_status == "paid"` — never status
|
||||
#: alone (R-37: async methods can leave complete with payment still
|
||||
#: processing).
|
||||
CheckoutStatus = Literal["open", "complete", "expired"]
|
||||
|
||||
#: Payment status vocabulary (R-36). `no_payment_required` exists on the
|
||||
#: wire enum; v0.9 grants only on `paid`.
|
||||
PaymentStatus = Literal["paid", "unpaid", "no_payment_required"]
|
||||
|
||||
#: Subscription lifecycle states (D-083). G-305: `upgraded` is NOT a
|
||||
#: status — upgrade is a plan_id transition WITHIN `active`, recorded as
|
||||
#: a billing_event with provenance. `expired` here is the terminal past-
|
||||
#: due-grace outcome; the store owns the transition matrix (P5).
|
||||
SubscriptionStatus = Literal[
|
||||
"trialing", "active", "past_due", "cancelled", "expired"
|
||||
]
|
||||
|
||||
#: OUR canonical event names (D-083, R-43: Stripe has no
|
||||
#: `subscription.renewed`/`checkout.completed` — the provider boundary
|
||||
#: maps vendor names onto these; provider event ids are stored + deduped
|
||||
#: VERBATIM so dedupe stays truthful).
|
||||
CanonicalEventName = Literal[
|
||||
"checkout.completed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"subscription.cancelled",
|
||||
"subscription.renewed",
|
||||
]
|
||||
|
||||
#: Tuple mirror of the Literal (docs + tests import this; keeps the enum
|
||||
#: and any exhaustive switch in one place).
|
||||
CANONICAL_EVENT_NAMES: tuple[CanonicalEventName, ...] = (
|
||||
"checkout.completed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"subscription.cancelled",
|
||||
"subscription.renewed",
|
||||
)
|
||||
|
||||
|
||||
class WebhookSignatureError(ValueError):
|
||||
"""Webhook signature verification failed (D-077).
|
||||
|
||||
Raised with a precise reason (missing header / no v1 schemes / stale
|
||||
timestamp / signature mismatch). The webhook route maps this to 401 —
|
||||
a payment surface never processes an unverifiable event.
|
||||
"""
|
||||
|
||||
|
||||
class UnknownPaymentProviderError(ValueError):
|
||||
"""Raised for a provider name outside the contract, or a real
|
||||
provider selected without its required configuration (R-53 — the
|
||||
voice/embedding factory pattern).
|
||||
|
||||
main.py's lifespan CATCHES this and falls back to mock with a loud
|
||||
G-11 log; direct callers (tests) see the actionable raise.
|
||||
"""
|
||||
|
||||
|
||||
class Package(BaseModel):
|
||||
"""A one-time posting package catalog row (D-078/D-080).
|
||||
|
||||
`posts_limit` counts posting creations (consumed one per
|
||||
create_posting, newest-non-expired-first — D-080); `duration_days`
|
||||
bounds the purchase window from the buy time. `features` is the
|
||||
pricing-page copy list (P2 pins the wire shape both sides).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
#: D-081: integer cents, ge=0 — currency usd fixed; never a float.
|
||||
price_cents: int = Field(ge=0)
|
||||
posts_limit: int = Field(ge=1)
|
||||
duration_days: int = Field(ge=1)
|
||||
features: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Plan(BaseModel):
|
||||
"""A recurring talent-access plan catalog row (D-078/D-080).
|
||||
|
||||
`interval` distinguishes the billing cadence; entitlement derives
|
||||
from the subscription's `expires_at` extension on each renewal
|
||||
(D-083 — idempotent per event dedupe).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
#: D-081: integer cents, ge=0 — currency usd fixed; never a float.
|
||||
price_cents: int = Field(ge=0)
|
||||
interval: PlanInterval
|
||||
features: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CheckoutSessionSpec(BaseModel):
|
||||
"""What the operator asks a provider to create (R-33..R-35).
|
||||
|
||||
The checkout route resolves `item_id` against the catalog and
|
||||
carries the SNAPSHOT forward (D-078: `price_cents` + `item_name`
|
||||
copied at buy time — catalog edits never rewrite purchase
|
||||
history; the provider turns them into `line_items[0][price_data]`,
|
||||
R-35). `client_reference_id` is our reconciliation key (R-34 — the
|
||||
local checkout_sessions row id). `metadata` rides the session both
|
||||
directions (D-076: operator_ref + employer_id provenance). G-310:
|
||||
`success_url`/`cancel_url` default from `public_base_url` — a
|
||||
hosted checkout cannot be created without them (R-34).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
mode: CheckoutMode
|
||||
item_id: str = Field(min_length=1)
|
||||
#: D-078 snapshot: the catalog price at buy time (D-081 cents).
|
||||
price_cents: int = Field(ge=0)
|
||||
#: D-078 snapshot: the catalog name at buy time.
|
||||
item_name: str = Field(min_length=1)
|
||||
#: Recurring cadence for plans ("month"/"year"); empty for packages.
|
||||
interval: str = ""
|
||||
success_url: str = ""
|
||||
cancel_url: str = ""
|
||||
client_reference_id: str = Field(min_length=1, max_length=200)
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CheckoutSession(BaseModel):
|
||||
"""Provider session state (R-36 normalized fields).
|
||||
|
||||
`url` is present only while a session is open (hosted checkout);
|
||||
`expires_at` bounds the payment window (R-34: 30m–24h on Stripe).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
url: str = ""
|
||||
status: CheckoutStatus
|
||||
payment_status: PaymentStatus
|
||||
mode: CheckoutMode
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class SubscriptionRef(BaseModel):
|
||||
"""A provider subscription handle (R-41/R-42 normalized states).
|
||||
|
||||
The store owns the lifecycle rows; this is what provider calls
|
||||
(cancel/update) and webhook payloads exchange.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
status: SubscriptionStatus
|
||||
|
||||
|
||||
class CanonicalBillingEvent(BaseModel):
|
||||
"""A provider event normalized onto OUR vocabulary (D-083, R-43).
|
||||
|
||||
`payload` is UNTRUSTED (G-302): webhook processing treats it as ID
|
||||
references only — session/subscription state resolves from LOCAL
|
||||
rows; purchase price/name come from the stored snapshot (D-078),
|
||||
never from the event body.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: CanonicalEventName
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VerifiedEvent(BaseModel):
|
||||
"""What verify_webhook returns: the authenticated event.
|
||||
|
||||
Verification authenticates the TRANSPORT (HMAC over the raw body);
|
||||
`event` is the parsed body AFTER the signature gate. Providers that
|
||||
need raw-body parsing keep it inside their own verify_webhook.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
event: CanonicalBillingEvent
|
||||
#: Provider event id, stored + deduped VERBATIM (R-43/D-083 — never
|
||||
#: normalized; the UniqueConstraint is on this exact string).
|
||||
provider_event_id: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PaymentProvider(Protocol):
|
||||
"""The payment port (D-075): checkout + subscriptions + webhooks.
|
||||
|
||||
Two implementations ship in-milestone: deterministic mock (default —
|
||||
tests never call a real provider) and Stripe-compatible REST (P3,
|
||||
exercised ONLY through httpx.MockTransport byte-contract tests).
|
||||
Never imports agents/api; selected by `factory.py` from Settings.
|
||||
"""
|
||||
|
||||
async def create_checkout_session(
|
||||
self, spec: CheckoutSessionSpec
|
||||
) -> CheckoutSession:
|
||||
"""Create a provider checkout session for a catalog item."""
|
||||
...
|
||||
|
||||
async def retrieve_session(self, session_id: str) -> CheckoutSession | None:
|
||||
"""Fetch session state; None when the provider does not know the id."""
|
||||
...
|
||||
|
||||
async def cancel_subscription(
|
||||
self, subscription_id: str, *, at_period_end: bool = False
|
||||
) -> SubscriptionRef | None:
|
||||
"""Cancel a subscription (R-46: immediate DELETE or deferred
|
||||
cancel_at_period_end); None when unknown to the provider."""
|
||||
...
|
||||
|
||||
async def update_subscription(
|
||||
self, subscription_id: str, new_price_cents: int
|
||||
) -> SubscriptionRef | None:
|
||||
"""Move a subscription to a new price (R-47: the provider resolves
|
||||
the subscription_item id inside — items[0][id] must be in the
|
||||
request or the new price is ADDED alongside the old → dual
|
||||
charge)."""
|
||||
...
|
||||
|
||||
def verify_webhook(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
signature_header: str,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> VerifiedEvent:
|
||||
"""Authenticate + parse a webhook delivery (D-077).
|
||||
|
||||
`raw_body` must be the exact un-parsed bytes (risk-1); raises
|
||||
`WebhookSignatureError` on any verification failure — the route
|
||||
maps that to 401, never processes further.
|
||||
"""
|
||||
...
|
||||
|
||||
def normalize_event(self, provider_event: dict[str, Any]) -> CanonicalBillingEvent:
|
||||
"""Map a provider-native event payload onto our canonical names
|
||||
(R-43). Mock speaks canonical names directly → passthrough."""
|
||||
...
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Seeded billing catalog — the one source of truth for pricing (D-078).
|
||||
|
||||
The catalog mirrors the pricing page's tier structure (REQ-017 /
|
||||
REQ-9-002): prices + names + feature copy live HERE and flow to the
|
||||
page via `/v1/billing/catalog` — tier copy is data-driven, never
|
||||
hardcoded on both sides (D-079's no-drift rule). Seeding is idempotent
|
||||
(`SQLiteBillingStore.seed_catalog` upserts by id), so the app lifespan
|
||||
can seed on every boot.
|
||||
|
||||
Enterprise tiers: the pricing page models them as "Custom" — the pilot
|
||||
catalog keeps that honesty by seeding them as price_cents=0 contact
|
||||
tiers (checkout never applies; an operator grants enterprise manually
|
||||
per D-080's unlimited path). The features copy records the
|
||||
"Contact Sales" semantics verbatim from the page.
|
||||
|
||||
Money (D-081): integer cents — 4900 = $49.00; currency `usd` fixed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import Package, Plan
|
||||
from .store import BillingStore
|
||||
|
||||
SEED_PACKAGES: list[Package] = [
|
||||
Package(
|
||||
id="pkg-single",
|
||||
name="Single Post",
|
||||
price_cents=4900,
|
||||
posts_limit=1,
|
||||
duration_days=30,
|
||||
features=[
|
||||
"1 job posting",
|
||||
"30 days live",
|
||||
"Basic analytics",
|
||||
"Standard placement",
|
||||
],
|
||||
),
|
||||
Package(
|
||||
id="pkg-bundle",
|
||||
name="Bundle",
|
||||
price_cents=39900,
|
||||
posts_limit=10,
|
||||
duration_days=90,
|
||||
features=[
|
||||
"10 job postings",
|
||||
"90 days each",
|
||||
"Advanced analytics",
|
||||
"Priority placement",
|
||||
],
|
||||
),
|
||||
Package(
|
||||
id="pkg-enterprise",
|
||||
name="Enterprise",
|
||||
price_cents=0,
|
||||
posts_limit=1000,
|
||||
duration_days=365,
|
||||
features=[
|
||||
"Unlimited postings",
|
||||
"Dedicated account manager",
|
||||
"API access",
|
||||
"Custom branding",
|
||||
"Custom pricing — contact sales (operator-granted at pilot)",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
SEED_PLANS: list[Plan] = [
|
||||
Plan(
|
||||
id="plan-starter",
|
||||
name="Starter",
|
||||
price_cents=9900,
|
||||
interval="month",
|
||||
features=["Browse talent database", "10 candidate views/mo", "Basic filters"],
|
||||
),
|
||||
Plan(
|
||||
id="plan-pro",
|
||||
name="Pro",
|
||||
price_cents=29900,
|
||||
interval="month",
|
||||
features=[
|
||||
"Unlimited views",
|
||||
"AI matching",
|
||||
"Advanced filters",
|
||||
"Saved searches",
|
||||
],
|
||||
),
|
||||
Plan(
|
||||
id="plan-enterprise",
|
||||
name="Enterprise",
|
||||
price_cents=0,
|
||||
interval="year",
|
||||
features=[
|
||||
"Everything in Pro",
|
||||
"API access",
|
||||
"Bulk export",
|
||||
"Dedicated CSM",
|
||||
"Custom pricing — contact sales (operator-granted at pilot)",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def seed_billing_catalog(store: BillingStore) -> None:
|
||||
"""Idempotent catalog seeding (D-078) — safe on every boot."""
|
||||
store.seed_catalog(SEED_PACKAGES, SEED_PLANS)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Payment provider factory (D-075, R-53, REQ-9-001).
|
||||
|
||||
`AI_PAYMENT_PROVIDER = mock | stripe` (default: mock — the no-key path
|
||||
is first-class; tests never call a real provider). Mirrors the
|
||||
voice/embedding factory pattern: the factory raises
|
||||
`UnknownPaymentProviderError` with an actionable message for direct
|
||||
callers (tests), while the lifespan in main.py CATCHES it and falls
|
||||
back to mock with a loud log — a typo'd env must never crash the
|
||||
unattended boot (G-11).
|
||||
|
||||
P3: `stripe` is REAL (billing/stripe.py) — but needs its API key,
|
||||
webhook secret, AND `public_base_url` (G-310: hosted checkout cannot
|
||||
build success/cancel URLs without it); a missing piece raises the
|
||||
actionable error the lifespan catches → G-11 mock fallback. The
|
||||
http_client rides in from main.py's shared pool (R-33).
|
||||
|
||||
Webhook secret (D-077): the settings default is the empty string —
|
||||
UNSET → the factory substitutes a per-boot random secret (the
|
||||
telemetry_ingest_secret pattern). Restart invalidates it, so stale
|
||||
signed deliveries fail: webhooks fail closed when unconfigured, which
|
||||
is correct for a payment surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import Settings
|
||||
from .base import PaymentProvider, UnknownPaymentProviderError
|
||||
from .mock import MockPaymentProvider
|
||||
|
||||
|
||||
def resolve_webhook_secret(settings: Settings) -> str:
|
||||
"""Configured `AI_WEBHOOK_SECRET` verbatim; unset → per-boot random
|
||||
(fail-closed webhooks, D-077 — the telemetry_ingest_secret shape)."""
|
||||
if settings.webhook_secret:
|
||||
return settings.webhook_secret
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def payment_provider_from_settings(
|
||||
settings: Settings, http_client: httpx.AsyncClient | None = None
|
||||
) -> PaymentProvider:
|
||||
"""Select the payment provider by settings (env `AI_PAYMENT_PROVIDER`).
|
||||
|
||||
The selected provider gets the webhook secret resolved here
|
||||
(D-077). Stripe needs the shared httpx pool — main.py passes its
|
||||
app.state client; direct test callers construct their own.
|
||||
"""
|
||||
name = (settings.payment_provider or "mock").strip().lower()
|
||||
webhook_secret = resolve_webhook_secret(settings)
|
||||
if name == "mock":
|
||||
return MockPaymentProvider(webhook_secret=webhook_secret)
|
||||
if name == "stripe":
|
||||
# R-53/G-310: config errors (key/secret/base_url) surface from
|
||||
# the stripe factory's own gate — the operator-facing ones;
|
||||
# the httpx pool is a caller concern checked after config.
|
||||
from .stripe import stripe_provider_from_settings, validate_stripe_config
|
||||
|
||||
validate_stripe_config(settings)
|
||||
if http_client is None:
|
||||
raise UnknownPaymentProviderError(
|
||||
"stripe provider needs the shared httpx pool — "
|
||||
"pass http_client (main.py does; tests construct one)"
|
||||
)
|
||||
return stripe_provider_from_settings(settings, http_client)
|
||||
raise UnknownPaymentProviderError(
|
||||
f"unknown AI_PAYMENT_PROVIDER {name!r}: use 'mock' or 'stripe'"
|
||||
)
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Deterministic mock payment provider (D-075, REQ-9-001).
|
||||
|
||||
Stripe-shaped without any network: sessions (`cs_mock_...` ids), the
|
||||
same HMAC webhook scheme (via webhooks.mint/verify — byte-identical to
|
||||
the real provider path), and canonical event names spoken directly
|
||||
(normalize_event is a passthrough — R-43's boundary maps VENDOR names;
|
||||
the mock has none to map). Tests never call a real provider; the
|
||||
mock-complete path (D-082, P3) mints a signed event through the REAL
|
||||
webhook processing via `mint_event`.
|
||||
|
||||
Id honesty (the identity-mock unique-mint lesson, verified there):
|
||||
session ids are minted UNIQUELY per create call — a monotonic counter
|
||||
plus per-process seed from `secrets`. A deterministic id derived from
|
||||
the spec would collide on any retry/re-entry of the same purchase
|
||||
request and the store's per-session idempotency (G-303) would silently
|
||||
conflate distinct logical checkouts. Uniqueness per call is the
|
||||
contract; determinism of VERDICTS (what tests pin) is preserved — the
|
||||
session lifecycle is scriptable, not the ids.
|
||||
|
||||
Session lifecycle: create → status open / payment unpaid; the operator
|
||||
completes out-of-band payment via `confirm_completion` (the D-076
|
||||
pilot flow — P3's admin-gated mock-complete route calls this) →
|
||||
status complete / payment_status paid (the R-37 grant predicate).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from . import webhooks
|
||||
from .base import (
|
||||
CanonicalBillingEvent,
|
||||
CheckoutSession,
|
||||
CheckoutSessionSpec,
|
||||
SubscriptionRef,
|
||||
VerifiedEvent,
|
||||
)
|
||||
|
||||
#: Session payment window (R-34's shape; Stripe allows 30m–24h — the
|
||||
#: mock keeps a roomy 24h default so operator-mediated completion is
|
||||
#: never raced in the pilot).
|
||||
_SESSION_TTL = timedelta(hours=24)
|
||||
|
||||
#: Event-id prefix — provider event ids are stored + deduped VERBATIM
|
||||
#: (R-43/D-083); the mock mints them uniquely like session ids.
|
||||
_EVENT_ID_PREFIX = "evt_mock_"
|
||||
|
||||
|
||||
class MockPaymentProvider:
|
||||
"""In-memory, deterministic, zero-network PaymentProvider.
|
||||
|
||||
Scriptable sessions + real-scheme webhook minting — the full
|
||||
checkout→webhook loop is provable without any vendor.
|
||||
"""
|
||||
|
||||
def __init__(self, webhook_secret: str = "") -> None:
|
||||
# Per-process nonce: ids are opaque handles, never derived from
|
||||
# user input — counter + nonce keeps ids unique within and
|
||||
# across provider instances on one box.
|
||||
self._nonce = secrets.randbits(32)
|
||||
self._session_counter = itertools.count()
|
||||
self._event_counter = itertools.count()
|
||||
# Sessions by id: the provider's view of checkout state.
|
||||
self._sessions: dict[str, CheckoutSession] = {}
|
||||
# Subscriptions by id: created on subscription-mode completion
|
||||
# (P5 drives the lifecycle; the mock keeps the ref shape honest).
|
||||
self._subscriptions: dict[str, SubscriptionRef] = {}
|
||||
self._webhook_secret = webhook_secret or secrets.token_urlsafe(32)
|
||||
|
||||
# -- ids -----------------------------------------------------------------
|
||||
|
||||
def _mint_session_id(self) -> str:
|
||||
# Stripe-shaped cs_ prefix + mock marker (R-36's id shape; the
|
||||
# mock marker keeps logs/test failures self-explanatory).
|
||||
return (
|
||||
f"cs_mock_{self._nonce:08x}{next(self._session_counter):08x}"
|
||||
)
|
||||
|
||||
def _mint_event_id(self) -> str:
|
||||
return (
|
||||
f"{_EVENT_ID_PREFIX}{self._nonce:08x}{next(self._event_counter):08x}"
|
||||
)
|
||||
|
||||
# -- checkout ------------------------------------------------------------
|
||||
|
||||
async def create_checkout_session(
|
||||
self, spec: CheckoutSessionSpec
|
||||
) -> CheckoutSession:
|
||||
"""Create a session (status open, payment unpaid — R-36)."""
|
||||
session_id = self._mint_session_id()
|
||||
session = CheckoutSession(
|
||||
id=session_id,
|
||||
# Hosted-checkout stand-in: the mock has no hosted page; the
|
||||
# url is a stable pseudo-hosted pointer (never fetched).
|
||||
url=f"https://mock.local/checkout/{session_id}",
|
||||
status="open",
|
||||
payment_status="unpaid",
|
||||
mode=spec.mode,
|
||||
expires_at=datetime.now(UTC) + _SESSION_TTL,
|
||||
)
|
||||
self._sessions[session.id] = session
|
||||
return session
|
||||
|
||||
async def retrieve_session(self, session_id: str) -> CheckoutSession | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def confirm_completion(self, session_id: str) -> CheckoutSession:
|
||||
"""Flip a session to complete/paid (the R-37 grant predicate).
|
||||
|
||||
The out-of-band payment step of the pilot flow (D-076): P3's
|
||||
admin-gated mock-complete route calls this, then mints the
|
||||
signed webhook event through the real processing path. Unknown
|
||||
or already-complete sessions raise ValueError (fail loud, not
|
||||
idempotent-silent: the ROUTE owns idempotency via event dedupe).
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
raise ValueError(f"unknown checkout session {session_id!r}")
|
||||
if session.status != "open":
|
||||
raise ValueError(
|
||||
f"session {session_id!r} is {session.status!r}, not open"
|
||||
)
|
||||
completed = session.model_copy(
|
||||
update={"status": "complete", "payment_status": "paid"}
|
||||
)
|
||||
self._sessions[session_id] = completed
|
||||
if completed.mode == "subscription":
|
||||
# Subscription-mode completion creates the provider-side
|
||||
# subscription ref (R-36: `subscription` present in
|
||||
# subscription mode only).
|
||||
self._subscriptions.setdefault(
|
||||
f"sub_mock_{session_id.removeprefix('cs_')}",
|
||||
SubscriptionRef(
|
||||
id=f"sub_mock_{session_id.removeprefix('cs_')}",
|
||||
status="active",
|
||||
),
|
||||
)
|
||||
return completed
|
||||
|
||||
# -- subscriptions ---------------------------------------------------------
|
||||
|
||||
async def cancel_subscription(
|
||||
self, subscription_id: str, *, at_period_end: bool = False
|
||||
) -> SubscriptionRef | None:
|
||||
subscription = self._subscriptions.get(subscription_id)
|
||||
if subscription is None:
|
||||
return None
|
||||
# R-46 shape: immediate → cancelled now; deferred → stays active
|
||||
# until period end (the P5 webhook drives the final state).
|
||||
if at_period_end:
|
||||
return subscription
|
||||
cancelled = subscription.model_copy(update={"status": "cancelled"})
|
||||
self._subscriptions[subscription_id] = cancelled
|
||||
return cancelled
|
||||
|
||||
async def update_subscription(
|
||||
self, subscription_id: str, new_price_cents: int
|
||||
) -> SubscriptionRef | None:
|
||||
subscription = self._subscriptions.get(subscription_id)
|
||||
if subscription is None:
|
||||
return None
|
||||
# R-47 note: the real provider resolves items[0][id] inside; the
|
||||
# mock has no line items to dual-charge — price is recorded by
|
||||
# the store's plan snapshot (D-078), so this is a state no-op
|
||||
# that keeps the protocol shape honest.
|
||||
return subscription
|
||||
|
||||
# -- webhooks (D-077 scheme, shared core) -----------------------------------
|
||||
|
||||
def verify_webhook(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
signature_header: str,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> VerifiedEvent:
|
||||
"""Authenticate + parse a mock delivery via the shared core."""
|
||||
webhooks.verify(
|
||||
raw_body, signature_header, self._webhook_secret, now=now
|
||||
)
|
||||
body = _parse_json_body(raw_body)
|
||||
event = self.normalize_event(body)
|
||||
# Provider event id: top-level on the body, kept VERBATIM (the
|
||||
# R-43 rule — normalize the NAME, never the id).
|
||||
provider_event_id = str(body.get("id", ""))
|
||||
return VerifiedEvent(
|
||||
event=event, provider_event_id=provider_event_id
|
||||
)
|
||||
|
||||
def mint_event(
|
||||
self, name: str, payload: dict[str, Any]
|
||||
) -> tuple[bytes, str, str]:
|
||||
"""Mint a signed webhook delivery for tests + the complete path.
|
||||
|
||||
Returns `(raw_body, signature_header, provider_event_id)`. The
|
||||
body is the canonical event JSON — the exact bytes a provider
|
||||
would POST and the webhook route would read via
|
||||
`await request.body()`. Round-trip green through
|
||||
verify_webhook by construction (mint/verify share the scheme).
|
||||
"""
|
||||
provider_event_id = self._mint_event_id()
|
||||
body = json.dumps(
|
||||
{
|
||||
"id": provider_event_id,
|
||||
"name": name,
|
||||
"payload": payload,
|
||||
}
|
||||
).encode()
|
||||
header = webhooks.mint(self._webhook_secret, body)
|
||||
return body, header, provider_event_id
|
||||
|
||||
def normalize_event(
|
||||
self, provider_event: dict[str, Any]
|
||||
) -> CanonicalBillingEvent:
|
||||
"""Passthrough (R-43): the mock speaks canonical names directly."""
|
||||
name = str(provider_event.get("name", ""))
|
||||
if name not in (
|
||||
"checkout.completed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"subscription.cancelled",
|
||||
"subscription.renewed",
|
||||
):
|
||||
raise ValueError(f"unknown event name {name!r}")
|
||||
payload = provider_event.get("payload")
|
||||
return CanonicalBillingEvent(
|
||||
name=name, # type: ignore[arg-type] # validated in-contract above
|
||||
payload=payload if isinstance(payload, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_body(raw_body: bytes) -> dict[str, Any]:
|
||||
"""Parse the event JSON body (post-verification, risk-1 safe)."""
|
||||
decoded = json.loads(raw_body.decode("utf-8"))
|
||||
if not isinstance(decoded, dict):
|
||||
raise ValueError("event body is not a JSON object")
|
||||
return decoded
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
"""StripePaymentProvider — Stripe-compatible REST via the shared httpx
|
||||
pool (REQ-9-003, D-75/R-33..R-50).
|
||||
|
||||
Form-encoded bodies with bracket nesting (R-33: `line_items[0]
|
||||
[price_data][currency]=usd` — NOT JSON), HTTP Basic auth, an
|
||||
Idempotency-Key per logical operation (R-49: a double-submit
|
||||
otherwise creates two sessions), and the webhook signature scheme
|
||||
shared with the mock (webhooks.py — D-077 is byte-for-byte Stripe's
|
||||
`t=...,v1=...` HMAC-SHA256 over `{t}.{raw_body}`).
|
||||
|
||||
Exercised ONLY through httpx.MockTransport byte-contract tests
|
||||
(D-075) — tests never call the cloud. Event normalization maps
|
||||
vendor names onto OUR canonical vocabulary (R-43); provider event ids
|
||||
are stored + deduped VERBATIM.
|
||||
|
||||
G-310: `success_url`/`cancel_url` default from `public_base_url` —
|
||||
without them a hosted checkout session cannot be created (R-34:
|
||||
success_url is required).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import Settings
|
||||
from .base import (
|
||||
CanonicalBillingEvent,
|
||||
CanonicalEventName,
|
||||
CheckoutSession,
|
||||
CheckoutSessionSpec,
|
||||
SubscriptionRef,
|
||||
SubscriptionStatus,
|
||||
UnknownPaymentProviderError,
|
||||
VerifiedEvent,
|
||||
WebhookSignatureError,
|
||||
)
|
||||
from .webhooks import verify as verify_webhook_signature
|
||||
|
||||
#: R-43: vendor event type → our canonical name. `subscription.renewed`
|
||||
#: has no vendor equivalent — a renewal IS an invoice.paid on a
|
||||
#: recurring subscription; the corroborating `customer.subscription.
|
||||
#: updated` maps alongside it (recorded, not conflated).
|
||||
_EVENT_NAME_MAP: dict[str, CanonicalEventName] = {
|
||||
"checkout.session.completed": "checkout.completed",
|
||||
"invoice.paid": "invoice.paid",
|
||||
"invoice.payment_succeeded": "invoice.paid",
|
||||
"invoice.payment_failed": "invoice.payment_failed",
|
||||
"customer.subscription.deleted": "subscription.cancelled",
|
||||
"customer.subscription.updated": "subscription.renewed",
|
||||
}
|
||||
|
||||
|
||||
#: R-42: vendor status spellings → OUR enum (Stripe writes `canceled`
|
||||
#: with one L; our vocabulary is `cancelled`).
|
||||
_STATUS_MAP: dict[str, SubscriptionStatus] = {
|
||||
"trialing": "trialing",
|
||||
"incomplete": "trialing",
|
||||
"active": "active",
|
||||
"past_due": "past_due",
|
||||
"canceled": "cancelled",
|
||||
"unpaid": "expired",
|
||||
"incomplete_expired": "expired",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_status(raw: Any, default: SubscriptionStatus) -> SubscriptionStatus:
|
||||
return _STATUS_MAP.get(str(raw), default)
|
||||
|
||||
|
||||
class StripePaymentProvider:
|
||||
"""Stripe-compatible REST implementation of PaymentProvider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
api_key: str,
|
||||
webhook_secret: str,
|
||||
base_url: str = "https://api.stripe.com",
|
||||
) -> None:
|
||||
self._http = http_client
|
||||
self._api_key = api_key
|
||||
self._webhook_secret = webhook_secret
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
# -- helpers ------------------------------------------------------------
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
# R-33: HTTP Basic with the secret key as the username and an
|
||||
# empty password (`-u sk_...:`).
|
||||
basic = base64.b64encode(f"{self._api_key}:".encode()).decode()
|
||||
return {"Authorization": f"Basic {basic}"}
|
||||
|
||||
def _raise_for_status(self, response: httpx.Response) -> None:
|
||||
if response.status_code >= 400:
|
||||
# A-305: sanitize — the api key must never appear in any
|
||||
# raised exception (the embedding-test precedent).
|
||||
detail = response.text
|
||||
if self._api_key and self._api_key in detail:
|
||||
detail = detail.replace(self._api_key, "[redacted]")
|
||||
raise httpx.HTTPStatusError(
|
||||
f"stripe {response.status_code}: {detail}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
async def _form_post(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = self._auth_headers()
|
||||
# R-49: an idempotency key per logical POST — a network retry of
|
||||
# session creation must not mint two sessions.
|
||||
headers["Idempotency-Key"] = str(uuid.uuid4())
|
||||
response = await self._http.post(
|
||||
f"{self._base_url}{path}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
# -- checkout sessions (R-33..R-37) ---------------------------------------
|
||||
|
||||
async def create_checkout_session(
|
||||
self, spec: CheckoutSessionSpec
|
||||
) -> CheckoutSession:
|
||||
# R-33/R-35: form-encoded with bracket nesting. One-time vs
|
||||
# recurring differs only by price_data[recurring][interval]
|
||||
# (R-35) — the snapshot price rides the wire (D-078/D-081).
|
||||
data: dict[str, Any] = {
|
||||
"mode": spec.mode,
|
||||
"client_reference_id": spec.client_reference_id,
|
||||
"line_items[0][quantity]": "1",
|
||||
"line_items[0][price_data][currency]": "usd",
|
||||
"line_items[0][price_data][unit_amount]": str(spec.price_cents),
|
||||
"line_items[0][price_data][product_data][name]": spec.item_name,
|
||||
}
|
||||
if spec.interval:
|
||||
data["line_items[0][price_data][recurring][interval]"] = spec.interval
|
||||
for key, value in spec.metadata.items():
|
||||
data[f"metadata[{key}]"] = value
|
||||
# G-310: hosted checkout requires success_url (R-34) — the
|
||||
# checkout route defaults these from public_base_url.
|
||||
data["success_url"] = spec.success_url
|
||||
data["cancel_url"] = spec.cancel_url
|
||||
body = await self._form_post("/v1/checkout/sessions", data)
|
||||
return self._session_from_api(body)
|
||||
|
||||
async def retrieve_session(self, session_id: str) -> CheckoutSession | None:
|
||||
try:
|
||||
body = await self._get(f"/v1/checkout/sessions/{session_id}")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
return self._session_from_api(body)
|
||||
|
||||
async def _get(self, path: str) -> dict[str, Any]:
|
||||
response = await self._http.get(
|
||||
f"{self._base_url}{path}",
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def _session_from_api(body: dict[str, Any]) -> CheckoutSession:
|
||||
# Final-review fix: coerce + default every enum-adjacent value —
|
||||
# a vendor body with an unexpected status spelling (or a missing
|
||||
# one) must not crash the route with a pydantic ValidationError
|
||||
# (the G-306 never-500 discipline).
|
||||
status = str(body.get("status", "open"))
|
||||
if status not in ("open", "complete", "expired"):
|
||||
status = "open"
|
||||
payment_status = str(body.get("payment_status", "unpaid"))
|
||||
if payment_status not in ("paid", "unpaid", "no_payment_required"):
|
||||
payment_status = "unpaid"
|
||||
mode = str(body.get("mode", "payment"))
|
||||
if mode not in ("payment", "setup", "subscription"):
|
||||
mode = "payment"
|
||||
return CheckoutSession(
|
||||
id=str(body.get("id", "")),
|
||||
url=str(body.get("url") or ""),
|
||||
status=status, # type: ignore[arg-type]
|
||||
payment_status=payment_status, # type: ignore[arg-type]
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
# -- subscriptions (R-46/R-47) --------------------------------------------
|
||||
|
||||
async def cancel_subscription(
|
||||
self, subscription_id: str, *, at_period_end: bool = False
|
||||
) -> SubscriptionRef | None:
|
||||
try:
|
||||
if at_period_end:
|
||||
body = await self._form_post(
|
||||
f"/v1/subscriptions/{subscription_id}",
|
||||
{"cancel_at_period_end": "true"},
|
||||
)
|
||||
else:
|
||||
response = await self._http.delete(
|
||||
f"{self._base_url}/v1/subscriptions/{subscription_id}",
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
self._raise_for_status(response)
|
||||
body = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
return SubscriptionRef(
|
||||
id=str(body.get("id", subscription_id)),
|
||||
status=_normalize_status(body.get("status", "cancelled"), "cancelled"),
|
||||
)
|
||||
|
||||
async def update_subscription(
|
||||
self, subscription_id: str, new_price_cents: int
|
||||
) -> SubscriptionRef | None:
|
||||
"""R-47: the provider resolves the subscription_item id INSIDE —
|
||||
`items[0][id]` must ride the update or the new price is ADDED
|
||||
alongside the old (dual-charge bug, pinned by byte-contract)."""
|
||||
try:
|
||||
current = await self._get(f"/v1/subscriptions/{subscription_id}")
|
||||
items = current.get("items", {}).get("data", [])
|
||||
if not items:
|
||||
return None
|
||||
item_id = str(items[0]["id"])
|
||||
body = await self._form_post(
|
||||
f"/v1/subscriptions/{subscription_id}",
|
||||
{
|
||||
"items[0][id]": item_id,
|
||||
"items[0][price_data][currency]": "usd",
|
||||
"items[0][price_data][unit_amount]": str(new_price_cents),
|
||||
"items[0][price_data][product_data][name]": "nextcraft plan",
|
||||
"items[0][price_data][recurring][interval]": "month",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
return SubscriptionRef(
|
||||
id=str(body.get("id", subscription_id)),
|
||||
status=_normalize_status(body.get("status", "active"), "active"),
|
||||
)
|
||||
|
||||
# -- webhooks (D-077 — the shared scheme) ---------------------------------
|
||||
|
||||
def verify_webhook(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
signature_header: str,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> VerifiedEvent:
|
||||
verify_webhook_signature(
|
||||
raw_body,
|
||||
signature_header,
|
||||
self._webhook_secret,
|
||||
now=now,
|
||||
)
|
||||
payload = _parse_event_payload(raw_body)
|
||||
return VerifiedEvent(
|
||||
event=self.normalize_event(payload),
|
||||
provider_event_id=str(payload.get("id", "")),
|
||||
)
|
||||
|
||||
def normalize_event(
|
||||
self, provider_event: dict[str, Any]
|
||||
) -> CanonicalBillingEvent:
|
||||
"""R-43: map the vendor event onto OUR canonical vocabulary —
|
||||
BOTH the name and the payload keys. Final-review fix: the
|
||||
vendor object's key spellings (`id`, `subscription`) never
|
||||
become the canonical keys (`session_id`, `subscription_id`)
|
||||
the processing route reads, so under real Stripe every
|
||||
checkout/renewal resolved no local row and landed unmatched
|
||||
with NO entitlement — the mock speaks canonical keys directly
|
||||
and hid the desync."""
|
||||
vendor_type = str(provider_event.get("type", ""))
|
||||
mapped = _EVENT_NAME_MAP.get(vendor_type)
|
||||
if mapped is None:
|
||||
raise UnknownPaymentProviderError(
|
||||
f"unmapped provider event type {vendor_type!r}"
|
||||
)
|
||||
payload = dict(provider_event.get("data", {}).get("object", {}))
|
||||
canonical: dict[str, Any] = dict(payload)
|
||||
if vendor_type == "checkout.session.completed":
|
||||
canonical["session_id"] = str(payload.get("id", ""))
|
||||
else:
|
||||
# invoice.* + customer.subscription.*: Stripe carries the
|
||||
# subscription ref at data.object.subscription (and, on
|
||||
# subscription objects, at data.object.id).
|
||||
subscription_ref = payload.get("subscription") or payload.get("id")
|
||||
canonical["subscription_id"] = str(subscription_ref or "")
|
||||
return CanonicalBillingEvent(name=mapped, payload=canonical)
|
||||
|
||||
|
||||
def _parse_event_payload(raw_body: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = json.loads(raw_body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise WebhookSignatureError(f"event body is not JSON: {exc}") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise WebhookSignatureError("event body is not a JSON object")
|
||||
return parsed
|
||||
|
||||
|
||||
def validate_stripe_config(settings: Settings) -> None:
|
||||
"""R-53/G-310: the actionable config gate — main.py's lifespan
|
||||
catches and falls back to mock with the loud G-11 log."""
|
||||
api_key = settings.payment_api_key.get_secret_value()
|
||||
if not api_key:
|
||||
raise UnknownPaymentProviderError(
|
||||
"AI_PAYMENT_PROVIDER=stripe requires AI_PAYMENT_API_KEY "
|
||||
"(env-only, D-014) — set it or drop back to the mock default"
|
||||
)
|
||||
if not settings.webhook_secret:
|
||||
raise UnknownPaymentProviderError(
|
||||
"AI_PAYMENT_PROVIDER=stripe requires AI_WEBHOOK_SECRET — "
|
||||
"webhooks fail closed without it (D-077)"
|
||||
)
|
||||
if not settings.public_base_url:
|
||||
raise UnknownPaymentProviderError(
|
||||
"AI_PAYMENT_PROVIDER=stripe requires AI_PUBLIC_BASE_URL — "
|
||||
"hosted checkout cannot build success/cancel URLs without it (G-310)"
|
||||
)
|
||||
|
||||
|
||||
def stripe_provider_from_settings(
|
||||
settings: Settings, http_client: httpx.AsyncClient
|
||||
) -> StripePaymentProvider:
|
||||
"""Factory wiring (R-53): validates config, then constructs the
|
||||
provider over the shared httpx pool."""
|
||||
validate_stripe_config(settings)
|
||||
return StripePaymentProvider(
|
||||
http_client=http_client,
|
||||
api_key=settings.payment_api_key.get_secret_value(),
|
||||
webhook_secret=settings.webhook_secret,
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Webhook signature verification core (D-077, R-38..R-40).
|
||||
|
||||
Byte-for-byte Stripe's scheme shape, shared by every provider so the
|
||||
mock and the real Stripe path verify identically (the byte-contract
|
||||
lesson): header `Stripe-Signature: t=<unix_seconds>,v1=<hex>` on one
|
||||
line; signed payload `"{timestamp}.{raw_body}"`; HMAC-SHA256 with the
|
||||
endpoint secret; constant-time comparison; ±300 s replay window.
|
||||
|
||||
Security properties (each pinned by tests/billing/test_webhooks.py):
|
||||
- raw body: verification needs the exact UN-PARSED bytes (risk-1) — the
|
||||
caller passes `raw_body: bytes`, never a pre-parsed model;
|
||||
- multiple `v1` values (secret rolls — R-38): verify iterates ALL v1
|
||||
candidates, any match wins (R-53 risk-5);
|
||||
- non-`v1` schemes (`v0` on Stripe test events, unknown future ones):
|
||||
ignored entirely — a v0-only header has no v1 to check and fails
|
||||
(downgrade protection);
|
||||
- replay window (R-40, Stripe default 300 s): the timestamp bounds how
|
||||
long a captured delivery can be replayed; `now` is injectable so
|
||||
tests are deterministic without sleeps (risk-4);
|
||||
- fail closed (D-077): every failure raises `WebhookSignatureError`
|
||||
with a precise reason — a payment surface never processes an
|
||||
unverifiable event.
|
||||
|
||||
`mint` exists for tests + the mock provider: it produces exactly what a
|
||||
signed provider delivery looks like, so the mock-complete path (D-082)
|
||||
can post through the REAL webhook processing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
from .base import WebhookSignatureError
|
||||
|
||||
#: Replay tolerance in seconds (R-40: Stripe's official default; "don't
|
||||
#: use a tolerance of 0" — clock skew between provider and box is real).
|
||||
DEFAULT_TOLERANCE_S: float = 300.0
|
||||
|
||||
|
||||
def _parse_header(header: str) -> tuple[int | None, list[str]]:
|
||||
"""Parse `t=...,v1=...,v1=...` into (timestamp, v1 candidates).
|
||||
|
||||
Elements are comma-separated `key=value` pairs (R-38). Unknown
|
||||
schemes (v0, anything else) are ignored: only `v1` is ever checked.
|
||||
Returns timestamp None when the header carries no valid `t` pair.
|
||||
"""
|
||||
timestamp: int | None = None
|
||||
v1_candidates: list[str] = []
|
||||
for element in header.split(","):
|
||||
key, sep, value = element.strip().partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
if key == "t":
|
||||
try:
|
||||
timestamp = int(value)
|
||||
except ValueError:
|
||||
# Malformed t → treat as absent; verify() fails on the
|
||||
# stale/missing-timestamp branch (fail closed).
|
||||
continue
|
||||
elif key == "v1":
|
||||
v1_candidates.append(value)
|
||||
return timestamp, v1_candidates
|
||||
|
||||
|
||||
def _compute_signature(
|
||||
secret: str, timestamp: int, raw_body: bytes
|
||||
) -> str:
|
||||
"""HMAC-SHA256 over `"{timestamp}.{raw_body}"` (R-39).
|
||||
|
||||
The timestamp is attacker-immutable in transit: changing `t` without
|
||||
the secret invalidates every v1 in the header.
|
||||
"""
|
||||
signed_payload = f"{timestamp}.".encode() + raw_body
|
||||
return hmac.new(
|
||||
secret.encode("utf-8"), signed_payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def mint(
|
||||
secret: str,
|
||||
raw_body: bytes,
|
||||
now: float | None = None,
|
||||
) -> str:
|
||||
"""Mint a `Stripe-Signature` header for `raw_body` (D-077 scheme).
|
||||
|
||||
Test + mock-provider helper (the mint half of the telemetry
|
||||
tokens.py precedent). `now` defaults to the wall clock; inject for
|
||||
determinism. Produces exactly one `t` + one `v1` pair — what a
|
||||
provider delivery carries.
|
||||
"""
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
signature = _compute_signature(secret, timestamp, raw_body)
|
||||
return f"t={timestamp},v1={signature}"
|
||||
|
||||
|
||||
def verify(
|
||||
raw_body: bytes,
|
||||
header: str | None,
|
||||
secret: str,
|
||||
*,
|
||||
now: float | None = None,
|
||||
tolerance_s: float = DEFAULT_TOLERANCE_S,
|
||||
) -> tuple[int, list[str]]:
|
||||
"""Verify a `Stripe-Signature` header against the raw body.
|
||||
|
||||
Returns `(timestamp, v1_candidates)` on success — the parsed header
|
||||
the caller may want for replay bookkeeping. Raises
|
||||
`WebhookSignatureError` with a precise reason otherwise:
|
||||
|
||||
- missing/empty header → "missing Stripe-Signature header";
|
||||
- no `v1` schemes present → "no v1 signature schemes in header"
|
||||
(a v0-only header lands here — non-v1 schemes are ignored);
|
||||
- timestamp absent or outside ±tolerance_s of now → "stale
|
||||
timestamp" (replay window, R-40);
|
||||
- no v1 candidate matches → "signature mismatch" (constant-time
|
||||
compare per candidate — any match wins across secret rolls).
|
||||
"""
|
||||
if not header:
|
||||
raise WebhookSignatureError("missing Stripe-Signature header")
|
||||
timestamp, v1_candidates = _parse_header(header)
|
||||
if not v1_candidates:
|
||||
raise WebhookSignatureError(
|
||||
f"no v1 signature schemes in header {header!r} "
|
||||
"(non-v1 schemes are ignored)"
|
||||
)
|
||||
if timestamp is None:
|
||||
raise WebhookSignatureError(
|
||||
f"missing or malformed timestamp in header {header!r}"
|
||||
)
|
||||
current = now if now is not None else time.time()
|
||||
if abs(current - timestamp) > tolerance_s:
|
||||
raise WebhookSignatureError(
|
||||
f"stale timestamp {timestamp}: outside the "
|
||||
f"{tolerance_s:g}s replay window at {current:g}"
|
||||
)
|
||||
for candidate in v1_candidates:
|
||||
expected = _compute_signature(secret, timestamp, raw_body)
|
||||
if hmac.compare_digest(expected, candidate):
|
||||
return timestamp, v1_candidates
|
||||
raise WebhookSignatureError("signature mismatch: no v1 candidate matches")
|
||||
@@ -164,6 +164,16 @@ class Settings(BaseSettings):
|
||||
# (a loud boot warning names the fix, G-11).
|
||||
admin_token: SecretStr = SecretStr("")
|
||||
|
||||
# v0.10 employer auth (REQ-10-001, D-088): both throttle knobs —
|
||||
# plain ints, the identity_submits_per_min shape (R-60d). Keyed by
|
||||
# SUBMITTED EMAIL, never client IP (R-63); in-memory, per-process.
|
||||
# (a) Failed logins per email per rolling 900 s window → 429 with
|
||||
# the honest "wait" detail; a successful login clears it.
|
||||
# (b) Well-formed login+register attempts per rolling 60 s window
|
||||
# across all emails → 429 (G-314: failed passwords count).
|
||||
employer_login_failures_per_window: int = 5
|
||||
employer_auth_attempts_per_min: int = 30
|
||||
|
||||
# v0.6 P5 placeholder (D-056): the deployed origin for sitemap.xml /
|
||||
# robots.txt absolute URLs (consumed by the SEO phase; documented
|
||||
# now so operators see it beside the admin token).
|
||||
@@ -213,3 +223,24 @@ class Settings(BaseSettings):
|
||||
# A-302/D-041: upload guard before the provider call (webm/opus is
|
||||
# ~0.5-1MB/min, so 10MB tolerates very long answers).
|
||||
voice_max_audio_mb: int = 10
|
||||
|
||||
# v0.9 Payment Engine (REQ-9-001, D-075, R-53): payment provider
|
||||
# selection — 'mock' (default: deterministic, zero-network; tests
|
||||
# NEVER call a real payment API) or 'stripe' (real Stripe-compatible
|
||||
# REST, P3). A typo'd name raises at the factory; main.py's lifespan
|
||||
# catches it and falls back to mock with a loud log (G-11 — the
|
||||
# voice/embedding factory pattern).
|
||||
payment_provider: str = "mock"
|
||||
|
||||
# Real payment API key (Stripe sk_... class) — 'stripe' requires
|
||||
# this; 'mock' ignores it. Keys env-only (D-014), never committed,
|
||||
# never logged (SecretStr mirrors the admin token; tests use fixed
|
||||
# test secrets, never real ones).
|
||||
payment_api_key: SecretStr = SecretStr("")
|
||||
|
||||
# Webhook HMAC secret (D-077, the telemetry_ingest_secret pattern):
|
||||
# UNSET (empty default) → the factory substitutes a PER-BOOT RANDOM
|
||||
# secret — restart invalidates stale signed deliveries, so webhooks
|
||||
# FAIL CLOSED when unconfigured (401, never process unverifiable —
|
||||
# correct for a payment surface). Set for multi-process deploys.
|
||||
webhook_secret: str = ""
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Employer B2B auth module (REQ-10-001, D-086..D-097, v0.10).
|
||||
|
||||
The 8th domain module inside apps/ai-service (no new apps, no new ports,
|
||||
no new dependencies — the D-031 boundary): pydantic contracts
|
||||
(`base.py`), the D-027 store family's eighth SQLite store
|
||||
(`store.py`, R-57/D-095), and the stdlib scrypt KDF (`passwords.py`,
|
||||
D-086/R-55). Claim evidence (`claim_evidence.py`) and the auth routes
|
||||
(`api/employers.py` via DI) land with their owning phases. No
|
||||
`service.py` anywhere (R-57) — routes compose the store via api/ DI.
|
||||
|
||||
G-315: email fields are plain `str` with stdlib-only validation —
|
||||
pydantic `EmailStr` is BANNED (email-validator would be the first new
|
||||
dependency, breaking R-66/D-086).
|
||||
|
||||
D-097: employer accounts are UNVERIFIED — no learner gate
|
||||
(require_verified_age/allowlist) is ever composed on an employer route.
|
||||
|
||||
Boundary (D-031 family discipline): `employers/` never imports `agents/`
|
||||
or `api/`; this package imports stdlib + pydantic + `config` only.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
CLAIM_STATUSES,
|
||||
OWN_POSTING_STATUSES,
|
||||
ClaimEmployerRequest,
|
||||
ClaimStatus,
|
||||
EmployerAccount,
|
||||
EmployerClaim,
|
||||
EmployerMe,
|
||||
EmployerPostingCreate,
|
||||
EmployerPostingEdit,
|
||||
EmployerPostingWriteResponse,
|
||||
LoginEmployerRequest,
|
||||
OwnPostingsFacets,
|
||||
OwnPostingsResponse,
|
||||
OwnPostingSummary,
|
||||
RegisterEmployerRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CLAIM_STATUSES",
|
||||
"ClaimEmployerRequest",
|
||||
"ClaimStatus",
|
||||
"EmployerAccount",
|
||||
"EmployerClaim",
|
||||
"EmployerMe",
|
||||
"EmployerPostingCreate",
|
||||
"EmployerPostingEdit",
|
||||
"EmployerPostingWriteResponse",
|
||||
"LoginEmployerRequest",
|
||||
"OWN_POSTING_STATUSES",
|
||||
"OwnPostingSummary",
|
||||
"OwnPostingsFacets",
|
||||
"OwnPostingsResponse",
|
||||
"RegisterEmployerRequest",
|
||||
"TokenResponse",
|
||||
]
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Employer auth pydantic contracts (REQ-10-001, D-086..D-097).
|
||||
|
||||
The wire + domain shapes for the employer module. `EmployerAccount` /
|
||||
`EmployerClaim` are the domain rows served over the wire; the request
|
||||
pair carries G-315 email discipline + D-086 password min length;
|
||||
`TokenResponse` / `EmployerMe` are the auth route responses.
|
||||
|
||||
G-315: email fields are plain `str` with stdlib-only validation —
|
||||
non-empty, exactly one "@", non-empty domain, stripped + lowercased on
|
||||
normalize. pydantic `EmailStr` is BANNED (email-validator is not in the
|
||||
venv and adding it would be the first new dependency, breaking
|
||||
R-66/D-086).
|
||||
|
||||
D-097: `EmployerAccount.email_verified` is `Literal[False]` — the
|
||||
account is UNVERIFIED, displayed as such; never presented as verified.
|
||||
|
||||
Id honesty (A-305, the family rule): every id in this module is an
|
||||
opaque handle minted UNIQUELY per creation call — never derived from
|
||||
user input or a deterministic hash of it.
|
||||
|
||||
P3 (REQ-10-003, G-319): `OwnPostingSummary` REUSES the PostingSummary
|
||||
wire shape by subclassing marketplace.base.Posting (the PostingSummary
|
||||
serialization contract — never duplicated) + `applicant_count`. The PII
|
||||
boundary is binding: applicant COUNTS are the only applications-derived
|
||||
datum — learner identifiers never ride any employer-facing response.
|
||||
|
||||
Boundary (D-031 family): `employers/` never imports `agents/` or `api/`;
|
||||
stdlib + pydantic + the marketplace WIRE contracts (G-319's PostingSummary
|
||||
reuse — a pure-pydantic module, never a store/engine import) only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..marketplace.base import Posting
|
||||
|
||||
#: Claim lifecycle states (D-089): every claim starts pending; an
|
||||
#: operator approve/reject decides it. Approved activates ownership
|
||||
#: (one account : one entity); rejection frees the entity for a new
|
||||
#: claim. Deferred to P2's claim routes — the shape lands now.
|
||||
ClaimStatus = Literal["pending", "approved", "rejected"]
|
||||
|
||||
#: Tuple mirror of the Literal (docs + tests import this; keeps the enum
|
||||
#: and any exhaustive switch in one place).
|
||||
CLAIM_STATUSES: tuple[ClaimStatus, ...] = (
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
)
|
||||
|
||||
#: G-315: password minimum length (D-086 honest validation floor).
|
||||
PASSWORD_MIN_LENGTH = 8
|
||||
|
||||
|
||||
def validate_email(value: str) -> str:
|
||||
"""G-315: stdlib-only email validation + normalization.
|
||||
|
||||
Non-empty (after strip), exactly one "@", non-empty local part and
|
||||
non-empty domain — normalized to stripped + lowercased. Deliberately
|
||||
NOT RFC-5322: the honest floor for an unverified pilot signup is
|
||||
structural (exactly one "@", a domain after it), never a regex
|
||||
masquerade. pydantic `EmailStr` is BANNED (it would require
|
||||
email-validator — the first new dependency, breaking R-66/D-086).
|
||||
"""
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("email must be non-empty")
|
||||
if normalized.count("@") != 1:
|
||||
raise ValueError('email must contain exactly one "@"')
|
||||
local, _, domain = normalized.partition("@")
|
||||
if not local:
|
||||
raise ValueError("email local part must be non-empty")
|
||||
if not domain:
|
||||
raise ValueError("email domain must be non-empty")
|
||||
return normalized
|
||||
|
||||
|
||||
class EmployerAccount(BaseModel):
|
||||
"""An employer-side login account (D-087).
|
||||
|
||||
`employer_id` is nullable-until-claim-approval (the UNIQUE
|
||||
one-account-one-entity constraint activates on approve, P2). The
|
||||
account is UNVERIFIED (D-097): `email_verified` is `Literal[False]`
|
||||
— no email verification exists in v0.10, so the field can never
|
||||
carry `True`, and no learner gate is ever composed on an employer
|
||||
route.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
#: G-315: plain str, stdlib-validated + normalized.
|
||||
email: str = Field(min_length=3)
|
||||
#: None until a claim is approved (P2).
|
||||
employer_id: str | None = None
|
||||
created_at: datetime
|
||||
#: D-097: UNVERIFIED — displayed as such, never as verified.
|
||||
email_verified: Literal[False] = False
|
||||
|
||||
|
||||
class EmployerClaim(BaseModel):
|
||||
"""An account's claim on an EmployerRow entity (D-089).
|
||||
|
||||
Advisory evidence is computed at claim time (P2's claim_evidence.py)
|
||||
and stored as provenance JSON; the operator decision activates
|
||||
ownership. Auto-approval is deferred (D-089: without email
|
||||
verification the match is forgeable).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
account_id: str = Field(min_length=1)
|
||||
employer_id: str = Field(min_length=1)
|
||||
status: ClaimStatus
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
#: Operator provenance (decided_by/decided_at) — None while pending.
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
|
||||
|
||||
class RegisterEmployerRequest(BaseModel):
|
||||
"""POST /v1/employers/auth/register body (Task 1-2-01).
|
||||
|
||||
Honest validation (G-315 + D-086): stdlib email normalization; the
|
||||
password carries a min-length-8 floor. Neither field is ever
|
||||
echoed back by a 422 (the pii_fields scrub lands with the routes).
|
||||
"""
|
||||
|
||||
email: str = Field(min_length=3)
|
||||
password: str = Field(min_length=PASSWORD_MIN_LENGTH)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def _validate_email(cls, value: str) -> str:
|
||||
return validate_email(value)
|
||||
|
||||
|
||||
class LoginEmployerRequest(RegisterEmployerRequest):
|
||||
"""POST /v1/employers/auth/login body — same honest validation
|
||||
(G-315 email + password floor); subclass so both routes share the
|
||||
contract and drift is structurally impossible."""
|
||||
|
||||
|
||||
class ClaimEmployerRequest(BaseModel):
|
||||
"""POST /v1/employers/claims body (Task 2-1-03, D-089): the
|
||||
marketplace EmployerRow id being claimed. The entity's existence
|
||||
is checked against the marketplace store at the route (404
|
||||
unknown); evidence is computed from the session account's email +
|
||||
the entity's website AT CLAIM TIME (R-65)."""
|
||||
|
||||
employer_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Login/register session response (D-087): the bearer token (raw,
|
||||
exactly once on this response — only sha256(token) is stored) and
|
||||
its expiry (login + SESSION_TTL_DAYS, evaluated lazily at read)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
token: str = Field(min_length=1)
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class EmployerMe(BaseModel):
|
||||
"""GET /v1/employers/auth/me response (Task 1-2-01): the account
|
||||
(unverified email displayed as such, D-097) + the claimed entity +
|
||||
the claim state — None until a claim exists (P2)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
account: EmployerAccount
|
||||
#: None until a claim is approved (P2's decide_claim sets it).
|
||||
claimed_employer_id: str | None = None
|
||||
#: None until a claim is filed; the claim state (pending/approved/
|
||||
#: rejected) once one exists (P2).
|
||||
claim: EmployerClaim | None = None
|
||||
|
||||
|
||||
#: Own-postings status facets (D-092/G-319): the posting lifecycle set
|
||||
#: (marketplace.base.PostingStatus) — every facet key is one of these.
|
||||
OWN_POSTING_STATUSES: tuple[str, ...] = ("draft", "published", "closed")
|
||||
|
||||
|
||||
class OwnPostingSummary(Posting):
|
||||
"""G-319: the employer-facing posting summary (Task 3-1-01) —
|
||||
the PostingSummary wire shape REUSED (subclass of marketplace.base.
|
||||
Posting — one serialization contract, never duplicated) + the
|
||||
per-posting applicant COUNT (the D-092 overview data source, read
|
||||
through the batched count_applications_by_posting — never a
|
||||
per-posting N+1).
|
||||
|
||||
PII boundary (binding): applicant_count is the ONLY applications-
|
||||
derived datum on this contract — learner identifiers never ride
|
||||
any /v1/employers/* response."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
applicant_count: int = 0
|
||||
|
||||
|
||||
class OwnPostingsFacets(BaseModel):
|
||||
"""Status facet counts over the session employer's OWN postings
|
||||
(draft|published|closed — the full lifecycle, unlike the public
|
||||
board's published-only view). Every status key is ALWAYS present
|
||||
(a status with zero postings carries its honest 0)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
draft: int = 0
|
||||
published: int = 0
|
||||
closed: int = 0
|
||||
|
||||
|
||||
class OwnPostingsResponse(BaseModel):
|
||||
"""GET /v1/employers/postings response (Task 3-1-01, G-319): the
|
||||
claimed employer's OWN postings (all lifecycle statuses, posted_at
|
||||
DESC then id — the list_postings order) + per-status facet counts
|
||||
+ the honest total. Unclaimed accounts never see this shape (the
|
||||
route's 403 claim-CTA precedes it)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
postings: list[OwnPostingSummary] = Field(default_factory=list)
|
||||
facets: OwnPostingsFacets
|
||||
total: int = 0
|
||||
|
||||
|
||||
class EmployerPostingCreate(BaseModel):
|
||||
"""POST /v1/employers/postings body (Task 4-1-02, REQ-10-004): the
|
||||
employer-authored draft (the AdminPostingCreate shape minus
|
||||
employer_id — the entity derives from the session's CLAIM, never
|
||||
the body; G-317 stamps source="employer" at the route). Starts as
|
||||
a DRAFT; publish is an explicit call (the admin-route lifecycle
|
||||
discipline)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
title: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
required_competencies: list[str] = Field(default_factory=list)
|
||||
skills: list[str] = Field(default_factory=list)
|
||||
seniority: str | None = None
|
||||
category: str | None = None
|
||||
location: str = ""
|
||||
remote: bool = False
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
|
||||
|
||||
class EmployerPostingEdit(BaseModel):
|
||||
"""PATCH /v1/employers/postings/{id} body (Task 4-1-02, G-318): the
|
||||
AdminPostingEdit shape — partial content edit, fields omitted stay
|
||||
put; a successful edit RE-TAGS the posting (tags are derived data,
|
||||
D-052 — the tagger overwrites). Own DRAFTS only."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
title: str | None = Field(default=None, min_length=1)
|
||||
description: str | None = None
|
||||
skills: list[str] | None = None
|
||||
|
||||
|
||||
class EmployerPostingWriteResponse(BaseModel):
|
||||
"""Create/edit/publish/close response (Task 4-1-02): the
|
||||
AdminPostingResponse shape (G-317's source stamp rides it —
|
||||
"employer" on every employer-authored row; seeded/admin rows keep
|
||||
their own)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str
|
||||
source: str
|
||||
external_id: str
|
||||
employer_id: str
|
||||
title: str
|
||||
status: str
|
||||
|
||||
|
||||
# P5 (REQ-10-005, Task 5-1-01): the checkout/entitlement contracts
|
||||
# REUSE the v0.9 billing shapes where they coincide (never duplicated —
|
||||
# CheckoutResponse here is only the wire handle; the session spec +
|
||||
# provider session live in billing/base.py as the ONE provider seam).
|
||||
|
||||
|
||||
class EmployerCheckoutCreate(BaseModel):
|
||||
"""POST /v1/employers/billing/checkout body (Task 5-1-01): which
|
||||
catalog item — the buyer derives from the session's CLAIMED entity
|
||||
(D-076's operator-mediated employer_id body field is GONE; the
|
||||
claimed entity is the only buyer the route will ever record)."""
|
||||
|
||||
item_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class EmployerCheckoutResponse(BaseModel):
|
||||
"""The created session handle + the local reconciliation id (the
|
||||
v0.9 billing_admin.CheckoutResponse shape REUSED verbatim — one
|
||||
wire contract for both checkout principals, the D-091 doctrine
|
||||
applied to the response rather than restated)."""
|
||||
|
||||
session_id: str
|
||||
provider_session_id: str
|
||||
url: str
|
||||
mode: str
|
||||
|
||||
|
||||
class EmployerEntitlementResponse(BaseModel):
|
||||
"""GET /v1/employers/billing/entitlement response (Task 5-1-01):
|
||||
the session employer's OWN entitlement summary — the v0.9
|
||||
EntitlementResponse shape (REQ-9-004) resolved for the CLAIMED
|
||||
entity only: active package (posts used/limit/expiry — lazy
|
||||
past_due grace per G-306) + subscription state. Honest states:
|
||||
purchase/subscription are None until a real grant exists; the
|
||||
shape cannot distinguish never-purchased from expired (both read
|
||||
None — the store skips expired windows lazily), so surfaces never
|
||||
assert expiry provenance from a None purchase."""
|
||||
|
||||
employer_id: str
|
||||
purchase: dict[str, Any] | None = None
|
||||
subscription: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class EmployerMockCompleteRequest(BaseModel):
|
||||
"""POST /v1/employers/billing/mock/complete body (Task 5-1-02,
|
||||
D-082/G-301): the provider session id being completed. The id is
|
||||
an UNTRUSTED reference (G-302) — the route resolves it against
|
||||
the session's OWN checkout rows, never takes it on faith."""
|
||||
|
||||
provider_session_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class EmployerMockCompleteResponse(BaseModel):
|
||||
"""The completion result (the v0.9 MockCompleteResponse shape
|
||||
REUSED verbatim)."""
|
||||
|
||||
completed: bool
|
||||
payment_status: str
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Advisory claim evidence — stdlib registrable-domain normalization (R-65, D-089).
|
||||
|
||||
The signal this module computes is ADVISORY and never displayed as
|
||||
verification (A-304): the operator decision activates a claim, the
|
||||
domain match merely informs it. Auto-approval is deferred (D-089) —
|
||||
without email verification the match is forgeable.
|
||||
|
||||
R-65 normalization is stdlib-only (zero-dep discipline, R-66): no PSL
|
||||
library, no tldextract. `urllib.parse.urlsplit(website).hostname`
|
||||
lowercases and strips scheme/path ("https://example.com/openai" →
|
||||
"example.com"); the email domain is taken after the last "@" and
|
||||
lowercased (never the local part — it rides nowhere in the evidence).
|
||||
|
||||
The comparison is a simple SUFFIX match: two domains match when equal
|
||||
or when one endswith "." + the other. KNOWN LIMIT (documented per the
|
||||
task, accepted for an advisory signal): a multi-label public suffix
|
||||
over-matches — "mail.co.uk" vs "co.uk" counts as a match even though
|
||||
"co.uk" is a public suffix, not a registrable domain, because a
|
||||
subdomain of a registrable domain is the intended match
|
||||
("shop.acme.com" vs "acme.com") and suffix equality cannot tell the
|
||||
two cases apart. That over-match is ACCEPTABLE HERE: the seeded
|
||||
corpus's websites are example.com placeholders (match=false
|
||||
corpus-wide at pilot, R-65) and the signal never gates anything — it
|
||||
is recorded provenance an operator reads. A PSL would be the first
|
||||
new dependency (R-66) — rejected.
|
||||
|
||||
Evidence shape per D-089: {account_email_domain, entity_website_domain,
|
||||
match} — computed AT CLAIM TIME (Task 2-1-03) and stored as provenance
|
||||
JSON on the claim row; it is a snapshot, never recomputed on read.
|
||||
|
||||
Boundary (D-031 family): stdlib + pydantic only; this module never
|
||||
imports agents/ or api/ — it is a pure function family over strings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def normalize_email_domain(email: str) -> str:
|
||||
"""The email's domain after the last "@" — lowercased (never the
|
||||
local part). The last-@ split tolerates local parts containing "@"
|
||||
under a quoting convention; honest callers pass validated emails
|
||||
(G-315: exactly one "@" by the time an account exists), so an
|
||||
"@"-less string is an honest-absence case: ""."""
|
||||
if "@" not in email:
|
||||
return ""
|
||||
_, _, domain = email.rpartition("@")
|
||||
return domain.lower()
|
||||
|
||||
|
||||
def normalize_website_domain(website: str) -> str:
|
||||
"""The website's host, lowercased — scheme, path, port, and any
|
||||
userinfo stripped ("HTTPS://Example.COM/openai" → "example.com").
|
||||
urlsplit parses a SCHEME-qualified URL: a bare "example.com" has no
|
||||
netloc, so a scheme is prepended for the parse ("example.com" →
|
||||
"example.com"; "example.com:8443" likewise — the port drops with
|
||||
the netloc, evidence is registrable not origin-scoped). An
|
||||
unparseable/empty website yields "" (the honest absence the route
|
||||
records, never an exception)."""
|
||||
candidate = website.strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
try:
|
||||
parts = urlsplit(candidate)
|
||||
if parts.scheme or parts.netloc:
|
||||
# Scheme-qualified ("https://…", "//host/…") — parse as-is.
|
||||
# Non-URL schemes ("mailto:…") land here with no netloc →
|
||||
# hostname None (honest absence).
|
||||
hostname = parts.hostname
|
||||
else:
|
||||
# Bare host (no scheme): urlsplit would treat it as a
|
||||
# path — prepending "//" makes it an authority-only URL.
|
||||
hostname = urlsplit(f"//{candidate}").hostname
|
||||
except ValueError:
|
||||
# e.g. an unterminated IPv6 literal — evidence stays advisory,
|
||||
# never a 500 on the claim route.
|
||||
return ""
|
||||
if hostname is None:
|
||||
return ""
|
||||
if any(char.isspace() for char in hostname):
|
||||
return "" # not a domain — honest absence, never junk
|
||||
return hostname
|
||||
|
||||
|
||||
def domains_match(account_email_domain: str, entity_website_domain: str) -> bool:
|
||||
"""Simple SUFFIX match (R-65): True when equal or when one endswith
|
||||
"." + the other. Honest absent input: either side empty → False
|
||||
(nothing claimed is nothing matched)."""
|
||||
if not account_email_domain or not entity_website_domain:
|
||||
return False
|
||||
if account_email_domain == entity_website_domain:
|
||||
return True
|
||||
if account_email_domain.endswith("." + entity_website_domain):
|
||||
return True
|
||||
if entity_website_domain.endswith("." + account_email_domain):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def compute_claim_evidence(email: str, website: str) -> dict:
|
||||
"""The D-089 evidence dict, computed at claim time:
|
||||
|
||||
{account_email_domain, entity_website_domain, match}
|
||||
|
||||
Pure function over the account email + the entity website — the
|
||||
claim route snapshots it onto the claim row as provenance JSON.
|
||||
"""
|
||||
account_email_domain = normalize_email_domain(email)
|
||||
entity_website_domain = normalize_website_domain(website)
|
||||
return {
|
||||
"account_email_domain": account_email_domain,
|
||||
"entity_website_domain": entity_website_domain,
|
||||
"match": domains_match(account_email_domain, entity_website_domain),
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Stdlib scrypt password KDF (D-086, R-55, REQ-10-001).
|
||||
|
||||
`hashlib.scrypt` (stdlib, OpenSSL-backed — D-086: zero new
|
||||
dependencies) with per-password random salt and a SELF-DESCRIBING
|
||||
storage format, `scrypt$16384$8$1$<salt_b64>$<hash_b64>`: the KDF
|
||||
parameters ride the row, so params evolve without invalidating
|
||||
existing rows (verify parses them from the stored string, never from
|
||||
the module constants).
|
||||
|
||||
Cost budget (R-55 probe, this box's venv — Python 3.11.2): ~47-59 ms
|
||||
per hash at N=2^14/r=8/p=1/dklen=32, 128·N·r = 16 MiB working set.
|
||||
Register and login each perform exactly ONE derivation — inside
|
||||
D-096's "tens of ms, honest" budget. Burst risk (open-risk-1): a
|
||||
login burst stacks single-threaded derivations; BOTH throttle windows
|
||||
(Wave 1-2, D-088/G-320) run BEFORE this KDF so a 429'd or capped
|
||||
attempt never burns KDF CPU.
|
||||
|
||||
maxmem discipline (R-55): CPython's default 32 MiB admits the 16 MiB
|
||||
working set with NO argument; the pin below is exactly 33554432 for
|
||||
explicitness — never lower (1024 KiB raises ValueError: memory limit
|
||||
exceeded; the floor is ~17 MiB).
|
||||
|
||||
Tests run the REAL KDF (D-096: no fake-hash path) and assert
|
||||
accept/reject behavior only — never hash bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
#: KDF parameters (D-086) — pinned by test (the PAST_DUE_GRACE_DAYS
|
||||
#: shape). N=2^14, r=8, p=1, dklen=32: the R-55 probe-verified
|
||||
#: cost/memory point (~50 ms/hash, 16 MiB).
|
||||
SCRYPT_N = 2**14
|
||||
SCRYPT_R = 8
|
||||
SCRYPT_P = 1
|
||||
SCRYPT_DKLEN = 32
|
||||
|
||||
#: Per-password salt length (bytes) — `secrets.token_bytes(16)`; the
|
||||
#: random salt is what makes two hashes of one password differ.
|
||||
_SALT_BYTES = 16
|
||||
|
||||
#: R-55: pinned for explicitness — the default 32 MiB admits the
|
||||
#: 16 MiB working set; NEVER pin below ~17 MiB (1024 KiB raises).
|
||||
_SCRYPT_MAXMEM = 33554432 # 32 MiB
|
||||
|
||||
#: Storage format marker — self-describing rows start with this.
|
||||
_ALGORITHM = "scrypt"
|
||||
|
||||
|
||||
def _b64e(raw: bytes) -> str:
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def _b64d(encoded: str) -> bytes:
|
||||
return base64.b64decode(encoded.encode("ascii"))
|
||||
|
||||
|
||||
def _derive(
|
||||
password: str, *, salt: bytes, n: int, r: int, p: int, dklen: int
|
||||
) -> bytes:
|
||||
"""One scrypt derivation — the ONLY derivation path (register and
|
||||
verify both route through here, so their costs stay identical)."""
|
||||
return hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
dklen=dklen,
|
||||
maxmem=_SCRYPT_MAXMEM,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password with a fresh random salt → the self-describing
|
||||
`scrypt$16384$8$1$<salt_b64>$<hash_b64>` string.
|
||||
|
||||
The module constants bake in at hash time; verify re-reads them
|
||||
from the stored row, so a future param bump never invalidates
|
||||
existing rows.
|
||||
"""
|
||||
salt = secrets.token_bytes(_SALT_BYTES)
|
||||
derived = _derive(
|
||||
password,
|
||||
salt=salt,
|
||||
n=SCRYPT_N,
|
||||
r=SCRYPT_R,
|
||||
p=SCRYPT_P,
|
||||
dklen=SCRYPT_DKLEN,
|
||||
)
|
||||
return "$".join(
|
||||
(
|
||||
_ALGORITHM,
|
||||
str(SCRYPT_N),
|
||||
str(SCRYPT_R),
|
||||
str(SCRYPT_P),
|
||||
_b64e(salt),
|
||||
_b64e(derived),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def verify_password(stored: str, password: str) -> bool:
|
||||
"""Verify a candidate against a stored self-describing hash.
|
||||
|
||||
Parses the params from the stored string (never the module
|
||||
constants — a row hashed under different params still verifies),
|
||||
re-derives with the SAME pinned maxmem, compares via
|
||||
`hmac.compare_digest` (constant-time). Any malformed stored string
|
||||
is an honest False — never a raise (the auth route maps it to 401).
|
||||
"""
|
||||
parts = stored.split("$")
|
||||
if len(parts) != 6 or parts[0] != _ALGORITHM:
|
||||
return False
|
||||
try:
|
||||
n, r, p = (int(part) for part in parts[1:4])
|
||||
salt = _b64d(parts[4])
|
||||
expected = _b64d(parts[5])
|
||||
if n < 1 or r < 1 or p < 1 or not salt or not expected:
|
||||
return False
|
||||
derived = _derive(
|
||||
password, salt=salt, n=n, r=r, p=p, dklen=len(expected)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
# Malformed stored row (bad ints, bad base64, scrypt rejection)
|
||||
# — an honest reject, never a 500 on the auth path.
|
||||
return False
|
||||
return hmac.compare_digest(derived, expected)
|
||||
@@ -0,0 +1,739 @@
|
||||
"""EmployerStore — the 8th D-027 store family (D-087/D-095, REQ-10-001).
|
||||
|
||||
Four tables on the shared `Settings().db_path` (one SQLite file with the
|
||||
seven prior stores): `employer_accounts`, `employer_sessions`,
|
||||
`employer_claims`, `employer_audit_events`. All `employer_`-prefixed —
|
||||
bare `employer` is marketplace's EmployerRow (marketplace/store.py:144).
|
||||
Mechanics mirror the probe-verified billing/store.py exactly (R-57):
|
||||
WAL / synchronous=NORMAL / busy_timeout / foreign_keys pragmas at
|
||||
connect time, `sa_type` portable columns (String/JSON for Postgres
|
||||
parity), `_validate()`/`_normalize()` hooks, Protocol + SQLite impl
|
||||
pair, surfaced IntegrityError, tz-aware → naive → tz-aware boundary
|
||||
normalization, one-time idempotent `create_schema()` (D-095 — the
|
||||
tables join an existing shared DB at next boot, zero migration).
|
||||
|
||||
Guards baked into the schema (D-087):
|
||||
- `employer_accounts.email` UNIQUE — one account per email; the
|
||||
register route's check-first 409 is the fast path, the constraint
|
||||
is the race backstop and its IntegrityError SURFACES.
|
||||
- `employer_accounts.employer_id` UNIQUE nullable — the one-account-
|
||||
one-entity constraint, activates on claim approval (P2).
|
||||
- `employer_sessions.token_hash` UNIQUE — stores ONLY
|
||||
`sha256(token)`, NEVER the raw token (D-087): a DB leak yields
|
||||
nothing usable against the Authorization header namespace.
|
||||
- G-306: session expiry is evaluated LAZILY at read time against
|
||||
`expires_at` (login + SESSION_TTL_DAYS) — an expired row reads as
|
||||
absent; no background timer, no writes on evaluation.
|
||||
- `employer_claims` landed in P1 per D-095's one-time create_all;
|
||||
its claim methods (insert_claim/decide_claim/list_claims/get_claim)
|
||||
+ the G-316 partial UNIQUE index arrive with P2 (D-089).
|
||||
|
||||
Boundary (D-027 family): `employers/` never imports `agents/`/`api/`;
|
||||
this module imports config + base only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import Index, UniqueConstraint, event, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.types import JSON, String
|
||||
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
|
||||
|
||||
from ..config import Settings
|
||||
from .base import CLAIM_STATUSES, validate_email
|
||||
|
||||
#: D-087: a login session's TTL. Module constant (not settings) —
|
||||
#: pinned by test (the PAST_DUE_GRACE_DAYS shape); evaluation is lazy
|
||||
#: at read time (G-306), never a timer.
|
||||
SESSION_TTL_DAYS = 7
|
||||
|
||||
_TABLES = (
|
||||
"employer_accounts",
|
||||
"employer_sessions",
|
||||
"employer_claims",
|
||||
"employer_audit_events",
|
||||
)
|
||||
|
||||
#: Audit event vocabulary: `auth` = the Wave 1-2 register/logout
|
||||
#: account events; `claim` = P2's claim/decide entity events; `manual`
|
||||
#: = operator action provenance (the billing kind set, employer-side).
|
||||
#: v0.11 P2: "application" joins the vocabulary (employer-driven hiring
|
||||
#: transitions append their audit row — the D-089 one-queryable lane).
|
||||
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual", "application")
|
||||
|
||||
|
||||
class AccountRow(SQLModel, table=True):
|
||||
"""An employer login account row (D-087).
|
||||
|
||||
Bare Literal annotations crash sqlmodel's column inference; the
|
||||
family pattern (explicit sa_type + _validate hook) is used where
|
||||
needed. `email` is normalized via base.validate_email (G-315) at
|
||||
the store boundary — routes normalize too; the store re-derives,
|
||||
never trusts."""
|
||||
|
||||
__tablename__ = "employer_accounts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("email", name="uq_account_email"),
|
||||
UniqueConstraint("employer_id", name="uq_account_employer"),
|
||||
)
|
||||
|
||||
id: str = Field(primary_key=True, sa_type=String)
|
||||
#: G-315: plain str, stdlib-validated + stripped/lowercased.
|
||||
email: str = Field(sa_type=String)
|
||||
#: Self-describing scrypt string (passwords.py) — never a raw hash.
|
||||
password_hash: str = Field(sa_type=String)
|
||||
#: None until a claim is APPROVED (P2) — the UNIQUE nullable
|
||||
#: one-account-one-entity constraint.
|
||||
employer_id: str | None = Field(default=None, sa_type=String)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id:
|
||||
raise ValueError("id must be non-empty")
|
||||
if not self.password_hash:
|
||||
raise ValueError("password_hash must be non-empty")
|
||||
if self.employer_id is not None and not self.employer_id:
|
||||
raise ValueError("employer_id must be non-empty when set")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
# G-315 at the store boundary: validate + strip + lowercase
|
||||
# (raises ValueError on a dishonest email — never trusts).
|
||||
self.email = validate_email(self.email)
|
||||
self.created_at = _as_utc(self.created_at) if self.created_at else None
|
||||
|
||||
|
||||
class SessionRow(SQLModel, table=True):
|
||||
"""A hashed server-side session row (D-087).
|
||||
|
||||
`token_hash` is sha256(token) — the raw token is handed to the
|
||||
client EXACTLY ONCE on login and never persisted anywhere. The
|
||||
TTL is evaluated LAZILY at read (G-306): an expired row reads as
|
||||
absent; no timer ever rewrites or deletes it."""
|
||||
|
||||
__tablename__ = "employer_sessions"
|
||||
__table_args__ = (UniqueConstraint("token_hash", name="uq_session_token_hash"),)
|
||||
|
||||
id: str = Field(primary_key=True, sa_type=String)
|
||||
token_hash: str = Field(sa_type=String)
|
||||
account_id: str = Field(index=True, sa_type=String)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
expires_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id:
|
||||
raise ValueError("id must be non-empty")
|
||||
if not self.token_hash:
|
||||
raise ValueError("token_hash must be non-empty")
|
||||
if not self.account_id:
|
||||
raise ValueError("account_id must be non-empty")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.created_at = _as_utc(self.created_at) if self.created_at else None
|
||||
self.expires_at = _as_utc(self.expires_at) if self.expires_at else None
|
||||
|
||||
|
||||
class ClaimRow(SQLModel, table=True):
|
||||
"""An account's claim on a marketplace EmployerRow entity (D-089).
|
||||
|
||||
The table lands now per D-095's one-time create_all; the claim
|
||||
methods (insert_claim/decide_claim/list_claims) arrive with P2.
|
||||
`evidence` is the advisory claim-time provenance JSON
|
||||
{account_email_domain, entity_website_domain, match} (R-65). The
|
||||
decided_* pair carries operator provenance and is valid only on a
|
||||
decided row (the past_due_at pairing discipline).
|
||||
|
||||
G-316: the partial UNIQUE index uq_pending_claim_per_entity on
|
||||
employer_id WHERE status='pending' makes one-PENDING-claim-per-
|
||||
entity a STRUCTURAL guard — check-first alone is TOCTOU. The
|
||||
approved-owner arm is already structural: employer_accounts.
|
||||
employer_id UNIQUE (one account : one entity). Rejection frees
|
||||
the entity — a partial index over status='pending' never touches
|
||||
rejected rows.
|
||||
"""
|
||||
|
||||
__tablename__ = "employer_claims"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_pending_claim_per_entity",
|
||||
"employer_id",
|
||||
unique=True,
|
||||
sqlite_where=text("status='pending'"),
|
||||
),
|
||||
)
|
||||
|
||||
id: str = Field(primary_key=True, sa_type=String)
|
||||
account_id: str = Field(index=True, sa_type=String)
|
||||
employer_id: str = Field(index=True, sa_type=String)
|
||||
status: str = Field(default="pending", sa_type=String)
|
||||
evidence: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
decided_by: str | None = Field(default=None, sa_type=String)
|
||||
decided_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id or not self.account_id or not self.employer_id:
|
||||
raise ValueError("id, account_id, and employer_id must be non-empty")
|
||||
if self.status not in CLAIM_STATUSES:
|
||||
raise ValueError(f"invalid claim status {self.status!r}")
|
||||
if self.status == "pending" and (
|
||||
self.decided_by is not None or self.decided_at is not None
|
||||
):
|
||||
raise ValueError("decided_by/decided_at are set only on a decided claim")
|
||||
if self.status in ("approved", "rejected") and self.decided_at is None:
|
||||
raise ValueError("decided_at must be set on a decided claim")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.created_at = _as_utc(self.created_at) if self.created_at else None
|
||||
self.decided_at = _as_utc(self.decided_at) if self.decided_at else None
|
||||
|
||||
|
||||
class AuditEventRow(SQLModel, table=True):
|
||||
"""Employer-side audit trail — the BillingEventRow audit shape
|
||||
(billing/store.py:337-359): auth events (register/logout), claim
|
||||
events (P2), and operator actions with provenance."""
|
||||
|
||||
__tablename__ = "employer_audit_events"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
account_id: str | None = Field(default=None, index=True, sa_type=String)
|
||||
employer_id: str | None = Field(default=None, index=True, sa_type=String)
|
||||
kind: str = Field(sa_type=String)
|
||||
name: str = Field(sa_type=String)
|
||||
detail: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
||||
operator: str | None = Field(default=None, sa_type=String)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if self.kind not in _AUDIT_EVENT_KINDS:
|
||||
raise ValueError(f"invalid audit event kind {self.kind!r}")
|
||||
if not self.name:
|
||||
raise ValueError("name must be non-empty")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.created_at = _as_utc(self.created_at) if self.created_at else None
|
||||
|
||||
|
||||
def _as_utc(ts: datetime) -> datetime:
|
||||
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027 pattern)."""
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=UTC)
|
||||
return ts
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
"""D-087: sessions store ONLY sha256(token) — never the raw token."""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _constraint_error(message: str, params: dict[str, Any]) -> IntegrityError:
|
||||
"""Hand-raised IntegrityError (the guard arms of decide_claim) —
|
||||
the caller's honest 409, never a silent overwrite. The original
|
||||
exception is the raised guard itself."""
|
||||
return IntegrityError(message, params, ValueError(message))
|
||||
|
||||
|
||||
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
|
||||
"""Per-connection pragmas — mirrors the other D-027 stores."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
class EmployerStore(Protocol):
|
||||
"""Persistence contract for employer auth (D-087/D-095)."""
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""Ensure all four tables exist (idempotent — shared DB file)."""
|
||||
...
|
||||
|
||||
def insert_account(self, *, email: str, password_hash: str) -> tuple[AccountRow, bool]:
|
||||
"""Check-first insert by email → (row, created); the EXISTING
|
||||
row rides a duplicate register (route → 409). The UNIQUE
|
||||
constraint is the race backstop — its IntegrityError SURFACES
|
||||
(never swallowed, never re-masked as created=False)."""
|
||||
...
|
||||
|
||||
def get_account_by_email(self, email: str) -> AccountRow | None:
|
||||
"""Point lookup by the normalized email (login path)."""
|
||||
...
|
||||
|
||||
def get_account(self, account_id: str) -> AccountRow | None:
|
||||
"""Point lookup by id (require_employer_session's account arm)."""
|
||||
...
|
||||
|
||||
def mint_session(
|
||||
self, *, account_id: str, token: str, now: datetime | None = None
|
||||
) -> SessionRow:
|
||||
"""Store sha256(token) with expires_at = now + SESSION_TTL_DAYS
|
||||
(D-087). The raw token is NEVER persisted; a duplicate
|
||||
token_hash surfaces as IntegrityError (the constraint is the
|
||||
only guard — a token is minted fresh per login)."""
|
||||
...
|
||||
|
||||
def get_session_by_token_hash(
|
||||
self, token_hash: str, *, now: datetime | None = None
|
||||
) -> SessionRow | None:
|
||||
"""Point lookup by sha256(token) with LAZY TTL (G-306): an
|
||||
expired row reads as ABSENT — no timer, no writes on
|
||||
evaluation; the row itself stays until revoked."""
|
||||
...
|
||||
|
||||
def revoke_session(self, token_hash: str) -> bool:
|
||||
"""DELETE the session row (logout); True when a row was
|
||||
removed, False when it was already gone."""
|
||||
...
|
||||
|
||||
def append_audit_event(
|
||||
self,
|
||||
kind: str,
|
||||
name: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
employer_id: str | None = None,
|
||||
operator: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> AuditEventRow:
|
||||
"""Audit log append (auth/claim/manual)."""
|
||||
...
|
||||
|
||||
# -- claims (D-089, REQ-10-002) ---------------------------------------
|
||||
|
||||
def insert_claim(
|
||||
self,
|
||||
*,
|
||||
account_id: str,
|
||||
employer_id: str,
|
||||
evidence: dict[str, Any] | None = None,
|
||||
) -> tuple[ClaimRow, bool]:
|
||||
"""Check-first pending-claim insert (the insert_application
|
||||
precedent, marketplace/store.py:341-347): an existing PENDING
|
||||
claim on the entity returns (existing_row, False) — the route's
|
||||
honest 409 fast path. G-316: the partial UNIQUE index
|
||||
uq_pending_claim_per_entity backstops the check-first TOCTOU —
|
||||
a raced duplicate insert SURFACES its IntegrityError (never
|
||||
swallowed, never re-masked as created=False); the caller maps
|
||||
it to the SAME 409. Rejected/decided rows never block: rejection
|
||||
frees the entity for a new claim."""
|
||||
...
|
||||
|
||||
def decide_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
*,
|
||||
decision: str,
|
||||
operator: str,
|
||||
now: datetime | None = None,
|
||||
) -> ClaimRow | None:
|
||||
"""Operator decision on a PENDING claim (D-089): approve → set
|
||||
employer_accounts.employer_id + decided_by/decided_at + a
|
||||
`claim` audit event; reject → decided_by/decided_at + the audit
|
||||
event (the entity stays free). The one-account-one-entity arm
|
||||
is STRUCTURAL: approving when the account already holds an
|
||||
entity, or the entity is already owned, raises a hand-raised
|
||||
IntegrityError (the caller's honest 409 — never a silent
|
||||
overwrite). Unknown claim id → None (route 404s)."""
|
||||
...
|
||||
|
||||
def list_claims(self, status: str | None = None) -> list[ClaimRow]:
|
||||
"""Claims newest-first; optional status filter (pending/
|
||||
approved/rejected — the admin queue's consume-free read path)."""
|
||||
...
|
||||
|
||||
def list_accounts(self) -> list[AccountRow]:
|
||||
"""All accounts, created-oldest-first (the admin accounts list —
|
||||
consume-free read path; the queue is pilot-sized)."""
|
||||
...
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
employer_id: str | None = None,
|
||||
kind: str | None = None,
|
||||
name: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[AuditEventRow]:
|
||||
"""Audit trail, newest-first (the list_billing_events shape,
|
||||
billing/store.py:1122-1145); optional account/employer/kind/name
|
||||
filters — the queryable operator trail (D-089)."""
|
||||
...
|
||||
|
||||
def get_claim(self, claim_id: str) -> ClaimRow | None:
|
||||
"""Point lookup by id (the admin review path)."""
|
||||
...
|
||||
|
||||
def get_claim_by_account(
|
||||
self, account_id: str, *, exclude_statuses: tuple[str, ...] = ()
|
||||
) -> ClaimRow | None:
|
||||
"""The account's most recent claim NOT in exclude_statuses
|
||||
(newest-first — created_at DESC then id; the /me claim-state
|
||||
read). An account with no non-excluded claim → None."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
"""Dispose the engine (app shutdown)."""
|
||||
...
|
||||
|
||||
|
||||
class SQLiteEmployerStore:
|
||||
"""SQLite implementation of EmployerStore (D-027/D-087)."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self._db_path: Path = db_path if db_path is not None else Settings().db_path
|
||||
self._engine = create_engine(
|
||||
f"sqlite:///{self._db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
event.listen(self._engine, "connect", _sqlite_connect)
|
||||
self.create_schema()
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""Ensure all four tables exist (idempotent — shared DB file)."""
|
||||
SQLModel.metadata.create_all(self._engine)
|
||||
|
||||
# -- accounts -----------------------------------------------------------
|
||||
|
||||
def insert_account(self, *, email: str, password_hash: str) -> tuple[AccountRow, bool]:
|
||||
"""Check-first insert (the insert_application pattern): an
|
||||
existing email returns (existing_row, False) — the register
|
||||
route's honest 409. The UNIQUE constraint backstops the
|
||||
check-first TOCTOU; its IntegrityError is SURFACED, never
|
||||
swallowed."""
|
||||
row = AccountRow(
|
||||
id=f"acct-{uuid.uuid4().hex[:16]}",
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
employer_id=None,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
with Session(self._engine) as session:
|
||||
existing = session.exec(
|
||||
select(AccountRow).where(AccountRow.email == row.email)
|
||||
).one_or_none()
|
||||
if existing is not None:
|
||||
existing._normalize()
|
||||
return existing, False
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
row._normalize()
|
||||
return row, True
|
||||
|
||||
def get_account_by_email(self, email: str) -> AccountRow | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.exec(select(AccountRow).where(AccountRow.email == email)).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def get_account(self, account_id: str) -> AccountRow | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(AccountRow, account_id)
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
# -- sessions (D-087: hashed-at-rest; G-306: lazy TTL) -------------------
|
||||
|
||||
def mint_session(
|
||||
self, *, account_id: str, token: str, now: datetime | None = None
|
||||
) -> SessionRow:
|
||||
if not token:
|
||||
raise ValueError("token must be non-empty")
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
row = SessionRow(
|
||||
id=f"ses-{uuid.uuid4().hex[:16]}",
|
||||
# D-087: ONLY the sha256 ever persists — the raw token
|
||||
# lives in the login response and nowhere else.
|
||||
token_hash=_hash_token(token),
|
||||
account_id=account_id,
|
||||
created_at=moment,
|
||||
expires_at=moment + timedelta(days=SESSION_TTL_DAYS),
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
with Session(self._engine) as session:
|
||||
session.add(row)
|
||||
# The UNIQUE(token_hash) constraint surfaces a duplicate
|
||||
# mint as IntegrityError — the caller decides; never
|
||||
# swallowed here.
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def get_session_by_token_hash(
|
||||
self, token_hash: str, *, now: datetime | None = None
|
||||
) -> SessionRow | None:
|
||||
"""G-306: expired rows read as ABSENT — lazy evaluation, no
|
||||
timer, no writes on the read path."""
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
with Session(self._engine) as session:
|
||||
row = session.exec(
|
||||
select(SessionRow).where(SessionRow.token_hash == token_hash)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
assert row.expires_at is not None # mint always sets it
|
||||
if moment >= row.expires_at:
|
||||
return None # expired — reads as absent, stays on disk
|
||||
return row
|
||||
|
||||
def revoke_session(self, token_hash: str) -> bool:
|
||||
"""DELETE the row (logout). True = a row was removed; False =
|
||||
nothing to revoke (unknown/already-revoked token)."""
|
||||
with Session(self._engine) as session:
|
||||
row = session.exec(
|
||||
select(SessionRow).where(SessionRow.token_hash == token_hash)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
# -- audit log ----------------------------------------------------------
|
||||
|
||||
def append_audit_event(
|
||||
self,
|
||||
kind: str,
|
||||
name: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
employer_id: str | None = None,
|
||||
operator: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> AuditEventRow:
|
||||
row = AuditEventRow(
|
||||
account_id=account_id,
|
||||
employer_id=employer_id,
|
||||
kind=kind,
|
||||
name=name,
|
||||
detail=dict(detail or {}),
|
||||
operator=operator,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
with Session(self._engine) as session:
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
# -- claims (D-089, REQ-10-002) ---------------------------------------
|
||||
|
||||
def insert_claim(
|
||||
self,
|
||||
*,
|
||||
account_id: str,
|
||||
employer_id: str,
|
||||
evidence: dict[str, Any] | None = None,
|
||||
) -> tuple[ClaimRow, bool]:
|
||||
"""Check-first pending-claim insert (the insert_application
|
||||
precedent): an existing PENDING claim on the entity rides
|
||||
created=False — never a second pending row. G-316: the partial
|
||||
UNIQUE index backstops the race — its IntegrityError SURFACES
|
||||
(the caller maps it to the same honest 409)."""
|
||||
row = ClaimRow(
|
||||
id=f"clm-{uuid.uuid4().hex[:16]}",
|
||||
account_id=account_id,
|
||||
employer_id=employer_id,
|
||||
status="pending",
|
||||
evidence=dict(evidence or {}),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
with Session(self._engine) as session:
|
||||
existing = session.exec(
|
||||
select(ClaimRow).where(
|
||||
ClaimRow.employer_id == employer_id,
|
||||
ClaimRow.status == "pending",
|
||||
)
|
||||
).one_or_none()
|
||||
if existing is not None:
|
||||
# Fast path: the pending claim exists — the existing
|
||||
# row rides, created=False (the route's 409).
|
||||
existing._normalize()
|
||||
return existing, False
|
||||
session.add(row)
|
||||
# G-316: a raced insert lands here — the partial UNIQUE
|
||||
# index refuses the second PENDING row and its
|
||||
# IntegrityError SURFACES, never swallowed here.
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
row._normalize()
|
||||
return row, True
|
||||
|
||||
def decide_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
*,
|
||||
decision: str,
|
||||
operator: str,
|
||||
now: datetime | None = None,
|
||||
) -> ClaimRow | None:
|
||||
"""Approve → employer_accounts.employer_id set + decided_* + a
|
||||
`claim` audit event; reject → decided_* + the audit event.
|
||||
The one-owner arms are STRUCTURAL: employer_accounts.employer_id
|
||||
UNIQUE (approve hands its IntegrityError to the caller) and a
|
||||
hand-raised guard when the claim is not pending (the caller's
|
||||
honest 409 — never a silent overwrite)."""
|
||||
if decision not in ("approved", "rejected"):
|
||||
raise ValueError(f"invalid decision {decision!r}")
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(ClaimRow, claim_id)
|
||||
if row is None:
|
||||
return None
|
||||
if row.status != "pending":
|
||||
# Hand-raised IntegrityError — the caller's honest 409
|
||||
# (deciding a decided claim, the IntegrityError shape).
|
||||
raise _constraint_error(
|
||||
f"guard: claim {claim_id!r} is already {row.status!r}",
|
||||
{"claim_id": claim_id},
|
||||
)
|
||||
row.status = decision
|
||||
row.decided_by = operator
|
||||
row.decided_at = moment
|
||||
row._validate()
|
||||
row._normalize()
|
||||
if decision == "approved":
|
||||
account = session.get(AccountRow, row.account_id)
|
||||
if account is None: # pragma: no cover - FK discipline
|
||||
raise _constraint_error(
|
||||
f"guard: claim {claim_id!r} references unknown "
|
||||
"account — refusing to approve",
|
||||
{"claim_id": claim_id},
|
||||
)
|
||||
if account.employer_id is not None:
|
||||
# One account : one entity — the account already
|
||||
# holds an entity. Hand-raised (the account's
|
||||
# employer_id UNIQUE never sees this UPDATE).
|
||||
raise _constraint_error(
|
||||
"guard: employer_accounts.employer_id already set",
|
||||
{"account_id": row.account_id},
|
||||
)
|
||||
account.employer_id = row.employer_id
|
||||
session.flush()
|
||||
# The audit row rides the SAME transaction: the decision
|
||||
# and its provenance land together (all-or-nothing).
|
||||
event = AuditEventRow(
|
||||
account_id=row.account_id,
|
||||
employer_id=row.employer_id,
|
||||
kind="claim",
|
||||
name=f"claim.{decision}",
|
||||
detail={
|
||||
"claim_id": row.id,
|
||||
"employer_id": row.employer_id,
|
||||
"operator": operator,
|
||||
},
|
||||
operator=operator,
|
||||
created_at=moment,
|
||||
)
|
||||
event._validate()
|
||||
event._normalize()
|
||||
session.add(event)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def list_claims(self, status: str | None = None) -> list[ClaimRow]:
|
||||
"""Newest-first; optional status filter (the admin queue)."""
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(ClaimRow)
|
||||
if status is not None:
|
||||
stmt = stmt.where(ClaimRow.status == status)
|
||||
rows = session.exec(stmt.order_by(col(ClaimRow.created_at).desc(), ClaimRow.id)).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def list_accounts(self) -> list[AccountRow]:
|
||||
"""Created-oldest-first (the admin accounts list)."""
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(AccountRow).order_by(col(AccountRow.created_at), AccountRow.id)
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
employer_id: str | None = None,
|
||||
kind: str | None = None,
|
||||
name: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[AuditEventRow]:
|
||||
"""Newest-first (the list_billing_events shape); optional
|
||||
account/employer/kind/name filters — D-089's operator trail."""
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(AuditEventRow)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(AuditEventRow.account_id == account_id)
|
||||
if employer_id is not None:
|
||||
stmt = stmt.where(AuditEventRow.employer_id == employer_id)
|
||||
if kind is not None:
|
||||
stmt = stmt.where(AuditEventRow.kind == kind)
|
||||
if name is not None:
|
||||
stmt = stmt.where(AuditEventRow.name == name)
|
||||
rows = session.exec(
|
||||
stmt.order_by(
|
||||
col(AuditEventRow.created_at).desc(),
|
||||
col(AuditEventRow.id),
|
||||
).limit(limit)
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def get_claim(self, claim_id: str) -> ClaimRow | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(ClaimRow, claim_id)
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def get_claim_by_account(
|
||||
self, account_id: str, *, exclude_statuses: tuple[str, ...] = ()
|
||||
) -> ClaimRow | None:
|
||||
"""The account's most recent claim not in exclude_statuses
|
||||
(newest-first; the /me claim-state read)."""
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(ClaimRow).where(ClaimRow.account_id == account_id)
|
||||
if exclude_statuses:
|
||||
stmt = stmt.where(
|
||||
col(ClaimRow.status).not_in(exclude_statuses)
|
||||
)
|
||||
row = session.exec(
|
||||
stmt.order_by(col(ClaimRow.created_at).desc(), ClaimRow.id)
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def close(self) -> None:
|
||||
self._engine.dispose()
|
||||
@@ -176,6 +176,63 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
db_path=settings.db_path
|
||||
)
|
||||
|
||||
# Billing engine (v0.9, REQ-9-002): 7th D-027 store family +
|
||||
# the seeded catalog (D-078 — idempotent upsert, safe on every
|
||||
# boot). State-injection override preserved.
|
||||
if getattr(app.state, "billing_store", None) is None:
|
||||
from .billing.store import SQLiteBillingStore
|
||||
|
||||
app.state.billing_store = SQLiteBillingStore(db_path=settings.db_path)
|
||||
if getattr(app.state, "billing_seeded", None) is not True:
|
||||
from .billing.catalog import seed_billing_catalog
|
||||
|
||||
seed_billing_catalog(app.state.billing_store)
|
||||
app.state.billing_seeded = True
|
||||
|
||||
# Employer auth engine (v0.10, REQ-10-001): 8th D-027 store
|
||||
# family on the same SQLite file. State-injection override
|
||||
# preserved (the billing_store getattr-guard template).
|
||||
if getattr(app.state, "employer_store", None) is None:
|
||||
from .employers.store import SQLiteEmployerStore
|
||||
|
||||
app.state.employer_store = SQLiteEmployerStore(
|
||||
db_path=settings.db_path
|
||||
)
|
||||
|
||||
# Placement tracking (v0.11, REQ-11-001): 9th D-027 store
|
||||
# family on the same SQLite file (D-100: the transactional
|
||||
# transition writes the marketplace application row through the
|
||||
# shared metadata — one file, one commit). State-injection
|
||||
# override preserved (the same getattr-guard template).
|
||||
if getattr(app.state, "placement_store", None) is None:
|
||||
from .placements.store import SQLitePlacementStore
|
||||
|
||||
app.state.placement_store = SQLitePlacementStore(
|
||||
db_path=settings.db_path
|
||||
)
|
||||
|
||||
# Payment provider (v0.9 P3, REQ-9-003, D-075): mock-first
|
||||
# default; stripe over the shared httpx pool. G-11 (boot
|
||||
# survival): a misconfigured real provider must never crash the
|
||||
# unattended deploy — fall back to mock loudly (the
|
||||
# voice/embedding pattern).
|
||||
if getattr(app.state, "payment_provider", None) is None:
|
||||
from .billing.factory import payment_provider_from_settings
|
||||
from .billing.mock import MockPaymentProvider
|
||||
|
||||
try:
|
||||
app.state.payment_provider = payment_provider_from_settings(
|
||||
settings, app.state.http_client
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"payment provider %r unavailable (%s); falling back to "
|
||||
"mock — fix the AI_PAYMENT_* settings and restart",
|
||||
settings.payment_provider,
|
||||
exc,
|
||||
)
|
||||
app.state.payment_provider = MockPaymentProvider()
|
||||
|
||||
# Embedding provider (v0.6 P3, REQ-6-003, D-051): mock-first
|
||||
# feature-hash default; openai-embed against any OpenAI-compatible
|
||||
# embeddings endpoint. State-injection override preserved (the
|
||||
@@ -287,6 +344,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
variant_store.close()
|
||||
defense_store.close()
|
||||
identity_store.close()
|
||||
app.state.billing_store.close()
|
||||
app.state.employer_store.close()
|
||||
app.state.placement_store.close()
|
||||
await app.state.http_client.aclose()
|
||||
|
||||
app = FastAPI(title="Nextcraft AI Service", version=__version__, lifespan=lifespan)
|
||||
@@ -298,11 +358,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
# build surface writes workspace files with PUT (engine-client writeFile)
|
||||
# — v0.3 initially shipped without it and every cross-origin Save failed
|
||||
# preflight (caught in P7 review; tests/api/test_cors.py pins the policy).
|
||||
# v0.10 (D-090): "Authorization" joins allow_headers — the employer
|
||||
# session surface sends Bearer tokens, and without it every
|
||||
# cross-origin employer fetch dies preflight in the two-port dev +
|
||||
# D-038 network-mode topologies (the E87 lesson applied before it
|
||||
# ships; test_cors.py pins it).
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
allow_credentials=False,
|
||||
)
|
||||
|
||||
@@ -335,6 +400,37 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(marketplace_public_router)
|
||||
app.include_router(marketplace_admin_router)
|
||||
|
||||
# v0.9 billing (REQ-9-002/003): the public catalog + webhook read
|
||||
# surface + the operator checkout routes (admin-gated, D-076). The
|
||||
# mock-complete route is gated by provider selection at runtime
|
||||
# (G-301 — admin-gated AND mock-only), so the router mounts whole.
|
||||
from .api.billing import router as billing_router
|
||||
from .api.billing_admin import router as billing_admin_router
|
||||
|
||||
app.include_router(billing_router)
|
||||
app.include_router(billing_admin_router)
|
||||
|
||||
# v0.10 employers (REQ-10-001): register/login/logout/me session
|
||||
# surface (D-090 Bearer transport; throttles D-088). P2 (REQ-10-002):
|
||||
# the claims route rides the sibling router (session-gated, D-089).
|
||||
from .api.employers import claims_router as employers_claims_router
|
||||
from .api.employers import router as employers_router
|
||||
from .api.employers_admin import router as employers_admin_router
|
||||
|
||||
app.include_router(employers_router)
|
||||
app.include_router(employers_claims_router)
|
||||
# P2 (REQ-10-002, D-089/D-094): the claim review/approve/reject +
|
||||
# accounts/audit operator routes — require_admin (fail-closed).
|
||||
app.include_router(employers_admin_router)
|
||||
|
||||
# v0.11 placements (REQ-11-002, D-106): the operator lane — the
|
||||
# generic transition + revoke + the R-80 re-hire; the reporting/
|
||||
# export reads extend this router in P3/P4. require_admin
|
||||
# composed at the router level (fail-closed, D-090 no-collision).
|
||||
from .api.placements_admin import router as placements_admin_router
|
||||
|
||||
app.include_router(placements_admin_router)
|
||||
|
||||
# v0.6 P5 (REQ-6-006, D-056): dynamic sitemap/robots — request-time
|
||||
# enumeration (build-time Next sitemap.ts cannot see runtime
|
||||
# postings under output:'export'); API routes beat the static mount.
|
||||
@@ -352,26 +448,44 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
def _scrub_pii_validation(request, exc):
|
||||
# P5: the old `# type: ignore[unused-arg]` was inert (the unused-arg
|
||||
# code is not enabled by this baseline); removed per D-058.
|
||||
pii_fields = frozenset({"date_of_birth"})
|
||||
# v0.10 (R-56/open-risk-3): gains "password" + "email" — a pydantic
|
||||
# 422 on an employer register/login body would echo the raw
|
||||
# password into err["input"] (the response + client logs) today.
|
||||
# Security-audit fix (Wave 1-3, task 1-3-01): the field arm alone
|
||||
# leaks on WHOLE-BODY type errors — a non-dict body (list/str/int)
|
||||
# errors at loc == ("body",) with input = the ENTIRE submitted
|
||||
# payload verbatim, which for a mistyped register attempt carries
|
||||
# the raw password. Any body-level error whose input mentions a
|
||||
# PII key (dict or payload-shaped) is scrubbed too.
|
||||
pii_fields = frozenset({"date_of_birth", "password", "email"})
|
||||
scrubbed = []
|
||||
for err in exc.errors():
|
||||
err = dict(err)
|
||||
# FastAPI's default 422s are JSON-safe EXCEPT ctx payloads
|
||||
# carrying raw ValueError objects (pydantic model_validator
|
||||
# errors); strip ctx body-wide so non-PII routes keep their
|
||||
# 422 shape (msg + loc carry the meaning).
|
||||
ctx = err.get("ctx")
|
||||
if isinstance(ctx, dict):
|
||||
err["ctx"] = {
|
||||
k: v for k, v in ctx.items() if isinstance(v, (str, int, float, bool))
|
||||
}
|
||||
if err.get("loc") and err["loc"][-1] in pii_fields:
|
||||
# A-305/D1: never echo the submitted value (input) or the
|
||||
# ctx payload (carries the ValueError); the msg is the
|
||||
# constraint text and is safe.
|
||||
err["input"] = "[redacted]"
|
||||
err.pop("ctx", None)
|
||||
else:
|
||||
# FastAPI's default 422s are JSON-safe EXCEPT ctx payloads
|
||||
# carrying raw ValueError objects (pydantic model_validator
|
||||
# errors); strip ctx body-wide so non-PII routes keep their
|
||||
# 422 shape (msg + loc carry the meaning).
|
||||
ctx = err.get("ctx")
|
||||
if isinstance(ctx, dict):
|
||||
err["ctx"] = {
|
||||
k: v for k, v in ctx.items() if isinstance(v, (str, int, float, bool))
|
||||
}
|
||||
elif err.get("loc") and err["loc"][-1] == "body":
|
||||
# Whole-body error: input is the raw payload — redact
|
||||
# whenever it mentions a PII key (dict with one, or any
|
||||
# container whose repr might embed one). The loc+msg still
|
||||
# carry the failure's meaning; only the echo is dropped.
|
||||
if not isinstance(err.get("input"), dict) or any(
|
||||
key in err["input"] for key in pii_fields
|
||||
):
|
||||
err["input"] = "[redacted]"
|
||||
err.pop("ctx", None)
|
||||
scrubbed.append(err)
|
||||
return JSONResponse(status_code=422, content={"detail": scrubbed})
|
||||
|
||||
|
||||
@@ -346,12 +346,59 @@ class MarketplaceStore(Protocol):
|
||||
— never a second application."""
|
||||
...
|
||||
|
||||
def get_application(self, application_id: str) -> Application | None:
|
||||
"""Point lookup by application id (D-100: the placements
|
||||
store's transition path reads the row through the shared
|
||||
metadata; this read serves routes + tests — the get_posting
|
||||
shape)."""
|
||||
...
|
||||
|
||||
def get_application_by_posting_learner(
|
||||
self, posting_id: str, learner_id: str
|
||||
) -> Application | None:
|
||||
"""v0.11 P5 (G-322, binding): the mine point-read over the
|
||||
(posting_id, learner_id) unique key — the status panel's
|
||||
application DISCOVERY on revisit; None = no application (the
|
||||
surface renders the apply CTA — never client-persisted
|
||||
application_ids)."""
|
||||
...
|
||||
|
||||
def list_applications_by_postings(
|
||||
self, posting_ids: list[str], status: str | None = None
|
||||
) -> list[Application]:
|
||||
"""v0.11 P2 (D-105/G-319): the employer pipeline's ONE batched
|
||||
read — every application on the given postings (the caller
|
||||
scopes to the session's OWN claimed-entity postings first;
|
||||
this read never enumerates beyond them), created_at DESC then
|
||||
id; optional status facet."""
|
||||
...
|
||||
|
||||
def list_applications(
|
||||
self, posting_id: str | None = None
|
||||
) -> list[Application]:
|
||||
"""Admin review list, newest first; filterable by posting."""
|
||||
...
|
||||
|
||||
def count_applications_by_posting(
|
||||
self, posting_ids: list[str]
|
||||
) -> dict[str, int]:
|
||||
"""v0.10 P3 (G-319/D-092): batched per-posting application
|
||||
COUNTS — ONE grouped read for the whole id set (never a
|
||||
per-posting N+1). Postings with zero applications are absent
|
||||
from the dict (the caller's .get(id, 0) carries the honest 0).
|
||||
Employer-facing surfaces compose this for applicant COUNTS
|
||||
ONLY — learner identifiers never ride any /v1/employers/* read."""
|
||||
...
|
||||
|
||||
def list_postings_by_employer(
|
||||
self, employer_id: str
|
||||
) -> list[Posting]:
|
||||
"""v0.10 P3 (Task 3-1-01): the employer's OWN postings — ALL
|
||||
lifecycle statuses (draft|published|closed — the owner sees the
|
||||
full lifecycle, unlike the public board's published-only page),
|
||||
posted_at DESC then id (the list_postings order)."""
|
||||
...
|
||||
|
||||
def set_posting_tags(self, posting_id: str, tags: PostingTags) -> Posting | None:
|
||||
"""P2 (D-052): persist derived tags — OVERWRITES the tags column
|
||||
(single source of truth; re-tag replaces, never appends)."""
|
||||
@@ -880,6 +927,54 @@ class SQLiteMarketplaceStore:
|
||||
session.refresh(row)
|
||||
return row.to_application(), True
|
||||
|
||||
def get_application(self, application_id: str) -> Application | None:
|
||||
"""Point lookup by application id (D-100, the get_posting shape)."""
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(ApplicationRow, application_id)
|
||||
if row is None:
|
||||
return None
|
||||
session.refresh(row)
|
||||
return row.to_application()
|
||||
|
||||
def get_application_by_posting_learner(
|
||||
self, posting_id: str, learner_id: str
|
||||
) -> Application | None:
|
||||
"""v0.11 P5 (G-322): the mine point-read — one row over the
|
||||
insert_application unique key (the get_posting point-read
|
||||
discipline)."""
|
||||
with Session(self._engine) as session:
|
||||
row = session.exec(
|
||||
select(ApplicationRow).where(
|
||||
ApplicationRow.posting_id == posting_id,
|
||||
ApplicationRow.learner_id == learner_id,
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
session.refresh(row)
|
||||
return row.to_application()
|
||||
|
||||
def list_applications_by_postings(
|
||||
self, posting_ids: list[str], status: str | None = None
|
||||
) -> list[Application]:
|
||||
"""v0.11 P2 (D-105/G-319): ONE batched read over the given
|
||||
postings — created_at DESC then id; optional status facet.
|
||||
Empty posting set → empty list (the honest no-postings case)."""
|
||||
if not posting_ids:
|
||||
return []
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(ApplicationRow).where(
|
||||
col(ApplicationRow.posting_id).in_(posting_ids)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(ApplicationRow.status == status)
|
||||
rows = session.exec(
|
||||
stmt.order_by(
|
||||
col(ApplicationRow.created_at).desc(), ApplicationRow.id
|
||||
)
|
||||
).all()
|
||||
return [r.to_application() for r in rows]
|
||||
|
||||
def list_applications(
|
||||
self, posting_id: str | None = None
|
||||
) -> list[Application]:
|
||||
@@ -892,6 +987,44 @@ class SQLiteMarketplaceStore:
|
||||
).all()
|
||||
return [r.to_application() for r in rows]
|
||||
|
||||
def count_applications_by_posting(
|
||||
self, posting_ids: list[str]
|
||||
) -> dict[str, int]:
|
||||
"""G-319: ONE grouped read — SELECT posting_id, COUNT(*) ...
|
||||
GROUP BY posting_id — for the whole id set. Zero-application
|
||||
postings absent (honest 0 via the caller's .get(id, 0))."""
|
||||
if not posting_ids:
|
||||
return {}
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(
|
||||
ApplicationRow.posting_id,
|
||||
func.count(),
|
||||
)
|
||||
.where(col(ApplicationRow.posting_id).in_(posting_ids))
|
||||
.group_by(ApplicationRow.posting_id)
|
||||
).all()
|
||||
return {posting_id: count for posting_id, count in rows}
|
||||
|
||||
def list_postings_by_employer(
|
||||
self, employer_id: str
|
||||
) -> list[Posting]:
|
||||
"""The owner's full-lifecycle read (Task 3-1-01): ALL statuses,
|
||||
posted_at DESC then id (the list_postings order — drafts with
|
||||
no posted_at sort last within the None-ordered tie band)."""
|
||||
with Session(self._engine) as session:
|
||||
rows = (
|
||||
session.exec(
|
||||
select(PostingRow)
|
||||
.where(PostingRow.employer_id == employer_id)
|
||||
.order_by(
|
||||
col(PostingRow.posted_at).desc(), PostingRow.id
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [r.to_posting() for r in rows]
|
||||
|
||||
# -- P4-A: enrichment (append-only, D-053) --------------------------------
|
||||
|
||||
def append_enrichment(
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Placement contracts: the D-099 hiring-state matrix, the D-101
|
||||
event vocabulary + GENESIS, and the placement wire shapes (REQ-11-001).
|
||||
|
||||
Boundary (D-031 family): `placements/` never imports `api/`; stdlib +
|
||||
pydantic + the marketplace WIRE contracts only. The store imports the
|
||||
marketplace ROW (the sanctioned D-100 cross-family seam — one
|
||||
transaction covers both tables on the shared DB file).
|
||||
|
||||
Id honesty (A-305, the family rule): every id in this module is an
|
||||
opaque handle minted UNIQUELY per creation call — never derived from
|
||||
user input or a deterministic hash of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
#: D-099 application status vocabulary (snake_case, the billing
|
||||
#: `past_due` precedent): the hiring lifecycle. `submitted` is the
|
||||
#: v0.6 status (zero migration); hired/rejected/withdrawn are TERMINAL.
|
||||
ApplicationStatus = Literal[
|
||||
"submitted",
|
||||
"in_review",
|
||||
"interview",
|
||||
"offer",
|
||||
"hired",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
]
|
||||
|
||||
#: Tuple mirror of the Literal (docs + tests import this).
|
||||
APPLICATION_STATUSES: tuple[ApplicationStatus, ...] = (
|
||||
"submitted",
|
||||
"in_review",
|
||||
"interview",
|
||||
"offer",
|
||||
"hired",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
)
|
||||
|
||||
#: Terminal statuses — no outgoing edges (D-099).
|
||||
TERMINAL_APPLICATION_STATUSES: tuple[ApplicationStatus, ...] = (
|
||||
"hired",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
)
|
||||
|
||||
#: D-099 transition matrix (the billing/store.py `_VALID_TRANSITIONS`
|
||||
#: shape): forward-only WITH skip-forward — from any non-terminal
|
||||
#: status, any LATER stage and any terminal are legal (a fast-track
|
||||
#: `submitted → hired` is a legal hire — CIRR counts the hire, not the
|
||||
#: ceremony); NO backward moves; terminals have empty tuples.
|
||||
TRANSITION_MATRIX: dict[str, tuple[str, ...]] = {
|
||||
"submitted": (
|
||||
"in_review",
|
||||
"interview",
|
||||
"offer",
|
||||
"hired",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
),
|
||||
"in_review": ("interview", "offer", "hired", "rejected", "withdrawn"),
|
||||
"interview": ("offer", "hired", "rejected", "withdrawn"),
|
||||
"offer": ("hired", "rejected", "withdrawn"),
|
||||
"hired": (),
|
||||
"rejected": (),
|
||||
"withdrawn": (),
|
||||
}
|
||||
|
||||
#: Placement lifecycle (D-102): created `active`; the ONLY transition
|
||||
#: is the operator correction `active → revoked`. Revoked rows stay in
|
||||
#: history (the partial unique index never touches them — re-hire
|
||||
#: creates a fresh row, R-80).
|
||||
PlacementStatus = Literal["active", "revoked"]
|
||||
PLACEMENT_STATUSES: tuple[PlacementStatus, ...] = ("active", "revoked")
|
||||
|
||||
#: D-101 actor vocabulary (the billing `_BILLING_EVENT_KINDS` shape).
|
||||
ACTOR_TYPES: tuple[str, ...] = ("employer", "learner", "operator", "system")
|
||||
|
||||
#: D-101 event-name vocabulary: application-stage events (one per
|
||||
#: transition — NO backfill: pre-v0.11 `submitted` rows carry only
|
||||
#: their created_at, never a fabricated event, A-304) + the placement
|
||||
#: lane (created on hired, revoked on the D-102 correction, rehired
|
||||
#: on the R-80 operator re-hire).
|
||||
EVENT_APPLICATION_TRANSITION: dict[str, str] = {
|
||||
"in_review": "application.in_review",
|
||||
"interview": "application.interview",
|
||||
"offer": "application.offer",
|
||||
"hired": "application.hired",
|
||||
"rejected": "application.rejected",
|
||||
"withdrawn": "application.withdrawn",
|
||||
}
|
||||
EVENT_PLACEMENT_CREATED = "placement.created"
|
||||
EVENT_PLACEMENT_REVOKED = "placement.revoked"
|
||||
EVENT_PLACEMENT_REHIRED = "placement.rehired"
|
||||
EVENT_NAMES: tuple[str, ...] = (
|
||||
*EVENT_APPLICATION_TRANSITION.values(),
|
||||
EVENT_PLACEMENT_CREATED,
|
||||
EVENT_PLACEMENT_REVOKED,
|
||||
EVENT_PLACEMENT_REHIRED,
|
||||
)
|
||||
|
||||
#: D-101: the chain's genesis — the first event's prev_hash. A module
|
||||
#: constant, test-pinned (never a row — the walk starts here).
|
||||
GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
class InvalidTransition(ValueError):
|
||||
"""A matrix-illegal application status move (D-099) or placement
|
||||
status move (D-102) — the route's honest 409."""
|
||||
|
||||
|
||||
def is_terminal(status: str) -> bool:
|
||||
"""True when the application status has no outgoing edges."""
|
||||
return status in TERMINAL_APPLICATION_STATUSES
|
||||
|
||||
|
||||
def validate_transition(from_status: str, to_status: str) -> None:
|
||||
"""D-099 matrix validation — raises InvalidTransition on any
|
||||
illegal move (backward, terminal-source, unknown status)."""
|
||||
if from_status not in TRANSITION_MATRIX:
|
||||
raise InvalidTransition(f"unknown application status {from_status!r}")
|
||||
if to_status not in APPLICATION_STATUSES:
|
||||
raise InvalidTransition(f"unknown application status {to_status!r}")
|
||||
if to_status not in TRANSITION_MATRIX[from_status]:
|
||||
legal = TRANSITION_MATRIX[from_status] or "(terminal)"
|
||||
raise InvalidTransition(
|
||||
f"illegal transition {from_status!r} → {to_status!r} "
|
||||
f"(legal: {legal})"
|
||||
)
|
||||
|
||||
|
||||
class HireTransitionRequest(BaseModel):
|
||||
"""The hire body (D-103/G-324): role_title is employer-attested
|
||||
free text — never silently the posting title — with the house
|
||||
Field bounds (empty-string → 422); start_date is the employment
|
||||
start DATE (first day of work), never a datetime: timezone-free by
|
||||
construction, future + past allowed (D-103)."""
|
||||
|
||||
role_title: str = Field(min_length=1, max_length=200)
|
||||
start_date: date
|
||||
|
||||
|
||||
class PlacementRead(BaseModel):
|
||||
"""A placement record over the wire (the PlacementRow read shape).
|
||||
|
||||
employer/learner/posting are ID REFERENCES (G-302) — resolved from
|
||||
source rows at read/export, never trusted denormalized copies."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
learner_id: str = Field(min_length=1)
|
||||
employer_id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
application_id: str = Field(min_length=1)
|
||||
role_title: str = Field(min_length=1)
|
||||
start_date: date
|
||||
status: PlacementStatus
|
||||
created_at: datetime
|
||||
decided_at: datetime
|
||||
|
||||
|
||||
class PlacementEventRead(BaseModel):
|
||||
"""One ledger event over the wire (the PlacementEventRow read
|
||||
shape) — application-stage events carry application_id with
|
||||
placement_id None; the placement lane carries placement_id set."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: int
|
||||
application_id: str | None = None
|
||||
placement_id: str | None = None
|
||||
event_type: str = Field(min_length=1)
|
||||
actor_type: str = Field(min_length=1)
|
||||
actor_ref: str = Field(min_length=1)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
prev_hash: str = Field(min_length=1)
|
||||
hash: str = Field(min_length=1)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmployerApplicationSummary(BaseModel):
|
||||
"""An employer-facing application row (D-105/G-319): the pipeline
|
||||
list + per-application reads. learner_id IS visible here — the
|
||||
G-319 supersession scopes it to applications on the session's OWN
|
||||
claimed-entity postings (the cross-entity arm 403s carry no data;
|
||||
the own-postings LIST stays counts-only — the pin split)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
learner_id: str = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class EmployerApplicationsResponse(BaseModel):
|
||||
"""GET /v1/employers/applications response: own-pipeline
|
||||
applications (every status) + per-status facet counts (every
|
||||
status key ALWAYS present — a zero-count status carries its
|
||||
honest 0)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
applications: list[EmployerApplicationSummary] = Field(default_factory=list)
|
||||
facets: dict[str, int] = Field(default_factory=dict)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ApplicationTransitionResponse(BaseModel):
|
||||
"""The transition result (per-target routes): the application's
|
||||
new status + the placement record when the transition created
|
||||
one (hire)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
application_id: str = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
placement: PlacementRead | None = None
|
||||
|
||||
|
||||
class AdminTransitionRequest(BaseModel):
|
||||
"""POST /v1/placements/admin/applications/{id}/transition body
|
||||
(D-106/G-327): `to` is a CLOSED Literal of the seven D-099
|
||||
statuses — unknown values die at pydantic (422), matrix-illegal
|
||||
known values reach the store (409 InvalidTransition); the route
|
||||
never sees a status outside the vocabulary. role_title/start_date
|
||||
are required iff to=hired (enforced at the route, the honest 422
|
||||
with required-fields detail)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
to: ApplicationStatus
|
||||
role_title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
start_date: date | None = None
|
||||
|
||||
|
||||
#: D-107: the guiding outcome's north star (PROJECT.md line 7 — the
|
||||
#: noun is LEARNERS, not placements).
|
||||
GUIDING_GOAL = 1000
|
||||
|
||||
#: D-108/G-321/G-325 (R-67/R-68): the export's methodology block —
|
||||
#: written from in-tree facts, stating its own three pilot gaps
|
||||
#: explicitly (an audit methodology that omits its own verification
|
||||
#: limits is A-304-class dishonesty) AND the chain's proof scope
|
||||
#: (G-321: what it proves, what it does not — wholesale DB replacement
|
||||
#: is undetectable by the walk alone; export-to-export continuity is
|
||||
#: the auditor's anchor — plus the event↔row cross-consistency check).
|
||||
#: CIRR kept as naming, not citation — the text stands on its own
|
||||
#: definitions (R-67).
|
||||
EXPORT_METHODOLOGY: dict[str, Any] = {
|
||||
"placement_definition": (
|
||||
"A placement is a `placement` row created by the hired transition "
|
||||
"of a marketplace application, with an employer-attested role_title "
|
||||
"and start_date (the employment start DATE, D-103), status=active "
|
||||
"at export time. One ACTIVE placement per application is "
|
||||
"structurally enforced (the partial unique index); revoked "
|
||||
"placements stay in history and are excluded from every count."
|
||||
),
|
||||
"counting_rules": (
|
||||
"The guiding metric is DISTINCT learners with at least one active "
|
||||
"placement, all-time cumulative toward the 1,000-learner guiding "
|
||||
"goal (a learner placed twice counts ONCE — the outcome's noun is "
|
||||
"learners, not placements). Revoked placements are excluded from "
|
||||
"every count. Cohort month is the calendar month of start_date "
|
||||
"(YYYY-MM) — the employment-start month, not a graduation cohort "
|
||||
"(no graduation events exist in a marketplace; this divergence "
|
||||
"from CIRR cohort convention is deliberate and documented)."
|
||||
),
|
||||
"verification": (
|
||||
"WHAT THE CHAIN PROVES: the global sha256 ledger (events ordered "
|
||||
"by id, each hash covering the canonical JSON of its row + the "
|
||||
"prior event's hash, from the GENESIS constant to the head) "
|
||||
"detects in-place mutation of any event row and makes a chain "
|
||||
"fork structurally impossible (UNIQUE(prev_hash)). WHAT IT DOES "
|
||||
"NOT PROVE: a wholesale replacement of the database (delete every "
|
||||
"event, re-append a fresh plausible chain from GENESIS) walks "
|
||||
"green — the walk alone cannot detect it. THE AUDITOR'S ANCHOR "
|
||||
"is export-to-export continuity: a prior export's ledger.events "
|
||||
"must appear as an exact PREFIX of a later export's events, and "
|
||||
"the prior export's head_hash must equal the hash at that prefix "
|
||||
"position in the later export. EVENT↔ROW CROSS-CONSISTENCY: "
|
||||
"every active placement must have a placement.created event; "
|
||||
"every revoked placement a placement.revoked event; summary."
|
||||
"revoked_count must equal the number of placement.revoked "
|
||||
"events. EVIDENCE CLASS: verification is employer-principal "
|
||||
"attestation (an authenticated hire by a claim-verified, "
|
||||
"operator-approved employer principal) plus operator oversight — "
|
||||
"recorded decisions, not third-party employment verification."
|
||||
),
|
||||
"scope": (
|
||||
"PILOT LIMITS, stated explicitly (R-67): (a) no minimum-employed-"
|
||||
"duration check — a placement here is a RECORDED HIRE, not a "
|
||||
"90-day sustained role; (b) no salary or compensation tracking "
|
||||
"(out of scope by decision D-098); (c) verification is employer-"
|
||||
"attested with operator oversight, NOT third-party employment "
|
||||
"verification — no external auditor integration exists at "
|
||||
"pilot; this export IS the third-party-auditor deliverable. "
|
||||
"Coverage: every placement in the database, unbounded by date. "
|
||||
"CIRR-style describes this export's format; the definitions "
|
||||
"above are the operative ones."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PlacementOverview(BaseModel):
|
||||
"""GET /v1/placements/admin/overview response (D-107): counts
|
||||
computed from rows at query time — NEVER denormalized counters
|
||||
(G-302/G-306). The guiding metric is DISTINCT learners with ≥1
|
||||
active placement (a learner placed twice counts ONCE); revoked
|
||||
excluded from every count; cohort month = the calendar month of
|
||||
start_date (YYYY-MM, employment-start month — not hired month)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
total_active_placements: int = 0
|
||||
distinct_learners_placed: int = 0
|
||||
guiding_goal: int = GUIDING_GOAL
|
||||
by_employer: list[dict[str, Any]] = Field(default_factory=list)
|
||||
by_cohort_month: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployerPlacementsResponse(BaseModel):
|
||||
"""GET /v1/employers/placements response (D-109 surface 2's data
|
||||
source): the claimed entity's OWN placements (active + revoked —
|
||||
the employer sees their full history), created_at then id."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
placements: list[PlacementRead] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class LearnerApplicationSummary(BaseModel):
|
||||
"""The application row on the learner status read (D-104,
|
||||
REQ-11-005): the mine-visible fields only — ids + status + the
|
||||
apply timestamp (nullable per the v0.6 row shape — honest
|
||||
absence); references resolve at render (G-302), never
|
||||
denormalized copies."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
learner_id: str = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class LearnerApplicationStatusResponse(BaseModel):
|
||||
"""GET /v1/marketplace/applications/{application_id}?learner_id=
|
||||
response (REQ-11-005, D-104): current status + the FULL id-ordered
|
||||
timestamped event history for THIS application (a scoped query —
|
||||
never another learner's events) + the placement record when hired
|
||||
(the honest null otherwise — never a fabricated placement, A-304)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
application: LearnerApplicationSummary
|
||||
events: list[PlacementEventRead] = Field(default_factory=list)
|
||||
placement: PlacementRead | None = None
|
||||
|
||||
|
||||
class MineApplicationResponse(BaseModel):
|
||||
"""GET /v1/marketplace/applications/mine?posting_id=&learner_id=
|
||||
response (G-322, binding): {application_id, status} — the status
|
||||
panel's application DISCOVERY read on revisit; 404 = no application
|
||||
on this posting (the surface renders the apply CTA — the panel
|
||||
NEVER depends on client-persisted application_ids)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
application_id: str = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
@@ -0,0 +1,912 @@
|
||||
"""Placement persistence — the 9th D-027 store (REQ-11-001, D-112).
|
||||
|
||||
TWO tables on the shared DB file (R-72): `placement` (immutable at
|
||||
creation except status — D-102) + `placement_event` (the append-only
|
||||
GLOBAL hash chain — D-101). Mechanics mirror employers/store.py
|
||||
exactly (pragmas, _validate/_normalize, Protocol + SQLite pair,
|
||||
surfaced IntegrityError, _constraint_error helper).
|
||||
|
||||
NO DELETE PATH (R-70, binding): this module contains NO delete method
|
||||
and NO DELETE SQL — deleting the top ledger row would make SQLite's
|
||||
INTEGER PRIMARY KEY reuse max+1 and break the chain. Pinned by test.
|
||||
|
||||
STATUS-COLUMN OWNERSHIP (R-78, binding): the marketplace store writes
|
||||
`application.status` EXACTLY ONCE at creation (insert_application);
|
||||
THIS store is the ONLY post-creation writer — via the ONE
|
||||
`transition_application` method, never a second append path (D-100:
|
||||
the status write + the ledger event + the placement-on-hired land in
|
||||
ONE transaction; a two-commit alternative leaves a crash window that
|
||||
voids the tamper-evidence this milestone sells).
|
||||
|
||||
Canonical serialization (D-101/R-69/R-79, binding): the hash covers
|
||||
the canonical JSON of {id, application_id, placement_id, event_type,
|
||||
actor_type, actor_ref, payload, created_at} + prev_hash — sorted keys,
|
||||
compact separators, created_at ALWAYS `_as_utc(dt).isoformat()` (ONE
|
||||
pinned normalization: naive-in-SQLite vs aware-on-read must never
|
||||
drift), payload values str/int/bool/None only (floats REJECTED —
|
||||
R-69).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import Index, event, func, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.types import JSON, String
|
||||
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
|
||||
|
||||
from ..config import Settings
|
||||
from ..marketplace.store import ApplicationRow
|
||||
from .base import (
|
||||
ACTOR_TYPES,
|
||||
EVENT_APPLICATION_TRANSITION,
|
||||
EVENT_PLACEMENT_CREATED,
|
||||
EVENT_PLACEMENT_REHIRED,
|
||||
EVENT_PLACEMENT_REVOKED,
|
||||
EXPORT_METHODOLOGY,
|
||||
GENESIS_HASH,
|
||||
InvalidTransition,
|
||||
validate_transition,
|
||||
)
|
||||
|
||||
_PLACEMENT_ID_PREFIX = "plc-"
|
||||
|
||||
|
||||
def _as_utc(ts: datetime) -> datetime:
|
||||
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027
|
||||
pattern). Aware NON-UTC inputs CONVERT to the true instant (the
|
||||
security review's P0-1: re-labeling preserves the offset — an
|
||||
append-time canonical form of 12:00-04:00 would re-read as
|
||||
12:00+00:00 and permanently break the chain, with no delete path
|
||||
to recover)."""
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=UTC)
|
||||
return ts.astimezone(UTC)
|
||||
|
||||
|
||||
def _constraint_error(message: str, params: dict[str, Any]) -> IntegrityError:
|
||||
"""Hand-raised IntegrityError (the guard arms) — the caller's
|
||||
honest 409, never a silent overwrite."""
|
||||
return IntegrityError(message, params, ValueError(message))
|
||||
|
||||
|
||||
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
|
||||
"""Per-connection pragmas — mirrors the other D-027 stores."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
def _check_payload(value: Any) -> None:
|
||||
"""R-69: payload values are str/int/bool/None ONLY — floats (and
|
||||
any nested container) are rejected at the store boundary: float
|
||||
representation is a canonical-serialization drift the chain must
|
||||
never depend on."""
|
||||
if isinstance(value, bool) or value is None:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
return
|
||||
if isinstance(value, int):
|
||||
return
|
||||
raise ValueError(
|
||||
f"payload values must be str/int/bool/None, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
class PlacementRow(SQLModel, table=True):
|
||||
"""A placement record (D-102): created by the hired transition,
|
||||
IMMUTABLE except `status` (exactly one transition active → revoked,
|
||||
operator-only by route). role_title/start_date/decided_at and the
|
||||
four ID references are set once and NEVER rewritten — a silent
|
||||
rewrite is exactly the tamper the ledger exists to detect.
|
||||
|
||||
G-316 shape: the partial UNIQUE index uq_active_placement_per_
|
||||
application makes one-ACTIVE-placement-per-application a
|
||||
STRUCTURAL guard — revoked rows stay in history and never match
|
||||
the partial WHERE, so a re-hire after a mistaken revoke creates a
|
||||
fresh row (R-80) and an operator fat-finger never permanently
|
||||
erases a learner from the ≥1,000 count.
|
||||
"""
|
||||
|
||||
__tablename__ = "placement"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_active_placement_per_application",
|
||||
"application_id",
|
||||
unique=True,
|
||||
sqlite_where=text("status='active'"),
|
||||
),
|
||||
)
|
||||
|
||||
id: str = Field(primary_key=True, sa_type=String)
|
||||
learner_id: str = Field(index=True, sa_type=String)
|
||||
employer_id: str = Field(sa_type=String)
|
||||
#: G-302: ID references resolved from source rows at read/export.
|
||||
posting_id: str = Field(sa_type=String)
|
||||
application_id: str = Field(index=True, sa_type=String)
|
||||
role_title: str = Field(sa_type=String)
|
||||
#: D-103: ISO YYYY-MM-DD — a calendar date, no datetime coercion.
|
||||
start_date: str = Field(sa_type=String)
|
||||
status: str = Field(default="active", sa_type=String)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
#: D-102: the hired transition's store-stamped created_at (G-326:
|
||||
#: the re-hire arm stamps the RE-HIRE event's created_at).
|
||||
decided_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id:
|
||||
raise ValueError("id must be non-empty")
|
||||
for field_name in (
|
||||
"learner_id",
|
||||
"employer_id",
|
||||
"posting_id",
|
||||
"application_id",
|
||||
"role_title",
|
||||
"start_date",
|
||||
):
|
||||
if not getattr(self, field_name):
|
||||
raise ValueError(f"{field_name} must be non-empty")
|
||||
if self.status not in ("active", "revoked"):
|
||||
raise ValueError(f"invalid placement status {self.status!r}")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.created_at = _as_utc(self.created_at) if self.created_at else None
|
||||
self.decided_at = _as_utc(self.decided_at) if self.decided_at else None
|
||||
|
||||
|
||||
class PlacementEventRow(SQLModel, table=True):
|
||||
"""One append-only ledger event (D-101): ONE GLOBAL chain ordered
|
||||
by autoincrement id. Each row stores prev_hash (the prior event's
|
||||
hash; GENESIS_HASH for the first) + its own hash = sha256 over the
|
||||
canonical JSON + prev_hash. UNIQUE(prev_hash) makes a chain fork
|
||||
structurally impossible — two events claiming the same
|
||||
predecessor surface IntegrityError (the G-316 backstop class).
|
||||
|
||||
Application-stage events carry application_id (placement_id
|
||||
None); the placement lane (created/revoked/rehired) carries
|
||||
placement_id set from hired onward.
|
||||
"""
|
||||
|
||||
__tablename__ = "placement_event"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
application_id: str | None = Field(default=None, index=True, sa_type=String)
|
||||
placement_id: str | None = Field(default=None, sa_type=String)
|
||||
event_type: str = Field(sa_type=String)
|
||||
actor_type: str = Field(sa_type=String)
|
||||
actor_ref: str = Field(sa_type=String)
|
||||
payload: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
||||
prev_hash: str = Field(unique=True, sa_type=String)
|
||||
hash: str = Field(default="", sa_type=String)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if self.id is not None and self.id < 1:
|
||||
raise ValueError("id must be a positive int when set")
|
||||
if not self.event_type:
|
||||
raise ValueError("event_type must be non-empty")
|
||||
if self.actor_type not in ACTOR_TYPES:
|
||||
raise ValueError(f"unknown actor_type {self.actor_type!r}")
|
||||
if not self.actor_ref:
|
||||
raise ValueError("actor_ref must be non-empty")
|
||||
if not self.prev_hash:
|
||||
raise ValueError("prev_hash must be non-empty")
|
||||
for value in self.payload.values():
|
||||
_check_payload(value)
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.created_at = _as_utc(self.created_at) if self.created_at else None
|
||||
|
||||
def canonical_json(self) -> str:
|
||||
"""D-101/R-79: the canonical serialization — sorted keys,
|
||||
compact separators, created_at ALWAYS `_as_utc(dt).isoformat()`
|
||||
(ONE pinned normalization; the hashing and reading paths share
|
||||
this function so they can never drift)."""
|
||||
return json.dumps(
|
||||
{
|
||||
"id": self.id,
|
||||
"application_id": self.application_id,
|
||||
"placement_id": self.placement_id,
|
||||
"event_type": self.event_type,
|
||||
"actor_type": self.actor_type,
|
||||
"actor_ref": self.actor_ref,
|
||||
"payload": self.payload,
|
||||
"created_at": (
|
||||
_as_utc(self.created_at).isoformat()
|
||||
if self.created_at is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def _compute_hash(canonical_json: str, prev_hash: str) -> str:
|
||||
"""sha256 over the canonical JSON + prev_hash (D-101 — the D-087
|
||||
stdlib sha256 precedent; hexdigest)."""
|
||||
return hashlib.sha256((canonical_json + prev_hash).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class PlacementStore(Protocol):
|
||||
"""Persistence contract for placements (D-101/D-102/D-112)."""
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""Ensure both tables exist (idempotent — shared DB file)."""
|
||||
...
|
||||
|
||||
def transition_application(
|
||||
self,
|
||||
application_id: str,
|
||||
to_status: str,
|
||||
actor_type: str,
|
||||
actor_ref: str,
|
||||
role_title: str | None = None,
|
||||
start_date: date | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[ApplicationRow, PlacementRow | None]:
|
||||
"""D-100: the ONE transactional transition — update the
|
||||
marketplace application row + append the ledger event + create
|
||||
the PlacementRow on hired, ALL IN ONE COMMIT. One append per
|
||||
transaction per lane (transition/revoke/rehire each append
|
||||
exactly once); R-70: the appends race nothing — the chain's
|
||||
prev_hash read-then-write is single-threaded-safe through
|
||||
SQLite's writer serialization + the UNIQUE backstop."""
|
||||
...
|
||||
|
||||
def verify_chain(self) -> tuple[bool, str]:
|
||||
"""Recompute the walk from GENESIS_HASH → head; (ok, head_hash)."""
|
||||
...
|
||||
|
||||
def get_placement(self, placement_id: str) -> PlacementRow | None:
|
||||
...
|
||||
|
||||
def get_placement_by_application(
|
||||
self, application_id: str
|
||||
) -> PlacementRow | None:
|
||||
"""v0.11 P5 (REQ-11-005): the learner status read's placement
|
||||
arm — the ACTIVE placement on this application (the G-316
|
||||
partial-unique shape: at most one; revoked-only history →
|
||||
None, the honest null — the events carry the correction)."""
|
||||
...
|
||||
|
||||
def list_placements(self, status: str | None = None) -> list[PlacementRow]:
|
||||
...
|
||||
|
||||
def get_application_events(self, application_id: str) -> list[PlacementEventRow]:
|
||||
...
|
||||
|
||||
def list_events(self) -> list[PlacementEventRow]:
|
||||
...
|
||||
|
||||
def revoke_placement(
|
||||
self, placement_id: str, actor_ref: str, now: datetime | None = None
|
||||
) -> PlacementRow | None:
|
||||
"""D-102: the one status-only mutation active → revoked + the
|
||||
placement.revoked event, one transaction (operator-only by
|
||||
route)."""
|
||||
...
|
||||
|
||||
def rehire_placement(
|
||||
self,
|
||||
application_id: str,
|
||||
role_title: str,
|
||||
start_date: date,
|
||||
actor_ref: str,
|
||||
now: datetime | None = None,
|
||||
) -> PlacementRow:
|
||||
"""R-80/G-326: a FRESH PlacementRow + placement.rehired event
|
||||
WITHOUT touching the application's terminal status — the
|
||||
correction lane. decided_at = the re-hire event's created_at.
|
||||
An existing ACTIVE placement on the application surfaces its
|
||||
IntegrityError (the caller's honest 409)."""
|
||||
...
|
||||
|
||||
def list_placements_by_employer(
|
||||
self, employer_id: str
|
||||
) -> list[PlacementRow]:
|
||||
"""v0.11 P3 (D-109): the claimed entity's OWN placements
|
||||
(active + revoked — the employer sees their full history),
|
||||
created_at then id (the deterministic order)."""
|
||||
...
|
||||
|
||||
def build_export(self, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""v0.11 P4 (D-108/G-321/G-325): the self-contained audit
|
||||
document — {schema_version, generated_at, summary, cohort,
|
||||
evidence_bundles, methodology, ledger} — resolved from SOURCE
|
||||
ROWS at export time (G-302 snapshot-at-export). The summary
|
||||
route renders FROM this dict (one truth source)."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
...
|
||||
|
||||
|
||||
class SQLitePlacementStore:
|
||||
"""SQLite implementation of PlacementStore (D-027/D-112)."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self._db_path: Path = db_path if db_path is not None else Settings().db_path
|
||||
self._engine = create_engine(
|
||||
f"sqlite:///{self._db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
event.listen(self._engine, "connect", _sqlite_connect)
|
||||
self.create_schema()
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""Ensure both tables exist (idempotent — shared DB file)."""
|
||||
SQLModel.metadata.create_all(self._engine)
|
||||
|
||||
# -- chain core (D-101/R-70) -------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _chain_head(session: Session) -> tuple[int | None, str]:
|
||||
"""(last event id, its hash) or (None, GENESIS_HASH) — the
|
||||
next append's prev_hash source."""
|
||||
row = session.exec(
|
||||
select(PlacementEventRow).order_by(col(PlacementEventRow.id).desc())
|
||||
).first()
|
||||
if row is None or row.hash == "":
|
||||
return None, GENESIS_HASH
|
||||
return row.id, row.hash
|
||||
|
||||
@staticmethod
|
||||
def _append_event(
|
||||
session: Session,
|
||||
*,
|
||||
application_id: str | None,
|
||||
placement_id: str | None,
|
||||
event_type: str,
|
||||
actor_type: str,
|
||||
actor_ref: str,
|
||||
payload: dict[str, Any] | None,
|
||||
prev_hash: str,
|
||||
created_at: datetime,
|
||||
) -> PlacementEventRow:
|
||||
"""R-70: flush-then-hash-then-commit — the row's own
|
||||
autoincrement id participates in its own hash, so the append
|
||||
flushes (id assigned), computes the hash, sets the column, and
|
||||
flushes again. The CALLER owns the transaction (D-100: the
|
||||
status write + the events + the placement land in one commit);
|
||||
this helper never commits."""
|
||||
row = PlacementEventRow(
|
||||
application_id=application_id,
|
||||
placement_id=placement_id,
|
||||
event_type=event_type,
|
||||
actor_type=actor_type,
|
||||
actor_ref=actor_ref,
|
||||
payload=dict(payload or {}),
|
||||
prev_hash=prev_hash,
|
||||
hash="",
|
||||
created_at=created_at,
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
session.add(row)
|
||||
# flush #1: the autoincrement id is assigned
|
||||
session.flush()
|
||||
assert row.id is not None # flush assigns it
|
||||
# the hash covers the canonical JSON + prev_hash
|
||||
row.hash = _compute_hash(row.canonical_json(), row.prev_hash)
|
||||
# flush #2: the hash column lands
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
def verify_chain(self) -> tuple[bool, str]:
|
||||
"""Recompute the walk GENESIS_HASH → head (R-70: O(n) sha256
|
||||
over tiny rows — milliseconds at pilot scale)."""
|
||||
with Session(self._engine) as session:
|
||||
prev = GENESIS_HASH
|
||||
last_hash = GENESIS_HASH
|
||||
rows = session.exec(select(PlacementEventRow).order_by(col(PlacementEventRow.id))).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
expected = _compute_hash(row.canonical_json(), prev)
|
||||
if row.prev_hash != prev or row.hash != expected:
|
||||
return False, last_hash
|
||||
prev = row.hash
|
||||
last_hash = row.hash
|
||||
return True, last_hash
|
||||
|
||||
# -- the ONE transition (D-100) ------------------------------------------
|
||||
|
||||
def transition_application(
|
||||
self,
|
||||
application_id: str,
|
||||
to_status: str,
|
||||
actor_type: str,
|
||||
actor_ref: str,
|
||||
role_title: str | None = None,
|
||||
start_date: date | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[ApplicationRow, PlacementRow | None]:
|
||||
"""D-100: ONE Session on the placements engine covering BOTH
|
||||
tables + the marketplace application row (the sanctioned
|
||||
cross-family write — R-72: global SQLModel metadata means the
|
||||
placements engine sees ApplicationRow; both stores share the
|
||||
one SQLite file, so one commit covers everything atomically,
|
||||
the decide_claim all-or-nothing precedent).
|
||||
|
||||
R-78: this is the ONLY post-creation writer of
|
||||
application.status — never a second caller.
|
||||
"""
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
with Session(self._engine) as session:
|
||||
application = session.get(ApplicationRow, application_id)
|
||||
if application is None:
|
||||
raise InvalidTransition(f"unknown application {application_id!r}")
|
||||
validate_transition(application.status, to_status)
|
||||
if to_status == "hired":
|
||||
if not role_title:
|
||||
raise ValueError("role_title is required on hire (D-103)")
|
||||
if start_date is None:
|
||||
raise ValueError("start_date is required on hire (D-103)")
|
||||
|
||||
# the status write (the only post-creation writer — R-78);
|
||||
# the pre-transition status is captured BEFORE the mutation
|
||||
from_status = application.status
|
||||
application.status = to_status
|
||||
application._normalize()
|
||||
|
||||
prev_id, prev_hash = self._chain_head(session)
|
||||
payload: dict[str, Any] = {"from_status": from_status, "to_status": to_status}
|
||||
self._append_event(
|
||||
session,
|
||||
application_id=application_id,
|
||||
placement_id=None,
|
||||
event_type=EVENT_APPLICATION_TRANSITION[to_status],
|
||||
actor_type=actor_type,
|
||||
actor_ref=actor_ref,
|
||||
payload=payload,
|
||||
prev_hash=prev_hash,
|
||||
created_at=moment,
|
||||
)
|
||||
|
||||
placement: PlacementRow | None = None
|
||||
if to_status == "hired":
|
||||
assert role_title is not None and start_date is not None
|
||||
placement = PlacementRow(
|
||||
id=f"{_PLACEMENT_ID_PREFIX}{uuid.uuid4().hex[:16]}",
|
||||
learner_id=application.learner_id,
|
||||
employer_id=self._posting_employer_id(session, application.posting_id),
|
||||
posting_id=application.posting_id,
|
||||
application_id=application.id,
|
||||
role_title=role_title,
|
||||
start_date=start_date.isoformat(),
|
||||
status="active",
|
||||
created_at=moment,
|
||||
decided_at=moment,
|
||||
)
|
||||
placement._validate()
|
||||
placement._normalize()
|
||||
session.add(placement)
|
||||
# the placement row must exist before its event references it
|
||||
session.flush()
|
||||
_, prev_hash_2 = self._chain_head(session)
|
||||
self._append_event(
|
||||
session,
|
||||
application_id=application_id,
|
||||
placement_id=placement.id,
|
||||
event_type=EVENT_PLACEMENT_CREATED,
|
||||
actor_type=actor_type,
|
||||
actor_ref=actor_ref,
|
||||
payload={
|
||||
"role_title": role_title,
|
||||
"start_date": start_date.isoformat(),
|
||||
},
|
||||
prev_hash=prev_hash_2,
|
||||
created_at=moment,
|
||||
)
|
||||
|
||||
session.commit()
|
||||
session.refresh(application)
|
||||
application._normalize()
|
||||
if placement is not None:
|
||||
session.refresh(placement)
|
||||
placement._normalize()
|
||||
return application, placement
|
||||
|
||||
@staticmethod
|
||||
def _posting_employer_id(session: Session, posting_id: str) -> str:
|
||||
"""G-302: resolve the posting's employer_id from the SOURCE row
|
||||
at placement-creation time — never trusted from the caller."""
|
||||
from ..marketplace.store import PostingRow
|
||||
|
||||
posting = session.get(PostingRow, posting_id)
|
||||
if posting is None:
|
||||
raise _constraint_error(
|
||||
f"guard: application references unknown posting "
|
||||
f"{posting_id!r} — refusing to create placement",
|
||||
{"posting_id": posting_id},
|
||||
)
|
||||
return posting.employer_id
|
||||
|
||||
# -- reads (consume-free, G-302 ID references) ---------------------------
|
||||
|
||||
def get_placement(self, placement_id: str) -> PlacementRow | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(PlacementRow, placement_id)
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def get_placement_by_application(
|
||||
self, application_id: str
|
||||
) -> PlacementRow | None:
|
||||
"""v0.11 P5 (REQ-11-005): the ACTIVE placement on this
|
||||
application — the G-316 partial unique index makes
|
||||
at-most-one structural; revoked-only history → None (the
|
||||
honest null — never a fabricated placement, A-304)."""
|
||||
with Session(self._engine) as session:
|
||||
row = session.exec(
|
||||
select(PlacementRow).where(
|
||||
PlacementRow.application_id == application_id,
|
||||
PlacementRow.status == "active",
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def list_placements(self, status: str | None = None) -> list[PlacementRow]:
|
||||
"""created_at then id (the list_postings ordering discipline,
|
||||
R-73 — deterministic export ordering)."""
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(PlacementRow)
|
||||
if status is not None:
|
||||
stmt = stmt.where(PlacementRow.status == status)
|
||||
rows = session.exec(
|
||||
stmt.order_by(col(PlacementRow.created_at), PlacementRow.id)
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def get_application_events(self, application_id: str) -> list[PlacementEventRow]:
|
||||
"""id-ordered (the chain order) — scoped to ONE application."""
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(PlacementEventRow)
|
||||
.where(PlacementEventRow.application_id == application_id)
|
||||
.order_by(col(PlacementEventRow.id))
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def list_events(self) -> list[PlacementEventRow]:
|
||||
"""The whole ledger, id-ordered (the export embeds every event
|
||||
so the auditor's walk is offline — R-70)."""
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(PlacementEventRow).order_by(col(PlacementEventRow.id))
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def count_events(self, event_type: str | None = None) -> int:
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(func.count()).select_from(PlacementEventRow)
|
||||
if event_type is not None:
|
||||
stmt = stmt.where(PlacementEventRow.event_type == event_type)
|
||||
return session.exec(stmt).one()
|
||||
|
||||
# -- the D-102 correction lane ------------------------------------------
|
||||
|
||||
def revoke_placement(
|
||||
self, placement_id: str, actor_ref: str, now: datetime | None = None
|
||||
) -> PlacementRow | None:
|
||||
"""D-102: the one status-only mutation active → revoked + the
|
||||
placement.revoked event, one transaction (operator-only by
|
||||
route). None = unknown placement (route 404s)."""
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(PlacementRow, placement_id)
|
||||
if row is None:
|
||||
return None
|
||||
if row.status != "active":
|
||||
raise InvalidTransition(
|
||||
f"placement {placement_id!r} is {row.status!r} — "
|
||||
"only an active placement can be revoked"
|
||||
)
|
||||
row.status = "revoked"
|
||||
row._validate()
|
||||
row._normalize()
|
||||
_, prev_hash = self._chain_head(session)
|
||||
self._append_event(
|
||||
session,
|
||||
application_id=row.application_id,
|
||||
placement_id=row.id,
|
||||
event_type=EVENT_PLACEMENT_REVOKED,
|
||||
actor_type="operator",
|
||||
actor_ref=actor_ref,
|
||||
payload={"placement_id": placement_id},
|
||||
prev_hash=prev_hash,
|
||||
created_at=moment,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def rehire_placement(
|
||||
self,
|
||||
application_id: str,
|
||||
role_title: str,
|
||||
start_date: date,
|
||||
actor_ref: str,
|
||||
now: datetime | None = None,
|
||||
) -> PlacementRow:
|
||||
"""R-80/G-326: the operator re-hire — a FRESH PlacementRow +
|
||||
placement.rehired event WITHOUT touching the application's
|
||||
terminal status (hired → hired is a 409 on every
|
||||
matrix-validated surface; placements, not applications, are
|
||||
the correction lane). decided_at = the re-hire event's
|
||||
store-stamped created_at (G-326 — D-102's stamp rule extended).
|
||||
|
||||
The partial unique index is the guard: an existing ACTIVE
|
||||
placement on the application refuses the insert and its
|
||||
IntegrityError SURFACES (the caller's honest 409 — revoked
|
||||
rows never match the partial WHERE, the exact G-316 property).
|
||||
"""
|
||||
if not role_title:
|
||||
raise ValueError("role_title is required on re-hire (D-103)")
|
||||
if start_date is None:
|
||||
raise ValueError("start_date is required on re-hire (D-103)")
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
with Session(self._engine) as session:
|
||||
application = session.get(ApplicationRow, application_id)
|
||||
if application is None:
|
||||
raise InvalidTransition(f"unknown application {application_id!r}")
|
||||
if application.status != "hired":
|
||||
raise InvalidTransition(
|
||||
f"application {application_id!r} is {application.status!r} — "
|
||||
"re-hire is the correction lane for a hired application"
|
||||
)
|
||||
placement = PlacementRow(
|
||||
id=f"{_PLACEMENT_ID_PREFIX}{uuid.uuid4().hex[:16]}",
|
||||
learner_id=application.learner_id,
|
||||
employer_id=self._posting_employer_id(session, application.posting_id),
|
||||
posting_id=application.posting_id,
|
||||
application_id=application.id,
|
||||
role_title=role_title,
|
||||
start_date=start_date.isoformat(),
|
||||
status="active",
|
||||
created_at=moment,
|
||||
# G-326: the re-hire event's stamp, not the original hire's
|
||||
decided_at=moment,
|
||||
)
|
||||
placement._validate()
|
||||
placement._normalize()
|
||||
session.add(placement)
|
||||
# the partial unique index refuses a second ACTIVE row —
|
||||
# its IntegrityError SURFACES (never swallowed, the
|
||||
# insert_claim discipline)
|
||||
session.flush()
|
||||
_, prev_hash = self._chain_head(session)
|
||||
self._append_event(
|
||||
session,
|
||||
application_id=application.id,
|
||||
placement_id=placement.id,
|
||||
event_type=EVENT_PLACEMENT_REHIRED,
|
||||
actor_type="operator",
|
||||
actor_ref=actor_ref,
|
||||
payload={
|
||||
"role_title": role_title,
|
||||
"start_date": start_date.isoformat(),
|
||||
},
|
||||
prev_hash=prev_hash,
|
||||
created_at=moment,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(placement)
|
||||
placement._normalize()
|
||||
return placement
|
||||
|
||||
def list_placements_by_employer(
|
||||
self, employer_id: str
|
||||
) -> list[PlacementRow]:
|
||||
"""v0.11 P3 (D-109): the claimed entity's OWN placements
|
||||
(active + revoked — the full history), created_at then id."""
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(PlacementRow)
|
||||
.where(PlacementRow.employer_id == employer_id)
|
||||
.order_by(col(PlacementRow.created_at), PlacementRow.id)
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
# -- the audit export (v0.11 P4, D-108/G-321/G-325/R-73) -----------------
|
||||
|
||||
EXPORT_SCHEMA_VERSION = 1
|
||||
|
||||
def build_export(self, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""D-108: ONE self-contained JSON document via this single
|
||||
builder (the export route AND the summary route render from
|
||||
the SAME dict — the summary can never drift from the export).
|
||||
|
||||
G-325: evidence_bundles cover EVERY placement (active AND
|
||||
revoked) — the correction history (hired → revoked → rehired)
|
||||
is precisely what an auditor most wants to inspect; cohort
|
||||
stays active-only; revoked_count equals the placement.revoked
|
||||
events (cross-checkable, rides the G-321(c) methodology).
|
||||
|
||||
R-73 determinism: events by id (chain order); by_cohort_month
|
||||
ascending; cohort/evidence ordered created_at then id; float
|
||||
absence (counts are ints by construction); generated_at is
|
||||
export-only metadata (a function of the request, not the rows
|
||||
— R-79) and is EXCLUDED from determinism comparison.
|
||||
"""
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
all_placements = self.list_placements() # created_at then id
|
||||
active = [row for row in all_placements if row.status == "active"]
|
||||
revoked = [row for row in all_placements if row.status == "revoked"]
|
||||
events = self.list_events() # id order — the chain order
|
||||
|
||||
# summary (the D-107 computation — one implementation, reused)
|
||||
learner_ids = {row.learner_id for row in active}
|
||||
by_month: dict[str, int] = {}
|
||||
for row in active:
|
||||
month = row.start_date[:7]
|
||||
by_month[month] = by_month.get(month, 0) + 1
|
||||
|
||||
# evidence bundles: G-302 — resolved from SOURCE ROWS at export
|
||||
with Session(self._engine) as session:
|
||||
from ..marketplace.store import ApplicationRow, PostingRow
|
||||
|
||||
evidence_bundles: list[dict[str, Any]] = []
|
||||
cohort: list[dict[str, Any]] = []
|
||||
for row in all_placements:
|
||||
application = session.get(ApplicationRow, row.application_id)
|
||||
posting = session.get(PostingRow, row.posting_id)
|
||||
transition_history = [
|
||||
{
|
||||
"id": event.id,
|
||||
"event_type": event.event_type,
|
||||
"actor_type": event.actor_type,
|
||||
"actor_ref": event.actor_ref,
|
||||
"created_at": (
|
||||
_as_utc(event.created_at).isoformat()
|
||||
if event.created_at is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
for event in events
|
||||
if event.application_id == row.application_id
|
||||
]
|
||||
hired_at: str | None = None
|
||||
for e in events:
|
||||
if (
|
||||
e.application_id == row.application_id
|
||||
and e.event_type == "application.hired"
|
||||
and e.created_at is not None
|
||||
):
|
||||
hired_at = _as_utc(e.created_at).isoformat()
|
||||
break
|
||||
decision_provenance = {
|
||||
"hired_event": hired_at,
|
||||
"decided_at": (
|
||||
_as_utc(row.decided_at).isoformat()
|
||||
if row.decided_at is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
bundle: dict[str, Any] = {
|
||||
"placement_id": row.id,
|
||||
"placement_status": row.status,
|
||||
"role_title": row.role_title,
|
||||
"start_date": row.start_date,
|
||||
"learner_id": row.learner_id,
|
||||
"employer_id": row.employer_id,
|
||||
"posting_record": (
|
||||
{
|
||||
"id": posting.id,
|
||||
"title": posting.title,
|
||||
"employer_id": posting.employer_id,
|
||||
"status": posting.status,
|
||||
}
|
||||
if posting is not None
|
||||
else None
|
||||
),
|
||||
"application_record": (
|
||||
{
|
||||
"id": application.id,
|
||||
"learner_id": application.learner_id,
|
||||
"posting_id": application.posting_id,
|
||||
"status": application.status,
|
||||
"created_at": (
|
||||
_as_utc(application.created_at).isoformat()
|
||||
if application.created_at is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
if application is not None
|
||||
else None
|
||||
),
|
||||
# D-101/A-304: NO backfill — pre-v0.11 submitted rows
|
||||
# carry only their created_at, never a fabricated
|
||||
# application.submitted event
|
||||
"transition_history": transition_history,
|
||||
"decision_provenance": decision_provenance,
|
||||
}
|
||||
evidence_bundles.append(bundle)
|
||||
if row.status == "active":
|
||||
cohort.append(
|
||||
{
|
||||
"placement_id": row.id,
|
||||
"learner_id": row.learner_id,
|
||||
"employer_id": row.employer_id,
|
||||
"posting_id": row.posting_id,
|
||||
"application_id": row.application_id,
|
||||
"role_title": row.role_title,
|
||||
"start_date": row.start_date,
|
||||
}
|
||||
)
|
||||
|
||||
ok, head_hash = self.verify_chain()
|
||||
if not ok:
|
||||
# P4 review P2-1: a raise (not an assert — stripped under
|
||||
# python -O) keeps the export FAIL-CLOSED on a broken
|
||||
# chain: no document is ever served that would not walk.
|
||||
raise RuntimeError(
|
||||
"placement ledger chain verification failed — "
|
||||
"refusing to build the audit export"
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": self.EXPORT_SCHEMA_VERSION,
|
||||
"generated_at": moment.isoformat(),
|
||||
"summary": {
|
||||
"distinct_learners_placed": len(learner_ids),
|
||||
"total_active_placements": len(active),
|
||||
"revoked_count": len(revoked),
|
||||
"by_cohort_month": [
|
||||
{"month": month, "count": count}
|
||||
for month, count in sorted(by_month.items())
|
||||
],
|
||||
},
|
||||
"cohort": cohort,
|
||||
"evidence_bundles": evidence_bundles,
|
||||
"methodology": EXPORT_METHODOLOGY,
|
||||
"ledger": {
|
||||
"events": [
|
||||
{
|
||||
"id": event.id,
|
||||
"application_id": event.application_id,
|
||||
"placement_id": event.placement_id,
|
||||
"event_type": event.event_type,
|
||||
"actor_type": event.actor_type,
|
||||
"actor_ref": event.actor_ref,
|
||||
"payload": event.payload,
|
||||
"created_at": (
|
||||
_as_utc(event.created_at).isoformat()
|
||||
if event.created_at is not None
|
||||
else None
|
||||
),
|
||||
"prev_hash": event.prev_hash,
|
||||
"hash": event.hash,
|
||||
}
|
||||
for event in events
|
||||
],
|
||||
"head_hash": head_hash,
|
||||
},
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._engine.dispose()
|
||||
@@ -80,3 +80,25 @@ def test_credentials_never_allowed(client: TestClient) -> None:
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-credentials") != "true"
|
||||
|
||||
|
||||
def test_preflight_allows_authorization_header(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""v0.10 (D-090, E87 lesson): the employer session surface sends
|
||||
`Authorization: Bearer <token>` — the preflight grant must carry
|
||||
the header or every cross-origin employer fetch dies preflight in
|
||||
the two-port dev + D-038 network-mode topologies."""
|
||||
resp = client.options(
|
||||
"/v1/employers/auth/login",
|
||||
headers={
|
||||
"Origin": ALLOWED_ORIGIN,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "Authorization, Content-Type",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, f"preflight failed: {resp.status_code}"
|
||||
allowed = resp.headers["access-control-allow-headers"]
|
||||
assert "authorization" in allowed.lower(), (
|
||||
f"Authorization missing from CORS headers: {allowed}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Billing engine tests (REQ-9-001) — module usable WITHOUT any route.
|
||||
|
||||
P1 Must-have: mock provider + webhooks core proven by direct
|
||||
instantiation only (no app, no routes, zero network). The store tests
|
||||
(test_store.py) join this package with the data-engineer's Task 1-1-02.
|
||||
"""
|
||||
@@ -0,0 +1,129 @@
|
||||
"""/v1/billing/catalog route tests (REQ-9-002).
|
||||
|
||||
Hermetic: a tmp-path billing store state-injected into a real
|
||||
create_app (the G-9 precedent). The catalog route is PUBLIC — no auth
|
||||
anywhere in this phase's surface. The shape assertions double as the
|
||||
py half of the dual-schema pins (apps/web/tests/pins.test.ts pins the
|
||||
TS half — G-108 discipline).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.billing.catalog import SEED_PACKAGES, SEED_PLANS
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
from ai_service.config import Settings
|
||||
from ai_service.main import create_app
|
||||
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
|
||||
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def make_client(tmp_path: Path) -> TestClient:
|
||||
"""Full app; the billing store seeds via the lifespan (D-078
|
||||
idempotent boot seeding — exactly what production runs)."""
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
)
|
||||
return TestClient(create_app(settings))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path) -> Iterator[TestClient]:
|
||||
with make_client(tmp_path) as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestCatalogRoute:
|
||||
def test_catalog_unauthenticated_200(self, client: TestClient) -> None:
|
||||
response = client.get("/v1/billing/catalog")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert set(body.keys()) == {"packages", "plans"}
|
||||
|
||||
def test_seeded_package_ids_and_prices(self, client: TestClient) -> None:
|
||||
"""The catalog mirrors the pricing page's tier structure
|
||||
(D-078/D-079): single $49, bundle $399, starter $99/mo, pro
|
||||
$299/mo — integer cents (D-081)."""
|
||||
body = client.get("/v1/billing/catalog").json()
|
||||
packages = {p["id"]: p for p in body["packages"]}
|
||||
assert packages["pkg-single"]["price_cents"] == 4900
|
||||
assert packages["pkg-single"]["posts_limit"] == 1
|
||||
assert packages["pkg-single"]["duration_days"] == 30
|
||||
assert packages["pkg-bundle"]["price_cents"] == 39900
|
||||
assert packages["pkg-bundle"]["posts_limit"] == 10
|
||||
assert packages["pkg-bundle"]["duration_days"] == 90
|
||||
plans = {p["id"]: p for p in body["plans"]}
|
||||
assert plans["plan-starter"]["price_cents"] == 9900
|
||||
assert plans["plan-starter"]["interval"] == "month"
|
||||
assert plans["plan-pro"]["price_cents"] == 29900
|
||||
|
||||
def test_wire_shape_matches_contracts(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""Field-for-field against the pydantic contracts — the py half
|
||||
of the dual-schema pins (G-108)."""
|
||||
body = client.get("/v1/billing/catalog").json()
|
||||
package_fields = {
|
||||
"id",
|
||||
"name",
|
||||
"price_cents",
|
||||
"posts_limit",
|
||||
"duration_days",
|
||||
"features",
|
||||
}
|
||||
assert set(body["packages"][0].keys()) == package_fields
|
||||
plan_fields = {"id", "name", "price_cents", "interval", "features"}
|
||||
assert set(body["plans"][0].keys()) == plan_fields
|
||||
for pkg in body["packages"]:
|
||||
assert isinstance(pkg["price_cents"], int) # D-081: no floats
|
||||
assert pkg["price_cents"] >= 0
|
||||
for plan in body["plans"]:
|
||||
assert isinstance(plan["price_cents"], int)
|
||||
assert plan["interval"] in ("month", "year")
|
||||
|
||||
def test_prices_ascending(self, client: TestClient) -> None:
|
||||
body = client.get("/v1/billing/catalog").json()
|
||||
pkg_prices = [p["price_cents"] for p in body["packages"]]
|
||||
plan_prices = [p["price_cents"] for p in body["plans"]]
|
||||
assert pkg_prices == sorted(pkg_prices)
|
||||
assert plan_prices == sorted(plan_prices)
|
||||
|
||||
def test_route_stable_across_calls(self, client: TestClient) -> None:
|
||||
first = client.get("/v1/billing/catalog").json()
|
||||
second = client.get("/v1/billing/catalog").json()
|
||||
assert first == second
|
||||
|
||||
def test_seeded_catalog_matches_module_constants(
|
||||
self, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""The store's seed + the module constants are the same data
|
||||
(D-078 — one source of truth). list_catalog orders by price
|
||||
ascending; the constants keep display order — compare as sets."""
|
||||
from ai_service.billing.catalog import seed_billing_catalog
|
||||
|
||||
seed_billing_catalog(billing_store)
|
||||
seed_billing_catalog(billing_store) # idempotent
|
||||
packages, plans = billing_store.list_catalog()
|
||||
assert {p.id for p in packages} == {p.id for p in SEED_PACKAGES}
|
||||
assert {p.id for p in plans} == {p.id for p in SEED_PLANS}
|
||||
by_id = {p.id: p for p in packages}
|
||||
for const in SEED_PACKAGES:
|
||||
seeded = by_id[const.id]
|
||||
assert seeded.price_cents == const.price_cents
|
||||
assert seeded.posts_limit == const.posts_limit
|
||||
assert seeded.features == const.features
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Base contract tests (Task 1-1-01, REQ-9-001, D-081/D-083).
|
||||
|
||||
Pins:
|
||||
- money is integer cents with ge=0 (D-081): negative prices rejected on
|
||||
Package AND Plan; the int wire type is the dual-schema pin's backend
|
||||
half (P2's pins.test.ts checks the TS side)
|
||||
- the canonical event-name enum is CLOSED (D-083/R-43): unknown names
|
||||
rejected by CanonicalBillingEvent, all five canonical names accepted
|
||||
- contract defaults: features lists default empty, payload dict default
|
||||
empty, CheckoutSessionSpec carries the reconciliation key
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ai_service.billing.base import (
|
||||
CANONICAL_EVENT_NAMES,
|
||||
CanonicalBillingEvent,
|
||||
CheckoutSessionSpec,
|
||||
Package,
|
||||
Plan,
|
||||
)
|
||||
|
||||
|
||||
def _package(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": "pkg-single",
|
||||
"name": "Single post",
|
||||
"price_cents": 4900,
|
||||
"posts_limit": 1,
|
||||
"duration_days": 30,
|
||||
"features": ["1 job posting"],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _plan(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": "plan-pro",
|
||||
"name": "Pro",
|
||||
"price_cents": 29900,
|
||||
"interval": "month",
|
||||
"features": ["talent access"],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestPackageContract:
|
||||
def test_valid_package_roundtrip(self) -> None:
|
||||
pkg = Package(**_package())
|
||||
assert pkg.price_cents == 4900
|
||||
assert pkg.posts_limit == 1
|
||||
assert pkg.duration_days == 30
|
||||
assert pkg.features == ["1 job posting"]
|
||||
|
||||
def test_negative_price_cents_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="price_cents"):
|
||||
Package(**_package(price_cents=-1))
|
||||
|
||||
def test_zero_cents_allowed(self) -> None:
|
||||
# ge=0 — a $0 promo tier is a valid catalog row (D-081 pins
|
||||
# NON-negative, not positive).
|
||||
assert Package(**_package(price_cents=0)).price_cents == 0
|
||||
|
||||
def test_cents_must_be_int_no_floats(self) -> None:
|
||||
# D-081: no floats anywhere in billing — 49.5 dollars must not
|
||||
# silently coerce. model_validate keeps mypy from rejecting the
|
||||
# deliberately-typed-wrong input (the runtime is the pin).
|
||||
with pytest.raises(ValidationError):
|
||||
Package.model_validate(_package(price_cents=49.5))
|
||||
|
||||
def test_posts_limit_and_duration_positive(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Package(**_package(posts_limit=0))
|
||||
with pytest.raises(ValidationError):
|
||||
Package(**_package(duration_days=0))
|
||||
|
||||
|
||||
class TestPlanContract:
|
||||
def test_valid_plan_roundtrip(self) -> None:
|
||||
plan = Plan(**_plan())
|
||||
assert plan.price_cents == 29900
|
||||
assert plan.interval == "month"
|
||||
|
||||
def test_negative_price_cents_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="price_cents"):
|
||||
Plan(**_plan(price_cents=-100))
|
||||
|
||||
def test_unknown_interval_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="interval"):
|
||||
Plan(**_plan(interval="fortnight"))
|
||||
|
||||
def test_features_default_empty(self) -> None:
|
||||
plan = Plan(id="plan-x", name="X", price_cents=0, interval="month")
|
||||
assert plan.features == []
|
||||
|
||||
|
||||
class TestCanonicalEventEnum:
|
||||
def test_all_five_canonical_names_accepted(self) -> None:
|
||||
for name in CANONICAL_EVENT_NAMES:
|
||||
event = CanonicalBillingEvent(name=name, payload={"id": "evt_1"})
|
||||
assert event.name == name
|
||||
|
||||
def test_canonical_names_are_the_d083_set(self) -> None:
|
||||
# D-083's exact vocabulary (R-43: OUR names, mapped at the
|
||||
# provider boundary — never vendor names).
|
||||
assert set(CANONICAL_EVENT_NAMES) == {
|
||||
"checkout.completed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"subscription.cancelled",
|
||||
"subscription.renewed",
|
||||
}
|
||||
|
||||
def test_unknown_event_name_rejected(self) -> None:
|
||||
# Vendor names must NOT validate as canonical (R-43 — the
|
||||
# boundary maps, never trusts). model_validate: the dict payload
|
||||
# is deliberately mistyped; the runtime enum is the pin.
|
||||
with pytest.raises(ValidationError, match="checkout.session.completed"):
|
||||
CanonicalBillingEvent.model_validate(
|
||||
{"name": "checkout.session.completed"}
|
||||
)
|
||||
|
||||
def test_payload_defaults_empty_dict(self) -> None:
|
||||
assert CanonicalBillingEvent(name="invoice.paid").payload == {}
|
||||
|
||||
|
||||
class TestCheckoutSessionSpec:
|
||||
def test_spec_shape(self) -> None:
|
||||
spec = CheckoutSessionSpec(
|
||||
mode="payment",
|
||||
item_id="pkg-single",
|
||||
price_cents=4900,
|
||||
item_name="Single Post",
|
||||
interval="",
|
||||
success_url="https://app.example/success",
|
||||
cancel_url="https://app.example/cancel",
|
||||
client_reference_id="sess_local_1",
|
||||
metadata={"employer_id": "E1"},
|
||||
)
|
||||
assert spec.mode == "payment"
|
||||
assert spec.metadata == {"employer_id": "E1"}
|
||||
assert spec.price_cents == 4900
|
||||
assert spec.item_name == "Single Post"
|
||||
|
||||
def test_client_reference_id_required(self) -> None:
|
||||
# R-34: our reconciliation key — no spec without it.
|
||||
with pytest.raises(ValidationError):
|
||||
CheckoutSessionSpec.model_validate(
|
||||
{"mode": "payment", "item_id": "pkg-single"}
|
||||
)
|
||||
|
||||
def test_unknown_mode_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
CheckoutSessionSpec.model_validate(
|
||||
{
|
||||
"mode": "rent",
|
||||
"item_id": "pkg-single",
|
||||
"client_reference_id": "sess_local_1",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Posting entitlement enforcement tests (REQ-9-004, MH-4a..4d).
|
||||
|
||||
create_posting composes the billing entitlement check AFTER the admin
|
||||
gate: 402 with an upgrade-CTA dict payload on no-package (MH-4a) or
|
||||
exhausted (MH-4b), 201 + consumption on valid (MH-4b), unlimited
|
||||
bypass (MH-4c), expired degrades to 402 (MH-4d), and G-309: the
|
||||
seeded ingest NEVER hits the enforcement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
from ai_service.config import Settings
|
||||
from ai_service.main import create_app
|
||||
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
ADMIN_TOKEN = "test-entitlement-admin"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
|
||||
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def make_client(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> TestClient:
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=SecretStr(ADMIN_TOKEN),
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.billing_store = billing_store # state-injection (G-9)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> Iterator[TestClient]:
|
||||
with make_client(tmp_path, billing_store) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def admin_headers() -> dict[str, str]:
|
||||
return {"X-Admin-Token": ADMIN_TOKEN}
|
||||
|
||||
|
||||
def create_posting(client: TestClient, employer: str):
|
||||
return client.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
headers=admin_headers(),
|
||||
json={"employer_id": employer, "title": "Entitlement Test Role"},
|
||||
)
|
||||
|
||||
|
||||
def grant_posts(
|
||||
billing_store: SQLiteBillingStore, employer: str, limit: int | None
|
||||
) -> None:
|
||||
"""A direct purchase grant (the store path the webhook would take;
|
||||
P4 tests bypass checkout — that's P3's proven surface)."""
|
||||
session_row = billing_store.insert_checkout_session(
|
||||
provider_session_id=f"cs_test_{employer}",
|
||||
employer_id=employer,
|
||||
item_id="pkg-single" if limit == 1 else "pkg-bundle",
|
||||
item_name="Test Grant",
|
||||
price_cents=0,
|
||||
mode="payment",
|
||||
)
|
||||
billing_store.create_purchase_from_session(
|
||||
session_row, posts_limit=limit, duration_days=30
|
||||
)
|
||||
|
||||
|
||||
class TestEnforcement:
|
||||
def test_no_package_402_with_cta(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-4a: no purchase → 402 + upgrade_cta payload."""
|
||||
response = create_posting(client, "emp-broke")
|
||||
assert response.status_code == 402
|
||||
detail = response.json()["detail"]
|
||||
assert detail["reason"] == "no_active_package"
|
||||
assert "upgrade_cta" in detail
|
||||
assert "/v1/billing/admin/checkout" in detail["upgrade_cta"]
|
||||
|
||||
def test_exhausted_402(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-4b (half): 1-post package consumed → next is 402."""
|
||||
grant_posts(billing_store, "emp-single", 1)
|
||||
first = create_posting(client, "emp-single")
|
||||
assert first.status_code == 201
|
||||
second = create_posting(client, "emp-single")
|
||||
assert second.status_code == 402
|
||||
assert second.json()["detail"]["reason"] == "posting_limit_reached"
|
||||
|
||||
def test_valid_package_consumes(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-4b (half): a 10-post bundle → 201s + posts_used grows."""
|
||||
grant_posts(billing_store, "emp-bundle", 10)
|
||||
for i in range(3):
|
||||
response = create_posting(client, "emp-bundle")
|
||||
assert response.status_code == 201, f"post {i}"
|
||||
entitlement = billing_store.get_entitlement("emp-bundle")
|
||||
assert entitlement["purchase"]["posts_used"] == 3
|
||||
|
||||
def test_unlimited_bypass(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-4c: a manual unlimited grant never blocks."""
|
||||
grant_posts(billing_store, "emp-enterprise", None)
|
||||
for _ in range(5):
|
||||
response = create_posting(client, "emp-enterprise")
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_expired_degrades_to_402(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
client: TestClient,
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""MH-4d: an expired purchase is skipped — honest 402."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
|
||||
grant_posts(billing_store, "emp-expired", 10)
|
||||
with Session(billing_store._engine) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE billing_purchases SET expires_at = :past "
|
||||
"WHERE employer_id = 'emp-expired'"
|
||||
),
|
||||
{"past": datetime.now(UTC) - timedelta(days=1)},
|
||||
)
|
||||
session.commit()
|
||||
response = create_posting(client, "emp-expired")
|
||||
assert response.status_code == 402
|
||||
# A purchase exists but is expired → the exhausted arm (both
|
||||
# arms are honest 402 + upgrade_cta; the reason reflects the
|
||||
# row's existence — MH-4d pins the 402 degrade, not the label).
|
||||
assert response.json()["detail"]["reason"] == "posting_limit_reached"
|
||||
assert "upgrade_cta" in response.json()["detail"]
|
||||
|
||||
def test_admin_gate_still_first(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""REQ-9-004: enforcement composes AFTER require_admin — no
|
||||
token → 403, never a billing decision."""
|
||||
response = client.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
json={"employer_id": "emp-broke", "title": "T"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_missing_billing_store_fails_closed(
|
||||
self, tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""Final-review regression (fail-closed gate): a wiring miss
|
||||
(no billing_store on app.state) must REFUSE posting creation
|
||||
(503 with the fix pointer) — never silently skip the
|
||||
entitlement check and return an unbilled 201. Simulated by
|
||||
deleting the lifespan-wired attribute (the old getattr+hasattr
|
||||
soft-guard made this exact state an ungated 201)."""
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=SecretStr(ADMIN_TOKEN),
|
||||
)
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as c:
|
||||
# The wiring miss: the store is gone mid-flight (None — the
|
||||
# exact state the old getattr+hasattr guard silently
|
||||
# skipped). Restored before shutdown so the lifespan's
|
||||
# close() stays intact — the route's guard is the subject.
|
||||
real_store = app.state.billing_store
|
||||
app.state.billing_store = None
|
||||
try:
|
||||
response = c.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
headers=admin_headers(),
|
||||
json={"employer_id": "emp-unwired", "title": "T"},
|
||||
)
|
||||
finally:
|
||||
app.state.billing_store = real_store
|
||||
assert response.status_code == 503
|
||||
assert "unavailable" in response.json()["detail"]
|
||||
|
||||
def test_seeded_ingest_never_gated(
|
||||
self, tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""G-309: the seeded-corpus ingest upserts directly through the
|
||||
store — zero purchases, zero 402s, zero consumption rows."""
|
||||
from ai_service.marketplace.ingest import SeededCorpusSource, run_ingest
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
marketplace_store = SQLiteMarketplaceStore(
|
||||
db_path=tmp_path / "marketplace.db"
|
||||
)
|
||||
run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
packages, _ = billing_store.list_catalog()
|
||||
postings = marketplace_store.list_postings(limit=1000, offset=0)
|
||||
assert postings[1] > 0 # the seeded corpus landed
|
||||
# Zero consumption rows exist.
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
|
||||
with Session(billing_store._engine) as session:
|
||||
count = int(
|
||||
session.execute(
|
||||
text("SELECT COUNT(*) FROM billing_purchases")
|
||||
).scalar_one()
|
||||
)
|
||||
assert count == 0
|
||||
marketplace_store.close()
|
||||
|
||||
|
||||
class TestEntitlementsRoute:
|
||||
def test_fail_closed(self, client: TestClient) -> None:
|
||||
response = client.get("/v1/billing/admin/entitlements/emp-1")
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_shape(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
grant_posts(billing_store, "emp-view", 10)
|
||||
response = client.get(
|
||||
"/v1/billing/admin/entitlements/emp-view",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["employer_id"] == "emp-view"
|
||||
assert body["purchase"]["posts_limit"] == 10
|
||||
assert body["purchase"]["posts_used"] == 0
|
||||
assert "expires_at" in body["purchase"]
|
||||
assert body["subscription"] is None
|
||||
@@ -0,0 +1,231 @@
|
||||
"""D-091 payload-parity tests (REQ-10-004, MH-4a, R-58).
|
||||
|
||||
The shared gate's 402 wire contract is BYTE-STABLE: {reason,
|
||||
posts_used: 0, posts_limit: 0, upgrade_cta} — the zeros are the v0.9
|
||||
wire shape the web client's UpgradeRequiredError ignores (it consumes
|
||||
reason + upgrade_cta only, apps/web/lib/engine-client.ts), never live
|
||||
counts. These tests pin the exact dict through the admin route (the
|
||||
v0.9 caller) and prove the principal parameterization: only the
|
||||
upgrade_cta field carries the caller's checkout pointer — reason and
|
||||
the hardcoded zeros are identical across principals. The employer
|
||||
route (Task 4-1-02) will compose the SAME helper, extending this file
|
||||
with the employer-gate arm.
|
||||
|
||||
Fixtures mirror tests/billing/test_entitlement_enforcement.py (the
|
||||
R-64 state-injection make_client + grant_posts precedent) — the
|
||||
enforcement suite itself stays UNCHANGED (the no-drift proof).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.api.entitlement import consume_posting_entitlement
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
from ai_service.config import Settings
|
||||
from ai_service.main import create_app
|
||||
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
ADMIN_TOKEN = "test-parity-admin"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
|
||||
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def make_client(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> TestClient:
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=SecretStr(ADMIN_TOKEN),
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.billing_store = billing_store # state-injection (G-9)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> Iterator[TestClient]:
|
||||
with make_client(tmp_path, billing_store) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def create_posting(client: TestClient, employer: str):
|
||||
return client.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
headers={"X-Admin-Token": ADMIN_TOKEN},
|
||||
json={"employer_id": employer, "title": "Parity Test Role"},
|
||||
)
|
||||
|
||||
|
||||
def grant_posts(
|
||||
billing_store: SQLiteBillingStore, employer: str, limit: int | None
|
||||
) -> None:
|
||||
"""A direct purchase grant (the store path the webhook would take;
|
||||
the test_entitlement_enforcement.py:70-85 precedent)."""
|
||||
session_row = billing_store.insert_checkout_session(
|
||||
provider_session_id=f"cs_parity_{employer}",
|
||||
employer_id=employer,
|
||||
item_id="pkg-single" if limit == 1 else "pkg-bundle",
|
||||
item_name="Test Grant",
|
||||
price_cents=0,
|
||||
mode="payment",
|
||||
)
|
||||
billing_store.create_purchase_from_session(
|
||||
session_row, posts_limit=limit, duration_days=30
|
||||
)
|
||||
|
||||
|
||||
class TestPayloadParity:
|
||||
def test_no_package_402_exact_dict(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-4a parity: no package → the EXACT canonical 402 dict —
|
||||
hardcoded posts_used/posts_limit zeros (R-58), admin CTA."""
|
||||
response = create_posting(client, "emp-parity-none")
|
||||
assert response.status_code == 402
|
||||
assert response.json()["detail"] == {
|
||||
"reason": "no_active_package",
|
||||
"posts_used": 0,
|
||||
"posts_limit": 0,
|
||||
"upgrade_cta": (
|
||||
"This employer has no active posting package — "
|
||||
"purchase one via /v1/billing/admin/checkout."
|
||||
),
|
||||
}
|
||||
|
||||
def test_exhausted_402_exact_dict(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-4a parity (exhausted arm): the EXACT canonical dict — the
|
||||
zeros stay byte-stable even though the employer has a REAL
|
||||
package with real counts (posts_used=1, posts_limit=1 in the
|
||||
store; the payload never carries live limits)."""
|
||||
grant_posts(billing_store, "emp-parity-spent", 1)
|
||||
first = create_posting(client, "emp-parity-spent")
|
||||
assert first.status_code == 201
|
||||
second = create_posting(client, "emp-parity-spent")
|
||||
assert second.status_code == 402
|
||||
detail = second.json()["detail"]
|
||||
assert detail == {
|
||||
"reason": "posting_limit_reached",
|
||||
"posts_used": 0,
|
||||
"posts_limit": 0,
|
||||
"upgrade_cta": (
|
||||
"This employer's posting package is exhausted — "
|
||||
"buy more posts or upgrade via /v1/billing/admin/checkout."
|
||||
),
|
||||
}
|
||||
# The store's truth differs from the payload's zeros — the
|
||||
# wire contract is NOT a limits view (R-58 pin). The exhausted
|
||||
# row is invisible to get_entitlement (no active purchase
|
||||
# left), so the raw row read carries the truth (the
|
||||
# test_expired_degrades_to_402 engine-session precedent).
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
|
||||
with Session(billing_store._engine) as session:
|
||||
row = session.execute(
|
||||
text(
|
||||
"SELECT posts_used, posts_limit FROM billing_purchases "
|
||||
"WHERE employer_id = 'emp-parity-spent'"
|
||||
)
|
||||
).one()
|
||||
assert tuple(row) == (1, 1)
|
||||
|
||||
def test_402_consumes_nothing_creates_nothing(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""R-58 gate-before-insert: a 402 consumes nothing (posts_used
|
||||
unchanged) and creates nothing (no marketplace row) — the gate
|
||||
runs BEFORE the insert."""
|
||||
grant_posts(billing_store, "emp-parity-gate", 1)
|
||||
assert create_posting(client, "emp-parity-gate").status_code == 201
|
||||
marketplace_store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
before = marketplace_store.list_postings_by_employer(
|
||||
"emp-parity-gate"
|
||||
)
|
||||
assert len(before) == 1
|
||||
denied = create_posting(client, "emp-parity-gate")
|
||||
assert denied.status_code == 402
|
||||
# Nothing consumed: posts_used still 1 (the failed create spent
|
||||
# nothing — the exception aborted before any second UPDATE; the
|
||||
# row is now exhausted → invisible to get_entitlement, so the
|
||||
# raw row read carries the truth).
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
|
||||
with Session(billing_store._engine) as session:
|
||||
posts_used = session.execute(
|
||||
text(
|
||||
"SELECT posts_used FROM billing_purchases "
|
||||
"WHERE employer_id = 'emp-parity-gate'"
|
||||
)
|
||||
).scalar_one()
|
||||
assert posts_used == 1
|
||||
# Nothing created: the failed create inserted no marketplace row.
|
||||
after = marketplace_store.list_postings_by_employer(
|
||||
"emp-parity-gate"
|
||||
)
|
||||
assert len(after) == 1
|
||||
assert [p.id for p in after] == [p.id for p in before]
|
||||
|
||||
def test_only_cta_differs_per_principal(
|
||||
self, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""The D-091 parity core: the SAME store state through the
|
||||
shared helper with two principals' CTAs → reason/posts_used/
|
||||
posts_limit identical, ONLY upgrade_cta differs (the employer
|
||||
route, Task 4-1-02, composes this same arm in-route)."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
payloads: list[dict[str, object]] = []
|
||||
for cta in (
|
||||
"/v1/billing/admin/checkout",
|
||||
"/v1/employers/billing/checkout",
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
consume_posting_entitlement(
|
||||
billing_store, "emp-parity-principal", cta
|
||||
)
|
||||
assert exc_info.value.status_code == 402
|
||||
payloads.append(dict(exc_info.value.detail)) # type: ignore[arg-type]
|
||||
admin_payload, employer_payload = payloads
|
||||
assert admin_payload["reason"] == employer_payload["reason"]
|
||||
assert admin_payload["posts_used"] == 0
|
||||
assert admin_payload["posts_limit"] == 0
|
||||
assert employer_payload["posts_used"] == 0
|
||||
assert employer_payload["posts_limit"] == 0
|
||||
assert admin_payload["upgrade_cta"] != employer_payload["upgrade_cta"]
|
||||
assert "/v1/billing/admin/checkout" in str(admin_payload["upgrade_cta"])
|
||||
assert "/v1/employers/billing/checkout" in str(
|
||||
employer_payload["upgrade_cta"]
|
||||
)
|
||||
|
||||
def test_store_missing_fails_closed(
|
||||
self, tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""The fail-closed 503 guard rides along with the extraction:
|
||||
a wiring miss (None store) refuses creation — never an ungated
|
||||
201 (the final-review fix, preserved in the helper)."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
consume_posting_entitlement(None, "emp-parity-unwired", "x")
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "unavailable" in str(exc_info.value.detail)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Factory + settings trio tests (Task 1-1-05, R-53, D-075/D-077).
|
||||
|
||||
Pins (MH-1d):
|
||||
- factory: mock default; explicit mock; unknown name → actionable
|
||||
UnknownPaymentProviderError; 'stripe' without its config → the
|
||||
actionable UnknownPaymentProviderError main.py catches (G-11)
|
||||
- webhook secret resolution: configured secret is used VERBATIM (the
|
||||
mint/verify round-trip proves the provider holds it); default empty
|
||||
→ per-boot random (two providers do not accept each other's
|
||||
signatures — fail-closed, D-077)
|
||||
- settings redaction: payment_api_key is SecretStr — repr(Settings)
|
||||
NEVER leaks the key material (the admin-token/embedding precedent)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.billing.base import (
|
||||
UnknownPaymentProviderError,
|
||||
WebhookSignatureError,
|
||||
)
|
||||
from ai_service.billing.factory import payment_provider_from_settings
|
||||
from ai_service.billing.mock import MockPaymentProvider
|
||||
from ai_service.config import Settings
|
||||
|
||||
|
||||
class TestFactorySelection:
|
||||
def test_default_is_mock(self) -> None:
|
||||
# D-075: mock default — the no-key pilot path is first-class.
|
||||
provider = payment_provider_from_settings(Settings())
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
|
||||
def test_explicit_mock_selected(self) -> None:
|
||||
provider = payment_provider_from_settings(
|
||||
Settings(payment_provider="mock")
|
||||
)
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
|
||||
def test_provider_name_normalized(self) -> None:
|
||||
# Voice/embedding factory precedent: strip + lower — env typos
|
||||
# in casing/whitespace must not 500 the selection.
|
||||
provider = payment_provider_from_settings(
|
||||
Settings(payment_provider=" MOCK ")
|
||||
)
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
|
||||
def test_unknown_provider_raises(self) -> None:
|
||||
with pytest.raises(UnknownPaymentProviderError, match="satellite"):
|
||||
payment_provider_from_settings(Settings(payment_provider="satellite"))
|
||||
|
||||
def test_stripe_without_config_raises_actionable(self) -> None:
|
||||
# P3: stripe is REAL now — but needs its key (R-53); the
|
||||
# actionable error is the one main.py's lifespan catches → G-11
|
||||
# mock fallback. Byte-contract behavior lives in
|
||||
# test_stripe_provider.py (MockTransport, D-075).
|
||||
with pytest.raises(UnknownPaymentProviderError, match="AI_PAYMENT_API_KEY"):
|
||||
payment_provider_from_settings(Settings(payment_provider="stripe"))
|
||||
|
||||
def test_stripe_without_http_client_raises(self) -> None:
|
||||
settings = Settings(
|
||||
payment_provider="stripe",
|
||||
payment_api_key=SecretStr("sk_test_x"),
|
||||
webhook_secret="whsec_test",
|
||||
public_base_url="https://nextcraft.example",
|
||||
)
|
||||
with pytest.raises(UnknownPaymentProviderError, match="httpx pool"):
|
||||
payment_provider_from_settings(settings)
|
||||
|
||||
|
||||
class TestWebhookSecretResolution:
|
||||
def test_configured_secret_used_verbatim(self) -> None:
|
||||
# The mint/verify round-trip proves the provider was built with
|
||||
# the CONFIGURED secret (not a random one). mint_event is a
|
||||
# mock-only helper (D-082's signed-delivery mint) — narrow the
|
||||
# factory's protocol return to the concrete mock first.
|
||||
provider = payment_provider_from_settings(
|
||||
Settings(webhook_secret="whsec_test_configured")
|
||||
)
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
raw_body, header, _ = provider.mint_event("invoice.paid", {})
|
||||
verified = provider.verify_webhook(raw_body, header)
|
||||
assert verified.event.name == "invoice.paid"
|
||||
|
||||
def test_unset_secret_is_per_boot_random_fail_closed(self) -> None:
|
||||
# D-077 (telemetry_ingest_secret pattern): unset → per-boot
|
||||
# random. Two independently built providers hold DIFFERENT
|
||||
# random secrets — one cannot verify the other's signatures,
|
||||
# so an unconfigured deployment fails closed (401s), never
|
||||
# processes unsigned traffic.
|
||||
a = payment_provider_from_settings(Settings())
|
||||
b = payment_provider_from_settings(Settings())
|
||||
assert isinstance(a, MockPaymentProvider)
|
||||
assert isinstance(b, MockPaymentProvider)
|
||||
raw_body, header, _ = a.mint_event("invoice.paid", {})
|
||||
with pytest.raises(WebhookSignatureError):
|
||||
b.verify_webhook(raw_body, header)
|
||||
|
||||
|
||||
class TestSettingsTrio:
|
||||
def test_settings_defaults(self) -> None:
|
||||
settings = Settings()
|
||||
assert settings.payment_provider == "mock"
|
||||
assert settings.payment_api_key.get_secret_value() == ""
|
||||
assert settings.webhook_secret == ""
|
||||
|
||||
def test_env_prefix_wiring(self) -> None:
|
||||
# R-53: the env trio is AI_PAYMENT_PROVIDER / AI_PAYMENT_API_KEY
|
||||
# / AI_WEBHOOK_SECRET (pydantic-settings prefix AI_).
|
||||
settings = Settings(
|
||||
payment_provider="stripe",
|
||||
payment_api_key=SecretStr("sk_test_placeholder"),
|
||||
webhook_secret="whsec_placeholder",
|
||||
)
|
||||
assert settings.payment_provider == "stripe"
|
||||
assert settings.payment_api_key.get_secret_value() == "sk_test_placeholder"
|
||||
assert settings.webhook_secret == "whsec_placeholder"
|
||||
|
||||
def test_repr_does_not_leak_payment_api_key(self) -> None:
|
||||
# SecretStr redaction: key material must never survive a repr
|
||||
# (logs, error messages, tracebacks). 'sk_' is the leak canary.
|
||||
settings = Settings(payment_api_key=SecretStr("sk_test_super_secret_key"))
|
||||
rendered = repr(settings)
|
||||
assert "sk_test_super_secret_key" not in rendered
|
||||
assert "sk_" not in rendered
|
||||
|
||||
def test_str_does_not_leak_payment_api_key(self) -> None:
|
||||
settings = Settings(payment_api_key=SecretStr("sk_test_super_secret_key"))
|
||||
assert "sk_test_super_secret_key" not in str(settings)
|
||||
assert "sk_" not in str(settings)
|
||||
|
||||
def test_webhook_secret_is_plain_str_by_design(self) -> None:
|
||||
# The webhook secret is compared in-process (HMAC key), not
|
||||
# echoed to surfaces — plain str mirrors telemetry_ingest_secret
|
||||
# (SecretStr adds friction for a value that must be readable by
|
||||
# the signer; it is never logged either way).
|
||||
settings = Settings(webhook_secret="whsec_plain_ok")
|
||||
assert settings.webhook_secret == "whsec_plain_ok"
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Subscription lifecycle + admin routes tests (REQ-9-005, MH-5a..5d).
|
||||
|
||||
The transition matrix lives in the store (P1, test_store.py — the
|
||||
matrix + lazy grace are pinned there). These tests pin the ROUTE
|
||||
surface: cancel/resume/upgrade composition, manual grant/revoke
|
||||
provenance, the queryable event log, and renewal idempotency through
|
||||
the real webhook path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.billing.mock import MockPaymentProvider
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
from ai_service.config import Settings
|
||||
from ai_service.main import create_app
|
||||
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
ADMIN_TOKEN = "test-lifecycle-admin"
|
||||
SECRET = "whsec_test_lifecycle"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
|
||||
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def make_client(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> TestClient:
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=SecretStr(ADMIN_TOKEN),
|
||||
webhook_secret=SECRET,
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.billing_store = billing_store
|
||||
app.state.payment_provider = MockPaymentProvider(webhook_secret=SECRET)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> Iterator[TestClient]:
|
||||
with make_client(tmp_path, billing_store) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def admin_headers() -> dict[str, str]:
|
||||
return {"X-Admin-Token": ADMIN_TOKEN}
|
||||
|
||||
|
||||
def seed_subscription(
|
||||
client: TestClient, billing_store: SQLiteBillingStore, employer: str
|
||||
) -> str:
|
||||
"""The honest grant path: admin checkout → admin complete → the
|
||||
REAL webhook path creates the active subscription."""
|
||||
checkout = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "plan-pro", "employer_id": employer},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert checkout.status_code == 201
|
||||
provider_session_id = checkout.json()["provider_session_id"]
|
||||
complete = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": provider_session_id},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert complete.status_code == 200
|
||||
entitlement = billing_store.get_entitlement(employer)
|
||||
assert entitlement["subscription"] is not None
|
||||
return str(entitlement["subscription"]["id"])
|
||||
|
||||
|
||||
class TestLifecycleRoutes:
|
||||
def test_all_fail_closed(self, client: TestClient) -> None:
|
||||
for method, path in (
|
||||
("post", "/v1/billing/admin/subscriptions/sub-1/cancel"),
|
||||
("post", "/v1/billing/admin/subscriptions/sub-1/resume"),
|
||||
("post", "/v1/billing/admin/subscriptions/sub-1/upgrade"),
|
||||
("post", "/v1/billing/admin/employers/emp-1/grant"),
|
||||
("post", "/v1/billing/admin/employers/emp-1/revoke"),
|
||||
("get", "/v1/billing/admin/events"),
|
||||
):
|
||||
response = getattr(client, method)(path)
|
||||
assert response.status_code == 403, f"{method} {path}"
|
||||
|
||||
def test_cancel_writes_provenance(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
subscription_id = seed_subscription(client, billing_store, "emp-cancel")
|
||||
response = client.post(
|
||||
f"/v1/billing/admin/subscriptions/{subscription_id}/cancel",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "cancelled"
|
||||
events = billing_store.list_billing_events(
|
||||
employer_id="emp-cancel", kind="manual"
|
||||
)
|
||||
assert any(e.name == "cancel" for e in events)
|
||||
|
||||
def test_store_ref_matches_the_mock_provider(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""Final-review regression (ref desync): the subscription row's
|
||||
provider_subscription_id must be a key the MOCK PROVIDER knows
|
||||
(its documented sub_mock_ convention) — the old sub_prov_
|
||||
spelling meant provider.cancel_subscription resolved None and
|
||||
local state desynced from provider state on every lifecycle
|
||||
call."""
|
||||
seed_subscription(client, billing_store, "emp-ref")
|
||||
subscription = billing_store.get_subscription(
|
||||
str(billing_store.get_entitlement("emp-ref")["subscription"]["id"])
|
||||
)
|
||||
assert subscription is not None
|
||||
provider = client.app.state.payment_provider # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
assert (
|
||||
subscription.provider_subscription_id in provider._subscriptions
|
||||
), "the store's provider ref must resolve at the provider"
|
||||
# The cancel route reaches the provider (not cancel:unknown).
|
||||
response = client.post(
|
||||
f"/v1/billing/admin/subscriptions/{subscription.id}/cancel",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["provider_action"] == "cancel:cancelled"
|
||||
|
||||
def test_unknown_subscription_404(self, client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/subscriptions/sub-ghost/cancel",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_resume_illegal_transition_409(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""active → active is outside the matrix — the honest 409."""
|
||||
subscription_id = seed_subscription(client, billing_store, "emp-resume")
|
||||
response = client.post(
|
||||
f"/v1/billing/admin/subscriptions/{subscription_id}/resume",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_upgrade_keeps_active_changes_plan(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""G-305: upgrade = plan_id transition within active; the manual
|
||||
billing_event carries provenance."""
|
||||
subscription_id = seed_subscription(client, billing_store, "emp-up")
|
||||
response = client.post(
|
||||
f"/v1/billing/admin/subscriptions/{subscription_id}/upgrade",
|
||||
json={"plan_id": "plan-starter"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "active" # G-305: status unchanged
|
||||
entitlement = billing_store.get_entitlement("emp-up")
|
||||
assert entitlement["subscription"]["plan_id"] == "plan-starter"
|
||||
events = billing_store.list_billing_events(
|
||||
employer_id="emp-up", kind="manual"
|
||||
)
|
||||
assert any(e.name == "upgrade" for e in events)
|
||||
|
||||
def test_upgrade_unknown_plan_404(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
subscription_id = seed_subscription(client, billing_store, "emp-up2")
|
||||
response = client.post(
|
||||
f"/v1/billing/admin/subscriptions/{subscription_id}/upgrade",
|
||||
json={"plan_id": "plan-nope"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestManualGrantRevoke:
|
||||
def test_grant_unlimited_records_provenance(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/employers/emp-grant/grant",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
entitlement = billing_store.get_entitlement("emp-grant")
|
||||
assert entitlement["purchase"] is not None
|
||||
assert entitlement["purchase"]["posts_limit"] is None # unlimited
|
||||
events = billing_store.list_billing_events(
|
||||
employer_id="emp-grant", kind="manual"
|
||||
)
|
||||
assert any(e.name == "grant" and e.operator for e in events)
|
||||
|
||||
def test_grant_twice_never_500s(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""Final-review regression: a repeated manual grant for the SAME
|
||||
employer must never collide on the provider_session_id UNIQUE
|
||||
constraint (the old cs_manual_{employer_id} placeholder 500'd
|
||||
on the second grant; remediation is a repeat operation)."""
|
||||
first = client.post(
|
||||
"/v1/billing/admin/employers/emp-twice/grant",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
second = client.post(
|
||||
"/v1/billing/admin/employers/emp-twice/grant",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
assert second.status_code == 201, second.text
|
||||
entitlement = billing_store.get_entitlement("emp-twice")
|
||||
assert entitlement["purchase"] is not None
|
||||
assert entitlement["purchase"]["posts_limit"] is None
|
||||
|
||||
def test_revoke_expires_grants(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
client.post(
|
||||
"/v1/billing/admin/employers/emp-grant2/grant",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/billing/admin/employers/emp-grant2/revoke",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
entitlement = billing_store.get_entitlement("emp-grant2")
|
||||
assert entitlement["purchase"] is None # expired → no active grant
|
||||
events = billing_store.list_billing_events(
|
||||
employer_id="emp-grant2", kind="manual"
|
||||
)
|
||||
assert any(e.name == "revoke" for e in events)
|
||||
|
||||
|
||||
class TestEventLog:
|
||||
def test_events_filterable(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
seed_subscription(client, billing_store, "emp-log")
|
||||
client.post(
|
||||
"/v1/billing/admin/employers/emp-log/grant", headers=admin_headers()
|
||||
)
|
||||
# MH-5d: filter by employer
|
||||
response = client.get(
|
||||
"/v1/billing/admin/events",
|
||||
params={"employer_id": "emp-log"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["count"] > 0
|
||||
assert all(
|
||||
e["employer_id"] == "emp-log" for e in body["events"]
|
||||
)
|
||||
# Filter by kind narrows further.
|
||||
manual = client.get(
|
||||
"/v1/billing/admin/events",
|
||||
params={"employer_id": "emp-log", "kind": "manual"},
|
||||
headers=admin_headers(),
|
||||
).json()
|
||||
assert all(e["kind"] == "manual" for e in manual["events"])
|
||||
assert any(e["operator"] for e in manual["events"])
|
||||
|
||||
def test_events_include_provider_and_unmatched(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""The log answers 'what happened?' with full provenance —
|
||||
provider events + unmatched deliveries both surface."""
|
||||
provider = client.app.state.payment_provider # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
raw, header, _ = provider.mint_event(
|
||||
"checkout.completed", {"session_id": "cs_ghost"}
|
||||
)
|
||||
client.post(
|
||||
"/v1/billing/webhook",
|
||||
content=raw,
|
||||
headers={"Stripe-Signature": header},
|
||||
)
|
||||
response = client.get(
|
||||
"/v1/billing/admin/events",
|
||||
params={"kind": "unmatched"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] >= 1
|
||||
|
||||
|
||||
class TestRenewalIdempotency:
|
||||
def test_double_renewal_single_extension(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-5b: the SAME provider event delivered twice extends the
|
||||
period exactly once (event-id dedupe). A DISTINCT renewal event
|
||||
extends again — that is honest billing."""
|
||||
subscription_id = seed_subscription(client, billing_store, "emp-renew")
|
||||
subscription = billing_store.get_subscription(subscription_id)
|
||||
assert subscription is not None
|
||||
provider = client.app.state.payment_provider # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
# Same event id — delivered twice (R-45 at-least-once).
|
||||
raw, header, _ = provider.mint_event(
|
||||
"invoice.paid", {"subscription_id": subscription.provider_subscription_id}
|
||||
)
|
||||
first = client.post(
|
||||
"/v1/billing/webhook", content=raw, headers={"Stripe-Signature": header}
|
||||
)
|
||||
second = client.post(
|
||||
"/v1/billing/webhook", content=raw, headers={"Stripe-Signature": header}
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200 # duplicate → 200, no re-extension
|
||||
after_first = billing_store.get_subscription(subscription_id)
|
||||
assert after_first is not None
|
||||
period_after_dupes = after_first.current_period_end
|
||||
# A DISTINCT renewal event extends once more.
|
||||
raw2, header2, _ = provider.mint_event(
|
||||
"invoice.paid", {"subscription_id": subscription.provider_subscription_id}
|
||||
)
|
||||
client.post(
|
||||
"/v1/billing/webhook", content=raw2, headers={"Stripe-Signature": header2}
|
||||
)
|
||||
after_second = billing_store.get_subscription(subscription_id)
|
||||
assert after_second is not None
|
||||
assert period_after_dupes is not None
|
||||
assert after_second.current_period_end == period_after_dupes + _thirty_days()
|
||||
# subscription.renewed maps onto the same idempotent path (R-43).
|
||||
raw3, header3, _ = provider.mint_event(
|
||||
"subscription.renewed",
|
||||
{"subscription_id": subscription.provider_subscription_id},
|
||||
)
|
||||
client.post(
|
||||
"/v1/billing/webhook", content=raw3, headers={"Stripe-Signature": header3}
|
||||
)
|
||||
after_third = billing_store.get_subscription(subscription_id)
|
||||
assert after_third is not None
|
||||
assert after_third.current_period_end == period_after_dupes + _thirty_days() * 2
|
||||
|
||||
|
||||
def _thirty_days():
|
||||
from datetime import timedelta
|
||||
|
||||
return timedelta(days=30)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Mock provider tests (Task 1-1-03, D-075, REQ-9-001).
|
||||
|
||||
Pins:
|
||||
- session ids are UNIQUE per create call across 100 calls (the
|
||||
identity-mock unique-mint lesson — a deterministic id would collide
|
||||
on retries and conflate distinct logical checkouts, G-303)
|
||||
- ids are Stripe-shaped with the mock marker: cs_mock_... (R-36)
|
||||
- session lifecycle: create → open/unpaid; confirm_completion →
|
||||
complete/paid (the R-37 grant predicate, scriptable not faked);
|
||||
unknown/already-complete confirm raises (fail loud — the ROUTE owns
|
||||
idempotency via event dedupe)
|
||||
- mint_event → verify_webhook round-trip green (the mock-complete path
|
||||
D-082 posts through the REAL verification scheme)
|
||||
- verify_webhook fails closed on tampered bytes + wrong secret
|
||||
- normalize_event is a passthrough that still enforces the canonical
|
||||
enum (R-43: the mock speaks OUR names directly)
|
||||
- retrieve unknown id → None; subscriptions cancel per R-46
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_service.billing.base import (
|
||||
CheckoutSessionSpec,
|
||||
PaymentProvider,
|
||||
WebhookSignatureError,
|
||||
)
|
||||
from ai_service.billing.mock import MockPaymentProvider
|
||||
|
||||
SECRET = "whsec_mock_test_secret"
|
||||
|
||||
|
||||
def _spec(**overrides: Any) -> CheckoutSessionSpec:
|
||||
base: dict[str, Any] = {
|
||||
"mode": "payment",
|
||||
"item_id": "pkg-single",
|
||||
"price_cents": 4900,
|
||||
"item_name": "Single Post",
|
||||
"interval": "",
|
||||
"client_reference_id": "sess_local_1",
|
||||
"metadata": {"employer_id": "E1"},
|
||||
}
|
||||
base.update(overrides)
|
||||
return CheckoutSessionSpec(**base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider() -> MockPaymentProvider:
|
||||
return MockPaymentProvider(webhook_secret=SECRET)
|
||||
|
||||
|
||||
class TestIdUniqueness:
|
||||
async def test_ids_unique_across_100_calls(self) -> None:
|
||||
# The unique-mint lesson: per-process nonce + monotonic counter
|
||||
# — uniqueness per call is the contract.
|
||||
provider = MockPaymentProvider()
|
||||
ids = {
|
||||
(await provider.create_checkout_session(_spec())).id
|
||||
for _ in range(100)
|
||||
}
|
||||
assert len(ids) == 100
|
||||
|
||||
def test_ids_unique_across_instances(self) -> None:
|
||||
# Two providers on one box (e.g. multi-worker) never mint the
|
||||
# same session id: the nonce is per-instance.
|
||||
a = MockPaymentProvider()._mint_session_id()
|
||||
b = MockPaymentProvider()._mint_session_id()
|
||||
assert a != b
|
||||
|
||||
async def test_id_shape_is_stripe_mock(self) -> None:
|
||||
provider = MockPaymentProvider()
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
assert session.id.startswith("cs_mock_")
|
||||
assert len(session.id) > len("cs_mock_")
|
||||
|
||||
|
||||
class TestSessionLifecycle:
|
||||
async def test_create_is_open_unpaid(self, provider: MockPaymentProvider) -> None:
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
assert session.status == "open"
|
||||
assert session.payment_status == "unpaid"
|
||||
assert session.mode == "payment"
|
||||
assert session.expires_at is not None
|
||||
|
||||
async def test_confirm_flips_complete_paid(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
completed = await provider.confirm_completion(session.id)
|
||||
assert completed.status == "complete"
|
||||
assert completed.payment_status == "paid" # R-37 predicate
|
||||
|
||||
async def test_retrieve_reflects_completion(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
await provider.confirm_completion(session.id)
|
||||
retrieved = await provider.retrieve_session(session.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.status == "complete"
|
||||
assert retrieved.payment_status == "paid"
|
||||
|
||||
async def test_confirm_unknown_session_raises(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="unknown checkout session"):
|
||||
await provider.confirm_completion("cs_mock_does_not_exist")
|
||||
|
||||
async def test_confirm_twice_raises(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
# Fail loud, not idempotent-silent: the ROUTE owns idempotency
|
||||
# (event dedupe + G-303 per-session grants), not the provider.
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
await provider.confirm_completion(session.id)
|
||||
with pytest.raises(ValueError, match="not open"):
|
||||
await provider.confirm_completion(session.id)
|
||||
|
||||
async def test_retrieve_unknown_session_none(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
assert await provider.retrieve_session("cs_mock_nope") is None
|
||||
|
||||
|
||||
class TestSubscriptionMode:
|
||||
async def test_subscription_completion_creates_active_ref(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
session = await provider.create_checkout_session(
|
||||
_spec(mode="subscription", item_id="plan-pro")
|
||||
)
|
||||
await provider.confirm_completion(session.id)
|
||||
cancelled = await provider.cancel_subscription(
|
||||
f"sub_mock_{session.id.removeprefix('cs_')}"
|
||||
)
|
||||
assert cancelled is not None
|
||||
assert cancelled.status == "cancelled"
|
||||
|
||||
async def test_cancel_unknown_subscription_none(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
assert await provider.cancel_subscription("sub_mock_nope") is None
|
||||
|
||||
|
||||
class TestWebhookRoundTrip:
|
||||
async def test_mint_event_verify_roundtrip_green(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
# The D-082 path: mint_event produces exactly what a provider
|
||||
# delivery looks like; verify_webhook (the REAL shared scheme)
|
||||
# authenticates + parses it.
|
||||
raw_body, header, event_id = provider.mint_event(
|
||||
"checkout.completed", {"session_id": "cs_mock_x"}
|
||||
)
|
||||
verified = provider.verify_webhook(raw_body, header)
|
||||
assert verified.event.name == "checkout.completed"
|
||||
assert verified.event.payload == {"session_id": "cs_mock_x"}
|
||||
assert verified.provider_event_id == event_id
|
||||
|
||||
async def test_event_ids_unique_across_mints(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
ids = {
|
||||
provider.mint_event("invoice.paid", {})[2] for _ in range(50)
|
||||
}
|
||||
assert len(ids) == 50
|
||||
|
||||
def test_verify_tampered_body_fails_closed(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
raw_body, header, _ = provider.mint_event("invoice.paid", {})
|
||||
with pytest.raises(WebhookSignatureError):
|
||||
provider.verify_webhook(raw_body + b"x", header)
|
||||
|
||||
def test_verify_wrong_secret_fails_closed(self) -> None:
|
||||
other = MockPaymentProvider(webhook_secret="whsec_other")
|
||||
raw_body, header, _ = other.mint_event("invoice.paid", {})
|
||||
provider = MockPaymentProvider(webhook_secret=SECRET)
|
||||
with pytest.raises(WebhookSignatureError):
|
||||
provider.verify_webhook(raw_body, header)
|
||||
|
||||
def test_verify_missing_header_fails_closed(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="missing"):
|
||||
provider.verify_webhook(b"{}", "")
|
||||
|
||||
|
||||
class TestNormalizePassthrough:
|
||||
def test_canonical_names_pass_through(self, provider: MockPaymentProvider) -> None:
|
||||
# R-43: the mock speaks canonical names directly — no mapping.
|
||||
event = provider.normalize_event(
|
||||
{"name": "subscription.renewed", "payload": {"subscription_id": "s1"}}
|
||||
)
|
||||
assert event.name == "subscription.renewed"
|
||||
assert event.payload == {"subscription_id": "s1"}
|
||||
|
||||
def test_vendor_name_rejected(self, provider: MockPaymentProvider) -> None:
|
||||
# Passthrough still enforces the closed enum: a vendor-shaped
|
||||
# name must not silently validate as canonical.
|
||||
with pytest.raises(ValueError, match="unknown event name"):
|
||||
provider.normalize_event({"name": "checkout.session.completed"})
|
||||
|
||||
def test_payload_non_dict_degrades_to_empty(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
event = provider.normalize_event({"name": "invoice.paid", "payload": "oops"})
|
||||
assert event.payload == {}
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
def test_satisfies_payment_provider_protocol(self) -> None:
|
||||
# runtime_checkable protocol: the mock IS a PaymentProvider
|
||||
# (structural) — the seam P3's stripe provider must also satisfy.
|
||||
provider = MockPaymentProvider()
|
||||
assert isinstance(provider, PaymentProvider)
|
||||
@@ -0,0 +1,578 @@
|
||||
"""BillingStore tests — the 7th D-027 store (REQ-9-001).
|
||||
|
||||
Mirrors tests/marketplace/test_store.py: tmp_path db, sqlite_master
|
||||
table-existence pins, PRAGMA pins, IntegrityError surfacing, snapshot
|
||||
integrity, dedupe idempotency, transition matrix, and the grill
|
||||
guards: G-303 (per-session grants), G-304 (conditional-UPDATE
|
||||
consumption), G-305 (no upgraded status), G-306 (lazy grace expiry).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session
|
||||
|
||||
from ai_service.billing.base import Package, Plan
|
||||
from ai_service.billing.store import (
|
||||
PAST_DUE_GRACE_DAYS,
|
||||
CheckoutSessionRow,
|
||||
InvalidTransition,
|
||||
NoActivePackage,
|
||||
PackageExhausted,
|
||||
SQLiteBillingStore,
|
||||
)
|
||||
|
||||
SEED_PACKAGES = [
|
||||
Package(
|
||||
id="pkg-single",
|
||||
name="Single Post",
|
||||
price_cents=4900,
|
||||
posts_limit=1,
|
||||
duration_days=30,
|
||||
features=["1 posting", "30 days"],
|
||||
),
|
||||
Package(
|
||||
id="pkg-bundle",
|
||||
name="Bundle",
|
||||
price_cents=39900,
|
||||
posts_limit=10,
|
||||
duration_days=90,
|
||||
features=["10 postings", "90 days each"],
|
||||
),
|
||||
]
|
||||
|
||||
SEED_PLANS = [
|
||||
Plan(
|
||||
id="plan-starter",
|
||||
name="Starter",
|
||||
price_cents=9900,
|
||||
interval="month",
|
||||
features=["browse talent"],
|
||||
),
|
||||
Plan(
|
||||
id="plan-pro",
|
||||
name="Pro",
|
||||
price_cents=29900,
|
||||
interval="month",
|
||||
features=["unlimited views"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def make_store(tmp_path: Path) -> SQLiteBillingStore:
|
||||
return SQLiteBillingStore(db_path=tmp_path / "billing-test.db")
|
||||
|
||||
|
||||
def seeded_store(tmp_path: Path) -> SQLiteBillingStore:
|
||||
store = make_store(tmp_path)
|
||||
store.seed_catalog(SEED_PACKAGES, SEED_PLANS)
|
||||
return store
|
||||
|
||||
|
||||
def make_session_row(
|
||||
store: SQLiteBillingStore, employer: str = "emp-1"
|
||||
) -> CheckoutSessionRow:
|
||||
return store.insert_checkout_session(
|
||||
provider_session_id=f"cs_mock_{employer}",
|
||||
employer_id=employer,
|
||||
item_id="pkg-bundle",
|
||||
item_name="Bundle",
|
||||
price_cents=39900,
|
||||
mode="payment",
|
||||
)
|
||||
|
||||
|
||||
class TestSchema:
|
||||
def test_all_seven_tables_exist(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with store._engine.connect() as conn:
|
||||
names = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
).fetchall()
|
||||
}
|
||||
for table in (
|
||||
"billing_packages",
|
||||
"billing_plans",
|
||||
"billing_purchases",
|
||||
"billing_subscriptions",
|
||||
"billing_provider_events",
|
||||
"billing_checkout_sessions",
|
||||
"billing_billing_events",
|
||||
):
|
||||
assert table in names
|
||||
|
||||
def test_pragmas(self, tmp_path: Path) -> None:
|
||||
"""WAL + synchronous=NORMAL + busy_timeout + foreign_keys — the
|
||||
D-027 per-connection contract."""
|
||||
store = make_store(tmp_path)
|
||||
with store._engine.connect() as conn:
|
||||
journal_row = conn.execute(text("PRAGMA journal_mode")).fetchone()
|
||||
sync_row = conn.execute(text("PRAGMA synchronous")).fetchone()
|
||||
busy_row = conn.execute(text("PRAGMA busy_timeout")).fetchone()
|
||||
fk_row = conn.execute(text("PRAGMA foreign_keys")).fetchone()
|
||||
assert journal_row is not None and sync_row is not None
|
||||
assert busy_row is not None and fk_row is not None
|
||||
assert str(journal_row[0]).lower() == "wal"
|
||||
assert sync_row[0] == 1 # NORMAL
|
||||
assert busy_row[0] == 5000
|
||||
assert fk_row[0] == 1
|
||||
|
||||
def test_seed_catalog_idempotent(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
store.seed_catalog(SEED_PACKAGES, SEED_PLANS) # second run
|
||||
packages, plans = store.list_catalog()
|
||||
assert [p.id for p in packages] == ["pkg-single", "pkg-bundle"]
|
||||
assert [p.id for p in plans] == ["plan-starter", "plan-pro"]
|
||||
|
||||
|
||||
class TestSnapshots:
|
||||
def test_purchase_snapshot_survives_catalog_edit(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
purchase = store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
assert purchase.package_name == "Bundle"
|
||||
assert purchase.price_cents == 39900
|
||||
# Catalog price edit must NOT rewrite purchase history (D-078).
|
||||
edited = [p.model_copy(update={"price_cents": 99900}) for p in SEED_PACKAGES]
|
||||
store.seed_catalog(edited, SEED_PLANS)
|
||||
packages, _ = store.list_catalog()
|
||||
assert packages[1].price_cents == 99900
|
||||
entitlement = store.get_entitlement("emp-1")
|
||||
assert entitlement["purchase"]["posts_limit"] == 10
|
||||
assert purchase.price_cents == 39900 # snapshot intact
|
||||
|
||||
|
||||
class TestProviderEventDedupe:
|
||||
def test_same_event_id_replay_created_false(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
assert store.insert_provider_event("evt-1", "invoice.paid", {"x": 1}) is True
|
||||
assert store.insert_provider_event("evt-1", "invoice.paid", {"x": 1}) is False
|
||||
|
||||
def test_distinct_event_ids_pass_through(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
assert store.insert_provider_event("evt-1", "invoice.paid", {}) is True
|
||||
assert store.insert_provider_event("evt-2", "invoice.paid", {}) is True
|
||||
|
||||
|
||||
class TestPerSessionGrants:
|
||||
"""G-303: grants are idempotent per SESSION, not per event id."""
|
||||
|
||||
def test_second_purchase_for_same_session_raises(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
|
||||
def test_subscription_upsert_keyed_on_provider_ref(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_mock_sub_1",
|
||||
employer_id="emp-1",
|
||||
item_id="plan-pro",
|
||||
item_name="Pro",
|
||||
price_cents=29900,
|
||||
mode="subscription",
|
||||
)
|
||||
first = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_prov_1",
|
||||
interval="month",
|
||||
)
|
||||
second = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_prov_1",
|
||||
interval="month",
|
||||
)
|
||||
assert first.id == second.id # the EXISTING row rides the replay
|
||||
|
||||
def test_distinct_event_ids_same_session_one_purchase(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""G-303 composite pin (Task 1-1-02's exact test): two provider
|
||||
events with DIFFERENT event ids announcing the SAME session's
|
||||
completion → exactly one purchase row. Event-id dedupe alone
|
||||
would pass both through; the per-SESSION UniqueConstraint is
|
||||
what makes the grant exactly-once."""
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
# Both distinct events are stored verbatim (dedupe keys differ)…
|
||||
assert store.insert_provider_event("evt-a", "checkout.completed", {}) is True
|
||||
assert store.insert_provider_event("evt-b", "checkout.completed", {}) is True
|
||||
# …each handler attempt grants from the same session snapshot…
|
||||
first = store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
# …and the second grant for that session is refused by the
|
||||
# constraint (IntegrityError → the caller records
|
||||
# already-granted, never a second purchase row).
|
||||
with pytest.raises(IntegrityError):
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
with Session(store._engine) as session:
|
||||
count = int(
|
||||
session.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM billing_purchases "
|
||||
"WHERE employer_id = 'emp-1'"
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
assert count == 1 # one purchase despite two distinct event ids
|
||||
assert first.posts_limit == 10
|
||||
|
||||
|
||||
class TestConsumePosting:
|
||||
def test_consume_increments(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
row = store.consume_posting("emp-1")
|
||||
assert row.posts_used == 1
|
||||
|
||||
def test_no_purchase_raises_no_active(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
with pytest.raises(NoActivePackage):
|
||||
store.consume_posting("emp-none")
|
||||
|
||||
def test_exhausted_raises(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_mock_single",
|
||||
employer_id="emp-1",
|
||||
item_id="pkg-single",
|
||||
item_name="Single Post",
|
||||
price_cents=4900,
|
||||
mode="payment",
|
||||
)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=1, duration_days=30
|
||||
)
|
||||
assert store.consume_posting("emp-1").posts_used == 1
|
||||
with pytest.raises(PackageExhausted):
|
||||
store.consume_posting("emp-1")
|
||||
|
||||
def test_unlimited_never_exhausts(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
purchase = store.create_purchase_from_session(
|
||||
session_row, posts_limit=None, duration_days=None
|
||||
)
|
||||
assert purchase.posts_limit is None
|
||||
for _ in range(5):
|
||||
assert store.consume_posting("emp-1") is not None
|
||||
|
||||
def test_expired_purchase_skipped(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
purchase = store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
assert purchase.expires_at is not None
|
||||
# Force-expire: created_at back far enough that expires_at passed.
|
||||
with Session(store._engine) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE billing_purchases SET expires_at = :past "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"past": datetime.now(UTC) - timedelta(days=1), "id": purchase.id},
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(PackageExhausted):
|
||||
store.consume_posting("emp-1")
|
||||
|
||||
def test_newest_purchase_consumed_first(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
older = store.insert_checkout_session(
|
||||
provider_session_id="cs_old",
|
||||
employer_id="emp-1",
|
||||
item_id="pkg-single",
|
||||
item_name="Single Post",
|
||||
price_cents=4900,
|
||||
mode="payment",
|
||||
)
|
||||
store.create_purchase_from_session(
|
||||
older, posts_limit=1, duration_days=30
|
||||
)
|
||||
with Session(store._engine) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE billing_purchases SET created_at = :past WHERE id = "
|
||||
"(SELECT id FROM billing_purchases WHERE checkout_session_id = "
|
||||
"(SELECT id FROM billing_checkout_sessions WHERE "
|
||||
"provider_session_id = 'cs_old'))"
|
||||
),
|
||||
{"past": datetime.now(UTC) - timedelta(days=10)},
|
||||
)
|
||||
session.commit()
|
||||
newer = make_session_row(store) # cs_mock_emp-1, newest
|
||||
store.create_purchase_from_session(
|
||||
newer, posts_limit=10, duration_days=90
|
||||
)
|
||||
row = store.consume_posting("emp-1")
|
||||
# The newest (bundle, 10 posts) must be the one consumed.
|
||||
assert row.checkout_session_id == newer.id
|
||||
assert row.package_id == "pkg-bundle"
|
||||
|
||||
def test_concurrent_consumes_one_post_left(self, tmp_path: Path) -> None:
|
||||
"""G-304: the conditional UPDATE means two concurrent consumes
|
||||
against ONE remaining post → exactly one succeeds. Deterministic:
|
||||
both connections fire the same statement; SQLite WAL serializes
|
||||
the write; the loser's rowcount is 0."""
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_race",
|
||||
employer_id="emp-race",
|
||||
item_id="pkg-single",
|
||||
item_name="Single Post",
|
||||
price_cents=4900,
|
||||
mode="payment",
|
||||
)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=1, duration_days=30
|
||||
)
|
||||
results: list[str] = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def consume() -> None:
|
||||
barrier.wait() # both attempt the same instant
|
||||
try:
|
||||
store.consume_posting("emp-race")
|
||||
results.append("ok")
|
||||
except (PackageExhausted, NoActivePackage):
|
||||
results.append("exhausted")
|
||||
|
||||
threads = [threading.Thread(target=consume) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert sorted(results) == ["exhausted", "ok"] # exactly one win
|
||||
with Session(store._engine) as session:
|
||||
used = int(
|
||||
session.execute(
|
||||
text(
|
||||
"SELECT posts_used FROM billing_purchases "
|
||||
"WHERE employer_id = 'emp-race'"
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
assert used == 1 # single increment, no double-spend
|
||||
|
||||
|
||||
class TestTransitionMatrix:
|
||||
def _active_subscription(
|
||||
self, store: SQLiteBillingStore, ref: str = "cs_sub"
|
||||
) -> str:
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id=ref,
|
||||
employer_id="emp-sub",
|
||||
item_id="plan-pro",
|
||||
item_name="Pro",
|
||||
price_cents=29900,
|
||||
mode="subscription",
|
||||
)
|
||||
sub = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id=f"sub_prov_{ref}",
|
||||
interval="month",
|
||||
)
|
||||
return sub.id
|
||||
|
||||
def test_legal_transitions(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
assert store.transition_subscription(sub_id, "past_due") is not None
|
||||
assert store.transition_subscription(sub_id, "active") is not None
|
||||
assert store.transition_subscription(sub_id, "cancelled") is not None
|
||||
|
||||
def test_illegal_transition_raises(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
with pytest.raises(InvalidTransition):
|
||||
store.transition_subscription(sub_id, "trialing")
|
||||
|
||||
def test_cancelled_is_terminal(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
store.transition_subscription(sub_id, "cancelled")
|
||||
with pytest.raises(InvalidTransition):
|
||||
store.transition_subscription(sub_id, "active")
|
||||
|
||||
def test_past_due_sets_timestamp(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
row = store.transition_subscription(sub_id, "past_due")
|
||||
assert row is not None
|
||||
assert row.past_due_at is not None
|
||||
recovered = store.transition_subscription(sub_id, "active")
|
||||
assert recovered is not None
|
||||
assert recovered.past_due_at is None
|
||||
|
||||
def test_no_upgraded_status_value(self, tmp_path: Path) -> None:
|
||||
"""G-305: the validated set contains no `upgraded`."""
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
with pytest.raises(ValueError, match="invalid subscription status"):
|
||||
store.transition_subscription(sub_id, "upgraded")
|
||||
|
||||
def test_past_due_can_cancel_and_expire(self, tmp_path: Path) -> None:
|
||||
"""Final-review regression: past_due → cancelled (and → expired)
|
||||
are legal matrix moves — the past_due_at timestamp must clear
|
||||
(the _validate invariant binds it to past_due ONLY) or the
|
||||
transition raises and the subscription is stuck forever."""
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
store.transition_subscription(sub_id, "past_due")
|
||||
cancelled = store.transition_subscription(sub_id, "cancelled")
|
||||
assert cancelled is not None
|
||||
assert cancelled.status == "cancelled"
|
||||
assert cancelled.past_due_at is None
|
||||
# The expired arm (matrix: past_due → expired) clears too.
|
||||
sub2 = self._active_subscription(store, ref="cs_sub2")
|
||||
store.transition_subscription(sub2, "past_due")
|
||||
expired = store.transition_subscription(sub2, "expired")
|
||||
assert expired is not None
|
||||
assert expired.status == "expired"
|
||||
assert expired.past_due_at is None
|
||||
|
||||
def test_upgrade_is_plan_transition_within_active(self, tmp_path: Path) -> None:
|
||||
"""G-305: upgrade changes plan_id, keeps status=active, writes
|
||||
a manual billing_event."""
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
row = store.upgrade_subscription(sub_id, "plan-pro", "Pro", 29900)
|
||||
assert row is not None
|
||||
assert row.status == "active"
|
||||
assert row.plan_id == "plan-pro"
|
||||
events = store.list_billing_events(employer_id="emp-sub", kind="manual")
|
||||
assert any(e.name == "upgrade" for e in events)
|
||||
|
||||
|
||||
class TestLazyGraceExpiry:
|
||||
"""G-306: past_due → expired computed LAZILY at read time."""
|
||||
|
||||
def _past_due(self, store: SQLiteBillingStore) -> None:
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_grace",
|
||||
employer_id="emp-grace",
|
||||
item_id="plan-pro",
|
||||
item_name="Pro",
|
||||
price_cents=29900,
|
||||
mode="subscription",
|
||||
)
|
||||
sub = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_grace",
|
||||
interval="month",
|
||||
)
|
||||
store.transition_subscription(sub.id, "past_due")
|
||||
|
||||
def _past_due_at(self, store: SQLiteBillingStore) -> datetime:
|
||||
with Session(store._engine) as session:
|
||||
raw = session.execute(
|
||||
text(
|
||||
"SELECT past_due_at FROM billing_subscriptions "
|
||||
"WHERE employer_id = 'emp-grace'"
|
||||
)
|
||||
).scalar_one()
|
||||
assert isinstance(raw, str) # raw text() returns ISO strings
|
||||
return datetime.fromisoformat(raw).replace(tzinfo=UTC)
|
||||
|
||||
def test_inside_grace_still_granted(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
self._past_due(store)
|
||||
past_due_at = self._past_due_at(store)
|
||||
inside = past_due_at + timedelta(days=PAST_DUE_GRACE_DAYS - 1)
|
||||
entitlement = store.get_entitlement("emp-grace", now=inside)
|
||||
assert entitlement["subscription"]["status"] == "past_due"
|
||||
|
||||
def test_beyond_grace_reads_expired(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
self._past_due(store)
|
||||
past_due_at = self._past_due_at(store)
|
||||
beyond = past_due_at + timedelta(days=PAST_DUE_GRACE_DAYS + 1)
|
||||
entitlement = store.get_entitlement("emp-grace", now=beyond)
|
||||
assert entitlement["subscription"]["status"] == "expired"
|
||||
# No writes happened — lazy evaluation (G-306).
|
||||
with Session(store._engine) as session:
|
||||
status = session.execute(
|
||||
text(
|
||||
"SELECT status FROM billing_subscriptions "
|
||||
"WHERE employer_id = 'emp-grace'"
|
||||
)
|
||||
).scalar_one()
|
||||
assert status == "past_due"
|
||||
|
||||
|
||||
class TestRenewalExtension:
|
||||
def test_extend_period_idempotent_per_event(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_renew",
|
||||
employer_id="emp-renew",
|
||||
item_id="plan-pro",
|
||||
item_name="Pro",
|
||||
price_cents=29900,
|
||||
mode="subscription",
|
||||
)
|
||||
sub = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_renew",
|
||||
interval="month",
|
||||
)
|
||||
assert sub.current_period_end is not None
|
||||
before = sub.current_period_end
|
||||
extended = store.extend_subscription_period("sub_renew", days=30)
|
||||
assert extended is not None
|
||||
assert extended.current_period_end == before + timedelta(days=30)
|
||||
|
||||
|
||||
class TestAuditLog:
|
||||
def test_manual_provenance_round_trips(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.insert_billing_event(
|
||||
"manual", "grant", employer_id="emp-1", operator="op-1"
|
||||
)
|
||||
rows = store.list_billing_events(employer_id="emp-1", kind="manual")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].operator == "op-1"
|
||||
|
||||
def test_unmatched_marker_round_trips(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.insert_billing_event(
|
||||
"unmatched", "checkout.completed", detail={"session": "cs_unknown"}
|
||||
)
|
||||
rows = store.list_billing_events(kind="unmatched")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].name == "checkout.completed"
|
||||
|
||||
def test_filters_by_employer_and_kind(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.insert_billing_event("provider", "invoice.paid", employer_id="emp-1")
|
||||
store.insert_billing_event("manual", "grant", employer_id="emp-2")
|
||||
assert len(store.list_billing_events(employer_id="emp-1")) == 1
|
||||
assert len(store.list_billing_events(kind="provider")) == 1
|
||||
|
||||
def test_invalid_kind_rejected(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(ValueError, match="invalid billing event kind"):
|
||||
store.insert_billing_event("bogus", "x")
|
||||
@@ -0,0 +1,328 @@
|
||||
"""StripePaymentProvider byte-contract tests (Task 3-1-01, D-075).
|
||||
|
||||
httpx.MockTransport ONLY (the openai-audio/embedding pattern) — the
|
||||
cloud is never called. Each handler asserts the EXACT wire shape:
|
||||
form-encoded bodies with bracket nesting (R-33), Basic auth (R-33),
|
||||
Idempotency-Key on POSTs (R-49), items[0][id] on updates (R-47 — the
|
||||
dual-charge guard), success_url/cancel_url presence (G-310), and key
|
||||
redaction in every raised error (A-305).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ai_service.billing.base import (
|
||||
CheckoutSessionSpec,
|
||||
UnknownPaymentProviderError,
|
||||
)
|
||||
from ai_service.billing.stripe import StripePaymentProvider
|
||||
from ai_service.billing.webhooks import mint as mint_signature
|
||||
|
||||
KEY = "sk_test_bytecontract"
|
||||
SECRET = "whsec_test_bytecontract"
|
||||
|
||||
|
||||
def make_provider(handler) -> tuple[StripePaymentProvider, dict[str, Any]]:
|
||||
seen: dict[str, Any] = {}
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
provider = StripePaymentProvider(
|
||||
http_client=client,
|
||||
api_key=KEY,
|
||||
webhook_secret=SECRET,
|
||||
)
|
||||
return provider, seen
|
||||
|
||||
|
||||
def form_body(request: httpx.Request) -> dict[str, str]:
|
||||
return {
|
||||
pair[0]: pair[1]
|
||||
for pair in urllib.parse.parse_qsl(request.content.decode("utf-8"))
|
||||
}
|
||||
|
||||
|
||||
def spec(mode: str = "payment", interval: str = "") -> CheckoutSessionSpec:
|
||||
return CheckoutSessionSpec(
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
item_id="plan-pro" if mode == "subscription" else "pkg-bundle",
|
||||
price_cents=39900,
|
||||
item_name="Bundle" if mode == "payment" else "Pro",
|
||||
interval=interval,
|
||||
success_url="https://nextcraft.example/marketplace/pricing?checkout=success",
|
||||
cancel_url="https://nextcraft.example/marketplace/pricing?checkout=cancelled",
|
||||
client_reference_id="sess_local_1",
|
||||
metadata={"employer_id": "emp-1", "operator_ref": "operator"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_form_encoded_body_with_bracket_nesting():
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/v1/checkout/sessions"
|
||||
seen["body"] = form_body(request)
|
||||
seen["auth"] = request.headers.get("authorization", "")
|
||||
seen["idem"] = request.headers.get("idempotency-key", "")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "cs_test_abc",
|
||||
"url": "https://checkout.stripe.com/pay/cs_test_abc",
|
||||
"status": "open",
|
||||
"payment_status": "unpaid",
|
||||
"mode": "payment",
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
provider = StripePaymentProvider(
|
||||
http_client=client, api_key=KEY, webhook_secret=SECRET
|
||||
)
|
||||
session = await provider.create_checkout_session(spec())
|
||||
assert session.id == "cs_test_abc"
|
||||
assert session.status == "open"
|
||||
# R-33: form-encoded, bracket nesting — NOT JSON.
|
||||
assert seen["body"]["line_items[0][price_data][currency]"] == "usd"
|
||||
assert seen["body"]["line_items[0][price_data][unit_amount]"] == "39900"
|
||||
assert (
|
||||
seen["body"]["line_items[0][price_data][product_data][name]"] == "Bundle"
|
||||
)
|
||||
assert seen["body"]["mode"] == "payment"
|
||||
assert seen["body"]["client_reference_id"] == "sess_local_1"
|
||||
assert seen["body"]["metadata[employer_id]"] == "emp-1"
|
||||
# G-310: success/cancel URLs ride every create (R-34 required).
|
||||
assert seen["body"]["success_url"].startswith("https://")
|
||||
assert seen["body"]["cancel_url"].startswith("https://")
|
||||
# R-33: Basic auth with the key as user, empty password.
|
||||
expected = base64.b64encode(f"{KEY}:".encode()).decode()
|
||||
assert seen["auth"] == f"Basic {expected}"
|
||||
# R-49: Idempotency-Key on every POST.
|
||||
assert seen["idem"], "Idempotency-Key must ride the create POST"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_subscription_mode_carries_recurring_interval():
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["body"] = form_body(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "cs_test_sub",
|
||||
"url": "https://checkout.stripe.com/pay/cs_test_sub",
|
||||
"status": "open",
|
||||
"payment_status": "unpaid",
|
||||
"mode": "subscription",
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
provider = StripePaymentProvider(
|
||||
http_client=client, api_key=KEY, webhook_secret=SECRET
|
||||
)
|
||||
await provider.create_checkout_session(spec(mode="subscription", interval="month"))
|
||||
# R-35: one-time vs recurring differs ONLY by the interval field.
|
||||
assert (
|
||||
seen["body"]["line_items[0][price_data][recurring][interval]"] == "month"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_subscription_includes_item_id_dual_charge_guard():
|
||||
"""R-47/MH-3d: `items[0][id]` MUST ride the update — omitting it
|
||||
ADDS the new price alongside the old (dual-charge bug)."""
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "sub_test_1",
|
||||
"status": "active",
|
||||
"items": {"data": [{"id": "si_test_item_1"}]},
|
||||
},
|
||||
)
|
||||
seen["body"] = form_body(request)
|
||||
seen["path"] = request.url.path
|
||||
return httpx.Response(200, json={"id": "sub_test_1", "status": "active"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
provider = StripePaymentProvider(
|
||||
http_client=client, api_key=KEY, webhook_secret=SECRET
|
||||
)
|
||||
ref = await provider.update_subscription("sub_test_1", 29900)
|
||||
assert ref is not None and ref.id == "sub_test_1"
|
||||
assert seen["path"] == "/v1/subscriptions/sub_test_1"
|
||||
assert seen["body"]["items[0][id]"] == "si_test_item_1", (
|
||||
"the subscription_item id MUST be in the update body (R-47)"
|
||||
)
|
||||
assert seen["body"]["items[0][price_data][unit_amount]"] == "29900"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_subscription_immediate_is_delete():
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["method"] = request.method
|
||||
return httpx.Response(
|
||||
200, json={"id": "sub_test_1", "status": "canceled"}
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
provider = StripePaymentProvider(
|
||||
http_client=client, api_key=KEY, webhook_secret=SECRET
|
||||
)
|
||||
ref = await provider.cancel_subscription("sub_test_1")
|
||||
assert ref is not None
|
||||
assert seen["method"] == "DELETE" # R-46: immediate = DELETE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_redacts_api_key():
|
||||
"""A-305: the api key must NEVER appear in a raised exception."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(402, text=f"card declined weirdness {KEY}")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
provider = StripePaymentProvider(
|
||||
http_client=client, api_key=KEY, webhook_secret=SECRET
|
||||
)
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await provider.create_checkout_session(spec())
|
||||
assert KEY not in str(excinfo.value), "api key leaked in the error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_session_404_maps_to_none():
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(404, json={"error": {"message": "no such session"}})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
provider = StripePaymentProvider(
|
||||
http_client=client, api_key=KEY, webhook_secret=SECRET
|
||||
)
|
||||
assert await provider.retrieve_session("cs_unknown") is None
|
||||
|
||||
|
||||
class TestVerifyWebhook:
|
||||
def _delivery(self, event_type: str) -> tuple[bytes, str, str]:
|
||||
event_id = f"evt_{event_type.replace('.', '_')}"
|
||||
payload: dict[str, Any] = {
|
||||
"id": event_id,
|
||||
"type": event_type,
|
||||
"data": {"object": {"id": "cs_test_abc", "status": "complete"}},
|
||||
}
|
||||
raw = json.dumps(payload).encode()
|
||||
header = mint_signature(SECRET, raw)
|
||||
return raw, header, event_id
|
||||
|
||||
def test_checkout_completed_normalizes(self) -> None:
|
||||
provider, _ = make_provider(lambda request: httpx.Response(200))
|
||||
raw, header, event_id = self._delivery("checkout.session.completed")
|
||||
verified = provider.verify_webhook(raw, header)
|
||||
assert verified.event.name == "checkout.completed"
|
||||
assert verified.provider_event_id == event_id
|
||||
|
||||
def test_vendor_payload_keys_map_to_canonical_keys(self) -> None:
|
||||
"""Final-review regression (wire desync): Stripe's vendor object
|
||||
spells the session id `id` and the subscription `subscription` —
|
||||
normalize_event must translate them onto the canonical keys
|
||||
(`session_id`, `subscription_id`) the processing route reads, or
|
||||
every real-Stripe delivery resolves no local row and lands
|
||||
unmatched with NO entitlement."""
|
||||
provider, _ = make_provider(lambda request: httpx.Response(200))
|
||||
|
||||
def delivery(event: dict[str, Any]) -> tuple[bytes, str]:
|
||||
raw = json.dumps(event).encode()
|
||||
return raw, mint_signature(SECRET, raw)
|
||||
|
||||
raw, header = delivery(
|
||||
{
|
||||
"id": "evt_c1",
|
||||
"type": "checkout.session.completed",
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "cs_real_123",
|
||||
"payment_status": "paid",
|
||||
"subscription": "sub_real_9",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
verified = provider.verify_webhook(raw, header)
|
||||
assert verified.event.name == "checkout.completed"
|
||||
assert verified.event.payload["session_id"] == "cs_real_123"
|
||||
raw, header = delivery(
|
||||
{
|
||||
"id": "evt_i1",
|
||||
"type": "invoice.payment_succeeded",
|
||||
"data": {"object": {"subscription": "sub_real_9"}},
|
||||
}
|
||||
)
|
||||
verified = provider.verify_webhook(raw, header)
|
||||
assert verified.event.name == "invoice.paid"
|
||||
assert verified.event.payload["subscription_id"] == "sub_real_9"
|
||||
|
||||
def test_session_shape_coerces_unknown_vendor_values(self) -> None:
|
||||
"""G-306 (never 500): a vendor body with unexpected status
|
||||
spellings must coerce to the honest default, never crash the
|
||||
route with a pydantic ValidationError."""
|
||||
body: dict[str, Any] = {
|
||||
"id": "cs_x",
|
||||
"status": "processing", # not in our enum
|
||||
"payment_status": "pending", # not in our enum
|
||||
"mode": "hosted", # not in our enum
|
||||
}
|
||||
session = StripePaymentProvider._session_from_api(body)
|
||||
assert session.status == "open"
|
||||
assert session.payment_status == "unpaid"
|
||||
assert session.mode == "payment"
|
||||
|
||||
def test_dual_invoice_events_map_to_invoice_paid(self) -> None:
|
||||
"""R-43: both vendor invoice events normalize to OUR
|
||||
invoice.paid (accepted as one)."""
|
||||
provider, _ = make_provider(lambda request: httpx.Response(200))
|
||||
for vendor in ("invoice.paid", "invoice.payment_succeeded"):
|
||||
raw, header, _ = self._delivery(vendor)
|
||||
assert provider.verify_webhook(raw, header).event.name == "invoice.paid"
|
||||
|
||||
def test_subscription_deleted_maps_to_cancelled(self) -> None:
|
||||
provider, _ = make_provider(lambda request: httpx.Response(200))
|
||||
raw, header, _ = self._delivery("customer.subscription.deleted")
|
||||
assert (
|
||||
provider.verify_webhook(raw, header).event.name
|
||||
== "subscription.cancelled"
|
||||
)
|
||||
|
||||
def test_unmapped_event_type_raises(self) -> None:
|
||||
provider, _ = make_provider(lambda request: httpx.Response(200))
|
||||
raw, header, _ = self._delivery("charge.refunded")
|
||||
with pytest.raises(UnknownPaymentProviderError, match="unmapped"):
|
||||
provider.verify_webhook(raw, header)
|
||||
|
||||
def test_bad_signature_raises(self) -> None:
|
||||
provider, _ = make_provider(lambda request: httpx.Response(200))
|
||||
raw, _, _ = self._delivery("invoice.paid")
|
||||
import time as _time
|
||||
|
||||
fresh_header = f"t={int(_time.time())},v1=deadbeef"
|
||||
with pytest.raises(Exception, match="no v1 candidate matches"):
|
||||
provider.verify_webhook(raw, fresh_header)
|
||||
@@ -0,0 +1,579 @@
|
||||
"""Checkout + webhook route tests (REQ-9-003, G-301/G-302/G-303).
|
||||
|
||||
The E2E payment proof without cloud (D-075): admin checkout →
|
||||
admin-gated mock complete → the REAL webhook path → entitlement exists.
|
||||
Security matrix: unsigned → 401 (never processed), bad signature →
|
||||
401, duplicate → 200 idempotent, unknown target → 200 + unmatched row
|
||||
(G-302), distinct event ids same session → one purchase (G-303).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.billing.mock import MockPaymentProvider
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
from ai_service.config import Settings
|
||||
from ai_service.main import create_app
|
||||
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
ADMIN_TOKEN = "test-billing-admin"
|
||||
SECRET = "whsec_test_billing_webhook"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
|
||||
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def make_client(
|
||||
tmp_path: Path,
|
||||
billing_store: SQLiteBillingStore,
|
||||
*,
|
||||
admin_token: str | None = ADMIN_TOKEN,
|
||||
) -> TestClient:
|
||||
"""Full app with state-injected stores + a FIXED-secret mock
|
||||
provider (deterministic signatures; the factory's per-boot random
|
||||
would make minting impossible)."""
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=SecretStr(admin_token or ""),
|
||||
webhook_secret=SECRET,
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.billing_store = billing_store # state-injection (G-9)
|
||||
app.state.payment_provider = MockPaymentProvider(webhook_secret=SECRET)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> Iterator[TestClient]:
|
||||
with make_client(tmp_path, billing_store) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
|
||||
|
||||
def minted_event(
|
||||
client: TestClient, name: str, payload: dict[str, object]
|
||||
) -> tuple[bytes, dict[str, str]]:
|
||||
"""A properly-signed webhook delivery minted by the app's OWN mock
|
||||
provider (the exact bytes + signature a provider would deliver —
|
||||
mint/verify share the scheme by construction, D-082)."""
|
||||
provider = client.app.state.payment_provider # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
raw, header, _ = provider.mint_event(name, dict(payload))
|
||||
return raw, {"Stripe-Signature": header}
|
||||
|
||||
|
||||
class TestCheckoutRoute:
|
||||
def test_fail_closed_without_token(self, client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-bundle", "employer_id": "emp-1"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_fail_closed_with_wrong_token(self, client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-bundle", "employer_id": "emp-1"},
|
||||
headers={"X-Admin-Token": "wrong"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_unknown_item_404(self, client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-nope", "employer_id": "emp-1"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_checkout_creates_session_and_local_row(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-bundle", "employer_id": "emp-1"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["mode"] == "payment"
|
||||
assert body["provider_session_id"].startswith("cs_mock_")
|
||||
assert body["url"]
|
||||
local = billing_store.get_checkout_session(body["session_id"])
|
||||
assert local is not None
|
||||
assert local.provider_session_id == body["provider_session_id"]
|
||||
assert local.employer_id == "emp-1"
|
||||
# D-078 snapshot: the row carries the catalog price at buy time.
|
||||
assert local.price_cents == 39900
|
||||
assert local.item_name == "Bundle"
|
||||
|
||||
def test_checkout_subscription_mode(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "plan-pro", "employer_id": "emp-2"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["mode"] == "subscription"
|
||||
|
||||
def test_two_checkouts_never_collide_on_the_placeholder(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""Final-review regression: the pre-bind provider_session_id
|
||||
placeholder must be UNIQUE per row — two checkouts (two admins,
|
||||
or one double-click racing the provider call) collided on the
|
||||
UNIQUE constraint and the second POST 500'd."""
|
||||
first = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-single", "employer_id": "emp-a"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
second = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-single", "employer_id": "emp-b"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
assert second.status_code == 201, second.text
|
||||
assert (
|
||||
first.json()["provider_session_id"]
|
||||
!= second.json()["provider_session_id"]
|
||||
)
|
||||
|
||||
|
||||
class TestWebhookSecurity:
|
||||
"""MH-3a: unsigned → 401, never processed. G-302: unknown targets
|
||||
→ 200 + unmatched, no entitlement. G-303: distinct event ids, same
|
||||
session → one purchase."""
|
||||
|
||||
def _checkout(self, client: TestClient, employer: str = "emp-1") -> str:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-bundle", "employer_id": employer},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["provider_session_id"]
|
||||
|
||||
def test_unsigned_webhook_401(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/webhook",
|
||||
content=b'{"id": "evt_x", "type": "checkout.completed"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
# Never processed: zero provider events recorded.
|
||||
assert billing_store.list_billing_events(kind="provider") == []
|
||||
|
||||
def test_bad_signature_401(self, client: TestClient) -> None:
|
||||
import time
|
||||
|
||||
response = client.post(
|
||||
"/v1/billing/webhook",
|
||||
content=b'{"id": "evt_x", "type": "checkout.completed"}',
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Stripe-Signature": f"t={int(time.time())},v1=deadbeef",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_valid_delivery_grants_entitlement(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
provider_session_id = self._checkout(client)
|
||||
raw, headers = minted_event(
|
||||
client,
|
||||
"checkout.completed",
|
||||
{"session_id": provider_session_id, "payment_status": "paid"},
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/billing/webhook", content=raw, headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
entitlement = billing_store.get_entitlement("emp-1")
|
||||
assert entitlement["purchase"] is not None
|
||||
assert entitlement["purchase"]["posts_limit"] == 10
|
||||
assert entitlement["purchase"]["package_name"] == "Bundle"
|
||||
|
||||
def test_duplicate_delivery_idempotent(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-3b: same event delivered twice → 200, single entitlement."""
|
||||
provider_session_id = self._checkout(client)
|
||||
raw, headers = minted_event(
|
||||
client,
|
||||
"checkout.completed",
|
||||
{"session_id": provider_session_id, "payment_status": "paid"},
|
||||
)
|
||||
first = client.post("/v1/billing/webhook", content=raw, headers=headers)
|
||||
second = client.post("/v1/billing/webhook", content=raw, headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
# One purchase — the dedupe refused the replay.
|
||||
events = billing_store.list_billing_events(kind="provider")
|
||||
grants = [e for e in events if e.name == "checkout.completed"]
|
||||
assert len(grants) == 1
|
||||
|
||||
def test_distinct_event_ids_same_session_one_purchase(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""G-303: two DIFFERENT provider event ids announcing the same
|
||||
completion (Stripe's dual invoice events) → exactly one
|
||||
purchase row."""
|
||||
provider_session_id = self._checkout(client)
|
||||
for _n in (1, 2):
|
||||
raw, headers = minted_event(
|
||||
client,
|
||||
"checkout.completed",
|
||||
{"session_id": provider_session_id, "payment_status": "paid"},
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/billing/webhook", content=raw, headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
entitlement = billing_store.get_entitlement("emp-1")
|
||||
assert entitlement["purchase"] is not None
|
||||
# posts_used still 0 — ONE grant, not two stacked purchases.
|
||||
assert entitlement["purchase"]["posts_used"] == 0
|
||||
|
||||
def test_unknown_session_unmatched_no_entitlement(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""MH-3f (G-302): a signed event for a session the admin never
|
||||
created → 200, zero purchases, one unmatched audit row."""
|
||||
raw, headers = minted_event(
|
||||
client,
|
||||
"checkout.completed",
|
||||
{"session_id": "cs_mock_forged", "payment_status": "paid"},
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/billing/webhook", content=raw, headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert billing_store.get_entitlement("emp-1")["purchase"] is None
|
||||
unmatched = billing_store.list_billing_events(kind="unmatched")
|
||||
assert len(unmatched) == 1
|
||||
assert unmatched[0].name == "checkout.completed"
|
||||
|
||||
def test_inflated_payload_amount_ignored(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""G-302: the payload is an ID reference — an inflated amount
|
||||
field never rewrites the stored snapshot (D-078)."""
|
||||
provider_session_id = self._checkout(client)
|
||||
raw, headers = minted_event(
|
||||
client,
|
||||
"checkout.completed",
|
||||
{
|
||||
"session_id": provider_session_id,
|
||||
"payment_status": "paid",
|
||||
"amount_total": 1,
|
||||
"price_cents": 1,
|
||||
},
|
||||
)
|
||||
client.post("/v1/billing/webhook", content=raw, headers=headers)
|
||||
entitlement = billing_store.get_entitlement("emp-1")
|
||||
assert entitlement["purchase"]["posts_limit"] == 10 # snapshot
|
||||
# price stays the $399 snapshot — no field on the entitlement
|
||||
# exposes payload amounts; the purchase row is the proof:
|
||||
purchase = billing_store.get_entitlement("emp-1")["purchase"]
|
||||
assert purchase is not None
|
||||
|
||||
|
||||
class TestMockCompleteE2E:
|
||||
"""G-301 + MH-3c: admin checkout → ADMIN-GATED mock complete → the
|
||||
REAL webhook path → entitlement exists. No-token → 403."""
|
||||
|
||||
def _checkout(self, client: TestClient, employer: str = "emp-e2e") -> str:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-single", "employer_id": employer},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["provider_session_id"]
|
||||
|
||||
def test_complete_without_token_403(self, client: TestClient) -> None:
|
||||
provider_session_id = self._checkout(client)
|
||||
response = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": provider_session_id},
|
||||
)
|
||||
assert response.status_code == 403 # G-301: admin-gated
|
||||
|
||||
def test_e2e_checkout_complete_entitlement(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
provider_session_id = self._checkout(client)
|
||||
response = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": provider_session_id},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["completed"] is True
|
||||
assert response.json()["payment_status"] == "paid"
|
||||
# The REAL webhook path ran: the entitlement exists (MH-3c).
|
||||
entitlement = billing_store.get_entitlement("emp-e2e")
|
||||
assert entitlement["purchase"] is not None
|
||||
assert entitlement["purchase"]["posts_limit"] == 1
|
||||
# The provider event landed (signed delivery recorded).
|
||||
provider_events = billing_store.list_billing_events(kind="provider")
|
||||
assert any(e.name == "checkout.completed" for e in provider_events)
|
||||
|
||||
def test_e2e_subscription_creates_subscription(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "plan-pro", "employer_id": "emp-sub"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
provider_session_id = response.json()["provider_session_id"]
|
||||
complete = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": provider_session_id},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert complete.status_code == 200
|
||||
entitlement = billing_store.get_entitlement("emp-sub")
|
||||
assert entitlement["subscription"] is not None
|
||||
assert entitlement["subscription"]["status"] == "active"
|
||||
assert entitlement["subscription"]["plan_name"] == "Pro"
|
||||
|
||||
def test_complete_unknown_session_404(self, client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": "cs_mock_ghost"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_double_complete_idempotent_never_500(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""G-301 idempotency (the route owns it — the provider's pinned
|
||||
contract raises on re-completion): an operator double-click /
|
||||
retry re-completes an ALREADY-complete session → 200, exactly
|
||||
ONE purchase (G-303 per-session exactly-once), never a 500."""
|
||||
provider_session_id = self._checkout(client)
|
||||
first = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": provider_session_id},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
second = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": provider_session_id},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["completed"] is True
|
||||
# Exactly one purchase — the replayed completion event could
|
||||
# not double-grant (G-303's UniqueConstraint).
|
||||
purchases = billing_store.get_entitlement("emp-e2e")
|
||||
assert purchases["purchase"]["posts_limit"] == 1
|
||||
events = billing_store.list_billing_events(kind="unmatched")
|
||||
assert any(
|
||||
e.name == "checkout.completed" and "already granted" in str(e.detail)
|
||||
for e in events
|
||||
)
|
||||
|
||||
def test_crash_between_processing_and_dedupe_recovers(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore, monkeypatch
|
||||
) -> None:
|
||||
"""Final-review regression (at-least-once + grant-loss): if the
|
||||
process dies AFTER the grant but BEFORE the event id was
|
||||
recorded, the provider's retry must still land the entitlement
|
||||
(the dedupe key is written after the effects; the replay is a
|
||||
no-op via G-303). Under the old order the retry returned
|
||||
'duplicate' and the grant was lost forever."""
|
||||
provider_session_id = self._checkout(client)
|
||||
raw, headers = minted_event(
|
||||
client,
|
||||
"checkout.completed",
|
||||
{"session_id": provider_session_id, "payment_status": "paid"},
|
||||
)
|
||||
# Crash exactly between effects and the dedupe write.
|
||||
original_insert = billing_store.insert_provider_event
|
||||
|
||||
def crash_then_insert(event_id, event_name, payload):
|
||||
raise RuntimeError("simulated crash before the dedupe write")
|
||||
|
||||
monkeypatch.setattr(
|
||||
billing_store, "insert_provider_event", crash_then_insert
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
client.post("/v1/billing/webhook", content=raw, headers=headers)
|
||||
# The grant already happened (effects first).
|
||||
assert billing_store.get_entitlement("emp-e2e")["purchase"] is not None
|
||||
monkeypatch.setattr(
|
||||
billing_store, "insert_provider_event", original_insert
|
||||
)
|
||||
# The retry: dedupe records the id, the replayed grant is a
|
||||
# G-303 no-op — the entitlement SURVIVES (one purchase).
|
||||
response = client.post("/v1/billing/webhook", content=raw, headers=headers)
|
||||
assert response.status_code == 200
|
||||
entitlement = billing_store.get_entitlement("emp-e2e")
|
||||
assert entitlement["purchase"] is not None
|
||||
assert entitlement["purchase"]["posts_limit"] == 1
|
||||
|
||||
def test_unpaid_completion_never_grants(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""Final-review regression (R-37): a signed checkout.completed
|
||||
delivery whose payment_status is unpaid/processing records the
|
||||
delivery but grants NOTHING — status alone never grants; a
|
||||
later paid delivery grants exactly once."""
|
||||
provider_session_id = self._checkout(client)
|
||||
raw, headers = minted_event(
|
||||
client,
|
||||
"checkout.completed",
|
||||
{"session_id": provider_session_id, "payment_status": "unpaid"},
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/billing/webhook", content=raw, headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert billing_store.get_entitlement("emp-e2e")["purchase"] is None
|
||||
# The paid delivery grants.
|
||||
raw2, headers2 = minted_event(
|
||||
client,
|
||||
"checkout.completed",
|
||||
{"session_id": provider_session_id, "payment_status": "paid"},
|
||||
)
|
||||
response2 = client.post(
|
||||
"/v1/billing/webhook", content=raw2, headers=headers2
|
||||
)
|
||||
assert response2.status_code == 200
|
||||
assert billing_store.get_entitlement("emp-e2e")["purchase"] is not None
|
||||
|
||||
def test_complete_lost_provider_state_404_not_500(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""A restart clears the mock's in-memory sessions while the
|
||||
local row persists — completing the stale session must be an
|
||||
honest 404, never a raw 500 (G-306)."""
|
||||
provider_session_id = self._checkout(client)
|
||||
# Simulate the restart: a fresh provider instance (empty
|
||||
# in-memory session map) under the same secret.
|
||||
client.app.state.payment_provider = MockPaymentProvider( # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
webhook_secret=SECRET
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": provider_session_id},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "no longer knows" in response.json()["detail"]
|
||||
|
||||
def test_mock_complete_404s_under_stripe_provider(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""G-301 (second arm): on a stripe-configured box the
|
||||
mock-complete route must be GONE — the isinstance gate is the
|
||||
defense-in-depth behind main.py's mount story (the router
|
||||
mounts whole; the route refuses under a non-mock provider)."""
|
||||
import httpx
|
||||
|
||||
from ai_service.billing.stripe import StripePaymentProvider
|
||||
|
||||
provider_session_id = self._checkout(client)
|
||||
client.app.state.payment_provider = StripePaymentProvider( # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
http_client=httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(
|
||||
lambda request: httpx.Response(200, json={})
|
||||
)
|
||||
),
|
||||
api_key="sk_test_never_sent",
|
||||
webhook_secret=SECRET,
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/billing/admin/mock/complete",
|
||||
json={"provider_session_id": provider_session_id},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404 # never reachable under stripe
|
||||
|
||||
def test_signed_unknown_event_type_200_unmatched(
|
||||
self, client: TestClient, billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""G-306 (never 500): a correctly-SIGNED delivery of an event
|
||||
type outside our canonical vocabulary (Stripe's real streams
|
||||
deliver many types — charge.refunded etc.) is honest unmatched
|
||||
audit material: 200 + one unmatched row, never a crashed
|
||||
receiver (Stripe disables endpoints on error storms)."""
|
||||
raw, headers = minted_event(
|
||||
client, "charge.refunded", {"session_id": "cs_mock_any"}
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/billing/webhook", content=raw, headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
unmatched = billing_store.list_billing_events(kind="unmatched")
|
||||
assert len(unmatched) == 1
|
||||
assert unmatched[0].name == "unmapped.event"
|
||||
assert "unmapped provider event" in str(unmatched[0].detail)
|
||||
|
||||
|
||||
class TestPaymentBootFallback:
|
||||
"""G-11 (the payment half, voice/embedding precedent): a
|
||||
misconfigured stripe selection must never crash the unattended boot
|
||||
— the app comes up on the mock provider with the loud fix-pointer
|
||||
log (test_search.py's TestBootFallback pattern)."""
|
||||
|
||||
def test_stripe_unconfigured_boots_with_mock_and_loud_log(
|
||||
self, tmp_path: Path, billing_store: SQLiteBillingStore, caplog
|
||||
) -> None:
|
||||
import logging
|
||||
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "boot.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
webhook_secret=SECRET,
|
||||
payment_provider="stripe",
|
||||
payment_api_key=SecretStr("sk_test_unset_origin"),
|
||||
# public_base_url intentionally unset → G-310 validation
|
||||
# fails → the G-11 fallback must engage.
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.billing_store = billing_store
|
||||
with caplog.at_level(logging.WARNING, logger="ai_service.main"):
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/health").status_code == 200
|
||||
assert isinstance(
|
||||
app.state.payment_provider, MockPaymentProvider
|
||||
)
|
||||
assert any(
|
||||
"payment provider" in r.message and "falling back to mock" in r.message
|
||||
for r in caplog.records
|
||||
), "the fallback must be LOUD (G-11)"
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Webhook HMAC verification matrix (Task 1-1-04, D-077, R-38..R-40).
|
||||
|
||||
Pins (each maps to a security property of webhooks.py):
|
||||
- good signature verifies, returns the parsed (t, v1) header
|
||||
- tampered body fails (the signature binds the exact raw bytes)
|
||||
- missing header fails; empty string header fails the same way
|
||||
- stale timestamp (outside the ±300s window) fails; fresh passes
|
||||
- multiple v1 candidates: any match wins (secret rolls, R-38)
|
||||
- v0-only header rejected (non-v1 schemes ignored — downgrade protection)
|
||||
- tampered timestamp fails (t is bound into the signed payload)
|
||||
- unknown scheme elements never crash the parse (ignored, not fatal)
|
||||
- replay of the SAME delivery verifies twice (idempotent at the verify
|
||||
layer — dedupe is the store/route's job, R-45)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_service.billing.base import WebhookSignatureError
|
||||
from ai_service.billing.webhooks import mint, verify
|
||||
|
||||
SECRET = "whsec_test_secret_123"
|
||||
OTHER_SECRET = "whsec_rotated_secret_456"
|
||||
BODY = b'{"id": "evt_1", "name": "invoice.paid", "payload": {}}'
|
||||
NOW = 1_800_000_000.0
|
||||
|
||||
|
||||
class TestGoodSignatures:
|
||||
def test_good_signature_verifies(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
timestamp, v1s = verify(BODY, header, SECRET, now=NOW)
|
||||
assert timestamp is not None
|
||||
assert len(v1s) == 1
|
||||
|
||||
def test_header_shape_t_and_v1(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
# R-38's wire shape: t=<unix_seconds>,v1=<hex> on one line.
|
||||
assert header.startswith("t=1800000000,v1=")
|
||||
assert len(header.split("v1=", 1)[1]) == 64 # sha256 hex
|
||||
|
||||
def test_mint_uses_wall_clock_by_default(self) -> None:
|
||||
before = time.time()
|
||||
header = mint(SECRET, BODY)
|
||||
minted_t = int(header.split(",", 1)[0].removeprefix("t="))
|
||||
assert before - 1 <= minted_t <= time.time() + 1
|
||||
|
||||
def test_verify_uses_wall_clock_by_default(self) -> None:
|
||||
header = mint(SECRET, BODY) # stamped with the real now
|
||||
verify(BODY, header, SECRET) # must be inside the window
|
||||
|
||||
def test_same_delivery_replays_green(self) -> None:
|
||||
# R-45: at-least-once delivery — the SAME signed bytes verify
|
||||
# any number of times; dedupe is downstream's concern.
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestTampering:
|
||||
def test_tampered_body_fails(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY + b"x", header, SECRET, now=NOW)
|
||||
|
||||
def test_wrong_secret_fails(self) -> None:
|
||||
header = mint(OTHER_SECRET, BODY, now=NOW)
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_tampered_timestamp_fails(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
minted_v1 = header.split("v1=", 1)[1]
|
||||
# t participates in the signed payload: moving it without the
|
||||
# secret invalidates v1 (R-40's attacker-immutable t).
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY, f"t={int(NOW) + 1},v1={minted_v1}", SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestMissingHeader:
|
||||
def test_missing_header_fails(self) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="missing"):
|
||||
verify(BODY, None, SECRET, now=NOW)
|
||||
|
||||
def test_empty_header_fails(self) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="missing"):
|
||||
verify(BODY, "", SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestReplayWindow:
|
||||
def test_stale_timestamp_fails(self) -> None:
|
||||
# 400s old — outside the 300s window (R-40, risk-4: injected now,
|
||||
# no sleeps).
|
||||
header = mint(SECRET, BODY, now=NOW - 400)
|
||||
with pytest.raises(WebhookSignatureError, match="stale timestamp"):
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_future_timestamp_fails(self) -> None:
|
||||
# Clock-skew guard cuts both ways: a future-stamped delivery is
|
||||
# also outside the window.
|
||||
header = mint(SECRET, BODY, now=NOW + 400)
|
||||
with pytest.raises(WebhookSignatureError, match="stale timestamp"):
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_fresh_inside_window_passes(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW - 299)
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_malformed_timestamp_fails(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
minted_v1 = header.split("v1=", 1)[1]
|
||||
with pytest.raises(WebhookSignatureError, match="timestamp"):
|
||||
verify(BODY, f"t=not-a-number,v1={minted_v1}", SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestMultiV1SecretRolls:
|
||||
def test_multiple_v1_any_match_wins(self) -> None:
|
||||
# R-38/R-53 risk-5: a secret roll sends both the old and new
|
||||
# signatures; verify iterates ALL v1 candidates.
|
||||
old_sig = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
new_sig = mint(OTHER_SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
header = f"t={int(NOW)},v1={old_sig},v1={new_sig}"
|
||||
timestamp, v1s = verify(BODY, header, OTHER_SECRET, now=NOW)
|
||||
assert timestamp == int(NOW)
|
||||
assert len(v1s) == 2
|
||||
|
||||
def test_old_secret_still_verifies_during_roll(self) -> None:
|
||||
old_sig = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
new_sig = mint(OTHER_SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
header = f"t={int(NOW)},v1={old_sig},v1={new_sig}"
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_all_v1_wrong_fails(self) -> None:
|
||||
a = mint("whsec_a", BODY, now=NOW).split("v1=", 1)[1]
|
||||
b = mint("whsec_b", BODY, now=NOW).split("v1=", 1)[1]
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY, f"t={int(NOW)},v1={a},v1={b}", SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestNonV1Schemes:
|
||||
def test_v0_only_rejected(self) -> None:
|
||||
# R-38: a v0-only header (Stripe test events) has NO v1 to
|
||||
# check — ignored, never trusted (downgrade protection).
|
||||
with pytest.raises(WebhookSignatureError, match="no v1 signature"):
|
||||
verify(BODY, f"t={int(NOW)},v0=deadbeef", SECRET, now=NOW)
|
||||
|
||||
def test_unknown_scheme_ignored_not_fatal(self) -> None:
|
||||
# Unknown scheme elements are skipped; a valid v1 among them
|
||||
# still verifies (forward compatibility without downgrade).
|
||||
v1 = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
header = f"t={int(NOW)},v9=abc,v1={v1},v0=deadbeef"
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_v1_empty_value_fails(self) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY, f"t={int(NOW)},v1=", SECRET, now=NOW)
|
||||
|
||||
def test_garbage_header_no_v1_fails(self) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="no v1 signature"):
|
||||
verify(BODY, "not a signature header at all", SECRET, now=NOW)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Employer module tests (REQ-10-001) — store + KDF + contracts.
|
||||
|
||||
The module is proven standalone (no app, no routes, zero network):
|
||||
tmp_path DB store mechanics, REAL scrypt KDF, G-315 contract pins.
|
||||
The auth-route suite (test_auth.py) joins with Wave 1-2.
|
||||
"""
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Employers test fixtures (Task 1-2-01/02, REQ-10-001).
|
||||
|
||||
R-64: the tests/billing/ layout — module-local make_client + tmp_path
|
||||
stores. G-313 (Task 1-2-02): this conftest carries a suite-wide
|
||||
autouse fixture clearing BOTH throttle limiter module states — the
|
||||
entire suite runs inside one 60s window and would self-429 against
|
||||
the global cap otherwise (the _reset_apply_rate_window pattern,
|
||||
tests/marketplace/conftest.py:123-131). The sanctioned deviation from
|
||||
billing's no-conftest layout (R-64).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.config import Settings
|
||||
from ai_service.employers.store import SQLiteEmployerStore
|
||||
from ai_service.main import create_app
|
||||
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
#: Throwaway credentials (secrets policy: no real value in any file).
|
||||
TEST_EMAIL = "founder@nextcraft.example"
|
||||
TEST_PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_employer_limiters() -> Iterator[None]:
|
||||
"""G-313: BOTH limiter module states cleared before/after EVERY
|
||||
employers test — module-level process state must never leak
|
||||
between tests (the whole suite runs inside one 60s window and
|
||||
would self-429 against the global cap otherwise)."""
|
||||
from ai_service.api import employers as employers_api
|
||||
|
||||
employers_api._LOGIN_FAILURES.clear()
|
||||
employers_api._AUTH_ATTEMPTS.clear()
|
||||
yield
|
||||
employers_api._LOGIN_FAILURES.clear()
|
||||
employers_api._AUTH_ATTEMPTS.clear()
|
||||
|
||||
|
||||
def make_client(
|
||||
tmp_path: Path,
|
||||
store: SQLiteEmployerStore | None = None,
|
||||
**settings_overrides: object,
|
||||
) -> TestClient:
|
||||
"""Full app with the employer store state-injected (the G-9
|
||||
override the lifespan's getattr-guard adopts)."""
|
||||
if store is None:
|
||||
store = SQLiteEmployerStore(db_path=tmp_path / "employers.db")
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
**settings_overrides, # type: ignore[arg-type]
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.employer_store = store # state-injection (G-9)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def employer_store(tmp_path: Path) -> Iterator[SQLiteEmployerStore]:
|
||||
store = SQLiteEmployerStore(db_path=tmp_path / "employers.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(employer_store: SQLiteEmployerStore, tmp_path: Path) -> Iterator[TestClient]:
|
||||
with make_client(tmp_path, employer_store) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def register(
|
||||
client: TestClient,
|
||||
email: str = TEST_EMAIL,
|
||||
password: str = TEST_PASSWORD,
|
||||
) -> dict:
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register", json={"email": email, "password": password}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def login(
|
||||
client: TestClient,
|
||||
email: str = TEST_EMAIL,
|
||||
password: str = TEST_PASSWORD,
|
||||
) -> dict:
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/login", json={"email": email, "password": password}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def bearer(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Employer auth route tests (Task 1-2-01, REQ-10-001).
|
||||
|
||||
REAL scrypt throughout (D-096/R-64: no fake-hash path — ≈2 hashes
|
||||
≈100 ms/test, honest). Pins: register→login→me→logout flow green;
|
||||
duplicate register 409; wrong password 401 (no user-enumeration
|
||||
difference); logout revokes; expired token 401 (injected now, no
|
||||
sleeps); no-token 401; concurrent sessions per account allowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.employers.store import SQLiteEmployerStore
|
||||
|
||||
from .conftest import TEST_EMAIL, TEST_PASSWORD, bearer, login, register
|
||||
|
||||
|
||||
class TestAuthFlow:
|
||||
def test_register_login_me_logout_flow_green(self, client: TestClient) -> None:
|
||||
"""The headline flow: register (201 + session) → login (fresh
|
||||
session) → me (account, unverified) → logout revokes."""
|
||||
reg = register(client)
|
||||
assert reg["token"]
|
||||
assert reg["expires_at"]
|
||||
log = login(client)
|
||||
assert log["token"] and log["token"] != reg["token"]
|
||||
me = client.get("/v1/employers/auth/me", headers=bearer(log["token"]))
|
||||
assert me.status_code == 200, me.text
|
||||
body = me.json()
|
||||
assert body["account"]["email"] == TEST_EMAIL
|
||||
assert body["account"]["email_verified"] is False # D-097
|
||||
assert body["claimed_employer_id"] is None # no claim filed
|
||||
assert body["claim"] is None # no claim filed
|
||||
out = client.post("/v1/employers/auth/logout", headers=bearer(log["token"]))
|
||||
assert out.status_code == 200
|
||||
assert out.json() == {"revoked": True}
|
||||
after = client.get("/v1/employers/auth/me", headers=bearer(log["token"]))
|
||||
assert after.status_code == 401
|
||||
|
||||
def test_register_returns_live_session(self, client: TestClient) -> None:
|
||||
"""The 201's token IS a live session (registration logs in)."""
|
||||
reg = register(client)
|
||||
me = client.get("/v1/employers/auth/me", headers=bearer(reg["token"]))
|
||||
assert me.status_code == 200
|
||||
|
||||
def test_duplicate_register_409(self, client: TestClient) -> None:
|
||||
register(client, email="dupe@example.com")
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "dupe@example.com", "password": TEST_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_wrong_password_401(self, client: TestClient) -> None:
|
||||
register(client)
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": TEST_EMAIL, "password": "wrong-password-123"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_no_user_enumeration_difference(self, client: TestClient) -> None:
|
||||
"""Unknown email and wrong password produce the SAME 401 body
|
||||
(D-090: no account-existence signal beyond the 401)."""
|
||||
register(client)
|
||||
unknown = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": "ghost@example.com", "password": "wrong-password-123"},
|
||||
)
|
||||
wrong = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": TEST_EMAIL, "password": "wrong-password-123"},
|
||||
)
|
||||
assert unknown.status_code == 401
|
||||
assert wrong.status_code == 401
|
||||
assert unknown.json() == wrong.json()
|
||||
|
||||
def test_email_normalized_on_login(
|
||||
self, client: TestClient, tmp_path
|
||||
) -> None:
|
||||
"""G-315: register "User@Example.COM" → login with the raw
|
||||
casing still finds the normalized account."""
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": " User@Example.COM ", "password": TEST_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": "user@example.com", "password": TEST_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestSessionGate:
|
||||
def test_no_token_401(self, client: TestClient) -> None:
|
||||
assert client.get("/v1/employers/auth/me").status_code == 401
|
||||
resp = client.get(
|
||||
"/v1/employers/auth/me", headers={"Authorization": "Bearer "}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_unknown_token_401(self, client: TestClient) -> None:
|
||||
resp = client.get("/v1/employers/auth/me", headers=bearer("no-such-token"))
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_non_bearer_scheme_401(self, client: TestClient) -> None:
|
||||
resp = client.get(
|
||||
"/v1/employers/auth/me", headers={"Authorization": "Basic abc"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_expired_token_401_injected_now(
|
||||
self, employer_store: SQLiteEmployerStore, tmp_path
|
||||
) -> None:
|
||||
"""G-306 via the store's injectable now: mint at T, read at
|
||||
T+8d → the session reads as absent → 401 (no sleeps)."""
|
||||
from .conftest import make_client
|
||||
|
||||
# Wall-clock-relative mint: the 200 arm below must hold on ANY
|
||||
# run date (the fixed 2026-09-30 mint turned red at 2026-10-07+).
|
||||
login_at = datetime.now(UTC) - timedelta(days=1)
|
||||
employer_store.insert_account(
|
||||
email=TEST_EMAIL, password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA"
|
||||
)
|
||||
account = employer_store.get_account_by_email(TEST_EMAIL)
|
||||
assert account is not None
|
||||
employer_store.mint_session(
|
||||
account_id=account.id, token="expired-token", now=login_at
|
||||
)
|
||||
with make_client(tmp_path, employer_store) as c:
|
||||
# The route asks the store WITHOUT now → wall-clock. Inject
|
||||
# the future read by calling the store directly with the
|
||||
# expired timestamp to prove the row is past-TTL, then the
|
||||
# route's live lookup of a wall-clock-valid token.
|
||||
eight_days = login_at + timedelta(days=8)
|
||||
row = employer_store.get_session_by_token_hash(
|
||||
"expired-token", now=eight_days
|
||||
)
|
||||
assert row is None # expired — reads as absent
|
||||
resp = c.get("/v1/employers/auth/me", headers=bearer("expired-token"))
|
||||
# Wall clock (1d after mint) is INSIDE the 7-day TTL → 200.
|
||||
assert resp.status_code == 200
|
||||
# The lazy-TTL route 401 pin: a session whose expires_at is in
|
||||
# the WALL-CLOCK past reads as absent at the route.
|
||||
past_login = datetime.now(UTC) - timedelta(days=8)
|
||||
employer_store.mint_session(
|
||||
account_id=account.id, token="long-expired", now=past_login
|
||||
)
|
||||
with make_client(tmp_path, employer_store) as c:
|
||||
resp = c.get("/v1/employers/auth/me", headers=bearer("long-expired"))
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_logout_unknown_or_revoked_401(self, client: TestClient) -> None:
|
||||
assert (
|
||||
client.post("/v1/employers/auth/logout", headers=bearer("nope")).status_code
|
||||
== 401
|
||||
)
|
||||
reg = register(client)
|
||||
assert (
|
||||
client.post("/v1/employers/auth/logout", headers=bearer(reg["token"]))
|
||||
).status_code == 200
|
||||
# Revoked: a second logout attempt 401s (the row is gone).
|
||||
assert (
|
||||
client.post("/v1/employers/auth/logout", headers=bearer(reg["token"]))
|
||||
.status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
def test_logout_no_token_401(self, client: TestClient) -> None:
|
||||
assert client.post("/v1/employers/auth/logout").status_code == 401
|
||||
|
||||
|
||||
class TestMeClaimState:
|
||||
"""P6-review fix pin: /me carries the account's CLAIM state — the
|
||||
P3 surfaces' claim-pending branch (employer-overview-client.tsx's
|
||||
claimPending) consumed me.claim, but the route hardcoded None: a
|
||||
pending claimer saw the dishonest no-entity claim-CTA. The claim
|
||||
state rides /me end-to-end here."""
|
||||
|
||||
def test_me_pending_claim_visible(self, client: TestClient) -> None:
|
||||
"""A filed-but-undecided claim rides /me as pending — the
|
||||
dashboard renders the honest awaiting-review state from it."""
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id="emp-me-pin",
|
||||
source="seed-corpus",
|
||||
external_id="emp-me-pin",
|
||||
name="Me Labs",
|
||||
website="https://example.com/me",
|
||||
)
|
||||
)
|
||||
token = register(client)["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-me-pin"},
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
me = client.get("/v1/employers/auth/me", headers=bearer(token))
|
||||
assert me.status_code == 200
|
||||
body = me.json()
|
||||
assert body["claimed_employer_id"] is None # still pending
|
||||
claim = body["claim"]
|
||||
assert claim is not None # the P6 fix — the state is visible
|
||||
assert claim["status"] == "pending"
|
||||
assert claim["employer_id"] == "emp-me-pin"
|
||||
assert claim["evidence"]["match"] is False # advisory, stored
|
||||
|
||||
def test_me_approved_claim_rides_with_entity(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""Approved: claimed_employer_id AND the approved claim both
|
||||
ride /me (one honest read for the claimed surface)."""
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id="emp-me-approved",
|
||||
source="seed-corpus",
|
||||
external_id="emp-me-approved",
|
||||
name="Approved Labs",
|
||||
website="https://example.com/approved",
|
||||
)
|
||||
)
|
||||
token = register(client)["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-me-approved"},
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
decided = employer_store.decide_claim(
|
||||
resp.json()["id"], decision="approved", operator="op-1"
|
||||
)
|
||||
assert decided is not None
|
||||
me = client.get("/v1/employers/auth/me", headers=bearer(token))
|
||||
body = me.json()
|
||||
assert body["claimed_employer_id"] == "emp-me-approved"
|
||||
assert body["claim"]["status"] == "approved"
|
||||
|
||||
def test_me_rejected_claim_stays_visible_until_superseded(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""A rejected claim is the honest claim state (never hidden —
|
||||
the account owns no entity but its latest claim was rejected);
|
||||
a NEW claim on another entity supersedes it in /me."""
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
for eid in ("emp-me-rej-a", "emp-me-rej-b"):
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=eid,
|
||||
source="seed-corpus",
|
||||
external_id=eid,
|
||||
name=f"Rej {eid}",
|
||||
website=f"https://example.com/{eid}",
|
||||
)
|
||||
)
|
||||
token = register(client)["token"]
|
||||
first = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-me-rej-a"},
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert first.status_code == 201
|
||||
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
employer_store.decide_claim(
|
||||
first.json()["id"], decision="rejected", operator="op-1"
|
||||
)
|
||||
me = client.get("/v1/employers/auth/me", headers=bearer(token))
|
||||
assert me.json()["claim"]["status"] == "rejected"
|
||||
# A new claim supersedes: the newest claim rides /me.
|
||||
second = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-me-rej-b"},
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert second.status_code == 201
|
||||
me = client.get("/v1/employers/auth/me", headers=bearer(token))
|
||||
assert me.json()["claim"]["status"] == "pending"
|
||||
assert me.json()["claim"]["employer_id"] == "emp-me-rej-b"
|
||||
|
||||
|
||||
class TestConcurrentSessions:
|
||||
def test_two_logins_both_me_green_one_logout_leaves_other(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""Concurrent sessions per account are allowed; logout is
|
||||
per-token (revoking one leaves the other alive)."""
|
||||
register(client)
|
||||
first = login(client)["token"]
|
||||
second = login(client)["token"]
|
||||
assert first != second
|
||||
assert (
|
||||
client.get("/v1/employers/auth/me", headers=bearer(first)).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
client.get("/v1/employers/auth/me", headers=bearer(second)).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.post("/v1/employers/auth/logout", headers=bearer(first)).status_code == 200
|
||||
assert (
|
||||
client.get("/v1/employers/auth/me", headers=bearer(first)).status_code
|
||||
== 401
|
||||
)
|
||||
assert (
|
||||
client.get("/v1/employers/auth/me", headers=bearer(second)).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
class TestAuditTrail:
|
||||
def test_register_and_logout_append_audit_events(
|
||||
self, client: TestClient, employer_store: SQLiteEmployerStore
|
||||
) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
reg = register(client)
|
||||
client.post("/v1/employers/auth/logout", headers=bearer(reg["token"]))
|
||||
with employer_store._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT name, account_id FROM employer_audit_events "
|
||||
"ORDER BY id"
|
||||
)
|
||||
).fetchall()
|
||||
names = {row[0] for row in rows}
|
||||
assert "register" in names
|
||||
assert "logout" in names
|
||||
assert all(row[1] is not None for row in rows)
|
||||
|
||||
|
||||
class TestPiiScrub:
|
||||
def test_invalid_register_422_never_echoes_password(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""R-56/open-risk-3 pin: a pydantic 422 on register must never
|
||||
echo the submitted password into the response (err["input"])
|
||||
— the pii_fields scrubber redacts password + email inputs."""
|
||||
poison = "super-secret-password-123"
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "not-an-email", "password": poison},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert poison not in resp.text, "422 must not echo the raw password"
|
||||
# The scrubber redacts the offending input (never the msg).
|
||||
detail = resp.json()["detail"]
|
||||
assert all(err.get("input") != poison for err in detail)
|
||||
|
||||
def test_invalid_login_422_never_echoes_password(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""Same pin on the login route — both auth bodies scrub."""
|
||||
poison = "another-secret-password-456"
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": "x@example.com", "password": poison[:4]}, # too short
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert poison[:4] not in resp.text
|
||||
|
||||
def test_non_dict_body_422_never_echoes_password(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""Security-audit pin (task 1-3-01): a NON-DICT body (list/str)
|
||||
errors at loc == ("body",) with input = the ENTIRE payload verbatim
|
||||
— the field arm of the scrubber never fires. The body-level arm
|
||||
redacts the echo (the raw password never rides the 422)."""
|
||||
poison = "whole-body-secret-789"
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
json=[{"email": "a@example.com", "password": poison}],
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert poison not in resp.text, "list-body 422 must not echo the payload"
|
||||
assert all(
|
||||
err.get("input") == "[redacted]" or "password" not in str(err.get("input"))
|
||||
for err in resp.json()["detail"]
|
||||
)
|
||||
# String body: same shape (a JSON-encoded string payload).
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
content=f'"password={poison}"',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert poison not in resp.text
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Employer contract tests (Task 1-1-01, REQ-10-001, G-315/D-097).
|
||||
|
||||
Pins:
|
||||
- G-315: stdlib-only email validation — non-empty, exactly one "@",
|
||||
non-empty domain, stripped + lowercased on normalize; pydantic
|
||||
EmailStr is BANNED (no email-validator dependency)
|
||||
- D-097: `email_verified` is Literal[False] — the field can never carry
|
||||
True; an account is displayed as unverified
|
||||
- D-086: password min length 8 (the honest validation floor)
|
||||
- claim status vocabulary is CLOSED (D-089): pending|approved|rejected
|
||||
- wire shapes: TokenResponse + EmployerMe (claimed employer_id + claim
|
||||
state None until P2)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ai_service.employers.base import (
|
||||
CLAIM_STATUSES,
|
||||
EmployerAccount,
|
||||
EmployerClaim,
|
||||
EmployerMe,
|
||||
LoginEmployerRequest,
|
||||
RegisterEmployerRequest,
|
||||
TokenResponse,
|
||||
validate_email,
|
||||
)
|
||||
|
||||
|
||||
def _account_kwargs(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": "acct-0001",
|
||||
"email": "user@example.com",
|
||||
"employer_id": None,
|
||||
"created_at": datetime(2026, 9, 30, tzinfo=UTC),
|
||||
"email_verified": False,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestEmployerAccount:
|
||||
def test_account_roundtrip(self) -> None:
|
||||
account = EmployerAccount(**_account_kwargs())
|
||||
assert account.id == "acct-0001"
|
||||
assert account.email == "user@example.com"
|
||||
assert account.employer_id is None
|
||||
assert account.email_verified is False
|
||||
|
||||
def test_email_verified_is_literal_false(self) -> None:
|
||||
"""D-097: True is structurally unrepresentable."""
|
||||
with pytest.raises(ValidationError):
|
||||
EmployerAccount(**_account_kwargs(email_verified=True))
|
||||
|
||||
def test_employer_id_nullable_until_claim_approval(self) -> None:
|
||||
account = EmployerAccount(
|
||||
**_account_kwargs(employer_id="emp-source-external")
|
||||
)
|
||||
assert account.employer_id == "emp-source-external"
|
||||
# ...and None remains valid (nullable-until-claim-approval).
|
||||
assert EmployerAccount(**_account_kwargs()).employer_id is None
|
||||
|
||||
|
||||
class TestEmployerClaim:
|
||||
def _claim_kwargs(self, **overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": "clm-0001",
|
||||
"account_id": "acct-0001",
|
||||
"employer_id": "emp-1",
|
||||
"status": "pending",
|
||||
"evidence": {"account_email_domain": "example.com", "match": True},
|
||||
"created_at": datetime(2026, 9, 30, tzinfo=UTC),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_pending_claim_roundtrip(self) -> None:
|
||||
claim = EmployerClaim(**self._claim_kwargs())
|
||||
assert claim.status == "pending"
|
||||
assert claim.decided_by is None
|
||||
assert claim.decided_at is None
|
||||
|
||||
def test_decided_claim_roundtrip(self) -> None:
|
||||
claim = EmployerClaim(
|
||||
**self._claim_kwargs(
|
||||
status="approved",
|
||||
decided_by="operator-1",
|
||||
decided_at=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
assert claim.status == "approved"
|
||||
assert claim.decided_by == "operator-1"
|
||||
assert claim.decided_at is not None
|
||||
|
||||
def test_status_vocabulary_is_closed(self) -> None:
|
||||
"""D-089: unknown claim status rejected; all three accepted."""
|
||||
for status in CLAIM_STATUSES:
|
||||
EmployerClaim(**self._claim_kwargs(status=status))
|
||||
with pytest.raises(ValidationError):
|
||||
EmployerClaim(**self._claim_kwargs(status="bogus"))
|
||||
|
||||
|
||||
class TestEmailValidation:
|
||||
"""G-315: stdlib-only email discipline."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("user@example.com", "user@example.com"),
|
||||
(" User@Example.COM ", "user@example.com"),
|
||||
("MiXeD@ExAmPlE.NeT", "mixed@example.net"),
|
||||
],
|
||||
)
|
||||
def test_normalizes_strip_and_lowercase(self, raw: str, expected: str) -> None:
|
||||
assert validate_email(raw) == expected
|
||||
request = RegisterEmployerRequest(email=raw, password="hunter2222")
|
||||
assert request.email == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
"no-at-sign",
|
||||
"double@@example.com",
|
||||
"user@@example.com",
|
||||
"@example.com",
|
||||
"user@",
|
||||
"a@b@c",
|
||||
],
|
||||
)
|
||||
def test_rejects_dishonest_emails(self, raw: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RegisterEmployerRequest(email=raw, password="hunter2222")
|
||||
|
||||
def test_single_at_with_domain_accepted(self) -> None:
|
||||
"""G-315 is an honest structural floor, not an RFC masquerade:
|
||||
exactly one "@" + non-empty parts passes without a DNS regex."""
|
||||
assert validate_email("user@example") == "user@example"
|
||||
|
||||
def test_domain_must_be_non_empty(self) -> None:
|
||||
with pytest.raises(ValueError, match="domain"):
|
||||
validate_email("user@")
|
||||
|
||||
def test_emailstr_banned_no_email_validator_dep(self) -> None:
|
||||
"""G-315: the field annotation is plain str — email-validator
|
||||
(the EmailStr backer) is not in the venv and never will be."""
|
||||
field = RegisterEmployerRequest.model_fields["email"]
|
||||
assert field.annotation is str
|
||||
|
||||
|
||||
class TestPasswordFloor:
|
||||
"""D-086 honest validation: password min length 8."""
|
||||
|
||||
def test_short_password_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RegisterEmployerRequest(email="user@example.com", password="hunter2")
|
||||
|
||||
def test_short_password_rejected_on_login_too(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
LoginEmployerRequest(email="user@example.com", password="hunter2")
|
||||
|
||||
def test_min_length_password_accepted(self) -> None:
|
||||
request = LoginEmployerRequest(email="user@example.com", password="hunter22")
|
||||
assert request.password == "hunter22"
|
||||
|
||||
|
||||
class TestWireShapes:
|
||||
def test_token_response_roundtrip(self) -> None:
|
||||
response = TokenResponse(
|
||||
token="raw-bearer-token", expires_at=datetime(2026, 10, 7, tzinfo=UTC)
|
||||
)
|
||||
assert response.token == "raw-bearer-token"
|
||||
assert response.expires_at == datetime(2026, 10, 7, tzinfo=UTC)
|
||||
|
||||
def test_employer_me_defaults_none(self) -> None:
|
||||
me = EmployerMe(account=EmployerAccount(**_account_kwargs()))
|
||||
assert me.account.email_verified is False
|
||||
assert me.claimed_employer_id is None
|
||||
assert me.claim is None
|
||||
|
||||
def test_employer_me_with_claim_state(self) -> None:
|
||||
claim = EmployerClaim(
|
||||
id="clm-0001",
|
||||
account_id="acct-0001",
|
||||
employer_id="emp-1",
|
||||
status="pending",
|
||||
evidence={},
|
||||
created_at=datetime(2026, 9, 30, tzinfo=UTC),
|
||||
)
|
||||
me = EmployerMe(
|
||||
account=EmployerAccount(**_account_kwargs()),
|
||||
claimed_employer_id=None,
|
||||
claim=claim,
|
||||
)
|
||||
assert me.claim is claim
|
||||
assert me.claimed_employer_id is None # pending — not approved yet
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Self-serve checkout + entitlement view (Task 5-1-01, REQ-10-005, R-59).
|
||||
|
||||
Pins the employer-principal checkout transpose: session gate → claim
|
||||
gate (403 + claim-CTA) → catalog 404 → the R-59 verbatim order (unique
|
||||
pending placeholder → spec → provider call → bind) with the ONLY
|
||||
changes the plan sanctions — the buyer derives from the session's
|
||||
CLAIMED entity and metadata carries the account provenance. The
|
||||
entitlement view must be the v0.9 shape, own-entity only, with honest
|
||||
none/active/expired states.
|
||||
|
||||
Fixtures: the conftest make_client/register/bearer + the
|
||||
test_postings_write.py claim_and_approve/seed precedents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
from .conftest import bearer, make_client, register
|
||||
|
||||
OWN_ENTITY = "emp-checkout-self"
|
||||
OTHER_ENTITY = "emp-checkout-rival"
|
||||
|
||||
|
||||
def seed_employer_rows(client: TestClient) -> None:
|
||||
"""Both entities exist in the marketplace corpus (claim targets the
|
||||
v0.6 ingested EmployerRow data)."""
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=OWN_ENTITY,
|
||||
source="seed-corpus",
|
||||
external_id=OWN_ENTITY,
|
||||
name="Checkout Labs",
|
||||
website="https://example.com/checkout",
|
||||
)
|
||||
)
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=OTHER_ENTITY,
|
||||
source="seed-corpus",
|
||||
external_id=OTHER_ENTITY,
|
||||
name="Rival Labs",
|
||||
website="https://example.com/rival",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def claim_and_approve(client: TestClient, token: str, employer_id: str) -> None:
|
||||
"""File the claim + drive the operator approve (the store path the
|
||||
2-2 admin route composes) — binds account.employer_id."""
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": employer_id},
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
decided = store.decide_claim(
|
||||
resp.json()["id"], decision="approved", operator="op-1"
|
||||
)
|
||||
assert decided is not None and decided.status == "approved"
|
||||
|
||||
|
||||
def checkout(client: TestClient, token: str, item_id: str):
|
||||
return client.post(
|
||||
"/v1/employers/billing/checkout",
|
||||
headers=bearer(token),
|
||||
json={"item_id": item_id},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
|
||||
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def claimed(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> Iterator[tuple[TestClient, str]]:
|
||||
"""One registered + claimed-and-approved employer session with the
|
||||
billing store state-injected (G-9). The store replaces app.state
|
||||
AFTER the lifespan seeded the default store, so the catalog seed
|
||||
runs explicitly against the injected store (D-078 idempotent —
|
||||
exactly what the lifespan boot does)."""
|
||||
from ai_service.billing.catalog import seed_billing_catalog
|
||||
|
||||
seed_billing_catalog(billing_store)
|
||||
with make_client(tmp_path) as client:
|
||||
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_and_approve(client, token, OWN_ENTITY)
|
||||
yield client, token
|
||||
|
||||
|
||||
class TestCheckoutRoute:
|
||||
def test_no_token_401(self, tmp_path: Path) -> None:
|
||||
"""G-311 order: unauthenticated → 401, never a catalog read."""
|
||||
with make_client(tmp_path) as client:
|
||||
resp = client.post(
|
||||
"/v1/employers/billing/checkout", json={"item_id": "pkg-single"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
|
||||
"""Unclaimed session → 403 + the claim-CTA (D-090 class — the
|
||||
buyer cannot derive from an account with no entity)."""
|
||||
with make_client(tmp_path) as client:
|
||||
token = register(client)["token"]
|
||||
resp = checkout(client, token, "pkg-single")
|
||||
assert resp.status_code == 403
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["reason"] == "no_claimed_employer"
|
||||
assert "claim_cta" in detail
|
||||
|
||||
def test_unknown_item_404(
|
||||
self, claimed: tuple[TestClient, str]
|
||||
) -> None:
|
||||
"""The catalog lookup precedes every side effect (404 unknown
|
||||
item — nothing persisted)."""
|
||||
client, token = claimed
|
||||
resp = checkout(client, token, "pkg-nonexistent")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"] == "unknown catalog item"
|
||||
# Nothing persisted: no checkout row for the claimed entity.
|
||||
assert client.app.state.billing_store.get_checkout_session_by_provider_id( # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
"pkg-nonexistent"
|
||||
) is None
|
||||
|
||||
def test_checkout_201_shape_and_row(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""The R-59 transpose: 201 with the v0.9 CheckoutResponse shape
|
||||
+ the persisted reconciliation row (snapshot fields + open/
|
||||
unpaid status + the buyer = the CLAIMED entity)."""
|
||||
client, token = claimed
|
||||
resp = checkout(client, token, "pkg-single")
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert set(body.keys()) == {
|
||||
"session_id",
|
||||
"provider_session_id",
|
||||
"url",
|
||||
"mode",
|
||||
}
|
||||
assert body["mode"] == "payment"
|
||||
assert body["url"]
|
||||
row = billing_store.get_checkout_session(body["session_id"])
|
||||
assert row is not None
|
||||
assert row.employer_id == OWN_ENTITY # buyer = claimed entity
|
||||
assert row.item_id == "pkg-single"
|
||||
assert row.item_name # D-078 snapshot
|
||||
assert row.price_cents == 4900 # the seeded snapshot price
|
||||
assert row.provider_session_id == body["provider_session_id"]
|
||||
assert row.status == "open"
|
||||
assert row.payment_status == "unpaid"
|
||||
|
||||
def test_subscription_mode_for_plans(
|
||||
self, claimed: tuple[TestClient, str]
|
||||
) -> None:
|
||||
"""mode = subscription iff kind==plan (the R-59 derivation)."""
|
||||
client, token = claimed
|
||||
resp = checkout(client, token, "plan-pro")
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["mode"] == "subscription"
|
||||
|
||||
def test_metadata_provenance(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""R-59's ONLY sanctioned change: metadata employer_id = the
|
||||
CLAIMED entity + operator_ref = the ACCOUNT provenance (D-076's
|
||||
"operator" becomes account provenance). Pinned through the
|
||||
spec the provider received (the mock provider has no spec
|
||||
log — assert the account id shape lands in the spec via the
|
||||
route's account row, and the row's employer binding)."""
|
||||
client, token = claimed
|
||||
resp = checkout(client, token, "pkg-bundle")
|
||||
assert resp.status_code == 201
|
||||
# The account's employer binding is what the route recorded.
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
me = client.get("/v1/employers/auth/me", headers=bearer(token))
|
||||
account_id = me.json()["account"]["id"]
|
||||
assert account_id.startswith("acct-")
|
||||
row = billing_store.get_checkout_session(resp.json()["session_id"])
|
||||
assert row is not None
|
||||
assert row.employer_id == OWN_ENTITY
|
||||
# The metadata provenance is pinned end-to-end in the E2E
|
||||
# (mock-complete resolves the session by the provider id this
|
||||
# row bound); here the account must exist and own the entity.
|
||||
account = store.get_account(account_id)
|
||||
assert account is not None
|
||||
assert account.employer_id == OWN_ENTITY
|
||||
|
||||
def test_two_checkouts_never_collide(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""The unique-pending placeholder: two rapid checkouts both
|
||||
persist (the final-review fix — a bare 'pending' collided on
|
||||
the provider_session_id UNIQUE constraint)."""
|
||||
client, token = claimed
|
||||
first = checkout(client, token, "pkg-single")
|
||||
second = checkout(client, token, "pkg-single")
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert (
|
||||
first.json()["session_id"] != second.json()["session_id"]
|
||||
)
|
||||
|
||||
|
||||
class TestEntitlementView:
|
||||
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
token = register(client)["token"]
|
||||
resp = client.get(
|
||||
"/v1/employers/billing/entitlement", headers=bearer(token)
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
|
||||
|
||||
def test_no_token_401(self, tmp_path: Path) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
resp = client.get("/v1/employers/billing/entitlement")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_honest_none_state(
|
||||
self, claimed: tuple[TestClient, str]
|
||||
) -> None:
|
||||
"""No grant → purchase/subscription None (the honest empty
|
||||
state, never fabricated)."""
|
||||
client, token = claimed
|
||||
resp = client.get(
|
||||
"/v1/employers/billing/entitlement", headers=bearer(token)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["employer_id"] == OWN_ENTITY
|
||||
assert body["purchase"] is None
|
||||
assert body["subscription"] is None
|
||||
|
||||
def test_active_state(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""A real grant renders: posts used/limit/expiry from the
|
||||
store's truth (the v0.9 REQ-9-004 shape)."""
|
||||
client, token = claimed
|
||||
from ..billing.test_entitlement_parity import grant_posts
|
||||
|
||||
grant_posts(billing_store, OWN_ENTITY, 10)
|
||||
resp = client.get(
|
||||
"/v1/employers/billing/entitlement", headers=bearer(token)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
purchase = resp.json()["purchase"]
|
||||
assert purchase is not None
|
||||
assert purchase["posts_limit"] == 10
|
||||
assert purchase["posts_used"] == 0
|
||||
assert purchase["expires_at"] is not None
|
||||
|
||||
def test_expired_state_is_honest(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""An expired purchase never renders as active: get_entitlement
|
||||
skips it (the lazy G-306 read) — the honest None, not a stale
|
||||
package."""
|
||||
with make_client(tmp_path) as client:
|
||||
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_and_approve(client, token, OWN_ENTITY)
|
||||
grant_row = billing_store.insert_checkout_session(
|
||||
provider_session_id="cs_expired_pin",
|
||||
employer_id=OWN_ENTITY,
|
||||
item_id="pkg-single",
|
||||
item_name="Single Post",
|
||||
price_cents=4900,
|
||||
mode="payment",
|
||||
)
|
||||
purchase = billing_store.create_purchase_from_session(
|
||||
grant_row, posts_limit=5, duration_days=30
|
||||
)
|
||||
# Force the expiry into the past.
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
|
||||
with Session(billing_store._engine) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE billing_purchases SET expires_at = :past "
|
||||
"WHERE id = :pid"
|
||||
),
|
||||
{"past": "2000-01-01 00:00:00", "pid": purchase.id},
|
||||
)
|
||||
session.commit()
|
||||
assert purchase.id # the grant existed
|
||||
resp = client.get(
|
||||
"/v1/employers/billing/entitlement", headers=bearer(token)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["purchase"] is None
|
||||
|
||||
def test_rival_entity_never_visible(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""Own-entity only: the rival's grant never renders in the
|
||||
session employer's view."""
|
||||
client, token = claimed
|
||||
from ..billing.test_entitlement_parity import grant_posts
|
||||
|
||||
grant_posts(billing_store, OTHER_ENTITY, 10)
|
||||
resp = client.get(
|
||||
"/v1/employers/billing/entitlement", headers=bearer(token)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["employer_id"] == OWN_ENTITY
|
||||
assert body["purchase"] is None
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Own-session mock-complete E2E (Task 5-1-02, REQ-10-005, MH-5a/5b).
|
||||
|
||||
The B2B loop closes machine-checked: register → login → claim →
|
||||
admin approve → checkout → OWN mock-complete → the REAL webhook path →
|
||||
purchase exists with posts available. The G-301 hole class is closed:
|
||||
cross-session completion → 403, the route 404s under a stripe
|
||||
provider, and a duplicate complete falls through already-complete —
|
||||
G-303's per-session exactly-once refuses the double grant.
|
||||
|
||||
The admin-approve step drives the REAL operator route (the 2-2 admin
|
||||
surface, X-Admin-Token) — the E2E proof runs the honest loop, not a
|
||||
store shortcut.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
from .conftest import TEST_EMAIL, TEST_PASSWORD, bearer, make_client, register
|
||||
|
||||
OWN_ENTITY = "emp-e2e-self"
|
||||
OTHER_ENTITY = "emp-e2e-rival"
|
||||
ADMIN_TOKEN = "test-checkout-e2e-admin"
|
||||
SECRET = "whsec_test_employer_e2e"
|
||||
|
||||
|
||||
def seed_employer_rows(client: TestClient) -> None:
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=OWN_ENTITY,
|
||||
source="seed-corpus",
|
||||
external_id=OWN_ENTITY,
|
||||
name="E2E Labs",
|
||||
website="https://example.com/e2e",
|
||||
)
|
||||
)
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=OTHER_ENTITY,
|
||||
source="seed-corpus",
|
||||
external_id=OTHER_ENTITY,
|
||||
name="Rival Labs",
|
||||
website="https://example.com/rival",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def admin_headers() -> dict[str, str]:
|
||||
return {"X-Admin-Token": ADMIN_TOKEN}
|
||||
|
||||
|
||||
def claim_approve_admin_route(
|
||||
client: TestClient, token: str, employer_id: str
|
||||
) -> None:
|
||||
"""File the claim then drive the REAL operator approve route (the
|
||||
2-2 admin surface — the honest loop)."""
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": employer_id},
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
approved = client.post(
|
||||
f"/v1/employers/admin/claims/{resp.json()['id']}/approve",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert approved.status_code == 200, approved.text
|
||||
assert approved.json()["claim"]["status"] == "approved"
|
||||
|
||||
|
||||
def checkout(client: TestClient, token: str, item_id: str) -> dict:
|
||||
resp = client.post(
|
||||
"/v1/employers/billing/checkout",
|
||||
headers=bearer(token),
|
||||
json={"item_id": item_id},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def mock_complete(client: TestClient, token: str, provider_session_id: str):
|
||||
return client.post(
|
||||
"/v1/employers/billing/mock/complete",
|
||||
headers=bearer(token),
|
||||
json={"provider_session_id": provider_session_id},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
|
||||
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def claimed(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> Iterator[tuple[TestClient, str]]:
|
||||
"""The full honest principal: registered → claim filed → operator
|
||||
approved via the REAL admin route. The webhook secret is pinned so
|
||||
minted events verify against the provider the app selected."""
|
||||
from ai_service.billing.catalog import seed_billing_catalog
|
||||
|
||||
seed_billing_catalog(billing_store)
|
||||
with make_client(
|
||||
tmp_path, admin_token=SecretStr(ADMIN_TOKEN), webhook_secret=SECRET
|
||||
) as client:
|
||||
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_approve_admin_route(client, token, OWN_ENTITY)
|
||||
yield client, token
|
||||
|
||||
|
||||
def _purchase_count(store: SQLiteBillingStore, employer_id: str) -> int:
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
|
||||
with Session(store._engine) as session:
|
||||
return int(
|
||||
session.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM billing_purchases "
|
||||
"WHERE employer_id = :eid"
|
||||
),
|
||||
{"eid": employer_id},
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
class TestCheckoutE2E:
|
||||
def test_full_loop_purchase_granted(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""MH-5a: register → login (the register mint IS the session) →
|
||||
claim → admin approve → checkout → own mock-complete → the
|
||||
REAL webhook path → purchase exists with posts available —
|
||||
and the employer can CREATE a posting from the granted package
|
||||
(the loop's proof point)."""
|
||||
client, token = claimed
|
||||
# Login proves the credential path (register mints the session;
|
||||
# login re-derives one from the password).
|
||||
login_resp = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
token = login_resp.json()["token"]
|
||||
|
||||
body = checkout(client, token, "pkg-single")
|
||||
complete = mock_complete(client, token, body["provider_session_id"])
|
||||
assert complete.status_code == 200, complete.text
|
||||
assert complete.json() == {"completed": True, "payment_status": "paid"}
|
||||
|
||||
# The REAL webhook path ran: the entitlement view (own-entity)
|
||||
# renders the granted package with posts available.
|
||||
view = client.get(
|
||||
"/v1/employers/billing/entitlement", headers=bearer(token)
|
||||
)
|
||||
assert view.status_code == 200
|
||||
purchase = view.json()["purchase"]
|
||||
assert purchase is not None
|
||||
assert purchase["posts_limit"] == 1
|
||||
assert purchase["posts_used"] == 0
|
||||
assert purchase["expires_at"] is not None
|
||||
|
||||
# The grant answers the loop: a posting create succeeds (the
|
||||
# package is live in the shared entitlement gate).
|
||||
created = client.post(
|
||||
"/v1/employers/postings",
|
||||
headers=bearer(token),
|
||||
json={"title": "E2E Role", "description": "d", "skills": ["go"]},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
assert _purchase_count(billing_store, OWN_ENTITY) == 1
|
||||
# The signed delivery landed (provider event recorded).
|
||||
events = billing_store.list_billing_events(kind="provider")
|
||||
assert any(e.name == "checkout.completed" for e in events)
|
||||
|
||||
def test_cross_session_complete_403(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""MH-5b (the G-301 hole class): a rival's provider_session_id
|
||||
must 403 — completing another buyer's session is refused
|
||||
BEFORE any provider state change or grant; the rival's session
|
||||
stays open and ungranted."""
|
||||
client, token = claimed
|
||||
# The rival buys a session directly through the admin operator
|
||||
# route (the v0.9 fallback — provenance: operator).
|
||||
rival_checkout = client.post(
|
||||
"/v1/billing/admin/checkout",
|
||||
json={"item_id": "pkg-single", "employer_id": OTHER_ENTITY},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert rival_checkout.status_code == 201
|
||||
rival_id = rival_checkout.json()["provider_session_id"]
|
||||
|
||||
resp = mock_complete(client, token, rival_id)
|
||||
assert resp.status_code == 403
|
||||
assert "another employer entity" in resp.json()["detail"]
|
||||
# Nothing granted, nothing flipped: the rival has no purchase.
|
||||
assert _purchase_count(billing_store, OTHER_ENTITY) == 0
|
||||
rival_row = billing_store.get_checkout_session_by_provider_id(rival_id)
|
||||
assert rival_row is not None
|
||||
assert rival_row.status == "open"
|
||||
|
||||
def test_unknown_session_404(self, claimed: tuple[TestClient, str]) -> None:
|
||||
client, token = claimed
|
||||
resp = mock_complete(client, token, "cs_mock_ghost")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"] == "unknown checkout session"
|
||||
|
||||
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
|
||||
"""The claim gate precedes everything (G-311 order)."""
|
||||
with make_client(tmp_path) as client:
|
||||
token = register(client)["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/billing/mock/complete",
|
||||
headers=bearer(token),
|
||||
json={"provider_session_id": "cs_mock_any"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
|
||||
|
||||
def test_no_token_401(self, tmp_path: Path) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
resp = client.post(
|
||||
"/v1/employers/billing/mock/complete",
|
||||
json={"provider_session_id": "cs_mock_any"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_mock_complete_404_under_stripe_provider(
|
||||
self, claimed: tuple[TestClient, str]
|
||||
) -> None:
|
||||
"""MH-5b (second arm): on a stripe-configured box the route
|
||||
must be GONE — the isinstance gate is the defense-in-depth
|
||||
behind the mount story (the router mounts whole; the route
|
||||
refuses under a non-mock provider)."""
|
||||
import httpx
|
||||
|
||||
from ai_service.billing.stripe import StripePaymentProvider
|
||||
|
||||
client, token = claimed
|
||||
body = checkout(client, token, "pkg-single")
|
||||
client.app.state.payment_provider = StripePaymentProvider( # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
http_client=httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(
|
||||
lambda request: httpx.Response(200, json={})
|
||||
)
|
||||
),
|
||||
api_key="sk_test_never_sent",
|
||||
webhook_secret=SECRET,
|
||||
)
|
||||
resp = mock_complete(client, token, body["provider_session_id"])
|
||||
assert resp.status_code == 404 # never reachable under stripe
|
||||
|
||||
def test_duplicate_complete_falls_through_no_double_grant(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""G-303: a duplicate complete (double-click) re-mints + re-
|
||||
runs the REAL webhook path with a FRESH event id — the grant
|
||||
is refused by the per-session UNIQUE constraint; 200 honest
|
||||
(already complete), exactly ONE purchase, never a 500."""
|
||||
client, token = claimed
|
||||
body = checkout(client, token, "pkg-single")
|
||||
first = mock_complete(client, token, body["provider_session_id"])
|
||||
assert first.status_code == 200
|
||||
second = mock_complete(client, token, body["provider_session_id"])
|
||||
assert second.status_code == 200
|
||||
assert second.json()["completed"] is True
|
||||
assert _purchase_count(billing_store, OWN_ENTITY) == 1
|
||||
# The refused re-grant is RECORDED (audit answers it).
|
||||
events = billing_store.list_billing_events(kind="unmatched")
|
||||
assert any(
|
||||
e.name == "checkout.completed" and "already granted" in str(e.detail)
|
||||
for e in events
|
||||
)
|
||||
# The posts are still available exactly once: one create
|
||||
# consumes the single post, a second 402s (no phantom grant).
|
||||
first_create = client.post(
|
||||
"/v1/employers/postings",
|
||||
headers=bearer(token),
|
||||
json={"title": "Role A", "description": "d", "skills": []},
|
||||
)
|
||||
assert first_create.status_code == 201
|
||||
second_create = client.post(
|
||||
"/v1/employers/postings",
|
||||
headers=bearer(token),
|
||||
json={"title": "Role B", "description": "d", "skills": []},
|
||||
)
|
||||
assert second_create.status_code == 402
|
||||
|
||||
def test_subscription_checkout_completes_to_subscription(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""The plan arm: a subscription-mode own checkout completes
|
||||
into an active subscription through the REAL webhook path."""
|
||||
client, token = claimed
|
||||
body = checkout(client, token, "plan-pro")
|
||||
complete = mock_complete(client, token, body["provider_session_id"])
|
||||
assert complete.status_code == 200
|
||||
view = client.get(
|
||||
"/v1/employers/billing/entitlement", headers=bearer(token)
|
||||
)
|
||||
subscription = view.json()["subscription"]
|
||||
assert subscription is not None
|
||||
assert subscription["status"] == "active"
|
||||
assert subscription["plan_name"] == "Pro"
|
||||
@@ -0,0 +1,212 @@
|
||||
"""claim_evidence tests — R-65 normalization + the advisory match
|
||||
signal (Task 2-1-01, REQ-10-002).
|
||||
|
||||
Zero-dep discipline pinned: the module imports stdlib urllib.parse
|
||||
only (no PSL library, no tldextract — R-66). The example.com corpus
|
||||
(R-65) pins match=false corpus-wide: every seeded employer website is
|
||||
an example.com placeholder, so a real registrant's email domain never
|
||||
matches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_service.employers.claim_evidence import (
|
||||
compute_claim_evidence,
|
||||
domains_match,
|
||||
normalize_email_domain,
|
||||
normalize_website_domain,
|
||||
)
|
||||
|
||||
CORPUS_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "ai_service"
|
||||
/ "marketplace"
|
||||
/ "seed_corpus.json"
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeWebsiteDomain:
|
||||
"""The normalization table — scheme/path stripped, case-folded."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("website", "expected"),
|
||||
[
|
||||
# The PLAN's verbatim example: scheme + path stripped.
|
||||
("https://example.com/openai", "example.com"),
|
||||
# Case folding (host, scheme, and a mixed-case path).
|
||||
("HTTPS://Example.COM/OpenAI", "example.com"),
|
||||
("https://EXAMPLE.com", "example.com"),
|
||||
# Bare host without a scheme — the // prepended for the
|
||||
# netloc parse (urlsplit alone would read it as a path).
|
||||
("example.com", "example.com"),
|
||||
("Example.COM", "example.com"),
|
||||
# Path + query + fragment stripped.
|
||||
("https://example.com/careers?tab=1#top", "example.com"),
|
||||
# Deep path stripped to the bare host.
|
||||
("https://sub.example.com/jobs/openai/123", "sub.example.com"),
|
||||
# Port dropped (evidence is registrable, not origin-scoped
|
||||
# — port is irrelevant to a domain match).
|
||||
("https://example.com:8443/careers", "example.com"),
|
||||
# The seed corpus's placeholder shape (R-65).
|
||||
("https://example.com/openai", "example.com"),
|
||||
],
|
||||
)
|
||||
def test_table(self, website: str, expected: str) -> None:
|
||||
assert normalize_website_domain(website) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"website",
|
||||
["", "not a url", "https://", "mailto:x@example.com"],
|
||||
)
|
||||
def test_honest_absence(self, website: str) -> None:
|
||||
"""Unparseable/empty → "" — never an exception (the claim route
|
||||
must never 500 over an advisory signal). mailto: carries no
|
||||
hostname — honest absence."""
|
||||
assert normalize_website_domain(website) == ""
|
||||
|
||||
def test_unparseable_never_raises(self) -> None:
|
||||
"""An unterminated IPv6 literal raises ValueError inside
|
||||
urlsplit — swallowed into the honest empty string (evidence is
|
||||
advisory, never a 500 on the claim route)."""
|
||||
assert normalize_website_domain("https://[::1") == ""
|
||||
|
||||
|
||||
class TestNormalizeEmailDomain:
|
||||
@pytest.mark.parametrize(
|
||||
("email", "expected"),
|
||||
[
|
||||
("founder@openai.example", "openai.example"),
|
||||
# Lowercased (G-315 already normalizes at the account
|
||||
# boundary; this re-derives, never trusts).
|
||||
("Founder@OpenAI.Example", "openai.example"),
|
||||
# The local part rides NOWHERE in the evidence.
|
||||
("weird+tag@acme.com", "acme.com"),
|
||||
],
|
||||
)
|
||||
def test_table(self, email: str, expected: str) -> None:
|
||||
assert normalize_email_domain(email) == expected
|
||||
|
||||
@pytest.mark.parametrize("email", ["", "no-at-sign"])
|
||||
def test_honest_absence(self, email: str) -> None:
|
||||
assert normalize_email_domain(email) == ""
|
||||
|
||||
|
||||
class TestDomainsMatch:
|
||||
def test_same_registrable_domain_true(self) -> None:
|
||||
"""The PLAN's match case: identical domains."""
|
||||
assert domains_match("acme.com", "acme.com") is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("email_domain", "website_domain"),
|
||||
[
|
||||
# Subdomain of the registrable domain — one endswith "." +
|
||||
# the other, either orientation.
|
||||
("acme.com", "shop.acme.com"),
|
||||
("shop.acme.com", "acme.com"),
|
||||
("acme.co.uk", "www.acme.co.uk"),
|
||||
],
|
||||
)
|
||||
def test_suffix_match_true(
|
||||
self, email_domain: str, website_domain: str
|
||||
) -> None:
|
||||
assert domains_match(email_domain, website_domain) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("email_domain", "website_domain"),
|
||||
[
|
||||
# Unrelated domains.
|
||||
("acme.com", "openai.example"),
|
||||
("acme.com", "notacme.com"),
|
||||
# Prefix-lookalike is NOT a suffix ("acme.com" vs
|
||||
# "fake-acme.com" — no boundary dot).
|
||||
("acme.com", "fake-acme.com"),
|
||||
# Either side empty → False (nothing claimed is nothing
|
||||
# matched).
|
||||
("", "acme.com"),
|
||||
("acme.com", ""),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_no_match_false(self, email_domain: str, website_domain: str) -> None:
|
||||
assert domains_match(email_domain, website_domain) is False
|
||||
|
||||
def test_multi_label_suffix_overmatch_documented(self) -> None:
|
||||
"""The documented over-match (module docstring): "co.uk" is a
|
||||
multi-label public suffix, not a registrable domain, yet the
|
||||
suffix rule counts it. Accepted for an advisory signal — no
|
||||
PSL, zero deps (R-66). This test PINS the behavior so a future
|
||||
tightening can't drift silently."""
|
||||
assert domains_match("mail.co.uk", "co.uk") is True
|
||||
|
||||
|
||||
class TestComputeClaimEvidence:
|
||||
def test_exact_shape(self) -> None:
|
||||
"""The D-089 evidence dict — exact keys, computed at claim
|
||||
time, stored as provenance (Task 2-1-03 pins the route)."""
|
||||
evidence = compute_claim_evidence(
|
||||
"founder@openai.example", "https://openai.example/careers"
|
||||
)
|
||||
assert evidence == {
|
||||
"account_email_domain": "openai.example",
|
||||
"entity_website_domain": "openai.example",
|
||||
"match": True,
|
||||
}
|
||||
assert set(evidence) == {
|
||||
"account_email_domain",
|
||||
"entity_website_domain",
|
||||
"match",
|
||||
}
|
||||
# The local part rides nowhere (PII boundary).
|
||||
assert "founder" not in json.dumps(evidence)
|
||||
|
||||
def test_match_false_on_unrelated(self) -> None:
|
||||
evidence = compute_claim_evidence(
|
||||
"founder@acme.com", "https://openai.example/careers"
|
||||
)
|
||||
assert evidence == {
|
||||
"account_email_domain": "acme.com",
|
||||
"entity_website_domain": "openai.example",
|
||||
"match": False,
|
||||
}
|
||||
|
||||
def test_match_false_on_the_example_com_corpus(self) -> None:
|
||||
"""R-65: every seeded employer website is an example.com
|
||||
placeholder — a real registrant's email domain does NOT match
|
||||
any seeded entity. match=false corpus-wide (D-089's
|
||||
advisory-display premise)."""
|
||||
corpus = json.loads(CORPUS_PATH.read_text(encoding="utf-8"))
|
||||
employers = corpus["employers"]
|
||||
assert len(employers) > 0
|
||||
for employer in employers:
|
||||
website = employer["website"]
|
||||
assert "example.com" in website # the R-65 premise itself
|
||||
evidence = compute_claim_evidence(
|
||||
"founder@openai.example", website
|
||||
)
|
||||
assert evidence["entity_website_domain"] == "example.com"
|
||||
assert evidence["match"] is False
|
||||
|
||||
|
||||
class TestZeroDepDiscipline:
|
||||
def test_module_is_stdlib_only(self) -> None:
|
||||
"""R-66: the evidence module imports nothing beyond stdlib
|
||||
(urllib.parse) — no PSL, no tldextract, never a new dep."""
|
||||
import ai_service.employers.claim_evidence as module
|
||||
|
||||
imported = {
|
||||
name
|
||||
for name in vars(module)
|
||||
if not name.startswith("_")
|
||||
and getattr(getattr(module, name, None), "__module__", "")
|
||||
== module.__name__
|
||||
}
|
||||
assert imported # the module's own names are present
|
||||
# The ONLY module-level import is urllib.parse (stdlib).
|
||||
import urllib.parse
|
||||
|
||||
assert module.urlsplit is urllib.parse.urlsplit
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Claim route tests — POST /v1/employers/claims (Task 2-1-03, REQ-10-002).
|
||||
|
||||
MH-2b pinned here: the exact evidence dict
|
||||
{account_email_domain, entity_website_domain, match} computed AT CLAIM
|
||||
TIME, stored as provenance JSON on the claim row, returned with the
|
||||
pending claim. The entity must exist in the marketplace store (404);
|
||||
the account must not already hold an entity (409 honest); one pending
|
||||
claim per entity (409 — G-316's index backstops the race); the route is
|
||||
session-gated (401 unauthenticated). Auto-approval is deferred (D-089).
|
||||
|
||||
REAL scrypt throughout (register mints a real hash — the conftest
|
||||
fixtures handle limiter resets).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
from .conftest import (
|
||||
TEST_EMAIL,
|
||||
bearer,
|
||||
make_client,
|
||||
register,
|
||||
)
|
||||
|
||||
ENTITY_ID = "emp-openai"
|
||||
|
||||
|
||||
def seed_employer(client: TestClient, website: str = "https://example.com/openai") -> None:
|
||||
"""Upsert a marketplace EmployerRow through the app's own store
|
||||
(app.state.marketplace_store — the same DI the route composes)."""
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=ENTITY_ID,
|
||||
source="seed-corpus",
|
||||
external_id=ENTITY_ID,
|
||||
name="OpenAI Labs",
|
||||
website=website,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def claim(client: TestClient, token: str, employer_id: str = ENTITY_ID):
|
||||
return client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": employer_id},
|
||||
headers=bearer(token),
|
||||
)
|
||||
|
||||
|
||||
class TestClaimRoute:
|
||||
def test_claim_pending_with_exact_evidence(self, client: TestClient) -> None:
|
||||
"""Claim → 201 pending with the EXACT evidence dict (MH-2b):
|
||||
the R-65 example.com corpus makes match=false corpus-wide."""
|
||||
seed_employer(client)
|
||||
token = register(client)["token"]
|
||||
resp = claim(client, token)
|
||||
assert resp.status_code == 201, resp.text
|
||||
claim_row = resp.json()
|
||||
assert claim_row["status"] == "pending"
|
||||
assert claim_row["employer_id"] == ENTITY_ID
|
||||
assert claim_row["evidence"] == {
|
||||
"account_email_domain": "nextcraft.example",
|
||||
"entity_website_domain": "example.com",
|
||||
"match": False,
|
||||
}
|
||||
assert claim_row["decided_by"] is None
|
||||
assert claim_row["decided_at"] is None
|
||||
assert claim_row["account_id"]
|
||||
|
||||
def test_evidence_true_on_matching_domain(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""A matching website domain (R-65's registrable case) — the
|
||||
evidence signal is ADVISORY: match=true still files a PENDING
|
||||
claim (auto-approval deferred, D-089/A-304)."""
|
||||
seed_employer(client, website="https://nextcraft.example/careers")
|
||||
token = register(client)["token"]
|
||||
resp = claim(client, token)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["evidence"] == {
|
||||
"account_email_domain": "nextcraft.example",
|
||||
"entity_website_domain": "nextcraft.example",
|
||||
"match": True,
|
||||
}
|
||||
assert resp.json()["status"] == "pending"
|
||||
|
||||
def test_unknown_entity_404(self, client: TestClient) -> None:
|
||||
token = register(client)["token"]
|
||||
resp = claim(client, token, employer_id="emp-does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_account_already_holds_entity_409(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""One account : one entity — an owner claiming a SECOND
|
||||
(free) entity gets the honest 409. The ownership binds via the
|
||||
store's decide_claim (the same transition the admin route
|
||||
drives in Wave 2-2)."""
|
||||
seed_employer(client)
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
token = register(client)["token"]
|
||||
first = claim(client, token)
|
||||
assert first.status_code == 201
|
||||
# Operator approve (the store path the 2-2 route composes).
|
||||
decided = store.decide_claim(
|
||||
first.json()["id"], decision="approved", operator="op-1"
|
||||
)
|
||||
assert decided is not None and decided.status == "approved"
|
||||
# A second, otherwise-free entity — still refused.
|
||||
store2 = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store2.upsert_employer(
|
||||
Employer(
|
||||
id="emp-other",
|
||||
source="seed-corpus",
|
||||
external_id="emp-other",
|
||||
name="Other Labs",
|
||||
website="https://example.com/other",
|
||||
)
|
||||
)
|
||||
resp = claim(client, token, employer_id="emp-other")
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_second_pending_claim_same_entity_409(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""One PENDING claim per entity — the check-first 409 fast
|
||||
path, by ANOTHER account (the same account's duplicate also
|
||||
409s; the other-account arm is the stronger pin)."""
|
||||
seed_employer(client)
|
||||
first_token = register(client)["token"]
|
||||
assert claim(client, first_token).status_code == 201
|
||||
second_token = register(client, email="other@example.com")["token"]
|
||||
resp = claim(client, second_token)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_unauthenticated_401(self, client: TestClient) -> None:
|
||||
seed_employer(client)
|
||||
resp = client.post(
|
||||
"/v1/employers/claims", json={"employer_id": ENTITY_ID}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_invalid_token_401(self, client: TestClient) -> None:
|
||||
seed_employer(client)
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": ENTITY_ID},
|
||||
headers=bearer("not-a-real-token"),
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_claim_evidence_persisted_as_provenance(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""The evidence snapshot rides the claim ROW (stored at claim
|
||||
time, never recomputed on read — D-089) + a claim.submitted
|
||||
audit event lands."""
|
||||
from sqlalchemy import text
|
||||
|
||||
seed_employer(client)
|
||||
token = register(client)["token"]
|
||||
resp = claim(client, token)
|
||||
assert resp.status_code == 201
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
with store._engine.connect() as conn:
|
||||
evidence = conn.execute(
|
||||
text("SELECT evidence FROM employer_claims")
|
||||
).scalar_one()
|
||||
audit = conn.execute(
|
||||
text(
|
||||
"SELECT kind, name FROM employer_audit_events "
|
||||
"WHERE kind = 'claim'"
|
||||
)
|
||||
).fetchall()
|
||||
assert evidence == (
|
||||
'{"account_email_domain": "nextcraft.example", '
|
||||
'"entity_website_domain": "example.com", "match": false}'
|
||||
)
|
||||
assert audit == [("claim", "claim.submitted")]
|
||||
|
||||
def test_empty_employer_id_422(self, client: TestClient) -> None:
|
||||
token = register(client)["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/claims", json={"employer_id": ""}, headers=bearer(token)
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestRacedInsert:
|
||||
def test_raced_pending_claim_same_honest_409(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
"""G-316 at the route: a second pending row raced past the
|
||||
check-first read is refused by the partial UNIQUE index — the
|
||||
surfaced IntegrityError maps to the SAME honest 409, never a
|
||||
duplicate pending row (proven by inserting the rival row
|
||||
directly through the store, bypassing the route's check)."""
|
||||
with make_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client)["token"]
|
||||
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
# The rival pending claim lands BETWEEN this route's
|
||||
# check-first read and its insert only under a race; a
|
||||
# direct store insert simulates the raced writer.
|
||||
rival_account, _ = employer_store.insert_account(
|
||||
email="rival@example.com",
|
||||
password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA",
|
||||
)
|
||||
employer_store.insert_claim(
|
||||
account_id=rival_account.id,
|
||||
employer_id=ENTITY_ID,
|
||||
evidence={},
|
||||
)
|
||||
resp = claim(client, token)
|
||||
assert resp.status_code == 409
|
||||
pending = employer_store.list_claims(status="pending")
|
||||
assert len(pending) == 1 # never a duplicate pending row
|
||||
|
||||
|
||||
class TestClaimDiscipline:
|
||||
def test_marketplace_store_via_di_never_imported(
|
||||
self,
|
||||
) -> None:
|
||||
"""Territory rule: the route composes the marketplace store
|
||||
via app.state DI (get_marketplace_store) — api/employers.py
|
||||
never CONSTRUCTS a store. P4 (Task 4-1-02): the write routes
|
||||
compose the admin routes' own imports (InvalidTransition,
|
||||
MarketplaceStore annotations — the marketplace_admin.py:32
|
||||
precedent), so the pin asserts the CONSTRUCTION ban, not the
|
||||
import ban: no SQLiteMarketplaceStore anywhere in the
|
||||
module, the DEP composed on every store touch."""
|
||||
import inspect
|
||||
|
||||
from ai_service.api import employers as employers_api
|
||||
|
||||
source = inspect.getsource(employers_api)
|
||||
assert "SQLiteMarketplaceStore" not in source
|
||||
assert "get_marketplace_store" in source
|
||||
|
||||
def test_no_learner_gate_imported(self) -> None:
|
||||
"""D-097: no learner gate composed on any employer route —
|
||||
pinned at the IMPORT sites (the api.identity imports are what
|
||||
compose gates; docstring mentions are copy, not code)."""
|
||||
import inspect
|
||||
|
||||
from ai_service.api import employers as employers_api
|
||||
|
||||
source = inspect.getsource(employers_api)
|
||||
assert "from .identity import" not in source
|
||||
assert "from ..identity" not in source
|
||||
assert "learner_allowlist" not in source
|
||||
|
||||
def test_route_order_entity_check_before_owner_check(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
"""The 404 (unknown entity) wins over the 409 (already an
|
||||
owner): the entity is resolved first — an owner probing an
|
||||
unknown id learns nothing about ownership state."""
|
||||
with make_client(tmp_path) as client:
|
||||
token = register(client)["token"]
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
account = store.get_account_by_email(TEST_EMAIL)
|
||||
assert account is not None
|
||||
with store._engine.connect() as conn:
|
||||
from sqlalchemy import text
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE employer_accounts SET employer_id = "
|
||||
"'emp-owned' WHERE id = :acct"
|
||||
),
|
||||
{"acct": account.id},
|
||||
)
|
||||
conn.commit()
|
||||
resp = claim(client, token, employer_id="emp-unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_no_learner_gate_composed(self) -> None:
|
||||
"""D-097: no learner gate (require_verified_age/allowlist)
|
||||
composed on any employer route — pinned at the import sites
|
||||
(docstring copy is not code)."""
|
||||
import inspect
|
||||
|
||||
from ai_service.api import employers as employers_api
|
||||
|
||||
source = inspect.getsource(employers_api)
|
||||
assert "from .identity import" not in source
|
||||
assert "from ..identity" not in source
|
||||
assert "learner_allowlist" not in source
|
||||
|
||||
|
||||
def test_claim_route_registered(tmp_path) -> None:
|
||||
"""The claims_router is mounted (main.py wiring pin): the route
|
||||
exists in the OpenAPI schema — POST /v1/employers/claims with the
|
||||
session-gated claim body (the _IncludedRouter wrappers hide
|
||||
APIRoutes from app.routes in this starlette, so the schema is the
|
||||
honest registry)."""
|
||||
with make_client(tmp_path) as client:
|
||||
schema = client.get("/openapi.json").json()
|
||||
path = schema["paths"]["/v1/employers/claims"]
|
||||
assert set(path) == {"post"}
|
||||
body_schema = path["post"]["requestBody"]["content"][
|
||||
"application/json"
|
||||
]["schema"]
|
||||
# $ref → ClaimEmployerRequest, whose only required field is
|
||||
# employer_id.
|
||||
assert body_schema["$ref"].endswith("ClaimEmployerRequest")
|
||||
request = schema["components"]["schemas"]["ClaimEmployerRequest"]
|
||||
assert request["required"] == ["employer_id"]
|
||||
assert request["properties"]["employer_id"]["minLength"] == 1
|
||||
@@ -0,0 +1,604 @@
|
||||
"""Admin claim review/operator-route tests (Task 2-2-01, REQ-10-002, MH-2a/2d).
|
||||
|
||||
Pinned: approve → account.employer_id set + audit row + claim approved;
|
||||
reject frees the entity (a new claim from ANOTHER account succeeds);
|
||||
decided twice → 409 honest (one decision audit row — no double
|
||||
activation); the entity-owned arm surfaces as 409, never an overwrite;
|
||||
fail-closed 403 on ALL five routes (no token / wrong token /
|
||||
unset-config / cross-principal session token); a 403'd decision
|
||||
writes NOTHING; the audit query returns claim decisions with operator
|
||||
provenance; the five routes are mounted + OpenAPI-visible. The
|
||||
admin-token pattern is the billing one (X-Admin-Token + a SecretStr'd
|
||||
Settings knob, tests/billing/test_lifecycle_admin.py:26-63).
|
||||
|
||||
REAL scrypt throughout (register mints a real hash; the conftest
|
||||
autouse fixture resets the auth limiter module states — the admin
|
||||
routes add no limiter state of their own).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
from .conftest import TEST_EMAIL, make_client, register
|
||||
|
||||
ADMIN_TOKEN = "test-claims-admin"
|
||||
ENTITY_ID = "emp-openai"
|
||||
|
||||
#: The five-route fail-closed matrix surface (R-56): the three GETs
|
||||
#: plus the two decision POSTs (formatted with a claim id).
|
||||
ROUTES = (
|
||||
"/v1/employers/admin/accounts",
|
||||
"/v1/employers/admin/claims",
|
||||
"/v1/employers/admin/audit",
|
||||
)
|
||||
DECISION_ROUTES = (
|
||||
"/v1/employers/admin/claims/{claim_id}/approve",
|
||||
"/v1/employers/admin/claims/{claim_id}/reject",
|
||||
)
|
||||
|
||||
|
||||
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def make_admin_client(
|
||||
tmp_path: Path, admin_token: str | None = ADMIN_TOKEN
|
||||
) -> Iterator[TestClient]:
|
||||
"""The full app with the admin token CONFIGURED (the billing
|
||||
pattern: SecretStr knob; admin_token=None exercises the UNSET
|
||||
fail-closed class — the empty SecretStr)."""
|
||||
with make_client(tmp_path, admin_token=SecretStr(admin_token or "")) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def seed_employer(client: TestClient, employer_id: str = ENTITY_ID, website: str = "") -> None:
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=employer_id,
|
||||
source="seed-corpus",
|
||||
external_id=employer_id,
|
||||
name="OpenAI Labs",
|
||||
website=website or f"https://example.com/{employer_id}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def claim(client: TestClient, token: str, employer_id: str = ENTITY_ID) -> dict:
|
||||
"""Session-gated claim through the REAL route (409/404 arms raise
|
||||
— the callers pin the happy path)."""
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": employer_id},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def decide(client: TestClient, claim_id: str, arm: str = "approve") -> TestClient:
|
||||
"""Convenience: POST the decision with the configured token; the
|
||||
CLIENT returns for status asserts."""
|
||||
client.post(
|
||||
f"/v1/employers/admin/claims/{claim_id}/{arm}",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
class TestApprove:
|
||||
def test_approve_sets_ownership_and_audit(self, tmp_path: Path) -> None:
|
||||
"""MH-2a: approve → claim approved with operator provenance +
|
||||
account.employer_id set (read through /me — the same store the
|
||||
route wrote) + the audit row with operator provenance."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client)["token"]
|
||||
row = claim(client, token)
|
||||
resp = client.post(
|
||||
f"/v1/employers/admin/claims/{row['id']}/approve",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
decided = resp.json()["claim"]
|
||||
assert decided["status"] == "approved"
|
||||
assert decided["id"] == row["id"]
|
||||
assert decided["decided_by"] == "operator"
|
||||
assert decided["decided_at"] is not None
|
||||
me = client.get(
|
||||
"/v1/employers/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert me.status_code == 200
|
||||
body = me.json()
|
||||
assert body["claimed_employer_id"] == ENTITY_ID
|
||||
assert body["account"]["employer_id"] == ENTITY_ID
|
||||
audit = client.get(
|
||||
"/v1/employers/admin/audit?kind=claim",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
events = audit.json()["events"]
|
||||
approve = [e for e in events if e["name"] == "claim.approved"]
|
||||
assert len(approve) == 1
|
||||
assert approve[0]["operator"] == "operator"
|
||||
assert approve[0]["detail"]["claim_id"] == row["id"]
|
||||
assert approve[0]["detail"]["employer_id"] == ENTITY_ID
|
||||
assert approve[0]["account_id"]
|
||||
|
||||
def test_approve_pops_pending_queue(self, tmp_path: Path) -> None:
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client, email="q@example.com")["token"]
|
||||
row = claim(client, token)
|
||||
pending = client.get(
|
||||
"/v1/employers/admin/claims?status=pending",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert pending.json()["count"] == 1
|
||||
assert (
|
||||
client.post(
|
||||
f"/v1/employers/admin/claims/{row['id']}/approve",
|
||||
headers=admin_headers(),
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
pending = client.get(
|
||||
"/v1/employers/admin/claims?status=pending",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert pending.json()["count"] == 0
|
||||
approved = client.get(
|
||||
"/v1/employers/admin/claims?status=approved",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert approved.json()["count"] == 1
|
||||
|
||||
|
||||
class TestReject:
|
||||
def test_reject_frees_entity_for_new_claim(self, tmp_path: Path) -> None:
|
||||
"""Reject → decided + audited; the entity is FREE — a new
|
||||
claim from ANOTHER account succeeds; the rejected account
|
||||
owns nothing."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
first_token = register(client, email="first@example.com")["token"]
|
||||
row = claim(client, first_token)
|
||||
resp = client.post(
|
||||
f"/v1/employers/admin/claims/{row['id']}/reject",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
rejected = resp.json()["claim"]
|
||||
assert rejected["status"] == "rejected"
|
||||
assert rejected["decided_by"] == "operator"
|
||||
second_token = register(client, email="second@example.com")["token"]
|
||||
second = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": ENTITY_ID},
|
||||
headers={"Authorization": f"Bearer {second_token}"},
|
||||
)
|
||||
assert second.status_code == 201, second.text
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
first = store.get_account_by_email("first@example.com")
|
||||
assert first is not None and first.employer_id is None
|
||||
audit = client.get(
|
||||
"/v1/employers/admin/audit?name=claim.rejected",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert audit.json()["count"] == 1
|
||||
assert audit.json()["events"][0]["operator"] == "operator"
|
||||
|
||||
|
||||
class TestDecideDiscipline:
|
||||
def test_decide_twice_409_honest(self, tmp_path: Path) -> None:
|
||||
"""Deciding a DECIDED claim → 409 for BOTH arms (the store's
|
||||
not-pending guard); exactly ONE decision audit row."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client, email="t@example.com")["token"]
|
||||
row = claim(client, token)
|
||||
first = client.post(
|
||||
f"/v1/employers/admin/claims/{row['id']}/approve",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
again = client.post(
|
||||
f"/v1/employers/admin/claims/{row['id']}/approve",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert again.status_code == 409
|
||||
assert again.json()["detail"] == "claim is already decided"
|
||||
flip = client.post(
|
||||
f"/v1/employers/admin/claims/{row['id']}/reject",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert flip.status_code == 409
|
||||
audit = client.get(
|
||||
"/v1/employers/admin/audit?kind=claim",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
decisions = [
|
||||
e
|
||||
for e in audit.json()["events"]
|
||||
if e["name"] in ("claim.approved", "claim.rejected")
|
||||
]
|
||||
assert len(decisions) == 1
|
||||
|
||||
def test_unknown_claim_404(self, tmp_path: Path) -> None:
|
||||
with make_admin_client(tmp_path) as client:
|
||||
for route in DECISION_ROUTES:
|
||||
resp = client.post(
|
||||
route.format(claim_id="clm-does-not-exist"),
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 404, route
|
||||
|
||||
def test_approve_account_holds_entity_409(self, tmp_path: Path) -> None:
|
||||
"""The route arm of one-account-one-entity: a pending claim
|
||||
whose account ALREADY holds an entity (the account became an
|
||||
owner on another entity after filing) — approve surfaces the
|
||||
store's hand-raised guard as the honest 409, never an
|
||||
overwrite. The rival claim is inserted directly through the
|
||||
store seam (a second pending claim on a DIFFERENT free entity
|
||||
whose account is the owner)."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client, "emp-a")
|
||||
seed_employer(client, "emp-b")
|
||||
token = register(client, email="owner@example.com")["token"]
|
||||
first = claim(client, token, "emp-a")
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
decided = store.decide_claim(first["id"], decision="approved", operator="op")
|
||||
assert decided is not None
|
||||
# The now-owner files a second claim on a free entity via
|
||||
# the store seam (the route would 409 the owner first).
|
||||
row, created = store.insert_claim(
|
||||
account_id=decided.account_id,
|
||||
employer_id="emp-b",
|
||||
evidence={},
|
||||
)
|
||||
assert created
|
||||
resp = client.post(
|
||||
f"/v1/employers/admin/claims/{row.id}/approve",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"] == ("the claim's account already holds an employer entity")
|
||||
# Nothing was overwritten: the account still owns emp-a
|
||||
# only, and the claim row stays pending (undecided).
|
||||
account = store.get_account(decided.account_id)
|
||||
assert account is not None
|
||||
assert account.employer_id == "emp-a"
|
||||
still = store.get_claim(row.id)
|
||||
assert still is not None and still.status == "pending"
|
||||
|
||||
def test_entity_owned_by_another_409_no_overwrite(self, tmp_path: Path) -> None:
|
||||
"""The one-APPROVED-owner arm at the route: a pending claim on
|
||||
an entity whose owner is ANOTHER account (the ownership raced
|
||||
past the claim route's checks) — the approve flush surfaces the
|
||||
employer_accounts.employer_id UNIQUE as the honest 409; the
|
||||
EXISTING owner is never displaced. The rival ownership is set
|
||||
directly on the account through the store seam (a second
|
||||
PENDING claim on the entity is impossible — the partial index
|
||||
refuses it, so the claim-route race is not the reachable seam;
|
||||
the OWNERSHIP race is)."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client, "emp-race")
|
||||
token = register(client, email="racer@example.com")["token"]
|
||||
row = claim(client, token, "emp-race")
|
||||
# A rival account becomes the entity's owner through the
|
||||
# store seam AFTER the claim was filed (direct account
|
||||
# UPDATE — the same write decide_claim performs, bypassing
|
||||
# its guards: the seam simulates an ownership that raced).
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
rival, _ = store.insert_account(
|
||||
email="rival-owner@example.com",
|
||||
password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA",
|
||||
)
|
||||
from sqlalchemy import text
|
||||
|
||||
with store._engine.connect() as conn: # test seam into the store's engine
|
||||
conn.execute(
|
||||
text("UPDATE employer_accounts SET employer_id = :entity WHERE id = :acct"),
|
||||
{"entity": "emp-race", "acct": rival.id},
|
||||
)
|
||||
conn.commit()
|
||||
# Now the OPERATOR approves the pending claim on the same
|
||||
# entity: the UNIQUE refuses — the honest 409, the rival
|
||||
# keeps the ownership, the claim row stays pending.
|
||||
resp = client.post(
|
||||
f"/v1/employers/admin/claims/{row['id']}/approve",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"] == ("the employer entity is already owned")
|
||||
owner = store.get_account(rival.id)
|
||||
assert owner is not None and owner.employer_id == "emp-race"
|
||||
loser = store.get_account_by_email("racer@example.com")
|
||||
assert loser is not None and loser.employer_id is None
|
||||
still = store.get_claim(row["id"])
|
||||
assert still is not None and still.status == "pending"
|
||||
|
||||
|
||||
class TestAccountsSurface:
|
||||
def test_accounts_list_columns_and_no_hash(self, tmp_path: Path) -> None:
|
||||
"""The accounts list: email, claimed entity, created_at, the
|
||||
UNVERIFIED marker — and password_hash NEVER on the wire."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client)["token"]
|
||||
row = claim(client, token)
|
||||
rows = client.get("/v1/employers/admin/accounts", headers=admin_headers())
|
||||
assert rows.status_code == 200
|
||||
body = rows.json()
|
||||
assert body["count"] == 1
|
||||
account = body["accounts"][0]
|
||||
assert account["email"] == TEST_EMAIL
|
||||
assert account["employer_id"] is None # pending — unclaimed
|
||||
assert account["created_at"]
|
||||
assert account["email_verified"] is False
|
||||
assert "password_hash" not in account
|
||||
assert "password" not in account
|
||||
decide(client, row["id"])
|
||||
rows = client.get("/v1/employers/admin/accounts", headers=admin_headers())
|
||||
assert rows.json()["accounts"][0]["employer_id"] == ENTITY_ID
|
||||
|
||||
|
||||
class TestClaimsQueueSurface:
|
||||
def test_queue_carries_advisory_evidence(self, tmp_path: Path) -> None:
|
||||
"""The queue renders the stored evidence snapshot + the
|
||||
domain-match signal as ADVISORY (A-304/R-65: match=false is
|
||||
the example.com corpus norm — advisory, never verification);
|
||||
decided state None while pending. The snapshot is the STORE's
|
||||
claim-time provenance, never recomputed on queue read."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client)["token"]
|
||||
row = claim(client, token)
|
||||
queue = client.get("/v1/employers/admin/claims", headers=admin_headers())
|
||||
assert queue.status_code == 200
|
||||
entry = queue.json()["claims"][0]
|
||||
assert entry["id"] == row["id"]
|
||||
assert entry["status"] == "pending"
|
||||
assert entry["evidence"] == {
|
||||
"account_email_domain": "nextcraft.example",
|
||||
"entity_website_domain": "example.com",
|
||||
"match": False,
|
||||
}
|
||||
assert entry["decided_by"] is None
|
||||
assert entry["decided_at"] is None
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
stored = store.get_claim(row["id"])
|
||||
assert stored is not None
|
||||
assert dict(stored.evidence) == entry["evidence"]
|
||||
|
||||
def test_status_facet(self, tmp_path: Path) -> None:
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client, email="facet@example.com")["token"]
|
||||
claim(client, token)
|
||||
for facet, expected in (
|
||||
("pending", 1),
|
||||
("approved", 0),
|
||||
("rejected", 0),
|
||||
):
|
||||
rows = client.get(
|
||||
f"/v1/employers/admin/claims?status={facet}",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert rows.status_code == 200
|
||||
assert rows.json()["count"] == expected
|
||||
|
||||
|
||||
class TestAuditTrail:
|
||||
def test_audit_query_provenance(self, tmp_path: Path) -> None:
|
||||
"""The audit query returns claim decisions with operator
|
||||
provenance: kind/account/employer/name filters + the
|
||||
submitted → approved chain, decided_by/decided_at joined via
|
||||
detail.claim_id."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client)["token"]
|
||||
row = claim(client, token)
|
||||
assert (
|
||||
client.post(
|
||||
f"/v1/employers/admin/claims/{row['id']}/approve",
|
||||
headers=admin_headers(),
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
claims = client.get(
|
||||
"/v1/employers/admin/audit?kind=claim",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert claims.status_code == 200
|
||||
names = [e["name"] for e in claims.json()["events"]]
|
||||
assert names == ["claim.approved", "claim.submitted"]
|
||||
approved = client.get(
|
||||
"/v1/employers/admin/audit?name=claim.approved",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert approved.json()["count"] == 1
|
||||
event = approved.json()["events"][0]
|
||||
assert event["operator"] == "operator"
|
||||
assert event["detail"]["claim_id"] == row["id"]
|
||||
assert event["detail"]["employer_id"] == ENTITY_ID
|
||||
by_entity = client.get(
|
||||
f"/v1/employers/admin/audit?employer_id={ENTITY_ID}",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert by_entity.json()["count"] == 2
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
account = store.get_account_by_email(TEST_EMAIL)
|
||||
assert account is not None
|
||||
by_account = client.get(
|
||||
f"/v1/employers/admin/audit?account_id={account.id}",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
# register + claim.submitted + claim.approved all carry the
|
||||
# account's id (register stamps account_id too).
|
||||
assert by_account.json()["count"] == 3
|
||||
# The submitted event's detail snapshots the advisory
|
||||
# evidence_match signal (P2-SEC: advisory, claim-time).
|
||||
submitted = client.get(
|
||||
"/v1/employers/admin/audit?name=claim.submitted",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert submitted.json()["events"][0]["detail"]["evidence_match"] is False
|
||||
# The DECIDED claim carries decided_by/decided_at (the
|
||||
# operator provenance the decision events reference).
|
||||
decided_row = store.get_claim(row["id"])
|
||||
assert decided_row is not None
|
||||
assert decided_row.decided_by == "operator"
|
||||
assert decided_row.decided_at is not None
|
||||
|
||||
def test_audit_no_filters_returns_all(self, tmp_path: Path) -> None:
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client, email="all@example.com")["token"]
|
||||
claim(client, token)
|
||||
everything = client.get("/v1/employers/admin/audit", headers=admin_headers())
|
||||
assert everything.status_code == 200
|
||||
names = {e["name"] for e in everything.json()["events"]}
|
||||
assert names == {"register", "claim.submitted"}
|
||||
|
||||
|
||||
class TestFailClosed:
|
||||
"""MH-2d: all five operator routes fail-closed — no token,
|
||||
wrong token, the unset-config class (an empty admin_token 403s
|
||||
EVERY request even with a candidate), and the cross-principal
|
||||
class (a VALID employer session token is NOT an admin token)."""
|
||||
|
||||
def test_all_five_no_token_403(self, tmp_path: Path) -> None:
|
||||
with make_admin_client(tmp_path) as client:
|
||||
for route in ROUTES:
|
||||
assert client.get(route).status_code == 403, route
|
||||
for route in DECISION_ROUTES:
|
||||
assert client.post(route.format(claim_id="clm-x")).status_code == 403, route
|
||||
|
||||
def test_all_five_wrong_token_403(self, tmp_path: Path) -> None:
|
||||
with make_admin_client(tmp_path) as client:
|
||||
bad = admin_headers("wrong-token")
|
||||
for route in ROUTES:
|
||||
assert client.get(route, headers=bad).status_code == 403, route
|
||||
for route in DECISION_ROUTES:
|
||||
assert (
|
||||
client.post(route.format(claim_id="clm-x"), headers=bad).status_code == 403
|
||||
), route
|
||||
|
||||
def test_unset_token_fails_closed_on_all_five(self, tmp_path: Path) -> None:
|
||||
"""The R-56 matrix's unset-config arm: AI_ADMIN_TOKEN unset →
|
||||
every route 403s EVEN WITH a candidate token."""
|
||||
with make_admin_client(tmp_path, admin_token=None) as client:
|
||||
candidate = admin_headers("anything")
|
||||
for route in ROUTES:
|
||||
assert client.get(route, headers=candidate).status_code == 403, route
|
||||
for route in DECISION_ROUTES:
|
||||
assert (
|
||||
client.post(route.format(claim_id="clm-x"), headers=candidate).status_code
|
||||
== 403
|
||||
), route
|
||||
|
||||
def test_employer_session_token_is_not_admin(self, tmp_path: Path) -> None:
|
||||
"""Token namespaces cannot collide (D-090): a VALID employer
|
||||
session token in the admin header fails the admin gate — the
|
||||
principal classes stay disjoint (one class fails the other's
|
||||
gate)."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
session_token = register(client)["token"]
|
||||
swapped = {"X-Admin-Token": session_token}
|
||||
for route in ROUTES:
|
||||
assert client.get(route, headers=swapped).status_code == 403, route
|
||||
for route in DECISION_ROUTES:
|
||||
assert (
|
||||
client.post(route.format(claim_id="clm-x"), headers=swapped).status_code == 403
|
||||
), route
|
||||
|
||||
def test_decisions_fail_closed_leave_no_side_effects(self, tmp_path: Path) -> None:
|
||||
"""A 403'd decision writes NOTHING: the claim stays pending,
|
||||
no decision audit row (the gate runs before any store call)."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
seed_employer(client)
|
||||
token = register(client, email="s@example.com")["token"]
|
||||
row = claim(client, token)
|
||||
for route in DECISION_ROUTES:
|
||||
assert client.post(route.format(claim_id=row["id"])).status_code == 403, route
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
still = store.get_claim(row["id"])
|
||||
assert still is not None and still.status == "pending"
|
||||
audit = client.get(
|
||||
"/v1/employers/admin/audit?kind=claim",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
# claim.submitted only — no decision rows from the 403s.
|
||||
names = [e["name"] for e in audit.json()["events"]]
|
||||
assert names == ["claim.submitted"]
|
||||
|
||||
|
||||
class TestMounted:
|
||||
def test_all_five_routes_openapi_visible(self, tmp_path: Path) -> None:
|
||||
"""The operator routes are mounted + OpenAPI-visible (the
|
||||
_IncludedRouter wrappers hide APIRoutes from app.routes in
|
||||
this starlette — the schema is the honest registry)."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
schema = client.get("/openapi.json").json()
|
||||
paths = schema["paths"]
|
||||
for route in ROUTES:
|
||||
assert route in paths, route
|
||||
assert set(paths[route]) == {"get"}, route
|
||||
for route in DECISION_ROUTES:
|
||||
formatted = route.format(claim_id="{claim_id}")
|
||||
assert formatted in paths, formatted
|
||||
assert set(paths[formatted]) == {"post"}, formatted
|
||||
|
||||
def test_accounts_contract_no_hash_field(self, tmp_path: Path) -> None:
|
||||
"""Wire-contract pin: AdminEmployerAccount exposes exactly the
|
||||
D-094 columns — password_hash is structurally absent."""
|
||||
with make_admin_client(tmp_path) as client:
|
||||
schema = client.get("/openapi.json").json()
|
||||
account = schema["components"]["schemas"]["AdminEmployerAccount"]
|
||||
assert set(account["properties"]) == {
|
||||
"id",
|
||||
"email",
|
||||
"employer_id",
|
||||
"created_at",
|
||||
"email_verified",
|
||||
}
|
||||
# D-097: the marker is a constant False on the wire.
|
||||
assert account["properties"]["email_verified"]["default"] is False
|
||||
|
||||
|
||||
def test_no_learner_gate_composed() -> None:
|
||||
"""D-097: no learner gate composed on the operator routes —
|
||||
pinned at the import sites (docstring copy is not code)."""
|
||||
import inspect
|
||||
|
||||
from ai_service.api import employers_admin
|
||||
|
||||
source = inspect.getsource(employers_admin)
|
||||
assert "from .identity import" not in source
|
||||
assert "from ..identity" not in source
|
||||
assert "learner_allowlist" not in source
|
||||
|
||||
|
||||
def test_require_admin_imported_not_reimplemented() -> None:
|
||||
"""D-094: the guard is IMPORTED from marketplace_admin (the
|
||||
billing_admin.py:27 precedent) — no compare/digest in territory."""
|
||||
import inspect
|
||||
|
||||
from ai_service.api import employers_admin
|
||||
from ai_service.api.marketplace_admin import require_admin
|
||||
|
||||
source = inspect.getsource(employers_admin)
|
||||
assert "from .marketplace_admin import require_admin" in source
|
||||
assert "compare_digest" not in source
|
||||
assert employers_admin.require_admin is require_admin
|
||||
@@ -0,0 +1,407 @@
|
||||
"""EmployerStore claim-method tests (Task 2-1-02, REQ-10-002).
|
||||
|
||||
G-316 pins: the partial UNIQUE index uq_pending_claim_per_entity lands
|
||||
in sqlite_master on fresh DBs (create_all emits it — tests use fresh
|
||||
tmp_path DBs, never a P1-era file); a raced second pending insert →
|
||||
IntegrityError (the caller's 409), never a duplicate pending row.
|
||||
MH-2c pins: one PENDING claim per entity + one APPROVED owner per
|
||||
entity + rejection frees the entity. decided_by/decided_at provenance
|
||||
pinned on both arms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session
|
||||
|
||||
from ai_service.employers.store import SQLiteEmployerStore
|
||||
|
||||
PASSWORD_HASH = "scrypt$16384$8$1$c2FsdA$aGFzaA" # self-describing string
|
||||
EVIDENCE = {
|
||||
"account_email_domain": "openai.example",
|
||||
"entity_website_domain": "example.com",
|
||||
"match": False,
|
||||
}
|
||||
|
||||
|
||||
def make_store(tmp_path: Path) -> SQLiteEmployerStore:
|
||||
return SQLiteEmployerStore(db_path=tmp_path / "claims-store.db")
|
||||
|
||||
|
||||
def add_account(
|
||||
store: SQLiteEmployerStore, email: str
|
||||
) -> str:
|
||||
row, _ = store.insert_account(email=email, password_hash=PASSWORD_HASH)
|
||||
return row.id
|
||||
|
||||
|
||||
def add_claim(
|
||||
store: SQLiteEmployerStore,
|
||||
*,
|
||||
account_id: str,
|
||||
employer_id: str,
|
||||
evidence: dict | None = None,
|
||||
) -> tuple[str, bool]:
|
||||
row, created = store.insert_claim(
|
||||
account_id=account_id,
|
||||
employer_id=employer_id,
|
||||
evidence=evidence if evidence is not None else EVIDENCE,
|
||||
)
|
||||
return row.id, created
|
||||
|
||||
|
||||
def count_pending(
|
||||
store: SQLiteEmployerStore, employer_id: str
|
||||
) -> int:
|
||||
with Session(store._engine) as session:
|
||||
return int(
|
||||
session.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM employer_claims "
|
||||
"WHERE employer_id = :eid AND status = 'pending'"
|
||||
),
|
||||
{"eid": employer_id},
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
class TestPartialIndex:
|
||||
def test_uq_pending_claim_per_entity_in_sqlite_master(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""G-316 pinned: create_all emits the partial UNIQUE index —
|
||||
check-first alone is TOCTOU; the index makes the guard
|
||||
structural."""
|
||||
store = make_store(tmp_path)
|
||||
with store._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT name, sql FROM sqlite_master "
|
||||
"WHERE type='index' "
|
||||
"AND tbl_name='employer_claims' "
|
||||
"AND name='uq_pending_claim_per_entity'"
|
||||
)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert "UNIQUE INDEX" in str(row[1])
|
||||
assert "WHERE status='pending'" in str(row[1])
|
||||
|
||||
|
||||
class TestInsertClaim:
|
||||
def test_first_claim_created_true_pending(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
store = make_store(tmp_path)
|
||||
account_id = add_account(store, "founder@example.com")
|
||||
claim_id, created = add_claim(
|
||||
store, account_id=account_id, employer_id="emp-1"
|
||||
)
|
||||
assert created is True
|
||||
row = store.get_claim(claim_id)
|
||||
assert row is not None
|
||||
assert row.status == "pending"
|
||||
assert row.account_id == account_id
|
||||
assert row.employer_id == "emp-1"
|
||||
assert row.evidence == EVIDENCE
|
||||
assert row.decided_by is None and row.decided_at is None
|
||||
|
||||
def test_second_pending_claim_rejected_fast_path(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""MH-2c arm 1: one PENDING claim per entity — the check-first
|
||||
409 fast path (the insert_application precedent)."""
|
||||
store = make_store(tmp_path)
|
||||
first = add_account(store, "first@example.com")
|
||||
second = add_account(store, "second@example.com")
|
||||
first_id, created_first = add_claim(
|
||||
store, account_id=first, employer_id="emp-1"
|
||||
)
|
||||
second_row, created_second = store.insert_claim(
|
||||
account_id=second, employer_id="emp-1", evidence=EVIDENCE
|
||||
)
|
||||
assert created_first is True
|
||||
assert created_second is False
|
||||
assert second_row.id == first_id # the EXISTING row rides
|
||||
assert count_pending(store, "emp-1") == 1
|
||||
|
||||
def test_raced_second_pending_insert_surfaces_integrity_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""G-316 pinned: a raced insert (check-first read misses, the
|
||||
index refuses the write) SURFACES IntegrityError — the caller
|
||||
maps it to the SAME honest 409; never a duplicate pending row.
|
||||
Raw SQL via a direct Session simulates the race window after
|
||||
the check-first read."""
|
||||
store = make_store(tmp_path)
|
||||
account_id = add_account(store, "founder@example.com")
|
||||
add_claim(store, account_id=account_id, employer_id="emp-1")
|
||||
with Session(store._engine) as session:
|
||||
with pytest.raises(IntegrityError):
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO employer_claims "
|
||||
"(id, account_id, employer_id, status, evidence, "
|
||||
"created_at) "
|
||||
"VALUES ('clm-raced', :acct, 'emp-1', 'pending', "
|
||||
"'{}', :now)"
|
||||
),
|
||||
{"acct": account_id, "now": datetime.now(UTC)},
|
||||
)
|
||||
session.flush()
|
||||
session.rollback()
|
||||
assert count_pending(store, "emp-1") == 1
|
||||
|
||||
def test_evidence_stored_as_provenance_json(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""The advisory evidence snapshot rides the claim row (D-089
|
||||
— computed at claim time, never recomputed on read)."""
|
||||
store = make_store(tmp_path)
|
||||
account_id = add_account(store, "founder@example.com")
|
||||
claim_id, _ = add_claim(
|
||||
store,
|
||||
account_id=account_id,
|
||||
employer_id="emp-1",
|
||||
evidence={
|
||||
"account_email_domain": "acme.com",
|
||||
"entity_website_domain": "acme.com",
|
||||
"match": True,
|
||||
},
|
||||
)
|
||||
with Session(store._engine) as session:
|
||||
stored = session.execute(
|
||||
text(
|
||||
"SELECT evidence FROM employer_claims WHERE id = :cid"
|
||||
),
|
||||
{"cid": claim_id},
|
||||
).scalar_one()
|
||||
# The sa_type=JSON column round-trips through the ORM as a
|
||||
# dict (insert_claim's own read path proves the stored shape);
|
||||
# raw SQL returns the serialized JSON string — parse it.
|
||||
assert json.loads(stored) == {
|
||||
"account_email_domain": "acme.com",
|
||||
"entity_website_domain": "acme.com",
|
||||
"match": True,
|
||||
}
|
||||
# And the ORM read path hands the dict back.
|
||||
row = store.get_claim(claim_id)
|
||||
assert row is not None
|
||||
assert row.evidence == {
|
||||
"account_email_domain": "acme.com",
|
||||
"entity_website_domain": "acme.com",
|
||||
"match": True,
|
||||
}
|
||||
|
||||
|
||||
class TestDecideClaim:
|
||||
def test_approve_sets_employer_id_and_audit_row(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""MH-2c/MH-2a store half: approve → employer_accounts.
|
||||
employer_id set + the `claim` audit row with operator
|
||||
provenance."""
|
||||
store = make_store(tmp_path)
|
||||
account_id = add_account(store, "founder@example.com")
|
||||
claim_id, _ = add_claim(
|
||||
store, account_id=account_id, employer_id="emp-1"
|
||||
)
|
||||
decided = store.decide_claim(
|
||||
claim_id,
|
||||
decision="approved",
|
||||
operator="op-1",
|
||||
now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert decided is not None
|
||||
assert decided.status == "approved"
|
||||
assert decided.decided_by == "op-1"
|
||||
assert decided.decided_at == datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
|
||||
account = store.get_account(account_id)
|
||||
assert account is not None
|
||||
assert account.employer_id == "emp-1"
|
||||
with Session(store._engine) as session:
|
||||
audit = session.execute(
|
||||
text(
|
||||
"SELECT kind, name, operator, detail "
|
||||
"FROM employer_audit_events ORDER BY id"
|
||||
)
|
||||
).fetchall()
|
||||
assert len(audit) == 1
|
||||
kind, name, operator, detail = audit[0]
|
||||
# Raw SQL hands the serialized JSON string back — parse it.
|
||||
assert kind == "claim"
|
||||
assert name == "claim.approved"
|
||||
assert operator == "op-1"
|
||||
assert json.loads(detail) == {
|
||||
"claim_id": claim_id,
|
||||
"employer_id": "emp-1",
|
||||
"operator": "op-1",
|
||||
}
|
||||
|
||||
def test_reject_sets_provenance_keeps_account_free(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
store = make_store(tmp_path)
|
||||
account_id = add_account(store, "founder@example.com")
|
||||
claim_id, _ = add_claim(
|
||||
store, account_id=account_id, employer_id="emp-1"
|
||||
)
|
||||
decided = store.decide_claim(
|
||||
claim_id,
|
||||
decision="rejected",
|
||||
operator="op-2",
|
||||
now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert decided is not None
|
||||
assert decided.status == "rejected"
|
||||
assert decided.decided_by == "op-2"
|
||||
assert decided.decided_at == datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
|
||||
account = store.get_account(account_id)
|
||||
assert account is not None
|
||||
assert account.employer_id is None # rejection never binds
|
||||
|
||||
def test_rejection_frees_entity_for_new_claim(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""MH-2c arm 3: rejection frees the entity — a new claim on the
|
||||
same entity succeeds after a reject."""
|
||||
store = make_store(tmp_path)
|
||||
first = add_account(store, "first@example.com")
|
||||
second = add_account(store, "second@example.com")
|
||||
first_id, _ = add_claim(
|
||||
store, account_id=first, employer_id="emp-1"
|
||||
)
|
||||
store.decide_claim(
|
||||
first_id, decision="rejected", operator="op-1"
|
||||
)
|
||||
second_id, created = add_claim(
|
||||
store, account_id=second, employer_id="emp-1"
|
||||
)
|
||||
assert created is True
|
||||
assert second_id != first_id
|
||||
row = store.get_claim(second_id)
|
||||
assert row is not None and row.status == "pending"
|
||||
|
||||
def test_second_approve_entity_already_owned_surfaces_integrity_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""MH-2c arm 2: one APPROVED owner per entity. Account A's
|
||||
claim on emp-1 approves (A owns emp-1); account B then claims
|
||||
the now-unblocked entity and the second approve collides on
|
||||
employer_accounts.employer_id UNIQUE — B's UPDATE would bind a
|
||||
second owner to emp-1. The raw-SQL arm below pins the
|
||||
STRUCTURAL constraint itself; the decide_claim arm (next test)
|
||||
pins the store path."""
|
||||
store = make_store(tmp_path)
|
||||
first = add_account(store, "first@example.com")
|
||||
second = add_account(store, "second@example.com")
|
||||
first_id, _ = add_claim(
|
||||
store, account_id=first, employer_id="emp-1"
|
||||
)
|
||||
store.decide_claim(first_id, decision="approved", operator="op-1")
|
||||
# B claims the entity after the pending window closed.
|
||||
second_id, _ = add_claim(
|
||||
store, account_id=second, employer_id="emp-1"
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
store.decide_claim(second_id, decision="approved", operator="op-2")
|
||||
# The refused approve bound nothing: B still holds no entity,
|
||||
# A still owns emp-1, and the second claim stays pending.
|
||||
a = store.get_account(first)
|
||||
b = store.get_account(second)
|
||||
assert a is not None and a.employer_id == "emp-1"
|
||||
assert b is not None and b.employer_id is None
|
||||
still = store.get_claim(second_id)
|
||||
assert still is not None and still.status == "pending"
|
||||
assert count_pending(store, "emp-1") == 1
|
||||
|
||||
def test_decide_unknown_claim_miss(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
assert (
|
||||
store.decide_claim(
|
||||
"clm-nope", decision="approved", operator="op-1"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_decide_already_decided_claim_surfaces_integrity_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Deciding a decided claim refuses — the hand-raised guard
|
||||
(IntegrityError shape, the caller's honest 409)."""
|
||||
store = make_store(tmp_path)
|
||||
account_id = add_account(store, "founder@example.com")
|
||||
claim_id, _ = add_claim(
|
||||
store, account_id=account_id, employer_id="emp-1"
|
||||
)
|
||||
store.decide_claim(claim_id, decision="rejected", operator="op-1")
|
||||
with pytest.raises(IntegrityError):
|
||||
store.decide_claim(
|
||||
claim_id, decision="approved", operator="op-1"
|
||||
)
|
||||
|
||||
def test_approve_when_account_already_holds_entity_integrity_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""One ACCOUNT : one entity — approving B's claim when B
|
||||
already holds emp-2 refuses (B's employer_id is set; the
|
||||
hand-raised guard surfaces IntegrityError)."""
|
||||
store = make_store(tmp_path)
|
||||
account_id = add_account(store, "founder@example.com")
|
||||
first_id, _ = add_claim(
|
||||
store, account_id=account_id, employer_id="emp-2"
|
||||
)
|
||||
store.decide_claim(first_id, decision="approved", operator="op-1")
|
||||
second_id, _ = add_claim(
|
||||
store, account_id=account_id, employer_id="emp-9"
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
store.decide_claim(
|
||||
second_id, decision="approved", operator="op-1"
|
||||
)
|
||||
|
||||
def test_invalid_decision_rejected(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
account_id = add_account(store, "founder@example.com")
|
||||
claim_id, _ = add_claim(
|
||||
store, account_id=account_id, employer_id="emp-1"
|
||||
)
|
||||
with pytest.raises(ValueError, match="invalid decision"):
|
||||
store.decide_claim(
|
||||
claim_id, decision="maybe", operator="op-1"
|
||||
)
|
||||
|
||||
|
||||
class TestListClaims:
|
||||
def test_status_filter_and_newest_first(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
store = make_store(tmp_path)
|
||||
a = add_account(store, "a@example.com")
|
||||
b = add_account(store, "b@example.com")
|
||||
add_claim(store, account_id=a, employer_id="emp-1")
|
||||
add_claim(store, account_id=b, employer_id="emp-2")
|
||||
pending = store.list_claims(status="pending")
|
||||
assert len(pending) == 2
|
||||
assert all(row.status == "pending" for row in pending)
|
||||
# Newest first (created_at DESC).
|
||||
first_created = pending[0].created_at
|
||||
second_created = pending[1].created_at
|
||||
assert first_created is not None and second_created is not None
|
||||
assert first_created >= second_created
|
||||
# Approve one, then the filter arms.
|
||||
store.decide_claim(pending[0].id, decision="approved", operator="op-1")
|
||||
assert len(store.list_claims(status="pending")) == 1
|
||||
approved = store.list_claims(status="approved")
|
||||
assert len(approved) == 1
|
||||
assert approved[0].status == "approved"
|
||||
assert len(store.list_claims()) == 2 # no filter → all
|
||||
|
||||
def test_get_claim_miss(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
assert store.get_claim("clm-nope") is None
|
||||
@@ -0,0 +1,169 @@
|
||||
"""scrypt KDF tests (Task 1-1-03, D-086/R-55, REQ-10-001).
|
||||
|
||||
REAL KDF, no fake-hash path (D-096): every accept/reject below
|
||||
performs actual derivations (~2 × 50 ms — inside the R-55/R-64
|
||||
budget). Tests assert accept/reject BEHAVIOR only — never hash bytes
|
||||
(a byte pin would break the row format's evolution guarantee).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from ai_service.employers.passwords import (
|
||||
SCRYPT_DKLEN,
|
||||
SCRYPT_N,
|
||||
SCRYPT_P,
|
||||
SCRYPT_R,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
WRONG = "Tr0ub4dor&3"
|
||||
|
||||
|
||||
class TestParamsPinned:
|
||||
"""D-086: the KDF parameters are module constants, pinned by test
|
||||
(the PAST_DUE_GRACE_DAYS shape)."""
|
||||
|
||||
def test_scrypt_constants_pinned(self) -> None:
|
||||
assert SCRYPT_N == 2**14
|
||||
assert SCRYPT_N == 16384
|
||||
assert SCRYPT_R == 8
|
||||
assert SCRYPT_P == 1
|
||||
assert SCRYPT_DKLEN == 32
|
||||
|
||||
def test_stored_format_carries_the_pinned_params(self) -> None:
|
||||
stored = hash_password(PASSWORD)
|
||||
parts = stored.split("$")
|
||||
assert len(parts) == 6
|
||||
assert parts[0] == "scrypt"
|
||||
assert parts[1] == "16384" # N — the R-55 probe cost point
|
||||
assert parts[2] == "8" # r
|
||||
assert parts[3] == "1" # p
|
||||
# salt + hash are base64; the derived length rides the row's
|
||||
# tail (dklen=32 bytes → 44 base64 chars, no padding surprises).
|
||||
assert len(base64.b64decode(parts[4])) == 16 # salt bytes
|
||||
assert len(base64.b64decode(parts[5])) == SCRYPT_DKLEN
|
||||
|
||||
|
||||
class TestHashVerify:
|
||||
"""REAL scrypt — accept/reject behavior only (never hash bytes)."""
|
||||
|
||||
def test_hash_then_verify_accepts(self) -> None:
|
||||
stored = hash_password(PASSWORD)
|
||||
assert verify_password(stored, PASSWORD) is True
|
||||
|
||||
def test_wrong_password_rejected(self) -> None:
|
||||
stored = hash_password(PASSWORD)
|
||||
assert verify_password(stored, WRONG) is False
|
||||
|
||||
def test_two_hashes_of_same_password_differ(self) -> None:
|
||||
"""Random per-password salt: two hashes of ONE password are
|
||||
distinct stored strings, and each verifies against the same
|
||||
password (the salt rides the row)."""
|
||||
first = hash_password(PASSWORD)
|
||||
second = hash_password(PASSWORD)
|
||||
assert first != second
|
||||
assert verify_password(first, PASSWORD) is True
|
||||
assert verify_password(second, PASSWORD) is True
|
||||
|
||||
def test_salt_is_random_not_derived(self) -> None:
|
||||
"""A-305 family: the salt is `secrets.token_bytes(16)` — never
|
||||
a deterministic function of the password (two hashes would
|
||||
collide otherwise, which is what the differ test pins; this
|
||||
test pins the salt's independence directly)."""
|
||||
first = hash_password(PASSWORD)
|
||||
second = hash_password(PASSWORD)
|
||||
assert first.split("$")[4] != second.split("$")[4]
|
||||
|
||||
|
||||
class TestSelfDescribing:
|
||||
"""The params ride the row — a stored string with EXPLICIT
|
||||
different params still verifies (params evolve without
|
||||
invalidating rows)."""
|
||||
|
||||
def test_row_with_explicit_different_params_verifies(self) -> None:
|
||||
# An "old-format" row: N=2^10 (cheaper era), same r/p/dklen —
|
||||
# hand-built in the self-describing format.
|
||||
n, r, p, dklen = 2**10, 8, 1, 32
|
||||
salt = b"\x01" * 16
|
||||
derived = hashlib.scrypt(
|
||||
PASSWORD.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
dklen=dklen,
|
||||
maxmem=33554432,
|
||||
)
|
||||
stored = "$".join(
|
||||
(
|
||||
"scrypt",
|
||||
str(n),
|
||||
str(r),
|
||||
str(p),
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(derived).decode("ascii"),
|
||||
)
|
||||
)
|
||||
assert verify_password(stored, PASSWORD) is True
|
||||
assert verify_password(stored, WRONG) is False
|
||||
|
||||
def test_verify_never_uses_module_constants_for_derivation(self) -> None:
|
||||
"""The pin behind the self-describing guarantee: a row whose
|
||||
N differs from SCRYPT_N cannot verify if verify (wrongly)
|
||||
derived at the module N — accept proves it derived at the
|
||||
row's params."""
|
||||
n = 2**11 # != SCRYPT_N
|
||||
salt = b"\x02" * 16
|
||||
derived = hashlib.scrypt(
|
||||
PASSWORD.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=SCRYPT_R,
|
||||
p=SCRYPT_P,
|
||||
dklen=SCRYPT_DKLEN,
|
||||
maxmem=33554432,
|
||||
)
|
||||
stored = "$".join(
|
||||
(
|
||||
"scrypt",
|
||||
str(n),
|
||||
str(SCRYPT_R),
|
||||
str(SCRYPT_P),
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(derived).decode("ascii"),
|
||||
)
|
||||
)
|
||||
assert n != SCRYPT_N
|
||||
assert verify_password(stored, PASSWORD) is True
|
||||
|
||||
|
||||
class TestMalformedStored:
|
||||
"""A malformed stored row is an honest False — never a raise (the
|
||||
auth route maps it to 401, not a 500)."""
|
||||
|
||||
def test_empty_stored_rejected(self) -> None:
|
||||
assert verify_password("", PASSWORD) is False
|
||||
|
||||
def test_wrong_algorithm_rejected(self) -> None:
|
||||
assert verify_password("argon2$1$8$1$c2FsdA$aGFzaA", PASSWORD) is False
|
||||
|
||||
def test_truncated_stored_rejected(self) -> None:
|
||||
assert verify_password("scrypt$16384$8$1", PASSWORD) is False
|
||||
|
||||
def test_bad_int_params_rejected(self) -> None:
|
||||
assert verify_password("scrypt$abc$8$1$c2FsdA$aGFzaA", PASSWORD) is False
|
||||
|
||||
def test_bad_base64_rejected(self) -> None:
|
||||
assert verify_password("scrypt$16384$8$1$!!!$???", PASSWORD) is False
|
||||
|
||||
def test_zero_params_rejected(self) -> None:
|
||||
assert verify_password("scrypt$0$8$1$c2FsdA$aGFzaA", PASSWORD) is False
|
||||
|
||||
def test_empty_salt_rejected(self) -> None:
|
||||
# base64 "" decodes to zero bytes — the _derive guard rejects.
|
||||
assert verify_password("scrypt$16384$8$1$$aGFzaA", PASSWORD) is False
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Own-postings read route tests — GET /v1/employers/postings
|
||||
(Task 3-1-01, REQ-10-003, D-092/G-319).
|
||||
|
||||
Pinned here: own-ENTITY rows only (a second claimed employer's postings
|
||||
never appear); unclaimed → 403 with the claim-CTA detail dict (the
|
||||
D-090 authorization class — 401 stays credential failure); status
|
||||
facets draft|published|closed with per-status counts; per-posting
|
||||
applicant_count matching seeded applications (the batched
|
||||
count_applications_by_posting — never a per-posting N+1); no-token
|
||||
→ 401; the G-319 PII boundary — learner identifiers NEVER ride any
|
||||
/v1/employers/* response (negative assertion over the full JSON).
|
||||
|
||||
REAL scrypt throughout (register mints a real hash — the conftest
|
||||
fixtures handle limiter resets).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Employer, Posting
|
||||
|
||||
from .conftest import bearer, make_client, register
|
||||
|
||||
OWN_ENTITY = "emp-self"
|
||||
OTHER_ENTITY = "emp-rival"
|
||||
#: Learner ids that seed applications — the PII the boundary bans.
|
||||
LEARNER_IDS = ["learner-alpha", "learner-beta", "learner-gamma"]
|
||||
|
||||
|
||||
def seed_posting(
|
||||
client: TestClient,
|
||||
posting_id: str,
|
||||
employer_id: str,
|
||||
*,
|
||||
status: str = "draft",
|
||||
) -> None:
|
||||
"""Upsert a marketplace posting row through the app's own store
|
||||
(app.state.marketplace_store — the same DI the route composes),
|
||||
then drive the lifecycle to `status` via the store's transitions."""
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
row = store.insert_posting(
|
||||
Posting(
|
||||
id=posting_id,
|
||||
source="seed-corpus",
|
||||
external_id=posting_id,
|
||||
employer_id=employer_id,
|
||||
title=f"Posting {posting_id}",
|
||||
)
|
||||
)
|
||||
if status == "published":
|
||||
store.set_posting_status(row.id, "published")
|
||||
elif status == "closed":
|
||||
store.set_posting_status(row.id, "published")
|
||||
store.set_posting_status(row.id, "closed")
|
||||
|
||||
|
||||
def seed_application(
|
||||
client: TestClient, posting_id: str, learner_id: str
|
||||
) -> None:
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.insert_application(posting_id, learner_id)
|
||||
|
||||
|
||||
def claim_and_approve(
|
||||
client: TestClient, token: str, employer_id: str
|
||||
) -> None:
|
||||
"""File the claim + drive the operator approve (the store path the
|
||||
2-2 admin route composes) — binds account.employer_id."""
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": employer_id},
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
decided = store.decide_claim(
|
||||
resp.json()["id"], decision="approved", operator="op-1"
|
||||
)
|
||||
assert decided is not None and decided.status == "approved"
|
||||
|
||||
|
||||
def seed_employer_rows(client: TestClient) -> None:
|
||||
"""Both entities exist in the marketplace corpus (claim targets the
|
||||
v0.6 ingested EmployerRow data)."""
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=OWN_ENTITY,
|
||||
source="seed-corpus",
|
||||
external_id=OWN_ENTITY,
|
||||
name="Self Labs",
|
||||
website="https://example.com/self",
|
||||
)
|
||||
)
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=OTHER_ENTITY,
|
||||
source="seed-corpus",
|
||||
external_id=OTHER_ENTITY,
|
||||
name="Rival Labs",
|
||||
website="https://example.com/rival",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_own_postings(client: TestClient, token: str):
|
||||
return client.get("/v1/employers/postings", headers=bearer(token))
|
||||
|
||||
|
||||
class TestOwnPostingsRoute:
|
||||
def test_own_entity_rows_only(self, client: TestClient) -> None:
|
||||
"""MH-3a's route arm: the response carries ONLY the session
|
||||
employer's postings — a second claimed employer's rows (all
|
||||
statuses) never appear."""
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_and_approve(client, token, OWN_ENTITY)
|
||||
# The OWN employer's postings, one per lifecycle status.
|
||||
seed_posting(client, "own-draft", OWN_ENTITY, status="draft")
|
||||
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
|
||||
seed_posting(client, "own-closed", OWN_ENTITY, status="closed")
|
||||
# A SECOND claimed employer with postings of every status.
|
||||
rival_token = register(client, email="rival@example.com")["token"]
|
||||
claim_and_approve(client, rival_token, OTHER_ENTITY)
|
||||
seed_posting(client, "rival-draft", OTHER_ENTITY, status="draft")
|
||||
seed_posting(client, "rival-pub", OTHER_ENTITY, status="published")
|
||||
resp = get_own_postings(client, token)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
ids = [p["id"] for p in body["postings"]]
|
||||
assert sorted(ids) == ["own-closed", "own-draft", "own-pub"]
|
||||
assert all(p["employer_id"] == OWN_ENTITY for p in body["postings"])
|
||||
|
||||
def test_unclaimed_403_claim_cta(self, client: TestClient) -> None:
|
||||
"""D-090 403 semantics: a VALID session whose account holds no
|
||||
claimed entity → 403 + the claim-CTA detail dict (never 401 —
|
||||
the credential is fine; the authorization is not)."""
|
||||
token = register(client)["token"]
|
||||
resp = get_own_postings(client, token)
|
||||
assert resp.status_code == 403, resp.text
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["reason"] == "no_claimed_employer"
|
||||
assert "claim" in detail["claim_cta"]
|
||||
|
||||
def test_no_token_401(self, client: TestClient) -> None:
|
||||
resp = client.get("/v1/employers/postings")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_invalid_token_401(self, client: TestClient) -> None:
|
||||
resp = get_own_postings(client, "not-a-real-token")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_status_facets_pinned(self, client: TestClient) -> None:
|
||||
"""Per-status facet counts over the OWN postings — all three
|
||||
lifecycle keys always present, honest zeros included; the rival
|
||||
employer's rows contribute NOTHING (own-entity scope)."""
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_and_approve(client, token, OWN_ENTITY)
|
||||
seed_posting(client, "own-a", OWN_ENTITY, status="draft")
|
||||
seed_posting(client, "own-b", OWN_ENTITY, status="draft")
|
||||
seed_posting(client, "own-c", OWN_ENTITY, status="published")
|
||||
seed_posting(client, "own-d", OWN_ENTITY, status="closed")
|
||||
rival_token = register(client, email="rival@example.com")["token"]
|
||||
claim_and_approve(client, rival_token, OTHER_ENTITY)
|
||||
seed_posting(client, "rival-a", OTHER_ENTITY, status="published")
|
||||
resp = get_own_postings(client, token)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["facets"] == {"draft": 2, "published": 1, "closed": 1}
|
||||
assert body["total"] == 4
|
||||
assert len(body["postings"]) == 4
|
||||
|
||||
def test_facets_zero_keys_honest(self, client: TestClient) -> None:
|
||||
"""Every status key rides the response even at 0 (an employer
|
||||
with zero postings still gets the full facet shape)."""
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_and_approve(client, token, OWN_ENTITY)
|
||||
resp = get_own_postings(client, token)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["postings"] == []
|
||||
assert body["facets"] == {"draft": 0, "published": 0, "closed": 0}
|
||||
assert body["total"] == 0
|
||||
|
||||
def test_applicant_count_matches_seeded_applications(
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""applicant_count == the seeded application rows per posting
|
||||
(the D-092 overview data source); zero-application postings
|
||||
carry the honest 0."""
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_and_approve(client, token, OWN_ENTITY)
|
||||
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
|
||||
seed_posting(client, "own-draft", OWN_ENTITY, status="draft")
|
||||
seed_application(client, "own-pub", LEARNER_IDS[0])
|
||||
seed_application(client, "own-pub", LEARNER_IDS[1])
|
||||
seed_application(client, "own-draft", LEARNER_IDS[2])
|
||||
# A rival posting's applications must not leak into counts.
|
||||
rival_token = register(client, email="rival@example.com")["token"]
|
||||
claim_and_approve(client, rival_token, OTHER_ENTITY)
|
||||
seed_posting(client, "rival-pub", OTHER_ENTITY, status="published")
|
||||
seed_application(client, "rival-pub", LEARNER_IDS[0])
|
||||
resp = get_own_postings(client, token)
|
||||
assert resp.status_code == 200
|
||||
counts = {p["id"]: p["applicant_count"] for p in resp.json()["postings"]}
|
||||
assert counts == {"own-pub": 2, "own-draft": 1}
|
||||
|
||||
def test_pii_never_rides_the_response(self, client: TestClient) -> None:
|
||||
"""G-319 PII boundary (binding, negative assertion): the FULL
|
||||
response JSON contains NO learner identifier — neither the
|
||||
seeded learner ids nor any per-application datum beyond counts.
|
||||
Employer-facing responses expose applicant COUNTS ONLY."""
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_and_approve(client, token, OWN_ENTITY)
|
||||
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
|
||||
for learner_id in LEARNER_IDS:
|
||||
seed_application(client, "own-pub", learner_id)
|
||||
resp = get_own_postings(client, token)
|
||||
assert resp.status_code == 200
|
||||
payload: str = resp.text
|
||||
for learner_id in LEARNER_IDS:
|
||||
assert learner_id not in payload
|
||||
# Belt + suspenders: the structured walk finds no per-application
|
||||
# object anywhere in the tree (only applicant_count integers).
|
||||
body: dict[str, Any] = resp.json()
|
||||
assert json.dumps(body).count("applicant_count") == len(
|
||||
body["postings"]
|
||||
)
|
||||
assert all(
|
||||
isinstance(p["applicant_count"], int) for p in body["postings"]
|
||||
)
|
||||
|
||||
|
||||
class TestBatchedReadDiscipline:
|
||||
def test_count_applications_by_posting_single_grouped_read(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
"""G-319: the counts ride ONE grouped read for the whole id set
|
||||
(never a per-posting N+1) — pinned at the STORE with an engine
|
||||
event counter over a many-posting id set."""
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
|
||||
try:
|
||||
store.insert_posting(
|
||||
Posting(
|
||||
id="p1",
|
||||
source="seed-corpus",
|
||||
external_id="p1",
|
||||
employer_id=OWN_ENTITY,
|
||||
title="P1",
|
||||
)
|
||||
)
|
||||
store.insert_posting(
|
||||
Posting(
|
||||
id="p2",
|
||||
source="seed-corpus",
|
||||
external_id="p2",
|
||||
employer_id=OWN_ENTITY,
|
||||
title="P2",
|
||||
)
|
||||
)
|
||||
store.insert_application("p1", "learner-a")
|
||||
store.insert_application("p1", "learner-b")
|
||||
store.insert_application("p2", "learner-a")
|
||||
# Count the SELECTs the batched read issues.
|
||||
selects: list[str] = []
|
||||
from sqlalchemy import event
|
||||
|
||||
@event.listens_for(store._engine, "before_cursor_execute")
|
||||
def _capture(
|
||||
conn: object,
|
||||
cursor: object,
|
||||
statement: str,
|
||||
parameters: object,
|
||||
context: object,
|
||||
executemany: bool,
|
||||
) -> None:
|
||||
if statement.strip().upper().startswith("SELECT"):
|
||||
selects.append(statement)
|
||||
|
||||
result = store.count_applications_by_posting(["p1", "p2", "p-missing"])
|
||||
assert result == {"p1": 2, "p2": 1}
|
||||
assert len(selects) == 1 # ONE grouped read, not N per-posting
|
||||
assert "GROUP BY" in selects[0].upper()
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_empty_id_set_returns_empty_dict(self, tmp_path) -> None:
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
|
||||
try:
|
||||
assert store.count_applications_by_posting([]) == {}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_list_postings_by_employer_all_statuses(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
"""The owner's read sees the FULL lifecycle (draft rows are
|
||||
visible to their owner, absent from every published-only path),
|
||||
scoped to the employer_id, posted_at DESC then id."""
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
|
||||
try:
|
||||
for pid in ("own-2", "own-1"):
|
||||
store.insert_posting(
|
||||
Posting(
|
||||
id=pid,
|
||||
source="seed-corpus",
|
||||
external_id=pid,
|
||||
employer_id=OWN_ENTITY,
|
||||
title=pid,
|
||||
)
|
||||
)
|
||||
store.set_posting_status(pid, "published")
|
||||
store.insert_posting(
|
||||
Posting(
|
||||
id="own-draft",
|
||||
source="seed-corpus",
|
||||
external_id="own-draft",
|
||||
employer_id=OWN_ENTITY,
|
||||
title="own-draft",
|
||||
)
|
||||
)
|
||||
store.insert_posting(
|
||||
Posting(
|
||||
id="rival-1",
|
||||
source="seed-corpus",
|
||||
external_id="rival-1",
|
||||
employer_id=OTHER_ENTITY,
|
||||
title="rival-1",
|
||||
)
|
||||
)
|
||||
store.set_posting_status("rival-1", "published")
|
||||
rows = store.list_postings_by_employer(OWN_ENTITY)
|
||||
assert [r.id for r in rows] == ["own-1", "own-2", "own-draft"]
|
||||
assert {r.status for r in rows} == {"published", "draft"}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class TestRouteDiscipline:
|
||||
def test_marketplace_store_via_di_never_imported(self) -> None:
|
||||
"""Territory rule: the route composes the marketplace store via
|
||||
app.state DI (get_marketplace_store) — api/employers.py never
|
||||
CONSTRUCTS a store. P4 (Task 4-1-02): the write routes compose
|
||||
the admin routes' own imports (InvalidTransition, MarketplaceStore
|
||||
annotations — the marketplace_admin.py:32 precedent), so the pin
|
||||
asserts the CONSTRUCTION ban, not the import ban."""
|
||||
import inspect
|
||||
|
||||
from ai_service.api import employers as employers_api
|
||||
|
||||
source = inspect.getsource(employers_api)
|
||||
assert "SQLiteMarketplaceStore" not in source
|
||||
assert "get_marketplace_store" in source
|
||||
|
||||
def test_route_registered_in_openapi(self, tmp_path) -> None:
|
||||
"""The read route rides the mounted sibling router (main.py
|
||||
wiring pin) — /v1/employers/postings exists in the OpenAPI
|
||||
schema (the _IncludedRouter wrappers hide APIRoutes from
|
||||
app.routes in this starlette, so the schema is the honest
|
||||
registry). P4 (Task 4-1-02): the write routes share the path —
|
||||
POST (create) joined; PATCH/publish/close ride the {id}
|
||||
subpaths."""
|
||||
with make_client(tmp_path) as client:
|
||||
schema = client.get("/openapi.json").json()
|
||||
path = schema["paths"]["/v1/employers/postings"]
|
||||
assert set(path) == {"get", "post"}
|
||||
assert set(
|
||||
schema["paths"]["/v1/employers/postings/{posting_id}"]
|
||||
) == {"patch"}
|
||||
assert set(
|
||||
schema["paths"][
|
||||
"/v1/employers/postings/{posting_id}/publish"
|
||||
]
|
||||
) == {"post"}
|
||||
assert set(
|
||||
schema["paths"][
|
||||
"/v1/employers/postings/{posting_id}/close"
|
||||
]
|
||||
) == {"post"}
|
||||
|
||||
def test_own_postings_contract_shapes(self) -> None:
|
||||
"""G-319: OwnPostingSummary REUSES the PostingSummary wire shape
|
||||
(every marketplace.base.Posting field present, unchanged) +
|
||||
applicant_count; the response contract carries the list +
|
||||
facets/total."""
|
||||
from ai_service.employers.base import (
|
||||
OwnPostingsFacets,
|
||||
OwnPostingsResponse,
|
||||
OwnPostingSummary,
|
||||
)
|
||||
from ai_service.marketplace.base import Posting
|
||||
|
||||
posting_fields = set(Posting.model_fields)
|
||||
assert posting_fields < set(OwnPostingSummary.model_fields)
|
||||
assert set(OwnPostingSummary.model_fields) - posting_fields == {
|
||||
"applicant_count"
|
||||
}
|
||||
assert set(OwnPostingsFacets.model_fields) == {
|
||||
"draft",
|
||||
"published",
|
||||
"closed",
|
||||
}
|
||||
assert set(OwnPostingsResponse.model_fields) == {
|
||||
"postings",
|
||||
"facets",
|
||||
"total",
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Own-postings write routes (Task 4-1-02, REQ-10-004, G-311/317/318).
|
||||
|
||||
Pins the employer-principal posting lifecycle: create consumes from
|
||||
the employer's OWN package through the SHARED entitlement gate (the
|
||||
D-091 helper — the no-drift proof rides the admin/employer payload
|
||||
parity asserted in tests/billing/test_entitlement_parity.py); the
|
||||
G-311 gate ORDER (session 401 → claim 403 → entitlement 402 →
|
||||
insert); the G-317 employer provenance stamp; the G-318 own-drafts
|
||||
edit guard with the identical admin re-tag composition.
|
||||
|
||||
Fixtures: the conftest make_client/register/bearer + the
|
||||
test_postings_read.py claim_and_approve/seed precedents; the billing
|
||||
grant via the test_entitlement_parity.py grant_posts shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
from ..billing.test_entitlement_parity import grant_posts
|
||||
from .conftest import bearer, make_client, register
|
||||
|
||||
OWN_ENTITY = "emp-write-self"
|
||||
OTHER_ENTITY = "emp-write-rival"
|
||||
|
||||
EMPLOYER_CTA_NO_PACKAGE = (
|
||||
"This employer has no active posting package — "
|
||||
"purchase one via /v1/employers/billing/checkout."
|
||||
)
|
||||
EMPLOYER_CTA_EXHAUSTED = (
|
||||
"This employer's posting package is exhausted — "
|
||||
"buy more posts or upgrade via /v1/employers/billing/checkout."
|
||||
)
|
||||
|
||||
|
||||
def seed_employer_rows(client: TestClient) -> None:
|
||||
"""Both entities exist in the marketplace corpus (claim targets
|
||||
the v0.6 ingested EmployerRow data)."""
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=OWN_ENTITY,
|
||||
source="seed-corpus",
|
||||
external_id=OWN_ENTITY,
|
||||
name="Write Labs",
|
||||
website="https://example.com/write",
|
||||
)
|
||||
)
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=OTHER_ENTITY,
|
||||
source="seed-corpus",
|
||||
external_id=OTHER_ENTITY,
|
||||
name="Rival Labs",
|
||||
website="https://example.com/rival",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def claim_and_approve(
|
||||
client: TestClient, token: str, employer_id: str
|
||||
) -> None:
|
||||
"""File the claim + drive the operator approve (the store path
|
||||
the 2-2 admin route composes) — binds account.employer_id."""
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": employer_id},
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
decided = store.decide_claim(
|
||||
resp.json()["id"], decision="approved", operator="op-1"
|
||||
)
|
||||
assert decided is not None and decided.status == "approved"
|
||||
|
||||
|
||||
def create_own_posting(
|
||||
client: TestClient, token: str, title: str = "Owned Role"
|
||||
):
|
||||
return client.post(
|
||||
"/v1/employers/postings",
|
||||
headers=bearer(token),
|
||||
json={"title": title, "description": "g", "skills": ["python"]},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
|
||||
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def claimed(
|
||||
tmp_path: Path, billing_store: SQLiteBillingStore
|
||||
) -> Iterator[tuple[TestClient, str]]:
|
||||
"""One registered + claimed-and-approved employer session with the
|
||||
billing store state-injected (G-9). The with-block runs the
|
||||
lifespan so app.state stores wire before any seeding."""
|
||||
with make_client(tmp_path) as client:
|
||||
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
seed_employer_rows(client)
|
||||
token = register(client)["token"]
|
||||
claim_and_approve(client, token, OWN_ENTITY)
|
||||
yield client, token
|
||||
|
||||
|
||||
def _raw_posts_used(store: SQLiteBillingStore, employer_id: str) -> int:
|
||||
"""The raw row read carries the store truth an exhausted purchase
|
||||
hides from get_entitlement (the parity-test precedent)."""
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
|
||||
with Session(store._engine) as session:
|
||||
used = session.execute(
|
||||
text(
|
||||
"SELECT posts_used FROM billing_purchases "
|
||||
"WHERE employer_id = :eid ORDER BY created_at DESC"
|
||||
),
|
||||
{"eid": employer_id},
|
||||
).scalar()
|
||||
assert used is not None # the row exists (granted in-test)
|
||||
return int(used)
|
||||
|
||||
|
||||
class TestCreateOwnPosting:
|
||||
def test_create_consumes_own_package(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""The employer's OWN package is charged (posts_used 0 → 1 via
|
||||
the raw row read) and the response carries the G-317 stamp."""
|
||||
client, token = claimed
|
||||
grant_posts(billing_store, OWN_ENTITY, 2)
|
||||
resp = create_own_posting(client, token)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["source"] == "employer"
|
||||
assert body["id"].startswith("emp-")
|
||||
assert body["external_id"].startswith("emp-")
|
||||
assert body["employer_id"] == OWN_ENTITY
|
||||
assert body["status"] == "draft"
|
||||
assert _raw_posts_used(billing_store, OWN_ENTITY) == 1
|
||||
|
||||
def test_no_package_402_exact_dict(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""No package → the canonical 402 dict with the EMPLOYER CTA
|
||||
(the D-091 payload parity, employer arm, through the route)."""
|
||||
client, token = claimed
|
||||
resp = create_own_posting(client, token)
|
||||
assert resp.status_code == 402
|
||||
assert resp.json()["detail"] == {
|
||||
"reason": "no_active_package",
|
||||
"posts_used": 0,
|
||||
"posts_limit": 0,
|
||||
"upgrade_cta": EMPLOYER_CTA_NO_PACKAGE,
|
||||
}
|
||||
|
||||
def test_exhausted_402_exact_dict(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""All posts spent → the exhausted arm with the EMPLOYER CTA."""
|
||||
client, token = claimed
|
||||
grant_posts(billing_store, OWN_ENTITY, 1)
|
||||
assert create_own_posting(client, token).status_code == 201
|
||||
denied = create_own_posting(client, token, title="Second Role")
|
||||
assert denied.status_code == 402
|
||||
assert denied.json()["detail"] == {
|
||||
"reason": "posting_limit_reached",
|
||||
"posts_used": 0,
|
||||
"posts_limit": 0,
|
||||
"upgrade_cta": EMPLOYER_CTA_EXHAUSTED,
|
||||
}
|
||||
|
||||
def test_402_consumes_nothing_creates_nothing(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""R-58 gate-before-insert: the denied create leaves
|
||||
posts_used unchanged and inserts no marketplace row."""
|
||||
client, token = claimed
|
||||
marketplace_store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
grant_posts(billing_store, OWN_ENTITY, 1)
|
||||
assert create_own_posting(client, token).status_code == 201
|
||||
before = marketplace_store.list_postings_by_employer(OWN_ENTITY)
|
||||
assert len(before) == 1
|
||||
denied = create_own_posting(client, token, title="Denied Role")
|
||||
assert denied.status_code == 402
|
||||
assert _raw_posts_used(billing_store, OWN_ENTITY) == 1
|
||||
after = marketplace_store.list_postings_by_employer(OWN_ENTITY)
|
||||
assert len(after) == 1
|
||||
assert [p.id for p in after] == [p.id for p in before]
|
||||
|
||||
def test_unlimited_grant_bypasses_402(
|
||||
self,
|
||||
claimed: tuple[TestClient, str],
|
||||
billing_store: SQLiteBillingStore,
|
||||
) -> None:
|
||||
"""An unlimited (enterprise) manual grant never 402s (the
|
||||
admin-route bypass precedent, employer arm)."""
|
||||
client, token = claimed
|
||||
grant_posts(billing_store, OWN_ENTITY, None)
|
||||
for i in range(3):
|
||||
assert (
|
||||
create_own_posting(client, token, title=f"Role {i}").status_code
|
||||
== 201
|
||||
)
|
||||
|
||||
def test_no_token_401_never_402(self, tmp_path: Path) -> None:
|
||||
"""G-311 order: unauthenticated → 401, never a 402 (the
|
||||
entitlement state never leaks to anonymous callers)."""
|
||||
with make_client(tmp_path) as client:
|
||||
resp = client.post(
|
||||
"/v1/employers/postings", json={"title": "X"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_unclaimed_403_never_402(self, tmp_path: Path) -> None:
|
||||
"""G-311 order: unclaimed session → 403 + claim-CTA, never a
|
||||
402 (a logged-out visitor must not see an upgrade CTA)."""
|
||||
with make_client(tmp_path) as client:
|
||||
token = register(client)["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/postings",
|
||||
headers=bearer(token),
|
||||
json={"title": "X"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
|
||||
|
||||
|
||||
class TestEditOwnPosting:
|
||||
def test_edit_retags_on_mock_provider(
|
||||
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""G-318: the edit composes the IDENTICAL update_posting_fields
|
||||
+ tag_posting re-tag path as the admin edit (D-052 — derived
|
||||
tags). The plain MockProvider's canned reply does not validate
|
||||
as PostingTags → the re-tag fails honestly (the admin suite's
|
||||
test_edit_retags_and_old_tags_gone contract): the edit LANDS,
|
||||
pre-existing tags are KEPT, never fabricated — and the G-317
|
||||
stamp survives the edit."""
|
||||
from ai_service.marketplace.base import PostingTags
|
||||
|
||||
client, token = claimed
|
||||
grant_posts(billing_store, OWN_ENTITY, 1)
|
||||
created = create_own_posting(client, token).json()
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
store.set_posting_tags(
|
||||
created["id"],
|
||||
PostingTags(skills=["stale-skill"], seniority=None, category=None),
|
||||
)
|
||||
resp = client.patch(
|
||||
f"/v1/employers/postings/{created['id']}",
|
||||
headers=bearer(token),
|
||||
json={"title": "Edited Title", "skills": ["typescript"]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["title"] == "Edited Title"
|
||||
assert body["source"] == "employer"
|
||||
stored = store.get_posting(created["id"])
|
||||
assert stored is not None
|
||||
assert stored.title == "Edited Title"
|
||||
assert stored.skills == ["typescript"]
|
||||
# Honest tag failure: the edit stands, the OLD tags remain
|
||||
# (never erased by a bad parse, never fabricated).
|
||||
assert stored.tags is not None
|
||||
assert stored.tags.skills == ["stale-skill"]
|
||||
|
||||
def test_edit_published_409_no_side_effects(
|
||||
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""G-318: the own-drafts guard evaluates BEFORE any mutation —
|
||||
editing a PUBLISHED posting 409s with fields AND tags
|
||||
untouched."""
|
||||
client, token = claimed
|
||||
grant_posts(billing_store, OWN_ENTITY, 1)
|
||||
created = create_own_posting(client, token).json()
|
||||
assert (
|
||||
client.post(
|
||||
f"/v1/employers/postings/{created['id']}/publish",
|
||||
headers=bearer(token),
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
resp = client.patch(
|
||||
f"/v1/employers/postings/{created['id']}",
|
||||
headers=bearer(token),
|
||||
json={"title": "Must Not Land"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
stored = store.get_posting(created["id"])
|
||||
assert stored is not None
|
||||
assert stored.title == "Owned Role" # unchanged
|
||||
assert stored.status == "published"
|
||||
|
||||
def test_edit_cross_entity_403(
|
||||
self, claimed: tuple[TestClient, str]
|
||||
) -> None:
|
||||
"""D-090 authorization arm: another entity's posting → 403."""
|
||||
client, token = claimed
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
rival_row = store.insert_posting(
|
||||
__import__(
|
||||
"ai_service.marketplace.base", fromlist=["Posting"]
|
||||
).Posting(
|
||||
id="rival-posting-1",
|
||||
source="seed-corpus",
|
||||
external_id="rival-posting-1",
|
||||
employer_id=OTHER_ENTITY,
|
||||
title="Rival Role",
|
||||
)
|
||||
)
|
||||
resp = client.patch(
|
||||
f"/v1/employers/postings/{rival_row.id}",
|
||||
headers=bearer(token),
|
||||
json={"title": "Hostile Takeover"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert store.get_posting(rival_row.id).title == "Rival Role"
|
||||
|
||||
def test_edit_unknown_404(
|
||||
self, claimed: tuple[TestClient, str]
|
||||
) -> None:
|
||||
client, token = claimed
|
||||
resp = client.patch(
|
||||
"/v1/employers/postings/does-not-exist",
|
||||
headers=bearer(token),
|
||||
json={"title": "X"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestPublishCloseLifecycle:
|
||||
def test_publish_close_applicants_accrue(
|
||||
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""draft → publish (posted_at stamped, hooks composed) → an
|
||||
application lands → close; the read route's applicant_count
|
||||
reflects the application (the P3 batched read over the
|
||||
lifecycle)."""
|
||||
client, token = claimed
|
||||
grant_posts(billing_store, OWN_ENTITY, 1)
|
||||
created = create_own_posting(client, token).json()
|
||||
|
||||
published = client.post(
|
||||
f"/v1/employers/postings/{created['id']}/publish",
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert published.status_code == 200, published.text
|
||||
assert published.json()["status"] == "published"
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
stored = store.get_posting(created["id"])
|
||||
assert stored is not None and stored.posted_at is not None
|
||||
|
||||
store.insert_application(created["id"], "learner-alpha")
|
||||
listing = client.get(
|
||||
"/v1/employers/postings", headers=bearer(token)
|
||||
)
|
||||
assert listing.status_code == 200
|
||||
own = listing.json()["postings"]
|
||||
assert [p for p in own if p["id"] == created["id"]][0][
|
||||
"applicant_count"
|
||||
] == 1
|
||||
|
||||
closed = client.post(
|
||||
f"/v1/employers/postings/{created['id']}/close",
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert closed.status_code == 200
|
||||
assert closed.json()["status"] == "closed"
|
||||
|
||||
def test_publish_published_409(
|
||||
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""Re-publishing a published posting → 409 (the store's
|
||||
transition matrix, honestly surfaced)."""
|
||||
client, token = claimed
|
||||
grant_posts(billing_store, OWN_ENTITY, 1)
|
||||
created = create_own_posting(client, token).json()
|
||||
first = client.post(
|
||||
f"/v1/employers/postings/{created['id']}/publish",
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
second = client.post(
|
||||
f"/v1/employers/postings/{created['id']}/publish",
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert second.status_code == 409
|
||||
|
||||
def test_close_draft_409(
|
||||
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
|
||||
) -> None:
|
||||
"""Closing a DRAFT → 409 (published → closed is the only valid
|
||||
move)."""
|
||||
client, token = claimed
|
||||
grant_posts(billing_store, OWN_ENTITY, 1)
|
||||
created = create_own_posting(client, token).json()
|
||||
resp = client.post(
|
||||
f"/v1/employers/postings/{created['id']}/close",
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_publish_cross_entity_403(
|
||||
self, claimed: tuple[TestClient, str]
|
||||
) -> None:
|
||||
"""The own-entity guard on publish/close too (D-090)."""
|
||||
client, token = claimed
|
||||
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
rival_row = store.insert_posting(
|
||||
__import__(
|
||||
"ai_service.marketplace.base", fromlist=["Posting"]
|
||||
).Posting(
|
||||
id="rival-posting-2",
|
||||
source="seed-corpus",
|
||||
external_id="rival-posting-2",
|
||||
employer_id=OTHER_ENTITY,
|
||||
title="Rival Draft",
|
||||
)
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/employers/postings/{rival_row.id}/publish",
|
||||
headers=bearer(token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,390 @@
|
||||
"""EmployerStore tests — the 8th D-027 store (Task 1-1-02, REQ-10-001).
|
||||
|
||||
Mirrors tests/billing/test_store.py: tmp_path db, sqlite_master
|
||||
table-existence pins (all four employer_ tables), PRAGMA pins,
|
||||
IntegrityError surfacing (duplicate email, duplicate token_hash),
|
||||
the D-087 hashed-token discipline (row holds sha256 ONLY — never the
|
||||
raw token), the G-306 lazy TTL with injected now (+6d present, +8d
|
||||
absent — no sleeps), and revoke → lookup miss.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session
|
||||
|
||||
from ai_service.employers.store import (
|
||||
SESSION_TTL_DAYS,
|
||||
SQLiteEmployerStore,
|
||||
)
|
||||
|
||||
EMAIL = "user@example.com"
|
||||
PASSWORD_HASH = "scrypt$16384$8$1$c2FsdA$aGFzaA" # any self-describing string
|
||||
|
||||
|
||||
def make_store(tmp_path: Path) -> SQLiteEmployerStore:
|
||||
return SQLiteEmployerStore(db_path=tmp_path / "employers-test.db")
|
||||
|
||||
|
||||
def insert_account(
|
||||
store: SQLiteEmployerStore, email: str = EMAIL
|
||||
) -> tuple[str, bool]:
|
||||
row, created = store.insert_account(email=email, password_hash=PASSWORD_HASH)
|
||||
return row.id, created
|
||||
|
||||
|
||||
class TestSchema:
|
||||
def test_all_four_employer_tables_exist(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with store._engine.connect() as conn:
|
||||
names = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
).fetchall()
|
||||
}
|
||||
for table in (
|
||||
"employer_accounts",
|
||||
"employer_sessions",
|
||||
"employer_claims",
|
||||
"employer_audit_events",
|
||||
):
|
||||
assert table in names
|
||||
|
||||
def test_module_registers_only_employer_prefixed_tables(self) -> None:
|
||||
"""The bare `employer` name belongs to marketplace's EmployerRow
|
||||
(marketplace/store.py:144) — every table THIS module registers
|
||||
carries the employer_ prefix (R-57); the shared-process metadata
|
||||
holds marketplace's rows, but the module owns exactly four."""
|
||||
from ai_service.employers import store as employers_store_module
|
||||
|
||||
module_tables = {
|
||||
employers_store_module.AccountRow.__tablename__,
|
||||
employers_store_module.SessionRow.__tablename__,
|
||||
employers_store_module.ClaimRow.__tablename__,
|
||||
employers_store_module.AuditEventRow.__tablename__,
|
||||
}
|
||||
assert module_tables == {
|
||||
"employer_accounts",
|
||||
"employer_sessions",
|
||||
"employer_claims",
|
||||
"employer_audit_events",
|
||||
}
|
||||
assert all(t.startswith("employer_") for t in module_tables)
|
||||
|
||||
def test_pragmas(self, tmp_path: Path) -> None:
|
||||
"""WAL + synchronous=NORMAL + busy_timeout + foreign_keys —
|
||||
the D-027 per-connection contract."""
|
||||
store = make_store(tmp_path)
|
||||
with store._engine.connect() as conn:
|
||||
journal_row = conn.execute(text("PRAGMA journal_mode")).fetchone()
|
||||
sync_row = conn.execute(text("PRAGMA synchronous")).fetchone()
|
||||
busy_row = conn.execute(text("PRAGMA busy_timeout")).fetchone()
|
||||
fk_row = conn.execute(text("PRAGMA foreign_keys")).fetchone()
|
||||
assert journal_row is not None and sync_row is not None
|
||||
assert busy_row is not None and fk_row is not None
|
||||
assert str(journal_row[0]).lower() == "wal"
|
||||
assert sync_row[0] == 1 # NORMAL
|
||||
assert busy_row[0] == 5000
|
||||
assert fk_row[0] == 1
|
||||
|
||||
def test_session_ttl_days_pinned(self) -> None:
|
||||
"""SESSION_TTL_DAYS = 7 — module constant (D-087), pinned by
|
||||
test (the PAST_DUE_GRACE_DAYS shape)."""
|
||||
assert SESSION_TTL_DAYS == 7
|
||||
|
||||
def test_create_schema_idempotent(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.create_schema() # second run — shared-DB re-boot shape
|
||||
account_id, created = insert_account(store)
|
||||
assert created is True
|
||||
with Session(store._engine) as session:
|
||||
count = int(
|
||||
session.execute(
|
||||
text("SELECT COUNT(*) FROM employer_accounts")
|
||||
).scalar_one()
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
|
||||
class TestAccounts:
|
||||
def test_insert_account_created_true(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
_, created = insert_account(store)
|
||||
assert created is True
|
||||
|
||||
def test_duplicate_email_rides_existing_row_created_false(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Check-first: the EXISTING row rides the duplicate register —
|
||||
no second row, honest created=False (the route's 409)."""
|
||||
store = make_store(tmp_path)
|
||||
first_id, created_first = insert_account(store, email=EMAIL)
|
||||
second_row, created_second = store.insert_account(
|
||||
email=EMAIL, password_hash="scrypt$other"
|
||||
)
|
||||
assert created_first is True
|
||||
assert created_second is False
|
||||
assert second_row.id == first_id
|
||||
assert second_row.password_hash == PASSWORD_HASH # original kept
|
||||
|
||||
def test_get_account_by_email(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
row = store.get_account_by_email(EMAIL)
|
||||
assert row is not None
|
||||
assert row.id == account_id
|
||||
assert row.employer_id is None # nullable-until-claim-approval
|
||||
|
||||
def test_get_account_by_email_miss(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
assert store.get_account_by_email("nobody@example.com") is None
|
||||
|
||||
def test_email_normalized_at_store_boundary(self, tmp_path: Path) -> None:
|
||||
"""G-315: the store re-derives normalization — never trusts
|
||||
the caller's casing/whitespace."""
|
||||
store = make_store(tmp_path)
|
||||
_, created = store.insert_account(
|
||||
email=" User@Example.COM ", password_hash=PASSWORD_HASH
|
||||
)
|
||||
assert created is True
|
||||
row = store.get_account_by_email("user@example.com")
|
||||
assert row is not None
|
||||
assert row.email == "user@example.com"
|
||||
|
||||
def test_dishonest_email_rejected_at_store_boundary(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(ValueError, match="email"):
|
||||
store.insert_account(email="no-at-sign", password_hash=PASSWORD_HASH)
|
||||
|
||||
def test_duplicate_email_integrity_error_surfaced(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""The UNIQUE(email) constraint is the check-first race
|
||||
backstop — its IntegrityError SURFACES (never swallowed, never
|
||||
re-masked). Raw SQL via a direct Session simulates the race
|
||||
window after the check-first read."""
|
||||
store = make_store(tmp_path)
|
||||
insert_account(store)
|
||||
with Session(store._engine) as session:
|
||||
with pytest.raises(IntegrityError):
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO employer_accounts (id, email, password_hash, "
|
||||
"created_at) VALUES ('acct-raced', :email, :ph, :now)"
|
||||
),
|
||||
{
|
||||
"email": EMAIL,
|
||||
"ph": PASSWORD_HASH,
|
||||
"now": datetime.now(UTC),
|
||||
},
|
||||
)
|
||||
session.flush()
|
||||
session.rollback()
|
||||
|
||||
def test_duplicate_employer_id_integrity_error_surfaced(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""The one-account-one-entity constraint (UNIQUE nullable
|
||||
employer_id) — two accounts on one entity surface."""
|
||||
store = make_store(tmp_path)
|
||||
insert_account(store, email="one@example.com")
|
||||
insert_account(store, email="two@example.com")
|
||||
with Session(store._engine) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE employer_accounts SET employer_id = 'emp-1' "
|
||||
"WHERE email = :email"
|
||||
),
|
||||
{"email": "one@example.com"},
|
||||
)
|
||||
session.flush() # materialize the first UPDATE before racing
|
||||
with pytest.raises(IntegrityError):
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE employer_accounts SET employer_id = 'emp-1' "
|
||||
"WHERE email = :email"
|
||||
),
|
||||
{"email": "two@example.com"},
|
||||
)
|
||||
session.flush()
|
||||
session.rollback()
|
||||
|
||||
|
||||
class TestSessions:
|
||||
def test_mint_session_hashes_token_sha256(self, tmp_path: Path) -> None:
|
||||
"""D-087: the row holds sha256(token) ONLY — never the raw
|
||||
token, in any column, ever."""
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
token = "raw-secret-bearer-token"
|
||||
row = store.mint_session(account_id=account_id, token=token)
|
||||
expected = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
assert row.token_hash == expected
|
||||
assert row.token_hash != token
|
||||
with Session(store._engine) as session:
|
||||
raw = session.execute(
|
||||
text("SELECT token_hash FROM employer_sessions")
|
||||
).scalar_one()
|
||||
assert raw == expected
|
||||
assert token not in str(raw) # raw token nowhere in the table
|
||||
|
||||
def test_get_session_by_token_hash_roundtrip(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
token = "raw-secret-bearer-token"
|
||||
minted = store.mint_session(account_id=account_id, token=token)
|
||||
found = store.get_session_by_token_hash(minted.token_hash)
|
||||
assert found is not None
|
||||
assert found.id == minted.id
|
||||
assert found.account_id == account_id
|
||||
|
||||
def test_expires_at_is_login_plus_seven_days(self, tmp_path: Path) -> None:
|
||||
"""The TTL constant binds: expires_at = minted now + 7d
|
||||
(SESSION_TTL_DAYS — pinned in TestSchema too)."""
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
moment = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
|
||||
row = store.mint_session(account_id=account_id, token="t", now=moment)
|
||||
assert row.expires_at is not None
|
||||
assert row.expires_at == moment + timedelta(days=SESSION_TTL_DAYS)
|
||||
|
||||
def test_lazy_ttl_inside_window_present(self, tmp_path: Path) -> None:
|
||||
"""G-306 with injected now: login+6d the session still reads —
|
||||
no sleeps, no timers."""
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
|
||||
row = store.mint_session(
|
||||
account_id=account_id, token="t", now=login_at
|
||||
)
|
||||
six_days_later = login_at + timedelta(days=6)
|
||||
assert store.get_session_by_token_hash(
|
||||
row.token_hash, now=six_days_later
|
||||
) is not None
|
||||
|
||||
def test_lazy_ttl_beyond_window_absent(self, tmp_path: Path) -> None:
|
||||
"""G-306 with injected now: login+8d the session reads as
|
||||
ABSENT (expired) — evaluated lazily, the row never deleted."""
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
|
||||
row = store.mint_session(
|
||||
account_id=account_id, token="t", now=login_at
|
||||
)
|
||||
eight_days_later = login_at + timedelta(days=8)
|
||||
assert (
|
||||
store.get_session_by_token_hash(
|
||||
row.token_hash, now=eight_days_later
|
||||
)
|
||||
is None
|
||||
)
|
||||
# Lazy: the row is still on disk (no timer, no writes on read).
|
||||
with Session(store._engine) as session:
|
||||
count = int(
|
||||
session.execute(
|
||||
text("SELECT COUNT(*) FROM employer_sessions")
|
||||
).scalar_one()
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
def test_duplicate_token_hash_integrity_error_surfaced(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""UNIQUE(token_hash): minting the same raw token twice
|
||||
surfaces IntegrityError — never a second row."""
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
store.mint_session(account_id=account_id, token="same-token")
|
||||
with pytest.raises(IntegrityError):
|
||||
store.mint_session(account_id=account_id, token="same-token")
|
||||
|
||||
def test_revoke_session_deletes_row(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
row = store.mint_session(account_id=account_id, token="t")
|
||||
assert store.revoke_session(row.token_hash) is True
|
||||
# Lookup miss after revoke (logout semantics).
|
||||
assert store.get_session_by_token_hash(row.token_hash) is None
|
||||
# Revoking again is an honest miss, not an error.
|
||||
assert store.revoke_session(row.token_hash) is False
|
||||
|
||||
def test_revoke_unknown_token_hash_miss(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
assert store.revoke_session("deadbeef" * 8) is False
|
||||
|
||||
def test_concurrent_sessions_per_account_allowed(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Two logins → two live sessions (the logout-one-leaves-the-
|
||||
other-alive route test's store half)."""
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
first = store.mint_session(account_id=account_id, token="token-one")
|
||||
second = store.mint_session(account_id=account_id, token="token-two")
|
||||
assert store.get_session_by_token_hash(first.token_hash) is not None
|
||||
assert store.get_session_by_token_hash(second.token_hash) is not None
|
||||
|
||||
|
||||
class TestAuditEvents:
|
||||
def test_append_and_read_back(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
account_id, _ = insert_account(store)
|
||||
row = store.append_audit_event(
|
||||
"auth",
|
||||
"register",
|
||||
account_id=account_id,
|
||||
detail={"email": EMAIL},
|
||||
)
|
||||
assert row.id is not None
|
||||
assert row.detail == {"email": EMAIL}
|
||||
with Session(store._engine) as session:
|
||||
stored = session.execute(
|
||||
text(
|
||||
"SELECT kind, name, account_id FROM employer_audit_events"
|
||||
)
|
||||
).fetchone()
|
||||
assert stored is not None
|
||||
assert tuple(stored) == ("auth", "register", account_id)
|
||||
|
||||
def test_operator_provenance_round_trips(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.append_audit_event(
|
||||
"manual",
|
||||
"grant",
|
||||
employer_id="emp-1",
|
||||
operator="op-1",
|
||||
)
|
||||
with Session(store._engine) as session:
|
||||
stored = session.execute(
|
||||
text(
|
||||
"SELECT employer_id, operator FROM employer_audit_events"
|
||||
)
|
||||
).fetchone()
|
||||
assert stored is not None
|
||||
assert tuple(stored) == ("emp-1", "op-1")
|
||||
|
||||
def test_invalid_kind_rejected(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(ValueError, match="invalid audit event kind"):
|
||||
store.append_audit_event("bogus", "x")
|
||||
|
||||
def test_empty_name_rejected(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
store.append_audit_event("auth", "")
|
||||
|
||||
|
||||
class TestStoreContract:
|
||||
def test_close_is_idempotent_safe(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.close()
|
||||
store.close() # engine.dispose() is idempotent by contract
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Employer auth throttle tests (Task 1-2-02, D-088/G-313/G-314/G-320a).
|
||||
|
||||
Knob-math pins, no sleeps: every window boundary is exercised via the
|
||||
Settings knobs set low, never by waiting. The G-313 conftest fixture
|
||||
resets both limiter module states around every test.
|
||||
|
||||
G-314 stuffing test: N distinct emails inside the window → the
|
||||
(cap+1)-th attempt 429s — failed-password attempts DO count; only
|
||||
422-invalid bodies and the cap's own 429s are exempt.
|
||||
|
||||
G-320a pin: a 429'd login performs ZERO scrypt derivations (a
|
||||
hash-spy counts _derive calls — a capped attempt must never burn
|
||||
KDF CPU, bounding open-risk-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.api import employers as employers_api
|
||||
from ai_service.employers import passwords
|
||||
|
||||
from .conftest import TEST_PASSWORD, make_client, register
|
||||
|
||||
|
||||
def make_throttle_client(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
per_min: int | None = None,
|
||||
failures_per_window: int | None = None,
|
||||
) -> TestClient:
|
||||
overrides: dict[str, Any] = {}
|
||||
if per_min is not None:
|
||||
overrides["employer_auth_attempts_per_min"] = per_min
|
||||
if failures_per_window is not None:
|
||||
overrides["employer_login_failures_per_window"] = failures_per_window
|
||||
return make_client(tmp_path, **overrides)
|
||||
|
||||
|
||||
def failed_login(client: TestClient, email: str) -> int:
|
||||
return client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": email, "password": "wrong-password-123"},
|
||||
).status_code
|
||||
|
||||
|
||||
def good_login(client: TestClient, email: str) -> int:
|
||||
return client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": email, "password": TEST_PASSWORD},
|
||||
).status_code
|
||||
|
||||
|
||||
class TestPerEmailFailedLoginWindow:
|
||||
def test_five_failures_then_429_with_wait_detail(self, tmp_path: Path) -> None:
|
||||
"""Default knob 5: failures 1-5 → 401, the 6th → 429 carrying
|
||||
the honest wait detail (a further 401 would invite retries)."""
|
||||
with make_throttle_client(tmp_path) as client:
|
||||
register(client, email="victim@example.com")
|
||||
for _ in range(5):
|
||||
assert failed_login(client, "victim@example.com") == 401
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={
|
||||
"email": "victim@example.com",
|
||||
"password": "wrong-password-123",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert "wait" in resp.json()["detail"].lower()
|
||||
|
||||
def test_failures_are_per_email(self, tmp_path: Path) -> None:
|
||||
"""One email's window never locks another's (D-088: keyed by
|
||||
submitted email — never global at this layer)."""
|
||||
with make_throttle_client(tmp_path, failures_per_window=2) as client:
|
||||
register(client, email="a@example.com")
|
||||
register(client, email="b@example.com")
|
||||
assert failed_login(client, "a@example.com") == 401
|
||||
assert failed_login(client, "a@example.com") == 401
|
||||
assert failed_login(client, "a@example.com") == 429
|
||||
assert failed_login(client, "b@example.com") == 401 # untouched
|
||||
|
||||
def test_success_clears_email_window(self, tmp_path: Path) -> None:
|
||||
"""D-088: a successful login CLEARS the email's failure window
|
||||
— 4 failures, a good login, then 4 more failures all pass."""
|
||||
with make_throttle_client(tmp_path, failures_per_window=5) as client:
|
||||
register(client, email="healthy@example.com")
|
||||
for _ in range(4):
|
||||
assert failed_login(client, "healthy@example.com") == 401
|
||||
assert good_login(client, "healthy@example.com") == 200
|
||||
for _ in range(4):
|
||||
assert failed_login(client, "healthy@example.com") == 401
|
||||
|
||||
def test_unknown_email_failures_also_windowed(self, tmp_path: Path) -> None:
|
||||
"""Failures against an email with NO account still append —
|
||||
the attacker probing dead addresses is bounded identically."""
|
||||
with make_throttle_client(tmp_path, failures_per_window=3) as client:
|
||||
for _ in range(3):
|
||||
assert failed_login(client, "ghost@example.com") == 401
|
||||
assert failed_login(client, "ghost@example.com") == 429
|
||||
|
||||
def test_window_state_is_the_module_dict(self, tmp_path: Path) -> None:
|
||||
"""R-63 shape pin: the window lives in the module dict keyed
|
||||
by the submitted email (monotonic timestamps, failures-only)."""
|
||||
with make_throttle_client(tmp_path, failures_per_window=2) as client:
|
||||
register(client, email="shape@example.com")
|
||||
failed_login(client, "shape@example.com")
|
||||
failed_login(client, "shape@example.com")
|
||||
window = employers_api._LOGIN_FAILURES["shape@example.com"]
|
||||
assert len(window) == 2
|
||||
assert all(isinstance(t, float) for t in window)
|
||||
|
||||
|
||||
class TestGlobalAttemptCap:
|
||||
def test_cap_reached_429(self, tmp_path: Path) -> None:
|
||||
"""Knob 3: three well-formed attempts admitted, the 4th → 429
|
||||
with the honest wait detail."""
|
||||
with make_throttle_client(tmp_path, per_min=3) as client:
|
||||
register(client, email="one@example.com")
|
||||
register(client, email="two@example.com")
|
||||
good_login(client, "one@example.com")
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": "one@example.com", "password": TEST_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert "wait" in resp.json()["detail"].lower()
|
||||
|
||||
def test_422_invalid_consumes_nothing(self, tmp_path: Path) -> None:
|
||||
"""G-314 exemption: a 422-invalid body never reaches the
|
||||
cap's append — after N invalid attempts the budget is whole
|
||||
(the cap only counts well-formed bodies; pydantic rejects
|
||||
these BEFORE the handler runs)."""
|
||||
with make_throttle_client(tmp_path, per_min=2) as client:
|
||||
for _ in range(5):
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/login",
|
||||
json={"email": "x@example.com", "password": "short"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert len(employers_api._AUTH_ATTEMPTS) == 0
|
||||
register(client, email="one@example.com")
|
||||
register(client, email="two@example.com")
|
||||
# Budget was 2, both consumed by the two registers — the
|
||||
# next WELL-FORMED attempt 429s (the cap is intact: the
|
||||
# five 422s above consumed nothing).
|
||||
assert good_login(client, "one@example.com") == 429
|
||||
|
||||
def test_failed_passwords_count_stuffing_test(self, tmp_path: Path) -> None:
|
||||
"""G-314: N distinct emails inside the window, each a FAILED
|
||||
password → the (cap+1)-th 429s — failed attempts DO consume
|
||||
the global budget or D-088's credential-stuffing bound is
|
||||
void (rotating emails must not evade the cap)."""
|
||||
with make_throttle_client(tmp_path, per_min=4) as client:
|
||||
for i in range(4):
|
||||
assert failed_login(client, f"stuff{i}@example.com") == 401
|
||||
assert failed_login(client, "stuff4@example.com") == 429
|
||||
|
||||
def test_cap_429_consumes_nothing(self, tmp_path: Path) -> None:
|
||||
"""The cap's own 429 never appends (a rejected attempt
|
||||
consumes no budget — the _check_global_create_rate shape)."""
|
||||
with make_throttle_client(tmp_path, per_min=2) as client:
|
||||
register(client, email="one@example.com")
|
||||
register(client, email="two@example.com")
|
||||
assert failed_login(client, "three@example.com") == 429
|
||||
assert len(employers_api._AUTH_ATTEMPTS) == 2
|
||||
|
||||
|
||||
class TestThrottleBeforeKDF:
|
||||
def test_429_performs_zero_kdf_derivations(self, tmp_path: Path) -> None:
|
||||
"""G-320a pin: a login 429'd by the per-email window performs
|
||||
ZERO scrypt derivations — both throttle checks run BEFORE the
|
||||
verify, bounding the open-risk-1 burst (a hash-spy on
|
||||
passwords._derive counts calls)."""
|
||||
calls: list[int] = []
|
||||
real_derive = passwords._derive
|
||||
|
||||
def spy(password: str, **kwargs: object) -> bytes:
|
||||
calls.append(1)
|
||||
return real_derive(password, **kwargs) # type: ignore[arg-type]
|
||||
|
||||
passwords._derive = spy
|
||||
try:
|
||||
with make_throttle_client(tmp_path, failures_per_window=2) as client:
|
||||
register(client, email="kdf@example.com")
|
||||
calls.clear() # the register hash is not under test
|
||||
assert failed_login(client, "kdf@example.com") == 401
|
||||
assert failed_login(client, "kdf@example.com") == 401
|
||||
assert failed_login(client, "kdf@example.com") == 429
|
||||
# The two 401s each performed ONE verify derivation; the
|
||||
# 429'd attempt performed ZERO — total stays at 2.
|
||||
assert sum(calls) == 2
|
||||
finally:
|
||||
passwords._derive = real_derive
|
||||
|
||||
def test_global_cap_429_performs_zero_kdf_derivations(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""G-320a pin, global arm: a cap-429'd attempt burns no KDF."""
|
||||
calls: list[int] = []
|
||||
real_derive = passwords._derive
|
||||
|
||||
def spy(password: str, **kwargs: object) -> bytes:
|
||||
calls.append(1)
|
||||
return real_derive(password, **kwargs) # type: ignore[arg-type]
|
||||
|
||||
passwords._derive = spy
|
||||
try:
|
||||
with make_throttle_client(tmp_path, per_min=1) as client:
|
||||
register(client, email="one@example.com")
|
||||
calls.clear() # the register hash is not under test
|
||||
assert failed_login(client, "two@example.com") == 429
|
||||
assert sum(calls) == 0 # the capped attempt derived nothing
|
||||
finally:
|
||||
passwords._derive = real_derive
|
||||
@@ -29,6 +29,25 @@ MARKETPLACE_LEARNERS = ["pilot-learner", "pilot-learner-2"]
|
||||
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
|
||||
#: v0.9 P4 (risk-5): every employer the pre-v0.9 marketplace tests
|
||||
#: create postings against — granted unlimited in make_client so the
|
||||
#: enforcement composition (REQ-9-004) preserves legacy semantics.
|
||||
_LEGACY_TEST_EMPLOYERS = (
|
||||
"emp-openai",
|
||||
"emp-langchain",
|
||||
"emp-x",
|
||||
"emp-anduril",
|
||||
"emp-anthropic",
|
||||
"emp-cohere",
|
||||
"emp-deepmind",
|
||||
"emp-huggingface",
|
||||
"emp-mistral",
|
||||
"emp-perplexity",
|
||||
"emp-pinecone",
|
||||
"emp-recursion",
|
||||
"emp-replicate",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def marketplace_store(tmp_path: Path) -> Iterator[SQLiteMarketplaceStore]:
|
||||
@@ -77,6 +96,27 @@ def make_client(
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store # state-injection (G-9)
|
||||
app.state.marketplace_store = marketplace_store
|
||||
# v0.9 P4 (risk-5 fixture strategy): posting creation now enforces
|
||||
# billing entitlements (REQ-9-004). Legacy marketplace tests create
|
||||
# postings for seeded employers with zero purchases — this fixture
|
||||
# grants each a manual UNLIMITED purchase (the D-080 enterprise
|
||||
# path), explicitly, so the pre-v0.9 test semantics stay intact.
|
||||
from ai_service.billing.store import SQLiteBillingStore
|
||||
|
||||
billing_store = SQLiteBillingStore(db_path=tmp_path / "billing-grants.db")
|
||||
for employer_id in _LEGACY_TEST_EMPLOYERS:
|
||||
session_row = billing_store.insert_checkout_session(
|
||||
provider_session_id=f"cs_legacy_{employer_id}",
|
||||
employer_id=employer_id,
|
||||
item_id="pkg-enterprise",
|
||||
item_name="Enterprise (test grant)",
|
||||
price_cents=0,
|
||||
mode="payment",
|
||||
)
|
||||
billing_store.create_purchase_from_session(
|
||||
session_row, posts_limit=None, duration_days=None
|
||||
)
|
||||
app.state.billing_store = billing_store
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Placement test fixtures (hermeticity rule: db_path pins tmp_path —
|
||||
never ~/.nextcraft, never the cloud).
|
||||
|
||||
Mirrors the tests/marketplace pattern: tmp-path stores injected into
|
||||
a real create_app via state-injection (the G-9 precedent) — the
|
||||
transition routes see the exact composition production runs.
|
||||
|
||||
G-313 (D-111): the autouse `_APPLY_TIMES` reset — the learner
|
||||
withdraw route (P2) rides the marketplace apply-rate window; without
|
||||
this reset the suite self-429s (the lesson applied BEFORE it ships).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.config import Settings
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
from ai_service.main import create_app
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
from ai_service.placements.store import SQLitePlacementStore
|
||||
|
||||
from ..conftest import SUITE_LEARNERS, seed_verified_identity
|
||||
|
||||
#: The pilot learner roster for gated-route tests (G-5 allowlist
|
||||
#: coverage + verified 18+ identity records).
|
||||
PLACEMENT_LEARNERS = ["pilot-learner", "pilot-learner-2"]
|
||||
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def marketplace_store(tmp_path: Path) -> Iterator[SQLiteMarketplaceStore]:
|
||||
"""Hermetic store: D-100's topology — marketplace + placements
|
||||
share ONE SQLite file (the cross-store transaction depends on
|
||||
it)."""
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "shared.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def identity_store(tmp_path: Path) -> Iterator[SQLiteIdentityStore]:
|
||||
store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
|
||||
seed_verified_identity(store, PLACEMENT_LEARNERS)
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def placement_store(tmp_path: Path) -> Iterator[SQLitePlacementStore]:
|
||||
"""The 9th D-027 store on the SAME shared file as the marketplace
|
||||
store (D-100/R-72: one SQLite file, two engines — the shipped
|
||||
multi-store topology)."""
|
||||
store = SQLitePlacementStore(db_path=tmp_path / "shared.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def make_client(
|
||||
tmp_path: Path,
|
||||
identity_store: SQLiteIdentityStore,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
admin_token: str | None = ADMIN_TOKEN,
|
||||
) -> TestClient:
|
||||
"""Full app with state-injected stores; admin_token=None exercises
|
||||
the D-057 fail-closed matrix. P1-2 (security review): ALL stores
|
||||
share the ONE tmp file (marketplace rides shared.db; the app DB +
|
||||
placement store point there too) — D-100's cross-store transition
|
||||
depends on the single-file topology, exactly as production runs."""
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "shared.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=SecretStr(admin_token or ""),
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store # state-injection (G-9)
|
||||
app.state.marketplace_store = marketplace_store
|
||||
app.state.placement_store = SQLitePlacementStore(db_path=tmp_path / "shared.db")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_apply_rate_window() -> Iterator[None]:
|
||||
"""The apply rate cap is module-level process state (the D-019
|
||||
precedent); tests must not inherit each other's windows — the P2
|
||||
withdraw route rides this limiter (G-313 applied BEFORE it
|
||||
ships)."""
|
||||
from ai_service.api.marketplace import _APPLY_TIMES
|
||||
|
||||
_APPLY_TIMES.clear()
|
||||
yield
|
||||
_APPLY_TIMES.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(
|
||||
tmp_path: Path,
|
||||
identity_store: SQLiteIdentityStore,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
) -> Iterator[TestClient]:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
@@ -0,0 +1,249 @@
|
||||
"""The operator lane tests (D-106/G-326/G-327, REQ-11-002) — the
|
||||
fail-closed matrix, the generic transition arms, revoke, and the
|
||||
R-80 re-hire route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
|
||||
import uuid
|
||||
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
# the learner apply route requires published (409 on drafts)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["application_id"]
|
||||
|
||||
|
||||
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application_id = _apply(entered, posting_id)
|
||||
yield {
|
||||
"client": entered,
|
||||
"posting_id": posting_id,
|
||||
"application_id": application_id,
|
||||
}
|
||||
|
||||
|
||||
# -- the fail-closed matrix (the R-56 shape) -------------------------------------
|
||||
|
||||
|
||||
def test_fail_closed_no_token(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "in_review"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_fail_closed_bad_token(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "in_review"},
|
||||
headers=_admin("wrong-token"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_fail_closed_revoke_and_rehire(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
# no token → 403 on every route (fail-closed)
|
||||
resp = client.post(
|
||||
"/v1/placements/admin/placements/plc-x/revoke",
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
# invalid token → 403 (never a 404/409 — the gate runs first)
|
||||
resp = client.post(
|
||||
"/v1/placements/admin/placements/plc-x/revoke",
|
||||
headers=_admin("wrong-token"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/by-application/{scaffold['application_id']}/re-hire",
|
||||
json={"role_title": "Engineer", "start_date": "2026-12-01"},
|
||||
headers=_admin("wrong-token"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# -- the generic transition route --------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_transition_every_legal_arm(scaffold) -> None:
|
||||
client, app_id = scaffold["client"], scaffold["application_id"]
|
||||
for to_status in ("in_review", "interview", "offer", "rejected"):
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/applications/{app_id}/transition",
|
||||
json={"to": to_status},
|
||||
headers=_admin(),
|
||||
)
|
||||
# rejected is terminal — the loop ends there
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == to_status
|
||||
if to_status == "rejected":
|
||||
break
|
||||
|
||||
|
||||
def test_admin_garbage_to_422(scaffold) -> None:
|
||||
"""G-327: `to` is a CLOSED Literal — garbage dies at pydantic
|
||||
(422), never a 500/409."""
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "garbage"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_admin_matrix_illegal_409(scaffold) -> None:
|
||||
client, app_id = scaffold["client"], scaffold["application_id"]
|
||||
client.post(
|
||||
f"/v1/placements/admin/applications/{app_id}/transition",
|
||||
json={"to": "offer"},
|
||||
headers=_admin(),
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/applications/{app_id}/transition",
|
||||
json={"to": "in_review"}, # backward
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_admin_hire_requires_fields_422(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "hired"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_admin_withdraw_on_learners_behalf(scaffold) -> None:
|
||||
"""The operator can drive ANY matrix-legal transition incl.
|
||||
withdrawn (remediation with provenance, the D-083 lane)."""
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "withdrawn"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "withdrawn"
|
||||
|
||||
|
||||
def _admin_hire(scaffold) -> str:
|
||||
"""The shared hire-through-the-operator-lane helper → placement id."""
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "hired", "role_title": "Engineer", "start_date": "2026-11-01"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "hired"
|
||||
assert body["placement"]["role_title"] == "Engineer"
|
||||
assert body["placement"]["status"] == "active"
|
||||
return body["placement"]["id"]
|
||||
|
||||
|
||||
def test_admin_hire_creates_placement(scaffold) -> None:
|
||||
_admin_hire(scaffold)
|
||||
|
||||
|
||||
# -- revoke + the R-80 re-hire ------------------------------------------------------
|
||||
|
||||
|
||||
def test_revoke_then_rehire_three_event_ledger(scaffold) -> None:
|
||||
client, app_id = scaffold["client"], scaffold["application_id"]
|
||||
placement_id = _admin_hire(scaffold)
|
||||
|
||||
# revoke
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/{placement_id}/revoke",
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "revoked"
|
||||
|
||||
# the revoked placement is out of the active set (P3's overview now
|
||||
# exists — the D-102/D-107 exclusion arm)
|
||||
resp = client.get("/v1/placements/admin/overview", headers=_admin())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total_active_placements"] == 0
|
||||
|
||||
# re-hire: the FRESH active row (R-80) + decided_at = the re-hire
|
||||
# event's stamp (G-326 — asserted at the store layer in test_store)
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/by-application/{app_id}/re-hire",
|
||||
json={"role_title": "Engineer II", "start_date": "2026-12-15"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
rehired = resp.json()
|
||||
assert rehired["status"] == "active"
|
||||
assert rehired["id"] != placement_id
|
||||
assert rehired["role_title"] == "Engineer II"
|
||||
|
||||
|
||||
def test_revoke_unknown_404(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
"/v1/placements/admin/placements/plc-nonexistent/revoke",
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_rehire_second_active_409(scaffold) -> None:
|
||||
"""The partial-unique arm: re-hire WITHOUT revoke → the honest 409."""
|
||||
client, app_id = scaffold["client"], scaffold["application_id"]
|
||||
client.post(
|
||||
f"/v1/placements/admin/applications/{app_id}/transition",
|
||||
json={"to": "hired", "role_title": "Engineer", "start_date": "2026-11-01"},
|
||||
headers=_admin(),
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/by-application/{app_id}/re-hire",
|
||||
json={"role_title": "Engineer II", "start_date": "2026-12-15"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "revoke it before re-hiring" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_rehire_requires_hired_application_409(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/placements/by-application/{scaffold['application_id']}/re-hire",
|
||||
json={"role_title": "Engineer", "start_date": "2026-11-01"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 409 # not hired yet — the correction lane refuses
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Employer application transition tests (D-105, REQ-11-002) — the
|
||||
G-311 gate-order pins + the D-099 matrix 409 arms + the G-319 pin
|
||||
split + the hire arms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.employers.store import SQLiteEmployerStore
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import make_client
|
||||
|
||||
# -- fixtures: the employer-principal + application scaffold -------------------
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
|
||||
import uuid
|
||||
|
||||
# the claim route requires the EmployerRow entity to exist (404 unknown)
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=employer_id,
|
||||
source="seed-corpus",
|
||||
external_id=employer_id,
|
||||
name="Test Labs",
|
||||
website="https://example.com/test",
|
||||
)
|
||||
)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
description="Test posting",
|
||||
)
|
||||
)
|
||||
# the learner apply route requires published (409 on drafts)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["application_id"]
|
||||
|
||||
|
||||
def _register_and_claim(
|
||||
client: TestClient, employer_store: SQLiteEmployerStore, email: str
|
||||
) -> str:
|
||||
"""Register + claim + admin-approve → (token, claimed employer_id)."""
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": email, "password": "hunter2hunter2"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-test"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
claim_id = resp.json()["id"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/admin/claims/{claim_id}/approve",
|
||||
headers={"X-Admin-Token": "test-admin-token"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return token
|
||||
|
||||
|
||||
def _make(tmp_path, identity_store, marketplace_store):
|
||||
"""Build the client + the employer store seam (the P2 scaffold)."""
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
employer_store = SQLiteEmployerStore(db_path=tmp_path / "shared.db")
|
||||
client.app.state.employer_store = ( # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
|
||||
employer_store
|
||||
)
|
||||
return client, employer_store
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client, employer_store = _make(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store, "emp-test")
|
||||
token = _register_and_claim(entered, employer_store, "hiring@example.com")
|
||||
application_id = _apply(entered, posting_id)
|
||||
yield {
|
||||
"client": entered,
|
||||
"token": token,
|
||||
"posting_id": posting_id,
|
||||
"application_id": application_id,
|
||||
}
|
||||
|
||||
|
||||
def _hdr(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# -- G-311 gate order (never a 409 before a 403; never a 403 before the 401) -----
|
||||
|
||||
|
||||
def test_gate_order_no_token_401(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/in-review"
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_gate_order_unclaimed_403(tmp_path, identity_store, marketplace_store) -> None:
|
||||
client, _ = _make(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store, "emp-test")
|
||||
resp = entered.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "unclaimed@example.com", "password": "hunter2hunter2"},
|
||||
)
|
||||
token = resp.json()["token"]
|
||||
application_id = _apply(entered, posting_id)
|
||||
resp = entered.post(
|
||||
f"/v1/employers/applications/{application_id}/in-review",
|
||||
headers=_hdr(token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "no_claimed_employer" in str(resp.json()["detail"])
|
||||
|
||||
|
||||
def test_gate_order_foreign_application_403(scaffold, tmp_path, identity_store,
|
||||
marketplace_store) -> None:
|
||||
"""The cross-entity arm: another entity's posting's application →
|
||||
403 with NO application data (anti-enumeration, G-319)."""
|
||||
client = scaffold["client"]
|
||||
foreign_posting = _seed_posting(marketplace_store, "emp-other")
|
||||
foreign_app = _apply(client, foreign_posting)
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{foreign_app}/in-review",
|
||||
headers=_hdr(scaffold["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == (
|
||||
"this application belongs to another employer entity"
|
||||
)
|
||||
|
||||
|
||||
# -- the pipeline list + G-319 pin split ----------------------------------------
|
||||
|
||||
|
||||
def test_list_own_applications_with_learner_id(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
resp = client.get("/v1/employers/applications", headers=_hdr(scaffold["token"]))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
app_row = body["applications"][0]
|
||||
# G-319 supersession: learner_id IS visible on the application-scoped read
|
||||
assert app_row["learner_id"] == "pilot-learner"
|
||||
assert app_row["status"] == "submitted"
|
||||
assert body["facets"]["submitted"] == 1
|
||||
assert body["facets"]["hired"] == 0
|
||||
|
||||
|
||||
def test_own_postings_list_stays_counts_only(scaffold) -> None:
|
||||
"""The G-319 pin split: the own-postings LIST never carries learner
|
||||
identifiers (the v0.10 boundary holds there — unchanged)."""
|
||||
client = scaffold["client"]
|
||||
resp = client.get("/v1/employers/postings", headers=_hdr(scaffold["token"]))
|
||||
assert resp.status_code == 200
|
||||
assert "pilot-learner" not in resp.text
|
||||
|
||||
|
||||
# -- per-target transitions + the D-099 matrix 409s -----------------------------
|
||||
|
||||
|
||||
def test_full_pipeline_transitions(scaffold) -> None:
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
app_id = scaffold["application_id"]
|
||||
for target in ("in-review", "interview", "offer"):
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{app_id}/{target}", headers=_hdr(token)
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == target.replace("-", "_")
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{app_id}/hire",
|
||||
json={"role_title": "AI Orchestration Engineer", "start_date": "2026-11-01"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "hired"
|
||||
assert body["placement"]["role_title"] == "AI Orchestration Engineer"
|
||||
assert body["placement"]["start_date"] == "2026-11-01"
|
||||
assert body["placement"]["employer_id"] == "emp-test"
|
||||
|
||||
|
||||
def test_skip_forward_hire_from_submitted(scaffold) -> None:
|
||||
"""R-71: submitted → hired is a legal fast-track."""
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/hire",
|
||||
json={"role_title": "Engineer", "start_date": "2026-12-01"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "hired"
|
||||
|
||||
|
||||
def test_matrix_backward_move_409(scaffold) -> None:
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
app_id = scaffold["application_id"]
|
||||
client.post(
|
||||
f"/v1/employers/applications/{app_id}/offer", headers=_hdr(token)
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{app_id}/in-review", headers=_hdr(token)
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_matrix_terminal_source_409(scaffold) -> None:
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
app_id = scaffold["application_id"]
|
||||
client.post(
|
||||
f"/v1/employers/applications/{app_id}/hire",
|
||||
json={"role_title": "Engineer", "start_date": "2026-12-01"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{app_id}/reject", headers=_hdr(token)
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_hire_requires_role_title_and_start_date_422(scaffold) -> None:
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/hire",
|
||||
json={"role_title": "", "start_date": "2026-12-01"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
# G-324: min_length=1 → the empty string dies at pydantic (422)
|
||||
assert resp.status_code == 422
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/hire",
|
||||
json={"role_title": "Engineer"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_unknown_application_404(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
"/v1/employers/applications/app-nonexistent/in-review",
|
||||
headers=_hdr(scaffold["token"]),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_withdraw_never_offered_to_employers(scaffold) -> None:
|
||||
"""D-099: employers reject — there is NO employer withdraw route."""
|
||||
client = scaffold["client"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/withdraw",
|
||||
headers=_hdr(scaffold["token"]),
|
||||
)
|
||||
assert resp.status_code in (404, 405)
|
||||
|
||||
|
||||
# -- the P2-review pin (P2-2 disposition): the D-089 audit lane -------------------
|
||||
|
||||
|
||||
def test_transition_appends_employer_audit_row(scaffold) -> None:
|
||||
"""P2-2 pin: every employer transition appends its
|
||||
employer_audit_events row (kind='application' — the vocabulary
|
||||
extended in this phase; the one-queryable lane)."""
|
||||
|
||||
scaffold["client"].post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/in-review",
|
||||
headers=_hdr(scaffold["token"]),
|
||||
)
|
||||
store = scaffold["client"].app.state.employer_store
|
||||
events = store.list_audit_events(kind="application")
|
||||
assert len(events) == 1
|
||||
assert events[0].name == "application.in_review"
|
||||
assert events[0].application_id if False else True # noqa: the id rides detail
|
||||
assert events[0].detail["application_id"] == scaffold["application_id"]
|
||||
assert events[0].detail["to_status"] == "in_review"
|
||||
@@ -0,0 +1,283 @@
|
||||
"""The audit-export tests (D-108/G-321/G-325, REQ-11-004) — re-export
|
||||
determinism, chain-head consistency, methodology completeness, the
|
||||
G-325 evidence bundles, fail-closed gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Employer, Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=employer_id,
|
||||
source="seed-corpus",
|
||||
external_id=employer_id,
|
||||
name=f"Labs {employer_id}",
|
||||
)
|
||||
)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:10]}",
|
||||
source="admin",
|
||||
external_id=f"t-{uuid.uuid4().hex[:10]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _hire_direct(
|
||||
client: TestClient,
|
||||
marketplace: SQLiteMarketplaceStore,
|
||||
posting_id: str,
|
||||
learner_id: str,
|
||||
start_date: str = "2026-11-01",
|
||||
) -> str:
|
||||
from datetime import date
|
||||
|
||||
placements = client.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
|
||||
application, _ = marketplace.insert_application(posting_id, learner_id)
|
||||
_, placement = placements.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="operator",
|
||||
actor_ref="operator",
|
||||
role_title="Engineer",
|
||||
start_date=date.fromisoformat(start_date),
|
||||
)
|
||||
assert placement is not None
|
||||
return placement.id
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
yield {"client": entered, "marketplace": marketplace_store}
|
||||
|
||||
|
||||
def _export(client: TestClient, token: str = ADMIN_TOKEN) -> dict:
|
||||
resp = client.get(
|
||||
"/v1/placements/admin/export", headers={"X-Admin-Token": token}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"].startswith("application/json")
|
||||
return json.loads(resp.text)
|
||||
|
||||
|
||||
# -- the G-321/G-325/R-73 contract -----------------------------------------------
|
||||
|
||||
|
||||
def test_reexport_determinism(scaffold) -> None:
|
||||
"""R-73: two exports on an unchanged DB → identical ledger chains
|
||||
+ identical head_hash, generated_at EXCLUDED from comparison."""
|
||||
s = scaffold
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
|
||||
|
||||
first = _export(s["client"])
|
||||
second = _export(s["client"])
|
||||
|
||||
# generated_at differs (a function of the request) — everything
|
||||
# else is byte-identical (the TOTAL-exclusion rule: strip the key)
|
||||
first.pop("generated_at")
|
||||
second.pop("generated_at")
|
||||
assert first == second
|
||||
assert first["ledger"]["events"] == second["ledger"]["events"]
|
||||
assert first["ledger"]["head_hash"] == second["ledger"]["head_hash"]
|
||||
|
||||
|
||||
def test_chain_head_consistency(scaffold) -> None:
|
||||
"""head_hash = the last event's hash = verify_chain's output."""
|
||||
s = scaffold
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
|
||||
body = _export(s["client"])
|
||||
events = body["ledger"]["events"]
|
||||
assert body["ledger"]["head_hash"] == events[-1]["hash"]
|
||||
placements = s["client"].app.state.placement_store
|
||||
ok, head = placements.verify_chain()
|
||||
assert ok is True
|
||||
assert head == body["ledger"]["head_hash"]
|
||||
|
||||
|
||||
def test_methodology_completeness(scaffold) -> None:
|
||||
"""G-321 + R-67/R-68: all four methodology keys present; the three
|
||||
pilot gaps stated explicitly; the chain proof-scope + continuity
|
||||
anchor + cross-check stated."""
|
||||
body = _export(scaffold["client"])
|
||||
methodology = body["methodology"]
|
||||
for key in ("placement_definition", "counting_rules", "verification", "scope"):
|
||||
assert key in methodology, f"methodology missing {key}"
|
||||
verification = methodology["verification"]
|
||||
scope = methodology["scope"]
|
||||
# the chain's proof scope + limit + anchor (G-321)
|
||||
assert "WHAT THE CHAIN PROVES" in verification
|
||||
assert "WHAT IT DOES NOT PROVE" in verification
|
||||
assert "export-to-export continuity" in verification or "PREFIX" in verification
|
||||
assert "revoked_count" in verification # the (c) cross-check
|
||||
# the three R-67 pilot gaps (a) (b) (c) stated in scope
|
||||
assert "minimum-employed-duration" in scope or "duration" in scope
|
||||
assert "salary" in scope
|
||||
assert "NOT third-party employment verification" in scope
|
||||
# employer-attested evidence class named
|
||||
assert "attestation" in verification
|
||||
|
||||
|
||||
def test_evidence_bundles_cover_revoked_too(scaffold) -> None:
|
||||
"""G-325: bundles cover EVERY placement (active AND revoked) with
|
||||
the full event history; cohort stays active-only."""
|
||||
s = scaffold
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
placement_id = _hire_direct(s["client"], s["marketplace"], posting_id,
|
||||
"pilot-learner")
|
||||
placements = s["client"].app.state.placement_store
|
||||
placements.revoke_placement(placement_id, "operator")
|
||||
|
||||
body = _export(s["client"])
|
||||
assert body["summary"]["revoked_count"] == 1
|
||||
assert body["cohort"] == [] # active-only
|
||||
assert len(body["evidence_bundles"]) == 1 # the revoked row IS bundled
|
||||
bundle = body["evidence_bundles"][0]
|
||||
assert bundle["placement_status"] == "revoked"
|
||||
assert bundle["posting_record"] is not None
|
||||
assert bundle["application_record"] is not None
|
||||
event_types = [e["event_type"] for e in bundle["transition_history"]]
|
||||
assert "application.hired" in event_types
|
||||
assert "placement.created" in event_types
|
||||
assert "placement.revoked" in event_types
|
||||
# the (c) cross-check: revoked_count == the placement.revoked events
|
||||
revoked_events = [
|
||||
e for e in body["ledger"]["events"] if e["event_type"] == "placement.revoked"
|
||||
]
|
||||
assert body["summary"]["revoked_count"] == len(revoked_events)
|
||||
|
||||
|
||||
def test_export_fail_closed(scaffold) -> None:
|
||||
resp = scaffold["client"].get("/v1/placements/admin/export")
|
||||
assert resp.status_code == 403
|
||||
resp = scaffold["client"].get("/v1/placements/admin/export/summary")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_summary_renders_from_the_same_document(scaffold) -> None:
|
||||
"""D-108's anti-drift rule: the summary's totals equal the export's
|
||||
summary block on the same DB state; the content type is markdown;
|
||||
the G-328 attestation title + the head hash present."""
|
||||
s = scaffold
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
|
||||
export = _export(s["client"])
|
||||
resp = s["client"].get(
|
||||
"/v1/placements/admin/export/summary", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/markdown")
|
||||
text = resp.text
|
||||
assert "Employer-attested, operator-overseen" in text # G-328 title
|
||||
assert f"| {export['summary']['distinct_learners_placed']} |" in text
|
||||
assert export["ledger"]["head_hash"] in text # the walk instructions
|
||||
assert "PREFIX" in text # the export-to-export continuity anchor
|
||||
|
||||
|
||||
# -- the full-chain E2E proof (the milestone's reason to exist) -------------------
|
||||
|
||||
|
||||
def test_e2e_audit_proof(scaffold) -> None:
|
||||
"""The machine-checked E2E (D-111): register → claim → admin
|
||||
approve → posting → verified learner applies → transitions →
|
||||
hire → placement created → chain green → export reproduces →
|
||||
counts the learner ONCE."""
|
||||
from ai_service.employers.store import SQLiteEmployerStore
|
||||
|
||||
s = scaffold
|
||||
client = s["client"]
|
||||
# fresh employer store on the shared file (the scaffold's client
|
||||
# wires one only through the lifespan default — build it here)
|
||||
import pathlib
|
||||
|
||||
db_path = pathlib.Path(
|
||||
str(client.app.state.marketplace_store._db_path) # the shared tmp file (test seam)
|
||||
)
|
||||
employer_store = SQLiteEmployerStore(db_path=db_path)
|
||||
client.app.state.employer_store = employer_store
|
||||
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
|
||||
# employer registers → claims → operator approves
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "e2e@example.com", "password": "hunter2hunter2"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
token = resp.json()["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-a"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 201)
|
||||
claim_id = resp.json()["id"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/admin/claims/{claim_id}/approve",
|
||||
headers={"X-Admin-Token": ADMIN_TOKEN},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# verified learner applies
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"posting_id": posting_id, "learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
application_id = resp.json()["application_id"]
|
||||
|
||||
# employer drives in_review → interview → offer → hire
|
||||
for target in ("in-review", "interview", "offer"):
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{application_id}/{target}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{application_id}/hire",
|
||||
json={"role_title": "AI Orchestration Engineer", "start_date": "2026-11-01"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["placement"]["status"] == "active"
|
||||
|
||||
# the chain walks green
|
||||
placements = client.app.state.placement_store
|
||||
ok, _ = placements.verify_chain()
|
||||
assert ok is True
|
||||
|
||||
# the export reproduces (chain + head identical, generated_at excluded)
|
||||
first = _export(client)
|
||||
first.pop("generated_at")
|
||||
second = _export(client)
|
||||
second.pop("generated_at")
|
||||
assert first == second
|
||||
|
||||
# the overview counts the learner ONCE toward 1,000
|
||||
resp = client.get(
|
||||
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
overview = resp.json()
|
||||
assert overview["distinct_learners_placed"] == 1
|
||||
assert overview["total_active_placements"] == 1
|
||||
assert overview["guiding_goal"] == 1000
|
||||
assert overview["by_cohort_month"] == [{"month": "2026-11", "count": 1}]
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Learner application-status tests (D-104/G-322, REQ-11-005) — the
|
||||
mine-read discovery arms, the status read's scoped-history + hired
|
||||
placement arms, the revoked honest-null arm, and the reads-stay-free
|
||||
pin (no self-429, G-313)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, PLACEMENT_LEARNERS, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
|
||||
import uuid
|
||||
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
# the learner apply route requires published (409 on drafts)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["application_id"]
|
||||
|
||||
|
||||
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
|
||||
|
||||
def _transition(client: TestClient, application_id: str, body: dict) -> None:
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/applications/{application_id}/transition",
|
||||
json=body,
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _status(client: TestClient, application_id: str, learner_id: str):
|
||||
return client.get(
|
||||
f"/v1/marketplace/applications/{application_id}",
|
||||
params={"learner_id": learner_id},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application_id = _apply(entered, posting_id)
|
||||
yield {
|
||||
"client": entered,
|
||||
"posting_id": posting_id,
|
||||
"application_id": application_id,
|
||||
}
|
||||
|
||||
|
||||
# -- the mine-read (G-322): discovery on revisit -------------------------------
|
||||
|
||||
|
||||
def test_mine_own_200(scaffold) -> None:
|
||||
resp = scaffold["client"].get(
|
||||
"/v1/marketplace/applications/mine",
|
||||
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["application_id"] == scaffold["application_id"]
|
||||
assert body["status"] == "submitted"
|
||||
|
||||
|
||||
def test_mine_none_404(scaffold) -> None:
|
||||
resp = scaffold["client"].get(
|
||||
"/v1/marketplace/applications/mine",
|
||||
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner-2"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_mine_unallowlisted_403(scaffold) -> None:
|
||||
resp = scaffold["client"].get(
|
||||
"/v1/marketplace/applications/mine",
|
||||
params={"posting_id": scaffold["posting_id"], "learner_id": "intruder"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_mine_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
|
||||
"""A learner on the roster but WITHOUT a verified identity record →
|
||||
the verified-18+ arm refuses (the D-043 composition)."""
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
|
||||
resp = entered.get(
|
||||
"/v1/marketplace/applications/mine",
|
||||
params={"posting_id": posting_id, "learner_id": unverified},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# -- the status read (D-104): status + scoped history + placement ---------------
|
||||
|
||||
|
||||
def test_status_own_200_with_events(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
_transition(client, scaffold["application_id"], {"to": "in_review"})
|
||||
resp = _status(client, scaffold["application_id"], "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["application"]["id"] == scaffold["application_id"]
|
||||
assert body["application"]["posting_id"] == scaffold["posting_id"]
|
||||
assert body["application"]["learner_id"] == "pilot-learner"
|
||||
assert body["application"]["status"] == "in_review"
|
||||
assert [e["event_type"] for e in body["events"]] == ["application.in_review"]
|
||||
assert body["placement"] is None
|
||||
|
||||
|
||||
def test_status_events_scoped_and_id_ordered(scaffold) -> None:
|
||||
"""The events arm is a SCOPED query: another learner's events never
|
||||
ride this read, and the history renders id-ordered (the chain
|
||||
order)."""
|
||||
client = scaffold["client"]
|
||||
import uuid
|
||||
|
||||
store = client.app.state.marketplace_store
|
||||
posting2 = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-b-{uuid.uuid4().hex[:6]}",
|
||||
source="admin",
|
||||
external_id=f"b-{uuid.uuid4().hex[:8]}",
|
||||
employer_id="emp-test",
|
||||
title="Second posting",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting2.id, "published")
|
||||
other = _apply(client, posting2.id, learner_id="pilot-learner-2")
|
||||
_transition(client, other, {"to": "interview"})
|
||||
_transition(client, scaffold["application_id"], {"to": "in_review"})
|
||||
_transition(client, scaffold["application_id"], {"to": "interview"})
|
||||
resp = _status(client, scaffold["application_id"], "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert [e["event_type"] for e in body["events"]] == [
|
||||
"application.in_review",
|
||||
"application.interview",
|
||||
]
|
||||
assert all(
|
||||
e["application_id"] == scaffold["application_id"] for e in body["events"]
|
||||
)
|
||||
|
||||
|
||||
def test_status_hired_carries_placement(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
_transition(
|
||||
client,
|
||||
scaffold["application_id"],
|
||||
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
|
||||
)
|
||||
resp = _status(client, scaffold["application_id"], "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["application"]["status"] == "hired"
|
||||
placement = body["placement"]
|
||||
assert placement is not None
|
||||
assert placement["role_title"] == "Placement Engineer"
|
||||
assert placement["start_date"] == "2026-11-02"
|
||||
assert placement["application_id"] == scaffold["application_id"]
|
||||
assert placement["learner_id"] == "pilot-learner"
|
||||
assert placement["employer_id"] == "emp-test"
|
||||
assert placement["posting_id"] == scaffold["posting_id"]
|
||||
# hired → the two-event chain: application.hired + placement.created
|
||||
assert [e["event_type"] for e in body["events"]] == [
|
||||
"application.hired",
|
||||
"placement.created",
|
||||
]
|
||||
|
||||
|
||||
def test_status_revoked_placement_is_honest_null(scaffold) -> None:
|
||||
"""A-304: no active placement record renders after the operator
|
||||
revoke — the honest null (the events carry the correction trail,
|
||||
never a fabricated badge)."""
|
||||
client = scaffold["client"]
|
||||
app_id = scaffold["application_id"]
|
||||
_transition(
|
||||
client,
|
||||
app_id,
|
||||
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
|
||||
)
|
||||
placement_id = _status(client, app_id, "pilot-learner").json()["placement"]["id"]
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/{placement_id}/revoke",
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = _status(client, app_id, "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["placement"] is None
|
||||
assert body["events"][-1]["event_type"] == "placement.revoked"
|
||||
|
||||
|
||||
def test_status_foreign_learner_403(scaffold) -> None:
|
||||
resp = _status(scaffold["client"], scaffold["application_id"], "pilot-learner-2")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_status_unallowlisted_403(scaffold) -> None:
|
||||
resp = _status(scaffold["client"], scaffold["application_id"], "intruder")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_status_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
|
||||
marketplace_store.insert_application(posting_id, unverified)
|
||||
application = marketplace_store.list_applications(posting_id)[0]
|
||||
resp = _status(entered, application.id, unverified)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_status_unknown_404(scaffold) -> None:
|
||||
resp = _status(scaffold["client"], "app-nonexistent", "pilot-learner")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# -- G-313: reads never consume the apply-rate budget ----------------------------
|
||||
|
||||
|
||||
def test_status_reads_stay_free_no_self_429(scaffold) -> None:
|
||||
"""A-408's distinct-WRITE rule: status reads stay free — a revisit
|
||||
storm of reads never 429s (the conftest reset proves the next
|
||||
test starts clean either way)."""
|
||||
client = scaffold["client"]
|
||||
for _ in range(10):
|
||||
resp = _status(client, scaffold["application_id"], "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Learner withdraw tests (D-104/G-323, REQ-11-002) — the D-043 gate
|
||||
stack + the terminal arms + the apply-route terminal guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import PLACEMENT_LEARNERS, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
|
||||
import uuid
|
||||
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
# the learner apply route requires published (409 on drafts)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["application_id"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application_id = _apply(entered, posting_id)
|
||||
yield {
|
||||
"client": entered,
|
||||
"posting_id": posting_id,
|
||||
"application_id": application_id,
|
||||
}
|
||||
|
||||
|
||||
# -- the D-043 gate stack (G-5 → verified 18+ → the apply-rate window) ------------
|
||||
|
||||
|
||||
def test_withdraw_own_submitted(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "withdrawn"
|
||||
|
||||
|
||||
def test_withdraw_unallowlisted_403(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "intruder"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_withdraw_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
|
||||
"""A learner on the roster but WITHOUT a verified identity record →
|
||||
the verified-18+ arm refuses (the D-043 composition)."""
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
# an allowlisted-but-unverified learner: the suite roster carries
|
||||
# learners beyond PLACEMENT_LEARNERS (the tests/conftest SUITE
|
||||
# list); the identity store only seeds PLACEMENT_LEARNERS.
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
|
||||
# even the APPLY is refused for an unverified learner (the same
|
||||
# D-043 stack) — the withdraw arm pins identically: build the
|
||||
# application row directly through the store, then attempt the
|
||||
# withdraw through the route.
|
||||
marketplace_store.insert_application(posting_id, unverified)
|
||||
application = marketplace_store.list_applications(posting_id)[0]
|
||||
resp = entered.post(
|
||||
f"/v1/marketplace/applications/{application.id}/withdraw",
|
||||
json={"learner_id": unverified},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_withdraw_foreign_learner_403(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner-2"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_withdraw_terminal_409(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
client.post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_withdraw_unknown_404(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
"/v1/marketplace/applications/app-nonexistent/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# -- G-323: apply on a terminal application → 409, never a 200 lie --------------
|
||||
|
||||
|
||||
def test_reapply_after_withdraw_409(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
client.post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["reason"] == "application_terminal"
|
||||
assert resp.json()["detail"]["status"] == "withdrawn"
|
||||
|
||||
|
||||
def test_apply_idempotent_still_200_on_submitted(scaffold) -> None:
|
||||
"""A-408 unchanged: a duplicate apply on a LIVE application still
|
||||
returns the 200 with the existing row (G-323 guards only terminals)."""
|
||||
resp = scaffold["client"].post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["application_id"] == scaffold["application_id"]
|
||||
|
||||
|
||||
# -- the P2-review pins (P2-1/P2-2 disposition): provenance + budget -------------
|
||||
|
||||
|
||||
def test_withdraw_ledger_actor_is_learner(scaffold) -> None:
|
||||
"""D-104 pin: the withdraw transition's ledger event records
|
||||
actor_type='learner' with the learner's actor_ref."""
|
||||
|
||||
scaffold["client"].post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
placements = scaffold["client"].app.state.placement_store
|
||||
events = placements.get_application_events(scaffold["application_id"])
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "application.withdrawn"
|
||||
assert events[0].actor_type == "learner"
|
||||
assert events[0].actor_ref == "learner:pilot-learner"
|
||||
|
||||
|
||||
def test_withdraw_rides_the_apply_rate_window(scaffold) -> None:
|
||||
"""D-104/G-313 pin: withdraw shares the apply-rate window. Budget:
|
||||
the scaffold's apply consumed 1; two withdraws (2,3) + one apply
|
||||
(4) exhaust the 5/60s window; the NEXT gated action 429s honestly
|
||||
(the conftest reset proves the next test starts clean)."""
|
||||
client = scaffold["client"]
|
||||
import uuid
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
|
||||
store = client.app.state.marketplace_store
|
||||
# withdraw the scaffold application (action 2)
|
||||
resp = client.post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# a second application: apply (action 3) + withdraw (action 4)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-w-{uuid.uuid4().hex[:6]}",
|
||||
source="admin",
|
||||
external_id=f"w-{uuid.uuid4().hex[:8]}",
|
||||
employer_id="emp-test",
|
||||
title="Posting",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
application_id = _apply(client, posting.id)
|
||||
resp = client.post(
|
||||
f"/v1/marketplace/applications/{application_id}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# a third application: the apply is the 5th gated action — green;
|
||||
# its withdraw would be the 6th → 429
|
||||
posting2 = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-w2-{uuid.uuid4().hex[:6]}",
|
||||
source="admin",
|
||||
external_id=f"w2-{uuid.uuid4().hex[:8]}",
|
||||
employer_id="emp-test",
|
||||
title="Posting",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting2.id, "published")
|
||||
application2 = _apply(client, posting2.id)
|
||||
resp = client.post(
|
||||
f"/v1/marketplace/applications/{application2}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Reporting tests (D-107, REQ-11-003) — the distinct-learner rule,
|
||||
cohort bucketing, revoke exclusion, the per-employer view's gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Employer, Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
from ai_service.placements.store import SQLitePlacementStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=employer_id,
|
||||
source="seed-corpus",
|
||||
external_id=employer_id,
|
||||
name=f"Labs {employer_id}",
|
||||
)
|
||||
)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:10]}",
|
||||
source="admin",
|
||||
external_id=f"t-{uuid.uuid4().hex[:10]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _hire_direct(
|
||||
placements: SQLitePlacementStore,
|
||||
marketplace: SQLiteMarketplaceStore,
|
||||
posting_id: str,
|
||||
learner_id: str,
|
||||
role_title: str = "Engineer",
|
||||
start_date: str = "2026-11-01",
|
||||
) -> str:
|
||||
"""Direct store-level hire (the route arms are P2-tested; here we
|
||||
exercise the REPORTING computation over placement rows)."""
|
||||
application, _ = marketplace.insert_application(posting_id, learner_id)
|
||||
_, placement = placements.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="operator",
|
||||
actor_ref="operator",
|
||||
role_title=role_title,
|
||||
start_date=__import__("datetime").date.fromisoformat(start_date),
|
||||
)
|
||||
assert placement is not None
|
||||
return placement.id
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
placements = entered.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
|
||||
yield {
|
||||
"client": entered,
|
||||
"placements": placements,
|
||||
"marketplace": marketplace_store,
|
||||
}
|
||||
|
||||
|
||||
def _overview(client: TestClient) -> dict:
|
||||
resp = client.get(
|
||||
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def test_empty_overview_honest_zeroes(scaffold) -> None:
|
||||
body = _overview(scaffold["client"])
|
||||
assert body["total_active_placements"] == 0
|
||||
assert body["distinct_learners_placed"] == 0
|
||||
assert body["guiding_goal"] == 1000
|
||||
assert body["by_employer"] == []
|
||||
assert body["by_cohort_month"] == []
|
||||
|
||||
|
||||
def test_distinct_learner_rule(scaffold) -> None:
|
||||
"""D-107: a learner placed TWICE counts ONCE toward the guiding
|
||||
goal (the outcome's noun is learners); both placements count in
|
||||
total_active_placements."""
|
||||
s = scaffold
|
||||
posting_a = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b = _seed_posting(s["marketplace"], "emp-b")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner")
|
||||
body = _overview(s["client"])
|
||||
assert body["total_active_placements"] == 2
|
||||
assert body["distinct_learners_placed"] == 1 # placed twice, counted once
|
||||
assert body["by_employer"] == [
|
||||
{"employer_id": "emp-a", "count": 1},
|
||||
{"employer_id": "emp-b", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_cohort_month_bucketing(scaffold) -> None:
|
||||
"""D-107/D-103: cohort month = the calendar month of start_date —
|
||||
two starts in one month share a bucket; a future-dated start
|
||||
lands in its own month."""
|
||||
s = scaffold
|
||||
posting_a = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_c = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner",
|
||||
start_date="2026-11-01")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner-2",
|
||||
start_date="2026-11-20")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_c, "api-learner",
|
||||
start_date="2027-02-05")
|
||||
body = _overview(s["client"])
|
||||
assert body["by_cohort_month"] == [
|
||||
{"month": "2026-11", "count": 2},
|
||||
{"month": "2027-02", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_revoke_excluded_from_counts(scaffold) -> None:
|
||||
"""D-102/D-107: revoked placements are gone from every count; a
|
||||
learner whose ONLY placement was revoked is back to 0."""
|
||||
s = scaffold
|
||||
posting = _seed_posting(s["marketplace"], "emp-a")
|
||||
placement_id = _hire_direct(s["placements"], s["marketplace"], posting,
|
||||
"pilot-learner")
|
||||
before = _overview(s["client"])
|
||||
assert before["distinct_learners_placed"] == 1
|
||||
|
||||
s["placements"].revoke_placement(placement_id, "operator")
|
||||
|
||||
after = _overview(s["client"])
|
||||
assert after["total_active_placements"] == 0
|
||||
assert after["distinct_learners_placed"] == 0 # back to 0
|
||||
assert after["by_employer"] == []
|
||||
assert after["by_cohort_month"] == []
|
||||
|
||||
|
||||
def test_overview_fail_closed(scaffold) -> None:
|
||||
resp = scaffold["client"].get("/v1/placements/admin/overview")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_per_employer_view_own_rows(scaffold) -> None:
|
||||
"""The employer placement view: session-gated, own claimed entity
|
||||
only; honest empty state (403 + claim-CTA unclaimed; 401 no token)."""
|
||||
s = scaffold
|
||||
posting = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting, "pilot-learner")
|
||||
|
||||
# 401 without a session
|
||||
resp = s["client"].get("/v1/employers/placements")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# 403 + claim-CTA when unclaimed
|
||||
resp = s["client"].post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "viewer@example.com", "password": "hunter2hunter2"},
|
||||
)
|
||||
token = resp.json()["token"]
|
||||
resp = s["client"].get(
|
||||
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "no_claimed_employer" in str(resp.json()["detail"])
|
||||
|
||||
# the honest empty state for a claimed entity with zero placements
|
||||
store = s["client"].app.state.marketplace_store
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id="emp-b",
|
||||
source="seed-corpus",
|
||||
external_id="emp-b",
|
||||
name="Labs emp-b",
|
||||
)
|
||||
)
|
||||
resp = s["client"].post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-b"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
claim_id = resp.json()["id"]
|
||||
s["client"].post(
|
||||
f"/v1/employers/admin/claims/{claim_id}/approve",
|
||||
headers={"X-Admin-Token": ADMIN_TOKEN},
|
||||
)
|
||||
resp = s["client"].get(
|
||||
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
assert resp.json()["placements"] == []
|
||||
|
||||
|
||||
def test_by_employer_ordering(scaffold) -> None:
|
||||
"""R-73: by_employer is count desc then employer_id ascending."""
|
||||
s = scaffold
|
||||
posting_a1 = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b1 = _seed_posting(s["marketplace"], "emp-b")
|
||||
posting_b2 = _seed_posting(s["marketplace"], "emp-b")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a1, "pilot-learner")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b1, "pilot-learner-2")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b2, "api-learner")
|
||||
body = _overview(s["client"])
|
||||
assert body["by_employer"] == [
|
||||
{"employer_id": "emp-b", "count": 2},
|
||||
{"employer_id": "emp-a", "count": 1},
|
||||
]
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Placement store tests (D-111, REQ-11-001) — the ledger's adversarial
|
||||
suite: chain verification, tamper, fork, concurrency, no-delete-path,
|
||||
the D-099 matrix, the D-102 revoke/re-hire lane.
|
||||
|
||||
Real stores on tmp_path DBs, zero network by construction (no
|
||||
MockTransport needed — no external calls exist), real scrypt-free
|
||||
paths, honest assertions (behavior over bytes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import (
|
||||
ApplicationRow,
|
||||
SQLiteMarketplaceStore,
|
||||
)
|
||||
from ai_service.placements.base import (
|
||||
APPLICATION_STATUSES,
|
||||
GENESIS_HASH,
|
||||
TRANSITION_MATRIX,
|
||||
InvalidTransition,
|
||||
validate_transition,
|
||||
)
|
||||
from ai_service.placements.store import PlacementEventRow, SQLitePlacementStore
|
||||
|
||||
# -- helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
|
||||
"""Insert a draft posting via the store's own path → its id."""
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
description="Test posting",
|
||||
required_competencies=["orchestration"],
|
||||
skills=["python"],
|
||||
)
|
||||
)
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(store: SQLiteMarketplaceStore, posting_id: str, learner_id: str) -> ApplicationRow:
|
||||
application, _created = store.insert_application(posting_id, learner_id)
|
||||
with Session(store._engine) as session:
|
||||
row = session.get(ApplicationRow, application.id)
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def _hire(
|
||||
store: SQLitePlacementStore,
|
||||
application_id: str,
|
||||
actor: str = "operator",
|
||||
now: datetime | None = None,
|
||||
) -> object:
|
||||
return store.transition_application(
|
||||
application_id,
|
||||
"hired",
|
||||
actor_type="operator",
|
||||
actor_ref=actor,
|
||||
role_title="AI Orchestration Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
# -- D-099 matrix (base contracts) ---------------------------------------------
|
||||
|
||||
|
||||
def test_genesis_hash_pinned() -> None:
|
||||
assert GENESIS_HASH == "0" * 64
|
||||
|
||||
|
||||
def test_transition_matrix_forward_only_with_skip_forward() -> None:
|
||||
# forward legal
|
||||
assert "in_review" in TRANSITION_MATRIX["submitted"]
|
||||
# skip-forward legal (the fast-track hire — R-71)
|
||||
assert "hired" in TRANSITION_MATRIX["submitted"]
|
||||
assert "interview" in TRANSITION_MATRIX["submitted"]
|
||||
# terminals empty
|
||||
assert TRANSITION_MATRIX["hired"] == ()
|
||||
assert TRANSITION_MATRIX["rejected"] == ()
|
||||
assert TRANSITION_MATRIX["withdrawn"] == ()
|
||||
# every status is a matrix key
|
||||
for status in APPLICATION_STATUSES:
|
||||
assert status in TRANSITION_MATRIX
|
||||
|
||||
|
||||
def test_validate_transition_arms() -> None:
|
||||
validate_transition("submitted", "hired") # skip-forward
|
||||
validate_transition("in_review", "rejected")
|
||||
with pytest.raises(InvalidTransition):
|
||||
validate_transition("offer", "in_review") # backward — illegal
|
||||
with pytest.raises(InvalidTransition):
|
||||
validate_transition("hired", "rejected") # terminal-source
|
||||
with pytest.raises(InvalidTransition):
|
||||
validate_transition("submitted", "garbage") # unknown target
|
||||
with pytest.raises(InvalidTransition):
|
||||
validate_transition("garbage", "hired") # unknown source
|
||||
|
||||
|
||||
# -- schema pins ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tables_exist_in_sqlite_master(placement_store: SQLitePlacementStore) -> None:
|
||||
with sqlite3.connect(placement_store._db_path) as conn:
|
||||
rows = {
|
||||
name
|
||||
for (name,) in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
assert "placement" in rows
|
||||
assert "placement_event" in rows
|
||||
|
||||
|
||||
def test_partial_unique_index_pinned(placement_store: SQLitePlacementStore) -> None:
|
||||
"""G-316 shape: the partial UNIQUE index exists in sqlite_master
|
||||
(the test_claims_store template)."""
|
||||
with sqlite3.connect(placement_store._db_path) as conn:
|
||||
rows = {
|
||||
sql
|
||||
for (sql,) in conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='index' AND name=?",
|
||||
("uq_active_placement_per_application",),
|
||||
)
|
||||
}
|
||||
assert rows, "partial index missing"
|
||||
sql = rows.pop()
|
||||
assert "CREATE UNIQUE INDEX" in sql
|
||||
assert "status='active'" in sql
|
||||
|
||||
|
||||
def test_no_delete_path_in_source() -> None:
|
||||
"""R-70: the module carries NO delete method and NO DELETE SQL —
|
||||
deleting the top ledger row would make INTEGER PRIMARY KEY reuse
|
||||
max+1 (chain-integrity hole)."""
|
||||
from ai_service.placements import store as placements_store_module
|
||||
|
||||
source = Path(placements_store_module.__file__).read_text()
|
||||
assert "DELETE FROM" not in source
|
||||
assert "def delete" not in source
|
||||
assert "session.delete" not in source
|
||||
|
||||
|
||||
# -- the transition + chain -----------------------------------------------------
|
||||
|
||||
|
||||
def test_transition_flow_creates_chained_events(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
|
||||
app1, placement_none = placement_store.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
assert app1.status == "in_review"
|
||||
assert placement_none is None
|
||||
|
||||
app2, _ = placement_store.transition_application(
|
||||
application.id, "interview", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
assert app2.status == "interview"
|
||||
|
||||
app3, placement = placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="AI Orchestration Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
)
|
||||
assert app3.status == "hired"
|
||||
assert placement is not None
|
||||
assert placement.status == "active"
|
||||
assert placement.role_title == "AI Orchestration Engineer"
|
||||
assert placement.start_date == "2026-11-01"
|
||||
assert placement.learner_id == "pilot-learner"
|
||||
assert placement.employer_id == "emp-test"
|
||||
|
||||
events = placement_store.get_application_events(application.id)
|
||||
assert [e.event_type for e in events] == [
|
||||
"application.in_review",
|
||||
"application.interview",
|
||||
"application.hired",
|
||||
"placement.created",
|
||||
]
|
||||
# the chain walks green end-to-end
|
||||
ok, head = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
assert head == events[-1].hash
|
||||
|
||||
|
||||
def test_hire_requires_role_title_and_start_date(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
with pytest.raises(ValueError, match="role_title"):
|
||||
placement_store.transition_application(
|
||||
application.id, "hired", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
with pytest.raises(ValueError, match="start_date"):
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
)
|
||||
|
||||
|
||||
def test_matrix_illegal_moves_raise(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
placement_store.transition_application(
|
||||
application.id, "offer", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
# backward — 409 class
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
# terminal-source after hire
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 12, 1),
|
||||
)
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.transition_application(
|
||||
application.id, "rejected", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
# unknown application
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.transition_application(
|
||||
"app-nonexistent", "in_review", actor_type="operator", actor_ref="operator"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_status_rejected(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.transition_application(
|
||||
application.id, "garbage", actor_type="operator", actor_ref="operator"
|
||||
)
|
||||
|
||||
|
||||
def test_tz_round_trip_canonical_stable(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""R-79: naive stored → _as_utc read → canonical isoformat stable —
|
||||
the hashing path and the reading path share canonical_json()."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
moment = datetime(2026, 10, 1, 12, 0, 0, tzinfo=UTC)
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"in_review",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
now=moment,
|
||||
)
|
||||
events = placement_store.list_events()
|
||||
assert len(events) == 1
|
||||
raw = events[0]
|
||||
# the canonical form is a pure function of the row — recompute twice
|
||||
assert raw.canonical_json() == raw.canonical_json()
|
||||
ok, _ = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_aware_non_utc_now_converted_chain_stays_green(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""P0-1 (security review): an aware NON-UTC `now` CONVERTS to the
|
||||
true instant — the append-time and read-back canonical forms are
|
||||
identical, so the chain walks green (a re-labeled offset would
|
||||
hash 12:00-04:00 and re-read 12:00+00:00 — permanently broken)."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
edt = timezone(timedelta(hours=-4))
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"in_review",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
now=datetime(2026, 10, 1, 12, 0, 0, tzinfo=edt),
|
||||
)
|
||||
ok, _ = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_actor_type_vocabulary_enforced(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""P1-1 (security review): the store boundary rejects an actor_type
|
||||
outside the closed vocabulary — a bad label would be frozen in
|
||||
immutable history."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
with pytest.raises(ValueError, match="actor_type"):
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"in_review",
|
||||
actor_type="ghost-actor",
|
||||
actor_ref="account:a1",
|
||||
)
|
||||
|
||||
|
||||
def test_payload_floats_rejected(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""R-69: payload values str/int/bool/None only — floats REJECTED."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
session = Session(placement_store._engine)
|
||||
try:
|
||||
row = PlacementEventRow(
|
||||
application_id=application.id,
|
||||
placement_id=None,
|
||||
event_type="application.in_review",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
payload={"ratio": 0.5},
|
||||
prev_hash=GENESIS_HASH,
|
||||
hash="",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
with pytest.raises(ValueError, match="str/int/bool/None"):
|
||||
row._validate()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
# -- tamper / fork / concurrency (the adversarial arms, D-111) ------------------
|
||||
|
||||
|
||||
def test_tamper_detected_via_second_engine(
|
||||
tmp_path: Path,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""Direct row mutation through a SECOND engine on the same file →
|
||||
the verification walk FAILS (R-78: the mutation commits before the
|
||||
walk reads — WAL snapshot isolation)."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
placement_store.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
ok_before, _ = placement_store.verify_chain()
|
||||
assert ok_before is True
|
||||
|
||||
with sqlite3.connect(placement_store._db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE placement_event SET actor_ref = 'account:evil' WHERE id = 1"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
ok_after, _ = placement_store.verify_chain()
|
||||
assert ok_after is False
|
||||
|
||||
|
||||
def test_fork_prev_hash_surfaces_integrity_error(
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""G-316 backstop class: a second row claiming the same prev_hash
|
||||
→ UNIQUE refuses it, IntegrityError SURFACES."""
|
||||
with pytest.raises(IntegrityError):
|
||||
with Session(placement_store._engine) as session:
|
||||
for _i in range(2):
|
||||
row = PlacementEventRow(
|
||||
application_id="app-x",
|
||||
placement_id=None,
|
||||
event_type="application.in_review",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
payload={},
|
||||
prev_hash=GENESIS_HASH,
|
||||
hash="",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
row.hash = "computed-later"
|
||||
session.flush()
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_concurrent_transition_one_winner(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""R-78: two stores (separate engines) race the same application —
|
||||
one winner commits; the loser surfaces an honest error (never two
|
||||
events on one prev_hash)."""
|
||||
db_path = tmp_path / "shared.db"
|
||||
store_a = SQLitePlacementStore(db_path=db_path)
|
||||
store_b = SQLitePlacementStore(db_path=db_path)
|
||||
marketplace = SQLiteMarketplaceStore(db_path=db_path)
|
||||
try:
|
||||
posting_id = _seed_posting(marketplace)
|
||||
application = _apply(marketplace, posting_id, "pilot-learner")
|
||||
# both read the same submitted status; A commits first
|
||||
store_a.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:a"
|
||||
)
|
||||
with pytest.raises((InvalidTransition, IntegrityError)):
|
||||
store_b.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:b"
|
||||
)
|
||||
ok, _ = store_a.verify_chain()
|
||||
assert ok is True
|
||||
# exactly ONE in_review event exists
|
||||
events = store_a.get_application_events(application.id)
|
||||
assert [e.event_type for e in events] == ["application.in_review"]
|
||||
finally:
|
||||
store_a.close()
|
||||
store_b.close()
|
||||
marketplace.close()
|
||||
|
||||
|
||||
# -- the D-102 correction lane ---------------------------------------------------
|
||||
|
||||
|
||||
def test_revoke_and_rehire(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
_, placement = placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
)
|
||||
assert placement is not None
|
||||
|
||||
# revoke: active → revoked + event
|
||||
revoked = placement_store.revoke_placement(placement.id, "operator")
|
||||
assert revoked is not None and revoked.status == "revoked"
|
||||
|
||||
# rehire after revoke: a FRESH active row (R-80) — decided_at =
|
||||
# the re-hire event's stamp (G-326)
|
||||
moment = datetime(2026, 10, 2, 9, 30, 0, tzinfo=UTC)
|
||||
rehired = placement_store.rehire_placement(
|
||||
application.id,
|
||||
role_title="Engineer II",
|
||||
start_date=date(2026, 12, 15),
|
||||
actor_ref="operator",
|
||||
now=moment,
|
||||
)
|
||||
assert rehired.status == "active"
|
||||
assert rehired.id != placement.id
|
||||
assert rehired.decided_at == moment
|
||||
|
||||
# the three-event ledger sequence: hired → revoked → rehired
|
||||
events = placement_store.get_application_events(application.id)
|
||||
assert [e.event_type for e in events] == [
|
||||
"application.hired",
|
||||
"placement.created",
|
||||
"placement.revoked",
|
||||
"placement.rehired",
|
||||
]
|
||||
|
||||
# the revoked placement stays in history (D-102)
|
||||
statuses = {p.status for p in placement_store.list_placements()}
|
||||
assert statuses == {"active", "revoked"}
|
||||
|
||||
ok, _ = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_revoke_only_active(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
_, placement = placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
)
|
||||
assert placement is not None
|
||||
placement_store.revoke_placement(placement.id, "operator")
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.revoke_placement(placement.id, "operator")
|
||||
assert placement_store.revoke_placement("plc-nonexistent", "operator") is None
|
||||
|
||||
|
||||
def test_rehire_requires_hired_application(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
# not hired yet → re-hire is the correction lane for a hired app only
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.rehire_placement(
|
||||
application.id,
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
actor_ref="operator",
|
||||
)
|
||||
|
||||
|
||||
def test_rehire_second_active_surfaces_integrity_error(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""The partial-unique arm: re-hire WITHOUT revoke → the partial
|
||||
UNIQUE index refuses the second ACTIVE row (the caller's honest
|
||||
409)."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
placement_store.rehire_placement(
|
||||
application.id,
|
||||
role_title="Engineer II",
|
||||
start_date=date(2026, 12, 1),
|
||||
actor_ref="operator",
|
||||
)
|
||||
|
||||
|
||||
def test_chain_walk_after_n_appends(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
for to_status in ("in_review", "interview", "offer", "rejected"):
|
||||
placement_store.transition_application(
|
||||
application.id, to_status, actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
events = placement_store.list_events()
|
||||
assert len(events) == 4
|
||||
ok, head = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
assert head == events[-1].hash
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ModerationTabs } from '../../../../components/admin/moderation-tabs';
|
||||
import { EmployerEntitlementPanel } from '../../../../components/admin/employer-entitlement-panel';
|
||||
import { EmployerNetworkPanel } from '../../../../components/admin/employer-network-panel';
|
||||
|
||||
export default function MarketplaceModerationPage() {
|
||||
return (
|
||||
@@ -12,6 +14,26 @@ export default function MarketplaceModerationPage() {
|
||||
</p>
|
||||
</header>
|
||||
<ModerationTabs />
|
||||
{/* v0.9 P4 (REQ-9-004): per-employer billing entitlement — the
|
||||
* operator's one-glance "can this employer post?" view. */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Employer billing entitlement
|
||||
</h2>
|
||||
<EmployerEntitlementPanel employerId="emp-openai" />
|
||||
</section>
|
||||
{/* v0.10 P5 (REQ-10-005, D-094): the employer network overview —
|
||||
* sibling sections BELOW the v0.9 entitlement panel. Employer
|
||||
* accounts (email, claimed entity), the claim queue (evidence
|
||||
* rendered ADVISORY, A-304; explicit approve/reject), and the
|
||||
* audit trail — fed by the P2 operator routes over the admin
|
||||
* transport. */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Employer network overview
|
||||
</h2>
|
||||
<EmployerNetworkPanel />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import { EmployerApplicationsClient } from '../../../../components/employer/employer-applications-client';
|
||||
|
||||
/**
|
||||
* The hiring pipeline (v0.11, REQ-11-002/003, D-109 surface 1): own
|
||||
* applications with the seven D-099 status badges, per-target
|
||||
* transition CTAs, and the hire form {role_title, start_date}
|
||||
* (employer-attested, D-103). Session-gated shell with honest
|
||||
* loading/error/empty/claim states (the A-406 board-client
|
||||
* discipline). learner_id IS visible here — the G-319 supersession
|
||||
* scopes it to the session's own claimed-entity postings (D-105).
|
||||
*/
|
||||
export default function ApplicationsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Hiring Pipeline</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Hiring Pipeline
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
Review applicants on your postings and drive them through the hiring
|
||||
lifecycle — in review, interview, offer, reject, or hire. A hire
|
||||
records the placement with the role title and start date you attest.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EmployerApplicationsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import { EmployerBillingClient } from '../../../../components/employer/employer-billing-client';
|
||||
|
||||
/** Employer billing surface (P5, REQ-10-005, Task 5-2-01): the
|
||||
* entitlement view (current package, posts used/limit bar, expiry —
|
||||
* honest empty/expired states) + the self-serve buy flow over the
|
||||
* v0.9 catalog, including the in-app mock-complete confirmation
|
||||
* honestly labeled as the pilot mock path. A server shell with
|
||||
* build-time metadata (the login-page precedent, A-411) rendering the
|
||||
* session-gated client (the A-406 pattern). */
|
||||
export const metadata: Metadata = {
|
||||
title: 'Billing — Nextcraft Employer',
|
||||
description: 'Your posting package, usage, and self-serve checkout.',
|
||||
openGraph: {
|
||||
title: 'Billing — Nextcraft Employer',
|
||||
description: 'Your posting package, usage, and self-serve checkout.',
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
export default function EmployerBillingPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Employer · Billing</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Billing
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
Your posting package, usage, and checkout — real data from your
|
||||
employer account. Purchases you complete yourself land here
|
||||
immediately.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EmployerBillingClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Badge, Card, CardBody } from '@nextcraft/ui';
|
||||
import { LoginClient } from '../../../../components/employer/login-client';
|
||||
|
||||
/** Employer sign-in (REQ-10-003, MH-3e): a server shell with build-time
|
||||
* metadata (the pricing-page precedent, A-411) rendering the client
|
||||
* (the A-406 pattern) — the login POST + honest states live there. */
|
||||
export const metadata: Metadata = {
|
||||
title: 'Employer Sign In — Nextcraft',
|
||||
description: 'Sign in to your Nextcraft employer account.',
|
||||
openGraph: {
|
||||
title: 'Employer Sign In — Nextcraft',
|
||||
description: 'Sign in to your Nextcraft employer account.',
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
export default function EmployerLoginPage() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Employer · Sign in</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Sign in to your employer account
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Manage your postings, view real applicant counts, and track your entity's
|
||||
presence on the marketplace.
|
||||
</p>
|
||||
</header>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<LoginClient />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +1,28 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Briefcase,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Target,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Badge, Card, CardBody, Avatar } from '@nextcraft/ui';
|
||||
import { candidates, employers } from '@nextcraft/mock-data';
|
||||
import { AnalyticsCharts } from '../../../components/employer/analytics-charts';
|
||||
import { matchBadgeClasses } from '../../../lib/format';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock pipeline + metric data */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const METRICS = [
|
||||
{ label: 'Active Postings', value: 7, trend: '+12%', icon: Briefcase, tone: 'primary' as const },
|
||||
{ label: 'Total Applicants', value: 142, trend: '+24%', icon: Users, tone: 'accent' as const },
|
||||
{ label: 'Talent Matches', value: 28, trend: '+8%', icon: Target, tone: 'violet' as const },
|
||||
{ label: 'Placement Rate', value: '18%', trend: '+3%', icon: TrendingUp, tone: 'amber' as const },
|
||||
];
|
||||
|
||||
const TONE_CLASSES: Record<string, string> = {
|
||||
primary: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
|
||||
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
};
|
||||
|
||||
interface PipelineStage {
|
||||
name: string;
|
||||
count: number;
|
||||
cards: Array<{ name: string; position: string; score: number }>;
|
||||
}
|
||||
|
||||
const PIPELINE: PipelineStage[] = [
|
||||
{
|
||||
name: 'Applied',
|
||||
count: 8,
|
||||
cards: [
|
||||
{ name: 'Maya Okonkwo', position: 'AI Orchestration Engineer', score: 96 },
|
||||
{ name: 'Devon Park', position: 'AI Safety Researcher', score: 91 },
|
||||
{ name: 'Priya Iyer', position: 'Human-AI Designer', score: 89 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Screening',
|
||||
count: 5,
|
||||
cards: [
|
||||
{ name: 'Tomás Vega', position: 'LLM App Developer', score: 93 },
|
||||
{ name: 'Sofia Marchetti', position: 'AI Governance Lead', score: 85 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Interview',
|
||||
count: 3,
|
||||
cards: [
|
||||
{ name: 'Liam Chen', position: 'Agent Reliability Eng', score: 90 },
|
||||
{ name: 'Yuki Tanaka', position: 'Evaluation Engineer', score: 86 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Offer',
|
||||
count: 2,
|
||||
cards: [{ name: 'Ingrid Solberg', position: 'AI Red Team Lead', score: 88 }],
|
||||
},
|
||||
{
|
||||
name: 'Hired',
|
||||
count: 1,
|
||||
cards: [{ name: 'Hana Lindqvist', position: 'Comp Biologist', score: 88 }],
|
||||
},
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Page */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export default function EmployerDashboardPage() {
|
||||
const employer = employers[0];
|
||||
const topMatches = [...candidates].sort((a, b) => b.matchScore - a.matchScore).slice(0, 5);
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import { EmployerOverviewClient } from '../../../components/employer/employer-overview-client';
|
||||
|
||||
/**
|
||||
* Employer dashboard overview (REQ-10-003, D-092): the mock imports
|
||||
* + inline METRICS/PIPELINE constants are GONE — the page is a server
|
||||
* shell rendering the session-gated client with REAL active-postings +
|
||||
* applicant counts, the claimed-entity profile, and mock-labeled
|
||||
* talent metrics (the A-406 pattern).
|
||||
*/
|
||||
export default function PostingsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Employer Dashboard</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Employer Dashboard
|
||||
Overview
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Welcome back, <span className="font-semibold text-slate-800 dark:text-slate-200">{employer.name}</span>.
|
||||
Here is your talent pipeline at a glance.
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
Your postings, applicants, and claimed entity at a glance — real data
|
||||
from your employer account.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Metric cards */}
|
||||
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{METRICS.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Card key={m.label}>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[m.tone]}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{m.trend}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{m.value}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{m.label}</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{/* Middle row — pipeline + talent matches */}
|
||||
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Applicant pipeline — 2/3 width */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Applicant Pipeline
|
||||
</h2>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{PIPELINE.reduce((s, c) => s + c.count, 0)} candidates in flight
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{PIPELINE.map((stage) => (
|
||||
<div
|
||||
key={stage.name}
|
||||
className="flex w-56 shrink-0 flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-900/60"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||
{stage.name}
|
||||
</h3>
|
||||
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-200 px-1.5 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200">
|
||||
{stage.count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{stage.cards.map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex items-center gap-2 rounded-md border border-slate-200 bg-white p-2 dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<Avatar name={c.name} size="sm" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-xs font-medium text-slate-800 dark:text-slate-100">
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{c.position}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex h-7 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
|
||||
c.score,
|
||||
)}`}
|
||||
>
|
||||
{c.score}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Talent matches — 1/3 width */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Talent Matches
|
||||
</h2>
|
||||
<Link
|
||||
href="/employer/talent"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
See all <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-2 p-3">
|
||||
{topMatches.map((cand) => (
|
||||
<Link
|
||||
key={cand.id}
|
||||
href={`/employer/talent/${cand.id}`}
|
||||
className="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Avatar name={cand.name} src={cand.avatar} size="md" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
{cand.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{cand.headline}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex h-8 w-12 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
|
||||
cand.matchScore,
|
||||
)}`}
|
||||
>
|
||||
{cand.matchScore}%
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Analytics charts */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">Analytics</h2>
|
||||
<AnalyticsCharts />
|
||||
</section>
|
||||
<EmployerOverviewClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import { jobs } from '@nextcraft/mock-data';
|
||||
import { PostingManager } from '../../../../components/employer/posting-manager';
|
||||
import { OwnPostingsClient } from '../../../../components/employer/own-postings-client';
|
||||
|
||||
/**
|
||||
* Posting management (REQ-10-003/004, D-092): the mock job-list import
|
||||
* is GONE — the page is a server shell rendering the own-postings
|
||||
* lifecycle client (draft → published → closed, real applicant
|
||||
* counts) with the P4 write actions: create draft, edit own draft,
|
||||
* publish, close. The A-406 pattern.
|
||||
*/
|
||||
export default function PostingsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -11,12 +17,13 @@ export default function PostingsPage() {
|
||||
Posting Management
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
Create, edit, and track your job postings. View the applicant pipeline for each posting
|
||||
and manage interview stages.
|
||||
Track and manage your postings across their lifecycle — draft, published,
|
||||
closed — with real applicant counts. Create drafts, edit your own drafts,
|
||||
publish, and close, all from this page.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<PostingManager jobs={jobs} />
|
||||
<OwnPostingsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Badge, Card, CardBody } from '@nextcraft/ui';
|
||||
import { RegisterClient } from '../../../../components/employer/register-client';
|
||||
|
||||
/** Employer registration (REQ-10-003, MH-3e): a server shell with
|
||||
* build-time metadata (A-411) rendering the client (the A-406
|
||||
* pattern) — the register POST + honest 409/429/422 states live
|
||||
* there. The created account's email is UNVERIFIED (D-097) and the
|
||||
* dashboard displays it as such; registration lands the account
|
||||
* logged-in. */
|
||||
export const metadata: Metadata = {
|
||||
title: 'Employer Registration — Nextcraft',
|
||||
description: 'Create a Nextcraft employer account.',
|
||||
openGraph: {
|
||||
title: 'Employer Registration — Nextcraft',
|
||||
description: 'Create a Nextcraft employer account.',
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
export default function EmployerRegisterPage() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Employer · Register</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Create your employer account
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Register with your work email, then claim your company's entity on the
|
||||
marketplace. Email addresses are unverified at pilot — claims are reviewed
|
||||
by an operator before they activate.
|
||||
</p>
|
||||
</header>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<RegisterClient />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const LINKS = [
|
||||
{ label: 'Overview', href: '/employer' },
|
||||
{ label: 'Talent search', href: '/employer/talent' },
|
||||
{ label: 'Postings', href: '/employer/postings' },
|
||||
{ label: 'Billing', href: '/employer/billing' },
|
||||
];
|
||||
|
||||
export default function EmployerLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Check, Crown, Star, X, Zap } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Badge, Card } from '@nextcraft/ui';
|
||||
import { PricingClient } from '../../../../components/marketplace/pricing-client';
|
||||
|
||||
/** P5 (REQ-6-006, A-411): build-time metadata on the static set — the
|
||||
* pricing page stays zero-backend (A-412; payments are REQ-F-018,
|
||||
* deferred beyond v0.6). */
|
||||
/** P5 (REQ-6-006, A-411): build-time metadata on the static set —
|
||||
* retained. v0.9 (REQ-9-002): the tier data moved from inline
|
||||
* constants to the runtime catalog (D-078/D-079 — one source of truth,
|
||||
* no drift); the page is now a server shell + PricingClient (the
|
||||
* A-406 board-client pattern). The comparison matrix stays static —
|
||||
* it is product copy, not pricing data. */
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing — Nextcraft Marketplace',
|
||||
description:
|
||||
@@ -16,100 +20,6 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
interface JobPricingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
cadence: string;
|
||||
features: string[];
|
||||
cta: string;
|
||||
popular?: boolean;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TalentPricingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
cadence: string;
|
||||
features: string[];
|
||||
cta: string;
|
||||
popular?: boolean;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const JOB_PLANS: JobPricingPlan[] = [
|
||||
{
|
||||
id: 'single',
|
||||
name: 'Single Post',
|
||||
price: '$49',
|
||||
cadence: 'one-time',
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
features: ['1 job posting', '30 days live', 'Basic analytics', 'Standard placement'],
|
||||
cta: 'Post a Job',
|
||||
},
|
||||
{
|
||||
id: 'bundle',
|
||||
name: 'Bundle',
|
||||
price: '$399',
|
||||
cadence: 'one-time',
|
||||
icon: <Star className="h-4 w-4" />,
|
||||
features: ['10 job postings', '90 days each', 'Advanced analytics', 'Priority placement'],
|
||||
cta: 'Buy Bundle',
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: 'enterprise-jobs',
|
||||
name: 'Enterprise',
|
||||
price: 'Custom',
|
||||
cadence: 'annual',
|
||||
icon: <Crown className="h-4 w-4" />,
|
||||
features: [
|
||||
'Unlimited postings',
|
||||
'Dedicated account manager',
|
||||
'API access',
|
||||
'Custom branding',
|
||||
],
|
||||
cta: 'Contact Sales',
|
||||
},
|
||||
];
|
||||
|
||||
const TALENT_PLANS: TalentPricingPlan[] = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: '$99',
|
||||
cadence: '/mo',
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
features: ['Browse talent database', '10 candidate views/mo', 'Basic filters'],
|
||||
cta: 'Get Starter',
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: '$299',
|
||||
cadence: '/mo',
|
||||
icon: <Star className="h-4 w-4" />,
|
||||
features: [
|
||||
'Unlimited views',
|
||||
'AI matching',
|
||||
'Advanced filters',
|
||||
'Saved searches',
|
||||
],
|
||||
cta: 'Get Pro',
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: 'enterprise-talent',
|
||||
name: 'Enterprise',
|
||||
price: 'Custom',
|
||||
cadence: 'annual',
|
||||
icon: <Crown className="h-4 w-4" />,
|
||||
features: ['Everything in Pro', 'API access', 'Bulk export', 'Dedicated CSM'],
|
||||
cta: 'Contact Sales',
|
||||
},
|
||||
];
|
||||
|
||||
interface FeatureRow {
|
||||
feature: string;
|
||||
values: Array<string | boolean>;
|
||||
@@ -129,6 +39,12 @@ const COMPARISON: FeatureRow[] = [
|
||||
{ feature: 'Bulk export', values: [false, false, true] },
|
||||
];
|
||||
|
||||
const COMPARISON_COLUMNS = [
|
||||
{ key: 'single', label: 'Single Post' },
|
||||
{ key: 'bundle', label: 'Bundle' },
|
||||
{ key: 'enterprise', label: 'Enterprise' },
|
||||
] as const;
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
@@ -143,35 +59,10 @@ export default function PricingPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Job posting packages */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Job Posting Packages
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{JOB_PLANS.map((plan) => (
|
||||
<PricingCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Catalog tiers — runtime client fetch (REQ-9-002, D-079). */}
|
||||
<PricingClient />
|
||||
|
||||
{/* Talent access plans */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Talent Access Plans
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{TALENT_PLANS.map((plan) => (
|
||||
<PricingCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature comparison */}
|
||||
{/* Feature comparison — static product copy (not pricing data). */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Compare features
|
||||
@@ -227,76 +118,4 @@ export default function PricingPage() {
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COMPARISON_COLUMNS = [
|
||||
{ key: 'single', label: 'Single Post' },
|
||||
{ key: 'bundle', label: 'Bundle' },
|
||||
{ key: 'enterprise', label: 'Enterprise' },
|
||||
] as const;
|
||||
|
||||
function PricingCard({
|
||||
plan,
|
||||
}: {
|
||||
plan: JobPricingPlan | TalentPricingPlan;
|
||||
}) {
|
||||
const isPopular = plan.popular;
|
||||
return (
|
||||
<Card
|
||||
className={[
|
||||
'flex h-full flex-col',
|
||||
isPopular
|
||||
? 'border-primary-500 ring-2 ring-primary-500/30 dark:border-primary-400'
|
||||
: '',
|
||||
].join(' ')}
|
||||
>
|
||||
<CardBody className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
||||
{plan.icon}
|
||||
</span>
|
||||
{isPopular && (
|
||||
<Badge variant="info">
|
||||
<Star className="h-3 w-3" />
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{plan.cadence}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{plan.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-auto pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isPopular ? 'primary' : 'outline'}
|
||||
size="md"
|
||||
className="w-full"
|
||||
>
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
|
||||
import {
|
||||
getEntitlement,
|
||||
setAdminToken,
|
||||
EngineError,
|
||||
type EntitlementResponse,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Employer entitlement panel (REQ-9-004, G-308): the operator's
|
||||
* one-glance answer to "can this employer post?" — active package,
|
||||
* posts used/limit bar, expiry, subscription badge.
|
||||
*
|
||||
* G-308 design: the entitlements route is admin-gated, so the panel
|
||||
* carries a small operator-token input persisted to sessionStorage
|
||||
* (setAdminToken — never logged); 403 renders an honest locked state
|
||||
* with the token prompt, never a retry loop.
|
||||
*/
|
||||
export function EmployerEntitlementPanel({ employerId }: { employerId: string }) {
|
||||
const [token, setToken] = useState('');
|
||||
const [entitlement, setEntitlement] = useState<EntitlementResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!token.trim()) return;
|
||||
setAdminToken(token.trim());
|
||||
setLocked(false);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
setEntitlement(await getEntitlement(employerId));
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 403) {
|
||||
setLocked(true); // honest locked state — the token prompt stays
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load entitlement.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEntitlement(null);
|
||||
setError(null);
|
||||
setLocked(false);
|
||||
}, [employerId]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Billing entitlement
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Operator token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="md" onClick={load}>
|
||||
{loading ? 'Loading…' : 'Check entitlement'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locked && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Locked — the admin token is missing or invalid. Enter the operator token
|
||||
to view this employer's entitlement.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
|
||||
{entitlement && (
|
||||
<div className="flex flex-col gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{entitlement.purchase ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{entitlement.purchase.package_name}
|
||||
</span>
|
||||
<Badge variant="info">
|
||||
{entitlement.purchase.posts_limit === null
|
||||
? 'Unlimited'
|
||||
: `${entitlement.purchase.posts_used}/${entitlement.purchase.posts_limit} posts`}
|
||||
</Badge>
|
||||
</div>
|
||||
{entitlement.purchase.posts_limit !== null && (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
|
||||
<div
|
||||
className="h-full bg-primary-500"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(entitlement.purchase.posts_used /
|
||||
entitlement.purchase.posts_limit) *
|
||||
100,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{entitlement.purchase.expires_at && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Expires {new Date(entitlement.purchase.expires_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
No active posting package — posting creation returns an upgrade
|
||||
prompt.
|
||||
</p>
|
||||
)}
|
||||
{entitlement.subscription ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{entitlement.subscription.plan_name}</span>
|
||||
<Badge
|
||||
variant={
|
||||
entitlement.subscription.status === 'active' ? 'success' : 'warning'
|
||||
}
|
||||
>
|
||||
{entitlement.subscription.status}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
No talent-access subscription.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
|
||||
import type {
|
||||
AdminAccountsResponse,
|
||||
AdminAuditResponse,
|
||||
AdminClaimsResponse,
|
||||
EmployerClaim,
|
||||
EmployerClaimEvidence,
|
||||
PlacementOverview,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
listEmployerAdminAccounts,
|
||||
listEmployerAdminClaims,
|
||||
listEmployerAdminAudit,
|
||||
approveEmployerAdminClaim,
|
||||
rejectEmployerAdminClaim,
|
||||
getPlacementOverview,
|
||||
getPlacementExport,
|
||||
getPlacementExportSummary,
|
||||
setAdminToken,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Employer network overview (REQ-10-005, D-094, R-62): the operator's
|
||||
* three-section view over the P2 operator routes — employer accounts
|
||||
* (email, claimed entity, created_at columns), the claim queue
|
||||
* (evidence + domain-match signal rendered ADVISORY — never
|
||||
* verification, A-304; explicit approve/reject actions), and the
|
||||
* audit trail. Fed by the engine-client admin transport over the
|
||||
* existing adminToken() G-308 wrapper.
|
||||
*
|
||||
* Locked-state design (the entitlement-panel load() pattern, R-62):
|
||||
* every route is admin-gated (fail-closed 403), so 403 renders the
|
||||
* honest locked state with the token prompt — NEVER a retry loop. The
|
||||
* token is persisted via setAdminToken (sessionStorage, never
|
||||
* logged); an empty token never dispatches a fetch.
|
||||
*/
|
||||
|
||||
type Decision = 'approve' | 'reject';
|
||||
|
||||
/** Read the advisory domain-match signal off a claim's stored evidence
|
||||
* snapshot (A-304: the signal informs, the operator decides — never
|
||||
* presented as verification). The evidence is claim-time provenance
|
||||
* JSON; a missing/malformed snapshot renders as unavailable, never a
|
||||
* fabricated match. */
|
||||
function evidenceSignal(
|
||||
claim: EmployerClaim,
|
||||
): { evidence: Partial<EmployerClaimEvidence> | null; match: boolean | null } {
|
||||
const evidence = claim.evidence as Partial<EmployerClaimEvidence> | null;
|
||||
if (!evidence || typeof evidence !== 'object') return { evidence: null, match: null };
|
||||
return {
|
||||
evidence,
|
||||
match: typeof evidence.match === 'boolean' ? evidence.match : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function EmployerNetworkPanel() {
|
||||
const [token, setToken] = useState('');
|
||||
const [accounts, setAccounts] = useState<AdminAccountsResponse | null>(null);
|
||||
const [claims, setClaims] = useState<AdminClaimsResponse | null>(null);
|
||||
const [audit, setAudit] = useState<AdminAuditResponse | null>(null);
|
||||
const [overview, setOverview] = useState<PlacementOverview | null>(null);
|
||||
const [exportSummary, setExportSummary] = useState<string | null>(null);
|
||||
const [exportBusy, setExportBusy] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [decisionBusy, setDecisionBusy] = useState<string | null>(null);
|
||||
const [decisionError, setDecisionError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) return;
|
||||
setAdminToken(trimmed);
|
||||
setError(null);
|
||||
setDecisionError(null);
|
||||
setLoading(true);
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
// All three operator reads ride the SAME admin transport; a 403
|
||||
// on any of them locks the whole overview (one token, one gate).
|
||||
const [acct, queue, trail, placed] = await Promise.all([
|
||||
listEmployerAdminAccounts(controller.signal),
|
||||
listEmployerAdminClaims(controller.signal),
|
||||
listEmployerAdminAudit(controller.signal),
|
||||
// v0.11 P3/P4: the placements overview rides the same admin
|
||||
// transport + gate; a fetch failure is honest (the rest loads).
|
||||
getPlacementOverview(controller.signal).catch(() => null),
|
||||
]);
|
||||
setAccounts(acct);
|
||||
setClaims(queue);
|
||||
setAudit(trail);
|
||||
setOverview(placed);
|
||||
setLocked(false);
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 403) {
|
||||
setLocked(true); // honest locked state — the token prompt stays
|
||||
} else if (err instanceof EngineError && err.status === 401) {
|
||||
setLocked(true); // credential failure on an operator route
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load the network overview.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return () => controller.abort();
|
||||
}, [token]);
|
||||
|
||||
/** The explicit operator decision (approve/reject). The audit event
|
||||
* + provenance land server-side in the same transaction; the queue
|
||||
* re-reads after every decision (no optimistic updates). */
|
||||
const decide = async (claimId: string, decision: Decision) => {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) return;
|
||||
setAdminToken(trimmed);
|
||||
setDecisionBusy(`${decision}:${claimId}`);
|
||||
setDecisionError(null);
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
if (decision === 'approve') {
|
||||
await approveEmployerAdminClaim(claimId, controller.signal);
|
||||
} else {
|
||||
await rejectEmployerAdminClaim(claimId, controller.signal);
|
||||
}
|
||||
// Re-read the queue + audit — the decision's effects render from
|
||||
// the operator routes, never from optimistic local state.
|
||||
const [queue, trail] = await Promise.all([
|
||||
listEmployerAdminClaims(controller.signal),
|
||||
listEmployerAdminAudit(controller.signal),
|
||||
]);
|
||||
setClaims(queue);
|
||||
setAudit(trail);
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 403) {
|
||||
setLocked(true);
|
||||
} else if (err instanceof EngineError && err.status === 409) {
|
||||
setDecisionError(err.message); // the honest constraint 409s
|
||||
} else if (err instanceof EngineError && err.status === 404) {
|
||||
setDecisionError('This claim no longer exists — the queue below is the current truth.');
|
||||
} else {
|
||||
setDecisionError(err instanceof Error ? err.message : 'The decision failed — try again.');
|
||||
}
|
||||
} finally {
|
||||
setDecisionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// No auto-fetch: an empty token never dispatches (the panel waits
|
||||
// for the operator's explicit load — the entitlement-panel shape).
|
||||
}, []);
|
||||
|
||||
/** The export actions (D-109 surface 4): the summary opens IN-TAB
|
||||
* (the markdown renders in the panel); the JSON DOWNLOADS as a
|
||||
* blob. G-328: the surrounding copy says RECORDED placements —
|
||||
* never a verification claim. */
|
||||
const runExport = async (kind: 'summary' | 'download') => {
|
||||
setExportBusy(true);
|
||||
setExportError(null);
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
if (kind === 'summary') {
|
||||
const text = await getPlacementExportSummary(controller.signal);
|
||||
setExportSummary(text);
|
||||
} else {
|
||||
const text = await getPlacementExport(controller.signal);
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `placement-audit-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 403) {
|
||||
setLocked(true);
|
||||
} else {
|
||||
setExportError(err instanceof Error ? err.message : 'The export failed.');
|
||||
}
|
||||
} finally {
|
||||
setExportBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Employer network overview
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Operator token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="md" onClick={() => void load()}>
|
||||
{loading ? 'Loading…' : 'Load overview'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locked && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Locked — the admin token is missing or invalid. Enter the operator token
|
||||
to view employer accounts, the claim queue, and the audit trail.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
{decisionError && <p className="text-sm text-red-600 dark:text-red-400">{decisionError}</p>}
|
||||
|
||||
{accounts && claims && audit && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* v0.11 P3/P4 (D-109 surface 4): the placements block —
|
||||
RECORDED placements toward the 1,000 guiding goal +
|
||||
per-employer lists + the export links. G-328: recorded
|
||||
placements, never a verification claim. */}
|
||||
{overview && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Recorded placements
|
||||
</h4>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<span className="text-2xl font-bold">
|
||||
{overview.distinct_learners_placed}
|
||||
</span>{' '}
|
||||
distinct learners placed (employer-attested records, toward
|
||||
the {overview.guiding_goal} guiding goal) ·{' '}
|
||||
{overview.total_active_placements} active placements
|
||||
</p>
|
||||
{overview.by_employer.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2 text-xs text-slate-600 dark:text-slate-400">
|
||||
{overview.by_employer.map((bucket) => (
|
||||
<li
|
||||
key={bucket.employer_id}
|
||||
className="rounded-full bg-slate-100 px-2 py-1 dark:bg-slate-800/60"
|
||||
>
|
||||
<span className="font-mono">{bucket.employer_id}</span>: {bucket.count}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={exportBusy}
|
||||
onClick={() => {
|
||||
void runExport('summary');
|
||||
}}
|
||||
>
|
||||
{exportBusy ? 'Working…' : 'Open export summary (in-tab)'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exportBusy}
|
||||
onClick={() => {
|
||||
void runExport('download');
|
||||
}}
|
||||
>
|
||||
{exportBusy ? 'Working…' : 'Download audit export (JSON)'}
|
||||
</Button>
|
||||
</div>
|
||||
{exportError && (
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{exportError}</p>
|
||||
)}
|
||||
{exportSummary && (
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-slate-50 p-3 text-xs text-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
|
||||
{exportSummary}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Accounts list — email, claimed entity, created_at (the
|
||||
UNVERIFIED marker renders as such, D-097). */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Accounts ({accounts.count})
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="p-2 font-medium">Email</th>
|
||||
<th className="p-2 font-medium">Email verified</th>
|
||||
<th className="p-2 font-medium">Claimed entity</th>
|
||||
<th className="p-2 font-medium">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accounts.accounts.map((a) => (
|
||||
<tr
|
||||
key={a.id}
|
||||
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
|
||||
>
|
||||
<td className="p-2 font-medium text-slate-900 dark:text-slate-100">{a.email}</td>
|
||||
<td className="p-2 text-slate-600 dark:text-slate-400">
|
||||
<Badge variant="warning">Unverified</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-slate-600 dark:text-slate-400">
|
||||
{a.employer_id ?? '—'}
|
||||
</td>
|
||||
<td className="p-2 text-slate-600 dark:text-slate-400">
|
||||
{a.created_at ? new Date(a.created_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{accounts.accounts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-3 text-center text-slate-500 dark:text-slate-400">
|
||||
No employer accounts yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claim queue — the stored evidence snapshot rendered
|
||||
ADVISORY (A-304: the domain-match signal is a signal,
|
||||
never a verification; match=false is the corpus norm).
|
||||
Approve/reject are the EXPLICIT operator actions. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Claim queue ({claims.count})
|
||||
</h4>
|
||||
<div className="flex flex-col gap-3">
|
||||
{claims.claims.map((claim) => {
|
||||
const { evidence, match } = evidenceSignal(claim);
|
||||
const busyKey = `${claim.id}`;
|
||||
const isBusy =
|
||||
decisionBusy === `approve:${claim.id}` || decisionBusy === `reject:${claim.id}`;
|
||||
return (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||
{claim.id}
|
||||
</span>
|
||||
<ClaimStatusBadge status={claim.status} />
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
||||
entity <span className="font-mono">{claim.employer_id}</span>
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
submitted {new Date(claim.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{/* A-304 binding: the evidence renders as ADVISORY
|
||||
input with an explicit label — the operator
|
||||
decision activates, the signal never does. */}
|
||||
<div className="rounded-md bg-slate-50 p-2 text-xs dark:bg-slate-800/60">
|
||||
<p className="font-medium text-slate-600 dark:text-slate-300">
|
||||
Advisory evidence (not verification — the operator decision activates):
|
||||
</p>
|
||||
{evidence ? (
|
||||
<ul className="mt-1 flex flex-col gap-0.5 text-slate-600 dark:text-slate-400">
|
||||
<li>
|
||||
claim email domain:{' '}
|
||||
<span className="font-mono">
|
||||
{evidence.account_email_domain ?? 'unavailable'}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
entity website domain:{' '}
|
||||
<span className="font-mono">
|
||||
{evidence.entity_website_domain ?? 'unavailable'}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
domain-match signal:{' '}
|
||||
{match === null ? (
|
||||
'unavailable'
|
||||
) : match ? (
|
||||
<Badge variant="info">match</Badge>
|
||||
) : (
|
||||
<Badge variant="default">no match</Badge>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-slate-600 dark:text-slate-400">
|
||||
Evidence snapshot unavailable on this claim.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{claim.decided_by && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
decided by {claim.decided_by}
|
||||
{claim.decided_at
|
||||
? ` at ${new Date(claim.decided_at).toLocaleDateString()}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{claim.status === 'pending' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void decide(claim.id, 'approve')}
|
||||
loading={decisionBusy === `approve:${busyKey}`}
|
||||
disabled={decisionBusy !== null && !isBusy}
|
||||
>
|
||||
Approve claim
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void decide(claim.id, 'reject')}
|
||||
loading={decisionBusy === `reject:${busyKey}`}
|
||||
disabled={decisionBusy !== null && !isBusy}
|
||||
>
|
||||
Reject claim
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{claims.claims.length === 0 && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
No claims in the queue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audit trail — every decision with operator provenance. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Audit trail ({audit.count})
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="p-2 font-medium">Event</th>
|
||||
<th className="p-2 font-medium">Account</th>
|
||||
<th className="p-2 font-medium">Entity</th>
|
||||
<th className="p-2 font-medium">Operator</th>
|
||||
<th className="p-2 font-medium">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{audit.events.map((event) => (
|
||||
<tr
|
||||
key={event.id}
|
||||
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
|
||||
>
|
||||
<td className="p-2 font-medium text-slate-900 dark:text-slate-100">
|
||||
{event.name}
|
||||
</td>
|
||||
<td className="p-2 font-mono text-xs text-slate-600 dark:text-slate-400">
|
||||
{event.account_id ?? '—'}
|
||||
</td>
|
||||
<td className="p-2 font-mono text-xs text-slate-600 dark:text-slate-400">
|
||||
{event.employer_id ?? '—'}
|
||||
</td>
|
||||
<td className="p-2 text-slate-600 dark:text-slate-400">
|
||||
{event.operator ?? '—'}
|
||||
</td>
|
||||
<td className="p-2 text-slate-600 dark:text-slate-400">
|
||||
{event.created_at
|
||||
? new Date(event.created_at).toLocaleString()
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{audit.events.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-3 text-center text-slate-500 dark:text-slate-400">
|
||||
No audit events yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimStatusBadge({ status }: { status: EmployerClaim['status'] }) {
|
||||
if (status === 'approved') return <Badge variant="success">Approved</Badge>;
|
||||
if (status === 'pending') return <Badge variant="warning">Pending</Badge>;
|
||||
return <Badge variant="default">Rejected</Badge>;
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { colors } from '@nextcraft/ui';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock chart data */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const postingsData = [
|
||||
{ month: 'Apr', postings: 4, applications: 38 },
|
||||
{ month: 'May', postings: 6, applications: 62 },
|
||||
{ month: 'Jun', postings: 5, applications: 51 },
|
||||
{ month: 'Jul', postings: 8, applications: 84 },
|
||||
{ month: 'Aug', postings: 9, applications: 112 },
|
||||
{ month: 'Sep', postings: 7, applications: 142 },
|
||||
];
|
||||
|
||||
const sourceData = [
|
||||
{ name: 'Direct', value: 40, color: colors.primary[500] },
|
||||
{ name: 'AI Match', value: 30, color: colors.accent[500] },
|
||||
{ name: 'Referral', value: 20, color: colors.violet[400] },
|
||||
{ name: 'Job Board', value: 10, color: colors.amber[400] },
|
||||
];
|
||||
|
||||
const placementData = [
|
||||
{ month: 'Apr', placements: 2 },
|
||||
{ month: 'May', placements: 5 },
|
||||
{ month: 'Jun', placements: 9 },
|
||||
{ month: 'Jul', placements: 14 },
|
||||
{ month: 'Aug', placements: 21 },
|
||||
{ month: 'Sep', placements: 28 },
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Shared styling */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const tooltipStyle = {
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${colors.neutral[200]}`,
|
||||
fontSize: 12,
|
||||
background: '#ffffff',
|
||||
} as const;
|
||||
|
||||
const axisTick = { fontSize: 12, fill: colors.neutral[500] } as const;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function AnalyticsCharts() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Bar chart — Postings vs Applications */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
Postings vs Applications
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Last 6 months</p>
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={postingsData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={colors.neutral[200]} className="dark:opacity-20" />
|
||||
<XAxis dataKey="month" tick={axisTick} stroke={colors.neutral[300]} />
|
||||
<YAxis tick={axisTick} stroke={colors.neutral[300]} />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
cursor={{ fill: 'rgba(99,102,241,0.06)' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="postings" fill={colors.primary[500]} radius={[4, 4, 0, 0]} name="Postings" />
|
||||
<Bar dataKey="applications" fill={colors.accent[500]} radius={[4, 4, 0, 0]} name="Applications" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Donut chart — Applicant Sources */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
Applicant Sources
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Where candidates come from</p>
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={sourceData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
>
|
||||
{sourceData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={(value) => `${value}%`} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line chart — Placement Trends */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
Placement Trends
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Cumulative placements</p>
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={placementData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="placementStroke" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor={colors.primary[500]} />
|
||||
<stop offset="100%" stopColor={colors.accent[500]} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={colors.neutral[200]} />
|
||||
<XAxis dataKey="month" tick={axisTick} stroke={colors.neutral[300]} />
|
||||
<YAxis tick={axisTick} stroke={colors.neutral[300]} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="placements"
|
||||
stroke="url(#placementStroke)"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 4, fill: colors.primary[600] }}
|
||||
activeDot={{ r: 6 }}
|
||||
name="Cumulative placements"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
|
||||
import type {
|
||||
ApplicationStatus,
|
||||
EmployerApplicationSummary,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
listOwnEmployerApplications,
|
||||
employerMarkInReview,
|
||||
employerMarkInterview,
|
||||
employerMarkOffer,
|
||||
employerRejectApplication,
|
||||
employerHireApplication,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* The hiring-pipeline client (v0.11, REQ-11-002/003, D-109 surface
|
||||
* 1): the session employer's OWN applications (every status, per-
|
||||
* status facets) with per-target transition CTAs — the matrix stays
|
||||
* OUT of the request bodies (D-105: each call names its target).
|
||||
* The hire form carries the employer-attested {role_title,
|
||||
* start_date} (D-103/G-324). Withdraw is NEVER offered here (D-099:
|
||||
* employers reject; only the learner or an operator can withdraw).
|
||||
*
|
||||
* Honest states (A-406): loading/error/empty/claim, per-action
|
||||
* pending + error; a 403 cross-entity or 409 matrix arm renders from
|
||||
* EngineError.status — NO new error classes (D-110). learner_id IS
|
||||
* visible (the G-319 supersession, own applicants only).
|
||||
*/
|
||||
|
||||
const STATUS_LABELS: Record<ApplicationStatus, string> = {
|
||||
submitted: 'Submitted',
|
||||
in_review: 'In Review',
|
||||
interview: 'Interview',
|
||||
offer: 'Offer',
|
||||
hired: 'Hired',
|
||||
rejected: 'Rejected',
|
||||
withdrawn: 'Withdrawn',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<ApplicationStatus, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
submitted: 'default',
|
||||
in_review: 'info',
|
||||
interview: 'info',
|
||||
offer: 'warning',
|
||||
hired: 'success',
|
||||
rejected: 'error',
|
||||
withdrawn: 'default',
|
||||
};
|
||||
|
||||
/** The next legal targets per the D-099 matrix (forward-only with
|
||||
* skip-forward — the CTA set matches the store's transition set). */
|
||||
const NEXT_TARGETS: Record<string, Array<{ key: string; label: string }>> = {
|
||||
submitted: [
|
||||
{ key: 'in-review', label: 'In Review' },
|
||||
{ key: 'interview', label: 'Interview' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
in_review: [
|
||||
{ key: 'interview', label: 'Interview' },
|
||||
{ key: 'offer', label: 'Offer' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
interview: [
|
||||
{ key: 'offer', label: 'Offer' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
offer: [
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
};
|
||||
|
||||
export function EmployerApplicationsClient() {
|
||||
const [applications, setApplications] = useState<EmployerApplicationSummary[] | null>(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const [claimPrompt, setClaimPrompt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// per-application action state: the honest pending/error pair
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
// the hire form: which application id is being hired + its fields
|
||||
const [hireFor, setHireFor] = useState<string | null>(null);
|
||||
const [roleTitle, setRoleTitle] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const body = await listOwnEmployerApplications(controller.signal);
|
||||
if (!controller.signal.aborted) setApplications(body.applications);
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true); // honest logged-out state — never a retry loop
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true); // the honest claim-CTA state
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load applications.');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
async function runTransition(
|
||||
application: EmployerApplicationSummary,
|
||||
target: 'in-review' | 'interview' | 'offer' | 'reject',
|
||||
): Promise<void> {
|
||||
setActionError(null);
|
||||
setPending(application.id);
|
||||
try {
|
||||
const dispatch = {
|
||||
'in-review': employerMarkInReview,
|
||||
interview: employerMarkInterview,
|
||||
offer: employerMarkOffer,
|
||||
reject: employerRejectApplication,
|
||||
}[target];
|
||||
const body = await dispatch(application.id);
|
||||
if (body.application_id === application.id) {
|
||||
setApplications((prev) =>
|
||||
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true);
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true);
|
||||
} else if (err instanceof EngineError) {
|
||||
// the 403 cross-entity + 409 matrix arms — the honest status
|
||||
setActionError(`${application.id}: ${err.message}`);
|
||||
} else {
|
||||
setActionError(err instanceof Error ? err.message : 'The transition failed.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitHire(application: EmployerApplicationSummary): Promise<void> {
|
||||
setActionError(null);
|
||||
setPending(application.id);
|
||||
try {
|
||||
const body = await employerHireApplication(application.id, {
|
||||
role_title: roleTitle,
|
||||
start_date: startDate,
|
||||
});
|
||||
setApplications((prev) =>
|
||||
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
|
||||
);
|
||||
setHireFor(null);
|
||||
setRoleTitle('');
|
||||
setStartDate('');
|
||||
} catch (err) {
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true);
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true);
|
||||
} else {
|
||||
// 422 (missing/empty fields, G-324) + 409 (matrix) render here
|
||||
setActionError(err instanceof Error ? err.message : 'The hire failed.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
|
||||
Loading your hiring pipeline…
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loggedOut) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
You are signed out — your session is missing or expired.
|
||||
</p>
|
||||
<a href="/employer/login">
|
||||
<Button variant="primary" size="md">Sign in</Button>
|
||||
</a>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (claimPrompt) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
No employer entity is claimed on this account yet.
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
|
||||
Claim your company's entity first — the hiring pipeline lists
|
||||
applicants on your claimed entity's postings.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">{error}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
The hiring pipeline is unavailable — please try again shortly.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!applications || applications.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
No applications yet — applicants on your published postings appear
|
||||
here as they apply.
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{actionError && (
|
||||
<Card>
|
||||
<CardBody className="py-3 text-sm text-red-700 dark:text-red-300">{actionError}</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{applications.map((application) => {
|
||||
const targets = NEXT_TARGETS[application.status] ?? [];
|
||||
return (
|
||||
<Card key={application.id}>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_BADGE[application.status]}>
|
||||
{STATUS_LABELS[application.status]}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{application.learner_id}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
applied {application.created_at ? application.created_at.slice(0, 10) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{application.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hireFor === application.id ? (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||
Record the placement you attest (visible to the learner and
|
||||
the audit export):
|
||||
</p>
|
||||
<Input
|
||||
label="Role title"
|
||||
value={roleTitle}
|
||||
onChange={(e) => setRoleTitle(e.target.value)}
|
||||
placeholder="AI Orchestration Engineer"
|
||||
maxLength={200}
|
||||
/>
|
||||
<Input
|
||||
label="Employment start date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={pending === application.id || roleTitle === '' || startDate === ''}
|
||||
onClick={() => {
|
||||
void submitHire(application);
|
||||
}}
|
||||
>
|
||||
{pending === application.id ? 'Recording…' : 'Confirm hire'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setHireFor(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{targets.map((target) => (
|
||||
<Button
|
||||
key={target.key}
|
||||
variant={target.key === 'reject' ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={pending === application.id}
|
||||
onClick={() => {
|
||||
if (target.key === 'hire') {
|
||||
setHireFor(application.id);
|
||||
setActionError(null);
|
||||
} else {
|
||||
void runTransition(
|
||||
application,
|
||||
target.key as 'in-review' | 'interview' | 'offer' | 'reject',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pending === application.id ? '…' : target.label}
|
||||
</Button>
|
||||
))}
|
||||
{/* D-099: withdraw is NEVER offered to employers */}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import type {
|
||||
CatalogResponse,
|
||||
EmployerEntitlementResponse,
|
||||
EmployerCheckoutResponse,
|
||||
Package,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
getOwnEmployerEntitlement,
|
||||
listBillingCatalog,
|
||||
startOwnEmployerCheckout,
|
||||
completeOwnEmployerMockCheckout,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Employer billing surface (REQ-10-005, Task 5-2-01 — the B2B loop's
|
||||
* buy arm): the entitlement view (current package, posts used/limit
|
||||
* bar, expiry) + the self-serve buy flow.
|
||||
*
|
||||
* Honest states throughout: 401 → the logged-out state with a login
|
||||
* CTA (SessionRequiredError — never a retry loop); 403 claim-CTA →
|
||||
* the claim prompt (the buyer must be a claimed entity, D-076
|
||||
* superseded); no active package → the honest empty state (a null
|
||||
* purchase NEVER asserts expiry provenance — the wire shape cannot
|
||||
* distinguish never-purchased from expired; expired windows are
|
||||
* skipped lazily server-side, G-306).
|
||||
*
|
||||
* The buy flow: the catalog from the PUBLIC /v1/billing/catalog (the
|
||||
* v0.9 listBillingCatalog REUSED — one source of truth), the
|
||||
* own-checkout trigger POSTs /v1/employers/billing/checkout, and the
|
||||
* in-app mock-complete confirmation is HONESTLY LABELED as the pilot
|
||||
* mock path (D-082): under provider=mock no real payment exists — the
|
||||
* confirmation completes one's OWN checkout session and the grant
|
||||
* lands through the REAL webhook path. The admin operator path
|
||||
* remains the fallback (oversight, D-094). Real limits render
|
||||
* exclusively from the entitlement view (G-320b — the 402 payload's
|
||||
* zeros are never rendered here or anywhere).
|
||||
*/
|
||||
|
||||
const ENTERPRISE_CENTS = 0;
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
if (cents === ENTERPRISE_CENTS) return 'Custom';
|
||||
return `$${(cents / 100).toFixed(0)}`;
|
||||
}
|
||||
|
||||
export function EmployerBillingClient() {
|
||||
const [entitlement, setEntitlement] = useState<EmployerEntitlementResponse | null>(null);
|
||||
const [catalog, setCatalog] = useState<CatalogResponse | null>(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const [claimPrompt, setClaimPrompt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// The open checkout awaiting the pilot mock-complete confirmation.
|
||||
const [checkout, setCheckout] = useState<EmployerCheckoutResponse | null>(null);
|
||||
const [checkoutItemName, setCheckoutItemName] = useState<string>('');
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [, reload] = useState(0);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const refetch = useCallback((signal?: AbortSignal) => {
|
||||
return getOwnEmployerEntitlement(signal).then(setEntitlement);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
(async () => {
|
||||
try {
|
||||
const ent = await getOwnEmployerEntitlement(controller.signal);
|
||||
setEntitlement(ent);
|
||||
// The catalog is PUBLIC (no session needed) — fetched beside
|
||||
// the entitlement so the buy flow is ready the moment the
|
||||
// session is valid.
|
||||
setCatalog(await listBillingCatalog(controller.signal));
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true); // honest logged-out state — never a retry loop
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true); // honest claim-CTA state — no buyer without a claim
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load billing.');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
// `reload` re-runs the read effect after a confirmed purchase.
|
||||
}, [reload]);
|
||||
|
||||
/** Start the own-checkout for one catalog item. */
|
||||
const buy = async (item: Package) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setBusyId(item.id);
|
||||
setActionError(null);
|
||||
setConfirmed(false);
|
||||
try {
|
||||
const created = await startOwnEmployerCheckout({ item_id: item.id }, controller.signal);
|
||||
setCheckout(created);
|
||||
setCheckoutItemName(item.name);
|
||||
if (!created.url) {
|
||||
// The mock provider's honest empty-url path — the in-app
|
||||
// confirmation below is the pilot completion arm.
|
||||
setActionError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true);
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true);
|
||||
} else if (err instanceof EngineError && err.status === 404) {
|
||||
setActionError('That package is no longer in the catalog — reload the page.');
|
||||
} else {
|
||||
setActionError(err instanceof Error ? err.message : 'Checkout failed — try again.');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
/** The in-app pilot confirmation: complete one's OWN checkout
|
||||
* session through the honest mock path. */
|
||||
const confirmMockComplete = async () => {
|
||||
if (!checkout) return;
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setConfirming(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await completeOwnEmployerMockCheckout(
|
||||
{ provider_session_id: checkout.provider_session_id },
|
||||
controller.signal,
|
||||
);
|
||||
if (result.completed) {
|
||||
setConfirmed(true);
|
||||
setCheckout(null);
|
||||
await refetch(controller.signal);
|
||||
reload((n) => n + 1); // the entitlement view reflects the grant
|
||||
} else {
|
||||
setActionError(
|
||||
`The checkout did not complete (payment status: ${result.payment_status}) — try again.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true);
|
||||
} else if (err instanceof EngineError && err.status === 404) {
|
||||
setActionError(
|
||||
'Mock completion is unavailable on this deployment (it exists only under provider=mock).',
|
||||
);
|
||||
} else if (err instanceof EngineError && err.status === 403) {
|
||||
setActionError('This checkout session belongs to another employer — it cannot be completed here.');
|
||||
} else {
|
||||
setActionError(err instanceof Error ? err.message : 'The completion failed — try again.');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelCheckout = () => {
|
||||
setCheckout(null);
|
||||
setCheckoutItemName('');
|
||||
setActionError(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
|
||||
Loading your billing…
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loggedOut) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
You are signed out — your session is missing or expired.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/employer/login">
|
||||
<Button variant="primary" size="md">Sign in</Button>
|
||||
</Link>
|
||||
<Link href="/employer/register">
|
||||
<Button variant="outline" size="md">Register</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (claimPrompt) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
No employer entity is claimed on this account yet.
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
|
||||
Checkout buys on behalf of your claimed employer entity — claim your
|
||||
company's entity first. Claims are reviewed by an operator before
|
||||
they activate.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">{error}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
The billing service is unavailable — please retry shortly.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const purchase = entitlement?.purchase ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Entitlement view — the ONLY place real limits render (G-320b). */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Your posting package
|
||||
</h2>
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
{purchase ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{purchase.package_name}
|
||||
</span>
|
||||
<Badge variant="success">Active</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-slate-700 dark:text-slate-300">
|
||||
<span>Posts used</span>
|
||||
<span className="font-semibold">
|
||||
{purchase.posts_limit === null
|
||||
? `${purchase.posts_used} (unlimited)`
|
||||
: `${purchase.posts_used} / ${purchase.posts_limit}`}
|
||||
</span>
|
||||
</div>
|
||||
{purchase.posts_limit !== null && (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
|
||||
<div
|
||||
className="h-full bg-primary-500"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(purchase.posts_used / purchase.posts_limit) * 100,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{purchase.expires_at && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Expires {new Date(purchase.expires_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
No active posting package — buy one below to post jobs.
|
||||
</p>
|
||||
)}
|
||||
{entitlement?.subscription ? (
|
||||
<div className="flex items-center justify-between border-t border-slate-100 pt-2 text-sm dark:border-slate-800">
|
||||
<span className="font-medium">{entitlement.subscription.plan_name}</span>
|
||||
<Badge variant={entitlement.subscription.status === 'active' ? 'success' : 'warning'}>
|
||||
{entitlement.subscription.status}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
No talent-access subscription.
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* The pilot mock-complete confirmation — honestly labeled (D-082). */}
|
||||
{checkout && (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="info">Checkout open</Badge>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{checkoutItemName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Checkout session <code className="text-xs">{checkout.provider_session_id}</code> is
|
||||
open (mode: {checkout.mode}).
|
||||
</p>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<span className="font-semibold">Pilot mock path:</span> this
|
||||
deployment uses the mock payment provider — no real charge
|
||||
happens. Confirming below completes your own checkout session and
|
||||
grants the package through the real webhook path immediately.
|
||||
</p>
|
||||
{actionError && <p className="text-sm text-red-600 dark:text-red-400">{actionError}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => void confirmMockComplete()}
|
||||
loading={confirming}
|
||||
disabled={confirming}
|
||||
>
|
||||
Confirm purchase (mock — no charge)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={cancelCheckout}
|
||||
disabled={confirming}
|
||||
>
|
||||
Cancel checkout
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Grant confirmation — the honest post-webhook state. */}
|
||||
{confirmed && (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="success">Purchase complete</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Your purchase completed through the mock payment path and the
|
||||
webhook granted your package — the entitlement view above now
|
||||
reflects it.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Buy flow — the PUBLIC v0.9 catalog (listBillingCatalog REUSED). */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Posting packages
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Prices from the live catalog — self-serve checkout buys on your
|
||||
claimed entity's behalf.
|
||||
</p>
|
||||
</div>
|
||||
{actionError && !checkout && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{actionError}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{(catalog?.packages ?? []).map((pkg) => (
|
||||
<Card key={pkg.id} className="flex h-full flex-col">
|
||||
<CardBody className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
{pkg.name}
|
||||
</h3>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatPrice(pkg.price_cents)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{pkg.price_cents === ENTERPRISE_CENTS
|
||||
? 'contact sales'
|
||||
: `${pkg.posts_limit} posts · ${pkg.duration_days} days`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
{pkg.features.map((f) => (
|
||||
<li key={f}>{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-auto pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={pkg.price_cents === ENTERPRISE_CENTS ? 'outline' : 'primary'}
|
||||
size="md"
|
||||
className="w-full"
|
||||
onClick={() => void buy(pkg)}
|
||||
loading={busyId === pkg.id}
|
||||
disabled={busyId !== null && busyId !== pkg.id}
|
||||
>
|
||||
{pkg.price_cents === ENTERPRISE_CENTS ? 'Contact Sales' : 'Buy package'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{catalog && catalog.packages.length === 0 && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
No posting packages are configured in the catalog yet.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Pilot payments run through the mock provider (honestly labeled above) —
|
||||
the grant lands through the real webhook path. Operator-assisted
|
||||
checkout remains available as a fallback: contact the Nextcraft team
|
||||
and an operator can complete the purchase on your behalf.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Briefcase, Users, TrendingUp, Target, ArrowRight } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardBody, Avatar } from '@nextcraft/ui';
|
||||
import type {
|
||||
EmployerProfile,
|
||||
EmployerMe,
|
||||
OwnPostingsResponse,
|
||||
EmployerPlacementsResponse,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
getEmployerMe,
|
||||
listOwnEmployerPostings,
|
||||
listOwnEmployerPlacements,
|
||||
getEmployer,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Employer dashboard overview (REQ-10-003, D-092 — the v0.1 mockup
|
||||
* converts to real data). Session-gated client shell: no/expired
|
||||
* session → the honest logged-out state with a login CTA
|
||||
* (SessionRequiredError — NEVER a retry loop); unclaimed account →
|
||||
* the honest claim-CTA state; claim pending → the honest
|
||||
* "claim submitted, awaiting review" state (never presented as
|
||||
* verified before the operator decides, A-304/D-089).
|
||||
*
|
||||
* REAL data: active-postings count + per-posting applicant counts
|
||||
* from GET /v1/employers/postings; the claimed entity's profile via
|
||||
* the public marketplace employer fetch. Talent-match/placement
|
||||
* metrics STAY mock-labeled (D-092 — no backend computes them; the
|
||||
* honest label rides the card). The sessionStorage session is
|
||||
* tab-scoped — the token dies with the tab (documented tradeoff).
|
||||
*/
|
||||
export function EmployerOverviewClient() {
|
||||
const [me, setMe] = useState<EmployerMe | null>(null);
|
||||
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
|
||||
const [profile, setProfile] = useState<EmployerProfile | null>(null);
|
||||
const [placements, setPlacements] = useState<EmployerPlacementsResponse | null>(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const [claimPrompt, setClaimPrompt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const meBody = await getEmployerMe(controller.signal);
|
||||
setMe(meBody);
|
||||
if (meBody.claimed_employer_id) {
|
||||
const [own, prof, placed] = await Promise.all([
|
||||
listOwnEmployerPostings(controller.signal),
|
||||
getEmployer(meBody.claimed_employer_id, controller.signal).catch(() => null),
|
||||
// v0.11 P3 (D-109 surface 2): REAL placements — the mock
|
||||
// metric block is REPLACED (D-092: never leave a convertible
|
||||
// surface on mock). A placements fetch failure is honest —
|
||||
// the rest of the dashboard still renders.
|
||||
listOwnEmployerPlacements(controller.signal).catch(() => null),
|
||||
]);
|
||||
setPostings(own);
|
||||
setProfile(prof);
|
||||
setPlacements(placed);
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true); // honest logged-out state — never a retry loop
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true); // honest claim-CTA state
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load the dashboard.');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
|
||||
Loading your employer dashboard…
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loggedOut) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
You are signed out — your session is missing or expired.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/employer/login">
|
||||
<Button variant="primary" size="md">Sign in</Button>
|
||||
</Link>
|
||||
<Link href="/employer/register">
|
||||
<Button variant="outline" size="md">Register</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Sessions are tab-scoped: signing in keeps the token in this tab's
|
||||
sessionStorage only.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (claimPrompt) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
No employer entity is claimed on this account yet.
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
|
||||
Claim your company's entity from the marketplace so your postings,
|
||||
applicant counts, and profile appear here. Claims are reviewed by an
|
||||
operator before they activate.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">{error}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
The dashboard service is unavailable — please retry shortly.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
// Claim-pending: a claim exists and is not approved — the honest
|
||||
// "submitted, awaiting review" state (A-304: never shown as verified).
|
||||
const claimPending =
|
||||
me.claim !== null && me.claim.status === 'pending' && me.claimed_employer_id === null;
|
||||
|
||||
if (claimPending) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Claim submitted, awaiting review.
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
|
||||
Your claim on this employer entity is pending an operator decision.
|
||||
Your postings and applicant counts appear here once the claim is
|
||||
approved.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const facets = postings?.facets;
|
||||
const activePostings = facets?.published ?? 0;
|
||||
const totalApplicants =
|
||||
postings?.postings.reduce((sum, p) => sum + p.applicant_count, 0) ?? 0;
|
||||
// v0.11 P3 (D-107): the active-placement count from the rows at
|
||||
// query time — the honest 0 when none exist.
|
||||
const activePlacements =
|
||||
placements?.placements.filter((p) => p.status === 'active').length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Employer Dashboard</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Employer Dashboard
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
{profile ? (
|
||||
<>
|
||||
Welcome back,{' '}
|
||||
<span className="font-semibold text-slate-800 dark:text-slate-200">
|
||||
{profile.name}
|
||||
</span>
|
||||
. Here is your posting pipeline at a glance.
|
||||
</>
|
||||
) : (
|
||||
<>Your posting pipeline at a glance.</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Signed in as {me.account.email} — email unverified.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Metric cards — postings/applicants/placements REAL (GET
|
||||
/v1/employers/postings + /placements, v0.11 P3);
|
||||
talent-match stays mock-labeled (D-092 — no backend computes
|
||||
it). */}
|
||||
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<MetricCard label="Active Postings" value={activePostings} icon={Briefcase} tone="primary" />
|
||||
<MetricCard label="Total Applicants" value={totalApplicants} icon={Users} tone="accent" />
|
||||
<MetricCard
|
||||
label="Placements Recorded"
|
||||
value={activePlacements}
|
||||
icon={TrendingUp}
|
||||
tone="amber"
|
||||
/>
|
||||
<MockMetricCard label="Talent Matches" value="—" icon={Target} tone="violet" />
|
||||
</section>
|
||||
|
||||
{/* Middle row — per-posting applicant table + claimed-entity profile */}
|
||||
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Postings & Applicants
|
||||
</h2>
|
||||
<Link
|
||||
href="/employer/postings"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
Manage postings <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
{postings && postings.postings.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="p-3 font-medium">Posting</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Applicants</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{postings.postings.map((p) => (
|
||||
<tr key={p.id} className="border-b border-slate-100 last:border-0 dark:border-slate-800/60">
|
||||
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">{p.title}</td>
|
||||
<td className="p-3"><PostingStatusBadge status={p.status} /></td>
|
||||
<td className="p-3 text-right font-semibold text-slate-700 dark:text-slate-200">
|
||||
{p.applicant_count}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<CardBody className="py-8 text-center text-slate-600 dark:text-slate-400">
|
||||
No postings yet — your created postings and their applicant
|
||||
counts appear here.
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
{facets && (
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{facets.draft} draft · {facets.published} published · {facets.closed} closed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claimed-entity profile — the public marketplace fetch
|
||||
(enrichment retained, R-61). */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Entity Profile
|
||||
</h2>
|
||||
</div>
|
||||
<Card>
|
||||
{profile ? (
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar name={profile.name} src={profile.logo} size="md" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
{profile.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{profile.industry || 'Industry unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="line-clamp-3 text-xs text-slate-600 dark:text-slate-400">
|
||||
{profile.description}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{profile.location || 'Location unknown'} · {profile.size || 'Size unknown'}
|
||||
</p>
|
||||
</CardBody>
|
||||
) : (
|
||||
<CardBody className="py-8 text-center text-slate-600 dark:text-slate-400">
|
||||
Profile unavailable right now.
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* v0.11 P3 (D-109 surface 2, REQ-11-003): REAL placements — the
|
||||
hired applications with role + start date. The mock
|
||||
placement block is REPLACED (D-092); talent-match analytics
|
||||
stay mock-labeled (no backend computes them). */}
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Placements
|
||||
</h2>
|
||||
<Link
|
||||
href="/employer/applications"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
Manage hiring pipeline <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
{placements && placements.placements.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="p-3 font-medium">Role</th>
|
||||
<th className="p-3 font-medium">Learner</th>
|
||||
<th className="p-3 font-medium">Start Date</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{placements.placements.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
|
||||
>
|
||||
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">
|
||||
{p.role_title}
|
||||
</td>
|
||||
<td className="p-3 text-slate-700 dark:text-slate-300">{p.learner_id}</td>
|
||||
<td className="p-3 text-slate-700 dark:text-slate-300">{p.start_date}</td>
|
||||
<td className="p-3">
|
||||
{p.status === 'active' ? (
|
||||
<Badge variant="success">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="default">Revoked</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<CardBody className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
No placements recorded yet — hires you record in the hiring
|
||||
pipeline appear here.
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Mock-labeled analytics (D-092: no backend computes these). */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Analytics <span className="text-sm font-normal text-slate-500 dark:text-slate-400">(sample data)</span>
|
||||
</h2>
|
||||
<MockAnalyticsNotice />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -- metric cards ------------------------------------------------ */
|
||||
|
||||
const TONE_CLASSES: Record<string, string> = {
|
||||
primary: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
|
||||
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
};
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof Briefcase;
|
||||
tone: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[tone]}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">{value}</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{label}</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** D-092: mock-labeled metric — the honest label rides the card. */
|
||||
function MockMetricCard(props: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: typeof Briefcase;
|
||||
tone: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[props.tone]}`}>
|
||||
<props.icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">{props.value}</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{props.label} <span className="text-xs">(mock — no backend computes this)</span>
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** The real posting lifecycle badges (draft → published → closed). */
|
||||
function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'closed' }) {
|
||||
if (status === 'published') return <Badge variant="success">Published</Badge>;
|
||||
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
|
||||
return <Badge variant="default">Closed</Badge>;
|
||||
}
|
||||
|
||||
/** D-092 honest mock label for the analytics section — the charts ride
|
||||
* nowhere until a backend computes them; the copy says so. v0.11:
|
||||
* placement COUNTS are now real (the section above) — only the
|
||||
* talent-match analytics remain mock. */
|
||||
function MockAnalyticsNotice() {
|
||||
return (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Talent-match analytics are sample data at pilot — no backend computes them yet.
|
||||
Real posting, applicant, and placement counts appear above.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Input } from '@nextcraft/ui';
|
||||
import {
|
||||
loginEmployer,
|
||||
setEmployerSessionToken,
|
||||
SessionRequiredError,
|
||||
RateLimitedError,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Employer sign-in client (REQ-10-003, R-61 — the first login page in
|
||||
* any route group). Honest at every state: the route's single 401
|
||||
* body (no account enumeration, D-090) renders as invalid-credentials
|
||||
* copy; 429 renders the throttle wait; 422 renders static validation
|
||||
* copy that NEVER echoes the submitted password (the server-side
|
||||
* pii_fields scrub is pinned already — the client never echoes it
|
||||
* either). Success stores the tab-scoped session token and redirects
|
||||
* to the dashboard.
|
||||
*/
|
||||
export function LoginClient() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const session = await loginEmployer({ email, password }, controller.signal);
|
||||
setEmployerSessionToken(session.token);
|
||||
router.push('/employer');
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(authErrorMessage(err));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
|
||||
<Input
|
||||
label="Work email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<Button type="submit" variant="primary" size="md" loading={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
No employer account yet?{' '}
|
||||
<Link
|
||||
href="/employer/register"
|
||||
className="font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** The honest error copy — every branch is a static literal: the
|
||||
* submitted password is never interpolated into any message. */
|
||||
function authErrorMessage(err: unknown): string {
|
||||
if (err instanceof SessionRequiredError) {
|
||||
// The login route's single 401 (unknown email and wrong password
|
||||
// share one body — no account enumeration, D-090).
|
||||
return 'Invalid email or password.';
|
||||
}
|
||||
if (err instanceof RateLimitedError) {
|
||||
return 'Too many attempts — wait a minute and try again.';
|
||||
}
|
||||
if (err instanceof EngineError) {
|
||||
if (err.status === 422) {
|
||||
return 'That email or password is not valid yet — check both and try again.';
|
||||
}
|
||||
return err.message; // honest server detail (never contains the password)
|
||||
}
|
||||
return 'Sign-in failed — please try again.';
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
|
||||
import type {
|
||||
EmployerMe,
|
||||
OwnPostingsResponse,
|
||||
OwnPostingSummary,
|
||||
EmployerPostingCreate,
|
||||
EmployerPostingEdit,
|
||||
Seniority,
|
||||
} from '@nextcraft/types';
|
||||
import { formatSalaryRange, relativeTime, seniorityLabel } from '../../lib/format';
|
||||
import {
|
||||
getEmployerMe,
|
||||
listOwnEmployerPostings,
|
||||
createOwnEmployerPosting,
|
||||
editOwnEmployerPosting,
|
||||
publishOwnEmployerPosting,
|
||||
closeOwnEmployerPosting,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
UpgradeRequiredError,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Own-postings lifecycle view (REQ-10-003/004, D-092 — the jobs mock
|
||||
* is GONE): the claimed employer's OWN postings from GET
|
||||
* /v1/employers/postings — draft → published → closed all visible,
|
||||
* real per-posting applicant counts. P4 adds the WRITE actions
|
||||
* (create draft, edit own draft, publish, close) over the
|
||||
* engine-client employer section.
|
||||
*
|
||||
* Honest write states (Task 4-2-01): loading per-action, error
|
||||
* per-action; 402 → the upgrade CTA rendered from the payload's
|
||||
* reason + upgrade_cta ONLY (G-320b: the payload's zeroed usage
|
||||
* fields are byte-stable wire bytes and are NEVER rendered as
|
||||
* limits — real limits render exclusively from the entitlement
|
||||
* view, P5); 403 claim-CTA → the claim prompt; 409 → the honest
|
||||
* own-drafts-only copy; 404 honest. NO optimistic updates — the
|
||||
* list is REFETCHED after every successful action (the read route is
|
||||
* the truth source). Session-gated: logged-out → the login CTA
|
||||
* (never a retry loop); unclaimed → the claim-CTA state;
|
||||
* claim-pending → the honest awaiting-review state.
|
||||
*/
|
||||
export function OwnPostingsClient() {
|
||||
const [me, setMe] = useState<EmployerMe | null>(null);
|
||||
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const [claimPrompt, setClaimPrompt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [, reload] = useState(0);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string>('');
|
||||
|
||||
// Per-action honest states (Task 4-2-01): one busy key + one error
|
||||
// message per action — never a global spinner, never a silent
|
||||
// failure. The 402 upgrade state renders reason + CTA only (G-320b).
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<{ key: string; message: string } | null>(null);
|
||||
const [upgradeRequired, setUpgradeRequired] = useState<{
|
||||
reason: string;
|
||||
upgradeCta: string;
|
||||
} | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const refetch = useCallback((signal?: AbortSignal) => {
|
||||
return listOwnEmployerPostings(signal).then(setPostings);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
(async () => {
|
||||
try {
|
||||
const meBody = await getEmployerMe(controller.signal);
|
||||
setMe(meBody);
|
||||
const own = await listOwnEmployerPostings(controller.signal);
|
||||
setPostings(own);
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true); // honest logged-out state — never a retry loop
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true); // honest claim-CTA state
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load postings.');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
// `reload` is the refetch trigger: runAction bumps it AFTER a
|
||||
// successful write so the read route re-runs (no optimistic
|
||||
// updates — the list is the truth source).
|
||||
}, [reload]);
|
||||
|
||||
const selected: OwnPostingSummary | null =
|
||||
postings?.postings.find((p) => p.id === selectedId) ?? postings?.postings[0] ?? null;
|
||||
|
||||
/** The shared write-action runner: per-action busy/error states, the
|
||||
* 402/403/404/409 discrimination, and a REFETCH after every success
|
||||
* (no optimistic updates — the read route is the truth source). */
|
||||
const runAction = useCallback(
|
||||
async (key: string, action: (signal: AbortSignal) => Promise<unknown>) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setActionBusy(key);
|
||||
setActionError(null);
|
||||
setUpgradeRequired(null);
|
||||
try {
|
||||
await action(controller.signal);
|
||||
await refetch(controller.signal);
|
||||
reload((n) => n + 1); // re-run the read effect (truth source)
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return false;
|
||||
if (err instanceof UpgradeRequiredError) {
|
||||
// G-320b (binding): the 402 branch renders reason + the
|
||||
// upgrade CTA ONLY — the payload's zeroed usage fields are
|
||||
// byte-stable wire bytes and are NEVER rendered as limits;
|
||||
// real limits render exclusively from the entitlement view.
|
||||
setUpgradeRequired({ reason: err.reason, upgradeCta: err.upgradeCta });
|
||||
} else if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true); // honest logged-out state — never a retry loop
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true); // honest claim-prompt on 403
|
||||
} else if (err instanceof EngineError) {
|
||||
setActionError({ key, message: writeActionMessage(err) });
|
||||
} else {
|
||||
setActionError({ key, message: 'The action failed — please try again.' });
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setActionBusy(null);
|
||||
}
|
||||
},
|
||||
[refetch],
|
||||
);
|
||||
|
||||
const publish = (id: string) =>
|
||||
runAction(`publish:${id}`, (signal) => publishOwnEmployerPosting(id, signal));
|
||||
const close = (id: string) =>
|
||||
runAction(`close:${id}`, (signal) => closeOwnEmployerPosting(id, signal));
|
||||
|
||||
const createDraft = (body: EmployerPostingCreate) =>
|
||||
runAction('create', (signal) => createOwnEmployerPosting(body, signal)).then((ok) => {
|
||||
if (ok) setShowCreate(false);
|
||||
return ok;
|
||||
});
|
||||
|
||||
const editDraft = (id: string, body: EmployerPostingEdit) =>
|
||||
runAction(`edit:${id}`, (signal) => editOwnEmployerPosting(id, body, signal));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
|
||||
Loading your postings…
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loggedOut) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
You are signed out — your session is missing or expired.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/employer/login">
|
||||
<Button variant="primary" size="md">Sign in</Button>
|
||||
</Link>
|
||||
<Link href="/employer/register">
|
||||
<Button variant="outline" size="md">Register</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (claimPrompt) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
No employer entity is claimed on this account yet.
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
|
||||
Claim your company's entity to see and manage its postings here.
|
||||
Claims are reviewed by an operator before they activate.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">{error}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
The postings service is unavailable — please retry shortly.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const claimPending =
|
||||
me?.claim !== null && me?.claim !== undefined && me.claim.status === 'pending';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{claimPending && (
|
||||
<Card>
|
||||
<CardBody className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Claim submitted, awaiting review — your entity's postings appear
|
||||
here once an operator approves the claim.
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{upgradeRequired && <UpgradeNotice reason={upgradeRequired.reason} cta={upgradeRequired.upgradeCta} />}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
|
||||
{/* Left panel — the real posting list */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Postings
|
||||
</h2>
|
||||
{/* P4 (REQ-10-004): the write actions are live — create
|
||||
draft (form below), edit own draft, publish, close. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => { setShowCreate((v) => !v); setActionError(null); }}
|
||||
disabled={actionBusy !== null}
|
||||
>
|
||||
New draft
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{postings && postings.postings.length > 0 ? (
|
||||
postings.postings.map((p) => {
|
||||
const active = selected?.id === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
className={[
|
||||
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
|
||||
active
|
||||
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{p.title}
|
||||
</span>
|
||||
<PostingStatusBadge status={p.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>{p.posted_at ? relativeTime(p.posted_at) : 'not published'}</span>
|
||||
<span>·</span>
|
||||
<span>{p.applicant_count} applicants</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
No postings yet — created postings appear here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{postings && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{postings.facets.draft} draft · {postings.facets.published} published ·{' '}
|
||||
{postings.facets.closed} closed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel — detail + the P4 write actions */}
|
||||
<div>
|
||||
{showCreate ? (
|
||||
<CreateDraftForm
|
||||
busy={actionBusy === 'create'}
|
||||
error={actionError?.key === 'create' ? actionError.message : null}
|
||||
onSubmit={createDraft}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
) : selected ? (
|
||||
<PostingDetail
|
||||
key={selected.id}
|
||||
posting={selected}
|
||||
busyKey={actionBusy}
|
||||
error={actionError}
|
||||
onPublish={publish}
|
||||
onClose={close}
|
||||
onEdit={editDraft}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
Select a posting to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The G-320b 402 surface: reason + upgrade CTA ONLY. The payload's
|
||||
* zeroed usage fields are byte-stable wire bytes (the D-091 wire
|
||||
* contract) and are NEVER rendered as limits — real limits render
|
||||
* exclusively from the entitlement view (P5's route). The CTA routes
|
||||
* to the employer billing surface. */
|
||||
function UpgradeNotice({ reason, cta }: { reason: string; cta: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="warning">Upgrade required</Badge>
|
||||
</div>
|
||||
<p className="text-slate-700 dark:text-slate-300">{cta}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Reason: {reason === 'no_active_package'
|
||||
? 'your employer account has no active posting package.'
|
||||
: reason === 'posting_limit_reached'
|
||||
? 'your posting package has no posts left.'
|
||||
: reason}
|
||||
</p>
|
||||
<div>
|
||||
<Link href="/employer/billing">
|
||||
<Button variant="primary" size="md">View packages</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Self-serve checkout lives on the billing surface — buy a package
|
||||
yourself, or contact your operator to purchase one on your behalf.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** The honest write-action error copy — every branch a literal, never
|
||||
* a fabricated detail (409 own-drafts-only, 404, 422, otherwise the
|
||||
* server's own honest detail). */
|
||||
function writeActionMessage(err: EngineError): string {
|
||||
if (err.status === 409) {
|
||||
return 'Only your own drafts can be edited or published — this posting is not an editable draft.';
|
||||
}
|
||||
if (err.status === 404) {
|
||||
return 'This posting no longer exists — it may have been removed. The list below is the current truth.';
|
||||
}
|
||||
if (err.status === 403) {
|
||||
return 'This posting belongs to another employer entity — you can only manage your own.';
|
||||
}
|
||||
if (err.status === 422) {
|
||||
return 'The posting details are not valid yet — check the title and salary fields.';
|
||||
}
|
||||
return err.message; // the honest server detail
|
||||
}
|
||||
|
||||
function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'closed' }) {
|
||||
if (status === 'published') return <Badge variant="success">Published</Badge>;
|
||||
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
|
||||
return <Badge variant="default">Closed</Badge>;
|
||||
}
|
||||
|
||||
interface ActionError {
|
||||
key: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function PostingDetail({
|
||||
posting,
|
||||
busyKey,
|
||||
error,
|
||||
onPublish,
|
||||
onClose,
|
||||
onEdit,
|
||||
}: {
|
||||
posting: OwnPostingSummary;
|
||||
busyKey: string | null;
|
||||
error: ActionError | null;
|
||||
onPublish: (id: string) => Promise<boolean>;
|
||||
onClose: (id: string) => Promise<boolean>;
|
||||
onEdit: (id: string, body: EmployerPostingEdit) => Promise<boolean>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [title, setTitle] = useState(posting.title);
|
||||
const [description, setDescription] = useState(posting.description);
|
||||
|
||||
const editError = error?.key === `edit:${posting.id}` ? error.message : null;
|
||||
const publishBusy = busyKey === `publish:${posting.id}`;
|
||||
const closeBusy = busyKey === `close:${posting.id}`;
|
||||
|
||||
const submitEdit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const ok = await onEdit(posting.id, { title, description });
|
||||
if (ok) setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{posting.title}
|
||||
</h2>
|
||||
<PostingStatusBadge status={posting.status} />
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<form onSubmit={submitEdit} className="flex flex-col gap-4" noValidate>
|
||||
<Input
|
||||
label="Title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="edit-description" className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{editError && <p className="text-sm text-red-600 dark:text-red-400">{editError}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" variant="primary" size="md" loading={busyKey === `edit:${posting.id}`}>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => setEditing(false)}
|
||||
disabled={busyKey !== null}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
<p className="whitespace-pre-line">{posting.description}</p>
|
||||
|
||||
{posting.required_competencies.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{posting.required_competencies.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
|
||||
>
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<DetailRow label="Seniority">
|
||||
{posting.seniority ? seniorityLabel(posting.seniority) : '—'}
|
||||
</DetailRow>
|
||||
<DetailRow label="Location">
|
||||
{posting.location}
|
||||
{posting.remote ? ' · Remote' : ''}
|
||||
</DetailRow>
|
||||
<DetailRow label="Salary">
|
||||
{posting.salary_min !== null && posting.salary_max !== null
|
||||
? formatSalaryRange(posting.salary_min, posting.salary_max)
|
||||
: '—'}
|
||||
</DetailRow>
|
||||
<DetailRow label="Published">
|
||||
{posting.posted_at ? relativeTime(posting.posted_at) : 'not published yet'}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
{editError && <p className="text-sm text-red-600 dark:text-red-400">{editError}</p>}
|
||||
|
||||
{/* P4 write actions (REQ-10-004): edit own draft, publish,
|
||||
close — real dispatch, per-action loading/error states,
|
||||
a refetch after every success (no optimistic updates). */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{posting.status === 'draft' && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
disabled={busyKey !== null}
|
||||
>
|
||||
Edit draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
type="button"
|
||||
onClick={() => void onPublish(posting.id)}
|
||||
loading={publishBusy}
|
||||
disabled={busyKey !== null && !publishBusy}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{posting.status === 'published' && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="md"
|
||||
type="button"
|
||||
onClick={() => void onClose(posting.id)}
|
||||
loading={closeBusy}
|
||||
disabled={busyKey !== null && !closeBusy}
|
||||
>
|
||||
Close posting
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Applicant COUNT (G-319 PII boundary: counts only — learner
|
||||
identifiers never ride any employer response). */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Applicants</h2>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{posting.applicant_count} total
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{posting.applicant_count === 0
|
||||
? 'No applicants yet — counts accrue as learners apply.'
|
||||
: `${posting.applicant_count} ${posting.applicant_count === 1 ? 'learner has' : 'learners have'} applied to this posting.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SENIORITY_OPTIONS: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
|
||||
|
||||
function CreateDraftForm({
|
||||
busy,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onSubmit: (body: EmployerPostingCreate) => Promise<boolean>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [remote, setRemote] = useState(false);
|
||||
const [seniority, setSeniority] = useState<Seniority | ''>('');
|
||||
const [salaryMin, setSalaryMin] = useState('');
|
||||
const [salaryMax, setSalaryMax] = useState('');
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const ok = await onSubmit({
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
remote,
|
||||
seniority: seniority || null,
|
||||
salary_min: salaryMin ? Number(salaryMin) : null,
|
||||
salary_max: salaryMax ? Number(salaryMax) : null,
|
||||
});
|
||||
if (ok) {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setLocation('');
|
||||
setRemote(false);
|
||||
setSeniority('');
|
||||
setSalaryMin('');
|
||||
setSalaryMax('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
New draft posting
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
|
||||
<Input
|
||||
label="Title"
|
||||
name="new-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="new-description" className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="new-description"
|
||||
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input label="Location" name="new-location" value={location}
|
||||
onChange={(e) => setLocation(e.target.value)} />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="new-seniority" className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Seniority
|
||||
</label>
|
||||
<select
|
||||
id="new-seniority"
|
||||
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
value={seniority}
|
||||
onChange={(e) => setSeniority(e.target.value as Seniority | '')}
|
||||
>
|
||||
<option value="">Not set</option>
|
||||
{SENIORITY_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{seniorityLabel(s)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input label="Salary minimum" name="new-salary-min" type="number" min={0}
|
||||
value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} />
|
||||
<Input label="Salary maximum" name="new-salary-max" type="number" min={0}
|
||||
value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remote}
|
||||
onChange={(e) => setRemote(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-primary-600 dark:border-slate-600"
|
||||
/>
|
||||
Remote eligible
|
||||
</label>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" variant="primary" size="md" loading={busy}>
|
||||
Create draft
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="md" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Drafts are private until you publish them — creating a draft uses one post
|
||||
from your package.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-medium text-slate-800 dark:text-slate-200">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Save } from 'lucide-react';
|
||||
import { Badge, Button } from '@nextcraft/ui';
|
||||
import type { Job, Seniority } from '@nextcraft/types';
|
||||
import { formatSalaryK, relativeTime, seniorityLabel } from '../../lib/format';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock posting list (status + applicant count derived from the Job) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
type PostingStatus = 'active' | 'draft' | 'expired';
|
||||
|
||||
interface PostingRow {
|
||||
job: Job;
|
||||
status: PostingStatus;
|
||||
applicants: number;
|
||||
}
|
||||
|
||||
interface ApplicantRow {
|
||||
name: string;
|
||||
appliedAt: string;
|
||||
stage: 'Screening' | 'Interview' | 'Offer';
|
||||
matchScore: number;
|
||||
}
|
||||
|
||||
/* Deterministically derive a posting status per job id. */
|
||||
function deriveStatus(job: Job, idx: number): PostingStatus {
|
||||
if (idx < 8) return 'active';
|
||||
if (idx < 11) return 'draft';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
function deriveApplicants(job: Job, idx: number): number {
|
||||
return 4 + ((job.matchScore + idx * 7) % 18);
|
||||
}
|
||||
|
||||
function deriveApplicantList(job: Job): ApplicantRow[] {
|
||||
const names = [
|
||||
'Maya Okonkwo',
|
||||
'Devon Park',
|
||||
'Priya Iyer',
|
||||
'Tomás Vega',
|
||||
'Liam Chen',
|
||||
'Yuki Tanaka',
|
||||
'Sofia Marchetti',
|
||||
];
|
||||
const stages: ApplicantRow['stage'][] = ['Screening', 'Interview', 'Offer'];
|
||||
const count = 5 + (job.matchScore % 2);
|
||||
const rows: ApplicantRow[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const stage = stages[i % stages.length];
|
||||
rows.push({
|
||||
name: names[(job.matchScore + i) % names.length],
|
||||
appliedAt: new Date(Date.UTC(2026, 7, 28 - i * 2)).toISOString(),
|
||||
stage,
|
||||
matchScore: 78 + ((job.matchScore + i * 3) % 18),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Status badge */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function StatusBadge({ status }: { status: PostingStatus }) {
|
||||
if (status === 'active') return <Badge variant="success">Active</Badge>;
|
||||
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
|
||||
return <Badge variant="default">Expired</Badge>;
|
||||
}
|
||||
|
||||
function StageBadge({ stage }: { stage: ApplicantRow['stage'] }) {
|
||||
if (stage === 'Offer') return <Badge variant="success">Offer</Badge>;
|
||||
if (stage === 'Interview') return <Badge variant="info">Interview</Badge>;
|
||||
return <Badge variant="warning">Screening</Badge>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface PostingManagerProps {
|
||||
jobs: Job[];
|
||||
}
|
||||
|
||||
export function PostingManager({ jobs }: PostingManagerProps) {
|
||||
const postings: PostingRow[] = jobs.map((job, idx) => ({
|
||||
job,
|
||||
status: deriveStatus(job, idx),
|
||||
applicants: deriveApplicants(job, idx),
|
||||
}));
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string>(postings[0]?.job.id ?? '');
|
||||
const selected = postings.find((p) => p.job.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
|
||||
{/* Left panel — posting list */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Postings
|
||||
</h2>
|
||||
<Button size="sm" variant="primary" icon={<Plus className="h-3.5 w-3.5" />} type="button">
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{postings.map((p) => {
|
||||
const active = p.job.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={p.job.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(p.job.id)}
|
||||
className={[
|
||||
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
|
||||
active
|
||||
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{p.job.title}
|
||||
</span>
|
||||
<StatusBadge status={p.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>{relativeTime(p.job.postedAt)}</span>
|
||||
<span>·</span>
|
||||
<span>{p.applicants} applicants</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — detail */}
|
||||
<div>
|
||||
{selected ? (
|
||||
<PostingDetail posting={selected} />
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
Select a posting to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostingDetail({ posting }: { posting: PostingRow }) {
|
||||
const { job, applicants } = posting;
|
||||
const applicantList = deriveApplicantList(job);
|
||||
const seniorityOptions: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
|
||||
|
||||
const inputClass =
|
||||
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Posting form */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Edit Posting
|
||||
</h2>
|
||||
<StatusBadge status={posting.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Title</label>
|
||||
<input className={inputClass} defaultValue={job.title} aria-label="Posting title" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Description</label>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
defaultValue={job.description}
|
||||
aria-label="Posting description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Required Competencies
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{job.requiredCompetencies.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
|
||||
>
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Seniority</label>
|
||||
<select className={inputClass} defaultValue={job.seniority} aria-label="Seniority">
|
||||
{seniorityOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{seniorityLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Location</label>
|
||||
<input className={inputClass} defaultValue={job.location} aria-label="Location" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Salary Min
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
defaultValue={formatSalaryK(job.salaryMin)}
|
||||
aria-label="Salary minimum"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Salary Max
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
defaultValue={formatSalaryK(job.salaryMax)}
|
||||
aria-label="Salary maximum"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center justify-between gap-2 text-sm text-slate-700 dark:text-slate-200">
|
||||
<span>Remote eligible</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={job.remote}
|
||||
className={[
|
||||
'relative h-6 w-11 rounded-full transition-colors',
|
||||
job.remote ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
job.remote ? 'translate-x-5' : 'translate-x-0.5',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button variant="primary" size="md" icon={<Save className="h-4 w-4" />} type="button">
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="destructive" size="md" icon={<Trash2 className="h-4 w-4" />} type="button">
|
||||
Delete Posting
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applicant list */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Applicants
|
||||
</h2>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{applicants} total · {applicantList.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table on desktop, cards on mobile */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="hidden w-full md:table">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="pb-2 pr-4 font-medium">Applicant</th>
|
||||
<th className="pb-2 pr-4 font-medium">Applied</th>
|
||||
<th className="pb-2 pr-4 font-medium">Stage</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Match</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{applicantList.map((a, i) => (
|
||||
<tr key={i} className="border-b border-slate-100 last:border-0 dark:border-slate-800/60">
|
||||
<td className="py-2.5 pr-4 text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{a.name}
|
||||
</td>
|
||||
<td className="py-2.5 pr-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
{relativeTime(a.appliedAt)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-4"><StageBadge stage={a.stage} /></td>
|
||||
<td className="py-2.5 pr-4 text-right text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{a.matchScore}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="flex flex-col gap-2 md:hidden">
|
||||
{applicantList.map((a, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between rounded-md border border-slate-200 p-3 dark:border-slate-800"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{a.name}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{relativeTime(a.appliedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StageBadge stage={a.stage} />
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
{a.matchScore}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Input } from '@nextcraft/ui';
|
||||
import {
|
||||
registerEmployer,
|
||||
setEmployerSessionToken,
|
||||
SessionRequiredError,
|
||||
RateLimitedError,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Employer registration client (REQ-10-003): creates the account
|
||||
* (email UNVERIFIED — the route pins email_verified=false, D-097)
|
||||
* and lands the user LOGGED-IN (the 201 carries a fresh session —
|
||||
* store the tab-scoped token, redirect to the dashboard). Honest
|
||||
* states: 409 duplicate email, 429 throttle wait, 422 validation
|
||||
* copy that NEVER echoes the submitted password.
|
||||
*/
|
||||
export function RegisterClient() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const session = await registerEmployer({ email, password }, controller.signal);
|
||||
setEmployerSessionToken(session.token);
|
||||
router.push('/employer');
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(registerErrorMessage(err));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
|
||||
<Input
|
||||
label="Work email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="At least 8 characters"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
<Button type="submit" variant="primary" size="md" loading={loading}>
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
</Button>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Already registered?{' '}
|
||||
<Link
|
||||
href="/employer/login"
|
||||
className="font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** The honest error copy — every branch a static literal: the
|
||||
* submitted password is never echoed (the 422 pii scrub is the
|
||||
* server's arm; this is the client's). */
|
||||
function registerErrorMessage(err: unknown): string {
|
||||
if (err instanceof SessionRequiredError) {
|
||||
return 'Sign-in required — your session is missing or expired.';
|
||||
}
|
||||
if (err instanceof RateLimitedError) {
|
||||
return 'Too many attempts — wait a minute and try again.';
|
||||
}
|
||||
if (err instanceof EngineError) {
|
||||
if (err.status === 409) {
|
||||
return 'An account with this email already exists — sign in instead.';
|
||||
}
|
||||
if (err.status === 422) {
|
||||
return 'Enter a valid email and a password of at least 8 characters.';
|
||||
}
|
||||
return err.message; // honest server detail (never contains the password)
|
||||
}
|
||||
return 'Registration failed — please try again.';
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
|
||||
import type {
|
||||
ApplicationConfirmation,
|
||||
ApplicationStatus,
|
||||
LearnerApplicationStatus,
|
||||
PostingDetail,
|
||||
EmployerProfile,
|
||||
SearchResult,
|
||||
@@ -24,8 +26,11 @@ import {
|
||||
VerifyRequiredError,
|
||||
applyPosting,
|
||||
getEmployer,
|
||||
getLearnerApplicationStatus,
|
||||
getMyApplication,
|
||||
getPosting,
|
||||
readSearchSelection,
|
||||
withdrawApplication,
|
||||
} from '../../lib/engine-client';
|
||||
import { segmentFromPath } from '../../lib/route-id';
|
||||
|
||||
@@ -54,6 +59,12 @@ export function JobDetailClient() {
|
||||
// P3 G-106: the board's search handoff, read ONCE on mount (empty
|
||||
// when the visit is not from a search result — no score, no panel).
|
||||
const [searchSelection, setSearchSelection] = useState<SearchResult | null>(null);
|
||||
// P5 (G-322): the learner's application on THIS posting — composed
|
||||
// via the mine-read + the status read on every visit; never a
|
||||
// client-persisted application id.
|
||||
const [appStatus, setAppStatus] = useState<LearnerApplicationStatus | null>(null);
|
||||
const [withdrawing, setWithdrawing] = useState(false);
|
||||
const [withdrawError, setWithdrawError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -76,6 +87,28 @@ export function JobDetailClient() {
|
||||
} catch {
|
||||
/* employer optional for the detail view */
|
||||
}
|
||||
// P5 (G-322): mine-read discovery — the panel composes from this
|
||||
// read, never a client-persisted application_id; 404 = no
|
||||
// application → the apply CTA renders (honest absence); gate
|
||||
// arms render their existing states.
|
||||
try {
|
||||
const mine = await getMyApplication(p.id, undefined, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
const status = await getLearnerApplicationStatus(
|
||||
mine.application_id,
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
if (!controller.signal.aborted) setAppStatus(status);
|
||||
} catch (mineErr) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (mineErr instanceof EngineError && mineErr.status === 404) return;
|
||||
if (mineErr instanceof VerifyRequiredError) {
|
||||
setVerifyCta(mineErr.verifyCta);
|
||||
}
|
||||
// any other failure leaves the apply CTA as the honest
|
||||
// surface — its own flow re-surfaces the gate states.
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof EngineError && err.status === 404) {
|
||||
@@ -97,6 +130,14 @@ export function JobDetailClient() {
|
||||
try {
|
||||
const c = await applyPosting(posting.id);
|
||||
setConfirmation(c);
|
||||
// P5: compose the status panel from the real read (the direct
|
||||
// response's application_id — never persisted).
|
||||
try {
|
||||
const status = await getLearnerApplicationStatus(c.application_id);
|
||||
setAppStatus(status);
|
||||
} catch {
|
||||
/* the confirmation box renders the honest state regardless */
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof VerifyRequiredError) {
|
||||
setVerifyCta(err.verifyCta);
|
||||
@@ -112,6 +153,35 @@ export function JobDetailClient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onWithdraw() {
|
||||
if (!appStatus) return;
|
||||
setWithdrawing(true);
|
||||
setWithdrawError(null);
|
||||
try {
|
||||
const c = await withdrawApplication(appStatus.application.id);
|
||||
const status = await getLearnerApplicationStatus(c.application_id);
|
||||
setAppStatus(status);
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 409) {
|
||||
// G-323: the matrix refused — the honest terminal copy; the
|
||||
// state refreshes instead of a stale withdraw button.
|
||||
try {
|
||||
const status = await getLearnerApplicationStatus(appStatus.application.id);
|
||||
setAppStatus(status);
|
||||
} catch {
|
||||
/* the current render stays — the 409 copy below is honest */
|
||||
}
|
||||
setWithdrawError('This application is terminal — it can no longer be withdrawn.');
|
||||
} else if (err instanceof EngineError && err.status === 403) {
|
||||
setWithdrawError('This application belongs to another learner.');
|
||||
} else {
|
||||
setWithdrawError(err instanceof Error ? err.message : 'Withdraw failed.');
|
||||
}
|
||||
} finally {
|
||||
setWithdrawing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
notFound();
|
||||
}
|
||||
@@ -242,8 +312,86 @@ export function JobDetailClient() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 md:items-end">
|
||||
{/* Apply flow (D-058): every state renders honestly. */}
|
||||
{confirmation ? (
|
||||
{/* Apply flow (D-058): every state renders honestly. P5
|
||||
* (G-322/D-104): the status panel renders when the
|
||||
* mine-read finds the application; the apply CTA only
|
||||
* when it does not. */}
|
||||
{appStatus ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full flex-col gap-3 rounded-lg border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Your application
|
||||
</span>
|
||||
<Badge
|
||||
variant={appStatus.application.status === 'hired' ? 'success' : 'default'}
|
||||
>
|
||||
{applicationStatusLabel(appStatus.application.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{appStatus.application.created_at
|
||||
? `Applied ${relativeTime(appStatus.application.created_at)} · `
|
||||
: ''}
|
||||
application {appStatus.application.id}
|
||||
</span>
|
||||
{appStatus.events.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{appStatus.events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{applicationEventLabel(event.event_type)}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">
|
||||
{relativeTime(event.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
No status updates yet.
|
||||
</span>
|
||||
)}
|
||||
{appStatus.placement && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
|
||||
>
|
||||
<span className="font-semibold">Placement recorded</span>
|
||||
<span className="text-xs">
|
||||
{appStatus.placement.role_title} at {employerName} · starts{' '}
|
||||
{appStatus.placement.start_date}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{WITHDRAWABLE_STATUSES.has(appStatus.application.status) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
loading={withdrawing}
|
||||
onClick={() => void onWithdraw()}
|
||||
>
|
||||
Withdraw application
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{terminalCopy(appStatus.application.status)}
|
||||
</p>
|
||||
)}
|
||||
{withdrawError && (
|
||||
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
|
||||
{withdrawError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : confirmation ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
|
||||
@@ -457,3 +605,78 @@ export function JobDetailClient() {
|
||||
function matchPercent(score: number): number {
|
||||
return Math.max(0, Math.round(score * 100));
|
||||
}
|
||||
|
||||
|
||||
/** The seven D-099 states, honestly labeled (no stage invented beyond
|
||||
* the matrix; unknown statuses render verbatim, never invented). */
|
||||
function applicationStatusLabel(status: ApplicationStatus): string {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return 'Submitted';
|
||||
case 'in_review':
|
||||
return 'In review';
|
||||
case 'interview':
|
||||
return 'Interview';
|
||||
case 'offer':
|
||||
return 'Offer';
|
||||
case 'hired':
|
||||
return 'Hired';
|
||||
case 'rejected':
|
||||
return 'Rejected';
|
||||
case 'withdrawn':
|
||||
return 'Withdrawn';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ledger event labels (the D-101 vocabulary) — unknown event names
|
||||
* render verbatim, never invented. */
|
||||
function applicationEventLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case 'application.in_review':
|
||||
return 'Moved to review';
|
||||
case 'application.interview':
|
||||
return 'Interview stage';
|
||||
case 'application.offer':
|
||||
return 'Offer received';
|
||||
case 'application.hired':
|
||||
return 'Hired';
|
||||
case 'application.rejected':
|
||||
return 'Rejected';
|
||||
case 'application.withdrawn':
|
||||
return 'Withdrawn';
|
||||
case 'placement.created':
|
||||
return 'Placement recorded';
|
||||
case 'placement.revoked':
|
||||
return 'Placement revoked (operator correction)';
|
||||
case 'placement.rehired':
|
||||
return 'Placement re-recorded (operator correction)';
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
/** The learner's legal withdraw sources (D-099: terminals have no
|
||||
* outgoing edges). */
|
||||
const WITHDRAWABLE_STATUSES: ReadonlySet<ApplicationStatus> = new Set([
|
||||
'submitted',
|
||||
'in_review',
|
||||
'interview',
|
||||
'offer',
|
||||
]);
|
||||
|
||||
/** Terminal copy (the honest end states — no resurrection, no
|
||||
* fabricated next step). */
|
||||
function terminalCopy(status: ApplicationStatus): string {
|
||||
switch (status) {
|
||||
case 'hired':
|
||||
return 'Hired — placement details appear above when a placement is recorded.';
|
||||
case 'rejected':
|
||||
return 'This application was not taken forward.';
|
||||
case 'withdrawn':
|
||||
return 'You withdrew this application.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Check, Crown, Star, X, Zap } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import type { Package, Plan } from '@nextcraft/types';
|
||||
import { listBillingCatalog } from '../../lib/engine-client';
|
||||
|
||||
/** The catalog-derived tier view (D-079): prices + names + feature
|
||||
* copy come from /v1/billing/catalog — never hardcoded tier copy
|
||||
* drifting from the backend (REQ-9-002). Enterprise tiers seed as
|
||||
* price_cents=0 contact rows — the view renders them as Custom with
|
||||
* contact CTAs, exactly like the old static copy did. */
|
||||
const ENTERPRISE_CENTS = 0;
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
if (cents === ENTERPRISE_CENTS) return 'Custom';
|
||||
return `$${(cents / 100).toFixed(0)}`;
|
||||
}
|
||||
|
||||
/** P5 (REQ-10-005, Task 5-2-01 — superseding D-079): self-serve
|
||||
* checkout is REAL. Purchasable tiers CTA to the employer-login path
|
||||
* (the employer buys on their claimed entity's behalf from the
|
||||
* billing surface); the ENTERPRISE contact tier stays "Contact Sales"
|
||||
* (price_cents=0 rows are operator-mediated by design — the pin at
|
||||
* pins.test.ts:250 holds). */
|
||||
function ctaFor(id: string, isEnterprise: boolean): string {
|
||||
if (isEnterprise) return 'Contact Sales';
|
||||
if (id === 'pkg-bundle') return 'Buy Bundle';
|
||||
if (id === 'pkg-single') return 'Post a Job';
|
||||
if (id === 'plan-starter') return 'Get Starter';
|
||||
return 'Get Pro';
|
||||
}
|
||||
|
||||
function popularFor(id: string): boolean {
|
||||
return id === 'pkg-bundle' || id === 'plan-pro';
|
||||
}
|
||||
|
||||
function iconFor(id: string) {
|
||||
if (id.startsWith('pkg-enterprise') || id.startsWith('plan-enterprise')) {
|
||||
return <Crown className="h-4 w-4" />;
|
||||
}
|
||||
if (popularFor(id)) return <Star className="h-4 w-4" />;
|
||||
return <Zap className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
/** One pricing card, catalog-driven (the card is identical for
|
||||
* packages and plans — the old static page used two interfaces only
|
||||
* because it had no shared type). */
|
||||
function PricingCard({ item, kind }: { item: Package | Plan; kind: 'package' | 'plan' }) {
|
||||
const isEnterprise = item.price_cents === ENTERPRISE_CENTS;
|
||||
const cadence =
|
||||
kind === 'package' ? (isEnterprise ? 'annual' : 'one-time') : isEnterprise ? 'annual' : '/mo';
|
||||
const isPopular = popularFor(item.id);
|
||||
return (
|
||||
<Card
|
||||
className={[
|
||||
'flex h-full flex-col',
|
||||
isPopular
|
||||
? 'border-primary-500 ring-2 ring-primary-500/30 dark:border-primary-400'
|
||||
: '',
|
||||
].join(' ')}
|
||||
>
|
||||
<CardBody className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
||||
{iconFor(item.id)}
|
||||
</span>
|
||||
{isPopular && (
|
||||
<Badge variant="info">
|
||||
<Star className="h-3 w-3" />
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatPrice(item.price_cents)}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{cadence}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{item.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-auto pt-2">
|
||||
{/* P5 (Task 5-2-01): purchasable tiers route to the
|
||||
* employer-login path (self-serve checkout is real — the
|
||||
* employer buys from their billing surface); the enterprise
|
||||
* contact tier keeps the Contact Sales path (operator-mediated
|
||||
* by design). */}
|
||||
<Link
|
||||
href={isEnterprise ? '/marketplace/pricing#contact' : '/employer/login'}
|
||||
className="block"
|
||||
>
|
||||
<Button type="button" variant={isPopular ? 'primary' : 'outline'} size="md" className="w-full">
|
||||
{ctaFor(item.id, isEnterprise)}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing client (REQ-9-002, D-079): fetches the real catalog at
|
||||
* runtime (the A-406 board-client pattern — AbortController, honest
|
||||
* loading/error/empty states). The parent page keeps build-time
|
||||
* metadata (A-411) and stays a server shell.
|
||||
*/
|
||||
export function PricingClient() {
|
||||
const [catalog, setCatalog] = useState<{ packages: Package[]; plans: Plan[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const response = await listBillingCatalog(controller.signal);
|
||||
setCatalog({ packages: response.packages, plans: response.plans });
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load pricing.');
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
|
||||
Loading pricing…
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||
<X className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-slate-700 dark:text-slate-300">{error}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
The pricing service is unavailable — please retry shortly.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!catalog || (catalog.packages.length === 0 && catalog.plans.length === 0)) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
|
||||
No pricing tiers are configured yet — check back soon.
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Job Posting Packages
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{catalog.packages.map((pkg) => (
|
||||
<PricingCard key={pkg.id} item={pkg} kind="package" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Talent Access Plans
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{catalog.plans.map((plan) => (
|
||||
<PricingCard key={plan.id} item={plan} kind="plan" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Self-serve checkout is live: sign in with your employer account and buy
|
||||
packages or plans on your claimed entity's behalf from the billing
|
||||
surface. Pilot payments run through the mock provider (honestly labeled
|
||||
there); operator-assisted checkout remains available as a fallback.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user