Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81a9cc7c12 | |||
| 7f598f3e7b | |||
| 1d08218544 | |||
| 3901fac2b1 | |||
| 6c9198ca56 | |||
| aed709b956 | |||
| 36cd9fa71f |
@@ -59,6 +59,41 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
|
||||
|
||||
**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.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"stage": "ship_pending",
|
||||
"milestone": "v0.10",
|
||||
"milestone_name": "b2b-employer-network",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-30T20:00:00Z"
|
||||
}
|
||||
@@ -263,3 +263,71 @@ 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
|
||||
|
||||
+41
-38
@@ -2,13 +2,13 @@
|
||||
|
||||
## Persona Roster
|
||||
|
||||
> **v0.9 update (RESEARCH, lead-developer assessment):** milestone = Payment Engine (Feature — ai_service/billing/ module: provider protocol + mock + stripe impl, 7th D-027 store, /v1/billing routes with HMAC webhooks, posting entitlement enforcement, pricing-page + admin-surface runtime wiring). **Activated:** backend-engineer (owns ai_service/billing/* + api/billing*.py + api/deps.py + main.py router/lifespan wiring — REQ-9-001/003/004/005 core), data-engineer (owns the 7-table billing store schema: idempotency UniqueConstraints, purchase price snapshots, subscription state machine, event dedupe — REQ-9-001/005), security-auditor (phase-specific: webhook HMAC verify + replay window + raw-body discipline, fail-closed secrets, cents money handling, 402 CTA payloads — REQ-9-003), frontend-engineer (owns the pricing-page client conversion + engine-client listBillingCatalog/PaymentRequiredError + packages/types/billing.ts + admin entitlement view — REQ-9-002/004), lead-developer (coordination + wave order + dual-schema pin discipline + cross-boundary wire review). **Deactivated:** cli-engineer (no CLI changes — billing is server+web only), sandbox-engineer, devops-engineer (no infra changes; the existing release lane carries the new code automatically), ai-engineer, identity-engineer, voice-engineer, design-system-engineer.
|
||||
> **v0.10 update (RESEARCH, lead-developer assessment):** milestone = B2B Employer Network (Feature — ai_service/employers/ module: 8th D-027 store + scrypt auth + session/claim flows, /v1/employers routes, employer web-surface conversion + admin network overview). **Activated:** lead-developer (coordination), backend-engineer (owns ai_service/employers/* + api/employers*.py + api/employers posting/billing routes + the D-091 helper + config.py knobs + main.py wiring — REQ-10-001..005 core), data-engineer (owns the 4-table employer store schema: account/session/claim/audit rows, unique + check-first constraints, tz normalization — REQ-10-001/002), frontend-engineer (owns the employer surface conversion + engine-client employer section + SessionRequiredError + login/register pages + packages/types/employer.ts + admin overview sections + pricing CTA conversion — REQ-10-003/005), security-auditor (phase-specific: scrypt params + compare_digest, session hashing-at-rest, throttle windows, CORS Authorization, 422 password-echo scrub, claim-evidence honesty, sessionStorage exposure, PII — REQ-10-001/002), identity-engineer stays DEACTIVATED (see its note — employer auth is NOT the v0.5 learner identity module). **Deactivated:** cli-engineer, devops-engineer, sandbox-engineer, ai-engineer, voice-engineer, design-system-engineer (all unchanged surfaces this milestone).
|
||||
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Coordinates the v0.9 Payment Engine milestone (foundation → catalog → checkout → enforcement → lifecycle waves); owns wave order + the cross-boundary wire review (billing.ts ↔ base.py dual-schema pins, 402 CTA payload shape) + provider-boundary event normalization consistency (R-43)
|
||||
reason: Coordinates the v0.10 B2B Employer Network milestone (auth foundation → claim → surface conversion → posting management → checkout waves); owns wave order + the cross-boundary wire review (employer.ts ↔ employers/base.py dual-schema pins, the 402/401/403 discrimination contract) + the D-091 extraction's no-drift review (both principal gates compose the same helper)
|
||||
domain: coordination
|
||||
frameworks:
|
||||
- turborepo
|
||||
@@ -27,7 +27,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.9 DEACTIVATED — no infra changes; billing rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
|
||||
reason: v0.10 DEACTIVATED — no infra changes; the employer 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
|
||||
@@ -53,7 +53,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.9 DEACTIVATED — no CLI changes; billing is server+web only. Roster + v0.8 territory retained.
|
||||
reason: v0.10 DEACTIVATED — no CLI changes; the employer surface is server+web only. Roster + v0.8 territory retained.
|
||||
domain: cli
|
||||
frameworks:
|
||||
- node
|
||||
@@ -82,7 +82,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.9 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
|
||||
reason: v0.10 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
|
||||
domain: infra
|
||||
frameworks:
|
||||
- python
|
||||
@@ -106,28 +106,29 @@ territory:
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.9 re-activated (phase-specific) — P3 webhook security review: HMAC-SHA256 verify discipline (raw-body-first, compare_digest, all-v1 iteration for secret rolls, ±300s replay window, non-v1 schemes ignored — R-38..R-40), fail-closed webhook secret (unset → per-boot random → 401s, correct for a payment surface), event dedupe integrity, cents money handling (no floats — D-081), 402 CTA payload honesty, stripe provider key redaction in errors (the embedding test precedent); P6 final review (no secrets in billing code/logs/releases)
|
||||
reason: v0.10 re-activated (phase-specific) — P1 auth review: scrypt params/verify discipline (N=2^14/r=8/p=1 module constants pinned by test, hmac.compare_digest, self-describing rows so params evolve without invalidating stored hashes — D-086), session tokens hashed-at-rest (sha256(token) only, DB-leak yields nothing — D-087), throttle windows (per-email failed-login + global cap; email-keyed never IP — D-088), 401 vs 403 semantics (credential vs authorization — D-090); P2 claim review: evidence is advisory, never displayed as verification (A-304 — example.com corpus makes match=false corpus-wide), one-account-one-entity UNIQUE enforcement; P3+ the 422 password-echo fix (main.py pii_fields gains "password"+"email" — pydantic 422s echo err.input today, R-56/open-risk-3), sessionStorage exposure copy, employer mock-complete own-session check (G-301 hole class); P6 final review (no secrets/logs/releases leak credentials)
|
||||
domain: security
|
||||
frameworks:
|
||||
- pytest
|
||||
- httpx
|
||||
constraints:
|
||||
- STRIDE-classified
|
||||
- secrets-via-env-only (D-014; AI_PAYMENT_API_KEY/AI_WEBHOOK_SECRET never logged)
|
||||
- secrets-via-env-only (D-014)
|
||||
- secrets-never-logged
|
||||
- webhooks-fail-closed-when-unconfigured
|
||||
- raw-body-before-parse (signature verification sees exact bytes)
|
||||
- fail-closed-on-unconfigured
|
||||
- tokens-hashed-at-rest (D-087)
|
||||
- never-display-forgeable-as-verified (D-089/A-304)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/billing/**"
|
||||
- "apps/ai-service/ai_service/api/billing*.py"
|
||||
- "apps/ai-service/tests/billing/**"
|
||||
- "apps/ai-service/ai_service/employers/**"
|
||||
- "apps/ai-service/ai_service/api/employers*.py"
|
||||
- "apps/ai-service/tests/employers/**"
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.9 ACTIVATED — owns the billing module core: ai_service/billing/base.py (PaymentProvider protocol + pydantic contracts + canonical event names), mock.py (deterministic Stripe-shaped provider), stripe.py (form-encoded REST via shared httpx pool, Idempotency-Key per R-49), factory.py (G-11 loud fallback), catalog.py (seeded idempotent), api/billing.py + api/billing_admin.py (require_admin dep verbatim R-51, webhook route raw-body-first, entitlement composition into create_posting AFTER the admin gate), config.py trio (payment_provider/payment_api_key/webhook_secret — R-53), main.py router mounts + lifespan store wiring
|
||||
reason: v0.10 ACTIVATED — owns the employers module core: ai_service/employers/base.py (pydantic contracts), passwords.py (stdlib scrypt KDF, D-086), store.py wiring via deps.py get_employer_store, claim evidence normalization (stdlib suffix match, D-089); api/employers.py (auth routes register/login/logout/me + claim + own-postings CRUD/publish/close + own-checkout + own-entitlement; require_employer_session dep per D-090) + api/employers_admin.py (accounts/claims/audit operator routes, require_admin imported from marketplace_admin — D-094); the D-091 shared consume_posting_entitlement helper + its adoption in marketplace_admin.create_posting (wire contract byte-identical); config.py throttle knobs + .env.example; main.py wiring (employer_store lifespan + close, router mounts, CORS allow_headers gains "Authorization" — R-60)
|
||||
domain: backend
|
||||
frameworks:
|
||||
- fastapi
|
||||
@@ -140,14 +141,15 @@ constraints:
|
||||
- secrets-via-env-only (D-014)
|
||||
- idempotent-scripts
|
||||
- fail-closed-admin
|
||||
- never-call-cloud-in-tests (MockTransport byte-contract only, D-075)
|
||||
- provider-boundary-normalization (R-43: store provider ids verbatim)
|
||||
- never-call-cloud-in-tests (zero network by construction, D-096)
|
||||
- gate-before-insert (the 402 consumes nothing, R-58)
|
||||
- real-kdf-in-tests (no fake-hash path, D-096)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/billing/**"
|
||||
- "apps/ai-service/ai_service/employers/**"
|
||||
- "apps/ai-service/ai_service/config.py"
|
||||
- "apps/ai-service/ai_service/main.py"
|
||||
- "apps/ai-service/ai_service/api/**"
|
||||
- "apps/ai-service/tests/billing/**"
|
||||
- "apps/ai-service/tests/employers/**"
|
||||
- "apps/ai-service/.env.example"
|
||||
- "apps/ai-service/pyproject.toml"
|
||||
```
|
||||
@@ -156,7 +158,7 @@ territory:
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.9 ACTIVATED — owns the pricing-page conversion (server shell retained per A-411/pins + PricingClient component with AbortController + honest loading/error/empty states, the A-406 board-client pattern), engine-client listBillingCatalog() + PaymentRequiredError (402 dict-detail discrimination — parseError precedent), packages/types/billing.ts (dual-schema pinned), admin entitlement view in the moderation surface (per-employer active package/posts used/limit/expiry, client fetch)
|
||||
reason: v0.10 ACTIVATED — owns the employer surface conversion (D-092): engine-client employer section (register/login/logout/me, claim, own-postings, own-checkout, own-entitlement + SessionRequiredError 401 discrimination + nc-employer-session sessionStorage wrapper — the adminToken pattern), /employer/login + /employer/register pages (greenfield), postings page own-lifecycle conversion, overview page real active-postings + applicant counts (talent metrics stay mock-labeled), employer profile via the existing public GET, packages/types/employer.ts (dual-schema pinned, D-093); admin overview sections on the moderation page (accounts list, claim queue with evidence signal + approve/reject, audit — the entitlement panel's locked-state pattern, D-094); pricing CTA conversion from contact-path to employer-login path (supersedes D-079); talent pages stay mock (D-092)
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- react
|
||||
@@ -166,19 +168,20 @@ constraints:
|
||||
- component-first
|
||||
- server-components-default
|
||||
- no-build-time-db-reads (D-039 export build stays repo-only)
|
||||
- honest-states (loading/error/empty never mocked)
|
||||
- cents-in-types (number documented as cents, D-081)
|
||||
- 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/billing.ts"
|
||||
- "packages/types/employer.ts"
|
||||
```
|
||||
|
||||
### ai-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.9 DEACTIVATED — no LLM-facing work; all provider layers ship unchanged.
|
||||
reason: v0.10 DEACTIVATED — no LLM-facing work; employer flows need no tagging/enrichment/embedding changes (publish re-tag + enrich-on-first-publish compose unchanged behind the employer routes).
|
||||
domain: ai
|
||||
frameworks:
|
||||
- pydantic
|
||||
@@ -199,7 +202,7 @@ territory:
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.9 ACTIVATED — owns the 7th D-027 store: billing/store.py with the 7-table schema (packages, plans, purchases with price snapshots — D-078, subscriptions state machine, subscription_events with verbatim event_id UniqueConstraint dedupe, checkout_sessions, billing_events audit trail), InvalidTransition domain error, transition matrix tests, purchase consumption query (newest non-expired with remaining posts), idempotent catalog seeding
|
||||
reason: v0.10 ACTIVATED — owns the 8th D-027 store schema: employers/store.py with the 4-table family (employer_accounts: email UNIQUE + scrypt hash + employer_id UNIQUE-on-approval; employer_sessions: token_hash UNIQUE, expires_at = login + 7-day constant, lazy TTL; employer_claims: pending→approved|rejected + evidence JSON + decided_by/decided_at; employer_audit_events: the BillingEventRow audit shape), the employer_ prefix (bare `employer` is marketplace's table), check-first one-pending-per-entity/one-approved-owner constraints (the insert_application precedent), _validate/_normalize + _as_utc tz discipline, SQLModel.metadata.create_all idempotent compat (D-095), consume-free read paths for accounts/claims/audit lists
|
||||
domain: data
|
||||
frameworks:
|
||||
- sqlmodel
|
||||
@@ -209,12 +212,12 @@ constraints:
|
||||
- schema-first
|
||||
- type-safe
|
||||
- dual-schema-sync
|
||||
- idempotency-by-constraint (UniqueConstraint + check-first, the insert_application pattern)
|
||||
- snapshots-immutable (catalog edits never rewrite purchase history, D-078)
|
||||
- idempotency-by-constraint (UniqueConstraint + check-first)
|
||||
- tokens-never-stored-raw (sha256 only, D-087)
|
||||
- audit-append-only (provenance rows never mutated)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/billing/store.py"
|
||||
- "apps/ai-service/ai_service/billing/catalog.py"
|
||||
- "apps/ai-service/tests/billing/test_store.py"
|
||||
- "apps/ai-service/ai_service/employers/store.py"
|
||||
- "apps/ai-service/tests/employers/test_store.py"
|
||||
- "packages/types/**"
|
||||
```
|
||||
|
||||
@@ -222,7 +225,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 custom persona — module shipped complete; no v0.9 identity-domain work.
|
||||
reason: v0.10 stays DEACTIVATED — the employer auth in this milestone is NOT the v0.5 learner identity module: identity/ is the KYC/age-gate store (IdentityRecord, derived age bands, require_verified_age gates for LEARNERS per D-043); v0.10's employer principals are a THIRD principal class (D-097 — no learner gates on employer routes, the D-043 composition on learner routes untouched). The identity module, its store, and its gates ship unchanged; backend-engineer owns the new employer auth surface. Reactivation is a founder call if/when a real KYC vendor lands or employer SSO/OIDC (explicitly out of scope at pilot) is scheduled.
|
||||
domain: identity
|
||||
frameworks:
|
||||
- fastapi
|
||||
@@ -241,7 +244,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 territory — voice shipped complete; no v0.9 voice work.
|
||||
reason: v0.5 territory — voice shipped complete; no v0.10 voice work.
|
||||
domain: ai-media
|
||||
frameworks:
|
||||
- httpx
|
||||
@@ -262,7 +265,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: No design-token or primitive work in v0.9; roster retained.
|
||||
reason: No design-token or primitive work in v0.10 (login/register pages reuse existing Input/Button/Card primitives); roster retained.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- tailwindcss
|
||||
@@ -278,7 +281,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 (auth surface: scrypt + session hashing + throttles + CORS + 422-scrub fix), 2 (claim evidence honesty + UNIQUE enforcement), 5 (employer mock-complete own-session + checkout review), 6 (final review: no credentials in logs/releases) | milestone complete |
|
||||
|
||||
All other personas span the milestone. Deactivated personas receive no tasks.
|
||||
|
||||
@@ -286,9 +289,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 employer section, SessionRequiredError, the session wrapper) — the shared `EntitlementResponse`/`PurchaseEntitlement` interfaces in engine-client.ts (lines 730-752) are a frozen v0.9 contract neither side may re-shape; the new employer wire types land in packages/types/employer.ts (data-engineer dual-schema-syncs, lead-developer reviews the pins) |
|
||||
| backend-engineer vs security-auditor (api/employers.py) | backend-engineer implements the dep/routes/throttles; security-auditor reviews and may patch security DEFECTS directly in api/employers*.py + employers/passwords.py (its territory) — e.g. the 422 password-echo scrub fix in main.py's pii_fields; implementation vs review stays with backend |
|
||||
| backend-engineer vs data-engineer (employers/store.py) | data-engineer owns the schema + store methods; backend-engineer owns routes/deps/wiring that compose it; passwords.py (KDF core) is backend territory, the password_hash COLUMN contract is data territory |
|
||||
| backend-engineer vs frontend-engineer (main.py CORS block) | backend-engineer owns the allow_headers edit at main.py:341; security-auditor owns the tests/api/test_cors.py preflight PIN (the E87 regression lesson is a security concern — the pin lives in the security review's changeset) |
|
||||
| frontend-engineer vs security-auditor (sessionStorage) | frontend-engineer implements the nc-employer-session wrapper; security-auditor reviews the exposure copy + logout-revoke discipline and pins the no-retry-loop locked state (the G-308 pattern); 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 |
|
||||
+132
-337
@@ -1,403 +1,198 @@
|
||||
# Nextcraft — PLAN.md (v0.9 Payment Engine)
|
||||
# Nextcraft — PLAN.md (v0.10 B2B Employer Network)
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone:** v0.9 — Payment Engine (Feature, D-074): payment processing and subscription management — 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 — behind a provider-agnostic `PaymentProvider` protocol, mock-first (D-014 pattern; tests never call Stripe). The pricing page (REQ-017's surface) gains its real backend.
|
||||
**Tag line:** phase tags advance the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release), continuing from the v0.7.6 milestone release
|
||||
**Branch:** milestone/v0.9-payment-engine
|
||||
**Milestone:** v0.10 — B2B Employer Network (Feature, D-085): real employer auth/accounts + employer self-serve — employers become principals: registered accounts with server-side sessions (scrypt-hashed credentials, hashed-at-rest session tokens), claim/verify of their existing v0.6 `EmployerRow` entity (admin-approved at pilot, evidence advisory), the employer dashboard converting from the v0.1 mockup to real data (own-postings lifecycle, real counts, honest mock-labels only where no backend exists), self-serve posting management composing the v0.9 entitlement gate unchanged (D-091 shared helper, 402 wire contract byte-stable), and self-serve checkout over the v0.9 billing catalog (buyer = session employer, superseding D-076; admin operator routes retained as fallback + oversight, D-094 network overview). Mock-first doctrine holds: no external auth provider, tests never call a cloud, zero new dependencies (R-66).
|
||||
**Tag line:** phase tags advance the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release), continuing from the v0.8.6 milestone release
|
||||
**Branch:** milestone/v0.10-b2b-employer-network
|
||||
|
||||
**Phase list (requirement mapping):**
|
||||
|
||||
| Phase | Requirement | Focus |
|
||||
|-------|-------------|-------|
|
||||
| P1 | REQ-9-001 | Billing foundation: billing/ module (protocol + mock + store + HMAC + factory + settings), no routes yet |
|
||||
| P2 | REQ-9-002 | Catalog + pricing surface: seeded catalog, /v1/billing/catalog, pricing-page client conversion, types + pins |
|
||||
| P3 | REQ-9-003 | Checkout + webhook flow: admin checkout route, stripe provider, public webhook (raw-body HMAC, dedupe, idempotent processing), mock-complete E2E path |
|
||||
| P4 | REQ-9-004 | Entitlement enforcement: consume_posting in create_posting, 402 CTA payload, admin entitlement view |
|
||||
| P5 | REQ-9-005 | Subscription lifecycle: state machine via webhooks, manual grant/revoke, billing event log, past_due grace |
|
||||
| P6 | — | Final review + audit + milestone ship (v0.8.6 = milestone release) |
|
||||
| P1 | REQ-10-001 | Employer auth foundation: employers/ module (8th D-027 store + stdlib scrypt KDF + hashed session rows + both throttle windows), register/login/logout/me routes, main.py wiring (store + CORS Authorization + 422 scrub) — zero web changes |
|
||||
| P2 | REQ-10-002 | Employer entity claim: claim route + advisory evidence (stdlib domain match), one account : one entity (UNIQUE + check-first), admin claim review/approve/reject operator routes + audit |
|
||||
| P3 | REQ-10-003 | Employer account surface: own-postings read route, packages/types/employer.ts + dual-schema pins, engine-client employer section (SessionRequiredError + nc-employer-session), login/register pages, session-gated shell, postings/overview conversion (talent stays mock) |
|
||||
| P4 | REQ-10-004 | Self-serve posting management: D-091 shared consume_posting_entitlement helper + admin adoption (no drift), employer create/edit/publish/close routes, PostingManager write actions |
|
||||
| P5 | REQ-10-005 | Self-serve checkout + admin overview: employer checkout + entitlement view + own-session mock-complete through the REAL webhook path, pricing CTAs → employer-login path, admin network overview sections |
|
||||
| P6 | — | Final review + audit + milestone ship (v0.9.6 = milestone release) |
|
||||
|
||||
**Wave order (binding): foundation before surface before flow before enforcement before lifecycle.** P1 builds the module everything else composes (store + protocol + mock — P2's catalog route reads the store, P3's checkout writes it). P2 precedes P3 because checkout references catalog items by id (the catalog must exist + be surfaced before sessions can be created against it). P3 precedes P4 because enforcement consumes entitlements that only the webhook flow grants. P5 needs the full event set the flow produces. Each phase is a vertical slice: store rows → routes → web surface → tests green → ship.
|
||||
**Wave order (binding): principals before claims before surface before management before monetization.** P1 builds the module everything else composes (store + KDF + session gate — P2's claim route authenticates through it, P3's surface gates on it, P4/P5 parameterize it). P2 precedes P3 because the dashboard's claimed-entity profile and claim-pending states need decided claims to exist. P3 precedes P4 because the postings page conversion consumes the own-postings read route and the P4 mutations wire into the converted page. P4 precedes P5 because checkout's buyer = the session's claimed, managing entity and the upgrade CTA targets the surface P4 completed. Each phase is a vertical slice: store/schema → routes → web wiring → tests green in the same wave → ship.
|
||||
|
||||
**Binding contracts:** clarify decisions D-075..D-084 (PROJECT.md) and research R-33..R-54 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates.
|
||||
**Binding contracts:** clarify decisions D-086..D-097 (PROJECT.md) and research conclusions R-55..R-66 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates. Module layout is decided (ARCHITECTURE v0.10): `ai_service/employers/{__init__,base,store,passwords,claim_evidence}.py` — no service.py; `api/employers.py` + `api/employers_admin.py`; `tests/employers/`.
|
||||
|
||||
**Environment facts (probe-verified, R-51..R-54):** require_admin dep pattern at api/marketplace_admin.py:46-85; D-027 store family replication (marketplace/store.py the template); Settings env trio precedent (config.py: provider selector + SecretStr key + per-boot-random secret); HMAC precedent telemetry/tokens.py; at-least-once dedupe precedent insert_application; pricing page inline constants at page.tsx:41-111 + metadata pinned by pins.test.ts:189-190 + sitemap STATIC_PAGES; engine-client jsonFetch/parseError at lines 90-140; main.py inline router mounts + lifespan state-injection + G-11 fallback pattern.
|
||||
**Environment facts (probe-verified, R-55..R-66):** scrypt is stdlib in the shipped venv (3.11.2), ~47 ms/hash at N=2^14/r=8/p=1, 16 MiB working set, default maxmem admits it — never pin maxmem below ~17 MiB (R-55); require_admin extract→verify→return shape at api/marketplace_admin.py:46-85, the 422 scrubber redacts only `date_of_birth` today (main.py:398-424, pii_fields line 401 — open-risk-3) (R-56); the 8th-store recipe fully specified by billing/store.py, bare `employer` table taken by marketplace/store.py:144 (R-57); create_posting gate order + the 402 dict's hardcoded posts_used=0/posts_limit=0 in BOTH arms (marketplace_admin.py:157-251) (R-58); billing_admin.create_checkout source mechanics incl. unique-pending placeholder + G-310 URLs (billing_admin.py:77-146) + the admin mock-complete path (152-227) (R-59); the four main.py wiring sites (lifespan ~182-190, CORS allow_headers line 341, inline router mounts 366-389, Settings-int knob shape) (R-60); web census — engine-client.ts section order (employer appends beside billing at ~717), adminToken() wrapper at 759-767, the pins.test.ts billing.ts block 205-231 + "Contact Sales" pin 250, NO login page in any route group (R-61); moderation page + entitlement-panel locked-state pattern (R-62); both throttle precedents (identity _SubmitRateLimiter 97-116, sandboxes _check_global_create_rate 123-137, reset-fixture pattern tests/marketplace/conftest.py:123-131) (R-63); tests/billing/ layout + state-injection make_client fixture (test_entitlement_enforcement.py:28-59) (R-64); seeded employer websites are example.com placeholders → evidence match=false corpus-wide, stdlib urlsplit suffix-match normalization (R-65); zero new deps, zero workspace changes, two .env.example knobs (R-66).
|
||||
|
||||
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to billing + the pricing/admin surfaces.
|
||||
**Verification commands (phase gates — the repo's own scripts, R-66):**
|
||||
|
||||
**Secrets policy (binding):** AI_PAYMENT_API_KEY / AI_WEBHOOK_SECRET env-only (D-014), SecretStr in settings, never logged, never in .env.example with real values; tests use fixed test secrets; the stripe provider redacts keys from exceptions (embedding test precedent).
|
||||
- `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, the D-086 passlib rejection holds)
|
||||
- `pnpm ai:test:fast` (FAST=1 scripts/test.sh — `-m "not real_server and not userns"` per D-063; tests/employers/ 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/employers/, api/employers*.py + the D-091 helper, config.py/main.py wiring, tests/employers/, packages/types/employer.ts, the employer web surface, and the admin overview/pricing CTA surfaces.
|
||||
|
||||
**Secrets policy (binding):** no new secrets this milestone — AI_ADMIN_TOKEN stays env-only (D-014); employer passwords are user data: stored only as self-describing scrypt hashes (D-086), never logged, never returned, never echoed in 422s (pii_fields fix, R-56); session tokens stored ONLY as sha256(token) (D-087 — a DB-file leak yields nothing usable); tests use throwaway credentials against tmp_path DBs; no credential value appears in any committed file, log line, or release asset.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Billing Foundation (REQ-9-001)
|
||||
## Phase 1: Employer Auth Foundation (REQ-10-001)
|
||||
|
||||
**Goal:** The billing module exists and is proven: protocol + mock provider + 7-table store + HMAC webhook verification + factory + settings — all tested, zero routes mounted, zero web changes.
|
||||
**Goal:** The employers module exists and is proven: 4-table store + stdlib scrypt KDF + hashed server-side sessions + register/login/logout/me routes + both throttle windows + main.py wiring — all tested with the REAL KDF, zero web changes, zero new dependencies.
|
||||
|
||||
### Wave 1-1: Module core (backend-engineer + data-engineer, sequential)
|
||||
### Wave 1-1: Module core — store + KDF + contracts (backend-engineer + data-engineer, sequential)
|
||||
|
||||
- **Task 1-1-01** (backend-engineer): `ai_service/billing/__init__.py` + `base.py` — pydantic contracts: `Package` (id, name, price_cents, posts_limit, duration_days, features), `Plan` (id, name, price_cents, interval, features), `CheckoutSessionSpec` (mode, line item ref, success/cancel refs, client_reference_id, metadata), `CheckoutSession` (id, url, status, payment_status, mode, expires_at), `SubscriptionRef`, `CanonicalBillingEvent` (name enum: checkout.completed, invoice.paid, invoice.payment_failed, subscription.cancelled, subscription.renewed + payload dict). Money = int cents ge=0 (D-081). `PaymentProvider` Protocol: create_checkout_session, retrieve_session, cancel_subscription, update_subscription, verify_webhook, normalize_event.
|
||||
- **Task 1-1-02** (data-engineer): `billing/store.py` — 7th D-027 store (R-52): 7 tables per D-080; UniqueConstraints (subscription_events.event_id, checkout_sessions.provider_session_id, **purchases.checkout_session_id — G-303: grants idempotent per session, not per event id**); purchases carry price/name snapshot columns; `_validate`/`_normalize`/`_as_utc`/`_sqlite_connect` (WAL/NORMAL/busy_timeout/foreign_keys); Protocol + SQLiteBillingStore pair; `InvalidTransition(ValueError)`; store methods: seed_catalog (idempotent upsert), list_catalog, get_purchase/consume_posting(employer_id) (**G-304: single conditional UPDATE `SET posts_used = posts_used + 1 WHERE id = <newest non-expired with remaining> AND posts_used < posts_limit`, rowcount-checked — never check-then-increment; 0 rows → PackageExhausted/NoActivePackage**), subscription get/upsert/transition (state machine per D-083; **upsert keyed on provider subscription id — G-303**), insert_provider_event (check-first dedupe → created flag), insert_billing_event (audit, manual provenance, **unmatched marker per G-302**), close. Tests: `tests/billing/test_store.py` — tmp_path db, table-existence via sqlite_master, pragma pins, IntegrityError surfacing, dedupe idempotency double-insert (**G-303: same session, different event ids → one purchase**), transition matrix (valid + InvalidTransition; **G-305: no `upgraded` status value**), consume_posting exhaustion + expiry + multi-purchase ordering (**G-304: concurrency test — 1 remaining, two connections → exactly one succeeds**).
|
||||
- **Task 1-1-03** (backend-engineer): `billing/mock.py` — deterministic mock provider (D-075): Stripe-shaped ids (`cs_mock_...` counter+randbits — the identity-mock unique-mint lesson), create/retrieve session (unpaid → paid on confirm), signature mint/verify identical scheme to real provider (whsec-shaped), normalize_event passthrough (mock emits canonical names directly). Tests: `tests/billing/test_mock.py`.
|
||||
- **Task 1-1-04** (backend-engineer): `billing/webhooks.py` — verification core shared by providers: parse `Stripe-Signature` (t=,v1= pairs, multi-v1, ignore v0/unknown — R-38), HMAC-SHA256 over `{t}.{raw_body}` bytes, hmac.compare_digest, ±300s window with `now` injection (R-40, risk-4). Tests: `tests/billing/test_webhooks.py` — good/bad signature, stale timestamp (outside window), missing header, multi-v1 (secret roll), v0-scheme rejected, replay-same-event accepted (idempotent at the verify layer).
|
||||
- **Task 1-1-05** (backend-engineer): `billing/factory.py` + config.py trio (R-53): `payment_provider="mock"`, `payment_api_key: SecretStr`, `webhook_secret=""` (per-boot random); UnknownPaymentProviderError; .env.example additions (commented, no real values). Tests: factory selection + unknown error + secret redaction (repr must not leak).
|
||||
- **Task 1-1-01** (backend-engineer): `ai_service/employers/__init__.py` + `base.py` — pydantic contracts (D-093's list, dual-schema-pinned later in P3): `EmployerAccount` (id, email, employer_id nullable, created_at, `email_verified: Literal[False]` — UNVERIFIED per D-097, displayed as such), `EmployerClaim` (id, account_id, employer_id, status pending|approved|rejected, evidence dict, created_at, decided_by/decided_at), `RegisterEmployerRequest`/`LoginEmployerRequest` (email + password, honest validation), `TokenResponse` (token, expires_at), `EmployerMe` (account + claimed employer_id + claim state). No service.py (R-57) — routes compose the store via deps DI. (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.)
|
||||
- **Task 1-1-02** (data-engineer): `employers/store.py` — 8th D-027 store (R-57, D-095): 4 tables, all `employer_`-prefixed (bare `employer` is marketplace's EmployerRow, marketplace/store.py:144): `employer_accounts` (id PK `acct-`-style, email UNIQUE indexed, password_hash self-describing string, employer_id UNIQUE nullable-until-claim-approval, created_at), `employer_sessions` (id PK, token_hash UNIQUE — stores ONLY sha256(token), account_id indexed, created_at, expires_at = login + `SESSION_TTL_DAYS = 7` module constant), `employer_claims` (schema per D-089, built in P2 — table lands now per D-095's one-time create_all), `employer_audit_events` (auto id PK, account_id/employer_id nullable indexed, kind/name, detail JSON, operator, created_at — the BillingEventRow audit shape, billing/store.py:337-359). Mechanics mirror billing/store.py exactly: `_sqlite_connect` pragmas (WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON), `sa_type=String/JSON` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` tz re-labeling, Protocol `EmployerStore` + `SQLiteEmployerStore` pair, surfaced IntegrityError; `SESSION_TTL_DAYS` pinned by test (the PAST_DUE_GRACE_DAYS shape, D-087/D-083). Store methods: insert_account (check-first → (row, created)), get_account_by_email, mint_session (token_hash=sha256(token), expires_at=now+TTL, `now` injectable), get_session_by_token_hash (**lazy TTL — expired row reads as absent, G-306, no timer**), revoke_session (DELETE), append_audit_event, close. Tests: `tests/employers/test_store.py` — tmp_path DB, table-existence via sqlite_master (4 employer_ tables on the shared db_path), pragma pins, UNIQUE violations surfaced (duplicate email, duplicate token_hash), **token never stored raw (row holds sha256 only)**, lazy TTL with injected now (login+6d present, +8d absent — no sleeps), revoke → lookup miss.
|
||||
- **Task 1-1-03** (backend-engineer): `employers/passwords.py` — stdlib scrypt KDF (D-086, R-55): module constants `SCRYPT_N=2**14, SCRYPT_R=8, SCRYPT_P=1, SCRYPT_DKLEN=32` (**pinned by test**); per-password salt `secrets.token_bytes(16)`; stored self-describing `scrypt$16384$8$1$<salt_b64>$<hash_b64>` (params evolve without invalidating rows); verify parses params from the stored string, derives, compares via `hmac.compare_digest`; **no maxmem argument below ~17 MiB** (R-55 probe: default 32 MiB admits the 16 MiB working set; if pinned for explicitness use 33554432); module docstring records the ~50 ms/hash budget + throttle-bounded burst risk (open-risk-1). Tests: `tests/employers/test_passwords.py` — REAL KDF, no fake-hash path (D-096): hash→verify accept, wrong password reject, two hashes of the same password differ (random salt), params-pinned constants test, self-describing round-trip (old-format row with explicit params still verifies), **tests never assert hash bytes — accept/reject behavior only**.
|
||||
|
||||
### Wave 1-2: Foundation must-haves
|
||||
### Wave 1-2: Auth routes + throttles + main.py wiring (backend-engineer)
|
||||
|
||||
- P1 runs the full suite (fast lane) green: new tests + existing 670 unaffected (billing module is additive; main.py untouched this phase).
|
||||
- mypy + ruff green (billing files join [tool.mypy] files set implicitly — same package).
|
||||
- **Must-have:** mock provider + store + webhooks.py are usable WITHOUT any route — proven by direct-instantiation tests only.
|
||||
- **Task 1-2-01** (backend-engineer): `api/employers.py` — `require_employer_session` dependency (D-090, R-56: the require_admin extract→verify→return shape, single-header `Authorization: Bearer <token>` via partition + scheme check; sha256(token) hash-lookup; **401** missing/expired/unknown — credential failure; **403** reserved for cross-entity authorization; returns session + account + claimed employer_id; token namespaces cannot collide with admin tokens — one class fails the other's gate) + `deps.py` `get_employer_store` (app.state DI). Routes: `POST /v1/employers/auth/register` (email+password → scrypt hash → insert_account; duplicate email → 409 honest; account UNVERIFIED per D-097 — no learner gates ever composed on employer routes), `POST /v1/employers/auth/login` (verify → mint token → `{token, expires_at}`; bad credentials → 401 honest, no user-enumeration difference in response body beyond the 401), `POST /v1/employers/auth/logout` (revoke: DELETE row + audit event; unknown/revoked token → 401), `GET /v1/employers/auth/me` (session + account + claimed employer_id + unverified email displayed as such). Tests: `tests/employers/test_auth.py` — **register→login→me→logout flow green**; duplicate register 409; wrong password 401; logout revokes (subsequent me → 401); expired token → 401 (injected now); no-token → 401; concurrent sessions per account allowed (two logins, both me-green, one logout leaves the other alive). REAL scrypt throughout (≈2 hashes ≈100 ms/test, R-64 budget).
|
||||
- **Task 1-2-02** (backend-engineer): throttles (D-088, R-63 — both copy the proven in-tree mechanics, keyed by SUBMITTED EMAIL, never client IP): (a) per-EMAIL failed-login window — module dict[email]→timestamps, `time.monotonic()` slice-trim, **default 5 failures / rolling 15 min (900 s, window seconds parameterized — identity hardcodes 60.0) → 429 with the honest "wait" detail**; failures-only append; **successful login CLEARS the email's window**; (b) global per-min cap on login+register attempts — module `deque[float]`, while-popleft older-than-60s, `len >= settings.employer_auth_attempts_per_min` → 429, **append only after ALL checks pass (a rejected attempt never consumes budget)**. Settings knobs as plain ints (the `identity_submits_per_min` shape, R-60d): `employer_login_failures_per_window: int = 5`, `employer_auth_attempts_per_min: int = 30`; `.env.example` entries beside the AI_ADMIN_TOKEN block (commented, default-off, R-66). Tests: `tests/employers/test_throttle.py` — 5 failures → 429 with wait detail; success clears the email window; global cap → 429; rejected attempts don't consume the global budget; **both limiter module states reset via fixtures (the _reset_apply_rate_window pattern, R-63)**. (G-313: an autouse fixture in a NEW tests/employers/conftest.py clears BOTH limiter module states before/after EVERY employers test — the whole suite runs inside one 60s window and self-429s otherwise; the sanctioned deviation from billing's no-conftest layout, R-64. G-314: the global deque appends every well-formed login+register attempt — FAILED PASSWORDS COUNT; only 422-invalid bodies and the cap's own 429s are exempt, or D-088's credential-stuffing bound is void; the "rejected attempts consume nothing" test is reworded to "422-invalid consumes nothing; failed-password attempts DO count; N distinct emails inside the window → the (cap+1)-th 429s — the stuffing test". G-320a: the login route order is body-validation → global cap → per-email window → scrypt verify → failure-append — a 429'd attempt performs ZERO KDF derivations, pinned.)
|
||||
- **Task 1-2-03** (backend-engineer): main.py wiring (R-60, all four sites): (a) employer_store lifespan block directly after billing_store (~line 191, same getattr-guard) + `close()` in the finally block; (b) employers router mount beside the billing block (inline-import pattern); (c) **CORS `allow_headers` gains `"Authorization"` (main.py:341) + the `tests/api/test_cors.py` preflight pin lands in THIS task** (the E87 lesson applied before it ships — open-risk-2: without it every cross-origin employer fetch dies preflight in the two-port dev + D-038 network-mode topologies); (d) **pii_fields gains `"password"` + `"email"`** (main.py:401, R-56/open-risk-3 — a pydantic 422 on register would echo the raw password into err["input"] today) + the pin: register with an invalid body → 422 whose detail never contains the submitted password (added to tests/employers/test_auth.py). Tests: test_cors.py Authorization-preflight green; 422-echo pin green.
|
||||
|
||||
## Phase 2: Catalog + Pricing Surface (REQ-9-002)
|
||||
### Wave 1-3: P1 security review + phase gate (security-auditor + lead-developer)
|
||||
|
||||
**Goal:** The catalog is real and surfaced: seeded packages/plans in the DB, `/v1/billing/catalog` public GET, the pricing page reads it at runtime, wire types pinned both sides.
|
||||
- **Task 1-3-01** (security-auditor): auth-core review — scrypt params/compare_digest discipline (constants pinned, self-describing rows), session tokens hashed-at-rest (sha256 only — DB leak yields nothing, D-087), throttle windows (email-keyed never IP, both knobs honored), 401 vs 403 semantics (D-090), CORS Authorization pin present, 422 scrub pinned, revoke-on-logout. 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) + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (existing suites unaffected — the module is additive, marketplace/billing/identity untouched); D-097 discipline check (no require_verified_age/allowlist composed on any employer route); test counts recorded.
|
||||
|
||||
### Wave 2-1: Backend catalog route (backend-engineer)
|
||||
## Phase 2: Employer Entity Claim (REQ-10-002)
|
||||
|
||||
- **Task 2-1-01** (backend-engineer): `billing/catalog.py` — the seed constant (single $49/1 post/30d, bundle $399/10 posts/90d, enterprise custom; starter $99/mo, pro $299/mo, enterprise-talent custom — REQ-017's tier structure) + idempotent seed into store. Mounted via `api/billing.py`: `GET /v1/billing/catalog` → `{packages: [...], plans: [...]}` (public — catalog is advertising, not secret; CORS-existing policy applies). Deps: `get_billing_store` (R-51/R-52 wiring: deps.py + main.py lifespan + close in finally). Tests: `tests/billing/test_api.py` — catalog returns the 6 seeded items, shape pinned, unauthenticated 200.
|
||||
**Goal:** An authenticated account claims an existing EmployerRow entity; claims are pending with recorded advisory evidence; admin approve/reject activates ownership (one account : one entity); the operator queue and audit trail are real.
|
||||
|
||||
### Wave 2-2: Web pricing conversion (frontend-engineer)
|
||||
### Wave 2-1: Claim core — evidence + store + route (backend-engineer + data-engineer)
|
||||
|
||||
- **Task 2-2-01** (frontend-engineer): `packages/types/billing.ts` — Package/Plan/CatalogResponse types (cents documented, D-081); dual-schema pins in `apps/web/tests/pins.test.ts` (G-108 discipline: field-for-field vs base.py).
|
||||
- **Task 2-2-02** (frontend-engineer): `engine-client.ts` — `listBillingCatalog()` via jsonFetch; page stays server shell (metadata + pins untouched — A-411); new `components/marketplace/pricing-client.tsx` ('use client', AbortController, loading/error/empty states — board-client pattern); CTA copy per D-079 (contact path + operator-mediated note; no fake purchase buttons). Tests: fetch-stub catalog render, error state render, empty catalog render, cents formatting.
|
||||
- **Task 2-2-03** (frontend-engineer): pins updated: pricing-page metadata pin retained; new catalog wire pins added.
|
||||
- **Task 2-1-01** (backend-engineer): `employers/claim_evidence.py` — stdlib registrable-domain normalization (R-65): `urllib.parse.urlsplit(website).hostname` lowercased ("https://example.com/openai" → "example.com"), email domain after the last "@" lowercased, `match` = simple SUFFIX match (equal, or one endswith "." + other) — no PSL library, no tldextract (zero-dep discipline); module docstring records the multi-label-suffix over-match as acceptable for an advisory signal; evidence shape per D-089: `{account_email_domain, entity_website_domain, match}`. Tests: `tests/employers/test_claim_evidence.py` — normalization table (scheme/path stripped, case-folded), match true on same registrable domain, false on the example.com corpus (R-65).
|
||||
- **Task 2-1-02** (data-engineer): store claim methods (the insert_application check-first precedent, marketplace/store.py:341-347): `insert_claim` (**one PENDING claim per entity → created=False/409; one APPROVED owner per entity; rejection frees the entity for a new claim**), `decide_claim` (approve → set `employer_accounts.employer_id` — the UNIQUE one-account-one-entity constraint — + decided_by/decided_at + audit append; reject → decided_by/decided_at + audit append), `list_claims` (status filter, consume-free read path for the admin queue), `get_claim`. Tests: `tests/employers/test_claims_store.py` — second pending claim on an entity rejected; claim after rejection succeeds; approve sets employer_id + writes an audit row; second approve by another account → IntegrityError surfaced (one approved owner); decided_at/decided_at provenance pinned. (G-316: the pending-arm uniqueness is TOCTOU on check-first alone — `employer_claims.__table_args__` gains a partial UNIQUE index `uq_pending_claim_per_entity` on employer_id `sqlite_where status='pending'` (SQLite partial index, emitted by create_all — the D-095 one-time path); insert_claim keeps the check-first 409 fast-path and SURFACES the IntegrityError as the same honest 409 under race; the approved-owner arm is already structurally sound via employer_accounts.employer_id UNIQUE. Pinned: the partial index exists in sqlite_master; a raced second pending insert → 409, never a duplicate pending row.)
|
||||
- **Task 2-1-03** (backend-engineer): `POST /v1/employers/claims {employer_id}` (session-gated via require_employer_session): entity must exist in the marketplace store (404 unknown — claims target the v0.6 ingested EmployerRow data), account must not already hold an entity (409 honest — one account : one entity), evidence computed AT CLAIM TIME + stored as provenance JSON on the claim row, response = pending claim with evidence. **Auto-approval is deferred: the operator decision activates (D-089 — without email verification the match is forgeable; A-304: never present unverified as verified).** Tests: `tests/employers/test_claims.py` — claim → pending with the exact evidence dict; unknown entity 404; already-owner 409; second pending claim on the same entity 409; unauthenticated 401.
|
||||
|
||||
## Phase 3: Checkout + Webhook Flow (REQ-9-003)
|
||||
### Wave 2-2: Admin claim review routes + security review (backend-engineer + security-auditor)
|
||||
|
||||
**Goal:** The full payment flow: admin creates a checkout session (mock by default, stripe via env), the provider completes it, the webhook (HMAC-verified, deduped, idempotent) grants the entitlement — proven E2E in tests with zero cloud calls.
|
||||
- **Task 2-2-01** (backend-engineer): `api/employers_admin.py` — `require_admin` imported from marketplace_admin (the billing_admin.py:27 precedent, D-094): `GET /v1/employers/admin/accounts` (email, claimed entity, created_at, unverified marker), `GET /v1/employers/admin/claims` (queue: status, evidence, domain-match signal, decided state), `POST /v1/employers/admin/claims/{id}/approve`, `POST .../claims/{id}/reject` (set decided_by/decided_at + append employer_audit_events per D-089), `GET /v1/employers/admin/audit` (queryable trail). Fail-closed 403 on no/missing/invalid token (the R-56 matrix). Tests: `tests/employers/test_claims_admin.py` — **approve → account.employer_id set + audit row + claim approved**; reject frees the entity (a new claim from another account succeeds); decided twice → 409 honest; 403 fail-closed on all five routes; audit query returns claim decisions with operator provenance.
|
||||
- **Task 2-2-02** (security-auditor): claim-evidence review — evidence is advisory, never displayed as verification (A-304; the example.com corpus makes match=false corpus-wide, R-65/open-risk-6); one-account-one-entity UNIQUE enforced at the store (approve path surfaces IntegrityError, never silently overwrites); claim decisions audit-complete (decided_by/decided_at + events); the 409 paths don't leak account existence beyond the authenticated principal. Findings recorded; P0s fixed in-phase.
|
||||
|
||||
### Wave 3-1: Stripe provider (backend-engineer)
|
||||
## Phase 3: Employer Account Surface (REQ-10-003)
|
||||
|
||||
- **Task 3-1-01** (backend-engineer): `billing/stripe.py` — form-encoded REST via the shared httpx pool: create session (mode, line_items[0][price_data] from catalog item snapshot, client_reference_id, Idempotency-Key uuid4 — R-33/R-35/R-49), retrieve session, cancel_subscription (DELETE or cancel_at_period_end — R-46), update_subscription (items[0][id] resolution inside provider — R-47 dual-charge guard), verify_webhook (shared webhooks.py), normalize_event (R-43 mapping). Tests: `tests/billing/test_stripe_provider.py` — MockTransport byte-contract: exact form-encoded body, Basic auth header, Idempotency-Key present, item-id in update body, key redaction in exceptions, normalize_event mapping table (Stripe → canonical).
|
||||
- **Task 3-1-02** (backend-engineer): main.py factory wiring: provider from settings (mock default), G-11 loud fallback on factory error (voice precedent), provider on app.state + deps.
|
||||
**Goal:** The employer surface is real: own-postings read route feeding a converted dashboard (login/register pages, session-gated shell, own-postings lifecycle view, real counts), wire types dual-schema-pinned, 401 → honest logged-out state — talent pages stay mock, honestly labeled.
|
||||
|
||||
### Wave 3-2: Checkout + webhook routes (backend-engineer)
|
||||
### Wave 3-1: Own-postings read route (backend-engineer)
|
||||
|
||||
- **Task 3-2-01** (backend-engineer): `api/billing_admin.py` — `POST /v1/billing/admin/checkout` (require_admin verbatim — R-51): body {item_id, employer_id} → catalog item lookup → create session (operator_ref + employer_id recorded, D-076; **success_url/cancel_url default from public_base_url per G-310: success = {public_base_url}/marketplace/pricing?checkout=success, cancel = …?checkout=cancelled; loud G-11 boot warning when stripe + public_base_url unset**) → persist checkout_sessions row → return {session_id, url}. Tests: fail-closed 403 (no/missing token), 404 unknown item, 201 with persisted row, **byte-contract: success_url + cancel_url present in the form body**.
|
||||
- **Task 3-2-02** (backend-engineer): `api/billing.py` webhook — `POST /v1/billing/webhook`: `await request.body()` FIRST (risk-1 raw-body discipline), verify HMAC (401 on bad/missing signature), parse event, insert_provider_event (dedupe: existing → 200 already-processed), process by canonical name — **G-302: event payloads are untrusted ID references — the session/subscription resolves from LOCAL rows (creatable only via the admin route); unknown target id → recorded as an unmatched billing_event, NO entitlement, 200; purchase price/name from the stored snapshot, never the event body** — checkout.completed → verify predicate status=complete AND payment_status=paid (R-37) against the local session row → create purchase/subscription from session snapshot; invoice.paid → extend/renew; invoice.payment_failed → past_due; subscription.cancelled → cancelled. All transitions via store (InvalidTransition tolerated-as-duplicate, recorded; **G-306: unknown-target events never 500 — recorded unmatched, 200**). 200 fast. Tests: `tests/billing/test_webhook_route.py` — unsigned 401, bad sig 401, valid + duplicate (idempotent 200, single entitlement), out-of-order tolerated, grace window pinned, mock flow E2E, **G-302: signed event for unknown session → 200 + zero purchases + one unmatched row; inflated payload amount → price = snapshot**, **G-303: distinct event ids same session → one purchase**.
|
||||
- **Task 3-2-03** (backend-engineer): mock-complete path (D-082, **amended by G-301**): `POST /v1/billing/mock/complete` mounted ONLY under provider=mock **AND composed with require_admin — the operator completing a session after out-of-band payment is the honest pilot flow (G-301: mock is the default provider, so this route exists on every default-config deployment; an unauthenticated completion route on a payment surface is a hole regardless of id unguessability)** — mints a signed event through the REAL webhook processing (direct handler call, same code path) — E2E proof: admin checkout → admin-gated mock complete → webhook → entitlement exists. Route 404s under stripe (factory-conditional mount, test-pinned). Tests: **no token → 403 under mock (G-301)**; 404 under stripe; E2E green.
|
||||
- **Task 3-1-01** (backend-engineer): `GET /v1/employers/postings` (session-gated; **unclaimed account → 403 + claim-CTA detail** — the D-090 403 semantics; claimed → own postings only, resolved from the marketplace store by the session's employer_id, status facets draft|published|closed, **per-posting applicant_count from the applications table** — the D-092 overview data source). Reuses marketplace store via deps DI; no new tables. Tests: `tests/employers/test_postings_read.py` — own-entity rows only (a second claimed employer's postings never appear); unclaimed 403 with claim-CTA; status facets pinned (counts per status); applicant_count matches seeded applications; no-token 401. (G-319: the response is a NAMED pydantic contract in employers/base.py — `OwnPostingSummary` (PostingSummary reused + applicant_count) + the facets/list response — so the MH-3a dual-schema pin is schema↔schema, not a prose payload; the counts ride a new in-pattern marketplace store read `count_applications_by_posting(posting_ids) -> dict[str, int]`, never a per-posting N+1; employer-facing responses expose applicant COUNTS ONLY — learner identifiers NEVER appear in any /v1/employers/* response, PII boundary, security-auditor P3 scope.)
|
||||
|
||||
## Phase 4: Entitlement Enforcement (REQ-9-004)
|
||||
### Wave 3-2: Wire types + engine-client employer section (frontend-engineer)
|
||||
|
||||
**Goal:** Posting creation enforces packages honestly; the admin surface shows entitlement state.
|
||||
- **Task 3-2-01** (frontend-engineer): `packages/types/employer.ts` (the billing.ts precedent, R-61/R-66) — EmployerAccount, EmployerClaim (+evidence), token/me shapes, own-postings list response (PostingSummary REUSED from marketplace.ts:99-117, never duplicated; entitlement view reuses the v0.9 shapes per D-093) + `export * from './employer'` in index.ts. Dual-schema pins in `apps/web/tests/pins.test.ts` (G-108 discipline; the billing.ts pin block 205-231 is the template): **the employer.ts pin block asserts every field field-for-field against ai_service/employers/base.py's pydantic contracts — both schemas**.
|
||||
- **Task 3-2-02** (frontend-engineer): `engine-client.ts` employer section (appended beside billing, R-61 — ONE client module, no fork): `registerEmployer`/`loginEmployer`/`logoutEmployer`/`getEmployerMe`/`claimEmployerEntity`/`listOwnEmployerPostings` + the `nc-employer-session` sessionStorage wrapper (the adminToken() 759-767 pattern: window-guarded get/set/clear; tab-scoped bounds XSS exposure — open-risk-4, documented in surface copy) + **`SessionRequiredError` — the 401 discrimination arm joining the parseError chain (no 401 arm exists today, R-61); 401 → honest logged-out state, NEVER a retry loop** + logout clears storage AND calls the revoke route + AbortController on every fetch (A-406). Tests: web fetch-stub tests (the billing-entitlement.test.ts template) — 401 → SessionRequiredError; token attached as Authorization header on every employer fetch; logout clears storage + fires revoke.
|
||||
|
||||
### Wave 4-1: Enforcement composition (backend-engineer)
|
||||
### Wave 3-3: Pages — login/register + dashboard conversion (frontend-engineer)
|
||||
|
||||
- **Task 4-1-01** (backend-engineer): `api/marketplace_admin.py` create_posting composes billing AFTER require_admin: consume_posting(employer_id) → success: proceed (posts_used incremented); `PackageExhausted`/`NoActivePackage` (billing domain errors) → `HTTPException(402, detail={reason, posts_used, posts_limit, upgrade_cta: {catalog_hint}})` (dict-detail precedent — require_verified_age). Backward-compat: employers with UNLIMITED manual grants bypass (enterprise — D-080). **G-309: the enforcement exists ONLY in the admin route — run_ingest and any future adapter bypass consume_posting by design (seed ingestion is never entitlement-gated)**. Tests: 402 on no package, 402 on exhausted, 201 + consumption on valid, unlimited bypass, expired package degrades to 402, **seeded ingest on a zero-purchase store → all postings upserted, zero 402s (G-309)**.
|
||||
- **Task 4-1-02** (backend-engineer): `GET /v1/billing/admin/entitlements/{employer_id}` — active package, posts used/limit, expiry, subscription state (require_admin). Tests: shape + fail-closed.
|
||||
- **Task 3-3-01** (frontend-engineer): `/employer/login` + `/employer/register` pages (greenfield client pages, R-61 — no login page exists in any route group; existing Input/Button/Card primitives, no design-token work): honest states throughout — 401 invalid-credentials, 409 duplicate email, 429 throttle wait, 422 validation (never echoing the password — server-side pinned already); register success → logged-in dashboard; login stores the session token. Tests: fetch-stub render tests — loading/error/empty honest, 429 copy, register→login handoff. (G-312: fetch-stub + source-pin discipline — no render harness, per D-059.)
|
||||
- **Task 3-3-02** (frontend-engineer): employer dashboard conversion: postings page drops the `jobs` mock import + PostingManager's derived mock statuses/applicants (components/employer/posting-manager.tsx:29-37) → own-postings read lifecycle view (draft → published → closed visible, applicant counts real; write actions land P4); overview page drops `candidates, employers` mock imports + inline METRICS/PIPELINE constants (page.tsx:10, 18-74) → real active-postings count + per-posting applicant counts from listOwnEmployerPostings + claimed-entity profile via the existing public GET /v1/marketplace/employers/{id} (enrichment retained); **talent-match/placement metrics STAY mock-labeled (D-092 — no backend computes them); talent search + candidate profile pages keep their mock imports unchanged (no candidate backend exists — honest out-of-scope)**. Session-gated shell: no/expired session → honest logged-out state with login CTA (SessionRequiredError path), never a retry loop; claim-pending state visible (claim submitted, awaiting review). Tests: web render tests — logged-out state, claim-pending state, postings lifecycle render, real counts; **pins: postings/overview pages grep-clean of the removed mock imports; talent pages' mock imports retained**; `pnpm --filter web build` (static export) green. (G-312: "render tests" = the D-059/E87 discipline — fetch-stub tests over engine-client employer functions + SOURCE pins for component honest states (logged-out/claim-pending/loading/error branches present in source, the pins.test.ts discipline); NO jsdom/happy-dom render harness exists in-tree and none may be added this milestone.)
|
||||
|
||||
### Wave 4-2: Admin surface (frontend-engineer)
|
||||
## Phase 4: Self-Serve Posting Management (REQ-10-004)
|
||||
|
||||
- **Task 4-2-01** (frontend-engineer): admin moderation surface gains per-employer entitlement panel (**G-308 design, replacing the A-406 citation — the entitlements route is admin-gated, A-406 is the public-route pattern**): a small operator-token input persisted to sessionStorage + a new `adminFetch` wrapper in engine-client (X-Admin-Token header, token never logged); 403 renders an honest locked state with the token prompt; panel shows active package, posts used/limit bar, expiry, subscription state badge, manual-grant pointer. Tests: fetch-stub render, 402-CTA payload rendering, **stubbed 403 → locked-state render with prompt (G-308)**, **fetch stub asserts the X-Admin-Token header**, no admin-token string in non-test web source beyond the wrapper.
|
||||
**Goal:** Employers manage their own postings end-to-end: the v0.9 entitlement gate extracted ONCE into a shared api/ helper, both principal gates composing it with byte-identical wire contracts; employer create/edit/publish/close routes; the converted PostingManager gains write actions with honest 402/403 states.
|
||||
|
||||
## Phase 5: Subscription Lifecycle (REQ-9-005)
|
||||
### Wave 4-1: Shared entitlement helper + employer posting routes (backend-engineer + lead-developer)
|
||||
|
||||
**Goal:** Full lifecycle: state machine driven by webhooks + manual admin actions, audit log complete, past_due grace honest.
|
||||
- **Task 4-1-01** (backend-engineer): the D-091 extraction: `consume_posting_entitlement(store, employer_id, upgrade_cta)` in api/ beside both callers (no billing/→api/ boundary violation) — extracted from marketplace_admin.create_posting lines 181-220; **canonical 402 dict `{reason, posts_used: 0, posts_limit: 0, upgrade_cta}` — posts_used/posts_limit hardcoded 0 in BOTH arms preserved EXACTLY (R-58 — the wire contract; UpgradeRequiredError consumes reason + upgrade_cta unchanged, D-080/D-084)**; store-missing → 503 fail-closed (the getattr guard); **gate runs BEFORE insert — a 402 consumes nothing, creates nothing**; `consume_posting` itself (G-304 single conditional UPDATE) UNTOUCHED. marketplace_admin.create_posting adopts the helper with the admin-checkout CTA — behavior byte-identical. Tests: **tests/billing/test_entitlement_enforcement.py green UNCHANGED (the no-drift proof)** + a new payload-parity test: admin-gate 402 vs employer-gate 402 assert equal reason/posts_used/posts_limit with only upgrade_cta differing per principal.
|
||||
- **Task 4-1-02** (backend-engineer): employer posting write routes (session gate → claim check → entitlement → insert, mirroring the "admin gate still first" composition, R-58): `POST /v1/employers/postings` (create draft — `consume_posting_entitlement(billing_store, session.employer_id, employer-CTA)`; 403 + claim-CTA unclaimed), `PATCH /v1/employers/postings/{id}` (**own drafts only — published/closed → 409 honest; another entity's posting → 403, the D-090 authorization arm; unknown → 404**), `POST .../postings/{id}/publish` (own draft → published + posted_at; the tagging/enrichment composition behind publish unchanged), `POST .../postings/{id}/close` (own posting → closed). Admin posting routes remain untouched (operator path unchanged, provenance-recorded). Tests: `tests/employers/test_postings_write.py` — create consumes from the employer's OWN package (posts_used+1 pinned); 402 both arms via direct store grants (the grant_posts precedent, test_entitlement_enforcement.py:70-85 — no package → `no_active_package`, exhausted → `posting_limit_reached`); **402 consumes nothing (gate-before-insert pinned: failed create leaves posts_used unchanged and no row)**; cross-entity 403; edit published → 409; publish/close lifecycle + applicant counts accrue; unlimited/enterprise manual grants bypass. (G-311: the session/claim gates compose BEFORE the entitlement check — unauthenticated → 401, unclaimed → 403, never a 402 (the R-58 admin-gate-first order; REQ-10-004's "AFTER" wording is corrected by this grill). G-317: employer create stamps its OWN provenance — `source="employer"` with `emp-` id/external_id schemes, never the admin route's `source="admin"` (authorship must stay answerable across seeded/admin/employer classes); the admin routes are untouched. G-318: employer edit composes the IDENTICAL update_posting_fields + tag_posting re-tag path as the admin edit (D-052 — tags are derived, overwritten on edit; tag failure honest, edit stands) with the own-drafts guard evaluated BEFORE any mutation — edit published/closed → 409 with no field/tag side effects. Pinned: employer-created posting carries the employer source stamp; employer edit re-tags on a mock provider; edit published → 409 AND nothing mutated.)
|
||||
- **Task 4-1-03** (lead-developer): D-091 no-drift review — both principal gates compose the SAME helper (source-level: no duplicated gate logic in either route); the 402 payload-parity test green; v0.9 billing/marketplace_admin suites green unmodified; wire contract (UpgradeRequiredError) unchanged.
|
||||
|
||||
### Wave 5-1: Lifecycle + admin routes (backend-engineer + data-engineer)
|
||||
### Wave 4-2: PostingManager write actions (frontend-engineer)
|
||||
|
||||
- **Task 5-1-01** (data-engineer): store transition matrix complete: trialing→active, active→past_due, past_due→active (recovery), past_due→expired (**G-306: lazily computed from past_due_at + 7-day constant at read/entitlement time — no background timer; test-pinned D-083**), active→cancelled, cancelled terminal; **G-305: no `upgraded` status — upgrade is a plan_id transition within `active`, recorded as a billing_event with provenance**; expires_at extension idempotent on invoice.paid; manual grant/revoke/complement methods (source="manual" + operator provenance in billing_events). Tests: full matrix + grace-elapsed expiry (**injected now: past_due_at+6d → granted, +8d → expired — no sleeps**) + manual-action audit rows + **upgrade keeps status=active, changes plan_id, writes billing_event (G-305)**.
|
||||
- **Task 5-1-02** (backend-engineer): `api/billing_admin.py` lifecycle routes: POST admin/subscriptions/{id}/cancel, /resume, /upgrade (provider calls for stripe; state transitions for mock), POST admin/employers/{id}/grant (manual unlimited/package), /revoke, GET /v1/billing/admin/events (queryable audit log: by employer, by type, by time). Tests: each route happy path + fail-closed 403 + InvalidTransition 409.
|
||||
- **Task 5-1-03** (backend-engineer): webhook subscription.renewed handling: renewal invoice.paid extends expires_at idempotently (event dedupe makes double-fire safe — R-45); corroborating customer.subscription.updated recorded. Tests: double-renew single-extension.
|
||||
- **Task 4-2-01** (frontend-engineer): PostingManager gains the write actions over the engine-client employer section: create draft, edit own draft, publish, close — honest states: **402 → UpgradeRequiredError renders the upgrade CTA with the correct reason (the CTA routes to the employer billing surface, P5)**; 403 claim-CTA renders the claim prompt; 409/404 honest; optimistic-free, loading/error/empty per component state; AbortController everywhere. Tests: web fetch-stub — 402 CTA render with reason, publish/close dispatch, own-draft-only error copy, claim-prompt on 403. (G-320b: the 402 branch renders reason + upgrade CTA ONLY — the payload's posts_used/posts_limit are byte-stable zeros (D-091/MH-4a) and are NEVER rendered as limits; real limits render exclusively from GET /v1/employers/billing/entitlement; pinned by source pin.)
|
||||
|
||||
## Phase 6: Final Review + Ship (milestone release, v0.8.x line)
|
||||
## Phase 5: Self-Serve Checkout + Admin Overview (REQ-10-005)
|
||||
|
||||
**Goal:** Multi-persona review of all v0.9 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.8.6 (final v0.8.x patch = milestone release), full asset matrix + registry image via the release lane (D-064), milestone completion commits, branch cleanup.
|
||||
**Goal:** The B2B loop closes: employer-principal checkout over the v0.9 catalog through the REAL webhook path (buyer = session's claimed entity), own entitlement view, pricing CTAs → employer-login path, and the admin moderation page gains the network overview (accounts, claim queue, audit).
|
||||
|
||||
- Review: P0 fixes in-phase; P1+ fixed here, never loop back to EXECUTE.
|
||||
- Audit: git log ↔ .ciagent/ reconstruction; file discipline; branch hygiene; commit discipline (feat: scoped to billing + pricing/admin surfaces).
|
||||
- Ship: phase/(N+1) branch → milestone → main; delete all milestone branches (tags preserve history).
|
||||
- Complete: REQUIREMENTS.md (REQ-9-001..005 complete), ROADMAP.md milestone entry, checkpoint cleared.
|
||||
### Wave 5-1: Employer checkout + entitlement view + mock-complete (backend-engineer)
|
||||
|
||||
- **Task 5-1-01** (backend-engineer): `POST /v1/employers/billing/checkout {item_id}` (R-59 verbatim transpose of billing_admin.create_checkout — only principal derivation + metadata provenance change): session gate → **unclaimed → 403 + claim-CTA** → catalog lookup 404 → mode = subscription iff kind==plan → success/cancel URLs from `settings.public_base_url` (G-310) → `insert_checkout_session` FIRST with unique pending placeholder (`pending-{uuid4().hex[:16]}`) → CheckoutSessionSpec with metadata employer_id = claimed entity, operator_ref → the account's provenance (D-076's "operator" becomes account provenance) → provider.create_checkout_session via app.state.payment_provider → bind_provider_session_id → CheckoutResponse. Plus `GET /v1/employers/billing/entitlement` (own purchases/subscriptions: current package, posts used/limit, expiry — honest states; the v0.9 shapes reused). The webhook route + _grant_entitlement serve employer checkouts verbatim (G-303 per-session exactly-once holds). Tests: `tests/employers/test_checkout.py` — unclaimed 403 claim-CTA; 404 unknown item; 201 shape + persisted row; metadata provenance pinned (employer_id = claimed entity, account ref recorded); entitlement view honest states (none/active/expired).
|
||||
- **Task 5-1-02** (backend-engineer): employer mock-complete arm (D-082 transposed, R-59): `POST /v1/employers/billing/mock/complete` mounted ONLY under provider=mock (factory-conditional), **employer-SESSION-gated, completing one's OWN session — cross-session completion → 403 (the G-301 hole class, open-risk-5); provider_session_id resolved from LOCAL rows by the session's own checkout rows (G-302 — payloads are untrusted ID references)**; mints the signed event through the REAL webhook path (mint_event → process_webhook_event, direct handler call — the D-082 proof shape); 404 under stripe (test-pinned). Tests: `tests/employers/test_checkout_e2e.py` — **E2E: register → login → claim → admin approve → checkout → own mock-complete → webhook → purchase exists with posts available**; cross-session completion → 403; stripe-mode mount → 404; duplicate complete falls through already-complete (no double grant, G-303).
|
||||
|
||||
### Wave 5-2: Employer billing surface + pricing CTAs + admin network overview (frontend-engineer)
|
||||
|
||||
- **Task 5-2-01** (frontend-engineer): employer billing surface + pricing CTA conversion: the dashboard gains the entitlement view (current package, posts used/limit bar, expiry — honest empty/expired states) + buy flow (catalog from the public `/v1/billing/catalog`, own-checkout trigger, **in-app mock-complete confirmation honestly labeled as the pilot mock path**); **pricing-client.tsx CTAs convert from contact-path to employer-login path (superseding D-079) — the "Contact Sales" enterprise pin at pins.test.ts:250 STAYS (R-61)**; the operator-mediated copy is replaced by the self-serve truth with the admin-fallback noted honestly. Tests: web fetch-stub — entitlement render, checkout dispatch, mock-complete confirmation copy, CTA → /employer/login path, enterprise Contact Sales retained.
|
||||
- **Task 5-2-02** (frontend-engineer): admin network overview (D-094, R-62): the moderation page (app/(admin)/admin/moderation/page.tsx) gains sibling `<section>` blocks below the v0.9 EmployerEntitlementPanel: employer accounts list (email, claimed entity, entitlement state columns), claim queue (**evidence + domain-match signal rendered ADVISORY — never as verification, A-304**; approve/reject actions), audit view — fed by the P2 operator routes via engine-client admin transport over the existing `adminToken()` G-308 wrapper; **403 renders an honest locked state with the token prompt, never a retry loop (the entitlement-panel load() pattern, R-62)**. Tests: web fetch-stub — locked state on 403, claim queue renders evidence + advisory signal, approve/reject dispatch, accounts list columns.
|
||||
|
||||
### Wave 5-3: P5 security review (security-auditor)
|
||||
|
||||
- **Task 5-3-01** (security-auditor): checkout surface review — employer mock-complete own-session check (cross-session 403 pinned; mock-only mount pinned; stripe-mode 404 pinned — the G-301 class closed), unclaimed checkout 403 claim-CTA honest, entitlement view leaks nothing cross-entity, sessionStorage exposure copy present on the dashboard (open-risk-4), pricing copy honest (self-serve real, mock path labeled, enterprise pin retained). Findings recorded; P0s fixed in-phase.
|
||||
|
||||
## Phase 6: Final Review + Ship (milestone release, v0.9.x line)
|
||||
|
||||
**Goal:** Multi-persona review of all v0.10 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.9.6 (final v0.9.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-091 no-drift verdict holds end-to-end (one helper, two gates, byte-identical 402), the cross-boundary wire review (employer.ts ↔ employers/base.py pins, the 401/402/403 discrimination contract in engine-client), D-097 discipline (learner gates untouched on learner routes; zero on employer routes), the D-092 honest-surface census (talent mock labels intact, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase, never loop back to EXECUTE.
|
||||
- **Task 6-1-02** (security-auditor): final review — no credentials in logs/releases/assets; scrypt params + self-describing rows pinned; sessions hashed-at-rest; 422 scrub covers password+email; throttle windows active; audit trail complete (claim decisions, grants, revocations all provenance-recorded); no secret ever echoed by an exception path.
|
||||
|
||||
### Wave 6-2: Audit + ship (lead-developer)
|
||||
|
||||
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the R-66 addition census: ai_service/employers/, api/employers*.py, the api/ helper + adoption, config.py knobs, main.py wiring, tests/employers/, .env.example, packages/types/employer.ts, engine-client section, employer pages, admin sections, pricing CTA; branch/commit discipline — feat: scoped to the employer surface) + ship: merge milestone → main; tag v0.9.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-10-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
|
||||
|
||||
---
|
||||
|
||||
## Must-Haves (per phase — concrete, falsifiable, test-linked)
|
||||
|
||||
- **P1 (REQ-9-001):** billing module importable + tested standalone; store tests green (7 tables, dedupe, transitions); webhooks.py verify matrix green; factory + settings trio live; fast suite green; NO routes mounted, NO web changes. **MH-1a** sqlite_master shows 7 billing tables. **MH-1b** duplicate insert_provider_event returns created=False. **MH-1c** bad-signature verify raises. **MH-1d** factory: mock default, unknown raises, key redacted.
|
||||
- **P2 (REQ-9-002):** catalog route serves 6 seeded items; pricing page renders from runtime fetch with honest states; dual-schema pins green; metadata pin retained. **MH-2a** GET /v1/billing/catalog unauth 200 with exactly the seeded items. **MH-2b** pins.test.ts billing section green. **MH-2c** pricing page: no inline JOB_PLANS/TALENT_PLANS constants remain (grep-clean).
|
||||
- **P3 (REQ-9-003):** stripe provider byte-contract green under MockTransport; webhook 401/200/idempotent matrix green; mock E2E green (checkout → complete → entitlement). **MH-3a** unsigned webhook → 401, never processed. **MH-3b** duplicate event → 200, single entitlement; **distinct event ids, same session → one purchase (G-303)**. **MH-3c** E2E: purchase exists after admin-gated mock complete with predicate honored; **no-token complete → 403 (G-301)**. **MH-3d** update body contains items[0][id]. **MH-3e** no test touches the network (MockTransport-only, grep-pin). **MH-3f** signed event for unknown session → 200, zero entitlement, unmatched row (G-302).
|
||||
- **P4 (REQ-9-004):** create_posting enforces; 402 payload shape pinned; admin entitlement view renders. **MH-4a** no-package posting → 402 with upgrade_cta. **MH-4b** exhausted → 402; valid → 201 + posts_used incremented. **MH-4c** unlimited bypass. **MH-4d** expired degrades to 402.
|
||||
- **P5 (REQ-9-005):** transition matrix complete + grace pinned; manual actions audited; renewal idempotent; event log queryable. **MH-5a** grace-elapsed past_due → expired (7-day constant pinned, lazy evaluation per G-306 — no sleeps); **no `upgraded` status value exists (G-305)**. **MH-5b** double-renew single extension. **MH-5c** manual grant → billing_events row with source=manual. **MH-5d** GET admin/events filters by employer.
|
||||
- **P1 (REQ-10-001):** the employers module is importable + tested standalone; register/login/logout/me live with hashed sessions + both throttles; main.py wired; zero web changes. **MH-1a** register→login→me→logout flow green in tests/employers/test_auth.py; logout revokes (subsequent me → 401); concurrent sessions survive single logout. **MH-1b** REAL scrypt in every auth test (no fake-hash path; ≈2 hashes/test within the R-55 budget); params are module constants pinned by test; hash bytes never asserted. **MH-1c** employer_sessions stores ONLY sha256(token) (store-pinned); lazy 7-day TTL pinned via injected now (+6d present, +8d absent — no sleeps). **MH-1d** both throttle windows green in tests/employers/test_throttle.py — 5 failures/15 min → 429 with wait detail, success clears the email window, global cap → 429, rejected attempts consume nothing; reset fixtures isolate module state. **MH-1e** CORS preflight with `Access-Control-Request-Headers: Authorization` allowed — pinned in tests/api/test_cors.py (landed with the routes, not after). **MH-1f** a 422 on register never echoes the submitted password or email (pii_fields pin); mypy + ruff + full fast pytest green, zero new mypy overrides; no learner gate composed on any employer route (D-097).
|
||||
- **P2 (REQ-10-002):** claim → pending → admin approve → account.employer_id set, all pinned. **MH-2a** claim→approve→ownership set + audit row green in tests/employers/test_claims_admin.py. **MH-2b** evidence dict `{account_email_domain, entity_website_domain, match}` computed at claim time, stored as provenance, pinned in tests/employers/test_claims.py; displayed nowhere as verification (match=false corpus-wide per R-65). **MH-2c** one PENDING claim per entity + one APPROVED owner per entity + rejection frees the entity — all three pinned in tests/employers/test_claims_store.py. **MH-2d** all five operator routes (accounts/claims/approve/reject/audit) fail-closed 403 without a valid admin token.
|
||||
- **P3 (REQ-10-003):** the dashboard reads real data through the authenticated surface. **MH-3a** pins.test.ts employer.ts block asserts every field both schemas (ai_service/employers/base.py ↔ packages/types/employer.ts, D-093; PostingSummary reused from marketplace.ts, never duplicated). **MH-3b** 401 → SessionRequiredError → honest logged-out state with login CTA, never a retry loop (web test-pinned). **MH-3c** postings + overview pages grep-clean of removed mock imports (`jobs`, `candidates`, `employers`, inline METRICS/PIPELINE constants); own-postings lifecycle + real active count + per-posting applicant counts render from GET /v1/employers/postings; unclaimed → claim-CTA state visible; claim-pending state visible. **MH-3d** talent search + candidate profile pages keep their mock imports + mock labels (D-092 honest out-of-scope). **MH-3e** /employer/login + /employer/register render with honest 401/409/429/422 states; `pnpm --filter web build` (static export) green.
|
||||
- **P4 (REQ-10-004):** one shared gate, two principals, no drift. **MH-4a** the 402 payload is byte-identical between admin and employer gates on `{reason, posts_used: 0, posts_limit: 0}` — only upgrade_cta carries the principal's CTA — pinned by the payload-parity test; **tests/billing/test_entitlement_enforcement.py green UNCHANGED**. **MH-4b** gate-before-insert: a 402 consumes nothing and creates nothing (posts_used unchanged, no row) — pinned. **MH-4c** employer create consumes from the employer's OWN package (posts_used+1 pinned); both 402 arms (no_active_package, posting_limit_reached) proven via direct store grants; unlimited bypass holds. **MH-4d** cross-entity access → 403; own-drafts-only edit (published → 409); publish/close lifecycle green in tests/employers/test_postings_write.py; admin posting routes unchanged (existing marketplace_admin tests green unmodified).
|
||||
- **P5 (REQ-10-005):** the B2B loop closes machine-checked. **MH-5a** employer checkout E2E through the REAL webhook path green in tests/employers/test_checkout_e2e.py: register → claim → approve → checkout → own mock-complete → purchase exists with posts available (G-303 exactly-once holds — duplicate complete never double-grants). **MH-5b** cross-session mock-complete → 403; the route 404s under provider=stripe (mock-only mount) — both pinned (the G-301 class closed). **MH-5c** unclaimed checkout → 403 + claim-CTA; entitlement view renders honest none/active/expired states. **MH-5d** pricing CTAs route to /employer/login (contact-path copy gone from purchasable tiers); the enterprise "Contact Sales" pin retained (pins.test.ts:250). **MH-5e** admin network overview renders the accounts list, claim queue with advisory evidence + approve/reject actions, and the audit view; 403 → honest locked state with token prompt (web tests).
|
||||
- **P6 (ship):** **MH-6a** multi-persona review clean (P0s fixed in-phase); the D-091 no-drift + wire-pin verdicts recorded. **MH-6b** security final review clean (no credentials in logs/releases; audit trail complete). **MH-6c** reconstruction + discipline checks green; v0.9.6 tagged, merged to main, release carries the full asset matrix + registry image via the existing lane; REQ-10-001..005 marked complete; branches deleted; checkpoint cleared.
|
||||
|
||||
## Milestone Must-Haves
|
||||
|
||||
- REQ-9-001..005 all complete; the mock payment loop is the machine-checked E2E proof (checkout → webhook → entitlement → consumption → renewal/cancel).
|
||||
- The stripe provider is byte-contract-proven without ever leaving MockTransport.
|
||||
- Zero secrets in code/tests/examples; AI_WEBHOOK_SECRET unset → webhooks fail closed.
|
||||
- Suite counts: pytest + web + cli all green, zero warnings (v0.7 regime holds).
|
||||
- Docs: .env.example billing section; deploy/README.md unaffected (billing needs no admin-box changes — record this explicitly); REQUIREMENTS/ROADMAP updated.
|
||||
- REQ-10-001..005 all complete per their REQUIREMENTS.md rows.
|
||||
- The full B2B loop is the machine-checked E2E proof: register → login → claim → admin approve → create draft → buy package (own mock checkout → REAL webhook) → publish → posting live — every step pinned in tests/employers/.
|
||||
- Zero new dependencies (R-66), zero workspace changes, zero mypy overrides; scrypt/secrets/hmac/sha256/urllib stdlib-only end-to-end.
|
||||
- The v0.9 wire contracts unchanged: UpgradeRequiredError byte-stable, existing billing/marketplace_admin entitlement tests green unmodified.
|
||||
- Suite counts: pytest fast lane + web + cli all green, zero warnings (the v0.7 regime holds).
|
||||
- Docs: .env.example throttle knobs; REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — record explicitly); the mock-first doctrine intact (no external auth provider, tests never call a cloud).
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Raw-body discipline** (risk-1): any body pre-parse breaks HMAC — pinned by MH-3a + route-order test.
|
||||
2. **Dual-charge** (R-47): pinned by MH-3d.
|
||||
3. **Out-of-order events** (R-45): transitions tolerate duplicates; InvalidTransition recorded not raised for already-reflected state — pinned in webhook tests.
|
||||
4. **Test clock**: now-injection in verify + store (no sleeps — deterministic).
|
||||
5. **Backward compat at P4**: existing marketplace admin tests create postings for seeded employers — seed catalog + grant manual unlimited to test employers OR mark route change explicit in fixtures (decided: tests grant unlimited to legacy seeded employers — honest, explicit).
|
||||
6. **Pins churn**: pins.test.ts grows — keep the G-108 discipline (regression/boundary pins only, no local-grep copy pins).
|
||||
1. **scrypt CPU on concurrent auth** (R-55, ~50 ms/hash single-threaded; a login burst stacks) — bounded by both throttle windows; budget recorded in the passwords.py docstring; acceptable at pilot.
|
||||
2. **CORS Authorization omission kills every cross-origin employer fetch** (E87 class, R-60/open-risk-2) — the main.py edit + the test_cors.py pin land in P1 Task 1-2-03, the same wave as the routes, never after.
|
||||
3. **422 password echo** (R-56/open-risk-3) — pii_fields fix + pin land in P1 with the register route; auditor re-verifies at the P1 review.
|
||||
4. **sessionStorage token exposure** (D-092 tradeoff, open-risk-4) — tab-scoped by design, exposure documented in surface copy; tokens stored hashed server-side (DB leak yields nothing); logout revokes the row.
|
||||
5. **Employer mock-complete cross-session completion** (G-301 hole class, open-risk-5) — own-session check (403 cross-session) + mock-only mount + session gate, all pinned in P5.
|
||||
6. **example.com seed websites make claim evidence match=false corpus-wide** (R-65/open-risk-6) — the queue copy presents the signal as advisory; the operator decision activates; auto-approval stays deferred (A-304).
|
||||
7. **D-091 extraction drift** (the 402 dict's hardcoded zeros are easy to "improve") — the payload-parity test + unchanged v0.9 entitlement suite are the no-drift proof; lead-developer reviews at P4.
|
||||
8. **Throttle module-state leaks across tests** (R-63) — both limiters get reset fixtures from day one (the _reset_apply_rate_window pattern).
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
1. **Pricing page** — real catalog data at runtime (packages + plans with cents-true prices), honest loading/error states, contact-path CTAs (operator-mediated checkout note).
|
||||
2. **Admin billing surface** — per-employer entitlement panel (package, posts used/limit, expiry, subscription badge) in the moderation view; operator checkout trigger.
|
||||
3. **Public API** — GET /v1/billing/catalog (advertises the catalog); POST /v1/billing/webhook (provider-facing).
|
||||
4. **Operator API** — /v1/billing/admin/* (checkout, entitlements, lifecycle, grants, event log) behind the admin token.
|
||||
5. **Release notes** — v0.8.x line ships the billing surface through the existing automated lane (no admin-box changes required).
|
||||
1. **/employer/login + /employer/register** — greenfield auth pages with honest 401/409/429/422 states; register → logged-in dashboard; sessions tab-scoped via `nc-employer-session`.
|
||||
2. **Employer dashboard — postings page** — the employer's OWN postings lifecycle (draft → published → closed) with real applicant counts, create/edit/publish/close actions, honest 402 upgrade CTA + 403 claim prompt.
|
||||
3. **Employer dashboard — overview** — real active-postings count + per-posting applicant counts + claimed-entity profile; talent-match/placement metrics honestly mock-labeled; claim-pending state visible.
|
||||
4. **Talent search + candidate profile** — remain the v0.1 mock, honestly labeled (no candidate backend exists — D-092).
|
||||
5. **Pricing page** — CTAs convert to the employer-login path (self-serve checkout is real); enterprise tier keeps Contact Sales.
|
||||
6. **Admin moderation page — network overview** — employer accounts list, claim queue with advisory evidence + approve/reject, audit view; 403 → honest locked state.
|
||||
7. **API surfaces** — /v1/employers/* (session-gated: auth, claims, postings, billing) + /v1/employers/admin/* (operator-gated) + the existing /v1/billing/* reused verbatim.
|
||||
8. **Release notes** — the v0.9.x line ships the employer network through the existing automated lane (no admin-box changes required).
|
||||
|
||||
## Happy Path (end-to-end, written BEFORE execute)
|
||||
|
||||
1. **Operator buys for an employer (mock pilot):** admin token → POST /v1/billing/admin/checkout {item: bundle, employer: E1} → session created → mock complete → webhook (signed, deduped) → purchase row: 10 posts, $3.99 snapshot → E1's admin panel shows 10/10 posts available.
|
||||
2. **Employer posts jobs:** create_posting E1 → 201, posts_used 1..N; at 10/10 the 11th → 402 with upgrade_cta → operator buys enterprise grant → unlimited.
|
||||
3. **Subscription month rolls:** pro plan renews → invoice.paid webhook → expires_at extended exactly once (double-delivery safe) → talent-access stays active.
|
||||
4. **Payment fails:** card declines → invoice.payment_failed → past_due → 7-day grace → still unpaid → expired (honest downgrade) — OR recovery payment inside grace → active again.
|
||||
5. **Stripe switch (env-only):** AI_PAYMENT_PROVIDER=stripe + keys → same routes, real hosted checkout, real webhooks — zero code changes.
|
||||
6. **Visitor checks pricing:** /marketplace/pricing loads → catalog fetched → prices + tiers match the DB exactly.
|
||||
1. **Employer registers:** /employer/register → account created (email unverified, labeled as such) → logged in → dashboard shell with the claim prompt.
|
||||
2. **Employer claims their entity:** picks their EmployerRow from the marketplace corpus → claim pending, evidence recorded → dashboard shows the honest claim-pending state.
|
||||
3. **Operator approves:** admin moderation page → claim queue shows the evidence + domain-match signal (advisory) → approve → account bound to the entity (one owner) → audit row written.
|
||||
4. **Employer tries to post without a package:** create draft → 402 with upgrade CTA (reason: no_active_package) → nothing consumed, nothing created.
|
||||
5. **Employer buys a package (pilot mock):** dashboard billing surface → own checkout over the catalog → in-app mock-complete (honestly labeled) → REAL webhook path → purchase granted → posts available in the entitlement view.
|
||||
6. **Employer publishes:** create draft (posts_used+1) → publish → posting live on the public job board; applicant counts accrue on the overview; the 11th post on a 10-pack → 402 upgrade CTA again.
|
||||
7. **Operator oversight throughout:** the network overview shows the account, claimed entity, and entitlement state; the audit trail answers every claim/grant/revocation decision.
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
1. No dishonest buttons: every CTA states the real pilot path (operator-mediated); no fake "buy now" without a real flow behind it.
|
||||
2. Prices are always cents-true and consistent: pricing page = catalog API = purchase snapshots; no drift.
|
||||
3. Failures are loud and actionable: 402 payloads carry upgrade_cta; webhook failures fail closed (401) rather than processing unsigned; past_due is visible in the admin panel before downgrade.
|
||||
4. The admin never needs to guess: entitlement panel answers "can this employer post?" in one glance; the event log answers "what happened to this subscription?" with full provenance.
|
||||
5. Existing surfaces unchanged: job board, defense, sandboxes, deploy — billing is additive; any regression there is a P0.
|
||||
|
||||
---
|
||||
|
||||
# v0.8 PLAN (Ship Anywhere — prior milestone, retained below)
|
||||
|
||||
## 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
|
||||
|
||||
**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) |
|
||||
|
||||
**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).
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to the four channels + guides.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Release-Consistency Rails (REQ-8-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.
|
||||
|
||||
### Wave 1-1: Runner fleet bring-up on CT 113 (devops-engineer)
|
||||
|
||||
- **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`).
|
||||
|
||||
### Wave 1-2: Workflows + secrets + hello-world (devops-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).
|
||||
|
||||
### Wave 1-3: P1 verification (lead-developer + security-auditor)
|
||||
|
||||
- **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).
|
||||
|
||||
**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: 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.
|
||||
|
||||
## 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).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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. **401 → honest logged-out state:** SessionRequiredError renders the logged-out state with a login CTA — never a retry loop, never a silent redirect.
|
||||
3. **402 upgrade CTA visible with correct limits:** the payload's reason drives the copy (no package vs limit reached); the CTA routes to the employer billing surface; limits shown are the store's truth. (G-320b: the 402 payload's posts_used/posts_limit are byte-stable zeros, never rendered — real limits render from the entitlement view only.)
|
||||
4. **Claim-pending state visible to the employer:** the dashboard says "claim submitted, awaiting review" until the operator decides — never presented as verified before approval.
|
||||
5. **Admin sees the claim queue with evidence:** the domain-match signal renders as advisory (match=false is the corpus norm); approve/reject are explicit operator actions with an audit trail.
|
||||
6. **Talent pages remain mock-labeled:** the honest-surface rule cuts both ways — never fake-convert, never leave a convertible surface on mock; talent stays mock because no backend computes it.
|
||||
+32
-4
@@ -16,13 +16,40 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
---
|
||||
|
||||
## Milestone v0.9 — Payment Engine (ACTIVE, Feature, started 2026-09-29)
|
||||
## Milestone v0.10 — B2B Employer Network (ACTIVE, Feature, started 2026-09-30)
|
||||
|
||||
**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 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).
|
||||
**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:** real employer auth/accounts (B2B employer network — deferred, lands post-payments per A-407), multi-currency, tax/VAT handling beyond passthrough, invoice PDF generation, Stripe-specific UI components, CIRR tracking (REQ-F-020, deferred).
|
||||
**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 (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).
|
||||
**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)
|
||||
|
||||
@@ -305,6 +332,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/* |
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Nextcraft — REQUIREMENTS.md
|
||||
|
||||
## v0.10 Requirements (Active — B2B Employer Network, Feature; started 2026-09-30)
|
||||
|
||||
**Milestone type: Feature** — real employer auth/accounts + employer self-serve (the B2B employer network; activated per D-085). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`. 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 | active |
|
||||
|
||||
### 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 | active |
|
||||
|
||||
### 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 | active |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
+411
-1
@@ -1,4 +1,414 @@
|
||||
# Nextcraft — RESEARCH.md (v0.9 Phase 0 RESEARCH — Payment Engine)
|
||||
# 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).
|
||||
|
||||
+18
-2
@@ -2,7 +2,9 @@
|
||||
|
||||
## 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.10 — B2B Employer Network (ACTIVE, started 2026-09-30).** 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`.
|
||||
|
||||
@@ -34,7 +36,21 @@ Payment processing and subscription management (REQ-F-018 activated per D-074, a
|
||||
|
||||
## Phase List
|
||||
|
||||
### v0.9 (complete)
|
||||
### v0.10 (active)
|
||||
|
||||
| # | 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 | pending | 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 |
|
||||
| 4 | Self-serve posting management | pending | 3 | REQ-10-004 | Employer-principal posting routes with own-entity authorization; entitlement enforcement parameterized by session employer (402 CTA unchanged); admin routes retained |
|
||||
| 5 | Self-serve checkout + admin overview | pending | 4 | REQ-10-005 | Employer-principal checkout over the v0.9 catalog; own entitlement view; pricing-page CTAs → employer-login path; admin network overview (accounts, claim queue, entitlement state) |
|
||||
| 6 | Final review + ship | pending | 1-5 | — | Review clean; audit pass; milestone tagged v0.9.6 (final v0.9.x patch = milestone release); release with full asset matrix + registry image; milestone merged to main |
|
||||
|
||||
---
|
||||
|
||||
## Phase List (v0.9 — complete)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
"projects": [],
|
||||
"active_project": null,
|
||||
"milestone": {
|
||||
"version": "v0.9",
|
||||
"name": "payment-engine",
|
||||
"version": "v0.10",
|
||||
"name": "b2b-employer-network",
|
||||
"type": "feature",
|
||||
"branch": "milestone/v0.9-payment-engine"
|
||||
"branch": "milestone/v0.10-b2b-employer-network"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'.
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -94,6 +95,12 @@ def get_billing_store(request: Request) -> BillingStore:
|
||||
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_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."""
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""/v1/employers — employer session + claim surface (REQ-10-001/002, D-087..D-097).
|
||||
|
||||
The employer principal (D-090): `require_employer_session` is the
|
||||
require_admin extract→verify→return shape (R-56) with a SINGLE
|
||||
header — `Authorization: Bearer <token>` via partition + scheme
|
||||
check. sha256(token) is looked up against `employer_sessions`
|
||||
(only the hash is ever stored, D-087); missing/expired/unknown →
|
||||
**401** (credential failure — the token namespaces cannot collide
|
||||
with admin tokens: this gate hash-looks-up, admin compares bytes);
|
||||
**403 is reserved** for cross-entity authorization (a valid session
|
||||
touching another employer's resource — P4).
|
||||
|
||||
D-097: employer routes NEVER compose the learner gates
|
||||
(require_verified_age / allowlist) — a different principal class.
|
||||
The account's email is UNVERIFIED and /me displays it as such
|
||||
(email_verified: Literal[False], no email verification exists).
|
||||
|
||||
Claims (P2, D-089): POST /v1/employers/claims targets an EXISTING
|
||||
marketplace EmployerRow (404 unknown); one PENDING claim per entity
|
||||
(409, the G-316 partial index backstops the race); an account that
|
||||
already holds an entity → 409 honest (one account : one entity);
|
||||
evidence is computed AT CLAIM TIME (claim_evidence.py, R-65) and
|
||||
stored as provenance JSON on the claim row. Auto-approval is
|
||||
DEFERRED (D-089: without email verification the match is forgeable;
|
||||
A-304: never present unverified as verified) — the operator decision
|
||||
activates. The marketplace store composes via app.state DI
|
||||
(get_marketplace_store — the territory rule), never a direct import.
|
||||
|
||||
Tokens: `secrets.token_urlsafe(32)` — 256-bit CSPRNG, handed to the
|
||||
client EXACTLY ONCE on login; only sha256(token) persists. TTL is
|
||||
lazy (G-306): an expired row reads as absent, no timer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
|
||||
from ..config import Settings
|
||||
from ..employers.base import (
|
||||
ClaimEmployerRequest,
|
||||
EmployerAccount,
|
||||
EmployerClaim,
|
||||
EmployerMe,
|
||||
LoginEmployerRequest,
|
||||
OwnPostingsFacets,
|
||||
OwnPostingsResponse,
|
||||
OwnPostingSummary,
|
||||
RegisterEmployerRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from ..employers.claim_evidence import compute_claim_evidence
|
||||
from ..employers.passwords import hash_password, verify_password
|
||||
from ..employers.store import AccountRow, ClaimRow, EmployerStore, SessionRow
|
||||
from .deps import get_employer_store, get_marketplace_store, get_settings
|
||||
|
||||
router = APIRouter(prefix="/v1/employers/auth", tags=["employers-auth"])
|
||||
|
||||
#: Claims router (P2, D-089): sibling prefix under /v1/employers —
|
||||
#: the claim route is session-gated, not auth-prefixed. P3 (Task
|
||||
#: 3-1-01): the own-postings read route rides the SAME sibling
|
||||
#: prefix — session-gated, not auth-prefixed.
|
||||
claims_router = APIRouter(prefix="/v1/employers", tags=["employers-claims"])
|
||||
|
||||
#: Per-EMAIL failed-login window (D-088): rolling 15 min (900 s —
|
||||
#: parameterized; identity hardcodes 60.0), monotonic timestamps,
|
||||
#: failures-only append, SUCCESS CLEARS the email's window. Keyed by
|
||||
#: the SUBMITTED EMAIL, never client IP (behind HAProxy the client
|
||||
#: host is the proxy — R-63). In-memory, process-local (D-019).
|
||||
LOGIN_FAILURE_WINDOW_S = 900.0
|
||||
_LOGIN_FAILURES: dict[str, list[float]] = {}
|
||||
|
||||
#: Global per-min cap on login+register attempts (D-088): module
|
||||
#: deque, popleft older-than-60s, `len >= cap` -> 429. G-314: the
|
||||
#: deque appends EVERY well-formed attempt — FAILED PASSWORDS COUNT
|
||||
#: (only 422-invalid bodies and the cap's own 429s are exempt, or
|
||||
#: the credential-stuffing bound is void). In-memory, process-local.
|
||||
_AUTH_ATTEMPTS: deque[float] = deque()
|
||||
|
||||
|
||||
def _check_auth_attempt_rate(settings: Settings) -> None:
|
||||
"""Global cap — the _check_global_create_rate shape (R-63):
|
||||
while-popleft older-than-60s, 429 at the knob, append only on
|
||||
admission (the cap's own 429 never consumes budget)."""
|
||||
now = time.monotonic()
|
||||
while _AUTH_ATTEMPTS and now - _AUTH_ATTEMPTS[0] > 60.0:
|
||||
_AUTH_ATTEMPTS.popleft()
|
||||
if len(_AUTH_ATTEMPTS) >= settings.employer_auth_attempts_per_min:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="employer auth attempt rate exceeded — wait a minute",
|
||||
)
|
||||
_AUTH_ATTEMPTS.append(now)
|
||||
|
||||
|
||||
def _check_login_failures(email: str, settings: Settings) -> None:
|
||||
"""Per-email failed-login window — the _SubmitRateLimiter shape
|
||||
(R-63): slice-trim on monotonic timestamps, 429 at the knob with
|
||||
the honest "wait" detail. Failures only APPEND here (the caller
|
||||
appends after a failed verify); a successful login CLEARS the
|
||||
window (never locks a healthy user out over old failures)."""
|
||||
now = time.monotonic()
|
||||
window = _LOGIN_FAILURES.setdefault(email, [])
|
||||
window[:] = [
|
||||
t for t in window if now - t < LOGIN_FAILURE_WINDOW_S
|
||||
]
|
||||
if len(window) >= settings.employer_login_failures_per_window:
|
||||
wait = int(LOGIN_FAILURE_WINDOW_S / 60) # honest wait, minutes
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=(
|
||||
f"too many failed logins for this email — wait ~{wait} "
|
||||
"minutes before trying again"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _record_login_failure(email: str) -> None:
|
||||
"""Failure-append (G-320a order: only AFTER the verify fails)."""
|
||||
_LOGIN_FAILURES.setdefault(email, []).append(time.monotonic())
|
||||
|
||||
|
||||
def _clear_login_failures(email: str) -> None:
|
||||
"""Successful login CLEARS the email's window (D-088)."""
|
||||
_LOGIN_FAILURES.pop(email, None)
|
||||
|
||||
|
||||
# -- session gate (D-090, R-56) ---------------------------------------------------
|
||||
|
||||
|
||||
def _extract_bearer_token(
|
||||
authorization: Annotated[str | None, Header(include_in_schema=False)] = None,
|
||||
) -> str | None:
|
||||
"""Single-header extractor: `Authorization: Bearer <token>`; None when
|
||||
the header is absent or carries no bearer candidate."""
|
||||
if not authorization:
|
||||
return None
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.strip().lower() == "bearer" and token.strip():
|
||||
return token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def require_employer_session(
|
||||
request_token: Annotated[str | None, Depends(_extract_bearer_token)],
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
) -> tuple[SessionRow, AccountRow]:
|
||||
"""Session gate (D-090): sha256(token) hash-lookup — 401 on
|
||||
missing/unknown/expired tokens (credential failure; TTL evaluated
|
||||
lazily at read, G-306). 403 is RESERVED for cross-entity
|
||||
authorization, never raised here. Returns (session, account)."""
|
||||
if request_token is None:
|
||||
raise HTTPException(status_code=401, detail="employer session required")
|
||||
token_hash = hashlib.sha256(request_token.encode("utf-8")).hexdigest()
|
||||
session = store.get_session_by_token_hash(token_hash)
|
||||
if session is None:
|
||||
# Unknown OR expired — one honest 401, no enumeration difference.
|
||||
raise HTTPException(status_code=401, detail="invalid or expired session")
|
||||
account = store.get_account(session.account_id)
|
||||
if account is None: # pragma: no cover - FK discipline keeps rows paired
|
||||
raise HTTPException(status_code=401, detail="invalid or expired session")
|
||||
return session, account
|
||||
|
||||
|
||||
def _register_audit(
|
||||
store: EmployerStore, name: str, account_id: str | None = None
|
||||
) -> None:
|
||||
"""Auth audit append (the BillingEventRow shape). Never fails the
|
||||
parent route — the auth event already landed."""
|
||||
store.append_audit_event(
|
||||
"auth", name, account_id=account_id, detail={"route": name}
|
||||
)
|
||||
|
||||
|
||||
# -- routes ------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/register", status_code=201, response_model=None)
|
||||
async def register_employer(
|
||||
body: RegisterEmployerRequest,
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> TokenResponse:
|
||||
"""Register: global cap -> scrypt hash -> insert_account; duplicate
|
||||
email → 409 honest. The account is UNVERIFIED (D-097). Registration
|
||||
logs in the new account (a fresh session rides the 201)."""
|
||||
_check_auth_attempt_rate(settings)
|
||||
_, created = store.insert_account(
|
||||
email=body.email, password_hash=hash_password(body.password)
|
||||
)
|
||||
if not created:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="an account with this email already exists",
|
||||
)
|
||||
account = store.get_account_by_email(body.email)
|
||||
# check-first just created it (the UNIQUE backstop would surface
|
||||
# IntegrityError otherwise — never swallowed by the store).
|
||||
assert account is not None
|
||||
_register_audit(store, "register", account_id=account.id)
|
||||
token = secrets.token_urlsafe(32)
|
||||
session = store.mint_session(account_id=account.id, token=token)
|
||||
assert session.expires_at is not None # mint always sets it
|
||||
return TokenResponse(token=token, expires_at=session.expires_at)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login_employer(
|
||||
body: LoginEmployerRequest,
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> TokenResponse:
|
||||
"""Login: bad credentials → 401 honest with no user-enumeration
|
||||
difference (one 401 body for unknown-email and wrong-password
|
||||
alike).
|
||||
|
||||
G-320a route order: body-validation (pydantic, before the handler)
|
||||
→ global cap → per-email failed-login window → scrypt verify →
|
||||
failure-append. A 429'd attempt performs ZERO KDF derivations —
|
||||
the throttle bounds the open-risk-1 burst before any CPU burns."""
|
||||
_check_auth_attempt_rate(settings)
|
||||
_check_login_failures(body.email, settings)
|
||||
account = store.get_account_by_email(body.email)
|
||||
if account is None or not verify_password(account.password_hash, body.password):
|
||||
_record_login_failure(body.email)
|
||||
# The SAME 401 for unknown-email and wrong-password — the
|
||||
# response carries no account-existence signal (D-090).
|
||||
raise HTTPException(status_code=401, detail="invalid credentials")
|
||||
_clear_login_failures(body.email)
|
||||
token = secrets.token_urlsafe(32)
|
||||
session = store.mint_session(account_id=account.id, token=token)
|
||||
assert session.expires_at is not None # mint always sets it
|
||||
return TokenResponse(token=token, expires_at=session.expires_at)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=None)
|
||||
async def logout_employer(
|
||||
request_token: Annotated[str | None, Depends(_extract_bearer_token)],
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
) -> dict:
|
||||
"""Logout: DELETE the session row + an audit event. Unknown or
|
||||
already-revoked token → 401 (never a silent 200 — the caller
|
||||
learns their token is not live)."""
|
||||
if request_token is None:
|
||||
raise HTTPException(status_code=401, detail="employer session required")
|
||||
token_hash = hashlib.sha256(request_token.encode("utf-8")).hexdigest()
|
||||
session = store.get_session_by_token_hash(token_hash)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="invalid or expired session")
|
||||
store.revoke_session(token_hash)
|
||||
_register_audit(store, "logout", account_id=session.account_id)
|
||||
return {"revoked": True}
|
||||
|
||||
|
||||
@router.get("/me", response_model=EmployerMe)
|
||||
async def employer_me(
|
||||
auth: Annotated[
|
||||
tuple[SessionRow, AccountRow], Depends(require_employer_session)
|
||||
],
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
) -> EmployerMe:
|
||||
"""The session's account (email UNVERIFIED, displayed as such —
|
||||
D-097/Literal[False]) + the claimed employer_id + the claim state
|
||||
(both None until P2's claim flow)."""
|
||||
_session, account = auth
|
||||
return EmployerMe(
|
||||
account=EmployerAccount(
|
||||
id=account.id,
|
||||
email=account.email,
|
||||
employer_id=account.employer_id,
|
||||
created_at=account.created_at, # type: ignore[arg-type]
|
||||
email_verified=False,
|
||||
),
|
||||
claimed_employer_id=account.employer_id,
|
||||
claim=None, # P2's claim flow fills this (D-089)
|
||||
)
|
||||
|
||||
|
||||
# -- claims (P2, D-089 — Task 2-1-03) ---------------------------------------------
|
||||
|
||||
|
||||
def _claim_to_contract(row: ClaimRow) -> EmployerClaim:
|
||||
"""Store row → the 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,
|
||||
)
|
||||
|
||||
|
||||
@claims_router.post(
|
||||
"/claims", status_code=201, response_model=None
|
||||
)
|
||||
async def claim_employer_entity(
|
||||
body: ClaimEmployerRequest,
|
||||
auth: Annotated[
|
||||
tuple[SessionRow, AccountRow], Depends(require_employer_session)
|
||||
],
|
||||
store: Annotated[EmployerStore, Depends(get_employer_store)],
|
||||
marketplace_store=Depends(get_marketplace_store),
|
||||
) -> EmployerClaim:
|
||||
"""Claim an existing marketplace EmployerRow (D-089): session-gated;
|
||||
404 unknown entity (claims target the v0.6 ingested EmployerRow
|
||||
data — resolved via the marketplace store's app.state DI, never a
|
||||
direct import); 409 when the account already holds an entity (one
|
||||
account : one entity) or a claim is already PENDING on the entity
|
||||
(G-316's partial index backstops the race — the IntegrityError
|
||||
maps to the SAME honest 409). Evidence is computed AT CLAIM TIME
|
||||
from the account email + the entity website (R-65) and stored as
|
||||
provenance JSON on the claim row; the response carries the pending
|
||||
claim with its evidence. Auto-approval is DEFERRED — the operator
|
||||
decision activates (D-089; A-304: never present unverified as
|
||||
verified)."""
|
||||
_session, account = auth
|
||||
employer = marketplace_store.get_employer(body.employer_id)
|
||||
if employer is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"no employer with id {body.employer_id!r}",
|
||||
)
|
||||
if account.employer_id is not None:
|
||||
# One account : one entity — the honest 409 (the account
|
||||
# already holds an entity; approving a second claim would be
|
||||
# refused by the store's UNIQUE constraint anyway).
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="account already holds an employer entity",
|
||||
)
|
||||
evidence = compute_claim_evidence(account.email, employer.website)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
try:
|
||||
row, created = store.insert_claim(
|
||||
account_id=account.id,
|
||||
employer_id=body.employer_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
except IntegrityError:
|
||||
# G-316: the raced insert refused by the partial UNIQUE index
|
||||
# — the SAME honest 409 as the check-first fast path.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="a claim is already pending for this employer",
|
||||
) from None
|
||||
if not created:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="a claim is already pending for this employer",
|
||||
)
|
||||
store.append_audit_event(
|
||||
"claim",
|
||||
"claim.submitted",
|
||||
account_id=account.id,
|
||||
employer_id=body.employer_id,
|
||||
detail={
|
||||
"claim_id": row.id,
|
||||
"evidence_match": evidence["match"],
|
||||
},
|
||||
)
|
||||
return _claim_to_contract(row)
|
||||
|
||||
|
||||
# -- own postings (P3, Task 3-1-01 — REQ-10-003, D-092/G-319) ----------------------
|
||||
|
||||
|
||||
@claims_router.get("/postings", response_model=OwnPostingsResponse)
|
||||
async def list_own_postings(
|
||||
auth: Annotated[
|
||||
tuple[SessionRow, AccountRow], Depends(require_employer_session)
|
||||
],
|
||||
marketplace_store=Depends(get_marketplace_store),
|
||||
) -> OwnPostingsResponse:
|
||||
"""GET /v1/employers/postings — the claimed employer's OWN postings
|
||||
(D-092's own-postings lifecycle view: draft → published → closed +
|
||||
real applicant counts). Session-gated (401 missing/unknown/expired
|
||||
token); UNCLAIMED account → **403 + claim-CTA detail** — the D-090
|
||||
semantics: 401 is credential failure, 403 is the authorization class
|
||||
(a valid session whose account holds no claimed entity is not
|
||||
authorized to any employer's postings).
|
||||
|
||||
Postings resolve from the marketplace store via app.state DI
|
||||
(get_marketplace_store — the territory rule), filtered to the
|
||||
session's employer_id; per-posting applicant_count rides ONE
|
||||
batched count_applications_by_posting read (never a per-posting
|
||||
N+1). G-319 PII boundary (binding): applicant COUNTS ONLY —
|
||||
learner identifiers never ride any /v1/employers/* response."""
|
||||
_session, account = auth
|
||||
if account.employer_id is None:
|
||||
# Unclaimed account — the honest claim-CTA 403 (D-090).
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"reason": "no_claimed_employer",
|
||||
"claim_cta": (
|
||||
"This account has not claimed an employer entity yet "
|
||||
"— submit a claim via POST /v1/employers/claims."
|
||||
),
|
||||
},
|
||||
)
|
||||
postings = marketplace_store.list_postings_by_employer(account.employer_id)
|
||||
counts = marketplace_store.count_applications_by_posting(
|
||||
[p.id for p in postings]
|
||||
)
|
||||
# Honest facet counts over every status the lifecycle defines —
|
||||
# each key rides the response even at 0.
|
||||
status_counts: dict[str, int] = dict.fromkeys(
|
||||
("draft", "published", "closed"), 0
|
||||
)
|
||||
items: list[OwnPostingSummary] = []
|
||||
for posting in postings:
|
||||
status_counts[posting.status] += 1
|
||||
items.append(
|
||||
OwnPostingSummary(
|
||||
**posting.model_dump(),
|
||||
applicant_count=counts.get(posting.id, 0),
|
||||
)
|
||||
)
|
||||
return OwnPostingsResponse(
|
||||
postings=items,
|
||||
facets=OwnPostingsFacets(**status_counts),
|
||||
total=len(items),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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,
|
||||
LoginEmployerRequest,
|
||||
OwnPostingsFacets,
|
||||
OwnPostingsResponse,
|
||||
OwnPostingSummary,
|
||||
RegisterEmployerRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CLAIM_STATUSES",
|
||||
"ClaimEmployerRequest",
|
||||
"ClaimStatus",
|
||||
"EmployerAccount",
|
||||
"EmployerClaim",
|
||||
"EmployerMe",
|
||||
"LoginEmployerRequest",
|
||||
"OWN_POSTING_STATUSES",
|
||||
"OwnPostingSummary",
|
||||
"OwnPostingsFacets",
|
||||
"OwnPostingsResponse",
|
||||
"RegisterEmployerRequest",
|
||||
"TokenResponse",
|
||||
]
|
||||
@@ -0,0 +1,232 @@
|
||||
"""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
|
||||
@@ -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,710 @@
|
||||
"""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).
|
||||
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual")
|
||||
|
||||
|
||||
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 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 close(self) -> None:
|
||||
self._engine.dispose()
|
||||
@@ -189,6 +189,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
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
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -323,6 +333,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
defense_store.close()
|
||||
identity_store.close()
|
||||
app.state.billing_store.close()
|
||||
app.state.employer_store.close()
|
||||
await app.state.http_client.aclose()
|
||||
|
||||
app = FastAPI(title="Nextcraft AI Service", version=__version__, lifespan=lifespan)
|
||||
@@ -334,11 +345,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,
|
||||
)
|
||||
|
||||
@@ -381,6 +397,19 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
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.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.
|
||||
@@ -398,26 +427,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})
|
||||
|
||||
|
||||
@@ -352,6 +352,26 @@ class MarketplaceStore(Protocol):
|
||||
"""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)."""
|
||||
@@ -892,6 +912,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(
|
||||
|
||||
@@ -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 @@
|
||||
"""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,280 @@
|
||||
"""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 # P2 fills this
|
||||
assert body["claim"] is None
|
||||
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
|
||||
|
||||
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
|
||||
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 (2026-09-30) 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 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,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,310 @@
|
||||
"""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
|
||||
imports the DEP only, never the store module."""
|
||||
import inspect
|
||||
|
||||
from ai_service.api import employers as employers_api
|
||||
|
||||
source = inspect.getsource(employers_api)
|
||||
assert "from ..marketplace.store import" 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,403 @@
|
||||
"""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 imports
|
||||
the DEP only, never the store module."""
|
||||
import inspect
|
||||
|
||||
from ai_service.api import employers as employers_api
|
||||
|
||||
source = inspect.getsource(employers_api)
|
||||
assert "from ..marketplace.store import" not in source
|
||||
assert "get_marketplace_store" in source
|
||||
|
||||
def test_route_registered_in_openapi(self, tmp_path) -> None:
|
||||
"""The GET 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)."""
|
||||
with make_client(tmp_path) as client:
|
||||
schema = client.get("/openapi.json").json()
|
||||
path = schema["paths"]["/v1/employers/postings"]
|
||||
assert set(path) == {"get"}
|
||||
|
||||
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,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
|
||||
@@ -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,12 @@
|
||||
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, 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; the write actions land in P4). The A-406 pattern.
|
||||
*/
|
||||
export default function PostingsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -11,12 +16,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 your postings across their lifecycle — draft, published, closed —
|
||||
with real applicant counts. Creating, editing, publishing, and closing
|
||||
postings arrives in an upcoming release.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
'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 } from '@nextcraft/types';
|
||||
import {
|
||||
getEmployerMe,
|
||||
listOwnEmployerPostings,
|
||||
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 [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] = await Promise.all([
|
||||
listOwnEmployerPostings(controller.signal),
|
||||
getEmployer(meBody.claimed_employer_id, controller.signal).catch(() => null),
|
||||
]);
|
||||
setPostings(own);
|
||||
setProfile(prof);
|
||||
}
|
||||
} 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;
|
||||
|
||||
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 REAL (GET /v1/employers/
|
||||
postings); talent-match/placement stay mock-labeled (D-092). */}
|
||||
<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" />
|
||||
<MockMetricCard label="Talent Matches" value="—" icon={Target} tone="violet" />
|
||||
<MockMetricCard label="Placement Rate" value="—" icon={TrendingUp} tone="amber" />
|
||||
</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>
|
||||
|
||||
{/* 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. */
|
||||
function MockAnalyticsNotice() {
|
||||
return (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Talent-match and placement analytics are sample data at pilot — no backend
|
||||
computes them yet. Real posting and applicant 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,298 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import type { EmployerMe, OwnPostingsResponse, OwnPostingSummary } from '@nextcraft/types';
|
||||
import { formatSalaryRange, relativeTime, seniorityLabel } from '../../lib/format';
|
||||
import {
|
||||
getEmployerMe,
|
||||
listOwnEmployerPostings,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Own-postings lifecycle view (REQ-10-003, 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. Read-only this phase: the
|
||||
* create/edit/publish/close write actions land in P4 (the button
|
||||
* says so honestly). 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);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(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();
|
||||
}, []);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string>('');
|
||||
const selected: OwnPostingSummary | null =
|
||||
postings?.postings.find((p) => p.id === selectedId) ?? postings?.postings[0] ?? null;
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* Read-only this phase — the write actions land in P4
|
||||
(REQ-10-004); the button is honest about that. */}
|
||||
<Button size="sm" variant="primary" type="button" disabled title="Posting management arrives in an upcoming release">
|
||||
New
|
||||
</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 (read-only this phase) */}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
function PostingDetail({ posting }: { posting: OwnPostingSummary }) {
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{/* P4 lands the write actions (create/edit/publish/close);
|
||||
this phase is the read lifecycle — the copy says so. */}
|
||||
<Button variant="primary" size="md" type="button" disabled title="Editing arrives in an upcoming release">
|
||||
Edit Posting
|
||||
</Button>
|
||||
<Button variant="outline" size="md" type="button" disabled title="Publish/close arrive in an upcoming release">
|
||||
Publish
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -28,6 +28,13 @@ import type {
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
CatalogResponse,
|
||||
EmployerMe,
|
||||
EmployerTokenResponse,
|
||||
EmployerClaim,
|
||||
OwnPostingsResponse,
|
||||
RegisterEmployerRequest,
|
||||
LoginEmployerRequest,
|
||||
ClaimEmployerRequest,
|
||||
} from '@nextcraft/types';
|
||||
|
||||
import { engineBaseUrl } from './engine-base-url';
|
||||
@@ -100,8 +107,38 @@ export class UpgradeRequiredError extends EngineError {
|
||||
}
|
||||
}
|
||||
|
||||
/** v0.10 (REQ-10-003): a 401 from an employer session route — the
|
||||
* session is missing/expired/unknown. The honest logged-out state
|
||||
* consumes this (a login CTA, NEVER a retry loop — R-61: no 401
|
||||
* discrimination existed before the employer surface). */
|
||||
export class SessionRequiredError extends EngineError {
|
||||
constructor(message = 'Sign in to continue — your session is missing or expired.') {
|
||||
super(message, 401);
|
||||
this.name = 'SessionRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
/** A 403 carrying the unclaimed-account claim-CTA (D-090: 403 is the
|
||||
* authorization class — a valid session whose account holds no claimed
|
||||
* entity). The dashboard renders the claim prompt from it. */
|
||||
export class ClaimRequiredError extends EngineError {
|
||||
constructor(
|
||||
public readonly reason: string,
|
||||
public readonly claimCta: string,
|
||||
) {
|
||||
super('Claim your employer entity to continue.', 403);
|
||||
this.name = 'ClaimRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(resp: Response): Promise<EngineError> {
|
||||
if (resp.status === 503) return new EngineBusyError();
|
||||
if (resp.status === 401) {
|
||||
// v0.10: the 401 discrimination arm — an employer-session
|
||||
// credential failure maps to SessionRequiredError so the surface
|
||||
// renders the honest logged-out state (never a retry loop).
|
||||
return new SessionRequiredError();
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
// G-10: discriminate 403 reasons — the payload shape decides.
|
||||
// verify_cta present → identity gate (actionable enrollment prompt);
|
||||
@@ -113,6 +150,13 @@ async function parseError(resp: Response): Promise<EngineError> {
|
||||
/* non-JSON 403 body */
|
||||
}
|
||||
const detail = (payload as { detail?: unknown } | null)?.detail;
|
||||
if (detail && typeof detail === 'object' && 'claim_cta' in (detail as object)) {
|
||||
// v0.10 (D-090): the unclaimed-account 403 — a valid session
|
||||
// whose account holds no claimed entity. The claim-CTA payload
|
||||
// drives the dashboard's claim prompt.
|
||||
const cta = detail as { reason: string; claim_cta: string };
|
||||
return new ClaimRequiredError(cta.reason, cta.claim_cta);
|
||||
}
|
||||
if (detail && typeof detail === 'object' && 'verify_cta' in (detail as object)) {
|
||||
const cta = detail as {
|
||||
reason: string;
|
||||
@@ -784,4 +828,127 @@ export async function getEntitlement(
|
||||
return (await resp.json()) as EntitlementResponse;
|
||||
}
|
||||
|
||||
// -- employer auth + account (v0.10 P3, REQ-10-003, D-092) -------------------------
|
||||
|
||||
/** The nc-employer-session sessionStorage key (the adminToken()
|
||||
* pattern, R-61): tab-scoped BY DESIGN — the session dies with the
|
||||
* tab, which bounds the token's XSS exposure window (open-risk-4,
|
||||
* documented in the dashboard copy). Only the raw token rides here;
|
||||
* the server stores only sha256(token) (D-087). */
|
||||
export const EMPLOYER_SESSION_KEY = 'nc-employer-session';
|
||||
|
||||
/** Window-guarded read (the adminToken() discipline: no window, no
|
||||
* storage — the static-export prerender path). */
|
||||
export function employerSessionToken(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return window.sessionStorage.getItem(EMPLOYER_SESSION_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setEmployerSessionToken(token: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.sessionStorage.setItem(EMPLOYER_SESSION_KEY, token);
|
||||
}
|
||||
|
||||
export function clearEmployerSessionToken(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.sessionStorage.removeItem(EMPLOYER_SESSION_KEY);
|
||||
}
|
||||
|
||||
/** The Authorization header on every employer fetch: single-header
|
||||
* Bearer transport (D-090 — the require_employer_session shape). A
|
||||
* missing token still fetches (the route's honest 401 → the surface's
|
||||
* logged-out state); the token is never logged anywhere. */
|
||||
function employerHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = employerSessionToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** POST /v1/employers/auth/register — email+password → account
|
||||
* (UNVERIFIED, D-097) + a fresh session (registration logs in). */
|
||||
export async function registerEmployer(
|
||||
body: RegisterEmployerRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EmployerTokenResponse> {
|
||||
return jsonFetch<EmployerTokenResponse>('/v1/employers/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /v1/employers/auth/login — bad credentials → SessionRequiredError
|
||||
* (the 401 arm; no account-enumeration difference server-side). */
|
||||
export async function loginEmployer(
|
||||
body: LoginEmployerRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EmployerTokenResponse> {
|
||||
return jsonFetch<EmployerTokenResponse>('/v1/employers/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /v1/employers/auth/logout — revokes the session server-side
|
||||
* AND clears the tab-scoped storage (both arms, always): a revoke
|
||||
* failure on an already-dead token still leaves the caller logged-out
|
||||
* locally, never holding a zombie session. */
|
||||
export async function logoutEmployer(signal?: AbortSignal): Promise<void> {
|
||||
const headers = employerHeaders(); // built BEFORE the clear — the
|
||||
// dying token still authenticates the revoke call.
|
||||
clearEmployerSessionToken();
|
||||
if (!headers['Authorization']) return; // nothing to revoke — storage already clean
|
||||
const resp = await fetch(`${AI_SERVICE_URL}/v1/employers/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
// A 401 here means the token was already dead — the caller is
|
||||
// logged out either way; the honest state is logged-out, not an
|
||||
// error surface. Other failures surface as-is.
|
||||
const err = await parseError(resp);
|
||||
if (!(err instanceof SessionRequiredError)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /v1/employers/auth/me — the session's account + claim state;
|
||||
* missing/expired session → SessionRequiredError (401 arm). */
|
||||
export async function getEmployerMe(signal?: AbortSignal): Promise<EmployerMe> {
|
||||
return jsonFetch<EmployerMe>('/v1/employers/auth/me', {
|
||||
headers: employerHeaders(),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /v1/employers/claims — claim an existing EmployerRow entity;
|
||||
* the response is the pending claim with its claim-time evidence
|
||||
* snapshot (advisory, D-089 — auto-approval is deferred). */
|
||||
export async function claimEmployerEntity(
|
||||
body: ClaimEmployerRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EmployerClaim> {
|
||||
return jsonFetch<EmployerClaim>('/v1/employers/claims', {
|
||||
method: 'POST',
|
||||
headers: employerHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /v1/employers/postings — the claimed employer's OWN postings
|
||||
* (draft|published|closed lifecycle + per-posting applicant counts +
|
||||
* facets). Unclaimed account → ClaimRequiredError (the 403 claim-CTA
|
||||
* discrimination, D-090); expired session → SessionRequiredError. */
|
||||
export async function listOwnEmployerPostings(
|
||||
signal?: AbortSignal,
|
||||
): Promise<OwnPostingsResponse> {
|
||||
return jsonFetch<OwnPostingsResponse>('/v1/employers/postings', {
|
||||
headers: employerHeaders(),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
// parseError gains the 402 discrimination (the dict-detail precedent).
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* v0.10 P3 (REQ-10-003, MH-3e): the /employer/login + /employer/register
|
||||
* submit flows. Per G-312 (the D-059/E87 discipline): fetch-stub tests
|
||||
* over the page-level submit functions the clients call + SOURCE pins
|
||||
* for the honest-state branches — no jsdom/happy-dom render harness
|
||||
* exists in-tree and none may be added this milestone.
|
||||
*/
|
||||
|
||||
const QS = `?t=${Date.now()}-${Math.random()}`;
|
||||
|
||||
const root = new URL("..", import.meta.url);
|
||||
|
||||
function web(path: string): string {
|
||||
return fileURLToPath(new URL(path, root));
|
||||
}
|
||||
|
||||
/** sessionStorage stub (node has none — the wrapper guards on window). */
|
||||
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
|
||||
const store = new Map<string, string>();
|
||||
const globalAny = globalThis as Record<string, unknown>;
|
||||
globalAny.window = {
|
||||
location: { hostname: "localhost" }, // engineBaseUrl reads this under window
|
||||
sessionStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
};
|
||||
return { store, restore() { delete globalAny.window; } };
|
||||
}
|
||||
|
||||
const TOKEN_BODY = { token: "tok-login-1", expires_at: "2026-10-07T00:00:00Z" };
|
||||
|
||||
test("login handoff: a successful login POST stores the session token (register→login handoff arm)", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; method?: string; body?: string }> = [];
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
body: typeof init?.body === "string" ? init.body : undefined,
|
||||
});
|
||||
return new Response(JSON.stringify(TOKEN_BODY), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
const { store, restore } = stubSessionStorage();
|
||||
try {
|
||||
const mod = await import(`../lib/engine-client.ts${QS}`);
|
||||
const session = await mod.loginEmployer(
|
||||
{ email: "ops@example.com", password: "longenough1" },
|
||||
undefined,
|
||||
);
|
||||
mod.setEmployerSessionToken(session.token);
|
||||
assert.equal(store.get("nc-employer-session"), "tok-login-1");
|
||||
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/login");
|
||||
assert.ok(calls[0].body?.includes('"password":"longenough1"'), "the login body carries the credential");
|
||||
} finally {
|
||||
restore();
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("register handoff: register → session token stored → dashboard (the success path lands logged-in)", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify(TOKEN_BODY), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
const { store, restore } = stubSessionStorage();
|
||||
try {
|
||||
const mod = await import(`../lib/engine-client.ts${QS}`);
|
||||
const session = await mod.registerEmployer({
|
||||
email: "new@example.com",
|
||||
password: "longenough1",
|
||||
});
|
||||
mod.setEmployerSessionToken(session.token);
|
||||
assert.equal(store.get("nc-employer-session"), "tok-login-1");
|
||||
} finally {
|
||||
restore();
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("honest states: 401/409/429/422 map to the pages' error copy branches (fetch-stub discrimination)", async () => {
|
||||
const { SessionRequiredError, RateLimitedError, EngineError } = await import(
|
||||
`../lib/engine-client.ts${QS}`
|
||||
);
|
||||
// 401 → the login page's invalid-credentials branch.
|
||||
assert.ok(new SessionRequiredError() instanceof EngineError);
|
||||
assert.equal(new SessionRequiredError().status, 401);
|
||||
// 429 → the throttle-wait branch.
|
||||
assert.ok(new RateLimitedError() instanceof EngineError);
|
||||
assert.equal(new RateLimitedError().status, 429);
|
||||
// 409/422 ride the generic EngineError with their statuses intact —
|
||||
// the pages' copy branches discriminate on err.status.
|
||||
assert.equal(new EngineError("an account with this email already exists", 409).status, 409);
|
||||
assert.equal(new EngineError("invalid", 422).status, 422);
|
||||
});
|
||||
|
||||
test("pin: the login/register pages + clients keep the honest-state + no-password-echo discipline (G-312 source pins)", async () => {
|
||||
const loginPage = readFileSync(web("app/(employer)/employer/login/page.tsx"), "utf8");
|
||||
const registerPage = readFileSync(web("app/(employer)/employer/register/page.tsx"), "utf8");
|
||||
const loginClient = readFileSync(web("components/employer/login-client.tsx"), "utf8");
|
||||
const registerClient = readFileSync(web("components/employer/register-client.tsx"), "utf8");
|
||||
|
||||
// Server shells with metadata (A-411) rendering the clients (A-406).
|
||||
for (const [name, page] of [
|
||||
["login", loginPage],
|
||||
["register", registerPage],
|
||||
] as const) {
|
||||
assert.ok(/export const metadata/.test(page), `${name}: build-time metadata`);
|
||||
assert.ok(!/"use client"/.test(page), `${name}: server shell (client lives in the component)`);
|
||||
assert.ok(/<LoginClient \/>|<RegisterClient \/>/.test(page), `${name}: renders the client`);
|
||||
}
|
||||
assert.ok(/'use client';/.test(loginClient), "login client is a client component");
|
||||
assert.ok(/'use client';/.test(registerClient), "register client is a client component");
|
||||
|
||||
// AbortController + honest loading/error states (A-406).
|
||||
for (const [name, client] of [
|
||||
["login", loginClient],
|
||||
["register", registerClient],
|
||||
] as const) {
|
||||
assert.ok(/AbortController/.test(client), `${name}: AbortController discipline`);
|
||||
assert.ok(/abortRef\.current\?\.abort\(\)/.test(client), `${name}: in-flight abort`);
|
||||
assert.ok(/loading/i.test(client), `${name}: loading state`);
|
||||
assert.ok(/error/i.test(client), `${name}: error state`);
|
||||
assert.ok(/controller\.signal\.aborted\) return;/.test(client), `${name}: aborted responses never render`);
|
||||
}
|
||||
|
||||
// The honest error-copy branches exist, per status, in both clients.
|
||||
assert.ok(/SessionRequiredError/.test(loginClient), "login: 401 branch");
|
||||
assert.ok(/RateLimitedError/.test(loginClient), "login: 429 branch");
|
||||
assert.ok(/err\.status === 422/.test(loginClient), "login: 422 branch");
|
||||
assert.ok(/SessionRequiredError/.test(registerClient), "register: session branch");
|
||||
assert.ok(/RateLimitedError/.test(registerClient), "register: 429 branch");
|
||||
assert.ok(/err\.status === 409/.test(registerClient), "register: 409 duplicate-email branch");
|
||||
assert.ok(/err\.status === 422/.test(registerClient), "register: 422 branch");
|
||||
|
||||
// 422 copy never echoes the password: no template interpolation of
|
||||
// the password into any message + no console logging of it anywhere.
|
||||
for (const [name, client] of [
|
||||
["login", loginClient],
|
||||
["register", registerClient],
|
||||
] as const) {
|
||||
assert.ok(!/\$\{password\}/.test(client), `${name}: no password interpolation`);
|
||||
assert.ok(!/console\./.test(client), `${name}: no console logging`);
|
||||
}
|
||||
|
||||
// Success stores the session + redirects to the dashboard.
|
||||
assert.ok(/setEmployerSessionToken\(session\.token\)/.test(loginClient), "login: stores the session");
|
||||
assert.ok(/setEmployerSessionToken\(session\.token\)/.test(registerClient), "register: stores the session");
|
||||
assert.ok(/router\.push\('\/employer'\)/.test(loginClient), "login: redirects to the dashboard");
|
||||
assert.ok(/router\.push\('\/employer'\)/.test(registerClient), "register: lands logged-in on the dashboard");
|
||||
|
||||
// The register page's copy is honest about UNVERIFIED email (D-097).
|
||||
assert.ok(/unverified/i.test(registerPage), "register copy: email marked unverified");
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* v0.10 P3 (REQ-10-003, MH-3c/3-3d — Task 3-3-02): the dashboard
|
||||
* conversion pins. The postings/overview pages are grep-clean of the
|
||||
* removed mock imports; the talent pages' mock imports are RETAINED
|
||||
* (D-092 honest out-of-scope); the session-gated clients carry the
|
||||
* honest-state branches (logged-out/claim-CTA/claim-pending/loading/
|
||||
* error) in source — the G-312 source-pin discipline (no render
|
||||
* harness in-tree, none may be added).
|
||||
*/
|
||||
|
||||
const root = new URL("..", import.meta.url);
|
||||
|
||||
function web(path: string): string {
|
||||
return fileURLToPath(new URL(path, root));
|
||||
}
|
||||
|
||||
test("pin: postings + overview pages are grep-clean of the removed mock imports (MH-3c)", async () => {
|
||||
const overview = readFileSync(web("app/(employer)/employer/page.tsx"), "utf8");
|
||||
const postings = readFileSync(web("app/(employer)/employer/postings/page.tsx"), "utf8");
|
||||
for (const [name, page] of [
|
||||
["overview", overview],
|
||||
["postings", postings],
|
||||
] as const) {
|
||||
assert.ok(!/@nextcraft\/mock-data/.test(page), `${name}: no mock-data import`);
|
||||
assert.ok(!/\bjobs\b/.test(page), `${name}: no jobs mock`);
|
||||
assert.ok(!/\bcandidates\b/.test(page), `${name}: no candidates mock`);
|
||||
assert.ok(!/\bemployers\b\s*from/.test(page), `${name}: no employers mock`);
|
||||
assert.ok(!/const METRICS/.test(page), `${name}: no inline METRICS constant`);
|
||||
assert.ok(!/const PIPELINE/.test(page), `${name}: no inline PIPELINE constant`);
|
||||
}
|
||||
// The clients render the real own-postings surface, not the old
|
||||
// mock-driven components (PostingManager's derived mock statuses/
|
||||
// applicants + AnalyticsCharts' unlabeled mock charts are gone from
|
||||
// the employer surface's real-data sections).
|
||||
for (const client of [
|
||||
"components/employer/employer-overview-client.tsx",
|
||||
"components/employer/own-postings-client.tsx",
|
||||
]) {
|
||||
const src = readFileSync(web(client), "utf8");
|
||||
assert.ok(!/@nextcraft\/mock-data/.test(src), `${client}: no mock-data import`);
|
||||
assert.ok(!/deriveStatus|deriveApplicants/.test(src), `${client}: no derived mock statuses/applicants`);
|
||||
}
|
||||
assert.ok(
|
||||
!/PostingManager/.test(postings),
|
||||
"postings page no longer renders the mock-driven PostingManager",
|
||||
);
|
||||
assert.ok(
|
||||
!/AnalyticsCharts/.test(overview),
|
||||
"overview page no longer renders the unlabeled AnalyticsCharts",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin: talent pages keep their mock imports + the mock-labeled metrics stay (MH-3d, D-092)", async () => {
|
||||
const talent = readFileSync(web("app/(employer)/employer/talent/page.tsx"), "utf8");
|
||||
const talentDetail = readFileSync(
|
||||
web("app/(employer)/employer/talent/[candidateId]/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// RETAINED (D-092: no candidate backend exists — honest out-of-scope).
|
||||
assert.ok(/import \{ candidates \} from '@nextcraft\/mock-data'/.test(talent), "talent search keeps the candidates mock");
|
||||
assert.ok(/import \{ candidates, competencyStacks \} from '@nextcraft\/mock-data'/.test(talentDetail), "candidate profile keeps the candidates mock");
|
||||
|
||||
// The overview's talent-match/placement metrics stay MOCK-LABELED —
|
||||
// the honest label rides the card copy (never presented as real).
|
||||
const overviewClient = readFileSync(
|
||||
web("components/employer/employer-overview-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.ok(/mock — no backend computes this/.test(overviewClient), "mock metric carries the honest label");
|
||||
assert.ok(/MockMetricCard/.test(overviewClient), "the mock-labeled metric card exists");
|
||||
assert.ok(/sample data/i.test(overviewClient), "analytics section labeled sample data");
|
||||
assert.ok(/no backend\s+computes them yet/.test(overviewClient), "analytics notice is honest about the mock");
|
||||
|
||||
// The REAL arms: active-postings + applicant counts from the real
|
||||
// route; the claimed-entity profile via the public marketplace fetch.
|
||||
assert.ok(/listOwnEmployerPostings/.test(overviewClient), "overview fetches own postings");
|
||||
assert.ok(/applicant_count/.test(overviewClient), "overview renders per-posting applicant counts");
|
||||
assert.ok(/getEmployer\(/.test(overviewClient), "overview fetches the claimed-entity profile (public route)");
|
||||
});
|
||||
|
||||
test("pin: the session-gated clients carry every honest-state branch (MH-3b, G-312 source pins)", async () => {
|
||||
for (const client of [
|
||||
"components/employer/employer-overview-client.tsx",
|
||||
"components/employer/own-postings-client.tsx",
|
||||
]) {
|
||||
const src = readFileSync(web(client), "utf8");
|
||||
// SessionRequiredError → the logged-out state with a login CTA —
|
||||
// NEVER a retry loop (the discrimination arm consumes the 401).
|
||||
assert.ok(/SessionRequiredError/.test(src), `${client}: the 401 arm is consumed`);
|
||||
assert.ok(/loggedOut/.test(src), `${client}: logged-out state exists`);
|
||||
assert.ok(/href="\/employer\/login"/.test(src), `${client}: login CTA present`);
|
||||
assert.ok(/Sign in/.test(src), `${client}: sign-in CTA copy`);
|
||||
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), `${client}: no retry loop machinery`);
|
||||
// ClaimRequiredError → the honest claim-CTA state (D-090 403 class).
|
||||
assert.ok(/ClaimRequiredError/.test(src), `${client}: the claim-CTA arm is consumed`);
|
||||
assert.ok(/claimPrompt/.test(src), `${client}: claim-prompt state exists`);
|
||||
// Claim-pending state visible ("submitted, awaiting review" —
|
||||
// never presented as verified before the operator decides).
|
||||
assert.ok(/claimPending/.test(src), `${client}: claim-pending state exists`);
|
||||
assert.ok(/awaiting review/.test(src), `${client}: awaiting-review copy`);
|
||||
// Loading + error arms (A-406).
|
||||
assert.ok(/AbortController/.test(src), `${client}: AbortController`);
|
||||
assert.ok(/Loading/.test(src), `${client}: honest loading state`);
|
||||
assert.ok(/error/.test(src), `${client}: honest error state`);
|
||||
assert.ok(/controller\.signal\.aborted\) return;/.test(src), `${client}: aborted responses never render`);
|
||||
}
|
||||
});
|
||||
|
||||
test("pin: the own-postings lifecycle view renders the full lifecycle + real counts (MH-3c)", async () => {
|
||||
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
|
||||
// draft → published → closed visible (the route's full lifecycle —
|
||||
// unlike the public board's published-only view).
|
||||
for (const status of ["draft", "published", "closed"]) {
|
||||
assert.ok(new RegExp(`'${status}'`).test(src), `lifecycle status ${status} renders`);
|
||||
}
|
||||
assert.ok(/facets\.draft/.test(src) && /facets\.published/.test(src) && /facets\.closed/.test(src), "facet counts render");
|
||||
// Real applicant counts (the D-092 overview data source).
|
||||
assert.ok(/applicant_count/.test(src), "applicant_count renders");
|
||||
assert.ok(/No postings yet/.test(src), "honest empty state");
|
||||
// Read-only this phase: the write actions land P4 — the buttons are
|
||||
// present but honestly disabled, never fake-wired.
|
||||
assert.ok(/disabled/.test(src), "write actions honestly disabled this phase");
|
||||
assert.ok(/upcoming release/.test(src), "the P4 copy is honest");
|
||||
// PII boundary (G-319): learner identifiers never render — only counts.
|
||||
assert.ok(!/learner_id|applicant_name|candidate_id/.test(src), "counts only, no learner identifiers");
|
||||
// The real route feeds the view.
|
||||
assert.ok(/listOwnEmployerPostings/.test(src), "own-postings route feeds the view");
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* v0.10 P3 (REQ-10-003, D-092/R-61): the engine-client employer
|
||||
* section — fetch stubs pin the 401 discrimination arm
|
||||
* (SessionRequiredError), the Bearer transport on every employer
|
||||
* fetch, and the logout revoke+clear contract. Source pins cover the
|
||||
* no-retry-loop discipline (no render harness in-tree — the
|
||||
* D-059/E87/G-312 pin discipline).
|
||||
*/
|
||||
|
||||
const QS = `?t=${Date.now()}-${Math.random()}`;
|
||||
|
||||
const root = new URL("..", import.meta.url);
|
||||
|
||||
function web(path: string): string {
|
||||
return fileURLToPath(new URL(path, root));
|
||||
}
|
||||
|
||||
/** sessionStorage stub (node has none — the wrappers guard on window). */
|
||||
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
|
||||
const store = new Map<string, string>();
|
||||
const globalAny = globalThis as Record<string, unknown>;
|
||||
globalAny.window = {
|
||||
sessionStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
};
|
||||
return { store, restore() { delete globalAny.window; } };
|
||||
}
|
||||
|
||||
const ME_BODY = {
|
||||
account: {
|
||||
id: "acct-1",
|
||||
email: "ops@example.com",
|
||||
employer_id: null,
|
||||
created_at: "2026-09-30T00:00:00Z",
|
||||
email_verified: false,
|
||||
},
|
||||
claimed_employer_id: null,
|
||||
claim: null,
|
||||
};
|
||||
|
||||
test("parseError: 401 maps to SessionRequiredError (the D-092 arm — no 401 discrimination existed before v0.10)", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ detail: "invalid or expired session" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
const { getEmployerMe, SessionRequiredError, EngineError } = await import(
|
||||
`../lib/engine-client.ts${QS}`
|
||||
);
|
||||
await assert.rejects(
|
||||
getEmployerMe(),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof SessionRequiredError, "must be SessionRequiredError");
|
||||
assert.ok(err instanceof EngineError, "rides the EngineError family");
|
||||
assert.equal((err as InstanceType<typeof EngineError>).status, 401);
|
||||
return true;
|
||||
},
|
||||
"401 maps to SessionRequiredError",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("parseError: the unclaimed 403 claim-CTA maps to ClaimRequiredError (D-090 authorization class)", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: {
|
||||
reason: "no_claimed_employer",
|
||||
claim_cta: "This account has not claimed an employer entity yet",
|
||||
},
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
const { listOwnEmployerPostings, ClaimRequiredError } = await import(
|
||||
`../lib/engine-client.ts${QS}`
|
||||
);
|
||||
await assert.rejects(
|
||||
listOwnEmployerPostings(),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ClaimRequiredError, "must be ClaimRequiredError");
|
||||
assert.equal(
|
||||
(err as InstanceType<typeof ClaimRequiredError>).reason,
|
||||
"no_claimed_employer",
|
||||
);
|
||||
return true;
|
||||
},
|
||||
"claim-CTA 403 maps to ClaimRequiredError",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("employer fetches: the Bearer token rides Authorization on every session-gated call", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||
});
|
||||
return new Response(JSON.stringify(ME_BODY), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
const { store, restore } = stubSessionStorage();
|
||||
try {
|
||||
const mod = await import(`../lib/engine-client.ts${QS}`);
|
||||
mod.setEmployerSessionToken("sess-token-abc");
|
||||
assert.equal(store.get("nc-employer-session"), "sess-token-abc", "wrapper writes the key");
|
||||
|
||||
await mod.getEmployerMe();
|
||||
await mod.listOwnEmployerPostings();
|
||||
await mod.claimEmployerEntity({ employer_id: "emp-openai" });
|
||||
for (const [i, call] of calls.entries()) {
|
||||
assert.equal(
|
||||
call.headers["Authorization"],
|
||||
"Bearer sess-token-abc",
|
||||
`employer fetch #${i} carries the Bearer header`,
|
||||
);
|
||||
}
|
||||
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/me");
|
||||
assert.equal(new URL(calls[1].url, "http://localhost").pathname, "/v1/employers/postings");
|
||||
assert.equal(new URL(calls[2].url, "http://localhost").pathname, "/v1/employers/claims");
|
||||
assert.equal(calls[2].url.includes("/v1/employers/claims"), true, "claims POST path");
|
||||
} finally {
|
||||
restore();
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("logoutEmployer: clears the tab-scoped storage AND fires the revoke route", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; method?: string; headers: Record<string, string> }> = [];
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||
});
|
||||
return new Response(JSON.stringify({ revoked: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
const { store, restore } = stubSessionStorage();
|
||||
try {
|
||||
const mod = await import(`../lib/engine-client.ts${QS}`);
|
||||
mod.setEmployerSessionToken("sess-token-xyz");
|
||||
await mod.logoutEmployer();
|
||||
assert.equal(store.has("nc-employer-session"), false, "storage cleared on logout");
|
||||
assert.equal(calls.length, 1, "the revoke route fired exactly once");
|
||||
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/logout");
|
||||
assert.equal(calls[0].method, "POST", "logout is a POST");
|
||||
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-xyz", "revoke carries the dying token");
|
||||
} finally {
|
||||
restore();
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("logoutEmployer: no token → no revoke call (storage already clean, no zombie fetch)", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: unknown[] = [];
|
||||
globalThis.fetch = (async (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const { restore } = stubSessionStorage();
|
||||
try {
|
||||
const mod = await import(`../lib/engine-client.ts${QS}`);
|
||||
await mod.logoutEmployer();
|
||||
assert.equal(calls.length, 0, "no revoke fetch without a stored token");
|
||||
} finally {
|
||||
restore();
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("logoutEmployer: an already-dead token (401) still leaves the caller logged out — no thrown error surface", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ detail: "invalid or expired session" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
const { store, restore } = stubSessionStorage();
|
||||
try {
|
||||
const mod = await import(`../lib/engine-client.ts${QS}`);
|
||||
mod.setEmployerSessionToken("already-dead");
|
||||
await mod.logoutEmployer(); // must NOT reject
|
||||
assert.equal(store.has("nc-employer-session"), false, "storage cleared even on the 401 path");
|
||||
} finally {
|
||||
restore();
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("registerEmployer/loginEmployer: POST the auth routes with the body on the wire", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; method?: string; body?: string }> = [];
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
body: typeof init?.body === "string" ? init.body : undefined,
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ token: "tok-1", expires_at: "2026-10-07T00:00:00Z" }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const { restore } = stubSessionStorage();
|
||||
try {
|
||||
const mod = await import(`../lib/engine-client.ts${QS}`);
|
||||
const reg = await mod.registerEmployer({ email: "a@example.com", password: "longenough1" });
|
||||
const log = await mod.loginEmployer({ email: "a@example.com", password: "longenough1" });
|
||||
assert.equal(reg.token, "tok-1");
|
||||
assert.equal(log.expires_at, "2026-10-07T00:00:00Z");
|
||||
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/register");
|
||||
assert.equal(new URL(calls[1].url, "http://localhost").pathname, "/v1/employers/auth/login");
|
||||
assert.equal(calls[0].method, "POST");
|
||||
assert.ok(calls[0].body?.includes('"email":"a@example.com"'), "email rides the body");
|
||||
// 422 honesty (R-56): the client never echoes the password into a
|
||||
// log or storage — this test pins the body is JSON-only (the
|
||||
// server's pii_fields scrub owns the 422 side).
|
||||
assert.ok(calls[0].body?.startsWith("{"), "JSON body");
|
||||
} finally {
|
||||
restore();
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("pin: the employer section keeps the no-retry-loop + AbortController discipline (G-312 source pin)", async () => {
|
||||
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
|
||||
// SessionRequiredError exists + joins parseError before the generic fallthrough.
|
||||
assert.ok(/export class SessionRequiredError/.test(client), "SessionRequiredError exported");
|
||||
assert.ok(
|
||||
/status === 401[\s\S]{0,200}SessionRequiredError/,
|
||||
"the 401 arm maps to SessionRequiredError inside parseError",
|
||||
);
|
||||
// The 401 arm precedes the 403 arm (both are discrimination arms).
|
||||
const idx401 = client.indexOf("resp.status === 401");
|
||||
const idx403 = client.indexOf("resp.status === 403");
|
||||
assert.ok(idx401 > 0 && idx403 > idx401, "401 discrimination precedes 403");
|
||||
// The sessionStorage wrapper is window-guarded (the adminToken pattern).
|
||||
assert.ok(
|
||||
/typeof window === 'undefined'[\s\S]{0,120}sessionStorage.getItem\(EMPLOYER_SESSION_KEY\)/.test(
|
||||
client,
|
||||
),
|
||||
"employerSessionToken is window-guarded",
|
||||
);
|
||||
// The employer functions own AbortController-compatible signal params.
|
||||
for (const fn of [
|
||||
"registerEmployer",
|
||||
"loginEmployer",
|
||||
"logoutEmployer",
|
||||
"getEmployerMe",
|
||||
"claimEmployerEntity",
|
||||
"listOwnEmployerPostings",
|
||||
]) {
|
||||
assert.ok(
|
||||
new RegExp(`export async function ${fn}\\([\\s\\S]*?signal\\?`).test(client),
|
||||
`${fn} takes an AbortSignal`,
|
||||
);
|
||||
}
|
||||
// No retry loop: the client never re-fetches on a 401 — the caller
|
||||
// renders the logged-out state (pinned as the absence of retry logic).
|
||||
assert.ok(
|
||||
!/retry|setTimeout|while \(true\)/.test(
|
||||
client.slice(client.indexOf("-- employer auth + account")),
|
||||
),
|
||||
"the employer section contains no retry machinery",
|
||||
);
|
||||
// The tab-scoped exposure tradeoff is documented in the section copy
|
||||
// (open-risk-4: bound the XSS window, honest about it).
|
||||
assert.ok(
|
||||
/tab-scoped/.test(client),
|
||||
"the sessionStorage tab-scoping rationale is documented",
|
||||
);
|
||||
});
|
||||
@@ -263,3 +263,179 @@ test("pricing page: runtime catalog wiring (REQ-9-002/D-079 — no hardcoded tie
|
||||
"no raw cents arithmetic outside formatPrice",
|
||||
);
|
||||
});
|
||||
|
||||
// -- v0.10 employer accounts (REQ-10-003, G-108 dual-schema, MH-3a) ---------------
|
||||
|
||||
test("wire pin: EmployerAccount/EmployerClaim/token+me shapes mirror employers/base.py field-for-field (dual-schema sync)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
||||
const pySrc = readFileSync(
|
||||
web("../ai-service/ai_service/employers/base.py"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// EmployerAccount: every pydantic field asserted on BOTH sides.
|
||||
const acctBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface EmployerAccount"),
|
||||
typesSrc.indexOf("export interface EmployerClaim {"),
|
||||
);
|
||||
for (const f of [
|
||||
"id: string;",
|
||||
"email: string;",
|
||||
"employer_id: string | null;",
|
||||
"created_at: string;",
|
||||
"email_verified: false;",
|
||||
]) {
|
||||
assert.ok(acctBlock.includes(f), `EmployerAccount missing base.py field ${f}`);
|
||||
}
|
||||
// D-097: email_verified can never be true (Literal[False] both sides).
|
||||
assert.ok(!/email_verified: boolean/.test(acctBlock), "email_verified is false, never boolean");
|
||||
assert.ok(/email_verified: Literal\[False\]/.test(pySrc), "py side pins Literal[False]");
|
||||
|
||||
// EmployerClaim (+ the typed evidence dict).
|
||||
const claimBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface EmployerClaim {"),
|
||||
typesSrc.indexOf("export interface RegisterEmployerRequest {"),
|
||||
);
|
||||
for (const f of [
|
||||
"id: string;",
|
||||
"account_id: string;",
|
||||
"employer_id: string;",
|
||||
"status: EmployerClaimStatus;",
|
||||
"evidence:",
|
||||
"created_at: string;",
|
||||
"decided_by: string | null;",
|
||||
"decided_at: string | null;",
|
||||
]) {
|
||||
assert.ok(claimBlock.includes(f), `EmployerClaim missing base.py field ${f}`);
|
||||
}
|
||||
// The advisory evidence dict shape (claim_evidence.py compute_claim_evidence).
|
||||
const evidenceBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface EmployerClaimEvidence"),
|
||||
typesSrc.indexOf("export interface EmployerAccount"),
|
||||
);
|
||||
for (const f of [
|
||||
"account_email_domain: string;",
|
||||
"entity_website_domain: string;",
|
||||
"match: boolean;",
|
||||
]) {
|
||||
assert.ok(evidenceBlock.includes(f), `EmployerClaimEvidence missing D-089 field ${f}`);
|
||||
}
|
||||
|
||||
// Request shapes + TokenResponse + EmployerMe.
|
||||
const reqBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface RegisterEmployerRequest {"),
|
||||
typesSrc.indexOf("export interface EmployerTokenResponse"),
|
||||
);
|
||||
for (const f of ["email: string;", "password: string;", "employer_id: string;"]) {
|
||||
assert.ok(reqBlock.includes(f), `employer request shapes missing base.py field ${f}`);
|
||||
}
|
||||
const tokenBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface EmployerTokenResponse"),
|
||||
typesSrc.indexOf("export interface EmployerMe"),
|
||||
);
|
||||
for (const f of ["token: string;", "expires_at: string;"]) {
|
||||
assert.ok(tokenBlock.includes(f), `TokenResponse missing base.py field ${f}`);
|
||||
}
|
||||
const meBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface EmployerMe"),
|
||||
typesSrc.indexOf("export type OwnPostingStatus"),
|
||||
);
|
||||
for (const f of [
|
||||
"account: EmployerAccount;",
|
||||
"claimed_employer_id: string | null;",
|
||||
"claim: EmployerClaim | null;",
|
||||
]) {
|
||||
assert.ok(meBlock.includes(f), `EmployerMe missing base.py field ${f}`);
|
||||
}
|
||||
|
||||
// The py side keeps the same fields in its pydantic contracts.
|
||||
for (const f of [
|
||||
"class EmployerAccount",
|
||||
"class EmployerClaim",
|
||||
"class RegisterEmployerRequest",
|
||||
"class LoginEmployerRequest",
|
||||
"class ClaimEmployerRequest",
|
||||
"class TokenResponse",
|
||||
"class EmployerMe",
|
||||
"ClaimStatus",
|
||||
"PASSWORD_MIN_LENGTH = 8",
|
||||
]) {
|
||||
assert.ok(pySrc.includes(f), `employers/base.py missing wire contract ${f}`);
|
||||
}
|
||||
// Claim status parity: the TS union mirrors the py Literal.
|
||||
const tsStatuses = typesSrc.match(
|
||||
/export type EmployerClaimStatus = '[a-z]+' \| '[a-z]+' \| '[a-z]+'/,
|
||||
);
|
||||
assert.ok(tsStatuses, "EmployerClaimStatus union exists");
|
||||
assert.ok(
|
||||
tsStatuses && tsStatuses[0].includes("'pending'") && tsStatuses[0].includes("'approved'") && tsStatuses[0].includes("'rejected'"),
|
||||
"claim status union = pending|approved|rejected (py ClaimStatus)",
|
||||
);
|
||||
});
|
||||
|
||||
test("wire pin: OwnPostingSummary REUSES PostingSummary + own-postings response mirrors base.py (G-319/D-093)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
||||
const marketplaceSrc = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
|
||||
const pySrc = readFileSync(
|
||||
web("../ai-service/ai_service/employers/base.py"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// G-319 REUSE: composition (extends PostingSummary), never a
|
||||
// field-for-field duplicate of the PostingSummary fields.
|
||||
const ownBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface OwnPostingSummary"),
|
||||
typesSrc.indexOf("export interface OwnPostingsFacets"),
|
||||
);
|
||||
assert.ok(
|
||||
/export interface OwnPostingSummary extends PostingSummary \{/.test(ownBlock),
|
||||
"OwnPostingSummary extends PostingSummary (reuse, never duplication)",
|
||||
);
|
||||
assert.ok(/applicant_count: number;/.test(ownBlock), "OwnPostingSummary carries applicant_count");
|
||||
// The PostingSummary fields must NOT be re-listed inside the own block
|
||||
// (a duplicate would drift — the REUSE contract).
|
||||
for (const dup of ["external_id:", "required_competencies:", "salary_min:"]) {
|
||||
assert.ok(!ownBlock.includes(dup), `OwnPostingSummary duplicates PostingSummary field ${dup}`);
|
||||
}
|
||||
// The py side subclasses marketplace.base.Posting (the same reuse).
|
||||
assert.ok(/class OwnPostingSummary\(Posting\)/.test(pySrc), "py OwnPostingSummary subclasses Posting");
|
||||
// PII boundary (G-319, binding): the contract carries the count only —
|
||||
// no learner-identifier field name exists anywhere on the TS shape.
|
||||
assert.ok(!/learner_id|applicant_name|candidate_id/.test(ownBlock), "PII boundary: counts only");
|
||||
|
||||
// OwnPostingsFacets: every lifecycle key on both sides.
|
||||
const facetsBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface OwnPostingsFacets"),
|
||||
typesSrc.indexOf("export interface OwnPostingsResponse"),
|
||||
);
|
||||
for (const f of ["draft: number;", "published: number;", "closed: number;"]) {
|
||||
assert.ok(facetsBlock.includes(f), `OwnPostingsFacets missing lifecycle key ${f}`);
|
||||
}
|
||||
assert.ok(/draft: int = 0/.test(pySrc) && /published: int = 0/.test(pySrc) && /closed: int = 0/.test(pySrc), "py OwnPostingsFacets carries the same three keys");
|
||||
|
||||
// OwnPostingsResponse: list + facets + honest total, both sides.
|
||||
const respBlock = typesSrc.slice(typesSrc.indexOf("export interface OwnPostingsResponse"));
|
||||
for (const f of [
|
||||
"postings: OwnPostingSummary[];",
|
||||
"facets: OwnPostingsFacets;",
|
||||
"total: number;",
|
||||
]) {
|
||||
assert.ok(respBlock.includes(f), `OwnPostingsResponse missing base.py field ${f}`);
|
||||
}
|
||||
assert.ok(/postings: list\[OwnPostingSummary\]/.test(pySrc), "py OwnPostingsResponse carries the postings list");
|
||||
|
||||
// PostingSummary itself stays single-sourced in marketplace.ts (the
|
||||
// wire pin at pins.test.ts:48 owns its field list).
|
||||
assert.ok(
|
||||
/export interface PostingSummary \{/.test(marketplaceSrc),
|
||||
"PostingSummary remains defined once in marketplace.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("wire pin: employer types surface through the package index (the billing.ts precedent)", async () => {
|
||||
const indexSrc = readFileSync(web("../../packages/types/index.ts"), "utf8");
|
||||
assert.ok(
|
||||
/export \* from '\.\/employer';/.test(indexSrc),
|
||||
"index.ts re-exports employer.ts",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Nextcraft — Employer Account Types
|
||||
*
|
||||
* Employer auth/account wire shapes (v0.10 P3, REQ-10-003): the
|
||||
* employer-principal surface served by /v1/employers/*.
|
||||
*
|
||||
* Dual-schema-sync mirror of
|
||||
* `apps/ai-service/ai_service/employers/base.py` (the G-108
|
||||
* discipline): field-for-field parity, snake_case as emitted by the
|
||||
* API — every change happens in both places, pinned by
|
||||
* `apps/web/tests/pins.test.ts`.
|
||||
*
|
||||
* D-097: `email_verified` is `false` — the account is UNVERIFIED,
|
||||
* displayed as such; never presented as verified.
|
||||
*
|
||||
* D-093/G-319: `OwnPostingSummary` REUSES the PostingSummary wire
|
||||
* shape from marketplace.ts (composition, never a field-for-field
|
||||
* duplication) + `applicant_count`. PII boundary (binding): applicant
|
||||
* COUNTS are the only applications-derived datum — learner
|
||||
* identifiers never ride any /v1/employers/* response.
|
||||
*/
|
||||
|
||||
import type { PostingSummary, PostingStatus } from './marketplace';
|
||||
|
||||
/** Claim lifecycle states (D-089): pending until an operator decides.
|
||||
* Approved activates ownership (one account : one entity); rejection
|
||||
* frees the entity for a new claim. */
|
||||
export type EmployerClaimStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
/** Advisory claim evidence (D-089, computed AT CLAIM TIME by
|
||||
* claim_evidence.py — a snapshot stored as provenance JSON, never
|
||||
* recomputed on read). The domain-match signal is ADVISORY and never
|
||||
* displayed as verification (A-304). */
|
||||
export interface EmployerClaimEvidence {
|
||||
account_email_domain: string;
|
||||
entity_website_domain: string;
|
||||
match: boolean;
|
||||
}
|
||||
|
||||
/** An employer-side login account (base.py EmployerAccount). */
|
||||
export interface EmployerAccount {
|
||||
id: string;
|
||||
/** G-315: plain string — stdlib-validated + normalized server-side. */
|
||||
email: string;
|
||||
/** None until a claim is APPROVED (one account : one entity). */
|
||||
employer_id: string | null;
|
||||
created_at: string;
|
||||
/** D-097: UNVERIFIED — always false, displayed as such. */
|
||||
email_verified: false;
|
||||
}
|
||||
|
||||
/** An account's claim on an EmployerRow entity (base.py EmployerClaim). */
|
||||
export interface EmployerClaim {
|
||||
id: string;
|
||||
account_id: string;
|
||||
employer_id: string;
|
||||
status: EmployerClaimStatus;
|
||||
/** The claim-time evidence snapshot (advisory, never verification). */
|
||||
evidence: Partial<EmployerClaimEvidence> & Record<string, unknown>;
|
||||
created_at: string;
|
||||
/** Operator provenance — null while pending. */
|
||||
decided_by: string | null;
|
||||
decided_at: string | null;
|
||||
}
|
||||
|
||||
/** POST /v1/employers/auth/register body (base.py RegisterEmployerRequest:
|
||||
* G-315 email + D-086 password min length 8, validated server-side). */
|
||||
export interface RegisterEmployerRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** POST /v1/employers/auth/login body (base.py LoginEmployerRequest). */
|
||||
export interface LoginEmployerRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** POST /v1/employers/claims body (base.py ClaimEmployerRequest). */
|
||||
export interface ClaimEmployerRequest {
|
||||
employer_id: string;
|
||||
}
|
||||
|
||||
/** Login/register session response (base.py TokenResponse): the bearer
|
||||
* token (raw, exactly once — only sha256(token) is stored server-side,
|
||||
* D-087) + its lazy-expiry timestamp. */
|
||||
export interface EmployerTokenResponse {
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
/** GET /v1/employers/auth/me response (base.py EmployerMe): the
|
||||
* account (unverified email displayed as such) + the claimed entity +
|
||||
* the claim state — null until a claim exists. */
|
||||
export interface EmployerMe {
|
||||
account: EmployerAccount;
|
||||
claimed_employer_id: string | null;
|
||||
claim: EmployerClaim | null;
|
||||
}
|
||||
|
||||
/** G-319: the employer-facing posting summary — the PostingSummary
|
||||
* wire shape REUSED from marketplace.ts (composition, never
|
||||
* duplicated) + the per-posting applicant COUNT (the D-092 overview
|
||||
* data source). PII boundary: counts ONLY, never learner identifiers. */
|
||||
export interface OwnPostingSummary extends PostingSummary {
|
||||
applicant_count: number;
|
||||
}
|
||||
|
||||
/** Status facet counts over the session employer's OWN postings
|
||||
* (base.py OwnPostingsFacets): the full lifecycle — draft|published|
|
||||
* closed — every key always present (a zero-count status carries its
|
||||
* honest 0). */
|
||||
export interface OwnPostingsFacets {
|
||||
draft: number;
|
||||
published: number;
|
||||
closed: number;
|
||||
}
|
||||
|
||||
/** GET /v1/employers/postings response (base.py OwnPostingsResponse):
|
||||
* own postings (all lifecycle statuses) + per-status facet counts +
|
||||
* the honest total. Unclaimed accounts never see this shape (the
|
||||
* route's 403 claim-CTA precedes it). */
|
||||
export interface OwnPostingsResponse {
|
||||
postings: OwnPostingSummary[];
|
||||
facets: OwnPostingsFacets;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** The own-postings status facet keys (base.py OWN_POSTING_STATUSES —
|
||||
* the marketplace PostingStatus lifecycle, all of it, unlike the
|
||||
* public board's published-only view). */
|
||||
export type OwnPostingStatus = PostingStatus;
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './domain';
|
||||
export * from './billing';
|
||||
export * from './employer';
|
||||
export * from './defense';
|
||||
export * from './grading';
|
||||
export * from './marketplace';
|
||||
|
||||
Reference in New Issue
Block a user