Compare commits

...

9 Commits

Author SHA1 Message Date
CIAgent c13af0ab5e docs(P04): complete self-serve-postings phase
CI / node (push) Successful in 38s
Release / release (push) Successful in 2m47s
CI / python (push) Successful in 3m5s
---ci---
phase: 4
milestone: v0.10
status: complete
requirements_covered: [REQ-10-004]
partial: []
---ci---
2026-09-30 23:27:29 +00:00
CIAgent be077857f2 chore(P03): phase 3 complete — v0.9.3 shipped (release via tag lane)
CI / node (push) Successful in 40s
CI / python (push) Successful in 2m44s
2026-09-30 20:32:47 +00:00
CIAgent 81a9cc7c12 docs(P03): complete employer-account-surface phase
CI / node (push) Successful in 40s
Release / release (push) Successful in 2m46s
CI / python (push) Successful in 2m57s
---ci---
phase: 3
milestone: v0.10
status: complete
requirements_covered: [REQ-10-003]
partial: []
---ci---
2026-09-30 20:32:23 +00:00
CIAgent 7f598f3e7b chore(P02): phase 2 complete — v0.9.2 shipped (release via tag lane)
CI / node (push) Successful in 41s
CI / python (push) Successful in 2m41s
---ci---
phase: 2
milestone: v0.10
status: complete
---/ci---
2026-09-30 19:31:39 +00:00
CIAgent 1d08218544 docs(P02): complete employer-entity-claim phase
CI / node (push) Successful in 39s
Release / release (push) Successful in 2m50s
CI / python (push) Successful in 2m51s
---ci---
phase: 2
milestone: v0.10
status: complete
requirements covered: [REQ-10-002]
partial: []
---/ci---
2026-09-30 19:31:08 +00:00
CIAgent 3901fac2b1 chore(P01): phase 1 complete — v0.9.1 shipped (release via tag lane)
CI / node (push) Successful in 40s
CI / python (push) Successful in 2m23s
---ci---
phase: 1
milestone: v0.10
status: complete
---/ci---
2026-09-30 18:28:10 +00:00
CIAgent 6c9198ca56 docs(P01): complete employer-auth-foundation phase
CI / node (push) Successful in 40s
Release / release (push) Successful in 2m51s
CI / python (push) Successful in 2m38s
Employers module (ai_service/employers/): pydantic contracts,
8th D-027 SQLite store (4 tables), stdlib scrypt KDF (params pinned),
hashed server-side sessions (7-day lazy TTL), register/login/logout/me
routes, dual D-088 throttles, CORS Authorization preflight, PII scrub
in 422s. Security review: 1 P0 fixed (whole-body scrub), 3 P1
dispositioned. 888 fast-suite green, zero warnings. Grill bindings
G-311..G-320 honored.

---ci---
phase: 1
milestone: v0.10
status: complete
requirements:
  covered: [REQ-10-001]
  partial: []
---/ci---
2026-09-30 18:27:47 +00:00
CIAgent aed709b956 chore(P00): phase 0 complete — v0.9.0 shipped (release via tag lane)
CI / node (push) Successful in 44s
CI / python (push) Successful in 2m11s
---ci---
phase: 0
milestone: v0.10
status: complete
---/ci---
2026-09-30 15:19:48 +00:00
CIAgent 36cd9fa71f docs(P00): complete b2b-employer-network phase 0 — v0.9.0
CI / node (push) Successful in 43s
CI / python (push) Successful in 2m23s
Release / release (push) Successful in 2m54s
Specify (D-085 scope lock: REQ-F-019 activated as REQ-10-001..005),
clarify (D-086..D-097), research (R-55..R-66 — session-auth patterns,
scrypt, the claim-evidence seam; in-tree integration probes), plan
(6 vertical-slice phases, waves binding, must-haves falsifiable),
grill (G-311..G-320 binding decisions applied), MVP/UX gate PASS.
Tags on the v0.9.x line; branch
milestone/v0.10-b2b-employer-network.

---ci---
phase: 0
milestone: v0.10
status: complete
---/ci---
2026-09-30 15:19:25 +00:00
56 changed files with 10361 additions and 1023 deletions
+35
View File
@@ -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.
+9
View File
@@ -0,0 +1,9 @@
{
"phase": 4,
"stage": "ship_pending",
"milestone": "v0.10",
"milestone_name": "b2b-employer-network",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-30T22:20:00Z"
}
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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/* |
+36
View File
@@ -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 | complete (v0.9.3, release 951) |
### Self-Serve Posting Management
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-004 | Self-serve posting management: employer-principal posting routes (`/v1/employers/postings/*`) — create draft, edit own drafts, publish own drafts, close own postings, list own postings with status facets; authorization: the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the admin-gate-first order preserved, G-311) with the 402 CTA unchanged, D-080 consumption model intact, via the shared api-layer helper extracting the v0.9 gate (D-091 — no duplication); posting creation consumes from the employer's own packages (the v0.9 entitlement check parameterized by the session's employer_id instead of request body); the admin posting routes remain (operator path unchanged, provenance-recorded); 18+ gate N/A (employer surface is not learner-facing; the D-043 learner gates untouched, D-097) | high | 4 | 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
View File
@@ -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
View File
@@ -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 | complete (v0.9.3) | 2 | REQ-10-003 | Employer dashboard real-data conversion (mock imports → runtime fetches, A-406 pattern); login/register UI; session-gated shell; own-postings view; dual-schema wire pins (shipped as v0.9.3, release 951) |
| 4 | Self-serve posting management | 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 |
|---|------|--------|------------|--------------|------------------|
+3 -3
View File
@@ -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"
}
}
+13 -1
View File
@@ -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
View File
@@ -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."""
+705
View File
@@ -0,0 +1,705 @@
"""/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
import uuid
from collections import deque
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from ..config import Settings
from ..employers.base import (
ClaimEmployerRequest,
EmployerAccount,
EmployerClaim,
EmployerMe,
EmployerPostingCreate,
EmployerPostingEdit,
EmployerPostingWriteResponse,
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 ..llm.base import LLMProvider
from ..marketplace.base import Posting
from ..marketplace.enrichment import maybe_enrich_on_publish
from ..marketplace.search import embed_posting
from ..marketplace.store import InvalidTransition, MarketplaceStore
from ..marketplace.tagging import TaggingUnavailable, tag_posting
from .deps import (
get_embedding_provider,
get_employer_store,
get_marketplace_store,
get_provider,
get_settings,
)
from .entitlement import consume_posting_entitlement
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),
)
# -- own postings write (P4, Task 4-1-02 — REQ-10-004, D-091/G-311/317/318) ---------
def _require_claimed(account: AccountRow) -> str:
"""The claim gate shared by every write route (G-311 order:
session 401 → claim 403 → entitlement 402 → insert). Returns the
claimed employer_id — a valid session whose account holds no
entity is not authorized to any employer's postings (the D-090
403 class, never a 402)."""
if account.employer_id is None:
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."
),
},
)
return account.employer_id
def _own_posting_or_404(
store: MarketplaceStore, posting_id: str, employer_id: str
) -> Posting:
"""Fetch the posting + enforce the cross-entity authorization arm
(D-090: another entity's posting → 403; unknown → 404)."""
posting = store.get_posting(posting_id)
if posting is None:
raise HTTPException(
status_code=404,
detail=f"no posting with id {posting_id!r}",
)
if posting.employer_id != employer_id:
raise HTTPException(
status_code=403,
detail="this posting belongs to another employer entity",
)
return posting
@claims_router.post("/postings", status_code=201, response_model=None)
async def create_own_posting(
body: EmployerPostingCreate,
request: Request,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
) -> EmployerPostingWriteResponse:
"""Create a DRAFT on the CLAIMED entity (G-311: session gate →
claim gate → entitlement gate → insert; unauthenticated → 401 and
unclaimed → 403, never a 402 — the R-58 admin-gate-first order).
The entitlement gate is the SHARED helper (D-091 no-drift): the
SAME consume_posting_entitlement the admin route composes, with
the employer checkout pointer; a 402 consumes nothing, creates
nothing (gate-before-insert).
G-317: employer authorship stamps its OWN provenance —
source="employer" + emp- id/external_id schemes (the admin route
stamps source="admin"; authorship stays answerable across the
seeded/admin/employer classes). The admin routes are untouched."""
_session, account = auth
employer_id = _require_claimed(account)
consume_posting_entitlement(
getattr(request.app.state, "billing_store", None),
employer_id,
"/v1/employers/billing/checkout",
)
posting = Posting(
id=f"emp-{uuid.uuid4().hex[:16]}",
source="employer",
external_id=f"emp-{uuid.uuid4().hex[:16]}",
employer_id=employer_id,
title=body.title,
description=body.description,
required_competencies=body.required_competencies,
skills=body.skills,
seniority=body.seniority, # type: ignore[arg-type]
category=body.category,
location=body.location,
remote=body.remote,
salary_min=body.salary_min,
salary_max=body.salary_max,
status="draft",
)
try:
stored = store.insert_posting(posting)
except Exception as exc: # surfaced IntegrityError from the store
raise HTTPException(status_code=409, detail=str(exc)) from exc
return EmployerPostingWriteResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
@claims_router.patch(
"/postings/{posting_id}", response_model=EmployerPostingWriteResponse
)
async def edit_own_posting(
posting_id: str,
body: EmployerPostingEdit,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
provider: LLMProvider = Depends(get_provider),
) -> EmployerPostingWriteResponse:
"""Edit the OWN DRAFT's content (G-318: own drafts ONLY — the
published/closed guard evaluates BEFORE any mutation, so a 409
leaves fields AND tags untouched; cross-entity → 403; unknown →
404). The content edit + re-tag composition is the IDENTICAL
update_posting_fields + tag_posting path as the admin edit
(D-052: tags are derived data — the new extraction OVERWRITES;
tag failure is honest: the edit stands, tags keep their previous
value, never a 500)."""
_session, account = auth
employer_id = _require_claimed(account)
posting = _own_posting_or_404(store, posting_id, employer_id)
if posting.status != "draft":
# Guard BEFORE any mutation — no field/tag side effects.
raise HTTPException(
status_code=409,
detail=(
f"only drafts can be edited — this posting is "
f"{posting.status}"
),
)
updated = store.update_posting_fields(
posting_id,
title=body.title,
description=body.description,
skills=body.skills,
)
if updated is None: # pragma: no cover - fetched above in-transaction
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
try:
tags = await tag_posting(
provider,
model=settings.model,
title=updated.title,
description=updated.description,
skills_hint=updated.skills,
)
updated = store.set_posting_tags(posting_id, tags) or updated
except TaggingUnavailable:
# Honest: the EDIT landed; tags keep their previous value (the
# warning already logged in tagging.py). Never 500 on tag
# failure — the admin edit's identical arm.
pass
return EmployerPostingWriteResponse(
id=updated.id,
source=updated.source,
external_id=updated.external_id,
employer_id=updated.employer_id,
title=updated.title,
status=updated.status,
)
@claims_router.post(
"/postings/{posting_id}/publish", response_model=EmployerPostingWriteResponse
)
async def publish_own_posting(
posting_id: str,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
embedder=Depends(get_embedding_provider),
provider: LLMProvider = Depends(get_provider),
) -> EmployerPostingWriteResponse:
"""Own draft → published (G-311 order: the own-entity + draft
guards run before any transition). The composition behind publish
is the admin publish's IDENTICAL hooks: embed-on-publish + the
one-shot enrich-on-first-publish (D-053) — failures are honest
warnings, the publish stands."""
_session, account = auth
employer_id = _require_claimed(account)
posting = _own_posting_or_404(store, posting_id, employer_id)
if posting.status != "draft":
raise HTTPException(
status_code=409,
detail=f"only drafts can be published — this posting is "
f"{posting.status}",
)
try:
stored = store.set_posting_status(posting_id, "published")
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if stored is None: # pragma: no cover - fetched above in-transaction
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
await embed_posting(store, embedder, stored)
try:
await maybe_enrich_on_publish(
store, provider, model=settings.model, employer_id=stored.employer_id
)
except Exception: # pragma: no cover - maybe_enrich swallows; belt+braces
pass # honest warning path — the publish stands (admin precedent)
return EmployerPostingWriteResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
@claims_router.post(
"/postings/{posting_id}/close", response_model=EmployerPostingWriteResponse
)
async def close_own_posting(
posting_id: str,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
) -> EmployerPostingWriteResponse:
"""Own posting → closed (published → closed is the only valid move;
409 on closing a draft or re-closing — the store's transition
matrix; cross-entity → 403; unknown → 404)."""
_session, account = auth
employer_id = _require_claimed(account)
_own_posting_or_404(store, posting_id, employer_id)
try:
stored = store.set_posting_status(posting_id, "closed")
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if stored is None: # pragma: no cover - fetched above in-transaction
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
return EmployerPostingWriteResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
@@ -0,0 +1,287 @@
"""/v1/employers/admin — employer claim operator surface (REQ-10-002, D-089/D-094).
Every route sits behind `require_admin` IMPORTED from
marketplace_admin (the billing_admin.py:27 precedent, D-094 — one
guard, never reimplemented): fail-closed 403 on no/missing/invalid
token — the R-56 matrix; an UNSET AI_ADMIN_TOKEN 403s every route
even with a candidate (the main.py boot warning covers the config
class). Operator routes compose NO learner gate (D-097) and no
employer-session gate — a different principal class, and the token
namespaces cannot collide (this gate compares bytes; the session
gate hash-looks-up — D-090).
D-089: evidence is ADVISORY — the queue returns the stored claim-time
provenance snapshot as-is; approval is the EXPLICIT operator decision
(auto-approval deferred: without email verification the match is
forgeable, A-304 — never present unverified as verified). Every
decision appends an employer_audit_events row with operator
provenance in the SAME transaction as the claim update (decide_claim
— all-or-nothing). The accounts wire contract carries NO password_hash
(hash-at-rest, D-087 — never returned anywhere).
409 discipline (the decide arms' honest constraint 409s — details
carry constraint names only, never account identifiers):
- claim not pending (decided twice) → "claim is already decided"
- the deciding account already holds an entity (the one-account-
one-entity guard) → "the claim's account already holds an employer
entity"
- the entity already owned (the employer_accounts.employer_id UNIQUE
surfacing on the approve flush) → "the employer entity is already
owned" — surfaced, NEVER silently overwritten.
Any OTHER IntegrityError re-raises (a 500) — never mask an unknown
constraint violation as a decision.
"""
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from ..config import Settings
from ..employers.base import EmployerClaim
from ..employers.store import ClaimRow, EmployerStore
from .deps import get_employer_store
from .marketplace_admin import require_admin
router = APIRouter(prefix="/v1/employers/admin", tags=["employers-admin"])
#: Operator provenance stamped on every decision + audit event (the
#: billing_admin "operator" precedent — route-level provenance; no
#: per-operator accounts exist at pilot, D-094).
OPERATOR = "operator"
# -- contracts ----------------------------------------------------------------
class AdminEmployerAccount(BaseModel):
"""The accounts-list wire row (D-094): email, claimed entity,
created_at, the UNVERIFIED marker. `email_verified` is
`Literal[False]` (D-097 — structurally incapable of True: no email
verification exists in v0.10, so the field can never carry it).
password_hash is NEVER on the wire (D-087)."""
id: str
email: str
employer_id: str | None
created_at: str
#: D-097: the account is UNVERIFIED — displayed as such, never as
#: verified (A-304's doctrine applied to the account surface).
email_verified: Literal[False] = False
class AdminAccountsResponse(BaseModel):
accounts: list[AdminEmployerAccount]
count: int
class AdminClaimsResponse(BaseModel):
"""The claim queue (D-089): status, the stored claim-time evidence
snapshot — the domain-match signal rides evidence["match"],
ADVISORY, never a verification — and the decided state
(decided_by/decided_at, None while pending)."""
claims: list[EmployerClaim]
count: int
class ClaimDecisionResponse(BaseModel):
"""The decided claim (status approved/rejected + operator
provenance) — either decision arm returns the same shape."""
claim: EmployerClaim
class AdminAuditEvent(BaseModel):
"""The audit-trail wire row (D-089's queryable operator trail —
the BillingEventRow_ shape, billing_admin.py:488)."""
id: int
account_id: str | None
employer_id: str | None
kind: str
name: str
detail: dict[str, object]
operator: str | None
created_at: str | None
class AdminAuditResponse(BaseModel):
events: list[AdminAuditEvent]
count: int
def _claim_to_contract(row: ClaimRow) -> EmployerClaim:
"""Store row → wire contract (created_at is always set on a row
the store returned — insert/decide stamp it)."""
assert row.created_at is not None # the store always stamps it
return EmployerClaim(
id=row.id,
account_id=row.account_id,
employer_id=row.employer_id,
status=row.status, # type: ignore[arg-type]
evidence=dict(row.evidence or {}),
created_at=row.created_at,
decided_by=row.decided_by,
decided_at=row.decided_at,
)
# -- queue reads (D-094) ---------------------------------------------------------
@router.get("/accounts", response_model=AdminAccountsResponse)
def list_accounts(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> AdminAccountsResponse:
"""The accounts list: email, claimed entity, created_at, and the
UNVERIFIED marker (D-097 — account emails are unverified and
displayed as such; a claimed entity column shows the APPROVED
ownership binding, never account verification)."""
rows = store.list_accounts()
return AdminAccountsResponse(
accounts=[
AdminEmployerAccount(
id=account.id,
email=account.email,
employer_id=account.employer_id,
created_at=account.created_at.isoformat() if account.created_at else "",
email_verified=False, # D-097: UNVERIFIED, always False
)
for account in rows
],
count=len(rows),
)
@router.get("/claims", response_model=AdminClaimsResponse)
def list_claims(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
status: str | None = None,
) -> AdminClaimsResponse:
"""The claim queue: status, the stored evidence snapshot (the
domain-match signal is ADVISORY — the signal informs, the operator
decides; match=false is the example.com corpus norm, R-65), and
the decided state. Optional ?status= facet (the admin queue's
consume-free read path)."""
rows = store.list_claims(status=status)
return AdminClaimsResponse(
claims=[_claim_to_contract(row) for row in rows],
count=len(rows),
)
# -- decisions (D-089 — the explicit operator action) ----------------------------
def _decide(store: EmployerStore, claim_id: str, decision: str) -> EmployerClaim:
"""The shared decide path: unknown claim → 404; the store's guard
arms + the employer_accounts.employer_id UNIQUE surface as the
honest 409s (constraint names only — never account identifiers);
decide_claim appends the operator audit event in the SAME
transaction as the decision."""
try:
row = store.decide_claim(claim_id, decision=decision, operator=OPERATOR)
except IntegrityError as exc:
message = str(getattr(exc, "orig", exc))
if "employer_accounts.employer_id already set" in message:
# decide_claim's account-already-owner guard — the
# one-account-one-entity arm, hand-raised by the store.
raise HTTPException(
status_code=409,
detail=("the claim's account already holds an employer entity"),
) from None
if "UNIQUE constraint failed: employer_accounts.employer_id" in (message):
# The approve flush refused: a DIFFERENT account already
# owns the entity — surfaced, never silently overwritten.
raise HTTPException(
status_code=409,
detail="the employer entity is already owned",
) from None
if "is already" in message:
# decide_claim's not-pending guard — deciding a decided
# claim, the IntegrityError shape (the honest 409).
raise HTTPException(
status_code=409,
detail="claim is already decided",
) from None
# Unmapped IntegrityError — re-raise (a 500): never mask an
# unknown constraint violation as a decision.
raise
if row is None:
raise HTTPException(status_code=404, detail="unknown claim")
return _claim_to_contract(row)
@router.post("/claims/{claim_id}/approve", response_model=ClaimDecisionResponse)
def approve_claim(
claim_id: str,
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> ClaimDecisionResponse:
"""Approve → the claim's account.employer_id is set (one account :
one entity — the UNIQUE activates) + decided_by/decided_at + the
operator audit event, all in one transaction. Evidence never
auto-activates anything: the operator decision is the action."""
row = _decide(store, claim_id, "approved")
return ClaimDecisionResponse(claim=row)
@router.post("/claims/{claim_id}/reject", response_model=ClaimDecisionResponse)
def reject_claim(
claim_id: str,
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> ClaimDecisionResponse:
"""Reject → decided_by/decided_at + the operator audit event (same
transaction); the entity is FREED for a new claim (the partial
pending index never touches rejected rows, G-316)."""
row = _decide(store, claim_id, "rejected")
return ClaimDecisionResponse(claim=row)
# -- audit trail (D-089's queryable operator trail) ------------------------------
@router.get("/audit", response_model=AdminAuditResponse)
def list_audit(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
account_id: str | None = None,
employer_id: str | None = None,
kind: str | None = None,
name: str | None = None,
limit: int = 100,
) -> AdminAuditResponse:
"""The queryable audit trail: every auth/claim event with operator
provenance — the claim.submitted/claim.approved/claim.rejected
names; decided_by/decided_at ride the claim rows the decision
events reference (detail.claim_id joins them)."""
rows = store.list_audit_events(
account_id=account_id,
employer_id=employer_id,
kind=kind,
name=name,
limit=limit,
)
return AdminAuditResponse(
events=[
AdminAuditEvent(
id=event.id or 0,
account_id=event.account_id,
employer_id=event.employer_id,
kind=event.kind,
name=event.name,
detail=dict(event.detail or {}),
operator=event.operator,
created_at=event.created_at.isoformat() if event.created_at else None,
)
for event in rows
],
count=len(rows),
)
@@ -0,0 +1,99 @@
"""The D-091 shared posting-entitlement gate (REQ-10-004, R-58).
ONE helper, TWO principal gates: the admin create route (v0.9,
marketplace_admin) and the employer create route (v0.10 Task 4-1-02)
both compose THIS — never a re-implemented copy (the no-drift shape;
extraction is the only way "the exact v0.9 check parameterized" holds).
The helper lives in api/ beside both callers — no billing/→api/
boundary violation (billing/ stays ignorant of routes, D-027/D-048).
Wire contract (R-58 — byte-stable, D-080/D-084): the 402 detail dict is
{reason, posts_used: 0, posts_limit: 0, upgrade_cta} with posts_used and
posts_limit HARDCODED 0 in BOTH arms. The zeros are the v0.9 wire shape
the web client's UpgradeRequiredError ignores (it consumes reason +
upgrade_cta only, apps/web/lib/engine-client.ts) — do NOT "improve" them
into live counts; the payload-parity test pins the bytes.
Edge ordering (mirrors the extracted v0.9 gate exactly):
1. store-missing (None) → 503 fail-closed BEFORE any consume attempt —
a payment gate never silently skips (the final-review fix).
2. consume_posting → NoActivePackage → the no_active_package 402 arm.
3. consume_posting → PackageExhausted → the posting_limit_reached arm.
The gate runs BEFORE the caller's insert — a 402 consumes nothing,
creates nothing. `consume_posting` itself (G-304 single conditional
UPDATE) is untouched; this helper only maps its exceptions to the
honest 402 arms.
"""
from __future__ import annotations
from fastapi import HTTPException
from ..billing.store import BillingStore, NoActivePackage, PackageExhausted
def consume_posting_entitlement(
store: BillingStore | None,
employer_id: str,
upgrade_cta: str,
) -> None:
"""Spend one posting post for `employer_id`, or raise the honest 402.
Args:
store: the billing store from app.state (None = a wiring miss →
503 fail-closed — posting creation is refused until the
service is restarted, never an ungated insert).
employer_id: the entity whose package is charged.
upgrade_cta: the caller-principal's checkout pointer
("/v1/billing/admin/checkout" from the admin route; the
employer route passes its own) — interpolated into the two
FIXED per-arm sentences, so the rendered upgrade_cta field
differs per principal while reason/posts_used/posts_limit
stay byte-identical across both gates (the D-091 parity).
Raises:
HTTPException 503: the store is missing (fail-closed).
HTTPException 402: no active package / all posts spent — the
canonical dict payload (hardcoded zeros, R-58).
"""
if store is None:
raise HTTPException(
status_code=503,
detail=(
"billing entitlement store is unavailable — posting "
"creation is refused until the service is restarted "
"(the store wires at boot)"
),
)
try:
store.consume_posting(employer_id)
except NoActivePackage:
raise HTTPException(
status_code=402,
detail={
"reason": "no_active_package",
# R-58: hardcoded zeros — the wire contract, never live
# counts (real limits render only from the entitlement
# view; the client's UpgradeRequiredError consumes
# reason + upgrade_cta alone).
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer has no active posting package — "
f"purchase one via {upgrade_cta}."
),
},
) from None
except PackageExhausted:
raise HTTPException(
status_code=402,
detail={
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer's posting package is exhausted — "
f"buy more posts or upgrade via {upgrade_cta}."
),
},
) from None
@@ -37,6 +37,7 @@ from .deps import (
get_provider,
get_settings,
)
from .entitlement import consume_posting_entitlement
logger = logging.getLogger(__name__)
@@ -164,60 +165,23 @@ async def create_posting(
"""Create a DRAFT posting (founder authoring path, D-050 admin_api
adapter — source=admin). Publish is a separate lifecycle call.
v0.9 P4 (REQ-9-004): the employer's billing entitlement composes
AFTER the admin gate — the package limit is enforced here (one
post per create), honest 402 + upgrade-CTA payload when the
employer has no active package or is out of posts. G-309: the
seeded-corpus ingest (and any future adapter) bypasses this check
by construction — enforcement exists ONLY in this admin route.
v0.10 P4 (D-091, Task 4-1-01): the inline gate is extracted into the
shared api-layer helper `consume_posting_entitlement` (api/entitlement.
py) — this admin route and the employer create route (Task 4-1-02)
compose the SAME gate, never re-implemented copies. The admin
principal passes the admin-checkout pointer; the 402 dict (hardcoded
posts_used/posts_limit zeros, R-58) is byte-stable across both
gates — the wire contract UpgradeRequiredError consumes unchanged.
Final-review fix (fail-closed): the store is fetched through the
SAME app.state DI the billing routes use (deps.get_billing_store's
direct attribute read). The old getattr+hasattr soft-guard made a
wiring miss silently SKIP the entitlement check — an unbilled
posting. A payment gate must fail closed: no store → 503 with the
fix-pointer, never an ungated 201.
Final-review fix preserved (fail-closed): the store is fetched via
getattr(app.state); a wiring miss (None) makes the helper refuse
with 503 — never an ungated 201.
"""
from ..billing.store import NoActivePackage, PackageExhausted
billing_store = getattr(request.app.state, "billing_store", None)
if billing_store is None:
raise HTTPException(
status_code=503,
detail=(
"billing entitlement store is unavailable — posting "
"creation is refused until the service is restarted "
"(the store wires at boot)"
),
)
try:
billing_store.consume_posting(body.employer_id)
except NoActivePackage:
raise HTTPException(
status_code=402,
detail={
"reason": "no_active_package",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer has no active posting package — "
"purchase one via /v1/billing/admin/checkout."
),
},
) from None
except PackageExhausted:
raise HTTPException(
status_code=402,
detail={
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer's posting package is exhausted — "
"buy more posts or upgrade via /v1/billing/admin/checkout."
),
},
) from None
consume_posting_entitlement(
getattr(request.app.state, "billing_store", None),
body.employer_id,
"/v1/billing/admin/checkout",
)
posting = Posting(
id=f"adm-{uuid.uuid4().hex[:16]}",
source="admin",
+10
View File
@@ -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,58 @@
"""Employer B2B auth module (REQ-10-001, D-086..D-097, v0.10).
The 8th domain module inside apps/ai-service (no new apps, no new ports,
no new dependencies — the D-031 boundary): pydantic contracts
(`base.py`), the D-027 store family's eighth SQLite store
(`store.py`, R-57/D-095), and the stdlib scrypt KDF (`passwords.py`,
D-086/R-55). Claim evidence (`claim_evidence.py`) and the auth routes
(`api/employers.py` via DI) land with their owning phases. No
`service.py` anywhere (R-57) — routes compose the store via api/ DI.
G-315: email fields are plain `str` with stdlib-only validation —
pydantic `EmailStr` is BANNED (email-validator would be the first new
dependency, breaking R-66/D-086).
D-097: employer accounts are UNVERIFIED — no learner gate
(require_verified_age/allowlist) is ever composed on an employer route.
Boundary (D-031 family discipline): `employers/` never imports `agents/`
or `api/`; this package imports stdlib + pydantic + `config` only.
"""
from .base import (
CLAIM_STATUSES,
OWN_POSTING_STATUSES,
ClaimEmployerRequest,
ClaimStatus,
EmployerAccount,
EmployerClaim,
EmployerMe,
EmployerPostingCreate,
EmployerPostingEdit,
EmployerPostingWriteResponse,
LoginEmployerRequest,
OwnPostingsFacets,
OwnPostingsResponse,
OwnPostingSummary,
RegisterEmployerRequest,
TokenResponse,
)
__all__ = [
"CLAIM_STATUSES",
"ClaimEmployerRequest",
"ClaimStatus",
"EmployerAccount",
"EmployerClaim",
"EmployerMe",
"EmployerPostingCreate",
"EmployerPostingEdit",
"EmployerPostingWriteResponse",
"LoginEmployerRequest",
"OWN_POSTING_STATUSES",
"OwnPostingSummary",
"OwnPostingsFacets",
"OwnPostingsResponse",
"RegisterEmployerRequest",
"TokenResponse",
]
@@ -0,0 +1,283 @@
"""Employer auth pydantic contracts (REQ-10-001, D-086..D-097).
The wire + domain shapes for the employer module. `EmployerAccount` /
`EmployerClaim` are the domain rows served over the wire; the request
pair carries G-315 email discipline + D-086 password min length;
`TokenResponse` / `EmployerMe` are the auth route responses.
G-315: email fields are plain `str` with stdlib-only validation —
non-empty, exactly one "@", non-empty domain, stripped + lowercased on
normalize. pydantic `EmailStr` is BANNED (email-validator is not in the
venv and adding it would be the first new dependency, breaking
R-66/D-086).
D-097: `EmployerAccount.email_verified` is `Literal[False]` — the
account is UNVERIFIED, displayed as such; never presented as verified.
Id honesty (A-305, the family rule): every id in this module is an
opaque handle minted UNIQUELY per creation call — never derived from
user input or a deterministic hash of it.
P3 (REQ-10-003, G-319): `OwnPostingSummary` REUSES the PostingSummary
wire shape by subclassing marketplace.base.Posting (the PostingSummary
serialization contract — never duplicated) + `applicant_count`. The PII
boundary is binding: applicant COUNTS are the only applications-derived
datum — learner identifiers never ride any employer-facing response.
Boundary (D-031 family): `employers/` never imports `agents/` or `api/`;
stdlib + pydantic + the marketplace WIRE contracts (G-319's PostingSummary
reuse — a pure-pydantic module, never a store/engine import) only.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..marketplace.base import Posting
#: Claim lifecycle states (D-089): every claim starts pending; an
#: operator approve/reject decides it. Approved activates ownership
#: (one account : one entity); rejection frees the entity for a new
#: claim. Deferred to P2's claim routes — the shape lands now.
ClaimStatus = Literal["pending", "approved", "rejected"]
#: Tuple mirror of the Literal (docs + tests import this; keeps the enum
#: and any exhaustive switch in one place).
CLAIM_STATUSES: tuple[ClaimStatus, ...] = (
"pending",
"approved",
"rejected",
)
#: G-315: password minimum length (D-086 honest validation floor).
PASSWORD_MIN_LENGTH = 8
def validate_email(value: str) -> str:
"""G-315: stdlib-only email validation + normalization.
Non-empty (after strip), exactly one "@", non-empty local part and
non-empty domain — normalized to stripped + lowercased. Deliberately
NOT RFC-5322: the honest floor for an unverified pilot signup is
structural (exactly one "@", a domain after it), never a regex
masquerade. pydantic `EmailStr` is BANNED (it would require
email-validator — the first new dependency, breaking R-66/D-086).
"""
normalized = value.strip().lower()
if not normalized:
raise ValueError("email must be non-empty")
if normalized.count("@") != 1:
raise ValueError('email must contain exactly one "@"')
local, _, domain = normalized.partition("@")
if not local:
raise ValueError("email local part must be non-empty")
if not domain:
raise ValueError("email domain must be non-empty")
return normalized
class EmployerAccount(BaseModel):
"""An employer-side login account (D-087).
`employer_id` is nullable-until-claim-approval (the UNIQUE
one-account-one-entity constraint activates on approve, P2). The
account is UNVERIFIED (D-097): `email_verified` is `Literal[False]`
— no email verification exists in v0.10, so the field can never
carry `True`, and no learner gate is ever composed on an employer
route.
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
#: G-315: plain str, stdlib-validated + normalized.
email: str = Field(min_length=3)
#: None until a claim is approved (P2).
employer_id: str | None = None
created_at: datetime
#: D-097: UNVERIFIED — displayed as such, never as verified.
email_verified: Literal[False] = False
class EmployerClaim(BaseModel):
"""An account's claim on an EmployerRow entity (D-089).
Advisory evidence is computed at claim time (P2's claim_evidence.py)
and stored as provenance JSON; the operator decision activates
ownership. Auto-approval is deferred (D-089: without email
verification the match is forgeable).
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
account_id: str = Field(min_length=1)
employer_id: str = Field(min_length=1)
status: ClaimStatus
evidence: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
#: Operator provenance (decided_by/decided_at) — None while pending.
decided_by: str | None = None
decided_at: datetime | None = None
class RegisterEmployerRequest(BaseModel):
"""POST /v1/employers/auth/register body (Task 1-2-01).
Honest validation (G-315 + D-086): stdlib email normalization; the
password carries a min-length-8 floor. Neither field is ever
echoed back by a 422 (the pii_fields scrub lands with the routes).
"""
email: str = Field(min_length=3)
password: str = Field(min_length=PASSWORD_MIN_LENGTH)
@field_validator("email")
@classmethod
def _validate_email(cls, value: str) -> str:
return validate_email(value)
class LoginEmployerRequest(RegisterEmployerRequest):
"""POST /v1/employers/auth/login body — same honest validation
(G-315 email + password floor); subclass so both routes share the
contract and drift is structurally impossible."""
class ClaimEmployerRequest(BaseModel):
"""POST /v1/employers/claims body (Task 2-1-03, D-089): the
marketplace EmployerRow id being claimed. The entity's existence
is checked against the marketplace store at the route (404
unknown); evidence is computed from the session account's email +
the entity's website AT CLAIM TIME (R-65)."""
employer_id: str = Field(min_length=1)
class TokenResponse(BaseModel):
"""Login/register session response (D-087): the bearer token (raw,
exactly once on this response — only sha256(token) is stored) and
its expiry (login + SESSION_TTL_DAYS, evaluated lazily at read)."""
model_config = ConfigDict(frozen=True)
token: str = Field(min_length=1)
expires_at: datetime
class EmployerMe(BaseModel):
"""GET /v1/employers/auth/me response (Task 1-2-01): the account
(unverified email displayed as such, D-097) + the claimed entity +
the claim state — None until a claim exists (P2)."""
model_config = ConfigDict(frozen=True)
account: EmployerAccount
#: None until a claim is approved (P2's decide_claim sets it).
claimed_employer_id: str | None = None
#: None until a claim is filed; the claim state (pending/approved/
#: rejected) once one exists (P2).
claim: EmployerClaim | None = None
#: Own-postings status facets (D-092/G-319): the posting lifecycle set
#: (marketplace.base.PostingStatus) — every facet key is one of these.
OWN_POSTING_STATUSES: tuple[str, ...] = ("draft", "published", "closed")
class OwnPostingSummary(Posting):
"""G-319: the employer-facing posting summary (Task 3-1-01) —
the PostingSummary wire shape REUSED (subclass of marketplace.base.
Posting — one serialization contract, never duplicated) + the
per-posting applicant COUNT (the D-092 overview data source, read
through the batched count_applications_by_posting — never a
per-posting N+1).
PII boundary (binding): applicant_count is the ONLY applications-
derived datum on this contract — learner identifiers never ride
any /v1/employers/* response."""
model_config = ConfigDict(frozen=True)
applicant_count: int = 0
class OwnPostingsFacets(BaseModel):
"""Status facet counts over the session employer's OWN postings
(draft|published|closed — the full lifecycle, unlike the public
board's published-only view). Every status key is ALWAYS present
(a status with zero postings carries its honest 0)."""
model_config = ConfigDict(frozen=True)
draft: int = 0
published: int = 0
closed: int = 0
class OwnPostingsResponse(BaseModel):
"""GET /v1/employers/postings response (Task 3-1-01, G-319): the
claimed employer's OWN postings (all lifecycle statuses, posted_at
DESC then id — the list_postings order) + per-status facet counts
+ the honest total. Unclaimed accounts never see this shape (the
route's 403 claim-CTA precedes it)."""
model_config = ConfigDict(frozen=True)
postings: list[OwnPostingSummary] = Field(default_factory=list)
facets: OwnPostingsFacets
total: int = 0
class EmployerPostingCreate(BaseModel):
"""POST /v1/employers/postings body (Task 4-1-02, REQ-10-004): the
employer-authored draft (the AdminPostingCreate shape minus
employer_id — the entity derives from the session's CLAIM, never
the body; G-317 stamps source="employer" at the route). Starts as
a DRAFT; publish is an explicit call (the admin-route lifecycle
discipline)."""
model_config = ConfigDict(frozen=True)
title: str = Field(min_length=1)
description: str = ""
required_competencies: list[str] = Field(default_factory=list)
skills: list[str] = Field(default_factory=list)
seniority: str | None = None
category: str | None = None
location: str = ""
remote: bool = False
salary_min: int | None = None
salary_max: int | None = None
class EmployerPostingEdit(BaseModel):
"""PATCH /v1/employers/postings/{id} body (Task 4-1-02, G-318): the
AdminPostingEdit shape — partial content edit, fields omitted stay
put; a successful edit RE-TAGS the posting (tags are derived data,
D-052 — the tagger overwrites). Own DRAFTS only."""
model_config = ConfigDict(frozen=True)
title: str | None = Field(default=None, min_length=1)
description: str | None = None
skills: list[str] | None = None
class EmployerPostingWriteResponse(BaseModel):
"""Create/edit/publish/close response (Task 4-1-02): the
AdminPostingResponse shape (G-317's source stamp rides it —
"employer" on every employer-authored row; seeded/admin rows keep
their own)."""
model_config = ConfigDict(frozen=True)
id: str
source: str
external_id: str
employer_id: str
title: str
status: str
@@ -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()
+59 -12
View File
@@ -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(
+22
View File
@@ -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,231 @@
"""D-091 payload-parity tests (REQ-10-004, MH-4a, R-58).
The shared gate's 402 wire contract is BYTE-STABLE: {reason,
posts_used: 0, posts_limit: 0, upgrade_cta} — the zeros are the v0.9
wire shape the web client's UpgradeRequiredError ignores (it consumes
reason + upgrade_cta only, apps/web/lib/engine-client.ts), never live
counts. These tests pin the exact dict through the admin route (the
v0.9 caller) and prove the principal parameterization: only the
upgrade_cta field carries the caller's checkout pointer — reason and
the hardcoded zeros are identical across principals. The employer
route (Task 4-1-02) will compose the SAME helper, extending this file
with the employer-gate arm.
Fixtures mirror tests/billing/test_entitlement_enforcement.py (the
R-64 state-injection make_client + grant_posts precedent) — the
enforcement suite itself stays UNCHANGED (the no-drift proof).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.api.entitlement import consume_posting_entitlement
from ai_service.billing.store import SQLiteBillingStore
from ai_service.config import Settings
from ai_service.main import create_app
from ..conftest import SUITE_LEARNERS
ADMIN_TOKEN = "test-parity-admin"
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
def make_client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> TestClient:
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(ADMIN_TOKEN),
)
app = create_app(settings)
app.state.billing_store = billing_store # state-injection (G-9)
return TestClient(app)
@pytest.fixture()
def client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[TestClient]:
with make_client(tmp_path, billing_store) as c:
yield c
def create_posting(client: TestClient, employer: str):
return client.post(
"/v1/marketplace/admin/postings",
headers={"X-Admin-Token": ADMIN_TOKEN},
json={"employer_id": employer, "title": "Parity Test Role"},
)
def grant_posts(
billing_store: SQLiteBillingStore, employer: str, limit: int | None
) -> None:
"""A direct purchase grant (the store path the webhook would take;
the test_entitlement_enforcement.py:70-85 precedent)."""
session_row = billing_store.insert_checkout_session(
provider_session_id=f"cs_parity_{employer}",
employer_id=employer,
item_id="pkg-single" if limit == 1 else "pkg-bundle",
item_name="Test Grant",
price_cents=0,
mode="payment",
)
billing_store.create_purchase_from_session(
session_row, posts_limit=limit, duration_days=30
)
class TestPayloadParity:
def test_no_package_402_exact_dict(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4a parity: no package → the EXACT canonical 402 dict —
hardcoded posts_used/posts_limit zeros (R-58), admin CTA."""
response = create_posting(client, "emp-parity-none")
assert response.status_code == 402
assert response.json()["detail"] == {
"reason": "no_active_package",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer has no active posting package — "
"purchase one via /v1/billing/admin/checkout."
),
}
def test_exhausted_402_exact_dict(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4a parity (exhausted arm): the EXACT canonical dict — the
zeros stay byte-stable even though the employer has a REAL
package with real counts (posts_used=1, posts_limit=1 in the
store; the payload never carries live limits)."""
grant_posts(billing_store, "emp-parity-spent", 1)
first = create_posting(client, "emp-parity-spent")
assert first.status_code == 201
second = create_posting(client, "emp-parity-spent")
assert second.status_code == 402
detail = second.json()["detail"]
assert detail == {
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer's posting package is exhausted — "
"buy more posts or upgrade via /v1/billing/admin/checkout."
),
}
# The store's truth differs from the payload's zeros — the
# wire contract is NOT a limits view (R-58 pin). The exhausted
# row is invisible to get_entitlement (no active purchase
# left), so the raw row read carries the truth (the
# test_expired_degrades_to_402 engine-session precedent).
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
row = session.execute(
text(
"SELECT posts_used, posts_limit FROM billing_purchases "
"WHERE employer_id = 'emp-parity-spent'"
)
).one()
assert tuple(row) == (1, 1)
def test_402_consumes_nothing_creates_nothing(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""R-58 gate-before-insert: a 402 consumes nothing (posts_used
unchanged) and creates nothing (no marketplace row) — the gate
runs BEFORE the insert."""
grant_posts(billing_store, "emp-parity-gate", 1)
assert create_posting(client, "emp-parity-gate").status_code == 201
marketplace_store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
before = marketplace_store.list_postings_by_employer(
"emp-parity-gate"
)
assert len(before) == 1
denied = create_posting(client, "emp-parity-gate")
assert denied.status_code == 402
# Nothing consumed: posts_used still 1 (the failed create spent
# nothing — the exception aborted before any second UPDATE; the
# row is now exhausted → invisible to get_entitlement, so the
# raw row read carries the truth).
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
posts_used = session.execute(
text(
"SELECT posts_used FROM billing_purchases "
"WHERE employer_id = 'emp-parity-gate'"
)
).scalar_one()
assert posts_used == 1
# Nothing created: the failed create inserted no marketplace row.
after = marketplace_store.list_postings_by_employer(
"emp-parity-gate"
)
assert len(after) == 1
assert [p.id for p in after] == [p.id for p in before]
def test_only_cta_differs_per_principal(
self, billing_store: SQLiteBillingStore
) -> None:
"""The D-091 parity core: the SAME store state through the
shared helper with two principals' CTAs → reason/posts_used/
posts_limit identical, ONLY upgrade_cta differs (the employer
route, Task 4-1-02, composes this same arm in-route)."""
from fastapi import HTTPException
payloads: list[dict[str, object]] = []
for cta in (
"/v1/billing/admin/checkout",
"/v1/employers/billing/checkout",
):
with pytest.raises(HTTPException) as exc_info:
consume_posting_entitlement(
billing_store, "emp-parity-principal", cta
)
assert exc_info.value.status_code == 402
payloads.append(dict(exc_info.value.detail)) # type: ignore[arg-type]
admin_payload, employer_payload = payloads
assert admin_payload["reason"] == employer_payload["reason"]
assert admin_payload["posts_used"] == 0
assert admin_payload["posts_limit"] == 0
assert employer_payload["posts_used"] == 0
assert employer_payload["posts_limit"] == 0
assert admin_payload["upgrade_cta"] != employer_payload["upgrade_cta"]
assert "/v1/billing/admin/checkout" in str(admin_payload["upgrade_cta"])
assert "/v1/employers/billing/checkout" in str(
employer_payload["upgrade_cta"]
)
def test_store_missing_fails_closed(
self, tmp_path: Path, billing_store: SQLiteBillingStore
) -> None:
"""The fail-closed 503 guard rides along with the extraction:
a wiring miss (None store) refuses creation — never an ungated
201 (the final-review fix, preserved in the helper)."""
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
consume_posting_entitlement(None, "emp-parity-unwired", "x")
assert exc_info.value.status_code == 503
assert "unavailable" in str(exc_info.value.detail)
@@ -0,0 +1,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.
"""
+105
View File
@@ -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,315 @@
"""Claim route tests — POST /v1/employers/claims (Task 2-1-03, REQ-10-002).
MH-2b pinned here: the exact evidence dict
{account_email_domain, entity_website_domain, match} computed AT CLAIM
TIME, stored as provenance JSON on the claim row, returned with the
pending claim. The entity must exist in the marketplace store (404);
the account must not already hold an entity (409 honest); one pending
claim per entity (409 — G-316's index backstops the race); the route is
session-gated (401 unauthenticated). Auto-approval is deferred (D-089).
REAL scrypt throughout (register mints a real hash — the conftest
fixtures handle limiter resets).
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer
from .conftest import (
TEST_EMAIL,
bearer,
make_client,
register,
)
ENTITY_ID = "emp-openai"
def seed_employer(client: TestClient, website: str = "https://example.com/openai") -> None:
"""Upsert a marketplace EmployerRow through the app's own store
(app.state.marketplace_store — the same DI the route composes)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=ENTITY_ID,
source="seed-corpus",
external_id=ENTITY_ID,
name="OpenAI Labs",
website=website,
)
)
def claim(client: TestClient, token: str, employer_id: str = ENTITY_ID):
return client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
class TestClaimRoute:
def test_claim_pending_with_exact_evidence(self, client: TestClient) -> None:
"""Claim → 201 pending with the EXACT evidence dict (MH-2b):
the R-65 example.com corpus makes match=false corpus-wide."""
seed_employer(client)
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201, resp.text
claim_row = resp.json()
assert claim_row["status"] == "pending"
assert claim_row["employer_id"] == ENTITY_ID
assert claim_row["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "example.com",
"match": False,
}
assert claim_row["decided_by"] is None
assert claim_row["decided_at"] is None
assert claim_row["account_id"]
def test_evidence_true_on_matching_domain(
self, client: TestClient
) -> None:
"""A matching website domain (R-65's registrable case) — the
evidence signal is ADVISORY: match=true still files a PENDING
claim (auto-approval deferred, D-089/A-304)."""
seed_employer(client, website="https://nextcraft.example/careers")
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201, resp.text
assert resp.json()["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "nextcraft.example",
"match": True,
}
assert resp.json()["status"] == "pending"
def test_unknown_entity_404(self, client: TestClient) -> None:
token = register(client)["token"]
resp = claim(client, token, employer_id="emp-does-not-exist")
assert resp.status_code == 404
def test_account_already_holds_entity_409(
self, client: TestClient
) -> None:
"""One account : one entity — an owner claiming a SECOND
(free) entity gets the honest 409. The ownership binds via the
store's decide_claim (the same transition the admin route
drives in Wave 2-2)."""
seed_employer(client)
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
token = register(client)["token"]
first = claim(client, token)
assert first.status_code == 201
# Operator approve (the store path the 2-2 route composes).
decided = store.decide_claim(
first.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
# A second, otherwise-free entity — still refused.
store2 = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store2.upsert_employer(
Employer(
id="emp-other",
source="seed-corpus",
external_id="emp-other",
name="Other Labs",
website="https://example.com/other",
)
)
resp = claim(client, token, employer_id="emp-other")
assert resp.status_code == 409
def test_second_pending_claim_same_entity_409(
self, client: TestClient
) -> None:
"""One PENDING claim per entity — the check-first 409 fast
path, by ANOTHER account (the same account's duplicate also
409s; the other-account arm is the stronger pin)."""
seed_employer(client)
first_token = register(client)["token"]
assert claim(client, first_token).status_code == 201
second_token = register(client, email="other@example.com")["token"]
resp = claim(client, second_token)
assert resp.status_code == 409
def test_unauthenticated_401(self, client: TestClient) -> None:
seed_employer(client)
resp = client.post(
"/v1/employers/claims", json={"employer_id": ENTITY_ID}
)
assert resp.status_code == 401
def test_invalid_token_401(self, client: TestClient) -> None:
seed_employer(client)
resp = client.post(
"/v1/employers/claims",
json={"employer_id": ENTITY_ID},
headers=bearer("not-a-real-token"),
)
assert resp.status_code == 401
def test_claim_evidence_persisted_as_provenance(
self, client: TestClient
) -> None:
"""The evidence snapshot rides the claim ROW (stored at claim
time, never recomputed on read — D-089) + a claim.submitted
audit event lands."""
from sqlalchemy import text
seed_employer(client)
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
with store._engine.connect() as conn:
evidence = conn.execute(
text("SELECT evidence FROM employer_claims")
).scalar_one()
audit = conn.execute(
text(
"SELECT kind, name FROM employer_audit_events "
"WHERE kind = 'claim'"
)
).fetchall()
assert evidence == (
'{"account_email_domain": "nextcraft.example", '
'"entity_website_domain": "example.com", "match": false}'
)
assert audit == [("claim", "claim.submitted")]
def test_empty_employer_id_422(self, client: TestClient) -> None:
token = register(client)["token"]
resp = client.post(
"/v1/employers/claims", json={"employer_id": ""}, headers=bearer(token)
)
assert resp.status_code == 422
class TestRacedInsert:
def test_raced_pending_claim_same_honest_409(
self, tmp_path
) -> None:
"""G-316 at the route: a second pending row raced past the
check-first read is refused by the partial UNIQUE index — the
surfaced IntegrityError maps to the SAME honest 409, never a
duplicate pending row (proven by inserting the rival row
directly through the store, bypassing the route's check)."""
with make_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
# The rival pending claim lands BETWEEN this route's
# check-first read and its insert only under a race; a
# direct store insert simulates the raced writer.
rival_account, _ = employer_store.insert_account(
email="rival@example.com",
password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA",
)
employer_store.insert_claim(
account_id=rival_account.id,
employer_id=ENTITY_ID,
evidence={},
)
resp = claim(client, token)
assert resp.status_code == 409
pending = employer_store.list_claims(status="pending")
assert len(pending) == 1 # never a duplicate pending row
class TestClaimDiscipline:
def test_marketplace_store_via_di_never_imported(
self,
) -> None:
"""Territory rule: the route composes the marketplace store
via app.state DI (get_marketplace_store) — api/employers.py
never CONSTRUCTS a store. P4 (Task 4-1-02): the write routes
compose the admin routes' own imports (InvalidTransition,
MarketplaceStore annotations — the marketplace_admin.py:32
precedent), so the pin asserts the CONSTRUCTION ban, not the
import ban: no SQLiteMarketplaceStore anywhere in the
module, the DEP composed on every store touch."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "SQLiteMarketplaceStore" not in source
assert "get_marketplace_store" in source
def test_no_learner_gate_imported(self) -> None:
"""D-097: no learner gate composed on any employer route —
pinned at the IMPORT sites (the api.identity imports are what
compose gates; docstring mentions are copy, not code)."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_route_order_entity_check_before_owner_check(
self, tmp_path
) -> None:
"""The 404 (unknown entity) wins over the 409 (already an
owner): the entity is resolved first — an owner probing an
unknown id learns nothing about ownership state."""
with make_client(tmp_path) as client:
token = register(client)["token"]
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
account = store.get_account_by_email(TEST_EMAIL)
assert account is not None
with store._engine.connect() as conn:
from sqlalchemy import text
conn.execute(
text(
"UPDATE employer_accounts SET employer_id = "
"'emp-owned' WHERE id = :acct"
),
{"acct": account.id},
)
conn.commit()
resp = claim(client, token, employer_id="emp-unknown")
assert resp.status_code == 404
def test_no_learner_gate_composed(self) -> None:
"""D-097: no learner gate (require_verified_age/allowlist)
composed on any employer route — pinned at the import sites
(docstring copy is not code)."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_claim_route_registered(tmp_path) -> None:
"""The claims_router is mounted (main.py wiring pin): the route
exists in the OpenAPI schema — POST /v1/employers/claims with the
session-gated claim body (the _IncludedRouter wrappers hide
APIRoutes from app.routes in this starlette, so the schema is the
honest registry)."""
with make_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
path = schema["paths"]["/v1/employers/claims"]
assert set(path) == {"post"}
body_schema = path["post"]["requestBody"]["content"][
"application/json"
]["schema"]
# $ref → ClaimEmployerRequest, whose only required field is
# employer_id.
assert body_schema["$ref"].endswith("ClaimEmployerRequest")
request = schema["components"]["schemas"]["ClaimEmployerRequest"]
assert request["required"] == ["employer_id"]
assert request["properties"]["employer_id"]["minLength"] == 1
@@ -0,0 +1,604 @@
"""Admin claim review/operator-route tests (Task 2-2-01, REQ-10-002, MH-2a/2d).
Pinned: approve → account.employer_id set + audit row + claim approved;
reject frees the entity (a new claim from ANOTHER account succeeds);
decided twice → 409 honest (one decision audit row — no double
activation); the entity-owned arm surfaces as 409, never an overwrite;
fail-closed 403 on ALL five routes (no token / wrong token /
unset-config / cross-principal session token); a 403'd decision
writes NOTHING; the audit query returns claim decisions with operator
provenance; the five routes are mounted + OpenAPI-visible. The
admin-token pattern is the billing one (X-Admin-Token + a SecretStr'd
Settings knob, tests/billing/test_lifecycle_admin.py:26-63).
REAL scrypt throughout (register mints a real hash; the conftest
autouse fixture resets the auth limiter module states — the admin
routes add no limiter state of their own).
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.marketplace.base import Employer
from .conftest import TEST_EMAIL, make_client, register
ADMIN_TOKEN = "test-claims-admin"
ENTITY_ID = "emp-openai"
#: The five-route fail-closed matrix surface (R-56): the three GETs
#: plus the two decision POSTs (formatted with a claim id).
ROUTES = (
"/v1/employers/admin/accounts",
"/v1/employers/admin/claims",
"/v1/employers/admin/audit",
)
DECISION_ROUTES = (
"/v1/employers/admin/claims/{claim_id}/approve",
"/v1/employers/admin/claims/{claim_id}/reject",
)
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
@contextmanager
def make_admin_client(
tmp_path: Path, admin_token: str | None = ADMIN_TOKEN
) -> Iterator[TestClient]:
"""The full app with the admin token CONFIGURED (the billing
pattern: SecretStr knob; admin_token=None exercises the UNSET
fail-closed class — the empty SecretStr)."""
with make_client(tmp_path, admin_token=SecretStr(admin_token or "")) as client:
yield client
def seed_employer(client: TestClient, employer_id: str = ENTITY_ID, website: str = "") -> None:
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name="OpenAI Labs",
website=website or f"https://example.com/{employer_id}",
)
)
def claim(client: TestClient, token: str, employer_id: str = ENTITY_ID) -> dict:
"""Session-gated claim through the REAL route (409/404 arms raise
— the callers pin the happy path)."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()
def decide(client: TestClient, claim_id: str, arm: str = "approve") -> TestClient:
"""Convenience: POST the decision with the configured token; the
CLIENT returns for status asserts."""
client.post(
f"/v1/employers/admin/claims/{claim_id}/{arm}",
headers=admin_headers(),
)
return client
class TestApprove:
def test_approve_sets_ownership_and_audit(self, tmp_path: Path) -> None:
"""MH-2a: approve → claim approved with operator provenance +
account.employer_id set (read through /me — the same store the
route wrote) + the audit row with operator provenance."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert resp.status_code == 200, resp.text
decided = resp.json()["claim"]
assert decided["status"] == "approved"
assert decided["id"] == row["id"]
assert decided["decided_by"] == "operator"
assert decided["decided_at"] is not None
me = client.get(
"/v1/employers/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
assert me.status_code == 200
body = me.json()
assert body["claimed_employer_id"] == ENTITY_ID
assert body["account"]["employer_id"] == ENTITY_ID
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
events = audit.json()["events"]
approve = [e for e in events if e["name"] == "claim.approved"]
assert len(approve) == 1
assert approve[0]["operator"] == "operator"
assert approve[0]["detail"]["claim_id"] == row["id"]
assert approve[0]["detail"]["employer_id"] == ENTITY_ID
assert approve[0]["account_id"]
def test_approve_pops_pending_queue(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="q@example.com")["token"]
row = claim(client, token)
pending = client.get(
"/v1/employers/admin/claims?status=pending",
headers=admin_headers(),
)
assert pending.json()["count"] == 1
assert (
client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
).status_code
== 200
)
pending = client.get(
"/v1/employers/admin/claims?status=pending",
headers=admin_headers(),
)
assert pending.json()["count"] == 0
approved = client.get(
"/v1/employers/admin/claims?status=approved",
headers=admin_headers(),
)
assert approved.json()["count"] == 1
class TestReject:
def test_reject_frees_entity_for_new_claim(self, tmp_path: Path) -> None:
"""Reject → decided + audited; the entity is FREE — a new
claim from ANOTHER account succeeds; the rejected account
owns nothing."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
first_token = register(client, email="first@example.com")["token"]
row = claim(client, first_token)
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/reject",
headers=admin_headers(),
)
assert resp.status_code == 200, resp.text
rejected = resp.json()["claim"]
assert rejected["status"] == "rejected"
assert rejected["decided_by"] == "operator"
second_token = register(client, email="second@example.com")["token"]
second = client.post(
"/v1/employers/claims",
json={"employer_id": ENTITY_ID},
headers={"Authorization": f"Bearer {second_token}"},
)
assert second.status_code == 201, second.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
first = store.get_account_by_email("first@example.com")
assert first is not None and first.employer_id is None
audit = client.get(
"/v1/employers/admin/audit?name=claim.rejected",
headers=admin_headers(),
)
assert audit.json()["count"] == 1
assert audit.json()["events"][0]["operator"] == "operator"
class TestDecideDiscipline:
def test_decide_twice_409_honest(self, tmp_path: Path) -> None:
"""Deciding a DECIDED claim → 409 for BOTH arms (the store's
not-pending guard); exactly ONE decision audit row."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="t@example.com")["token"]
row = claim(client, token)
first = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert first.status_code == 200
again = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert again.status_code == 409
assert again.json()["detail"] == "claim is already decided"
flip = client.post(
f"/v1/employers/admin/claims/{row['id']}/reject",
headers=admin_headers(),
)
assert flip.status_code == 409
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
decisions = [
e
for e in audit.json()["events"]
if e["name"] in ("claim.approved", "claim.rejected")
]
assert len(decisions) == 1
def test_unknown_claim_404(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
for route in DECISION_ROUTES:
resp = client.post(
route.format(claim_id="clm-does-not-exist"),
headers=admin_headers(),
)
assert resp.status_code == 404, route
def test_approve_account_holds_entity_409(self, tmp_path: Path) -> None:
"""The route arm of one-account-one-entity: a pending claim
whose account ALREADY holds an entity (the account became an
owner on another entity after filing) — approve surfaces the
store's hand-raised guard as the honest 409, never an
overwrite. The rival claim is inserted directly through the
store seam (a second pending claim on a DIFFERENT free entity
whose account is the owner)."""
with make_admin_client(tmp_path) as client:
seed_employer(client, "emp-a")
seed_employer(client, "emp-b")
token = register(client, email="owner@example.com")["token"]
first = claim(client, token, "emp-a")
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(first["id"], decision="approved", operator="op")
assert decided is not None
# The now-owner files a second claim on a free entity via
# the store seam (the route would 409 the owner first).
row, created = store.insert_claim(
account_id=decided.account_id,
employer_id="emp-b",
evidence={},
)
assert created
resp = client.post(
f"/v1/employers/admin/claims/{row.id}/approve",
headers=admin_headers(),
)
assert resp.status_code == 409
assert resp.json()["detail"] == ("the claim's account already holds an employer entity")
# Nothing was overwritten: the account still owns emp-a
# only, and the claim row stays pending (undecided).
account = store.get_account(decided.account_id)
assert account is not None
assert account.employer_id == "emp-a"
still = store.get_claim(row.id)
assert still is not None and still.status == "pending"
def test_entity_owned_by_another_409_no_overwrite(self, tmp_path: Path) -> None:
"""The one-APPROVED-owner arm at the route: a pending claim on
an entity whose owner is ANOTHER account (the ownership raced
past the claim route's checks) — the approve flush surfaces the
employer_accounts.employer_id UNIQUE as the honest 409; the
EXISTING owner is never displaced. The rival ownership is set
directly on the account through the store seam (a second
PENDING claim on the entity is impossible — the partial index
refuses it, so the claim-route race is not the reachable seam;
the OWNERSHIP race is)."""
with make_admin_client(tmp_path) as client:
seed_employer(client, "emp-race")
token = register(client, email="racer@example.com")["token"]
row = claim(client, token, "emp-race")
# A rival account becomes the entity's owner through the
# store seam AFTER the claim was filed (direct account
# UPDATE — the same write decide_claim performs, bypassing
# its guards: the seam simulates an ownership that raced).
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival, _ = store.insert_account(
email="rival-owner@example.com",
password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA",
)
from sqlalchemy import text
with store._engine.connect() as conn: # test seam into the store's engine
conn.execute(
text("UPDATE employer_accounts SET employer_id = :entity WHERE id = :acct"),
{"entity": "emp-race", "acct": rival.id},
)
conn.commit()
# Now the OPERATOR approves the pending claim on the same
# entity: the UNIQUE refuses — the honest 409, the rival
# keeps the ownership, the claim row stays pending.
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert resp.status_code == 409
assert resp.json()["detail"] == ("the employer entity is already owned")
owner = store.get_account(rival.id)
assert owner is not None and owner.employer_id == "emp-race"
loser = store.get_account_by_email("racer@example.com")
assert loser is not None and loser.employer_id is None
still = store.get_claim(row["id"])
assert still is not None and still.status == "pending"
class TestAccountsSurface:
def test_accounts_list_columns_and_no_hash(self, tmp_path: Path) -> None:
"""The accounts list: email, claimed entity, created_at, the
UNVERIFIED marker — and password_hash NEVER on the wire."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
rows = client.get("/v1/employers/admin/accounts", headers=admin_headers())
assert rows.status_code == 200
body = rows.json()
assert body["count"] == 1
account = body["accounts"][0]
assert account["email"] == TEST_EMAIL
assert account["employer_id"] is None # pending — unclaimed
assert account["created_at"]
assert account["email_verified"] is False
assert "password_hash" not in account
assert "password" not in account
decide(client, row["id"])
rows = client.get("/v1/employers/admin/accounts", headers=admin_headers())
assert rows.json()["accounts"][0]["employer_id"] == ENTITY_ID
class TestClaimsQueueSurface:
def test_queue_carries_advisory_evidence(self, tmp_path: Path) -> None:
"""The queue renders the stored evidence snapshot + the
domain-match signal as ADVISORY (A-304/R-65: match=false is
the example.com corpus norm — advisory, never verification);
decided state None while pending. The snapshot is the STORE's
claim-time provenance, never recomputed on queue read."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
queue = client.get("/v1/employers/admin/claims", headers=admin_headers())
assert queue.status_code == 200
entry = queue.json()["claims"][0]
assert entry["id"] == row["id"]
assert entry["status"] == "pending"
assert entry["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "example.com",
"match": False,
}
assert entry["decided_by"] is None
assert entry["decided_at"] is None
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_claim(row["id"])
assert stored is not None
assert dict(stored.evidence) == entry["evidence"]
def test_status_facet(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="facet@example.com")["token"]
claim(client, token)
for facet, expected in (
("pending", 1),
("approved", 0),
("rejected", 0),
):
rows = client.get(
f"/v1/employers/admin/claims?status={facet}",
headers=admin_headers(),
)
assert rows.status_code == 200
assert rows.json()["count"] == expected
class TestAuditTrail:
def test_audit_query_provenance(self, tmp_path: Path) -> None:
"""The audit query returns claim decisions with operator
provenance: kind/account/employer/name filters + the
submitted → approved chain, decided_by/decided_at joined via
detail.claim_id."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
assert (
client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
).status_code
== 200
)
claims = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
assert claims.status_code == 200
names = [e["name"] for e in claims.json()["events"]]
assert names == ["claim.approved", "claim.submitted"]
approved = client.get(
"/v1/employers/admin/audit?name=claim.approved",
headers=admin_headers(),
)
assert approved.json()["count"] == 1
event = approved.json()["events"][0]
assert event["operator"] == "operator"
assert event["detail"]["claim_id"] == row["id"]
assert event["detail"]["employer_id"] == ENTITY_ID
by_entity = client.get(
f"/v1/employers/admin/audit?employer_id={ENTITY_ID}",
headers=admin_headers(),
)
assert by_entity.json()["count"] == 2
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
account = store.get_account_by_email(TEST_EMAIL)
assert account is not None
by_account = client.get(
f"/v1/employers/admin/audit?account_id={account.id}",
headers=admin_headers(),
)
# register + claim.submitted + claim.approved all carry the
# account's id (register stamps account_id too).
assert by_account.json()["count"] == 3
# The submitted event's detail snapshots the advisory
# evidence_match signal (P2-SEC: advisory, claim-time).
submitted = client.get(
"/v1/employers/admin/audit?name=claim.submitted",
headers=admin_headers(),
)
assert submitted.json()["events"][0]["detail"]["evidence_match"] is False
# The DECIDED claim carries decided_by/decided_at (the
# operator provenance the decision events reference).
decided_row = store.get_claim(row["id"])
assert decided_row is not None
assert decided_row.decided_by == "operator"
assert decided_row.decided_at is not None
def test_audit_no_filters_returns_all(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="all@example.com")["token"]
claim(client, token)
everything = client.get("/v1/employers/admin/audit", headers=admin_headers())
assert everything.status_code == 200
names = {e["name"] for e in everything.json()["events"]}
assert names == {"register", "claim.submitted"}
class TestFailClosed:
"""MH-2d: all five operator routes fail-closed — no token,
wrong token, the unset-config class (an empty admin_token 403s
EVERY request even with a candidate), and the cross-principal
class (a VALID employer session token is NOT an admin token)."""
def test_all_five_no_token_403(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
for route in ROUTES:
assert client.get(route).status_code == 403, route
for route in DECISION_ROUTES:
assert client.post(route.format(claim_id="clm-x")).status_code == 403, route
def test_all_five_wrong_token_403(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
bad = admin_headers("wrong-token")
for route in ROUTES:
assert client.get(route, headers=bad).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=bad).status_code == 403
), route
def test_unset_token_fails_closed_on_all_five(self, tmp_path: Path) -> None:
"""The R-56 matrix's unset-config arm: AI_ADMIN_TOKEN unset →
every route 403s EVEN WITH a candidate token."""
with make_admin_client(tmp_path, admin_token=None) as client:
candidate = admin_headers("anything")
for route in ROUTES:
assert client.get(route, headers=candidate).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=candidate).status_code
== 403
), route
def test_employer_session_token_is_not_admin(self, tmp_path: Path) -> None:
"""Token namespaces cannot collide (D-090): a VALID employer
session token in the admin header fails the admin gate — the
principal classes stay disjoint (one class fails the other's
gate)."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
session_token = register(client)["token"]
swapped = {"X-Admin-Token": session_token}
for route in ROUTES:
assert client.get(route, headers=swapped).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=swapped).status_code == 403
), route
def test_decisions_fail_closed_leave_no_side_effects(self, tmp_path: Path) -> None:
"""A 403'd decision writes NOTHING: the claim stays pending,
no decision audit row (the gate runs before any store call)."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="s@example.com")["token"]
row = claim(client, token)
for route in DECISION_ROUTES:
assert client.post(route.format(claim_id=row["id"])).status_code == 403, route
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
still = store.get_claim(row["id"])
assert still is not None and still.status == "pending"
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
# claim.submitted only — no decision rows from the 403s.
names = [e["name"] for e in audit.json()["events"]]
assert names == ["claim.submitted"]
class TestMounted:
def test_all_five_routes_openapi_visible(self, tmp_path: Path) -> None:
"""The operator routes are mounted + OpenAPI-visible (the
_IncludedRouter wrappers hide APIRoutes from app.routes in
this starlette — the schema is the honest registry)."""
with make_admin_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
paths = schema["paths"]
for route in ROUTES:
assert route in paths, route
assert set(paths[route]) == {"get"}, route
for route in DECISION_ROUTES:
formatted = route.format(claim_id="{claim_id}")
assert formatted in paths, formatted
assert set(paths[formatted]) == {"post"}, formatted
def test_accounts_contract_no_hash_field(self, tmp_path: Path) -> None:
"""Wire-contract pin: AdminEmployerAccount exposes exactly the
D-094 columns — password_hash is structurally absent."""
with make_admin_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
account = schema["components"]["schemas"]["AdminEmployerAccount"]
assert set(account["properties"]) == {
"id",
"email",
"employer_id",
"created_at",
"email_verified",
}
# D-097: the marker is a constant False on the wire.
assert account["properties"]["email_verified"]["default"] is False
def test_no_learner_gate_composed() -> None:
"""D-097: no learner gate composed on the operator routes —
pinned at the import sites (docstring copy is not code)."""
import inspect
from ai_service.api import employers_admin
source = inspect.getsource(employers_admin)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_require_admin_imported_not_reimplemented() -> None:
"""D-094: the guard is IMPORTED from marketplace_admin (the
billing_admin.py:27 precedent) — no compare/digest in territory."""
import inspect
from ai_service.api import employers_admin
from ai_service.api.marketplace_admin import require_admin
source = inspect.getsource(employers_admin)
assert "from .marketplace_admin import require_admin" in source
assert "compare_digest" not in source
assert employers_admin.require_admin is require_admin
@@ -0,0 +1,407 @@
"""EmployerStore claim-method tests (Task 2-1-02, REQ-10-002).
G-316 pins: the partial UNIQUE index uq_pending_claim_per_entity lands
in sqlite_master on fresh DBs (create_all emits it — tests use fresh
tmp_path DBs, never a P1-era file); a raced second pending insert →
IntegrityError (the caller's 409), never a duplicate pending row.
MH-2c pins: one PENDING claim per entity + one APPROVED owner per
entity + rejection frees the entity. decided_by/decided_at provenance
pinned on both arms.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.employers.store import SQLiteEmployerStore
PASSWORD_HASH = "scrypt$16384$8$1$c2FsdA$aGFzaA" # self-describing string
EVIDENCE = {
"account_email_domain": "openai.example",
"entity_website_domain": "example.com",
"match": False,
}
def make_store(tmp_path: Path) -> SQLiteEmployerStore:
return SQLiteEmployerStore(db_path=tmp_path / "claims-store.db")
def add_account(
store: SQLiteEmployerStore, email: str
) -> str:
row, _ = store.insert_account(email=email, password_hash=PASSWORD_HASH)
return row.id
def add_claim(
store: SQLiteEmployerStore,
*,
account_id: str,
employer_id: str,
evidence: dict | None = None,
) -> tuple[str, bool]:
row, created = store.insert_claim(
account_id=account_id,
employer_id=employer_id,
evidence=evidence if evidence is not None else EVIDENCE,
)
return row.id, created
def count_pending(
store: SQLiteEmployerStore, employer_id: str
) -> int:
with Session(store._engine) as session:
return int(
session.execute(
text(
"SELECT COUNT(*) FROM employer_claims "
"WHERE employer_id = :eid AND status = 'pending'"
),
{"eid": employer_id},
).scalar_one()
)
class TestPartialIndex:
def test_uq_pending_claim_per_entity_in_sqlite_master(
self, tmp_path: Path
) -> None:
"""G-316 pinned: create_all emits the partial UNIQUE index —
check-first alone is TOCTOU; the index makes the guard
structural."""
store = make_store(tmp_path)
with store._engine.connect() as conn:
row = conn.execute(
text(
"SELECT name, sql FROM sqlite_master "
"WHERE type='index' "
"AND tbl_name='employer_claims' "
"AND name='uq_pending_claim_per_entity'"
)
).fetchone()
assert row is not None
assert "UNIQUE INDEX" in str(row[1])
assert "WHERE status='pending'" in str(row[1])
class TestInsertClaim:
def test_first_claim_created_true_pending(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, created = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
assert created is True
row = store.get_claim(claim_id)
assert row is not None
assert row.status == "pending"
assert row.account_id == account_id
assert row.employer_id == "emp-1"
assert row.evidence == EVIDENCE
assert row.decided_by is None and row.decided_at is None
def test_second_pending_claim_rejected_fast_path(
self, tmp_path: Path
) -> None:
"""MH-2c arm 1: one PENDING claim per entity — the check-first
409 fast path (the insert_application precedent)."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, created_first = add_claim(
store, account_id=first, employer_id="emp-1"
)
second_row, created_second = store.insert_claim(
account_id=second, employer_id="emp-1", evidence=EVIDENCE
)
assert created_first is True
assert created_second is False
assert second_row.id == first_id # the EXISTING row rides
assert count_pending(store, "emp-1") == 1
def test_raced_second_pending_insert_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""G-316 pinned: a raced insert (check-first read misses, the
index refuses the write) SURFACES IntegrityError — the caller
maps it to the SAME honest 409; never a duplicate pending row.
Raw SQL via a direct Session simulates the race window after
the check-first read."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
add_claim(store, account_id=account_id, employer_id="emp-1")
with Session(store._engine) as session:
with pytest.raises(IntegrityError):
session.execute(
text(
"INSERT INTO employer_claims "
"(id, account_id, employer_id, status, evidence, "
"created_at) "
"VALUES ('clm-raced', :acct, 'emp-1', 'pending', "
"'{}', :now)"
),
{"acct": account_id, "now": datetime.now(UTC)},
)
session.flush()
session.rollback()
assert count_pending(store, "emp-1") == 1
def test_evidence_stored_as_provenance_json(
self, tmp_path: Path
) -> None:
"""The advisory evidence snapshot rides the claim row (D-089
— computed at claim time, never recomputed on read)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store,
account_id=account_id,
employer_id="emp-1",
evidence={
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
},
)
with Session(store._engine) as session:
stored = session.execute(
text(
"SELECT evidence FROM employer_claims WHERE id = :cid"
),
{"cid": claim_id},
).scalar_one()
# The sa_type=JSON column round-trips through the ORM as a
# dict (insert_claim's own read path proves the stored shape);
# raw SQL returns the serialized JSON string — parse it.
assert json.loads(stored) == {
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
}
# And the ORM read path hands the dict back.
row = store.get_claim(claim_id)
assert row is not None
assert row.evidence == {
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
}
class TestDecideClaim:
def test_approve_sets_employer_id_and_audit_row(
self, tmp_path: Path
) -> None:
"""MH-2c/MH-2a store half: approve → employer_accounts.
employer_id set + the `claim` audit row with operator
provenance."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
decided = store.decide_claim(
claim_id,
decision="approved",
operator="op-1",
now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC),
)
assert decided is not None
assert decided.status == "approved"
assert decided.decided_by == "op-1"
assert decided.decided_at == datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
account = store.get_account(account_id)
assert account is not None
assert account.employer_id == "emp-1"
with Session(store._engine) as session:
audit = session.execute(
text(
"SELECT kind, name, operator, detail "
"FROM employer_audit_events ORDER BY id"
)
).fetchall()
assert len(audit) == 1
kind, name, operator, detail = audit[0]
# Raw SQL hands the serialized JSON string back — parse it.
assert kind == "claim"
assert name == "claim.approved"
assert operator == "op-1"
assert json.loads(detail) == {
"claim_id": claim_id,
"employer_id": "emp-1",
"operator": "op-1",
}
def test_reject_sets_provenance_keeps_account_free(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
decided = store.decide_claim(
claim_id,
decision="rejected",
operator="op-2",
now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC),
)
assert decided is not None
assert decided.status == "rejected"
assert decided.decided_by == "op-2"
assert decided.decided_at == datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
account = store.get_account(account_id)
assert account is not None
assert account.employer_id is None # rejection never binds
def test_rejection_frees_entity_for_new_claim(
self, tmp_path: Path
) -> None:
"""MH-2c arm 3: rejection frees the entity — a new claim on the
same entity succeeds after a reject."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, _ = add_claim(
store, account_id=first, employer_id="emp-1"
)
store.decide_claim(
first_id, decision="rejected", operator="op-1"
)
second_id, created = add_claim(
store, account_id=second, employer_id="emp-1"
)
assert created is True
assert second_id != first_id
row = store.get_claim(second_id)
assert row is not None and row.status == "pending"
def test_second_approve_entity_already_owned_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""MH-2c arm 2: one APPROVED owner per entity. Account A's
claim on emp-1 approves (A owns emp-1); account B then claims
the now-unblocked entity and the second approve collides on
employer_accounts.employer_id UNIQUE — B's UPDATE would bind a
second owner to emp-1. The raw-SQL arm below pins the
STRUCTURAL constraint itself; the decide_claim arm (next test)
pins the store path."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, _ = add_claim(
store, account_id=first, employer_id="emp-1"
)
store.decide_claim(first_id, decision="approved", operator="op-1")
# B claims the entity after the pending window closed.
second_id, _ = add_claim(
store, account_id=second, employer_id="emp-1"
)
with pytest.raises(IntegrityError):
store.decide_claim(second_id, decision="approved", operator="op-2")
# The refused approve bound nothing: B still holds no entity,
# A still owns emp-1, and the second claim stays pending.
a = store.get_account(first)
b = store.get_account(second)
assert a is not None and a.employer_id == "emp-1"
assert b is not None and b.employer_id is None
still = store.get_claim(second_id)
assert still is not None and still.status == "pending"
assert count_pending(store, "emp-1") == 1
def test_decide_unknown_claim_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert (
store.decide_claim(
"clm-nope", decision="approved", operator="op-1"
)
is None
)
def test_decide_already_decided_claim_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""Deciding a decided claim refuses — the hand-raised guard
(IntegrityError shape, the caller's honest 409)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
store.decide_claim(claim_id, decision="rejected", operator="op-1")
with pytest.raises(IntegrityError):
store.decide_claim(
claim_id, decision="approved", operator="op-1"
)
def test_approve_when_account_already_holds_entity_integrity_error(
self, tmp_path: Path
) -> None:
"""One ACCOUNT : one entity — approving B's claim when B
already holds emp-2 refuses (B's employer_id is set; the
hand-raised guard surfaces IntegrityError)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
first_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-2"
)
store.decide_claim(first_id, decision="approved", operator="op-1")
second_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-9"
)
with pytest.raises(IntegrityError):
store.decide_claim(
second_id, decision="approved", operator="op-1"
)
def test_invalid_decision_rejected(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
with pytest.raises(ValueError, match="invalid decision"):
store.decide_claim(
claim_id, decision="maybe", operator="op-1"
)
class TestListClaims:
def test_status_filter_and_newest_first(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
a = add_account(store, "a@example.com")
b = add_account(store, "b@example.com")
add_claim(store, account_id=a, employer_id="emp-1")
add_claim(store, account_id=b, employer_id="emp-2")
pending = store.list_claims(status="pending")
assert len(pending) == 2
assert all(row.status == "pending" for row in pending)
# Newest first (created_at DESC).
first_created = pending[0].created_at
second_created = pending[1].created_at
assert first_created is not None and second_created is not None
assert first_created >= second_created
# Approve one, then the filter arms.
store.decide_claim(pending[0].id, decision="approved", operator="op-1")
assert len(store.list_claims(status="pending")) == 1
approved = store.list_claims(status="approved")
assert len(approved) == 1
assert approved[0].status == "approved"
assert len(store.list_claims()) == 2 # no filter → all
def test_get_claim_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.get_claim("clm-nope") is None
@@ -0,0 +1,169 @@
"""scrypt KDF tests (Task 1-1-03, D-086/R-55, REQ-10-001).
REAL KDF, no fake-hash path (D-096): every accept/reject below
performs actual derivations (~2 × 50 ms — inside the R-55/R-64
budget). Tests assert accept/reject BEHAVIOR only — never hash bytes
(a byte pin would break the row format's evolution guarantee).
"""
from __future__ import annotations
import base64
import hashlib
from ai_service.employers.passwords import (
SCRYPT_DKLEN,
SCRYPT_N,
SCRYPT_P,
SCRYPT_R,
hash_password,
verify_password,
)
PASSWORD = "correct horse battery staple"
WRONG = "Tr0ub4dor&3"
class TestParamsPinned:
"""D-086: the KDF parameters are module constants, pinned by test
(the PAST_DUE_GRACE_DAYS shape)."""
def test_scrypt_constants_pinned(self) -> None:
assert SCRYPT_N == 2**14
assert SCRYPT_N == 16384
assert SCRYPT_R == 8
assert SCRYPT_P == 1
assert SCRYPT_DKLEN == 32
def test_stored_format_carries_the_pinned_params(self) -> None:
stored = hash_password(PASSWORD)
parts = stored.split("$")
assert len(parts) == 6
assert parts[0] == "scrypt"
assert parts[1] == "16384" # N — the R-55 probe cost point
assert parts[2] == "8" # r
assert parts[3] == "1" # p
# salt + hash are base64; the derived length rides the row's
# tail (dklen=32 bytes → 44 base64 chars, no padding surprises).
assert len(base64.b64decode(parts[4])) == 16 # salt bytes
assert len(base64.b64decode(parts[5])) == SCRYPT_DKLEN
class TestHashVerify:
"""REAL scrypt — accept/reject behavior only (never hash bytes)."""
def test_hash_then_verify_accepts(self) -> None:
stored = hash_password(PASSWORD)
assert verify_password(stored, PASSWORD) is True
def test_wrong_password_rejected(self) -> None:
stored = hash_password(PASSWORD)
assert verify_password(stored, WRONG) is False
def test_two_hashes_of_same_password_differ(self) -> None:
"""Random per-password salt: two hashes of ONE password are
distinct stored strings, and each verifies against the same
password (the salt rides the row)."""
first = hash_password(PASSWORD)
second = hash_password(PASSWORD)
assert first != second
assert verify_password(first, PASSWORD) is True
assert verify_password(second, PASSWORD) is True
def test_salt_is_random_not_derived(self) -> None:
"""A-305 family: the salt is `secrets.token_bytes(16)` — never
a deterministic function of the password (two hashes would
collide otherwise, which is what the differ test pins; this
test pins the salt's independence directly)."""
first = hash_password(PASSWORD)
second = hash_password(PASSWORD)
assert first.split("$")[4] != second.split("$")[4]
class TestSelfDescribing:
"""The params ride the row — a stored string with EXPLICIT
different params still verifies (params evolve without
invalidating rows)."""
def test_row_with_explicit_different_params_verifies(self) -> None:
# An "old-format" row: N=2^10 (cheaper era), same r/p/dklen —
# hand-built in the self-describing format.
n, r, p, dklen = 2**10, 8, 1, 32
salt = b"\x01" * 16
derived = hashlib.scrypt(
PASSWORD.encode("utf-8"),
salt=salt,
n=n,
r=r,
p=p,
dklen=dklen,
maxmem=33554432,
)
stored = "$".join(
(
"scrypt",
str(n),
str(r),
str(p),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(derived).decode("ascii"),
)
)
assert verify_password(stored, PASSWORD) is True
assert verify_password(stored, WRONG) is False
def test_verify_never_uses_module_constants_for_derivation(self) -> None:
"""The pin behind the self-describing guarantee: a row whose
N differs from SCRYPT_N cannot verify if verify (wrongly)
derived at the module N — accept proves it derived at the
row's params."""
n = 2**11 # != SCRYPT_N
salt = b"\x02" * 16
derived = hashlib.scrypt(
PASSWORD.encode("utf-8"),
salt=salt,
n=n,
r=SCRYPT_R,
p=SCRYPT_P,
dklen=SCRYPT_DKLEN,
maxmem=33554432,
)
stored = "$".join(
(
"scrypt",
str(n),
str(SCRYPT_R),
str(SCRYPT_P),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(derived).decode("ascii"),
)
)
assert n != SCRYPT_N
assert verify_password(stored, PASSWORD) is True
class TestMalformedStored:
"""A malformed stored row is an honest False — never a raise (the
auth route maps it to 401, not a 500)."""
def test_empty_stored_rejected(self) -> None:
assert verify_password("", PASSWORD) is False
def test_wrong_algorithm_rejected(self) -> None:
assert verify_password("argon2$1$8$1$c2FsdA$aGFzaA", PASSWORD) is False
def test_truncated_stored_rejected(self) -> None:
assert verify_password("scrypt$16384$8$1", PASSWORD) is False
def test_bad_int_params_rejected(self) -> None:
assert verify_password("scrypt$abc$8$1$c2FsdA$aGFzaA", PASSWORD) is False
def test_bad_base64_rejected(self) -> None:
assert verify_password("scrypt$16384$8$1$!!!$???", PASSWORD) is False
def test_zero_params_rejected(self) -> None:
assert verify_password("scrypt$0$8$1$c2FsdA$aGFzaA", PASSWORD) is False
def test_empty_salt_rejected(self) -> None:
# base64 "" decodes to zero bytes — the _derive guard rejects.
assert verify_password("scrypt$16384$8$1$$aGFzaA", PASSWORD) is False
@@ -0,0 +1,421 @@
"""Own-postings read route tests — GET /v1/employers/postings
(Task 3-1-01, REQ-10-003, D-092/G-319).
Pinned here: own-ENTITY rows only (a second claimed employer's postings
never appear); unclaimed → 403 with the claim-CTA detail dict (the
D-090 authorization class — 401 stays credential failure); status
facets draft|published|closed with per-status counts; per-posting
applicant_count matching seeded applications (the batched
count_applications_by_posting — never a per-posting N+1); no-token
→ 401; the G-319 PII boundary — learner identifiers NEVER ride any
/v1/employers/* response (negative assertion over the full JSON).
REAL scrypt throughout (register mints a real hash — the conftest
fixtures handle limiter resets).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer, Posting
from .conftest import bearer, make_client, register
OWN_ENTITY = "emp-self"
OTHER_ENTITY = "emp-rival"
#: Learner ids that seed applications — the PII the boundary bans.
LEARNER_IDS = ["learner-alpha", "learner-beta", "learner-gamma"]
def seed_posting(
client: TestClient,
posting_id: str,
employer_id: str,
*,
status: str = "draft",
) -> None:
"""Upsert a marketplace posting row through the app's own store
(app.state.marketplace_store — the same DI the route composes),
then drive the lifecycle to `status` via the store's transitions."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
row = store.insert_posting(
Posting(
id=posting_id,
source="seed-corpus",
external_id=posting_id,
employer_id=employer_id,
title=f"Posting {posting_id}",
)
)
if status == "published":
store.set_posting_status(row.id, "published")
elif status == "closed":
store.set_posting_status(row.id, "published")
store.set_posting_status(row.id, "closed")
def seed_application(
client: TestClient, posting_id: str, learner_id: str
) -> None:
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.insert_application(posting_id, learner_id)
def claim_and_approve(
client: TestClient, token: str, employer_id: str
) -> None:
"""File the claim + drive the operator approve (the store path the
2-2 admin route composes) — binds account.employer_id."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
def seed_employer_rows(client: TestClient) -> None:
"""Both entities exist in the marketplace corpus (claim targets the
v0.6 ingested EmployerRow data)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="Self Labs",
website="https://example.com/self",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def get_own_postings(client: TestClient, token: str):
return client.get("/v1/employers/postings", headers=bearer(token))
class TestOwnPostingsRoute:
def test_own_entity_rows_only(self, client: TestClient) -> None:
"""MH-3a's route arm: the response carries ONLY the session
employer's postings — a second claimed employer's rows (all
statuses) never appear."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
# The OWN employer's postings, one per lifecycle status.
seed_posting(client, "own-draft", OWN_ENTITY, status="draft")
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
seed_posting(client, "own-closed", OWN_ENTITY, status="closed")
# A SECOND claimed employer with postings of every status.
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-draft", OTHER_ENTITY, status="draft")
seed_posting(client, "rival-pub", OTHER_ENTITY, status="published")
resp = get_own_postings(client, token)
assert resp.status_code == 200, resp.text
body = resp.json()
ids = [p["id"] for p in body["postings"]]
assert sorted(ids) == ["own-closed", "own-draft", "own-pub"]
assert all(p["employer_id"] == OWN_ENTITY for p in body["postings"])
def test_unclaimed_403_claim_cta(self, client: TestClient) -> None:
"""D-090 403 semantics: a VALID session whose account holds no
claimed entity → 403 + the claim-CTA detail dict (never 401 —
the credential is fine; the authorization is not)."""
token = register(client)["token"]
resp = get_own_postings(client, token)
assert resp.status_code == 403, resp.text
detail = resp.json()["detail"]
assert detail["reason"] == "no_claimed_employer"
assert "claim" in detail["claim_cta"]
def test_no_token_401(self, client: TestClient) -> None:
resp = client.get("/v1/employers/postings")
assert resp.status_code == 401
def test_invalid_token_401(self, client: TestClient) -> None:
resp = get_own_postings(client, "not-a-real-token")
assert resp.status_code == 401
def test_status_facets_pinned(self, client: TestClient) -> None:
"""Per-status facet counts over the OWN postings — all three
lifecycle keys always present, honest zeros included; the rival
employer's rows contribute NOTHING (own-entity scope)."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-a", OWN_ENTITY, status="draft")
seed_posting(client, "own-b", OWN_ENTITY, status="draft")
seed_posting(client, "own-c", OWN_ENTITY, status="published")
seed_posting(client, "own-d", OWN_ENTITY, status="closed")
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-a", OTHER_ENTITY, status="published")
resp = get_own_postings(client, token)
assert resp.status_code == 200
body = resp.json()
assert body["facets"] == {"draft": 2, "published": 1, "closed": 1}
assert body["total"] == 4
assert len(body["postings"]) == 4
def test_facets_zero_keys_honest(self, client: TestClient) -> None:
"""Every status key rides the response even at 0 (an employer
with zero postings still gets the full facet shape)."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
resp = get_own_postings(client, token)
assert resp.status_code == 200
body = resp.json()
assert body["postings"] == []
assert body["facets"] == {"draft": 0, "published": 0, "closed": 0}
assert body["total"] == 0
def test_applicant_count_matches_seeded_applications(
self, client: TestClient
) -> None:
"""applicant_count == the seeded application rows per posting
(the D-092 overview data source); zero-application postings
carry the honest 0."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
seed_posting(client, "own-draft", OWN_ENTITY, status="draft")
seed_application(client, "own-pub", LEARNER_IDS[0])
seed_application(client, "own-pub", LEARNER_IDS[1])
seed_application(client, "own-draft", LEARNER_IDS[2])
# A rival posting's applications must not leak into counts.
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-pub", OTHER_ENTITY, status="published")
seed_application(client, "rival-pub", LEARNER_IDS[0])
resp = get_own_postings(client, token)
assert resp.status_code == 200
counts = {p["id"]: p["applicant_count"] for p in resp.json()["postings"]}
assert counts == {"own-pub": 2, "own-draft": 1}
def test_pii_never_rides_the_response(self, client: TestClient) -> None:
"""G-319 PII boundary (binding, negative assertion): the FULL
response JSON contains NO learner identifier — neither the
seeded learner ids nor any per-application datum beyond counts.
Employer-facing responses expose applicant COUNTS ONLY."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
for learner_id in LEARNER_IDS:
seed_application(client, "own-pub", learner_id)
resp = get_own_postings(client, token)
assert resp.status_code == 200
payload: str = resp.text
for learner_id in LEARNER_IDS:
assert learner_id not in payload
# Belt + suspenders: the structured walk finds no per-application
# object anywhere in the tree (only applicant_count integers).
body: dict[str, Any] = resp.json()
assert json.dumps(body).count("applicant_count") == len(
body["postings"]
)
assert all(
isinstance(p["applicant_count"], int) for p in body["postings"]
)
class TestBatchedReadDiscipline:
def test_count_applications_by_posting_single_grouped_read(
self, tmp_path
) -> None:
"""G-319: the counts ride ONE grouped read for the whole id set
(never a per-posting N+1) — pinned at the STORE with an engine
event counter over a many-posting id set."""
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
store.insert_posting(
Posting(
id="p1",
source="seed-corpus",
external_id="p1",
employer_id=OWN_ENTITY,
title="P1",
)
)
store.insert_posting(
Posting(
id="p2",
source="seed-corpus",
external_id="p2",
employer_id=OWN_ENTITY,
title="P2",
)
)
store.insert_application("p1", "learner-a")
store.insert_application("p1", "learner-b")
store.insert_application("p2", "learner-a")
# Count the SELECTs the batched read issues.
selects: list[str] = []
from sqlalchemy import event
@event.listens_for(store._engine, "before_cursor_execute")
def _capture(
conn: object,
cursor: object,
statement: str,
parameters: object,
context: object,
executemany: bool,
) -> None:
if statement.strip().upper().startswith("SELECT"):
selects.append(statement)
result = store.count_applications_by_posting(["p1", "p2", "p-missing"])
assert result == {"p1": 2, "p2": 1}
assert len(selects) == 1 # ONE grouped read, not N per-posting
assert "GROUP BY" in selects[0].upper()
finally:
store.close()
def test_empty_id_set_returns_empty_dict(self, tmp_path) -> None:
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
assert store.count_applications_by_posting([]) == {}
finally:
store.close()
def test_list_postings_by_employer_all_statuses(
self, tmp_path
) -> None:
"""The owner's read sees the FULL lifecycle (draft rows are
visible to their owner, absent from every published-only path),
scoped to the employer_id, posted_at DESC then id."""
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
for pid in ("own-2", "own-1"):
store.insert_posting(
Posting(
id=pid,
source="seed-corpus",
external_id=pid,
employer_id=OWN_ENTITY,
title=pid,
)
)
store.set_posting_status(pid, "published")
store.insert_posting(
Posting(
id="own-draft",
source="seed-corpus",
external_id="own-draft",
employer_id=OWN_ENTITY,
title="own-draft",
)
)
store.insert_posting(
Posting(
id="rival-1",
source="seed-corpus",
external_id="rival-1",
employer_id=OTHER_ENTITY,
title="rival-1",
)
)
store.set_posting_status("rival-1", "published")
rows = store.list_postings_by_employer(OWN_ENTITY)
assert [r.id for r in rows] == ["own-1", "own-2", "own-draft"]
assert {r.status for r in rows} == {"published", "draft"}
finally:
store.close()
class TestRouteDiscipline:
def test_marketplace_store_via_di_never_imported(self) -> None:
"""Territory rule: the route composes the marketplace store via
app.state DI (get_marketplace_store) — api/employers.py never
CONSTRUCTS a store. P4 (Task 4-1-02): the write routes compose
the admin routes' own imports (InvalidTransition, MarketplaceStore
annotations — the marketplace_admin.py:32 precedent), so the pin
asserts the CONSTRUCTION ban, not the import ban."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "SQLiteMarketplaceStore" not in source
assert "get_marketplace_store" in source
def test_route_registered_in_openapi(self, tmp_path) -> None:
"""The read route rides the mounted sibling router (main.py
wiring pin) — /v1/employers/postings exists in the OpenAPI
schema (the _IncludedRouter wrappers hide APIRoutes from
app.routes in this starlette, so the schema is the honest
registry). P4 (Task 4-1-02): the write routes share the path —
POST (create) joined; PATCH/publish/close ride the {id}
subpaths."""
with make_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
path = schema["paths"]["/v1/employers/postings"]
assert set(path) == {"get", "post"}
assert set(
schema["paths"]["/v1/employers/postings/{posting_id}"]
) == {"patch"}
assert set(
schema["paths"][
"/v1/employers/postings/{posting_id}/publish"
]
) == {"post"}
assert set(
schema["paths"][
"/v1/employers/postings/{posting_id}/close"
]
) == {"post"}
def test_own_postings_contract_shapes(self) -> None:
"""G-319: OwnPostingSummary REUSES the PostingSummary wire shape
(every marketplace.base.Posting field present, unchanged) +
applicant_count; the response contract carries the list +
facets/total."""
from ai_service.employers.base import (
OwnPostingsFacets,
OwnPostingsResponse,
OwnPostingSummary,
)
from ai_service.marketplace.base import Posting
posting_fields = set(Posting.model_fields)
assert posting_fields < set(OwnPostingSummary.model_fields)
assert set(OwnPostingSummary.model_fields) - posting_fields == {
"applicant_count"
}
assert set(OwnPostingsFacets.model_fields) == {
"draft",
"published",
"closed",
}
assert set(OwnPostingsResponse.model_fields) == {
"postings",
"facets",
"total",
}
@@ -0,0 +1,445 @@
"""Own-postings write routes (Task 4-1-02, REQ-10-004, G-311/317/318).
Pins the employer-principal posting lifecycle: create consumes from
the employer's OWN package through the SHARED entitlement gate (the
D-091 helper — the no-drift proof rides the admin/employer payload
parity asserted in tests/billing/test_entitlement_parity.py); the
G-311 gate ORDER (session 401 → claim 403 → entitlement 402 →
insert); the G-317 employer provenance stamp; the G-318 own-drafts
edit guard with the identical admin re-tag composition.
Fixtures: the conftest make_client/register/bearer + the
test_postings_read.py claim_and_approve/seed precedents; the billing
grant via the test_entitlement_parity.py grant_posts shape.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.billing.store import SQLiteBillingStore
from ai_service.marketplace.base import Employer
from ..billing.test_entitlement_parity import grant_posts
from .conftest import bearer, make_client, register
OWN_ENTITY = "emp-write-self"
OTHER_ENTITY = "emp-write-rival"
EMPLOYER_CTA_NO_PACKAGE = (
"This employer has no active posting package — "
"purchase one via /v1/employers/billing/checkout."
)
EMPLOYER_CTA_EXHAUSTED = (
"This employer's posting package is exhausted — "
"buy more posts or upgrade via /v1/employers/billing/checkout."
)
def seed_employer_rows(client: TestClient) -> None:
"""Both entities exist in the marketplace corpus (claim targets
the v0.6 ingested EmployerRow data)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="Write Labs",
website="https://example.com/write",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def claim_and_approve(
client: TestClient, token: str, employer_id: str
) -> None:
"""File the claim + drive the operator approve (the store path
the 2-2 admin route composes) — binds account.employer_id."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
def create_own_posting(
client: TestClient, token: str, title: str = "Owned Role"
):
return client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": title, "description": "g", "skills": ["python"]},
)
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
@pytest.fixture()
def claimed(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[tuple[TestClient, str]]:
"""One registered + claimed-and-approved employer session with the
billing store state-injected (G-9). The with-block runs the
lifespan so app.state stores wire before any seeding."""
with make_client(tmp_path) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
yield client, token
def _raw_posts_used(store: SQLiteBillingStore, employer_id: str) -> int:
"""The raw row read carries the store truth an exhausted purchase
hides from get_entitlement (the parity-test precedent)."""
from sqlalchemy import text
from sqlmodel import Session
with Session(store._engine) as session:
used = session.execute(
text(
"SELECT posts_used FROM billing_purchases "
"WHERE employer_id = :eid ORDER BY created_at DESC"
),
{"eid": employer_id},
).scalar()
assert used is not None # the row exists (granted in-test)
return int(used)
class TestCreateOwnPosting:
def test_create_consumes_own_package(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The employer's OWN package is charged (posts_used 0 → 1 via
the raw row read) and the response carries the G-317 stamp."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 2)
resp = create_own_posting(client, token)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["source"] == "employer"
assert body["id"].startswith("emp-")
assert body["external_id"].startswith("emp-")
assert body["employer_id"] == OWN_ENTITY
assert body["status"] == "draft"
assert _raw_posts_used(billing_store, OWN_ENTITY) == 1
def test_no_package_402_exact_dict(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""No package → the canonical 402 dict with the EMPLOYER CTA
(the D-091 payload parity, employer arm, through the route)."""
client, token = claimed
resp = create_own_posting(client, token)
assert resp.status_code == 402
assert resp.json()["detail"] == {
"reason": "no_active_package",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": EMPLOYER_CTA_NO_PACKAGE,
}
def test_exhausted_402_exact_dict(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""All posts spent → the exhausted arm with the EMPLOYER CTA."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
assert create_own_posting(client, token).status_code == 201
denied = create_own_posting(client, token, title="Second Role")
assert denied.status_code == 402
assert denied.json()["detail"] == {
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": EMPLOYER_CTA_EXHAUSTED,
}
def test_402_consumes_nothing_creates_nothing(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""R-58 gate-before-insert: the denied create leaves
posts_used unchanged and inserts no marketplace row."""
client, token = claimed
marketplace_store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
grant_posts(billing_store, OWN_ENTITY, 1)
assert create_own_posting(client, token).status_code == 201
before = marketplace_store.list_postings_by_employer(OWN_ENTITY)
assert len(before) == 1
denied = create_own_posting(client, token, title="Denied Role")
assert denied.status_code == 402
assert _raw_posts_used(billing_store, OWN_ENTITY) == 1
after = marketplace_store.list_postings_by_employer(OWN_ENTITY)
assert len(after) == 1
assert [p.id for p in after] == [p.id for p in before]
def test_unlimited_grant_bypasses_402(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""An unlimited (enterprise) manual grant never 402s (the
admin-route bypass precedent, employer arm)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, None)
for i in range(3):
assert (
create_own_posting(client, token, title=f"Role {i}").status_code
== 201
)
def test_no_token_401_never_402(self, tmp_path: Path) -> None:
"""G-311 order: unauthenticated → 401, never a 402 (the
entitlement state never leaks to anonymous callers)."""
with make_client(tmp_path) as client:
resp = client.post(
"/v1/employers/postings", json={"title": "X"}
)
assert resp.status_code == 401
def test_unclaimed_403_never_402(self, tmp_path: Path) -> None:
"""G-311 order: unclaimed session → 403 + claim-CTA, never a
402 (a logged-out visitor must not see an upgrade CTA)."""
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "X"},
)
assert resp.status_code == 403
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
class TestEditOwnPosting:
def test_edit_retags_on_mock_provider(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""G-318: the edit composes the IDENTICAL update_posting_fields
+ tag_posting re-tag path as the admin edit (D-052 — derived
tags). The plain MockProvider's canned reply does not validate
as PostingTags → the re-tag fails honestly (the admin suite's
test_edit_retags_and_old_tags_gone contract): the edit LANDS,
pre-existing tags are KEPT, never fabricated — and the G-317
stamp survives the edit."""
from ai_service.marketplace.base import PostingTags
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.set_posting_tags(
created["id"],
PostingTags(skills=["stale-skill"], seniority=None, category=None),
)
resp = client.patch(
f"/v1/employers/postings/{created['id']}",
headers=bearer(token),
json={"title": "Edited Title", "skills": ["typescript"]},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["title"] == "Edited Title"
assert body["source"] == "employer"
stored = store.get_posting(created["id"])
assert stored is not None
assert stored.title == "Edited Title"
assert stored.skills == ["typescript"]
# Honest tag failure: the edit stands, the OLD tags remain
# (never erased by a bad parse, never fabricated).
assert stored.tags is not None
assert stored.tags.skills == ["stale-skill"]
def test_edit_published_409_no_side_effects(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""G-318: the own-drafts guard evaluates BEFORE any mutation —
editing a PUBLISHED posting 409s with fields AND tags
untouched."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
assert (
client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
).status_code
== 200
)
resp = client.patch(
f"/v1/employers/postings/{created['id']}",
headers=bearer(token),
json={"title": "Must Not Land"},
)
assert resp.status_code == 409
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_posting(created["id"])
assert stored is not None
assert stored.title == "Owned Role" # unchanged
assert stored.status == "published"
def test_edit_cross_entity_403(
self, claimed: tuple[TestClient, str]
) -> None:
"""D-090 authorization arm: another entity's posting → 403."""
client, token = claimed
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival_row = store.insert_posting(
__import__(
"ai_service.marketplace.base", fromlist=["Posting"]
).Posting(
id="rival-posting-1",
source="seed-corpus",
external_id="rival-posting-1",
employer_id=OTHER_ENTITY,
title="Rival Role",
)
)
resp = client.patch(
f"/v1/employers/postings/{rival_row.id}",
headers=bearer(token),
json={"title": "Hostile Takeover"},
)
assert resp.status_code == 403
assert store.get_posting(rival_row.id).title == "Rival Role"
def test_edit_unknown_404(
self, claimed: tuple[TestClient, str]
) -> None:
client, token = claimed
resp = client.patch(
"/v1/employers/postings/does-not-exist",
headers=bearer(token),
json={"title": "X"},
)
assert resp.status_code == 404
class TestPublishCloseLifecycle:
def test_publish_close_applicants_accrue(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""draft → publish (posted_at stamped, hooks composed) → an
application lands → close; the read route's applicant_count
reflects the application (the P3 batched read over the
lifecycle)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
published = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert published.status_code == 200, published.text
assert published.json()["status"] == "published"
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_posting(created["id"])
assert stored is not None and stored.posted_at is not None
store.insert_application(created["id"], "learner-alpha")
listing = client.get(
"/v1/employers/postings", headers=bearer(token)
)
assert listing.status_code == 200
own = listing.json()["postings"]
assert [p for p in own if p["id"] == created["id"]][0][
"applicant_count"
] == 1
closed = client.post(
f"/v1/employers/postings/{created['id']}/close",
headers=bearer(token),
)
assert closed.status_code == 200
assert closed.json()["status"] == "closed"
def test_publish_published_409(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""Re-publishing a published posting → 409 (the store's
transition matrix, honestly surfaced)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
first = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert first.status_code == 200
second = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert second.status_code == 409
def test_close_draft_409(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""Closing a DRAFT → 409 (published → closed is the only valid
move)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
resp = client.post(
f"/v1/employers/postings/{created['id']}/close",
headers=bearer(token),
)
assert resp.status_code == 409
def test_publish_cross_entity_403(
self, claimed: tuple[TestClient, str]
) -> None:
"""The own-entity guard on publish/close too (D-090)."""
client, token = claimed
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival_row = store.insert_posting(
__import__(
"ai_service.marketplace.base", fromlist=["Posting"]
).Posting(
id="rival-posting-2",
source="seed-corpus",
external_id="rival-posting-2",
employer_id=OTHER_ENTITY,
title="Rival Draft",
)
)
resp = client.post(
f"/v1/employers/postings/{rival_row.id}/publish",
headers=bearer(token),
)
assert resp.status_code == 403
@@ -0,0 +1,390 @@
"""EmployerStore tests — the 8th D-027 store (Task 1-1-02, REQ-10-001).
Mirrors tests/billing/test_store.py: tmp_path db, sqlite_master
table-existence pins (all four employer_ tables), PRAGMA pins,
IntegrityError surfacing (duplicate email, duplicate token_hash),
the D-087 hashed-token discipline (row holds sha256 ONLY — never the
raw token), the G-306 lazy TTL with injected now (+6d present, +8d
absent — no sleeps), and revoke → lookup miss.
"""
from __future__ import annotations
import hashlib
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.employers.store import (
SESSION_TTL_DAYS,
SQLiteEmployerStore,
)
EMAIL = "user@example.com"
PASSWORD_HASH = "scrypt$16384$8$1$c2FsdA$aGFzaA" # any self-describing string
def make_store(tmp_path: Path) -> SQLiteEmployerStore:
return SQLiteEmployerStore(db_path=tmp_path / "employers-test.db")
def insert_account(
store: SQLiteEmployerStore, email: str = EMAIL
) -> tuple[str, bool]:
row, created = store.insert_account(email=email, password_hash=PASSWORD_HASH)
return row.id, created
class TestSchema:
def test_all_four_employer_tables_exist(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
with store._engine.connect() as conn:
names = {
row[0]
for row in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
}
for table in (
"employer_accounts",
"employer_sessions",
"employer_claims",
"employer_audit_events",
):
assert table in names
def test_module_registers_only_employer_prefixed_tables(self) -> None:
"""The bare `employer` name belongs to marketplace's EmployerRow
(marketplace/store.py:144) — every table THIS module registers
carries the employer_ prefix (R-57); the shared-process metadata
holds marketplace's rows, but the module owns exactly four."""
from ai_service.employers import store as employers_store_module
module_tables = {
employers_store_module.AccountRow.__tablename__,
employers_store_module.SessionRow.__tablename__,
employers_store_module.ClaimRow.__tablename__,
employers_store_module.AuditEventRow.__tablename__,
}
assert module_tables == {
"employer_accounts",
"employer_sessions",
"employer_claims",
"employer_audit_events",
}
assert all(t.startswith("employer_") for t in module_tables)
def test_pragmas(self, tmp_path: Path) -> None:
"""WAL + synchronous=NORMAL + busy_timeout + foreign_keys —
the D-027 per-connection contract."""
store = make_store(tmp_path)
with store._engine.connect() as conn:
journal_row = conn.execute(text("PRAGMA journal_mode")).fetchone()
sync_row = conn.execute(text("PRAGMA synchronous")).fetchone()
busy_row = conn.execute(text("PRAGMA busy_timeout")).fetchone()
fk_row = conn.execute(text("PRAGMA foreign_keys")).fetchone()
assert journal_row is not None and sync_row is not None
assert busy_row is not None and fk_row is not None
assert str(journal_row[0]).lower() == "wal"
assert sync_row[0] == 1 # NORMAL
assert busy_row[0] == 5000
assert fk_row[0] == 1
def test_session_ttl_days_pinned(self) -> None:
"""SESSION_TTL_DAYS = 7 — module constant (D-087), pinned by
test (the PAST_DUE_GRACE_DAYS shape)."""
assert SESSION_TTL_DAYS == 7
def test_create_schema_idempotent(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.create_schema() # second run — shared-DB re-boot shape
account_id, created = insert_account(store)
assert created is True
with Session(store._engine) as session:
count = int(
session.execute(
text("SELECT COUNT(*) FROM employer_accounts")
).scalar_one()
)
assert count == 1
class TestAccounts:
def test_insert_account_created_true(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
_, created = insert_account(store)
assert created is True
def test_duplicate_email_rides_existing_row_created_false(
self, tmp_path: Path
) -> None:
"""Check-first: the EXISTING row rides the duplicate register —
no second row, honest created=False (the route's 409)."""
store = make_store(tmp_path)
first_id, created_first = insert_account(store, email=EMAIL)
second_row, created_second = store.insert_account(
email=EMAIL, password_hash="scrypt$other"
)
assert created_first is True
assert created_second is False
assert second_row.id == first_id
assert second_row.password_hash == PASSWORD_HASH # original kept
def test_get_account_by_email(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id, _ = insert_account(store)
row = store.get_account_by_email(EMAIL)
assert row is not None
assert row.id == account_id
assert row.employer_id is None # nullable-until-claim-approval
def test_get_account_by_email_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.get_account_by_email("nobody@example.com") is None
def test_email_normalized_at_store_boundary(self, tmp_path: Path) -> None:
"""G-315: the store re-derives normalization — never trusts
the caller's casing/whitespace."""
store = make_store(tmp_path)
_, created = store.insert_account(
email=" User@Example.COM ", password_hash=PASSWORD_HASH
)
assert created is True
row = store.get_account_by_email("user@example.com")
assert row is not None
assert row.email == "user@example.com"
def test_dishonest_email_rejected_at_store_boundary(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
with pytest.raises(ValueError, match="email"):
store.insert_account(email="no-at-sign", password_hash=PASSWORD_HASH)
def test_duplicate_email_integrity_error_surfaced(
self, tmp_path: Path
) -> None:
"""The UNIQUE(email) constraint is the check-first race
backstop — its IntegrityError SURFACES (never swallowed, never
re-masked). Raw SQL via a direct Session simulates the race
window after the check-first read."""
store = make_store(tmp_path)
insert_account(store)
with Session(store._engine) as session:
with pytest.raises(IntegrityError):
session.execute(
text(
"INSERT INTO employer_accounts (id, email, password_hash, "
"created_at) VALUES ('acct-raced', :email, :ph, :now)"
),
{
"email": EMAIL,
"ph": PASSWORD_HASH,
"now": datetime.now(UTC),
},
)
session.flush()
session.rollback()
def test_duplicate_employer_id_integrity_error_surfaced(
self, tmp_path: Path
) -> None:
"""The one-account-one-entity constraint (UNIQUE nullable
employer_id) — two accounts on one entity surface."""
store = make_store(tmp_path)
insert_account(store, email="one@example.com")
insert_account(store, email="two@example.com")
with Session(store._engine) as session:
session.execute(
text(
"UPDATE employer_accounts SET employer_id = 'emp-1' "
"WHERE email = :email"
),
{"email": "one@example.com"},
)
session.flush() # materialize the first UPDATE before racing
with pytest.raises(IntegrityError):
session.execute(
text(
"UPDATE employer_accounts SET employer_id = 'emp-1' "
"WHERE email = :email"
),
{"email": "two@example.com"},
)
session.flush()
session.rollback()
class TestSessions:
def test_mint_session_hashes_token_sha256(self, tmp_path: Path) -> None:
"""D-087: the row holds sha256(token) ONLY — never the raw
token, in any column, ever."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
token = "raw-secret-bearer-token"
row = store.mint_session(account_id=account_id, token=token)
expected = hashlib.sha256(token.encode("utf-8")).hexdigest()
assert row.token_hash == expected
assert row.token_hash != token
with Session(store._engine) as session:
raw = session.execute(
text("SELECT token_hash FROM employer_sessions")
).scalar_one()
assert raw == expected
assert token not in str(raw) # raw token nowhere in the table
def test_get_session_by_token_hash_roundtrip(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id, _ = insert_account(store)
token = "raw-secret-bearer-token"
minted = store.mint_session(account_id=account_id, token=token)
found = store.get_session_by_token_hash(minted.token_hash)
assert found is not None
assert found.id == minted.id
assert found.account_id == account_id
def test_expires_at_is_login_plus_seven_days(self, tmp_path: Path) -> None:
"""The TTL constant binds: expires_at = minted now + 7d
(SESSION_TTL_DAYS — pinned in TestSchema too)."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
moment = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
row = store.mint_session(account_id=account_id, token="t", now=moment)
assert row.expires_at is not None
assert row.expires_at == moment + timedelta(days=SESSION_TTL_DAYS)
def test_lazy_ttl_inside_window_present(self, tmp_path: Path) -> None:
"""G-306 with injected now: login+6d the session still reads —
no sleeps, no timers."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
row = store.mint_session(
account_id=account_id, token="t", now=login_at
)
six_days_later = login_at + timedelta(days=6)
assert store.get_session_by_token_hash(
row.token_hash, now=six_days_later
) is not None
def test_lazy_ttl_beyond_window_absent(self, tmp_path: Path) -> None:
"""G-306 with injected now: login+8d the session reads as
ABSENT (expired) — evaluated lazily, the row never deleted."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
row = store.mint_session(
account_id=account_id, token="t", now=login_at
)
eight_days_later = login_at + timedelta(days=8)
assert (
store.get_session_by_token_hash(
row.token_hash, now=eight_days_later
)
is None
)
# Lazy: the row is still on disk (no timer, no writes on read).
with Session(store._engine) as session:
count = int(
session.execute(
text("SELECT COUNT(*) FROM employer_sessions")
).scalar_one()
)
assert count == 1
def test_duplicate_token_hash_integrity_error_surfaced(
self, tmp_path: Path
) -> None:
"""UNIQUE(token_hash): minting the same raw token twice
surfaces IntegrityError — never a second row."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
store.mint_session(account_id=account_id, token="same-token")
with pytest.raises(IntegrityError):
store.mint_session(account_id=account_id, token="same-token")
def test_revoke_session_deletes_row(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id, _ = insert_account(store)
row = store.mint_session(account_id=account_id, token="t")
assert store.revoke_session(row.token_hash) is True
# Lookup miss after revoke (logout semantics).
assert store.get_session_by_token_hash(row.token_hash) is None
# Revoking again is an honest miss, not an error.
assert store.revoke_session(row.token_hash) is False
def test_revoke_unknown_token_hash_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.revoke_session("deadbeef" * 8) is False
def test_concurrent_sessions_per_account_allowed(
self, tmp_path: Path
) -> None:
"""Two logins → two live sessions (the logout-one-leaves-the-
other-alive route test's store half)."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
first = store.mint_session(account_id=account_id, token="token-one")
second = store.mint_session(account_id=account_id, token="token-two")
assert store.get_session_by_token_hash(first.token_hash) is not None
assert store.get_session_by_token_hash(second.token_hash) is not None
class TestAuditEvents:
def test_append_and_read_back(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id, _ = insert_account(store)
row = store.append_audit_event(
"auth",
"register",
account_id=account_id,
detail={"email": EMAIL},
)
assert row.id is not None
assert row.detail == {"email": EMAIL}
with Session(store._engine) as session:
stored = session.execute(
text(
"SELECT kind, name, account_id FROM employer_audit_events"
)
).fetchone()
assert stored is not None
assert tuple(stored) == ("auth", "register", account_id)
def test_operator_provenance_round_trips(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.append_audit_event(
"manual",
"grant",
employer_id="emp-1",
operator="op-1",
)
with Session(store._engine) as session:
stored = session.execute(
text(
"SELECT employer_id, operator FROM employer_audit_events"
)
).fetchone()
assert stored is not None
assert tuple(stored) == ("emp-1", "op-1")
def test_invalid_kind_rejected(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
with pytest.raises(ValueError, match="invalid audit event kind"):
store.append_audit_event("bogus", "x")
def test_empty_name_rejected(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
with pytest.raises(ValueError, match="name"):
store.append_audit_event("auth", "")
class TestStoreContract:
def test_close_is_idempotent_safe(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.close()
store.close() # engine.dispose() is idempotent by contract
@@ -0,0 +1,218 @@
"""Employer auth throttle tests (Task 1-2-02, D-088/G-313/G-314/G-320a).
Knob-math pins, no sleeps: every window boundary is exercised via the
Settings knobs set low, never by waiting. The G-313 conftest fixture
resets both limiter module states around every test.
G-314 stuffing test: N distinct emails inside the window → the
(cap+1)-th attempt 429s — failed-password attempts DO count; only
422-invalid bodies and the cap's own 429s are exempt.
G-320a pin: a 429'd login performs ZERO scrypt derivations (a
hash-spy counts _derive calls — a capped attempt must never burn
KDF CPU, bounding open-risk-1).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi.testclient import TestClient
from ai_service.api import employers as employers_api
from ai_service.employers import passwords
from .conftest import TEST_PASSWORD, make_client, register
def make_throttle_client(
tmp_path: Path,
*,
per_min: int | None = None,
failures_per_window: int | None = None,
) -> TestClient:
overrides: dict[str, Any] = {}
if per_min is not None:
overrides["employer_auth_attempts_per_min"] = per_min
if failures_per_window is not None:
overrides["employer_login_failures_per_window"] = failures_per_window
return make_client(tmp_path, **overrides)
def failed_login(client: TestClient, email: str) -> int:
return client.post(
"/v1/employers/auth/login",
json={"email": email, "password": "wrong-password-123"},
).status_code
def good_login(client: TestClient, email: str) -> int:
return client.post(
"/v1/employers/auth/login",
json={"email": email, "password": TEST_PASSWORD},
).status_code
class TestPerEmailFailedLoginWindow:
def test_five_failures_then_429_with_wait_detail(self, tmp_path: Path) -> None:
"""Default knob 5: failures 1-5 → 401, the 6th → 429 carrying
the honest wait detail (a further 401 would invite retries)."""
with make_throttle_client(tmp_path) as client:
register(client, email="victim@example.com")
for _ in range(5):
assert failed_login(client, "victim@example.com") == 401
resp = client.post(
"/v1/employers/auth/login",
json={
"email": "victim@example.com",
"password": "wrong-password-123",
},
)
assert resp.status_code == 429
assert "wait" in resp.json()["detail"].lower()
def test_failures_are_per_email(self, tmp_path: Path) -> None:
"""One email's window never locks another's (D-088: keyed by
submitted email — never global at this layer)."""
with make_throttle_client(tmp_path, failures_per_window=2) as client:
register(client, email="a@example.com")
register(client, email="b@example.com")
assert failed_login(client, "a@example.com") == 401
assert failed_login(client, "a@example.com") == 401
assert failed_login(client, "a@example.com") == 429
assert failed_login(client, "b@example.com") == 401 # untouched
def test_success_clears_email_window(self, tmp_path: Path) -> None:
"""D-088: a successful login CLEARS the email's failure window
— 4 failures, a good login, then 4 more failures all pass."""
with make_throttle_client(tmp_path, failures_per_window=5) as client:
register(client, email="healthy@example.com")
for _ in range(4):
assert failed_login(client, "healthy@example.com") == 401
assert good_login(client, "healthy@example.com") == 200
for _ in range(4):
assert failed_login(client, "healthy@example.com") == 401
def test_unknown_email_failures_also_windowed(self, tmp_path: Path) -> None:
"""Failures against an email with NO account still append —
the attacker probing dead addresses is bounded identically."""
with make_throttle_client(tmp_path, failures_per_window=3) as client:
for _ in range(3):
assert failed_login(client, "ghost@example.com") == 401
assert failed_login(client, "ghost@example.com") == 429
def test_window_state_is_the_module_dict(self, tmp_path: Path) -> None:
"""R-63 shape pin: the window lives in the module dict keyed
by the submitted email (monotonic timestamps, failures-only)."""
with make_throttle_client(tmp_path, failures_per_window=2) as client:
register(client, email="shape@example.com")
failed_login(client, "shape@example.com")
failed_login(client, "shape@example.com")
window = employers_api._LOGIN_FAILURES["shape@example.com"]
assert len(window) == 2
assert all(isinstance(t, float) for t in window)
class TestGlobalAttemptCap:
def test_cap_reached_429(self, tmp_path: Path) -> None:
"""Knob 3: three well-formed attempts admitted, the 4th → 429
with the honest wait detail."""
with make_throttle_client(tmp_path, per_min=3) as client:
register(client, email="one@example.com")
register(client, email="two@example.com")
good_login(client, "one@example.com")
resp = client.post(
"/v1/employers/auth/login",
json={"email": "one@example.com", "password": TEST_PASSWORD},
)
assert resp.status_code == 429
assert "wait" in resp.json()["detail"].lower()
def test_422_invalid_consumes_nothing(self, tmp_path: Path) -> None:
"""G-314 exemption: a 422-invalid body never reaches the
cap's append — after N invalid attempts the budget is whole
(the cap only counts well-formed bodies; pydantic rejects
these BEFORE the handler runs)."""
with make_throttle_client(tmp_path, per_min=2) as client:
for _ in range(5):
resp = client.post(
"/v1/employers/auth/login",
json={"email": "x@example.com", "password": "short"},
)
assert resp.status_code == 422
assert len(employers_api._AUTH_ATTEMPTS) == 0
register(client, email="one@example.com")
register(client, email="two@example.com")
# Budget was 2, both consumed by the two registers — the
# next WELL-FORMED attempt 429s (the cap is intact: the
# five 422s above consumed nothing).
assert good_login(client, "one@example.com") == 429
def test_failed_passwords_count_stuffing_test(self, tmp_path: Path) -> None:
"""G-314: N distinct emails inside the window, each a FAILED
password → the (cap+1)-th 429s — failed attempts DO consume
the global budget or D-088's credential-stuffing bound is
void (rotating emails must not evade the cap)."""
with make_throttle_client(tmp_path, per_min=4) as client:
for i in range(4):
assert failed_login(client, f"stuff{i}@example.com") == 401
assert failed_login(client, "stuff4@example.com") == 429
def test_cap_429_consumes_nothing(self, tmp_path: Path) -> None:
"""The cap's own 429 never appends (a rejected attempt
consumes no budget — the _check_global_create_rate shape)."""
with make_throttle_client(tmp_path, per_min=2) as client:
register(client, email="one@example.com")
register(client, email="two@example.com")
assert failed_login(client, "three@example.com") == 429
assert len(employers_api._AUTH_ATTEMPTS) == 2
class TestThrottleBeforeKDF:
def test_429_performs_zero_kdf_derivations(self, tmp_path: Path) -> None:
"""G-320a pin: a login 429'd by the per-email window performs
ZERO scrypt derivations — both throttle checks run BEFORE the
verify, bounding the open-risk-1 burst (a hash-spy on
passwords._derive counts calls)."""
calls: list[int] = []
real_derive = passwords._derive
def spy(password: str, **kwargs: object) -> bytes:
calls.append(1)
return real_derive(password, **kwargs) # type: ignore[arg-type]
passwords._derive = spy
try:
with make_throttle_client(tmp_path, failures_per_window=2) as client:
register(client, email="kdf@example.com")
calls.clear() # the register hash is not under test
assert failed_login(client, "kdf@example.com") == 401
assert failed_login(client, "kdf@example.com") == 401
assert failed_login(client, "kdf@example.com") == 429
# The two 401s each performed ONE verify derivation; the
# 429'd attempt performed ZERO — total stays at 2.
assert sum(calls) == 2
finally:
passwords._derive = real_derive
def test_global_cap_429_performs_zero_kdf_derivations(
self, tmp_path: Path
) -> None:
"""G-320a pin, global arm: a cap-429'd attempt burns no KDF."""
calls: list[int] = []
real_derive = passwords._derive
def spy(password: str, **kwargs: object) -> bytes:
calls.append(1)
return real_derive(password, **kwargs) # type: ignore[arg-type]
passwords._derive = spy
try:
with make_throttle_client(tmp_path, per_min=1) as client:
register(client, email="one@example.com")
calls.clear() # the register hash is not under test
assert failed_login(client, "two@example.com") == 429
assert sum(calls) == 0 # the capped attempt derived nothing
finally:
passwords._derive = real_derive
@@ -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&apos;s
presence on the marketplace.
</p>
</header>
<Card>
<CardBody>
<LoginClient />
</CardBody>
</Card>
</div>
);
}
+17 -223
View File
@@ -1,234 +1,28 @@
import Link from 'next/link';
import {
Briefcase,
Users,
TrendingUp,
Target,
ArrowRight,
} from 'lucide-react';
import { Badge, Card, CardBody, Avatar } from '@nextcraft/ui';
import { candidates, employers } from '@nextcraft/mock-data';
import { AnalyticsCharts } from '../../../components/employer/analytics-charts';
import { matchBadgeClasses } from '../../../lib/format';
/* -------------------------------------------------------------------------- */
/* Mock pipeline + metric data */
/* -------------------------------------------------------------------------- */
const METRICS = [
{ label: 'Active Postings', value: 7, trend: '+12%', icon: Briefcase, tone: 'primary' as const },
{ label: 'Total Applicants', value: 142, trend: '+24%', icon: Users, tone: 'accent' as const },
{ label: 'Talent Matches', value: 28, trend: '+8%', icon: Target, tone: 'violet' as const },
{ label: 'Placement Rate', value: '18%', trend: '+3%', icon: TrendingUp, tone: 'amber' as const },
];
const TONE_CLASSES: Record<string, string> = {
primary: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
interface PipelineStage {
name: string;
count: number;
cards: Array<{ name: string; position: string; score: number }>;
}
const PIPELINE: PipelineStage[] = [
{
name: 'Applied',
count: 8,
cards: [
{ name: 'Maya Okonkwo', position: 'AI Orchestration Engineer', score: 96 },
{ name: 'Devon Park', position: 'AI Safety Researcher', score: 91 },
{ name: 'Priya Iyer', position: 'Human-AI Designer', score: 89 },
],
},
{
name: 'Screening',
count: 5,
cards: [
{ name: 'Tomás Vega', position: 'LLM App Developer', score: 93 },
{ name: 'Sofia Marchetti', position: 'AI Governance Lead', score: 85 },
],
},
{
name: 'Interview',
count: 3,
cards: [
{ name: 'Liam Chen', position: 'Agent Reliability Eng', score: 90 },
{ name: 'Yuki Tanaka', position: 'Evaluation Engineer', score: 86 },
],
},
{
name: 'Offer',
count: 2,
cards: [{ name: 'Ingrid Solberg', position: 'AI Red Team Lead', score: 88 }],
},
{
name: 'Hired',
count: 1,
cards: [{ name: 'Hana Lindqvist', position: 'Comp Biologist', score: 88 }],
},
];
/* -------------------------------------------------------------------------- */
/* Page */
/* -------------------------------------------------------------------------- */
export default function EmployerDashboardPage() {
const employer = employers[0];
const topMatches = [...candidates].sort((a, b) => b.matchScore - a.matchScore).slice(0, 5);
import { Badge } from '@nextcraft/ui';
import { EmployerOverviewClient } from '../../../components/employer/employer-overview-client';
/**
* Employer dashboard overview (REQ-10-003, D-092): the mock imports
* + inline METRICS/PIPELINE constants are GONE — the page is a server
* shell rendering the session-gated client with REAL active-postings +
* applicant counts, the claimed-entity profile, and mock-labeled
* talent metrics (the A-406 pattern).
*/
export default function PostingsPage() {
return (
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer Dashboard</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Employer Dashboard
Overview
</h1>
<p className="text-slate-600 dark:text-slate-400">
Welcome back, <span className="font-semibold text-slate-800 dark:text-slate-200">{employer.name}</span>.
Here is your talent pipeline at a glance.
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Your postings, applicants, and claimed entity at a glance — real data
from your employer account.
</p>
</header>
{/* Metric cards */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{METRICS.map((m) => {
const Icon = m.icon;
return (
<Card key={m.label}>
<CardBody className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[m.tone]}`}
>
<Icon className="h-5 w-5" />
</span>
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
{m.trend}
</span>
</div>
<div className="flex flex-col">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{m.value}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">{m.label}</span>
</div>
</CardBody>
</Card>
);
})}
</section>
{/* Middle row — pipeline + talent matches */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Applicant pipeline — 2/3 width */}
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Applicant Pipeline
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{PIPELINE.reduce((s, c) => s + c.count, 0)} candidates in flight
</span>
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{PIPELINE.map((stage) => (
<div
key={stage.name}
className="flex w-56 shrink-0 flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-900/60"
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{stage.name}
</h3>
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-200 px-1.5 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{stage.count}
</span>
</div>
<div className="flex flex-col gap-2">
{stage.cards.map((c) => (
<div
key={c.name}
className="flex items-center gap-2 rounded-md border border-slate-200 bg-white p-2 dark:border-slate-800 dark:bg-slate-900"
>
<Avatar name={c.name} size="sm" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-xs font-medium text-slate-800 dark:text-slate-100">
{c.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{c.position}
</span>
</div>
<span
className={`inline-flex h-7 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
c.score,
)}`}
>
{c.score}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Talent matches — 1/3 width */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Talent Matches
</h2>
<Link
href="/employer/talent"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
See all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
<CardBody className="flex flex-col gap-2 p-3">
{topMatches.map((cand) => (
<Link
key={cand.id}
href={`/employer/talent/${cand.id}`}
className="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
>
<Avatar name={cand.name} src={cand.avatar} size="md" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{cand.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{cand.headline}
</span>
</div>
<span
className={`inline-flex h-8 w-12 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
cand.matchScore,
)}`}
>
{cand.matchScore}%
</span>
</Link>
))}
</CardBody>
</Card>
</div>
</section>
{/* Analytics charts */}
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">Analytics</h2>
<AnalyticsCharts />
</section>
<EmployerOverviewClient />
</div>
);
}
}
@@ -1,7 +1,13 @@
import { Badge } from '@nextcraft/ui';
import { jobs } from '@nextcraft/mock-data';
import { PostingManager } from '../../../../components/employer/posting-manager';
import { OwnPostingsClient } from '../../../../components/employer/own-postings-client';
/**
* Posting management (REQ-10-003/004, D-092): the mock job-list import
* is GONE — the page is a server shell rendering the own-postings
* lifecycle client (draft → published → closed, real applicant
* counts) with the P4 write actions: create draft, edit own draft,
* publish, close. The A-406 pattern.
*/
export default function PostingsPage() {
return (
<div className="flex flex-col gap-6">
@@ -11,12 +17,13 @@ export default function PostingsPage() {
Posting Management
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Create, edit, and track your job postings. View the applicant pipeline for each posting
and manage interview stages.
Track and manage your postings across their lifecycle — draft, published,
closed — with real applicant counts. Create drafts, edit your own drafts,
publish, and close, all from this page.
</p>
</header>
<PostingManager jobs={jobs} />
<OwnPostingsClient />
</div>
);
}
}
@@ -0,0 +1,42 @@
import type { Metadata } from 'next';
import { Badge, Card, CardBody } from '@nextcraft/ui';
import { RegisterClient } from '../../../../components/employer/register-client';
/** Employer registration (REQ-10-003, MH-3e): a server shell with
* build-time metadata (A-411) rendering the client (the A-406
* pattern) — the register POST + honest 409/429/422 states live
* there. The created account's email is UNVERIFIED (D-097) and the
* dashboard displays it as such; registration lands the account
* logged-in. */
export const metadata: Metadata = {
title: 'Employer Registration — Nextcraft',
description: 'Create a Nextcraft employer account.',
openGraph: {
title: 'Employer Registration — Nextcraft',
description: 'Create a Nextcraft employer account.',
type: 'website',
},
};
export default function EmployerRegisterPage() {
return (
<div className="mx-auto flex max-w-md flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer · Register</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Create your employer account
</h1>
<p className="text-slate-600 dark:text-slate-400">
Register with your work email, then claim your company&apos;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&apos;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&apos;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 &amp; 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,700 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
EmployerMe,
OwnPostingsResponse,
OwnPostingSummary,
EmployerPostingCreate,
EmployerPostingEdit,
Seniority,
} from '@nextcraft/types';
import { formatSalaryRange, relativeTime, seniorityLabel } from '../../lib/format';
import {
getEmployerMe,
listOwnEmployerPostings,
createOwnEmployerPosting,
editOwnEmployerPosting,
publishOwnEmployerPosting,
closeOwnEmployerPosting,
SessionRequiredError,
ClaimRequiredError,
UpgradeRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* Own-postings lifecycle view (REQ-10-003/004, D-092 — the jobs mock
* is GONE): the claimed employer's OWN postings from GET
* /v1/employers/postings — draft → published → closed all visible,
* real per-posting applicant counts. P4 adds the WRITE actions
* (create draft, edit own draft, publish, close) over the
* engine-client employer section.
*
* Honest write states (Task 4-2-01): loading per-action, error
* per-action; 402 → the upgrade CTA rendered from the payload's
* reason + upgrade_cta ONLY (G-320b: the payload's zeroed usage
* fields are byte-stable wire bytes and are NEVER rendered as
* limits — real limits render exclusively from the entitlement
* view, P5); 403 claim-CTA → the claim prompt; 409 → the honest
* own-drafts-only copy; 404 honest. NO optimistic updates — the
* list is REFETCHED after every successful action (the read route is
* the truth source). Session-gated: logged-out → the login CTA
* (never a retry loop); unclaimed → the claim-CTA state;
* claim-pending → the honest awaiting-review state.
*/
export function OwnPostingsClient() {
const [me, setMe] = useState<EmployerMe | null>(null);
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [, reload] = useState(0);
const [selectedId, setSelectedId] = useState<string>('');
// Per-action honest states (Task 4-2-01): one busy key + one error
// message per action — never a global spinner, never a silent
// failure. The 402 upgrade state renders reason + CTA only (G-320b).
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<{ key: string; message: string } | null>(null);
const [upgradeRequired, setUpgradeRequired] = useState<{
reason: string;
upgradeCta: string;
} | null>(null);
const [showCreate, setShowCreate] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const refetch = useCallback((signal?: AbortSignal) => {
return listOwnEmployerPostings(signal).then(setPostings);
}, []);
useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
(async () => {
try {
const meBody = await getEmployerMe(controller.signal);
setMe(meBody);
const own = await listOwnEmployerPostings(controller.signal);
setPostings(own);
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load postings.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
// `reload` is the refetch trigger: runAction bumps it AFTER a
// successful write so the read route re-runs (no optimistic
// updates — the list is the truth source).
}, [reload]);
const selected: OwnPostingSummary | null =
postings?.postings.find((p) => p.id === selectedId) ?? postings?.postings[0] ?? null;
/** The shared write-action runner: per-action busy/error states, the
* 402/403/404/409 discrimination, and a REFETCH after every success
* (no optimistic updates — the read route is the truth source). */
const runAction = useCallback(
async (key: string, action: (signal: AbortSignal) => Promise<unknown>) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setActionBusy(key);
setActionError(null);
setUpgradeRequired(null);
try {
await action(controller.signal);
await refetch(controller.signal);
reload((n) => n + 1); // re-run the read effect (truth source)
return true;
} catch (err) {
if (controller.signal.aborted) return false;
if (err instanceof UpgradeRequiredError) {
// G-320b (binding): the 402 branch renders reason + the
// upgrade CTA ONLY — the payload's zeroed usage fields are
// byte-stable wire bytes and are NEVER rendered as limits;
// real limits render exclusively from the entitlement view.
setUpgradeRequired({ reason: err.reason, upgradeCta: err.upgradeCta });
} else if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-prompt on 403
} else if (err instanceof EngineError) {
setActionError({ key, message: writeActionMessage(err) });
} else {
setActionError({ key, message: 'The action failed — please try again.' });
}
return false;
} finally {
if (!controller.signal.aborted) setActionBusy(null);
}
},
[refetch],
);
const publish = (id: string) =>
runAction(`publish:${id}`, (signal) => publishOwnEmployerPosting(id, signal));
const close = (id: string) =>
runAction(`close:${id}`, (signal) => closeOwnEmployerPosting(id, signal));
const createDraft = (body: EmployerPostingCreate) =>
runAction('create', (signal) => createOwnEmployerPosting(body, signal)).then((ok) => {
if (ok) setShowCreate(false);
return ok;
});
const editDraft = (id: string, body: EmployerPostingEdit) =>
runAction(`edit:${id}`, (signal) => editOwnEmployerPosting(id, body, signal));
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your postings…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<div className="flex gap-2">
<Link href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</Link>
<Link href="/employer/register">
<Button variant="outline" size="md">Register</Button>
</Link>
</div>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;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&apos;s postings appear
here once an operator approves the claim.
</CardBody>
</Card>
)}
{upgradeRequired && <UpgradeNotice reason={upgradeRequired.reason} cta={upgradeRequired.upgradeCta} />}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — the real posting list */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Postings
</h2>
{/* P4 (REQ-10-004): the write actions are live — create
draft (form below), edit own draft, publish, close. */}
<Button
size="sm"
variant="primary"
type="button"
onClick={() => { setShowCreate((v) => !v); setActionError(null); }}
disabled={actionBusy !== null}
>
New draft
</Button>
</div>
<div className="flex flex-col gap-2">
{postings && postings.postings.length > 0 ? (
postings.postings.map((p) => {
const active = selected?.id === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
className={[
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
active
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{p.title}
</span>
<PostingStatusBadge status={p.status} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<span>{p.posted_at ? relativeTime(p.posted_at) : 'not published'}</span>
<span>·</span>
<span>{p.applicant_count} applicants</span>
</div>
</button>
);
})
) : (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
No postings yet — created postings appear here.
</div>
)}
</div>
{postings && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{postings.facets.draft} draft · {postings.facets.published} published ·{' '}
{postings.facets.closed} closed
</p>
)}
</div>
{/* Right panel — detail + the P4 write actions */}
<div>
{showCreate ? (
<CreateDraftForm
busy={actionBusy === 'create'}
error={actionError?.key === 'create' ? actionError.message : null}
onSubmit={createDraft}
onCancel={() => setShowCreate(false)}
/>
) : selected ? (
<PostingDetail
key={selected.id}
posting={selected}
busyKey={actionBusy}
error={actionError}
onPublish={publish}
onClose={close}
onEdit={editDraft}
/>
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
Select a posting to view details
</div>
)}
</div>
</div>
</div>
);
}
/** The G-320b 402 surface: reason + upgrade CTA ONLY. The payload's
* zeroed usage fields are byte-stable wire bytes (the D-091 wire
* contract) and are NEVER rendered as limits — real limits render
* exclusively from the entitlement view (P5's route). The CTA routes
* to the employer billing surface. */
function UpgradeNotice({ reason, cta }: { reason: string; cta: string }) {
return (
<Card>
<CardBody className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge variant="warning">Upgrade required</Badge>
</div>
<p className="text-slate-700 dark:text-slate-300">{cta}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
Reason: {reason === 'no_active_package'
? 'your employer account has no active posting package.'
: reason === 'posting_limit_reached'
? 'your posting package has no posts left.'
: reason}
</p>
<div>
<Link href="/employer/billing">
<Button variant="primary" size="md">View packages</Button>
</Link>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Self-serve checkout for posting packages arrives on the employer
billing surface — contact your operator to purchase a package in
the meantime.
</p>
</CardBody>
</Card>
);
}
/** The honest write-action error copy — every branch a literal, never
* a fabricated detail (409 own-drafts-only, 404, 422, otherwise the
* server's own honest detail). */
function writeActionMessage(err: EngineError): string {
if (err.status === 409) {
return 'Only your own drafts can be edited or published — this posting is not an editable draft.';
}
if (err.status === 404) {
return 'This posting no longer exists — it may have been removed. The list below is the current truth.';
}
if (err.status === 403) {
return 'This posting belongs to another employer entity — you can only manage your own.';
}
if (err.status === 422) {
return 'The posting details are not valid yet — check the title and salary fields.';
}
return err.message; // the honest server detail
}
function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'closed' }) {
if (status === 'published') return <Badge variant="success">Published</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Closed</Badge>;
}
interface ActionError {
key: string;
message: string;
}
function PostingDetail({
posting,
busyKey,
error,
onPublish,
onClose,
onEdit,
}: {
posting: OwnPostingSummary;
busyKey: string | null;
error: ActionError | null;
onPublish: (id: string) => Promise<boolean>;
onClose: (id: string) => Promise<boolean>;
onEdit: (id: string, body: EmployerPostingEdit) => Promise<boolean>;
}) {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(posting.title);
const [description, setDescription] = useState(posting.description);
const editError = error?.key === `edit:${posting.id}` ? error.message : null;
const publishBusy = busyKey === `publish:${posting.id}`;
const closeBusy = busyKey === `close:${posting.id}`;
const submitEdit = async (event: React.FormEvent) => {
event.preventDefault();
const ok = await onEdit(posting.id, { title, description });
if (ok) setEditing(false);
};
return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{posting.title}
</h2>
<PostingStatusBadge status={posting.status} />
</div>
{editing ? (
<form onSubmit={submitEdit} className="flex flex-col gap-4" noValidate>
<Input
label="Title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-description" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Description
</label>
<textarea
id="edit-description"
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{editError && <p className="text-sm text-red-600 dark:text-red-400">{editError}</p>}
<div className="flex flex-wrap gap-2">
<Button type="submit" variant="primary" size="md" loading={busyKey === `edit:${posting.id}`}>
Save changes
</Button>
<Button
type="button"
variant="outline"
size="md"
onClick={() => setEditing(false)}
disabled={busyKey !== null}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="flex flex-col gap-4 text-sm text-slate-700 dark:text-slate-300">
<p className="whitespace-pre-line">{posting.description}</p>
{posting.required_competencies.length > 0 && (
<div className="flex flex-wrap gap-2">
{posting.required_competencies.map((c) => (
<span
key={c}
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{c}
</span>
))}
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DetailRow label="Seniority">
{posting.seniority ? seniorityLabel(posting.seniority) : '—'}
</DetailRow>
<DetailRow label="Location">
{posting.location}
{posting.remote ? ' · Remote' : ''}
</DetailRow>
<DetailRow label="Salary">
{posting.salary_min !== null && posting.salary_max !== null
? formatSalaryRange(posting.salary_min, posting.salary_max)
: '—'}
</DetailRow>
<DetailRow label="Published">
{posting.posted_at ? relativeTime(posting.posted_at) : 'not published yet'}
</DetailRow>
</div>
{editError && <p className="text-sm text-red-600 dark:text-red-400">{editError}</p>}
{/* P4 write actions (REQ-10-004): edit own draft, publish,
close — real dispatch, per-action loading/error states,
a refetch after every success (no optimistic updates). */}
<div className="flex flex-wrap gap-2 pt-2">
{posting.status === 'draft' && (
<>
<Button
variant="primary"
size="md"
type="button"
onClick={() => setEditing(true)}
disabled={busyKey !== null}
>
Edit draft
</Button>
<Button
variant="outline"
size="md"
type="button"
onClick={() => void onPublish(posting.id)}
loading={publishBusy}
disabled={busyKey !== null && !publishBusy}
>
Publish
</Button>
</>
)}
{posting.status === 'published' && (
<Button
variant="destructive"
size="md"
type="button"
onClick={() => void onClose(posting.id)}
loading={closeBusy}
disabled={busyKey !== null && !closeBusy}
>
Close posting
</Button>
)}
</div>
</div>
)}
</div>
{/* Applicant COUNT (G-319 PII boundary: counts only — learner
identifiers never ride any employer response). */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Applicants</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{posting.applicant_count} total
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
{posting.applicant_count === 0
? 'No applicants yet — counts accrue as learners apply.'
: `${posting.applicant_count} ${posting.applicant_count === 1 ? 'learner has' : 'learners have'} applied to this posting.`}
</p>
</div>
</div>
);
}
const SENIORITY_OPTIONS: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
function CreateDraftForm({
busy,
error,
onSubmit,
onCancel,
}: {
busy: boolean;
error: string | null;
onSubmit: (body: EmployerPostingCreate) => Promise<boolean>;
onCancel: () => void;
}) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [location, setLocation] = useState('');
const [remote, setRemote] = useState(false);
const [seniority, setSeniority] = useState<Seniority | ''>('');
const [salaryMin, setSalaryMin] = useState('');
const [salaryMax, setSalaryMax] = useState('');
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const ok = await onSubmit({
title,
description,
location,
remote,
seniority: seniority || null,
salary_min: salaryMin ? Number(salaryMin) : null,
salary_max: salaryMax ? Number(salaryMax) : null,
});
if (ok) {
setTitle('');
setDescription('');
setLocation('');
setRemote(false);
setSeniority('');
setSalaryMin('');
setSalaryMax('');
}
};
return (
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
New draft posting
</h2>
</div>
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
<Input
label="Title"
name="new-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<div className="flex flex-col gap-1.5">
<label htmlFor="new-description" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Description
</label>
<textarea
id="new-description"
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label="Location" name="new-location" value={location}
onChange={(e) => setLocation(e.target.value)} />
<div className="flex flex-col gap-1.5">
<label htmlFor="new-seniority" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Seniority
</label>
<select
id="new-seniority"
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={seniority}
onChange={(e) => setSeniority(e.target.value as Seniority | '')}
>
<option value="">Not set</option>
{SENIORITY_OPTIONS.map((s) => (
<option key={s} value={s}>{seniorityLabel(s)}</option>
))}
</select>
</div>
<Input label="Salary minimum" name="new-salary-min" type="number" min={0}
value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} />
<Input label="Salary maximum" name="new-salary-max" type="number" min={0}
value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} />
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
<input
type="checkbox"
checked={remote}
onChange={(e) => setRemote(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-primary-600 dark:border-slate-600"
/>
Remote eligible
</label>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex flex-wrap gap-2">
<Button type="submit" variant="primary" size="md" loading={busy}>
Create draft
</Button>
<Button type="button" variant="outline" size="md" onClick={onCancel} disabled={busy}>
Cancel
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Drafts are private until you publish them — creating a draft uses one post
from your package.
</p>
</form>
</div>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</span>
<span className="font-medium text-slate-800 dark:text-slate-200">{children}</span>
</div>
);
}
@@ -1,343 +0,0 @@
'use client';
import { useState } from 'react';
import { Plus, Trash2, Save } from 'lucide-react';
import { Badge, Button } from '@nextcraft/ui';
import type { Job, Seniority } from '@nextcraft/types';
import { formatSalaryK, relativeTime, seniorityLabel } from '../../lib/format';
/* -------------------------------------------------------------------------- */
/* Mock posting list (status + applicant count derived from the Job) */
/* -------------------------------------------------------------------------- */
type PostingStatus = 'active' | 'draft' | 'expired';
interface PostingRow {
job: Job;
status: PostingStatus;
applicants: number;
}
interface ApplicantRow {
name: string;
appliedAt: string;
stage: 'Screening' | 'Interview' | 'Offer';
matchScore: number;
}
/* Deterministically derive a posting status per job id. */
function deriveStatus(job: Job, idx: number): PostingStatus {
if (idx < 8) return 'active';
if (idx < 11) return 'draft';
return 'expired';
}
function deriveApplicants(job: Job, idx: number): number {
return 4 + ((job.matchScore + idx * 7) % 18);
}
function deriveApplicantList(job: Job): ApplicantRow[] {
const names = [
'Maya Okonkwo',
'Devon Park',
'Priya Iyer',
'Tomás Vega',
'Liam Chen',
'Yuki Tanaka',
'Sofia Marchetti',
];
const stages: ApplicantRow['stage'][] = ['Screening', 'Interview', 'Offer'];
const count = 5 + (job.matchScore % 2);
const rows: ApplicantRow[] = [];
for (let i = 0; i < count; i++) {
const stage = stages[i % stages.length];
rows.push({
name: names[(job.matchScore + i) % names.length],
appliedAt: new Date(Date.UTC(2026, 7, 28 - i * 2)).toISOString(),
stage,
matchScore: 78 + ((job.matchScore + i * 3) % 18),
});
}
return rows;
}
/* -------------------------------------------------------------------------- */
/* Status badge */
/* -------------------------------------------------------------------------- */
function StatusBadge({ status }: { status: PostingStatus }) {
if (status === 'active') return <Badge variant="success">Active</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Expired</Badge>;
}
function StageBadge({ stage }: { stage: ApplicantRow['stage'] }) {
if (stage === 'Offer') return <Badge variant="success">Offer</Badge>;
if (stage === 'Interview') return <Badge variant="info">Interview</Badge>;
return <Badge variant="warning">Screening</Badge>;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export interface PostingManagerProps {
jobs: Job[];
}
export function PostingManager({ jobs }: PostingManagerProps) {
const postings: PostingRow[] = jobs.map((job, idx) => ({
job,
status: deriveStatus(job, idx),
applicants: deriveApplicants(job, idx),
}));
const [selectedId, setSelectedId] = useState<string>(postings[0]?.job.id ?? '');
const selected = postings.find((p) => p.job.id === selectedId) ?? null;
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — posting list */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Postings
</h2>
<Button size="sm" variant="primary" icon={<Plus className="h-3.5 w-3.5" />} type="button">
New
</Button>
</div>
<div className="flex flex-col gap-2">
{postings.map((p) => {
const active = p.job.id === selectedId;
return (
<button
key={p.job.id}
type="button"
onClick={() => setSelectedId(p.job.id)}
className={[
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
active
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{p.job.title}
</span>
<StatusBadge status={p.status} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<span>{relativeTime(p.job.postedAt)}</span>
<span>·</span>
<span>{p.applicants} applicants</span>
</div>
</button>
);
})}
</div>
</div>
{/* Right panel — detail */}
<div>
{selected ? (
<PostingDetail posting={selected} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
Select a posting to view details
</div>
)}
</div>
</div>
);
}
function PostingDetail({ posting }: { posting: PostingRow }) {
const { job, applicants } = posting;
const applicantList = deriveApplicantList(job);
const seniorityOptions: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
const inputClass =
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100';
return (
<div className="flex flex-col gap-6">
{/* Posting form */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Edit Posting
</h2>
<StatusBadge status={posting.status} />
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Title</label>
<input className={inputClass} defaultValue={job.title} aria-label="Posting title" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Description</label>
<textarea
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
defaultValue={job.description}
aria-label="Posting description"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Required Competencies
</label>
<div className="flex flex-wrap gap-2">
{job.requiredCompetencies.map((c) => (
<span
key={c}
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{c}
</span>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Seniority</label>
<select className={inputClass} defaultValue={job.seniority} aria-label="Seniority">
{seniorityOptions.map((s) => (
<option key={s} value={s}>
{seniorityLabel(s)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Location</label>
<input className={inputClass} defaultValue={job.location} aria-label="Location" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Salary Min
</label>
<input
className={inputClass}
defaultValue={formatSalaryK(job.salaryMin)}
aria-label="Salary minimum"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Salary Max
</label>
<input
className={inputClass}
defaultValue={formatSalaryK(job.salaryMax)}
aria-label="Salary maximum"
/>
</div>
</div>
<label className="flex cursor-pointer items-center justify-between gap-2 text-sm text-slate-700 dark:text-slate-200">
<span>Remote eligible</span>
<button
type="button"
role="switch"
aria-checked={job.remote}
className={[
'relative h-6 w-11 rounded-full transition-colors',
job.remote ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-700',
].join(' ')}
>
<span
className={[
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
job.remote ? 'translate-x-5' : 'translate-x-0.5',
].join(' ')}
/>
</button>
</label>
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="primary" size="md" icon={<Save className="h-4 w-4" />} type="button">
Save Changes
</Button>
<Button variant="destructive" size="md" icon={<Trash2 className="h-4 w-4" />} type="button">
Delete Posting
</Button>
</div>
</div>
</div>
{/* Applicant list */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Applicants
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{applicants} total · {applicantList.length} shown
</span>
</div>
{/* Table on desktop, cards on mobile */}
<div className="overflow-x-auto">
<table className="hidden w-full md:table">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="pb-2 pr-4 font-medium">Applicant</th>
<th className="pb-2 pr-4 font-medium">Applied</th>
<th className="pb-2 pr-4 font-medium">Stage</th>
<th className="pb-2 pr-4 font-medium text-right">Match</th>
</tr>
</thead>
<tbody>
{applicantList.map((a, i) => (
<tr key={i} className="border-b border-slate-100 last:border-0 dark:border-slate-800/60">
<td className="py-2.5 pr-4 text-sm font-medium text-slate-900 dark:text-slate-100">
{a.name}
</td>
<td className="py-2.5 pr-4 text-sm text-slate-600 dark:text-slate-400">
{relativeTime(a.appliedAt)}
</td>
<td className="py-2.5 pr-4"><StageBadge stage={a.stage} /></td>
<td className="py-2.5 pr-4 text-right text-sm font-semibold text-slate-700 dark:text-slate-200">
{a.matchScore}%
</td>
</tr>
))}
</tbody>
</table>
{/* Mobile cards */}
<div className="flex flex-col gap-2 md:hidden">
{applicantList.map((a, i) => (
<div
key={i}
className="flex items-center justify-between rounded-md border border-slate-200 p-3 dark:border-slate-800"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{a.name}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{relativeTime(a.appliedAt)}
</span>
</div>
<div className="flex items-center gap-2">
<StageBadge stage={a.stage} />
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{a.matchScore}%
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button, Input } from '@nextcraft/ui';
import {
registerEmployer,
setEmployerSessionToken,
SessionRequiredError,
RateLimitedError,
EngineError,
} from '../../lib/engine-client';
/**
* Employer registration client (REQ-10-003): creates the account
* (email UNVERIFIED — the route pins email_verified=false, D-097)
* and lands the user LOGGED-IN (the 201 carries a fresh session —
* store the tab-scoped token, redirect to the dashboard). Honest
* states: 409 duplicate email, 429 throttle wait, 422 validation
* copy that NEVER echoes the submitted password.
*/
export function RegisterClient() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setError(null);
setLoading(true);
try {
const session = await registerEmployer({ email, password }, controller.signal);
setEmployerSessionToken(session.token);
router.push('/employer');
} catch (err) {
if (controller.signal.aborted) return;
setError(registerErrorMessage(err));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
return (
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
<Input
label="Work email"
name="email"
type="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="new-password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<Button type="submit" variant="primary" size="md" loading={loading}>
{loading ? 'Creating account…' : 'Create account'}
</Button>
<p className="text-sm text-slate-600 dark:text-slate-400">
Already registered?{' '}
<Link
href="/employer/login"
className="font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Sign in
</Link>
</p>
</form>
);
}
/** The honest error copy — every branch a static literal: the
* submitted password is never echoed (the 422 pii scrub is the
* server's arm; this is the client's). */
function registerErrorMessage(err: unknown): string {
if (err instanceof SessionRequiredError) {
return 'Sign-in required — your session is missing or expired.';
}
if (err instanceof RateLimitedError) {
return 'Too many attempts — wait a minute and try again.';
}
if (err instanceof EngineError) {
if (err.status === 409) {
return 'An account with this email already exists — sign in instead.';
}
if (err.status === 422) {
return 'Enter a valid email and a password of at least 8 characters.';
}
return err.message; // honest server detail (never contains the password)
}
return 'Registration failed — please try again.';
}
+242 -1
View File
@@ -28,6 +28,16 @@ import type {
SearchResponse,
SearchResult,
CatalogResponse,
EmployerMe,
EmployerTokenResponse,
EmployerClaim,
OwnPostingsResponse,
RegisterEmployerRequest,
LoginEmployerRequest,
ClaimEmployerRequest,
EmployerPostingCreate,
EmployerPostingEdit,
EmployerPostingWriteResponse,
} from '@nextcraft/types';
import { engineBaseUrl } from './engine-base-url';
@@ -100,8 +110,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 +153,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 +831,198 @@ export async function getEntitlement(
return (await resp.json()) as EntitlementResponse;
}
// parseError gains the 402 discrimination (the dict-detail precedent).
// -- 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,
});
}
// -- employer own-postings write (v0.10 P4, REQ-10-004, G-320b) --------------------
/** POST /v1/employers/postings — create a DRAFT on the claimed
* entity. Gate order server-side (G-311): expired session →
* SessionRequiredError; unclaimed → ClaimRequiredError; a 402 →
* UpgradeRequiredError (the shared D-091 gate's reason + upgrade_cta
* ONLY — the payload's posts_used/posts_limit are byte-stable zeros
* and are NEVER rendered, G-320b; real limits render exclusively from
* the entitlement view). */
export async function createOwnEmployerPosting(
body: EmployerPostingCreate,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>('/v1/employers/postings', {
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
});
}
/** PATCH /v1/employers/postings/{id} — edit the OWN DRAFT's content
* (G-318: published/closed → 409 honest own-drafts-only copy; another
* entity's posting → 403 the honest authorization error; unknown →
* 404 honest). */
export async function editOwnEmployerPosting(
postingId: string,
body: EmployerPostingEdit,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}`,
{
method: 'PATCH',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
},
);
}
/** POST /v1/employers/postings/{id}/publish — own draft → published.
* 409 on a non-draft, 403 cross-entity, 404 unknown (all honest). */
export async function publishOwnEmployerPosting(
postingId: string,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}/publish`,
{
method: 'POST',
headers: employerHeaders(),
signal,
},
);
}
/** POST /v1/employers/postings/{id}/close — own posting → closed
* (published → closed; 409 on closing a draft or re-closing). */
export async function closeOwnEmployerPosting(
postingId: string,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}/close`,
{
method: 'POST',
headers: employerHeaders(),
signal,
},
);
}
+165
View File
@@ -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");
});
+134
View File
@@ -0,0 +1,134 @@
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");
// P4 (Task 4-2-01) supersedes the read-only pin: the write actions
// are LIVE now — pinned in employer-postings-write.test.ts (the
// disabled/upcoming-release copy is gone, never fake-wired).
assert.ok(!/upcoming release/.test(src), "the P3 read-only copy is gone (P4 shipped the actions)");
assert.ok(!/disabled title=/.test(src), "no fake-wired disabled buttons remain");
// 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,307 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P4 (REQ-10-004, Task 4-2-01 — G-320b/G-312): the engine-client
* employer WRITE section + the PostingManager write actions. Fetch
* stubs pin the dispatch + refetch + the 402/403/409 discrimination;
* source pins cover the component's honest-state branches (no render
* harness in-tree — the D-059/E87 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). location is included: engineBaseUrl reads
* window.location.hostname at module-import time (the auth-pages
* precedent). */
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" },
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 WRITE_RESPONSE = {
id: "emp-abc123",
source: "employer",
external_id: "emp-abc123",
employer_id: "emp-openai",
title: "AI Platform Engineer",
status: "draft",
};
test("createOwnEmployerPosting: POSTs the create route with the Bearer token + body on the wire", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string>; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-write");
const created = await mod.createOwnEmployerPosting({
title: "AI Platform Engineer",
description: "Build the platform.",
location: "San Francisco",
remote: true,
});
assert.equal(created.id, "emp-abc123");
assert.equal(created.source, "employer", "G-317: employer authorship rides the response");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/postings");
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-write");
assert.ok(calls[0].body?.includes('"title":"AI Platform Engineer"'), "create body rides the wire");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("editOwnEmployerPosting: PATCHes the own-draft route; publish/close POST their sub-routes", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method });
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await mod.editOwnEmployerPosting("job-1", { title: "Updated", description: "New copy." });
await mod.publishOwnEmployerPosting("job-1");
await mod.closeOwnEmployerPosting("job-1");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/postings/job-1");
assert.equal(calls[0].method, "PATCH", "edit is a PATCH");
assert.equal(
new URL(calls[1].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/publish",
);
assert.equal(calls[1].method, "POST", "publish is a POST");
assert.equal(
new URL(calls[2].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/close",
);
assert.equal(calls[2].method, "POST", "close is a POST");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("402 → UpgradeRequiredError with reason + upgrade_cta from the dict detail (G-320b client arm)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
// The D-091 wire contract: byte-stable zeros in BOTH arms —
// the client consumes reason + upgrade_cta ONLY (G-320b).
reason: "no_active_package",
posts_used: 0,
posts_limit: 0,
upgrade_cta:
"This employer has no active posting package — purchase one via /v1/employers/billing/checkout.",
},
}),
{ status: 402, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const { createOwnEmployerPosting, UpgradeRequiredError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
createOwnEmployerPosting({ title: "Blocked" }),
(err: unknown) => {
const upgrade = err as InstanceType<typeof UpgradeRequiredError>;
assert.ok(err instanceof UpgradeRequiredError, "must be UpgradeRequiredError");
assert.equal(upgrade.reason, "no_active_package");
assert.equal(
upgrade.upgradeCta,
"This employer has no active posting package — purchase one via /v1/employers/billing/checkout.",
);
assert.equal(upgrade.status, 402);
return true;
},
"402 maps to UpgradeRequiredError with reason + CTA",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("403 no_claimed_employer on a write route → ClaimRequiredError (claim-prompt on 403)", 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 — submit a claim via POST /v1/employers/claims.",
},
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const { createOwnEmployerPosting, ClaimRequiredError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
createOwnEmployerPosting({ title: "Blocked" }),
(err: unknown) => {
const claim = err as InstanceType<typeof ClaimRequiredError>;
assert.ok(err instanceof ClaimRequiredError, "must be ClaimRequiredError");
assert.equal(claim.reason, "no_claimed_employer");
return true;
},
"the claim-CTA 403 maps to ClaimRequiredError on write routes",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("publish/close dispatch: the wire verbs hit their sub-routes (the component refetches after — pinned in source)", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method });
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await mod.publishOwnEmployerPosting("job-1");
await mod.closeOwnEmployerPosting("job-1");
assert.equal(
new URL(calls[0].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/publish",
);
assert.equal(calls[0].method, "POST", "publish dispatches a POST");
assert.equal(
new URL(calls[1].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/close",
);
assert.equal(calls[1].method, "POST", "close dispatches a POST");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: PostingManager write actions — honest 402/403/409/404 branches + refetch (G-312 source pins)", async () => {
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
// The four write actions are wired to the engine-client functions.
for (const fn of [
"createOwnEmployerPosting",
"editOwnEmployerPosting",
"publishOwnEmployerPosting",
"closeOwnEmployerPosting",
]) {
assert.ok(src.includes(fn), `write action wired: ${fn}`);
}
// The four client functions take an AbortSignal (A-406).
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
for (const fn of [
"createOwnEmployerPosting",
"editOwnEmployerPosting",
"publishOwnEmployerPosting",
"closeOwnEmployerPosting",
]) {
assert.ok(
new RegExp(`export async function ${fn}\\([\\s\\S]*?signal\\?`).test(client),
`${fn} takes an AbortSignal`,
);
}
// 402 → the upgrade CTA with reason ONLY (G-320b binding).
assert.ok(/UpgradeRequiredError/.test(src), "the 402 arm is consumed");
assert.ok(/upgradeCta/.test(src), "the CTA text renders from the payload");
assert.ok(/reason === 'no_active_package'/.test(src), "reason drives the copy (no package)");
assert.ok(/reason === 'posting_limit_reached'/.test(src), "reason drives the copy (limit reached)");
assert.ok(/href="\/employer\/billing"/.test(src), "the CTA routes to the employer billing surface");
// The 402 branch NEVER renders the payload's zeroed usage fields as
// limits — real limits render exclusively from the entitlement view
// (P5's route; absent until then). Byte-stable source pin.
const upgradeBranch = src.slice(
src.indexOf("function UpgradeNotice"),
src.indexOf("function writeActionMessage"),
);
assert.ok(
!/posts_used|posts_limit/.test(upgradeBranch),
"G-320b: the 402 branch renders no usage/limit numbers",
);
// 403 claim-CTA → the claim prompt on write actions too.
assert.ok(/ClaimRequiredError/.test(src), "the claim-CTA arm is consumed");
assert.ok(/claimPrompt/.test(src), "claim-prompt state exists");
// 401 → honest logged-out state (never a retry loop).
assert.ok(/SessionRequiredError/.test(src), "the 401 arm is consumed");
assert.ok(/loggedOut/.test(src), "logged-out state exists");
// 409 own-drafts-only error copy (G-318).
assert.ok(/err\.status === 409/.test(src), "the 409 branch exists");
assert.ok(/Only your own drafts/.test(src), "honest own-drafts-only copy");
// 404 honest.
assert.ok(/err\.status === 404/.test(src), "the 404 branch exists");
assert.ok(/no longer exists/.test(src), "honest 404 copy");
// Per-action loading + error states.
assert.ok(/actionBusy/.test(src), "per-action loading state");
assert.ok(/actionError/.test(src), "per-action error state");
// NO optimistic updates — the refetch composes every action.
assert.ok(/refetch/.test(src), "the list refetches after actions");
assert.ok(/await refetch\(controller\.signal\)/.test(src), "refetch AWAITS the read route");
assert.ok(!/setPostings\(/.test(src.slice(src.indexOf("runAction"))), "no optimistic posting writes");
// AbortController + no retry machinery (A-406).
assert.ok(/AbortController/.test(src), "AbortController discipline");
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), "no retry loop machinery");
// No console logging (tokens/errors never logged).
assert.ok(!/console\./.test(src), "no console logging in the component");
});
test("pin: the dead mock PostingManager is gone (no mock-driven employer surface components)", async () => {
assert.ok(
!existsSync(web("components/employer/posting-manager.tsx")),
"the mock posting-manager.tsx is deleted (P4 write actions replace it)",
);
// The real surface is mock-free: no derived statuses, no mock imports.
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
assert.ok(!/@nextcraft\/mock-data/.test(src), "the real surface never imports mock data");
assert.ok(!/deriveStatus|deriveApplicants/.test(src), "no derived mock statuses/applicants");
});
+296
View File
@@ -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",
);
});
+268
View File
@@ -263,3 +263,271 @@ 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",
);
});
test("wire pin: EmployerPostingCreate/Edit/WriteResponse mirror employers/base.py field-for-field (P4 write contracts)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// EmployerPostingCreate: every pydantic field asserted on BOTH sides.
const createBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingCreate"),
typesSrc.indexOf("export interface EmployerPostingEdit"),
);
for (const f of [
"title: string;",
"description?: string;",
"required_competencies?: string[];",
"skills?: string[];",
"seniority?: string | null;",
"category?: string | null;",
"location?: string;",
"remote?: boolean;",
"salary_min?: number | null;",
"salary_max?: number | null;",
]) {
assert.ok(createBlock.includes(f), `EmployerPostingCreate missing base.py field ${f}`);
}
// The employer_id field is deliberately ABSENT (the entity derives
// from the session's claim, never the body — Task 4-1-02).
assert.ok(
!/employer_id/.test(createBlock),
"EmployerPostingCreate never carries employer_id (claim-derived)",
);
// The py side keeps the same fields (base.py EmployerPostingCreate).
const pyCreateBlock = pySrc.slice(
pySrc.indexOf("class EmployerPostingCreate"),
pySrc.indexOf("class EmployerPostingEdit"),
);
for (const f of [
"title: str = Field(min_length=1)",
"description: str =",
"required_competencies: list[str]",
"skills: list[str]",
"seniority: str | None",
"category: str | None",
"location: str =",
"remote: bool =",
"salary_min: int | None",
"salary_max: int | None",
]) {
assert.ok(pyCreateBlock.includes(f), `base.py EmployerPostingCreate missing ${f}`);
}
// EmployerPostingEdit: the partial shape (title/description/skills).
const editBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingEdit"),
typesSrc.indexOf("export interface EmployerPostingWriteResponse"),
);
for (const f of ["title?: string;", "description?: string;", "skills?: string[];"]) {
assert.ok(editBlock.includes(f), `EmployerPostingEdit missing base.py field ${f}`);
}
const pyEditBlock = pySrc.slice(
pySrc.indexOf("class EmployerPostingEdit"),
pySrc.indexOf("class EmployerPostingWriteResponse"),
);
for (const f of [
"title: str | None = Field(default=None, min_length=1)",
"description: str | None = None",
"skills: list[str] | None = None",
]) {
assert.ok(pyEditBlock.includes(f), `base.py EmployerPostingEdit missing ${f}`);
}
// EmployerPostingWriteResponse: the write-verdict shape both sides.
const respBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingWriteResponse"),
);
for (const f of [
"id: string;",
"source: string;",
"external_id: string;",
"employer_id: string;",
"title: string;",
"status: string;",
]) {
assert.ok(respBlock.includes(f), `EmployerPostingWriteResponse missing base.py field ${f}`);
}
const pyRespBlock = pySrc.slice(pySrc.indexOf("class EmployerPostingWriteResponse"));
for (const f of ["id: str", "source: str", "external_id: str", "employer_id: str", "title: str", "status: str"]) {
assert.ok(pyRespBlock.includes(f), `base.py EmployerPostingWriteResponse missing ${f}`);
}
});
+175
View File
@@ -0,0 +1,175 @@
/**
* 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;
/** POST /v1/employers/postings body (base.py EmployerPostingCreate,
* Task 4-1-02): the employer-authored draft — the AdminPostingCreate
* shape MINUS employer_id (the entity derives from the session's
* CLAIM, never the body). Starts as a DRAFT; publish is explicit (the
* admin-route lifecycle discipline). Field-for-field mirror: title is
* required (min_length 1); every other field carries the pydantic
* default and may be omitted on the wire. */
export interface EmployerPostingCreate {
title: string;
description?: string;
required_competencies?: string[];
skills?: string[];
seniority?: string | null;
category?: string | null;
location?: string;
remote?: boolean;
salary_min?: number | null;
salary_max?: number | null;
}
/** PATCH /v1/employers/postings/{id} body (base.py EmployerPostingEdit,
* G-318): partial content edit — fields omitted stay put; a
* successful edit RE-TAGS the posting (tags are derived data, D-052).
* Own DRAFTS only (published/closed → 409). */
export interface EmployerPostingEdit {
title?: string;
description?: string;
skills?: string[];
}
/** Create/edit/publish/close response (base.py
* EmployerPostingWriteResponse): the write-verdict shape — G-317's
* source stamp rides it ("employer" on every employer-authored row;
* seeded/admin rows keep their own). */
export interface EmployerPostingWriteResponse {
id: string;
source: string;
external_id: string;
employer_id: string;
title: string;
status: string;
}
+1
View File
@@ -1,5 +1,6 @@
export * from './domain';
export * from './billing';
export * from './employer';
export * from './defense';
export * from './grading';
export * from './marketplace';