diff --git a/.env.example b/.env.example
index 7cdf4bb..d1fc1f5 100644
--- a/.env.example
+++ b/.env.example
@@ -48,6 +48,19 @@ PIG_PORT=8920
PIG_PUBLIC_URL=http://localhost:8920
NODE_ENV=development
+# --- Deployment: which image to run -----------------------------------------
+# Leave EMPTY to build from the working tree, which is what a development or
+# self-hosted-from-source install wants. Set it to a published tag and
+# scripts/deploy.sh pulls instead of building, and compose runs exactly that
+# image for both the app and Piggy — never one version of each.
+#
+# Set automatically by scripts/autodeploy.sh; you only put it here to pin a
+# specific release by hand.
+# PIG_IMAGE=git.karti.ai/pig/pig:release-2026-08-13
+PIG_IMAGE=
+# The loopback port the app is published on. TLS belongs to the proxy in front.
+PIG_HOST_PORT=8920
+
# Comma-separated emails granted platform-admin rights.
# Every address listed here MUST already have an account. An address listed but
# unregistered is a standing offer of admin to whoever claims it first.
@@ -84,6 +97,37 @@ PIGGY_CHAT_PORT=8931
# published Piggy port.
PIGGY_CHAT_ALLOW_NON_LOOPBACK=false
+# --- Deployment: the release poller -----------------------------------------
+# Only relevant on a host running scripts/autodeploy.sh. These belong in
+# /etc/pig/autodeploy.env (read by the systemd unit), not here — they are
+# listed here so the whole deployment surface is in one file to read.
+#
+# The registry credential is NOT an environment variable. It is a file, mode
+# 0600, holding a pull-only token and nothing else:
+#
+# /etc/pig/registry-token
+#
+# Mint it in Gitea as a token with `read:package` scope ONLY. A token that can
+# write packages, or push to the repository, defeats the point: the reason CI
+# cannot deploy to production is that no build-side credential should be able
+# to change what production runs, and a write-capable token here reintroduces
+# exactly that from the other end.
+#
+# PIG_REGISTRY_USER=pig-deploy # the Gitea user that owns the token
+# PIG_REGISTRY=git.karti.ai
+# PIG_IMAGE_REPO=pig/pig # Gitea lowercases the owner
+# PIG_REGISTRY_TOKEN_FILE=/etc/pig/registry-token
+# PIG_REPO_DIR=/opt/pig
+# PIG_RELEASE_TAG_PREFIX=release-
+#
+# The public origin deploy.sh checks AFTER the container is healthy, to catch a
+# proxy that is answering 200 with an empty body. Defaults to PIG_PUBLIC_URL
+# above, then to the production origin.
+# PIG_DEPLOY_PUBLIC_URL=https://primeintellectgrowth.com
+# A string the real application always renders. Change it only if index.html's
+# mount point changes.
+# PIG_DEPLOY_PUBLIC_MARKER=
+
# --- Slack ------------------------------------------------------------------
SLACK_BOT_TOKEN=
SLACK_SIGNING_SECRET=
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 83f81a1..4599ae8 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -18,12 +18,29 @@
# matches what the proxy is configured to allow. Editing that script
# changes its hash, and the failure mode is a silent white flash for
# dark-mode users rather than an error.
+#
+# THE CSP HASH IS DUPLICATED IN THREE PLACES: the `expected` constant below,
+# `deploy/Caddyfile.example`, and the LIVE Caddyfile on cloud-2. Only the first
+# two are checked by anything. The live one is the copy that actually decides
+# whether a browser runs the script, and nothing in this repository can see it,
+# so changing the script means editing all three by hand — see deploy/README.md.
+#
+# Shipping is a two-step, and the second step is a human:
+#
+# push to main -> `verify` only. Nothing is published, nothing deploys.
+# tag release-* -> `verify`, then `publish` pushes the image to the Gitea
+# registry. The production host notices it and deploys.
+#
+# So the tag IS the ship decision. No credential on this runner can reach
+# cloud-2; the host pulls, the runner never pushes to it.
name: CI
on:
push:
branches: [main]
+ # A tag push runs the same verification and then, and only then, publishes.
+ tags: ['release-*']
pull_request:
jobs:
@@ -178,3 +195,76 @@ jobs:
- name: Stop Postgres
if: always()
run: docker rm -f "$PG_CONTAINER" 2>/dev/null || true
+
+ # Publish the image that production will run.
+ #
+ # Only on a `release-*` tag. A push to main proves the commit is sound and
+ # stops there; tagging is the deliberate, human act that says "ship this".
+ # The production host polls the registry for the newest release tag and
+ # deploys it (scripts/autodeploy.sh) — which is how this gets automated
+ # WITHOUT the thing scripts/deploy.sh refuses to do. Nothing here holds a
+ # credential for cloud-2, and nothing here can execute anything on cloud-2.
+ #
+ # `gitea.ref` and `github.ref` are the same object in Gitea Actions; the
+ # gitea-prefixed spelling is used for the ref test because that is the one
+ # documented for tag conditions, and github.* elsewhere to match the job
+ # above.
+ publish:
+ needs: verify
+ if: startsWith(gitea.ref, 'refs/tags/release-')
+ runs-on: ubuntu-latest
+
+ env:
+ REGISTRY: git.karti.ai
+ # Gitea namespaces packages under the lowercased owner, so PIG/pig is
+ # published as pig/pig.
+ IMAGE: git.karti.ai/pig/pig
+ # THE POINT OF THIS VARIABLE: the shared act_runner on cloud-1 runs with
+ # `container.network: host`, and its docker config is visible to jobs
+ # from every other repository on that host. A plain `docker login` would
+ # leave a credential in ~/.docker/config.json that any of them could
+ # read. Pointing DOCKER_CONFIG at a per-run directory keeps the token out
+ # of the shared file entirely; the logout step below is the second belt.
+ DOCKER_CONFIG: /tmp/pig-docker-${{ github.run_id }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Log in to the Gitea registry
+ # The per-run Actions token, not a long-lived secret: it is minted for
+ # this run and dies with it. --password-stdin because an argument is
+ # visible in the runner's process list to anything else on that host.
+ run: |
+ mkdir -p "$DOCKER_CONFIG"
+ printf '%s' '${{ secrets.GITHUB_TOKEN }}' \
+ | docker login "$REGISTRY" -u '${{ github.actor }}' --password-stdin
+
+ - name: Build and push
+ # Both cloud-1 and cloud-2 are aarch64, so this is a native build and
+ # needs no --platform. The layer cache from the `verify` job's
+ # `docker build` is warm on this same daemon, so the rebuild is cheap.
+ #
+ # Two tags, always pushed together: the tag is what a human asked for,
+ # the short sha is what is unambiguous a year later when tags have been
+ # moved or deleted.
+ run: |
+ TAG="${GITHUB_REF#refs/tags/}"
+ SHORT_SHA=$(printf '%s' "${{ github.sha }}" | cut -c1-7)
+ echo "Publishing $IMAGE:$TAG and $IMAGE:$SHORT_SHA"
+
+ docker build -t "$IMAGE:$TAG" -t "$IMAGE:$SHORT_SHA" .
+ docker push "$IMAGE:$TAG"
+ docker push "$IMAGE:$SHORT_SHA"
+
+ # Print the digest: it is what the host poller compares against, and
+ # the only identifier that cannot be reassigned.
+ docker image inspect "$IMAGE:$TAG" \
+ --format '{{range .RepoDigests}}{{println .}}{{end}}'
+
+ - name: Log out
+ if: always()
+ # Runs even when the build failed, because a failed job that left a
+ # credential behind is exactly the leak this is guarding against.
+ run: |
+ docker logout "$REGISTRY" || true
+ rm -rf "$DOCKER_CONFIG"
diff --git a/AGENTS.md b/AGENTS.md
index c6badb4..0f6ff52 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,23 +30,28 @@ Everything else is plumbing that exists to keep that ledger honest.
## 2. Orientation
```
-packages/core Ontology (stages, tiers, enums) + margin arithmetic + palette
-packages/db Drizzle schema, migrations, seeds
+packages/core Ontology (stages, tiers, enums) + permissions + margin + palette
+packages/db Drizzle schema (47 tables), migrations, seeds
packages/prime Typed client for the Prime Intellect compute API
-apps/api Hono HTTP API, auth, capacity service
+apps/api Hono HTTP API, auth, capacity/contract/calendar services
apps/web React + Vite + Tailwind + shadcn-idiom components
+apps/piggy The agent — lease-based queue worker + private chat server
apps/mcp MCP server (stdio) — 9 tools
-docs/ ontology.md, build-plan.md, agents.md, deploy.md, seed-data.md
+apps/cli `pig`, the HTTP surface for scripts and agent kernels
+docs/ ontology.md, build-plan.md, agents.md, seed-data.md
+deploy/ README.md (deployment), Caddyfile example, autodeploy units
```
-~11,000 lines. 39 tests. Node 22+.
+~45,000 lines including tests. 261 tests across five packages
+(core 62, prime 24, api 157, piggy 13, cli 5), plus a critical-path E2E suite
+under `apps/api/e2e`. Node 22+.
| | |
|---|---|
| Repo | `PIG/pig` on git.karti.ai (Gitea) |
| Live | https://primeintellectgrowth.com |
| CI | Gitea Actions, `.gitea/workflows/ci.yml`, ~2 min, must stay green |
-| Deploy | `bash scripts/deploy.sh` on the host — deliberately manual |
+| Deploy | Push a `release-*` tag; CI publishes the image and the host's poller pulls it. A push to `main` deploys nothing. `bash scripts/deploy.sh` on the host is the manual path |
---
@@ -204,8 +209,10 @@ the expected value in the workflow.
**`prices.onDemand` from the Prime Intellect API is the TOTAL FOR THE NODE.**
Verified: 1× A100 at 1.79, 2× A100 at 3.58. `gpuMemory` is likewise a node
-total. There is an open bug for this — the mapper currently stores both as if
-per-GPU, so an 8-GPU node reads eight times too expensive.
+total. `packages/prime/src/map.ts` now divides both by `gpuCount` at the
+boundary and keeps the node totals in `raw` for reconciliation — this was a
+real bug that made an 8-GPU node read eight times too expensive. Anything new
+that reads an upstream price must normalise the same way.
**The SPA fallback must never answer an `/api/` path.** Without an explicit
guard, an unknown API route returns `200 text/html` — the app shell — and the
@@ -221,6 +228,15 @@ pods. Inference is `api.pinference.ai/api/v1`, OpenAI-compatible.
hybrid reasoning model; under a tight `max_tokens` it rambles and truncates.
Pass `reasoning_effort: "none"` for tool use, routing and extraction.
+**A route file with green tests can still be unmounted.** Every route module is
+a factory returning a `Hono` app, and `createApp` has to call it. The tests
+mount the factory themselves, so they pass whether or not `app.ts` ever does.
+Four modules are in exactly that state right now — `read-guards.ts`,
+`learn.ts`, `hubspot.ts`, `hubspot-webhook.ts` — which is why read
+authorisation is unenforced and `/learn` answers 404 from a page that is in the
+navigation. After adding a route file, curl the path against a running server;
+the test suite cannot tell you.
+
**Deployment traps** live in `deploy/README.md` — chiefly that every Caddy site
block on that host needs `bind 10.0.0.2`, and that the CI runner uses
`container.network: host` so dependencies must be published on `127.0.0.1`.
@@ -258,28 +274,30 @@ real database. "It should work" has been wrong repeatedly.
## 7. Where to start
-[`docs/build-plan.md`](./docs/build-plan.md) has 24 tasks in three waves with
-real dependency edges.
+**Every task in the original three-wave plan has shipped.**
+[`docs/build-plan.md`](./docs/build-plan.md) is now an audited record of that
+rather than a queue, and it carries the remaining work at the bottom. The two
+interfaces everything else codes against — `packages/core/src/permissions.ts`
+(the RBAC model) and `apps/api/src/lib/mutation.ts` (the write path) — are
+settled; read them before adding any write.
-**Do these first, alone, before anything fans out:**
+**The highest-value work now, in order:**
-- **F1** — install the shadcn primitive set
-- **F3** — the RBAC permission model
-- **F2** — the shared API write-path convention (needs F3 to call into)
+1. **Mount `createReadGuardRoutes`.** The read half of the permission model is
+ written, tabulated and tested, and does nothing, because `app.ts` never
+ mounts it. Until it does, every authenticated member can read supplier cost
+ and margin. It is one line, and it must be registered *before* the handlers
+ it guards — Hono runs matched handlers in registration order.
+2. **Mount `learn.ts`.** `/learn` is in the navigation and its API answers 404.
+3. **Enqueue the other six agent task kinds.** The worker is complete; only
+ `enrich_account` and `enrich_contact` are ever written to `agent_tasks`, so
+ Piggy does far less than the ontology implies.
+4. **Mount the HubSpot routes, or delete them.** Seven tables, OAuth, sync jobs
+ and webhook verification, all written, tested and unreachable.
-They are small and they are the interface every other track codes against.
-Starting parallel work before they settle is how it turns into merge conflict.
-
-**Then the two that unblock a demo:**
-
-- **A1** — allocation and commitment write paths. Today the core table can only
- be populated by seed, so a visitor can look at the demo book but cannot enter
- a deal of their own.
-- **A2** — API keys. Nothing mints one, so the MCP server — the headline
- feature — is unreachable in production.
-
-**A4 (Piggy) is fully independent** and can start immediately alongside the
-foundation. It touches no UI and no shared API conventions.
+`app.ts` is the one shared file. If your change needs a route mounted, a public
+path allowlisted or a schema widened there, say so rather than racing another
+agent for it.
---
diff --git a/README.md b/README.md
index 63bd865..5c09cb3 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[](./LICENSE)
-*Self-hostable. Auditable. Built for teams that buy compute on one side and sell it on the other.*
+*Self-hostable. Auditable. Built for teams that buy GPU capacity on one side and sell it on the other.*
@@ -17,167 +17,490 @@
A company that aggregates GPU capacity and resells it does not run one pipeline.
It runs two, and its business is the spread between them.
-Generic CRMs — Salesforce, HubSpot, Attio — model a single pipeline of deals
-against companies. They have no concept of **inventory**, no concept of a
-**commitment you already bought and are paying for**, and therefore no way to
-answer the question the business actually turns on:
+Today that spread is usually managed in a spreadsheet with a margin calculator
+in column K, a document of supplier terms, and a general-purpose CRM that has
+no idea what an H100-hour is. Salesforce, HubSpot and Attio model a single
+pipeline of deals against companies. They have no concept of **inventory**, no
+concept of a **commitment you already bought and are paying for whether or not
+it sells**, and therefore no way to answer the question the business turns on:
> Which contracted capacity is sold, to whom, at what margin — and what is idle
> right now?
-PIG is built around that question. One table, [`allocations`](./packages/db/src/schema/allocations.ts),
-joins a `capacity_commitment` (what you bought from a provider) to a
-`demand_deal` (what you sold to a customer). Revenue minus cost is margin per
-GPU-hour. Committed capacity with no allocation is money burning. Everything
-else in PIG is ordinary CRM plumbing that exists to keep that ledger honest.
+PIG is one ledger that knows the domain. The load-bearing table is
+[`allocations`](./packages/db/src/schema/allocations.ts), which joins a
+`capacity_commitment` (what you bought, at a known cost) to a `demand_deal`
+(what you sold, at a known price). Margin, utilisation and idle capacity all
+fall out of that one join. Everything else is plumbing that keeps the ledger
+honest.
-## Who it's for
+**Cost is charged against the full commitment, not only the hours that sold.**
+Unsold hours are already paid for. Charging only the allocated share reports a
+healthy margin on a block that is losing money, which is precisely the failure
+PIG exists to prevent. There is a test pinning it.
+
+## Who it is for
PIG models three teams, because two-sided compute companies have three
-constituencies competing for the same scarce capacity:
+constituencies competing for the same scarce capacity.
| Team | Job to be done |
|---|---|
-| **Supply** | Source, qualify, price, and contract GPU capacity from providers |
+| **Supply** | Source, qualify, price and contract GPU capacity from providers |
| **Demand** | Sell compute and post-training; renew and expand accounts |
| **Research** | Consume capacity internally — real burn, no revenue |
-Research is a first-class tenant rather than an afterthought. Internal research
-burn competes with revenue for the same GPUs, and margin math that cannot see it
-is wrong.
+Research is a first-class tenant rather than an afterthought: internal burn
+competes with revenue for the same GPUs, and margin arithmetic that cannot see
+it is wrong.
-The team set is configurable. PIG ships with these three because they match the
-structure of the company it was designed for, not because they are universal.
+The team set is configurable in `packages/core/src/ontology.ts`. PIG ships with
+these three because they match the structure of the company it was designed
+for, not because they are universal.
-## Agent-native, not agent-decorated
+## Screenshots
-PIG is a first-class application for agents *and* for humans, and neither is a
-degraded view of the other.
+> **Placeholder — fresh captures needed.** The application shell was rebuilt as
+> a three-pane layout (header, collapsible sidebar rail, docked Piggy) and
+> every screenshot taken before that redesign now misrepresents the product.
+> Rather than ship misleading images, this section is deliberately empty until
+> the new shell is re-shot at desktop and at 393px, in light and dark.
+>
+> Pages to capture: `/` Overview, `/margin`, `/capacity` (the matcher),
+> `/calendar`, `/piggy` docked beside a record.
-- **An MCP server** ([`apps/mcp`](./apps/mcp)) exposes the CRM over both stdio
- and Streamable HTTP. Any MCP client connects: **Claude Code**, **Codex**,
- **[prime-agent](https://github.com/PrimeIntellect-ai/prime-agent)**, or a
- **[Buzz](https://github.com/block/buzz)** workspace agent via its ACP bridge.
- Each team member points their own agent at PIG and works from the terminal.
-- **Piggy**, the in-app agent, drains a leased database queue rather than being
- called over HTTP — so work survives the agent being down, and every action it
- takes is recorded with an idempotency key.
-- **Every agent-derived fact carries evidence.** Enrichment writes to a `facts`
- table with a confidence score, a band (verified / probable / possible), a
- source URL, and a status. Strong signals apply automatically; weak ones become
- proposals a human approves. A CRM that lets an agent write unattributed claims
- into the record is a hallucination store, not a database.
+
-### The architectural rule
+## The two-sided data model
-> **Intelligence never lives in the API.**
+Forty-seven tables, but the shape is small. These are the ones that carry the
+thesis:
-The API does HTTP, auth, validation, and sync. All research, enrichment,
-scoring, and identity matching lives in the agent. They communicate through a
-table, never a direct call. This separation is borrowed from
-[Comp AI CRM](https://github.com/trycompai/crm) and it is the single most
-load-bearing decision in the codebase.
+| Table | What it holds | Why it is not in a generic CRM |
+|---|---|---|
+| `capacity_commitments` | What you bought: term, GPU-hours, cost per GPU-hour, floor and ceiling, and a **shape** (`{intervals[], quantities[]}`) | Real contracts ramp across tranches and step down at checkpoints; a single start/end/total reports availability that does not exist in the month someone wants it |
+| `demand_deals` | What you are selling: ACV, product line, MSA/DPA state, stage | The paper state is a separate axis from the stage, because paper gates delivery |
+| `supply_deals` | The other pipeline: sourcing a provider through diligence to live | Generic CRMs have one pipeline and call the supplier a vendor |
+| **`allocations`** | **The join.** Commitment × deal × GPU-hours × window × status | This is the whole product. Margin, utilisation and idle all derive from it |
+| `inventory_listings` | Market availability mirrored from the Prime Intellect API | Sync is a straight field mapping, not an ETL project |
+| `capacity_requests` | What a customer asked for, whether or not it could be served | Unservable demand is the signal for what to buy next |
+| `contracts` + `sla_terms` + `sla_metric_targets` + `contract_obligations` | Polymorphic over party and type — MSA, DPA, SLA, order form, capacity commitment — with negotiated SLA terms and dated obligations | The supply side negotiates heavyweight paper; the self-serve demand side runs on a reliability tier and a credits policy instead |
+| `export_authorizations`, `compliance_artifacts`, `compliance_decisions` | Export-control determinations recorded **on the allocation edge**, with reasoning and rule version | US controls apply an ultimate-parent test that reaches through the corporate tree, so country of incorporation is not a valid key |
+| `facts` | Every agent-derived claim, with score, band, evidence excerpt and source URL | An agent allowed to write unattributed claims will eventually write a wrong one and nobody will be able to tell which |
+| `agent_tasks` / `agent_runs` / `agent_actions` | The queue the API writes to and the agent drains, plus what it did | The API never calls the model; it writes a row |
-## What makes it compute-native
+Two pipelines, with stages taken from how the market operates:
-- **`inventory_listings`** mirrors the Prime Intellect availability API
- field-for-field — `gpuType`, `socket`, `interconnectType`, `stockStatus`,
- `security` (secure vs community cloud), `prices.onDemand`, `provisioningTime`.
- Sync is a straight mapping, not an ETL project.
-- **`capacity_commitments`** records what you bought: term, GPU-hours,
- cost per GPU-hour, floor and ceiling.
-- **`contracts`** is polymorphic over party and type — MSA, DPA, SLA, order
- form, capacity commitment — because the supply side negotiates heavyweight
- paper while the self-serve demand side runs on a reliability tier and a
- credits policy instead of a signed uptime guarantee.
-- **Two real pipelines**, with stages taken from how this market actually
- operates rather than invented:
+```
+Demand: qualification → legal → scoping → proposal → procurement
+ → POC → deployment → expansion (+ closed_won / closed_lost)
- ```
- Demand: qualification → legal → scoping → proposal → procurement
- → POC → deployment → expansion
+Supply: sourced → qualifying → technical diligence → financial diligence
+ → pricing → contracting → onboarding → live → renewal
+ (+ churned / rejected)
+```
- Supply: sourced → qualifying → technical diligence → financial diligence
- → pricing → contracting → onboarding → live → renewal
- ```
+**Legal sits second** in the demand pipeline. MSA and DPA execution gates the
+deal rather than closing it. Most CRMs put contracts at the end of the funnel
+and are wrong about it for this market.
- Note that **legal sits second** in the demand pipeline. MSA and DPA execution
- gates the deal rather than closing it. Most CRMs put contracts at the end and
- are wrong about it for this market.
+Three further decisions worth knowing before you read the schema:
-## Stack
+- **Holds reserve; they do not sell.** A live hold removes capacity from
+ everyone else's availability — otherwise two sellers promise the same GPUs —
+ but never counts toward utilisation or revenue.
+- **Security tiers are ranked, not labelled.** `community_cloud` <
+ `secure_cloud` < `government`, and a requirement is satisfied only from at or
+ above its tier.
+- **Money is integer cents**, rounded exactly once, at the boundary.
-| Layer | Choice |
-|---|---|
-| Web | React + Vite + TypeScript, Tailwind, shadcn/ui, light + dark |
-| API | Hono + tRPC on Node 22+ |
-| Database | PostgreSQL 16, Drizzle ORM |
-| Auth | Supabase (JWT verification only — PIG stores no passwords) |
-| Agent | Piggy — a worker draining a leased task queue |
-| MCP | `@modelcontextprotocol/sdk` — stdio + Streamable HTTP |
-| Deploy | Docker Compose behind any reverse proxy |
+## Self-hosting
-Authorization comes from PIG's own `users` table, never from the mere existence
-of an auth account. An identity provider that PIG shares with another
-application must not grant access here.
+### Requirements
-## Quick start
+Node 22+, pnpm 11+ (pinned by `packageManager`; `corepack enable` installs it),
+and a PostgreSQL 16 database that PIG owns exclusively.
+
+### Development
```bash
-git clone pig && cd pig
+corepack enable
pnpm install
-cp .env.example .env # then edit it
+
+docker run -d --name pig-dev -p 5432:5432 \
+ -e POSTGRES_USER=pig -e POSTGRES_PASSWORD=pig -e POSTGRES_DB=pig \
+ postgres:16-alpine
+
+export DATABASE_URL=postgres://pig:pig@localhost:5432/pig
pnpm run db:migrate
-pnpm run db:seed # optional — public, sourced, confidence-graded
-pnpm run dev:api # :8920
-pnpm run dev:web # :5173
+pnpm run db:seed # optional — sourced, cited, confidence-graded people
+pnpm run db:demo # optional — a plausible demo book, prefixed "DEMO — "
+
+pnpm run dev:api # :8920
+pnpm run dev:web # :5173, proxies /api to :8920
```
-Connect an agent:
+With no identity provider configured, **authentication is disabled in
+development** and every request runs as the first user in the table.
+`loadConfig` refuses to start with `NODE_ENV=production` in that state, so it
+cannot leak into a deployment.
+
+### Production
```bash
-claude mcp add pig -- npx -y @pig/mcp # stdio
-# or point any MCP client at https:///mcp
+cp .env.example .env # then edit
+docker compose -p pig up -d db
+docker compose -p pig run --rm --no-deps app pnpm exec tsx packages/db/src/migrate.ts
+docker compose -p pig up -d --build app
```
-## Repository layout
+Migrate from a one-off container **before** the app starts, not with `exec`: a
+release that queries a table its migration has not yet created crash-loops
+before you can attach to it. Full deployment notes, including the reverse
+proxy, the release poller and rollback semantics, are in
+[`deploy/README.md`](./deploy/README.md).
+
+### Every environment variable
+
+Read from `apps/api/src/lib/config.ts` (API), `apps/piggy/src/config.ts`
+(Piggy) and `docker-compose.yml`. **Bold** means no default.
+
+#### Required
+
+| Variable | Default | Notes |
+|---|---|---|
+| **`DATABASE_URL`** | — | The only unconditionally required value. PIG owns this database exclusively |
+| **`POSTGRES_PASSWORD`** | — | Compose only; `docker-compose.yml` refuses to start without it |
+
+In production you must additionally set **either** `SUPABASE_URL` **or**
+`PIG_OIDC_ISSUER`. The API throws at boot with neither.
+
+#### Identity
+
+| Variable | Default | Notes |
+|---|---|---|
+| `SUPABASE_URL` | unset | Hosted path. Absent in development ⇒ auth disabled |
+| `SUPABASE_ANON_KEY` | unset | Public by design; served to the browser via `/api/config` |
+| `SUPABASE_SERVICE_KEY` | unset | Only for administrative provisioning and self-registration. Warns at boot when set |
+| `PIG_OIDC_ISSUER` | unset | On-premises path. **Takes precedence over `SUPABASE_URL`** |
+| `PIG_OIDC_JWKS_URI` | discovered | Set it to skip discovery on an air-gapped network |
+| `PIG_OIDC_AUDIENCE` | unset | Strongly recommended: without it, any token your provider issued for any application in the same tenant is accepted here. Warns, does not refuse |
+| `PIG_OIDC_EMAIL_CLAIMS` | provider defaults | Comma-separated, in preference order |
+
+#### Server
+
+| Variable | Default | Notes |
+|---|---|---|
+| `PIG_PORT` | `8920` | |
+| `PIG_PUBLIC_URL` | `http://localhost:8920` | The single origin the app is served from; CORS and the Google redirect are validated against it |
+| `NODE_ENV` | `development` | `production` activates the identity-provider guard |
+| `PIG_ADMIN_EMAILS` | `''` | Comma-separated. Every address must already have an account — an unregistered address here is a standing offer of admin rights to whoever claims it first |
+| `PIG_INVITE_CODE` | unset | Set it to gate signup |
+| `PIG_SETTINGS_ENCRYPTION_KEY` | unset | Base64-encoded 32 bytes. Required for Notion and Google OAuth; secrets written in the admin UI need it |
+
+#### Prime Intellect
+
+| Variable | Default | Notes |
+|---|---|---|
+| `PRIME_API_KEY` | unset | Scope it to `Availability → Read` only |
+| `PRIME_API_BASE` | `https://api.primeintellect.ai` | The compute/pods host. Inference is a *different* host — see below |
+| `PRIME_SYNC_ENABLED` | `false` | Warns if on without a key |
+| `PRIME_SYNC_INTERVAL_MINUTES` | `30` | |
+
+#### Piggy
+
+The API and the Piggy container read overlapping but distinct sets.
+
+| Variable | Default | Read by | Notes |
+|---|---|---|---|
+| `PIGGY_ENABLED` | `false` | API | Gates the chat surface |
+| **`PIGGY_INFERENCE_API_KEY`** | — | Piggy | Required by the Piggy process. The model credential never reaches the API container |
+| `PIGGY_INFERENCE_BASE` | `https://api.pinference.ai/api/v1` | both | OpenAI-compatible |
+| `PIGGY_MODEL` | `nvidia/nemotron-3-nano-30b-a3b` | both | Admin-selectable at runtime too |
+| `PIGGY_LEASE_SECONDS` | `300` | both | Queue lease duration |
+| `PIGGY_POLL_INTERVAL_MS` | `2000` | Piggy | |
+| `PIGGY_MAX_TOKENS` | `1024` | Piggy | |
+| `PIGGY_WORKER_ID` | `hostname:pid` | Piggy | |
+| `PIGGY_INTERNAL_URL` | unset | API | `http://piggy:8931` under Compose |
+| **`PIGGY_INTERNAL_TOKEN`** | — | both | Min 32 chars; required by the Piggy process. Never put it in a query string |
+| `PIGGY_CHAT_HOST` | `127.0.0.1` | Piggy | |
+| `PIGGY_CHAT_PORT` | `8931` | Piggy | Never published to the host |
+| `PIGGY_CHAT_ALLOW_NON_LOOPBACK` | `false` | Piggy | Compose sets `true`, because the API reaches it across the Compose network |
+
+#### Integrations — all optional, all validated as a group
+
+Setting one member of a group without the others fails at boot rather than
+half-working.
+
+| Group | Variables |
+|---|---|
+| Slack | `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET` |
+| Buzz | `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG` |
+| Notion import | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` (+ `PIG_SETTINGS_ENCRYPTION_KEY`) |
+| Google Sheets import | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` (+ `PIG_SETTINGS_ENCRYPTION_KEY`) |
+
+`GOOGLE_REDIRECT_URI` must be exactly `/oauth/google/callback`.
+
+## Architecture
+
+A pnpm monorepo. Around 45k lines of TypeScript including tests, 261 tests
+across five packages, green CI.
```
apps/
- web/ React + Vite front end
- api/ Hono + tRPC API, Supabase JWT verification
- mcp/ MCP server — stdio and Streamable HTTP
+ web/ React 19 + Vite + Tailwind + shadcn-idiom components
+ api/ Hono HTTP API — auth, validation, capacity and contract services
+ piggy/ The agent: a lease-based queue worker plus a private chat server
+ mcp/ MCP server (stdio) — 9 tools
+ cli/ `pig`, the HTTP surface for scripts and agent kernels
packages/
- db/ Drizzle schema, migrations, seed
- core/ Shared domain types and the ontology
+ core/ Ontology, permissions, margin arithmetic, palette — no I/O
+ db/ Drizzle schema (47 tables), 13 migrations, seed and demo data
prime/ Typed client for the Prime Intellect compute API
-docs/ Ontology, deployment, seed-data provenance
-deploy/ Compose files and reverse-proxy snippets
+docs/ ontology.md, build-plan.md, agents.md, seed-data.md
+deploy/ Caddyfile example, autodeploy units, deployment notes
```
+Three rules hold the shape:
+
+**Intelligence never lives in the API.** Handlers validate, authorise, call a
+service, serialise. Research, enrichment, scoring and matching heuristics live
+in the service layer or in the agent. The API signals the agent by *writing a
+row to `agent_tasks`*, never by calling it — so the queue survives the agent
+being down and no request thread ever blocks on a model.
+
+**Authentication is not authorisation.** A verified JWT proves someone has an
+account in an identity provider PIG may share with another application. Access
+additionally requires a row in PIG's own `users` table; a token without one
+gets `403 needs_profile`, which the front end turns into a join flow rather
+than a login screen they have already completed. Both providers reduce to
+"verify a bearer token, return a subject and an email" behind
+`apps/api/src/lib/auth-provider.ts`.
+
+**Writes go through one chokepoint.** `apps/api/src/lib/mutation.ts` derives
+zod schemas from the ontology, applies the capability check, runs the write and
+its audit activity in one transaction, and returns a consistent error shape.
+
+## The RBAC model, as it now stands
+
+Eleven capabilities, in `packages/core/src/permissions.ts`, resolved from team
+membership and role and shared by the API and the browser so a disabled button
+and a 403 cannot disagree.
+
+Roles are ranked, and every rule is "at or above": `viewer` < `member` <
+`lead` < `admin`. A platform admin (an address in `PIG_ADMIN_EMAILS`) holds
+everything, platform-wide.
+
+**Writes are team-scoped:**
+
+| Capability | Teams | Minimum role |
+|---|---|---|
+| `deal:write` | supply, demand | member |
+| `commitment:write` | supply | lead |
+| `contract:sign` | supply, demand | admin |
+| `activity:write` | all | member |
+| `data:import` | all | admin |
+| `fact:review` | research | admin |
+| `integration:connect` | all | admin |
+| `settings:admin` | — | platform admin only |
+
+**Reads are platform-wide, deliberately:**
+
+| Capability | Teams | Minimum role | Covers |
+|---|---|---|---|
+| `book:read` | all | viewer | Accounts, contacts, both pipelines, contracts, growth, facts |
+| `economics:read` | supply, demand | member | Supplier cost, break-even price, margin, idle, inventory, the dashboard |
+| `team:read` | all | viewer | The roster |
+
+Read grants are **not** team-scoped, and that is a decision rather than an
+omission: no row-level team filter exists anywhere in the query layer, so a
+"demand only" read grant would be a promise the guard could not keep. The
+honest model is that a read capability is held or it is not, and the *role*
+required to hold it is what separates the roster from the cost book.
+`economics:read` is the one that matters — supplier cost per GPU-hour and
+break-even price *are* the business.
+
+⚠️ **The read half is written and tested but not yet enforced.** The policy
+table (`apps/api/src/routes/read-guards.ts`) and its middleware exist, and
+`read-governance.test.ts` fails when a GET appears that no rule covers — but
+`createReadGuardRoutes` is not mounted in `app.ts`, so today every
+authenticated member can read the whole book including cost. See
+[limitations](#what-is-not-built-yet).
+
+## The agent surface
+
+PIG is a first-class application for agents *and* for humans, and neither is a
+degraded view of the other. There are two distinct surfaces.
+
+### Piggy — the in-app agent
+
+`apps/piggy` is one image running two processes' worth of behaviour:
+
+- **The queue worker** claims a task with `SELECT … FOR UPDATE SKIP LOCKED`
+ inside a transaction, holds a renewable lease (default 300s, renewed at half
+ the interval), and aborts its own work if it ever loses that lease — so two
+ workers can never both be mid-flight on one task. Failures retry with
+ exponential backoff capped at one hour, up to the task's `maxAttempts`. Every
+ attempt writes an `agent_runs` row with the model, the input, the token
+ counts and either a summary or the error. Its tool set is exactly two:
+ `pig_get_subject` and `pig_record_fact`, and a fact is refused without both a
+ source URL and an evidence excerpt.
+- **The chat server** listens on `127.0.0.1:8931` and is never published to the
+ host. The API authenticates the user, forwards bounded context, and calls it
+ with a shared internal bearer token. Chat is **read-only**: seven tools
+ (`pig_get_record`, `pig_get_account_lifecycle` and five page-scoped
+ summaries), each of which aggregates first and returns at most a handful of
+ exemplar rows, because interactive chat runs at 1024 max tokens across at
+ most four turns. Ambient coding tools are rejected before inference by an
+ explicit boundary check.
+
+Piggy is off by default. `PIGGY_ENABLED` defaults to `false` and the Compose
+service sits behind `profiles: ['piggy']`, so a default `docker compose up`
+starts the CRM without it:
+
+```bash
+docker compose -p pig --profile piggy up -d --build
+```
+
+### The MCP server — for the agent you already use
+
+`apps/mcp` speaks **stdio** and holds an API key. It calls the same HTTP API a
+browser does: no database credentials, no privileged path, and deliberately no
+tool that provisions infrastructure, spends money or emails a customer. Nine
+tools, because a sprawling tool list measurably degrades model performance:
+
+| Tool | What it answers |
+|---|---|
+| `pig_whoami` | Who am I acting for, and which teams am I on? |
+| `pig_my_pipeline` | Where are we? What needs attention? |
+| `pig_capacity_match` | What have we bought that would serve this customer? |
+| `pig_margin_report` | What is each block earning against what it cost? |
+| `pig_idle_capacity` | What are we paying for and not selling? |
+| `pig_inventory_search` | What could we buy to cover demand we cannot serve? |
+| `pig_search` | Find an account |
+| `pig_get_account` | Everything about one account |
+| `pig_log_activity` | Record a call, meeting or note |
+
+Mint a key in **Settings → API keys** (shown once), then run it from a clone —
+`@pig/mcp` is a workspace package and is not published to npm:
+
+```bash
+export PIG_URL=https://your-pig-host
+export PIG_API_KEY=pig_...
+claude mcp add pig -- pnpm --dir /path/to/pig exec tsx apps/mcp/src/stdio.ts
+```
+
+There is also a `pig` CLI with `--json` output for scripts and agent kernels;
+see [docs/agents.md](./docs/agents.md).
+
+## Shipping — tag to deploy
+
+CI is Gitea Actions, one sequence, about two minutes. It typechecks every
+package, applies the migration chain **twice** to a real empty Postgres, asserts
+the seed is idempotent, runs 261 unit tests and the critical-path E2E, boots the
+server and curls it, builds the front end, checks the inline theme script still
+hashes to the value the proxy's CSP allows, and builds the Docker image.
+
+Shipping is two steps and the second one is a human:
+
+```bash
+git tag release-2026-08-13 && git push origin release-2026-08-13
+```
+
+1. A push to `main` runs `verify` and stops. **Nothing deploys.**
+2. A `release-*` tag runs the same `verify`, then `publish` pushes
+ `git.karti.ai/pig/pig:` and `:` to the registry.
+3. Within five minutes `pig-autodeploy.timer` on the production host notices
+ the newest release tag has a different digest, checks the tree out at that
+ tag, and runs `scripts/deploy.sh` with `PIG_IMAGE` set.
+
+The direction of travel is the point: no credential on the shared CI runner can
+execute anything on the production host. The host holds a pull-only token and
+fetches. `deploy.sh` dumps the database first, gates on health, the
+unauthenticated-401 check and a public-origin body marker, and rolls back to
+the previous image if a gate fails — exiting 1 when the previous image was
+restored and 3 when the release under test is still live, because that is the
+one thing an on-call needs at 04:00.
+
+## What is not built yet
+
+Said plainly, because you are going to grep the repo anyway.
+
+**Read authorisation is not enforced.** As above: the policy, the middleware
+and the governance test all exist; the router line that mounts them does not.
+Any authenticated member can currently read cost and margin regardless of team
+or role. This is the most significant gap in the product and it is one line in
+`app.ts`.
+
+**Four route modules are written, tested and never mounted.**
+`routes/read-guards.ts`, `routes/learn.ts`, `routes/hubspot.ts` and
+`routes/hubspot-webhook.ts` are all absent from `app.ts`. Consequences: the
+**Learn** page is in the navigation and its API answers 404, and the HubSpot
+integration — OAuth, connections, sync jobs, webhook verification, seven
+`hubspot_*` tables — is unreachable. `routes/activities.ts` is likewise
+unmounted, but harmlessly: an older inline `POST /api/activities` in `app.ts`
+still serves it.
+
+**Six of the eight declared agent task kinds are never enqueued.** The worker
+is complete and generic, but only `enrich_account` and `enrich_contact` are
+ever written to `agent_tasks` (both from record creation). `write_brief`,
+`match_capacity`, `detect_idle_capacity`, `summarise_pipeline`,
+`watch_renewal` and `research_supplier` are declared in the ontology and
+nothing produces them. Piggy therefore does far less than the queue implies —
+not because the machinery is missing, but because nothing asks.
+
+**Piggy chat cannot write.** By design for now, but worth stating: the
+interactive agent reads and cites; it cannot create or update a CRM record.
+
+**The MCP server is stdio only.** There is no Streamable HTTP transport and no
+`/mcp` endpoint on the API, so remote MCP clients cannot connect over the
+network — each user runs the server locally against their own API key. The
+package is also not published to npm, so `npx @pig/mcp` does not work.
+
+**No row-level or team-scoped read filtering exists** anywhere in the query
+layer. Every read returns the whole book. This is why read capabilities are
+platform-wide rather than per-team, and it is the thing to build before PIG
+serves a company where that is not acceptable.
+
+**`.env.example` is incomplete.** `POSTGRES_PASSWORD` and
+`PIG_SETTINGS_ENCRYPTION_KEY` are both load-bearing and both missing from it;
+the table above is authoritative. `ANTHROPIC_API_KEY` is declared in the API
+config and read by nothing.
+
+**Not started at all:** email or calendar ingestion, forecasting, quota and
+attainment, invoicing or billing reconciliation, a public API beyond what the
+MCP tools cover, multi-tenancy of any kind, and any mobile application. PIG is
+responsive to 393px; it is not a native app.
+
## Documentation
- **[AGENTS.md](./AGENTS.md) — start here if you are joining this codebase.**
- Architecture rules, the traps that have already bitten, conventions, and
- where to start.
-- [Build plan](./docs/build-plan.md) — what remains, in dependency order
+ Architecture rules, the traps that have already bitten, and conventions.
- [Ontology](./docs/ontology.md) — the domain model, and why it is shaped this way
+- [Build plan](./docs/build-plan.md) — what shipped, what remains, in dependency order
+- [Agent integration](./docs/agents.md) — MCP clients and the CLI
- [Seed data provenance](./docs/seed-data.md) — every claim, graded and cited
-- [Agent integration](./docs/agents.md) — Claude Code, Codex, prime-agent, Buzz
-- [Deployment](./docs/deploy.md) — self-hosting
+- [Deployment](./deploy/README.md) — self-hosting, the release poller, rollback
## A note on seed data
PIG ships with a roster of publicly documented people so the application is
-legible on first run. Every record carries a confidence grade and a source URL.
-**No email addresses are included or inferred.** Records that could not be
-independently sourced are marked as such rather than quietly presented as fact,
-and people who are demonstrably *not* staff — alumni, residency participants —
-are labelled accordingly. See [docs/seed-data.md](./docs/seed-data.md).
+legible on first run. Every record carries a confidence grade and a source URL,
+both shown in the interface. **No email addresses are included or inferred.**
+Records that could not be independently sourced are marked as such rather than
+quietly presented as fact, and people who are demonstrably *not* staff —
+alumni, residency participants — are labelled accordingly. Seeding is opt-in
+(`pnpm run db:seed`) and never automatic. See
+[docs/seed-data.md](./docs/seed-data.md).
-If you are seeded here and would rather not be, open an issue and it will be
-removed.
+If you are seeded here and would rather not be, open an issue and the record
+will be removed.
## Licence
-Apache License 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
+Apache License 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE). The
+architectural debts to [Comp AI CRM](https://github.com/trycompai/crm) (MIT)
+and [Buzz](https://github.com/block/buzz) (Apache-2.0) are credited in NOTICE.
+No source code was copied from either.
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index e6920aa..b57d4e8 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -26,7 +26,6 @@ import {
} from '@pig/db';
import {
ACCENTS,
- ACTIVITY_TYPES,
DEMAND_STAGES,
SECURITY_TIERS,
SUPPLY_STAGES,
@@ -58,13 +57,17 @@ import { createRecordRoutes } from './routes/records';
import { createImportRoutes } from './routes/imports';
import { createGoogleSheetsRoutes } from './routes/google-sheets';
import { createContractRoutes } from './routes/contracts';
-import { createPiggyChatRoutes } from './routes/piggy-chat';
+import { createPiggyChatRoutes, platformPiggyEnabled } from './routes/piggy-chat';
import { createAdminSettingsRoutes } from './routes/admin-settings';
import { createSlackRoutes, SLACK_CAPACITY_COMMAND_PATH } from './routes/slack';
import { createBuzzRoutes } from './routes/buzz';
import { createIntegrationSettingsRoutes } from './routes/integration-settings';
import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/notion-import';
import { createGrowthRoutes } from './routes/growth';
+import { createCalendarRoutes } from './routes/calendar';
+import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn';
+import { createReadGuardRoutes } from './routes/read-guards';
+import { createActivityRoutes } from './routes/activities';
import { NotificationOutbox } from './services/notification-outbox';
type Env = { Variables: { principal: Principal } };
@@ -142,6 +145,13 @@ export function createApp(
path === '/api/register'
|| path === SLACK_CAPACITY_COMMAND_PATH
|| path === NOTION_OAUTH_CALLBACK_PATH
+ // Learn is reachable with a share code and no account. These two paths
+ // are exact-string matches, deliberately: /api/learn and
+ // /api/learn/resources/* stay behind the authenticator, and the public
+ // reader is structurally incapable of naming a row that is not both
+ // platform-track and code-visible.
+ || path === LEARN_ACCESS_PATH
+ || path === LEARN_PUBLIC_PATH
) {
return next();
}
@@ -156,6 +166,19 @@ export function createApp(
return next();
});
+ /*
+ * Read authorisation, mounted before every handler it guards.
+ *
+ * Hono runs matched handlers in registration order, so a guard registered
+ * after its route never runs and returns 200 while looking correct. That is
+ * why this sits here rather than beside the feature routes below, and why
+ * read-governance.test.ts pins the ordering in both directions.
+ *
+ * The policy is one table in read-guards.ts precisely so that "who can see
+ * cost?" has a single answer rather than one per route.
+ */
+ app.route('/', createReadGuardRoutes());
+
// ---------------------------------------------------------------- identity
app.get('/api/me', (c) => {
@@ -220,12 +243,20 @@ export function createApp(
}));
app.route('/', createContractRoutes(db));
app.route('/', createGrowthRoutes(db));
+ app.route('/', createCalendarRoutes(db));
+ app.route('/', createLearnRoutes(db));
app.route(
'/',
createPiggyChatRoutes({
enabled: config.PIGGY_ENABLED,
internalUrl: config.PIGGY_INTERNAL_URL,
internalToken: config.PIGGY_INTERNAL_TOKEN,
+ // Without this the stored toggle is never consulted and isAvailable()
+ // short-circuits to the environment variable, which is the bug the
+ // resolver exists to fix. The tests inject their own resolver, so they
+ // stay green whether or not this line is here — it is the composition
+ // that has to be right.
+ resolvePiggyEnabled: platformPiggyEnabled(config, db),
}),
);
app.route('/', createSlackRoutes(config, db, capacity));
@@ -358,54 +389,7 @@ export function createApp(
app.route('/', createCapacityWriteRoutes(db));
app.route('/', createFactsRoute(db));
- // ------------------------------------------------------------- activities
-
- const activitySchema = z.object({
- accountId: z.string().uuid().optional(),
- contactId: z.string().uuid().optional(),
- demandDealId: z.string().uuid().optional(),
- supplyDealId: z.string().uuid().optional(),
- type: z.enum(ACTIVITY_TYPES),
- subject: z.string().min(1).max(200),
- body: z.string().max(8000).optional(),
- occurredAt: z.string().datetime().optional(),
- externalId: z.string().max(200).optional(),
- });
-
- app.post('/api/activities', async (c) => {
- const p = c.get('principal');
- const parsed = activitySchema.safeParse(await c.req.json());
- if (!parsed.success) {
- return c.json({ error: 'Invalid activity', issues: parsed.error.issues }, 400);
- }
- const { occurredAt, ...rest } = parsed.data;
- const when = occurredAt ? new Date(occurredAt) : new Date();
-
- const [created] = await db
- .insert(activities)
- .values({
- ...rest,
- occurredAt: when,
- actorUserId: p.userId,
- // An agent acting for someone is recorded as such, so the log
- // distinguishes what a person did from what was done on their behalf.
- actorAgent: p.via === 'api_key' ? 'agent' : null,
- source: p.via === 'api_key' ? 'agent' : 'manual',
- })
- // An `externalId` collision means this event was already synced from
- // Slack or Buzz; silently ignoring the duplicate keeps sync idempotent.
- .onConflictDoNothing()
- .returning();
-
- if (rest.accountId) {
- await db
- .update(accounts)
- .set({ lastActivityAt: when })
- .where(eq(accounts.id, rest.accountId));
- }
-
- return c.json(created ?? { deduplicated: true }, created ? 201 : 200);
- });
+ app.route('/', createActivityRoutes(db));
// ---------------------------------------------------------------- capacity
diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts
index 2fc2ba4..9319c49 100644
--- a/apps/api/src/lib/auth.ts
+++ b/apps/api/src/lib/auth.ts
@@ -24,12 +24,17 @@ import type { Database } from '@pig/db';
import { apiKeys, teamMemberships, users } from '@pig/db';
import {
permissionGranted,
- resolvePermissionGrants,
- type Capability,
+ resolveReadPermissionGrants,
+ resolveWritePermissionGrants,
+ roleMeets,
+ TEAM_CAPABILITY_RULES,
+ type GlobalCapability,
type PermissionGrant,
+ type ReadCapability,
type Team,
type TeamCapability,
type TeamRole,
+ type WriteCapability,
} from '@pig/core';
import { createHash, timingSafeEqual } from 'node:crypto';
import type { Config } from './config';
@@ -231,8 +236,7 @@ export function hasTeamAccess(
if (principal.isPlatformAdmin) return true;
const membership = principal.teams.find((t) => t.team === team);
if (!membership) return false;
- const rank: Record = { member: 0, lead: 1, admin: 2 };
- return rank[membership.role] >= rank[minimumRole];
+ return roleMeets(membership.role, minimumRole);
}
export function requireScope(principal: Principal, scope: string): void {
@@ -240,13 +244,22 @@ export function requireScope(principal: Principal, scope: string): void {
throw new AuthError(`This credential lacks the '${scope}' scope.`, 403, 'insufficient_scope');
}
-/** Effective grants include credential scope, not merely the owner's roles. */
+/**
+ * Effective grants include credential scope, not merely the owner's roles.
+ *
+ * Read and write scopes are filtered separately. Before read capabilities
+ * existed a read-only key resolved to no grants at all, which was right then
+ * and would now be wrong: it would tell `/api/me` that a read-only agent may
+ * not read, and the browser would grey out a page the server happily serves.
+ */
export function effectivePermissions(principal: Principal): PermissionGrant[] {
- if (!principal.scopes.includes('write')) return [];
- return resolvePermissionGrants(principal);
+ const grants: PermissionGrant[] = [];
+ if (principal.scopes.includes('read')) grants.push(...resolveReadPermissionGrants(principal));
+ if (principal.scopes.includes('write')) grants.push(...resolveWritePermissionGrants(principal));
+ return grants;
}
-export function requireCapability(principal: Principal, capability: Capability): void;
+export function requireCapability(principal: Principal, capability: GlobalCapability): void;
export function requireCapability(
principal: Principal,
capability: TeamCapability,
@@ -254,14 +267,58 @@ export function requireCapability(
): void;
export function requireCapability(
principal: Principal,
- capability: Capability,
+ capability: WriteCapability,
team?: Team,
): void {
requireScope(principal, 'write');
- if (permissionGranted(resolvePermissionGrants(principal), capability, team)) return;
+ if (permissionGranted(resolveWritePermissionGrants(principal), capability, team)) return;
throw new AuthError(
`This principal lacks the '${capability}' capability${team ? ` for ${team}` : ''}.`,
403,
'insufficient_permission',
);
}
+
+/**
+ * "May they do this on *some* team?"
+ *
+ * Separate from `requireCapability` and deliberately harder to type by
+ * accident. Passing no team to the old `requireCapability` silently meant this
+ * — which is how a research-team admin could bulk-import demand deals — so the
+ * overloads above now refuse it and every remaining any-team check has to say
+ * so in its own name. Use it only where no team is knowable yet: listing the
+ * spreadsheets in someone's Drive, before an entity has been chosen. The
+ * moment the target is known, go back to `requireCapability` with its team.
+ */
+export function requireAnyTeamCapability(
+ principal: Principal,
+ capability: TeamCapability,
+): void {
+ requireScope(principal, 'write');
+ const grants = resolveWritePermissionGrants(principal);
+ for (const team of TEAM_CAPABILITY_RULES[capability].teams) {
+ if (permissionGranted(grants, capability, team)) return;
+ }
+ throw new AuthError(
+ `This principal lacks the '${capability}' capability on any team.`,
+ 403,
+ 'insufficient_permission',
+ );
+}
+
+/**
+ * Reads are governed too. The 'read' scope is checked rather than 'write'
+ * because a read-only API key is exactly the credential this must admit.
+ */
+export function requireReadCapability(
+ principal: Principal,
+ capability: ReadCapability,
+): void {
+ requireScope(principal, 'read');
+ if (permissionGranted(resolveReadPermissionGrants(principal), capability)) return;
+ throw new AuthError(
+ `This principal lacks the '${capability}' capability.`,
+ 403,
+ 'insufficient_permission',
+ );
+}
diff --git a/apps/api/src/lib/mutation.ts b/apps/api/src/lib/mutation.ts
index 0100cbf..bbe0c48 100644
--- a/apps/api/src/lib/mutation.ts
+++ b/apps/api/src/lib/mutation.ts
@@ -1,4 +1,5 @@
import type { ActivityType, GlobalCapability, Team, TeamCapability } from '@pig/core';
+import { isTeamCapability } from '@pig/core';
import type { Database } from '@pig/db';
import { activities } from '@pig/db';
import type { Context, Handler } from 'hono';
@@ -55,9 +56,18 @@ export interface MutationActivity {
meta?: Record;
}
+/**
+ * `'self'` is for the one write whose own row IS the audit event: logging an
+ * activity. Inserting an audit row about it would double every synced call in
+ * the feed. It is a literal rather than an omitted field so that audit can
+ * never be skipped by forgetting to write one — the type still demands an
+ * answer, and `'self'` is a visible, greppable claim.
+ */
+export type MutationAudit = MutationActivity | 'self';
+
export interface MutationResult {
data: Result;
- activity: MutationActivity;
+ activity: MutationAudit;
}
interface MutationContext {
@@ -80,11 +90,15 @@ function enforcePermission(principal: Principal, permission: PermissionRequireme
permission.authorize(principal);
return;
}
- if (permission.capability === 'settings:admin') {
- requireCapability(principal, permission.capability);
+ // Discriminated by the capability itself rather than by a hard-coded
+ // 'settings:admin' check, which quietly sent any future global capability
+ // down the team-scoped branch with an undefined team — the "passes on any
+ // team" bug, reintroduced by omission.
+ if (isTeamCapability(permission.capability)) {
+ requireCapability(principal, permission.capability, permission.team as Team);
return;
}
- requireCapability(principal, permission.capability, permission.team);
+ requireCapability(principal, permission.capability as GlobalCapability);
}
/**
@@ -131,13 +145,15 @@ export async function executeMutation(
};
const result = await definition.mutate(context);
- await tx.insert(activities).values({
- ...result.activity,
- actorUserId: principal.userId,
- actorAgent: principal.via === 'api_key' ? 'agent' : null,
- source: principal.via === 'api_key' ? 'agent' : 'manual',
- occurredAt: now,
- });
+ if (result.activity !== 'self') {
+ await tx.insert(activities).values({
+ ...result.activity,
+ actorUserId: principal.userId,
+ actorAgent: principal.via === 'api_key' ? 'agent' : null,
+ source: principal.via === 'api_key' ? 'agent' : 'manual',
+ occurredAt: now,
+ });
+ }
return result.data;
});
}
diff --git a/apps/api/src/lib/read-guard.ts b/apps/api/src/lib/read-guard.ts
new file mode 100644
index 0000000..854db70
--- /dev/null
+++ b/apps/api/src/lib/read-guard.ts
@@ -0,0 +1,30 @@
+/**
+ * Authorisation for reads.
+ *
+ * The write path has had one chokepoint since F2 — `executeMutation` — and
+ * reads had none. Every GET was "any authenticated member", so a research
+ * contractor and a demand lead saw supplier cost per GPU-hour, break-even
+ * price and the full negotiated terms of every contract identically. For a
+ * company whose margin is the product, that was the hole that mattered.
+ *
+ * This is the reading half of the same chokepoint. It is thin on purpose:
+ * capability in, middleware out, and the AuthError it throws is mapped to HTTP
+ * by `app.onError` exactly as the write path's is, so a read denial and a write
+ * denial are indistinguishable in shape to a client.
+ *
+ * `growth.ts` had the shape of this already but keyed on API-key *scope*, which
+ * answers "is this credential allowed to read anything?" and not "is this
+ * person allowed to read *this*". Scope is a property of the credential; the
+ * capability is a property of the person. Both are checked here.
+ */
+import type { ReadCapability } from '@pig/core';
+import type { MiddlewareHandler } from 'hono';
+import { requireReadCapability } from './auth';
+import type { ApiEnv } from './mutation';
+
+export function readGuard(capability: ReadCapability): MiddlewareHandler {
+ return async (context, next) => {
+ requireReadCapability(context.get('principal'), capability);
+ await next();
+ };
+}
diff --git a/apps/api/src/routes/activities.ts b/apps/api/src/routes/activities.ts
new file mode 100644
index 0000000..9fe0bcc
--- /dev/null
+++ b/apps/api/src/routes/activities.ts
@@ -0,0 +1,136 @@
+/**
+ * Logging an activity.
+ *
+ * This was the one write in PIG that never went through `executeMutation`: it
+ * lived inline in `app.ts`, checked no capability at all, and would insert an
+ * activity against any `accountId` a caller cared to name — then move that
+ * account's `lastActivityAt`, which is what the account list sorts on. Any
+ * member, and any write-scoped API key, could therefore reorder somebody
+ * else's book and plant a fabricated call in the audit trail of an account
+ * they have no relationship with.
+ *
+ * Two things are checked, in two places, deliberately:
+ *
+ * 1. Up front, before the body is read: does this principal hold
+ * `activity:write` on *any* team? A caller with none must not get to probe
+ * validation rules for a write they can never perform.
+ * 2. Inside the transaction, once the referenced account has been read: do
+ * they hold it on a team that account is actually on? The side is a
+ * property of the row, so it cannot be known before the row is fetched.
+ *
+ * That is the same shape as `ensureSidePermission` in contracts.ts, and for the
+ * same reason.
+ */
+import { ACTIVITY_TYPES, type AccountSide, type Team } from '@pig/core';
+import type { Activity, Database } from '@pig/db';
+import { accounts, activities } from '@pig/db';
+import { eq } from 'drizzle-orm';
+import { Hono } from 'hono';
+import { z } from 'zod';
+import { AuthError, requireAnyTeamCapability, requireCapability, type Principal } from '../lib/auth';
+import type { ApiEnv, MutationDefinition } from '../lib/mutation';
+import { MutationError, mutation } from '../lib/mutation';
+
+const activitySchema = z
+ .object({
+ accountId: z.string().uuid().optional(),
+ contactId: z.string().uuid().optional(),
+ demandDealId: z.string().uuid().optional(),
+ supplyDealId: z.string().uuid().optional(),
+ type: z.enum(ACTIVITY_TYPES),
+ subject: z.string().min(1).max(200),
+ body: z.string().max(8000).optional(),
+ occurredAt: z.string().datetime().optional(),
+ externalId: z.string().max(200).optional(),
+ })
+ .strict();
+
+export interface LoggedActivity {
+ activity: Activity | null;
+ /** True when an `externalId` collision meant the event was already synced. */
+ deduplicated: boolean;
+}
+
+/**
+ * Research consumes capacity but keeps no commercial book, so an activity is
+ * always a supply-side or demand-side event. A `both` account admits either.
+ */
+function requireSidePermission(principal: Principal, side: AccountSide): void {
+ const sides: Team[] = side === 'both' ? ['supply', 'demand'] : [side];
+ for (const team of sides) {
+ try {
+ requireCapability(principal, 'activity:write', team);
+ return;
+ } catch (error) {
+ if (!(error instanceof AuthError)) throw error;
+ }
+ }
+ throw new AuthError(
+ `This principal cannot log activity against a ${side}-side account.`,
+ 403,
+ 'insufficient_permission',
+ );
+}
+
+export function createActivityMutationDefinition(): MutationDefinition<
+ typeof activitySchema,
+ LoggedActivity
+> {
+ return {
+ schema: activitySchema,
+ permission: { authorize: (principal) => requireAnyTeamCapability(principal, 'activity:write') },
+ invalidMessage: 'Invalid activity.',
+ async mutate({ input, principal, tx, now }) {
+ const { occurredAt, accountId, ...rest } = input;
+ // A backdated entry is the normal case for sync, so the caller's
+ // timestamp wins over `now` — unlike the audit rows this convention
+ // usually writes, where `now` is the point.
+ const when = occurredAt ? new Date(occurredAt) : now;
+
+ if (accountId) {
+ const [account] = await tx
+ .select({ side: accounts.side })
+ .from(accounts)
+ .where(eq(accounts.id, accountId))
+ .limit(1);
+ if (!account) throw MutationError.notFound('Account');
+ requireSidePermission(principal, account.side as AccountSide);
+ }
+
+ const [created] = await tx
+ .insert(activities)
+ .values({
+ ...rest,
+ accountId,
+ occurredAt: when,
+ actorUserId: principal.userId,
+ // An agent acting for someone is recorded as such, so the log
+ // distinguishes what a person did from what was done on their behalf.
+ actorAgent: principal.via === 'api_key' ? 'agent' : null,
+ source: principal.via === 'api_key' ? 'agent' : 'manual',
+ })
+ // An `externalId` collision means this event was already synced from
+ // Slack or Buzz; silently ignoring the duplicate keeps sync idempotent.
+ .onConflictDoNothing()
+ .returning();
+
+ // Only on a real insert. Bumping it on a deduplicated replay would let a
+ // repeated sync keep an account at the top of the list forever.
+ if (created && accountId) {
+ await tx.update(accounts).set({ lastActivityAt: when }).where(eq(accounts.id, accountId));
+ }
+
+ return {
+ data: { activity: created ?? null, deduplicated: !created },
+ // The inserted row is the audit event. See `MutationAudit`.
+ activity: 'self',
+ };
+ },
+ };
+}
+
+export function createActivityRoutes(db: Database): Hono {
+ const routes = new Hono();
+ routes.post('/api/activities', mutation(db, createActivityMutationDefinition()));
+ return routes;
+}
diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts
new file mode 100644
index 0000000..7909e10
--- /dev/null
+++ b/apps/api/src/routes/calendar.ts
@@ -0,0 +1,494 @@
+/**
+ * GET /api/calendar and the CRUD for the one table it owns.
+ *
+ * The read endpoint is the first in this API to accept a date range and filter
+ * on it server-side. Every other list route is `order by updated_at desc limit
+ * 300` with the browser filtering afterwards, which means the records dated
+ * inside a quarter are not guaranteed to be in the response — the failure this
+ * route exists to remove. Nothing here computes anything; the projection lives
+ * in the service, per the rule that intelligence never lives in the API.
+ */
+import { and, eq } from 'drizzle-orm';
+import { Hono, type Context } from 'hono';
+import { z } from 'zod';
+import {
+ CALENDAR_ENTRY_KINDS,
+ CALENDAR_EVENT_KINDS,
+ isCalendarEventKind,
+ isValidTimeZone,
+ parseQuarter,
+ permissionGranted,
+ quarterBounds,
+ quarterBoundsFor,
+ type CalendarEventKind,
+} from '@pig/core';
+import { accounts, calendarEntries, demandDeals, supplyDeals, users } from '@pig/db';
+import type { CalendarEntry, Database } from '@pig/db';
+import { effectivePermissions, requireCapability, type Principal } from '../lib/auth';
+import {
+ MutationError,
+ apiError,
+ bodylessMutation,
+ mutation,
+ type ApiEnv,
+ type MutationDefinition,
+} from '../lib/mutation';
+import { CalendarService } from '../services/calendar';
+
+/**
+ * A dated item belongs to whoever runs the motion, so either pipeline's
+ * write-capable members may keep the calendar. Mirrors the treatment contracts
+ * already give a capability that is meaningful on both sides.
+ */
+export function requireCalendarWrite(principal: Principal): void {
+ const grants = effectivePermissions(principal);
+ if (
+ permissionGranted(grants, 'deal:write', 'supply') ||
+ permissionGranted(grants, 'deal:write', 'demand')
+ ) {
+ return;
+ }
+ // Re-run the check so the caller gets the standard 403 envelope rather than
+ // a bespoke one, and so a scopeless credential is reported as such.
+ requireCapability(principal, 'deal:write', 'demand');
+}
+
+export function calendarReadAllowed(scopes: readonly string[]): boolean {
+ return scopes.includes('read');
+}
+
+/**
+ * Comma-separated, and an unknown kind is an error rather than a silent empty
+ * result — a typo in `kinds` that returns nothing looks exactly like a quiet
+ * quarter.
+ */
+export function parseKinds(raw: string | undefined): CalendarEventKind[] | undefined {
+ if (!raw) return undefined;
+ const requested = raw
+ .split(',')
+ .map((kind) => kind.trim())
+ .filter(Boolean);
+ if (!requested.length) return undefined;
+ const unknown = requested.filter((kind) => !isCalendarEventKind(kind));
+ if (unknown.length) {
+ throw new MutationError(
+ 'invalid_kinds',
+ `Unknown calendar event kind(s): ${unknown.join(', ')}. Known kinds: ${CALENDAR_EVENT_KINDS.join(', ')}.`,
+ 400,
+ );
+ }
+ return requested as CalendarEventKind[];
+}
+
+const isoDate = z.string().datetime();
+const nullableId = z.string().uuid().nullable();
+
+const entryFields = {
+ title: z.string().min(1).max(240).optional(),
+ description: z.string().max(8000).nullable().optional(),
+ kind: z.enum(CALENDAR_ENTRY_KINDS).optional(),
+ startsAt: isoDate.optional(),
+ endsAt: isoDate.nullable().optional(),
+ allDay: z.boolean().optional(),
+ ownerUserId: nullableId.optional(),
+ accountId: nullableId.optional(),
+ demandDealId: nullableId.optional(),
+ supplyDealId: nullableId.optional(),
+ completedAt: isoDate.nullable().optional(),
+};
+
+const createEntrySchema = z.object({
+ ...entryFields,
+ title: z.string().min(1).max(240),
+ startsAt: isoDate,
+});
+const updateEntrySchema = z.object(entryFields);
+
+function date(value: string | null | undefined): Date | null | undefined {
+ return value === undefined ? undefined : value === null ? null : new Date(value);
+}
+
+function requiredRouteParam(params: Readonly>): string {
+ const value = params.id;
+ if (!value) {
+ throw new MutationError('invalid_route_parameter', "Route parameter 'id' is required.", 400);
+ }
+ return value;
+}
+
+function writtenRow(row: Row | undefined): Row {
+ if (row === undefined) {
+ throw new Error('Calendar entry write completed without returning a row.');
+ }
+ return row;
+}
+
+type Transaction = Parameters[0]>[0];
+
+/**
+ * The nullable foreign keys are polymorphic, so nothing in the schema stops an
+ * entry pointing at an account and a deal belonging to someone else. Checked
+ * here, where the intent is known.
+ */
+async function requireRelationships(
+ tx: Transaction,
+ record: {
+ /**
+ * Only ever the client's own choice. The default — the author's own id —
+ * is a user we have just authenticated, so re-reading it would be a query
+ * per create to confirm something the request already proved.
+ */
+ ownerUserId?: string | null;
+ accountId?: string | null;
+ demandDealId?: string | null;
+ supplyDealId?: string | null;
+ startsAt: Date;
+ endsAt?: Date | null;
+ },
+): Promise {
+ if (record.endsAt && record.endsAt < record.startsAt) {
+ throw new MutationError('invalid_window', 'An entry cannot end before it starts.', 400);
+ }
+ if (record.ownerUserId) {
+ // Assigning to someone who has since been removed is an ordinary client
+ // mistake, and without this it surfaces as a 500 from the foreign key
+ // rather than the 404 every other polymorphic reference here returns.
+ const [owner] = await tx
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.id, record.ownerUserId))
+ .limit(1);
+ if (!owner) throw MutationError.notFound('User');
+ }
+ if (record.accountId) {
+ const [account] = await tx
+ .select({ id: accounts.id })
+ .from(accounts)
+ .where(eq(accounts.id, record.accountId))
+ .limit(1);
+ if (!account) throw MutationError.notFound('Account');
+ }
+ if (record.demandDealId) {
+ const [deal] = await tx
+ .select({ accountId: demandDeals.accountId })
+ .from(demandDeals)
+ .where(eq(demandDeals.id, record.demandDealId))
+ .limit(1);
+ if (!deal) throw MutationError.notFound('Demand deal');
+ if (record.accountId && deal.accountId !== record.accountId) {
+ throw new MutationError(
+ 'relationship_mismatch',
+ 'Demand deal belongs to a different account.',
+ 409,
+ );
+ }
+ }
+ if (record.supplyDealId) {
+ const [deal] = await tx
+ .select({ accountId: supplyDeals.accountId })
+ .from(supplyDeals)
+ .where(eq(supplyDeals.id, record.supplyDealId))
+ .limit(1);
+ if (!deal) throw MutationError.notFound('Supply deal');
+ if (record.accountId && deal.accountId !== record.accountId) {
+ throw new MutationError(
+ 'relationship_mismatch',
+ 'Supply deal belongs to a different account.',
+ 409,
+ );
+ }
+ }
+}
+
+export function createEntryMutationDefinition(): MutationDefinition<
+ typeof createEntrySchema,
+ CalendarEntry
+> {
+ return {
+ schema: createEntrySchema,
+ permission: { authorize: requireCalendarWrite },
+ invalidMessage: 'Invalid calendar entry.',
+ async mutate({ input, principal, tx, now }) {
+ const values = {
+ ...input,
+ startsAt: new Date(input.startsAt),
+ endsAt: date(input.endsAt) ?? null,
+ completedAt: date(input.completedAt) ?? null,
+ // Unassigned work is work nobody does, so an entry defaults to the
+ // person creating it rather than to nobody.
+ ownerUserId: input.ownerUserId === undefined ? principal.userId : input.ownerUserId,
+ createdByUserId: principal.userId,
+ updatedAt: now,
+ };
+ await requireRelationships(tx, { ...values, ownerUserId: input.ownerUserId ?? null });
+ const created = writtenRow(
+ (await tx.insert(calendarEntries).values(values).returning())[0],
+ );
+ return {
+ data: created,
+ activity: {
+ type: created.kind === 'meeting' || created.kind === 'qbr' ? 'meeting' : 'task',
+ subject: `Scheduled ${created.title}`,
+ accountId: created.accountId ?? undefined,
+ demandDealId: created.demandDealId ?? undefined,
+ supplyDealId: created.supplyDealId ?? undefined,
+ meta: {
+ calendarEntryId: created.id,
+ entryKind: created.kind,
+ startsAt: created.startsAt,
+ },
+ },
+ };
+ },
+ };
+}
+
+export function updateEntryMutationDefinition(): MutationDefinition<
+ typeof updateEntrySchema,
+ CalendarEntry
+> {
+ return {
+ schema: updateEntrySchema,
+ permission: { authorize: requireCalendarWrite },
+ invalidMessage: 'Invalid calendar entry update.',
+ async mutate({ input, params, tx, now }) {
+ const id = requiredRouteParam(params);
+ const [before] = await tx
+ .select()
+ .from(calendarEntries)
+ .where(eq(calendarEntries.id, id))
+ .limit(1);
+ if (!before) throw MutationError.notFound('Calendar entry');
+
+ const changes = {
+ ...input,
+ startsAt: input.startsAt ? new Date(input.startsAt) : undefined,
+ endsAt: date(input.endsAt),
+ completedAt: date(input.completedAt),
+ updatedAt: now,
+ };
+ // `undefined` means "leave alone" and `null` means "clear", so the row
+ // being validated has to be the merge, not the patch.
+ await requireRelationships(tx, {
+ // Unlike the others this is the patch, not the merge: the stored owner
+ // was checked when it was written and may since have been deleted, and
+ // failing an unrelated edit over that helps nobody.
+ ownerUserId: changes.ownerUserId ?? null,
+ accountId: changes.accountId === undefined ? before.accountId : changes.accountId,
+ demandDealId:
+ changes.demandDealId === undefined ? before.demandDealId : changes.demandDealId,
+ supplyDealId:
+ changes.supplyDealId === undefined ? before.supplyDealId : changes.supplyDealId,
+ startsAt: changes.startsAt ?? before.startsAt,
+ endsAt: changes.endsAt === undefined ? before.endsAt : changes.endsAt,
+ });
+ const updated = writtenRow(
+ (
+ await tx
+ .update(calendarEntries)
+ .set(changes)
+ .where(eq(calendarEntries.id, before.id))
+ .returning()
+ )[0],
+ );
+ return {
+ data: updated,
+ activity: {
+ type: 'task',
+ subject: `${updated.completedAt ? 'Completed' : 'Updated'} ${updated.title}`,
+ accountId: updated.accountId ?? undefined,
+ demandDealId: updated.demandDealId ?? undefined,
+ supplyDealId: updated.supplyDealId ?? undefined,
+ meta: {
+ calendarEntryId: updated.id,
+ completedAt: updated.completedAt,
+ startsAt: updated.startsAt,
+ },
+ },
+ };
+ },
+ };
+}
+
+export function deleteEntryMutationDefinition(): MutationDefinition<
+ z.ZodObject>,
+ { id: string }
+> {
+ return {
+ schema: z.object({}),
+ permission: { authorize: requireCalendarWrite },
+ invalidMessage: 'Invalid calendar entry deletion.',
+ async mutate({ params, tx }) {
+ const id = requiredRouteParam(params);
+ const [deleted] = await tx
+ .delete(calendarEntries)
+ .where(eq(calendarEntries.id, id))
+ .returning();
+ if (!deleted) throw MutationError.notFound('Calendar entry');
+ return {
+ data: { id: deleted.id },
+ activity: {
+ type: 'task',
+ subject: `Removed ${deleted.title}`,
+ accountId: deleted.accountId ?? undefined,
+ demandDealId: deleted.demandDealId ?? undefined,
+ supplyDealId: deleted.supplyDealId ?? undefined,
+ meta: { calendarEntryId: deleted.id, entryKind: deleted.kind },
+ },
+ };
+ },
+ };
+}
+
+export const querySchema = z.object({
+ from: isoDate.optional(),
+ to: isoDate.optional(),
+ quarter: z.string().optional(),
+ kinds: z.string().optional(),
+ accountId: z.string().uuid().optional(),
+ ownerUserId: z.string().uuid().optional(),
+ /**
+ * Zero-based month index the fiscal year starts on. Passed per request
+ * because PIG has nowhere to store an organisation-wide fiscal calendar yet,
+ * and inventing a settings column here would be a second place for the
+ * answer to live. Calendar quarters remain the default.
+ */
+ fiscalYearStartMonth: z.coerce.number().int().min(0).max(11).optional(),
+ /**
+ * Checked against ICU here rather than left to the UTC fallback in
+ * `@pig/core`: the fallback exists for `users.timezone`, which is already
+ * stored and cannot be argued with, whereas a caller who asked for
+ * `Mars/Olympus` can be told. It also keeps the formatter cache — keyed on
+ * this string — from being fed arbitrary values by a caller in a loop.
+ */
+ timezone: z
+ .string()
+ .max(80)
+ .refine(isValidTimeZone, { message: 'Unknown IANA time zone.' })
+ .optional(),
+});
+
+/** The one filter the entries listing takes; a uuid column cannot be asked about free text. */
+export const entriesQuerySchema = z.object({
+ accountId: z.string().uuid().optional(),
+});
+
+export function createCalendarRoutes(db: Database): Hono {
+ const routes = new Hono();
+ const service = new CalendarService(db);
+
+ routes.get('/api/calendar', async (context: Context) => {
+ const principal = context.get('principal');
+ if (!calendarReadAllowed(principal.scopes)) {
+ return context.json(
+ apiError('insufficient_scope', "This credential lacks the 'read' scope."),
+ 403,
+ );
+ }
+
+ const parsed = querySchema.safeParse(context.req.query());
+ if (!parsed.success) {
+ return context.json(
+ apiError('invalid_query', 'Invalid calendar query.', parsed.error.issues),
+ 400,
+ );
+ }
+ const query = parsed.data;
+ const fiscalYearStartMonth = query.fiscalYearStartMonth ?? 0;
+ // The reader's own zone decides where a quarter begins; an explicit
+ // parameter wins so a shared link shows both people the same window.
+ const timeZone = query.timezone ?? (await service.timeZoneFor(principal.userId));
+
+ let from: Date;
+ let to: Date;
+ if (query.from && query.to) {
+ from = new Date(query.from);
+ to = new Date(query.to);
+ if (to <= from) {
+ return context.json(apiError('invalid_range', "'to' must be after 'from'."), 400);
+ }
+ } else if (query.quarter) {
+ const label = parseQuarter(query.quarter);
+ if (!label) {
+ return context.json(
+ apiError('invalid_quarter', "Expected a quarter label such as '2026-Q3'."),
+ 400,
+ );
+ }
+ const bounds = quarterBounds(label.year, label.quarter, fiscalYearStartMonth, timeZone);
+ from = bounds.from;
+ to = bounds.to;
+ } else if (query.from || query.to) {
+ return context.json(
+ apiError('invalid_range', "Provide both 'from' and 'to', or neither."),
+ 400,
+ );
+ } else {
+ // No range at all is the common case — a GTM lead opening the page wants
+ // the quarter they are standing in.
+ const bounds = quarterBoundsFor(new Date(), fiscalYearStartMonth, timeZone);
+ from = bounds.from;
+ to = bounds.to;
+ }
+
+ let kinds: CalendarEventKind[] | undefined;
+ try {
+ kinds = parseKinds(query.kinds);
+ } catch (error) {
+ if (error instanceof MutationError) {
+ return context.json(apiError(error.code, error.message), error.status);
+ }
+ throw error;
+ }
+
+ return context.json(
+ await service.project({
+ from,
+ to,
+ kinds,
+ accountId: query.accountId,
+ ownerUserId: query.ownerUserId,
+ fiscalYearStartMonth,
+ timeZone,
+ }),
+ );
+ });
+
+ /** The owned rows, listed on their own so the CRUD is inspectable. */
+ routes.get('/api/calendar/entries', async (context: Context) => {
+ const principal = context.get('principal');
+ if (!calendarReadAllowed(principal.scopes)) {
+ return context.json(
+ apiError('insufficient_scope', "This credential lacks the 'read' scope."),
+ 403,
+ );
+ }
+ // Postgres rejects a malformed uuid with 22P02, which surfaces as a 500;
+ // the sibling read above already answers 400 for the same parameter.
+ const parsed = entriesQuerySchema.safeParse(context.req.query());
+ if (!parsed.success) {
+ return context.json(
+ apiError('invalid_query', 'Invalid calendar entries query.', parsed.error.issues),
+ 400,
+ );
+ }
+ const accountId = parsed.data.accountId;
+ const rows = await db
+ .select({ entry: calendarEntries, accountName: accounts.name })
+ .from(calendarEntries)
+ .leftJoin(accounts, eq(accounts.id, calendarEntries.accountId))
+ .where(and(accountId ? eq(calendarEntries.accountId, accountId) : undefined))
+ .orderBy(calendarEntries.startsAt)
+ .limit(500);
+ return context.json({ kinds: CALENDAR_ENTRY_KINDS, entries: rows });
+ });
+
+ routes.post('/api/calendar/entries', mutation(db, createEntryMutationDefinition()));
+ routes.patch('/api/calendar/entries/:id', mutation(db, updateEntryMutationDefinition()));
+ routes.delete(
+ '/api/calendar/entries/:id',
+ bodylessMutation(db, deleteEntryMutationDefinition()),
+ );
+
+ return routes;
+}
diff --git a/apps/api/src/routes/facts.ts b/apps/api/src/routes/facts.ts
index 2e7e6e4..d56929b 100644
--- a/apps/api/src/routes/facts.ts
+++ b/apps/api/src/routes/facts.ts
@@ -45,7 +45,11 @@ export const factDecisionDefinition: MutationDefinition<
FactDecisionResult
> = {
schema: factDecisionSchema,
- permission: { capability: 'data:import', team: 'research' },
+ // `fact:review`, not `data:import`. Accepting an agent's claim about a named
+ // person is a judgement about evidence; rewriting five thousand rows from a
+ // spreadsheet is not. They shared a capability until an audit noticed that
+ // granting either granted both.
+ permission: { capability: 'fact:review', team: 'research' },
invalidMessage: 'Invalid fact review decision.',
async mutate({ input, params, principal, tx, now }) {
const id = params.id;
diff --git a/apps/api/src/routes/google-sheets.ts b/apps/api/src/routes/google-sheets.ts
index c23b34b..8beebcf 100644
--- a/apps/api/src/routes/google-sheets.ts
+++ b/apps/api/src/routes/google-sheets.ts
@@ -1,7 +1,7 @@
import type { Database } from '@pig/db';
import { Hono } from 'hono';
import { z } from 'zod';
-import { requireCapability } from '../lib/auth';
+import { requireAnyTeamCapability } from '../lib/auth';
import type { ApiEnv } from '../lib/mutation';
import { MutationError } from '../lib/mutation';
import {
@@ -50,8 +50,29 @@ export function createGoogleSheetsRoutes(
}
});
+ /*
+ * Two capabilities, not one.
+ *
+ * Handing PIG a long-lived Google refresh token is `integration:connect`:
+ * an authority over a third-party account, granted once, revocable
+ * separately. Reading the resulting spreadsheets in order to import them is
+ * `data:import`. Someone allowed to connect their Drive is not thereby
+ * allowed to rewrite the book from it, and the reverse is just as true.
+ *
+ * `data:import` is any-team here because no import entity has been chosen
+ * yet — the browser is still picking a file. The team is enforced at commit,
+ * in imports.ts, where the target is known.
+ */
routes.use('/api/imports/google/*', async (context, next) => {
- requireCapability(context.get('principal'), 'data:import');
+ const path = new URL(context.req.url).pathname;
+ const managesConnection =
+ path === '/api/imports/google/status' ||
+ path === '/api/imports/google/connect' ||
+ path === '/api/imports/google/connection';
+ requireAnyTeamCapability(
+ context.get('principal'),
+ managesConnection ? 'integration:connect' : 'data:import',
+ );
await next();
});
routes.get('/api/imports/google/status', async (context) =>
diff --git a/apps/api/src/routes/growth.ts b/apps/api/src/routes/growth.ts
index e12faf9..c8b1036 100644
--- a/apps/api/src/routes/growth.ts
+++ b/apps/api/src/routes/growth.ts
@@ -1,5 +1,5 @@
import type { Database } from '@pig/db';
-import { Hono, type Context } from 'hono';
+import { Hono } from 'hono';
import { z } from 'zod';
import type { ApiEnv } from '../lib/mutation';
import { apiError } from '../lib/mutation';
@@ -7,29 +7,20 @@ import { CustomerLifecycleService } from '../services/customer-lifecycle';
const accountIdSchema = z.string().uuid();
-export function growthReadAllowed(scopes: readonly string[]): boolean {
- return scopes.includes('read');
-}
-
-function requireGrowthRead(context: Context) {
- if (growthReadAllowed(context.get('principal').scopes)) return null;
- return context.json(
- apiError('insufficient_scope', "This credential lacks the 'read' scope."),
- 403,
- );
-}
-
+/*
+ * These used to carry their own `requireGrowthRead`, which asked whether the
+ * *credential* had the 'read' scope. That was the right instinct and the wrong
+ * question: scope is a property of the API key, and it said nothing about
+ * whether the person holding it may see the growth book. Both halves are now
+ * asked once, for every read in the product, by the READ_RULES table —
+ * `requireReadCapability` checks the scope first and then `book:read`.
+ */
export function createGrowthRoutes(db: Database): Hono {
const routes = new Hono();
const service = new CustomerLifecycleService(db);
- routes.get('/api/growth', async (context) => {
- const denied = requireGrowthRead(context);
- return denied ?? context.json(await service.report());
- });
+ routes.get('/api/growth', async (context) => context.json(await service.report()));
routes.get('/api/growth/accounts/:id', async (context) => {
- const denied = requireGrowthRead(context);
- if (denied) return denied;
const accountId = accountIdSchema.safeParse(context.req.param('id'));
if (!accountId.success) {
return context.json(apiError('invalid_account', 'Invalid account ID.', accountId.error.issues), 400);
diff --git a/apps/api/src/routes/hubspot.ts b/apps/api/src/routes/hubspot.ts
index d5407a1..af87067 100644
--- a/apps/api/src/routes/hubspot.ts
+++ b/apps/api/src/routes/hubspot.ts
@@ -3,7 +3,7 @@ import { HUBSPOT_OBJECT_TYPES } from '../integrations/hubspot/contracts';
import { HubSpotOAuthError } from '../integrations/hubspot/oauth';
import { Hono } from 'hono';
import { z } from 'zod';
-import { requireCapability } from '../lib/auth';
+import { requireAnyTeamCapability, requireCapability } from '../lib/auth';
import type { ApiEnv } from '../lib/mutation';
const connectionParamSchema = z.string().uuid();
@@ -70,7 +70,10 @@ export function createHubSpotRoutes(service: HubSpotRouteService): Hono
routes.post('/api/integrations/hubspot/connections/:connectionId/sync', async (context) => {
const principal = context.get('principal');
- requireCapability(principal, 'data:import');
+ // Any team, and honestly so: a HubSpot sync pulls companies, contacts and
+ // deals from both sides at once, so there is no single team to scope it to.
+ // Narrowing it would need the sync to accept an object-type filter first.
+ requireAnyTeamCapability(principal, 'data:import');
const parsedId = connectionParamSchema.safeParse(context.req.param('connectionId'));
if (!parsedId.success) return context.json({ error: 'Invalid HubSpot connection ID.' }, 400);
return context.json(
diff --git a/apps/api/src/routes/imports.ts b/apps/api/src/routes/imports.ts
index 9e91d7f..d1d7e4b 100644
--- a/apps/api/src/routes/imports.ts
+++ b/apps/api/src/routes/imports.ts
@@ -1,8 +1,8 @@
-import { IMPORT_ENTITIES, IMPORT_ENTITY_DEFINITIONS } from '@pig/core';
+import { IMPORT_ENTITIES, IMPORT_ENTITY_DEFINITIONS, type ImportEntity, type Team } from '@pig/core';
import type { Database } from '@pig/db';
import { Hono } from 'hono';
import { z } from 'zod';
-import { requireCapability } from '../lib/auth';
+import { requireAnyTeamCapability, requireCapability } from '../lib/auth';
import type { ApiEnv, MutationDefinition } from '../lib/mutation';
import { MutationError, mutation } from '../lib/mutation';
import {
@@ -37,6 +37,39 @@ const parseSchema = z.object({
base64: z.string().min(1).max(Math.ceil(MAX_IMPORT_FILE_BYTES * 4 / 3) + 16),
}).strict();
+/**
+ * Which team's book an import writes into.
+ *
+ * The commit is the only point at which that is knowable, and it is the only
+ * point at which it matters: `requireCapability(p, 'data:import')` with no team
+ * passed if the principal held the capability on *any* team, so a research-team
+ * admin could rewrite the demand pipeline. Accounts and contacts are shared by
+ * both commercial sides, so admin of either is enough for those.
+ */
+export const IMPORT_ENTITY_TEAMS: Readonly> = {
+ account: ['supply', 'demand'],
+ contact: ['supply', 'demand'],
+ demand_deal: ['demand'],
+ supply_deal: ['supply'],
+};
+
+function requireImportPermission(
+ principal: Parameters[0],
+ entity: ImportEntity,
+): void {
+ const teams = IMPORT_ENTITY_TEAMS[entity];
+ let denial: unknown;
+ for (const team of teams) {
+ try {
+ requireCapability(principal, 'data:import', team);
+ return;
+ } catch (error) {
+ denial = error;
+ }
+ }
+ throw denial;
+}
+
interface ImportCommitOperations {
commit(
input: z.infer,
@@ -52,9 +85,13 @@ export function createImportCommitMutationDefinition(
): MutationDefinition {
return {
schema: commitSchema,
- permission: { authorize: (principal) => requireCapability(principal, 'data:import') },
+ // Two stages: any-team up front so a principal with no import authority at
+ // all cannot probe the schema, then the entity's own team once the body has
+ // been parsed and the target is finally knowable.
+ permission: { authorize: (principal) => requireAnyTeamCapability(principal, 'data:import') },
invalidMessage: 'Invalid import commit.',
async mutate({ input, principal, tx, now }) {
+ requireImportPermission(principal, input.entity);
const result = await makeService(tx).commit(input, principal, now);
const entityLabel = IMPORT_ENTITY_DEFINITIONS[input.entity].label.toLocaleLowerCase();
return {
@@ -78,8 +115,21 @@ export function createImportCommitMutationDefinition(
export function createImportRoutes(db: Database): Hono {
const routes = new Hono();
+ // Any team, deliberately: config, parse and preview touch no book at all —
+ // preview is a dry run against uploaded cells. The commit is where the team
+ // is enforced, because the commit is where rows are written.
+ //
+ // The nested integration namespaces are skipped rather than left to fall
+ // through this. They are mounted after this router, so Hono runs this
+ // middleware for them too, and it would have re-imposed `data:import` on the
+ // OAuth routes that were just split onto `integration:connect` — the split
+ // would have compiled, passed its unit tests, and changed nothing.
routes.use('/api/imports/*', async (context, next) => {
- requireCapability(context.get('principal'), 'data:import');
+ const path = new URL(context.req.url).pathname;
+ if (path.startsWith('/api/imports/google/') || path.startsWith('/api/imports/notion/')) {
+ return next();
+ }
+ requireAnyTeamCapability(context.get('principal'), 'data:import');
await next();
});
routes.get('/api/imports/config', (context) => context.json({
diff --git a/apps/api/src/routes/learn.ts b/apps/api/src/routes/learn.ts
new file mode 100644
index 0000000..634f449
--- /dev/null
+++ b/apps/api/src/routes/learn.ts
@@ -0,0 +1,782 @@
+/**
+ * Learn — the member curriculum, the admin CRUD, and the one door in this API
+ * that opens without a principal.
+ *
+ * ## The security shape, which is the reason this file is long
+ *
+ * Everywhere else in PIG a request resolves a `Principal` and then a
+ * capability check decides what it may do. A code-holder has no account, so
+ * there is no principal to resolve — and the tempting shortcut, minting a
+ * synthetic one, is the thing this design exists to refuse. A principal is
+ * accepted by every downstream handler by construction; the only thing keeping
+ * it out of the CRM would be that each of those handlers remembered to check a
+ * capability. One that forgot would leak the book of business to anyone
+ * holding a marketing share code, and nothing would report an error.
+ *
+ * So the code mints a **scoped bearer token that is not a credential for this
+ * API at all**. It is an HMAC over a scope string and an expiry, verified by
+ * exactly one handler, and `authenticate()` never sees it. Presenting it to
+ * `/api/dashboard` produces the same 401 as presenting nothing, because to the
+ * authenticator it is simply a bearer token that is not a JWT and does not
+ * start with `pig_`. There is a test that asserts precisely this.
+ *
+ * The token's signing key is derived from the stored access code, so rotating
+ * the code invalidates every outstanding token as a side effect rather than
+ * requiring a second revocation mechanism.
+ *
+ * ## The public read
+ *
+ * `GET /api/learn/public` is written so that it is *structurally* incapable of
+ * naming another row: the predicates are two literals, there is no parameter
+ * that reaches the WHERE clause, and the selected columns are enumerated. It
+ * cannot be widened by a query string because it does not read one.
+ *
+ * ## What may be code-visible
+ *
+ * Only the platform track. Enforced here in the write path AND by a CHECK
+ * constraint on the table. Not in the UI: a form is not a security boundary,
+ * and a concept video becoming anon-visible through a mis-set select is the
+ * failure that matters.
+ */
+import { and, asc, desc, eq, isNull } from 'drizzle-orm';
+import { Hono } from 'hono';
+import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
+import { z } from 'zod';
+import {
+ LEARN_CODE_TRACK,
+ LEARN_EMBED_REJECTION_MESSAGES,
+ LEARN_TRACKS,
+ LEARN_VISIBILITIES,
+ learnEmbedUrl,
+ learnVisibilityPermitted,
+ learnWatchUrl,
+ resolveLearnEmbed,
+ type LearnProvider,
+ type LearnTrack,
+ type LearnVisibility,
+} from '@pig/core';
+import { learnResources, platformSettings } from '@pig/db';
+import type { Database } from '@pig/db';
+import { requireCapability, safeEqual } from '../lib/auth';
+import {
+ apiError,
+ bodylessMutation,
+ MutationError,
+ mutation,
+ type ApiEnv,
+} from '../lib/mutation';
+
+/**
+ * The two paths that must be allowlisted in `app.ts`, exported as constants
+ * for the same reason the Slack and Notion callbacks are: a public path
+ * spelled twice is a public path that eventually differs in one of them.
+ */
+export const LEARN_ACCESS_PATH = '/api/learn/access';
+export const LEARN_PUBLIC_PATH = '/api/learn/public';
+
+const SETTINGS_ID = 'default';
+
+// ---------------------------------------------------------------------- token
+
+const LEARN_TOKEN_VERSION = 'v1';
+/**
+ * Baked into the signature, not merely into the format. A token is a claim
+ * about a scope; if the scope were only in the envelope, widening the format
+ * later would silently promote every token already in a browser.
+ */
+const LEARN_TOKEN_SCOPE = `learn:${LEARN_CODE_TRACK}`;
+export const LEARN_TOKEN_PREFIX = 'learn_';
+/**
+ * Long enough that someone working through onboarding is not interrupted,
+ * short enough that a code rotation is not the only way to end a session. The
+ * token grants nothing but the platform track, so the usual argument for a
+ * short expiry — blast radius — barely applies.
+ */
+export const LEARN_TOKEN_TTL_MS = 12 * 60 * 60 * 1_000;
+
+/**
+ * The signing key, derived from the code rather than configured separately.
+ *
+ * This is what makes rotation total: change the code and every token already
+ * in a browser stops verifying, with no revocation list to maintain and no
+ * second secret to keep in step. The separator keeps the salt and the code
+ * from running together, so no two codes can yield the same key material.
+ */
+function tokenKey(accessCode: string): Buffer {
+ return createHash('sha256')
+ .update(`pig.learn.token.${LEARN_TOKEN_VERSION}\u0000${accessCode}`)
+ .digest();
+}
+
+export function mintLearnToken(accessCode: string, expiresAt: number): string {
+ const signature = createHmac('sha256', tokenKey(accessCode))
+ .update(`${LEARN_TOKEN_VERSION}.${LEARN_TOKEN_SCOPE}.${expiresAt}`)
+ .digest('base64url');
+ return `${LEARN_TOKEN_PREFIX}${LEARN_TOKEN_VERSION}.${expiresAt}.${signature}`;
+}
+
+export const LEARN_TOKEN_REJECTIONS = ['missing', 'malformed', 'expired', 'mismatch'] as const;
+export type LearnTokenRejection = (typeof LEARN_TOKEN_REJECTIONS)[number];
+
+export type LearnTokenResult =
+ | { valid: true; expiresAt: number }
+ | { valid: false; reason: LearnTokenRejection };
+
+/**
+ * Verify a learn token against the code currently in force.
+ *
+ * Follows the register of `integrations/hubspot/signature.ts`: recompute,
+ * compare byte lengths first because `timingSafeEqual` throws on a mismatch,
+ * then compare in constant time. Expiry is checked before the HMAC only
+ * because an expired token is not a secret worth protecting the timing of.
+ */
+export function verifyLearnToken(
+ accessCode: string | null,
+ token: string | undefined,
+ now: number = Date.now(),
+): LearnTokenResult {
+ if (!accessCode) return { valid: false, reason: 'mismatch' };
+ if (!token) return { valid: false, reason: 'missing' };
+ if (!token.startsWith(LEARN_TOKEN_PREFIX)) return { valid: false, reason: 'malformed' };
+
+ const parts = token.slice(LEARN_TOKEN_PREFIX.length).split('.');
+ if (parts.length !== 3) return { valid: false, reason: 'malformed' };
+ const [version, rawExpiry, signature] = parts as [string, string, string];
+ if (version !== LEARN_TOKEN_VERSION) return { valid: false, reason: 'malformed' };
+ if (!/^\d{1,15}$/.test(rawExpiry)) return { valid: false, reason: 'malformed' };
+
+ const expiresAt = Number(rawExpiry);
+ if (!Number.isSafeInteger(expiresAt)) return { valid: false, reason: 'malformed' };
+ if (expiresAt <= now) return { valid: false, reason: 'expired' };
+
+ const expected = Buffer.from(
+ createHmac('sha256', tokenKey(accessCode))
+ .update(`${LEARN_TOKEN_VERSION}.${LEARN_TOKEN_SCOPE}.${expiresAt}`)
+ .digest('base64url'),
+ 'utf8',
+ );
+ const supplied = Buffer.from(signature, 'utf8');
+ if (expected.length !== supplied.length) return { valid: false, reason: 'mismatch' };
+ return timingSafeEqual(expected, supplied)
+ ? { valid: true, expiresAt }
+ : { valid: false, reason: 'mismatch' };
+}
+
+// -------------------------------------------------------------- rate limiting
+
+export interface AttemptDecision {
+ allowed: boolean;
+ remaining: number;
+ retryAfterSeconds: number;
+}
+
+export interface AttemptLimiter {
+ check(key: string, now?: number): AttemptDecision;
+}
+
+/**
+ * A fixed-window limiter, in process.
+ *
+ * Deliberately not distributed and deliberately not durable. PIG runs as one
+ * container; the job here is to stop a script walking a short passphrase
+ * keyspace at HTTP speed, not to enforce an exact quota. A restart resetting
+ * the window costs an attacker one restart's worth of guesses, which is not
+ * the difference between safe and unsafe — the code length is.
+ *
+ * The map is pruned on write rather than on a timer, and capped, because the
+ * key is a client-supplied-ish address and an unbounded map keyed on one is a
+ * memory-exhaustion primitive.
+ */
+export function createAttemptLimiter({
+ limit,
+ windowMs,
+ maxKeys = 10_000,
+}: {
+ limit: number;
+ windowMs: number;
+ maxKeys?: number;
+}): AttemptLimiter {
+ const windows = new Map();
+
+ return {
+ check(key, now = Date.now()) {
+ if (windows.size >= maxKeys) {
+ for (const [existing, window] of windows) {
+ if (window.resetAt <= now) windows.delete(existing);
+ }
+ // Still full: every window is live, so this is either a real flood or
+ // a spoofed-address one. Refuse rather than grow.
+ if (windows.size >= maxKeys) {
+ return { allowed: false, remaining: 0, retryAfterSeconds: Math.ceil(windowMs / 1000) };
+ }
+ }
+
+ const current = windows.get(key);
+ if (!current || current.resetAt <= now) {
+ windows.set(key, { count: 1, resetAt: now + windowMs });
+ return { allowed: true, remaining: limit - 1, retryAfterSeconds: 0 };
+ }
+
+ current.count += 1;
+ if (current.count > limit) {
+ return {
+ allowed: false,
+ remaining: 0,
+ retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
+ };
+ }
+ return { allowed: true, remaining: limit - current.count, retryAfterSeconds: 0 };
+ },
+ };
+}
+
+/**
+ * Which client is this, for rate-limiting purposes?
+ *
+ * The LAST entry in `X-Forwarded-For`, not the first. Caddy APPENDS the real
+ * peer to whatever the client sent, so the first hop is attacker-controlled
+ * and using it hands anyone an unlimited number of rate-limit buckets. Behind
+ * exactly one proxy — which is this deployment — the last entry is the only
+ * one the client could not write.
+ */
+export function rateLimitKey(forwardedFor: string | undefined): string {
+ if (!forwardedFor) return 'unknown';
+ const hops = forwardedFor
+ .split(',')
+ .map((hop) => hop.trim())
+ .filter(Boolean);
+ return hops[hops.length - 1] ?? 'unknown';
+}
+
+// ------------------------------------------------------------------- schemas
+
+const urlField = z.string().trim().min(1).max(2_000);
+
+export const learnAccessSchema = z
+ .object({ code: z.string().min(1).max(200) })
+ .strict();
+
+export const learnResourceCreateSchema = z
+ .object({
+ track: z.enum(LEARN_TRACKS),
+ title: z.string().trim().min(1).max(200),
+ summary: z.string().trim().max(1_000).optional(),
+ url: urlField,
+ visibility: z.enum(LEARN_VISIBILITIES).default('members'),
+ // A day is generous for a walkthrough and rules out a mistyped
+ // milliseconds value being stored as seconds.
+ durationSeconds: z.number().int().positive().max(86_400).optional(),
+ sortOrder: z.number().int().min(0).max(10_000).optional(),
+ publishedAt: z.string().datetime().optional(),
+ })
+ .strict()
+ .refine(
+ (value) => learnVisibilityPermitted(value.track, value.visibility),
+ 'Only platform-track resources may be unlocked by the share code.',
+ );
+
+export const learnResourceUpdateSchema = z
+ .object({
+ track: z.enum(LEARN_TRACKS).optional(),
+ title: z.string().trim().min(1).max(200).optional(),
+ summary: z.string().trim().max(1_000).nullable().optional(),
+ url: urlField.optional(),
+ visibility: z.enum(LEARN_VISIBILITIES).optional(),
+ durationSeconds: z.number().int().positive().max(86_400).nullable().optional(),
+ sortOrder: z.number().int().min(0).max(10_000).optional(),
+ publishedAt: z.string().datetime().optional(),
+ archived: z.boolean().optional(),
+ })
+ .strict()
+ .refine((value) => Object.values(value).some((item) => item !== undefined), 'No changes supplied.');
+
+/**
+ * A passphrase a human reads aloud, so printable ASCII and no whitespace.
+ * Six is the floor because the endpoint is rate-limited, not because a short
+ * code is otherwise fine.
+ */
+export const learnAccessCodeSchema = z
+ .object({ code: z.string().trim().min(6).max(120).regex(/^[\x21-\x7e]+$/, 'Use printable characters with no spaces.') })
+ .strict();
+
+// ------------------------------------------------------------- serialisation
+
+interface LearnRowForView {
+ id: string;
+ track: LearnTrack;
+ title: string;
+ summary: string | null;
+ provider: LearnProvider;
+ externalId: string;
+ visibility: LearnVisibility;
+ durationSeconds: number | null;
+ sortOrder: number;
+ publishedAt: Date;
+}
+
+/**
+ * The wire shape. Note what is absent: the stored `url` column never leaves
+ * the database. Both URLs a client receives are rebuilt from the allowlist,
+ * so a row whose `url` was poisoned by some future write path still cannot put
+ * an attacker's bytes into an `iframe src`.
+ *
+ * A row we cannot rebuild an embed for is dropped rather than returned
+ * without one — it would render as a card that does nothing, and the honest
+ * reading of "this provider is no longer enabled" is that the video is not
+ * available, not that it is broken.
+ */
+export function learnResourceView(row: LearnRowForView) {
+ const embedUrl = learnEmbedUrl(row.provider, row.externalId);
+ const watchUrl = learnWatchUrl(row.provider, row.externalId);
+ if (!embedUrl || !watchUrl) return null;
+ return {
+ id: row.id,
+ track: row.track,
+ title: row.title,
+ summary: row.summary,
+ provider: row.provider,
+ visibility: row.visibility,
+ durationSeconds: row.durationSeconds,
+ sortOrder: row.sortOrder,
+ publishedAt: row.publishedAt,
+ embedUrl,
+ watchUrl,
+ };
+}
+
+export type LearnResourceView = NonNullable>;
+
+function renderable(rows: LearnRowForView[]): LearnResourceView[] {
+ return rows.map(learnResourceView).filter((view): view is LearnResourceView => view !== null);
+}
+
+/** Enumerated rather than `select()`, so `url` cannot be added by accident. */
+const viewColumns = {
+ id: learnResources.id,
+ track: learnResources.track,
+ title: learnResources.title,
+ summary: learnResources.summary,
+ provider: learnResources.provider,
+ externalId: learnResources.externalId,
+ visibility: learnResources.visibility,
+ durationSeconds: learnResources.durationSeconds,
+ sortOrder: learnResources.sortOrder,
+ publishedAt: learnResources.publishedAt,
+} as const;
+
+// ---------------------------------------------------------------------- routes
+
+async function currentAccessCode(db: Database): Promise {
+ const [row] = await db
+ .select({ code: platformSettings.learnAccessCode })
+ .from(platformSettings)
+ .where(eq(platformSettings.id, SETTINGS_ID))
+ .limit(1);
+ // No settings row means the workspace has not been initialised. Refusing
+ // every code is the correct answer; inserting a row here would write default
+ // Piggy configuration over what `ensurePlatformSettings` derives from the
+ // environment, which is a far worse bug than an unusable share code.
+ return row?.code ?? null;
+}
+
+export function createLearnRoutes(
+ db: Database,
+ options: { limiter?: AttemptLimiter } = {},
+) {
+ const app = new Hono();
+ // Ten guesses a minute per address. A human who has been given the code
+ // types it once; anything approaching this rate is a script.
+ const limiter = options.limiter ?? createAttemptLimiter({ limit: 10, windowMs: 60_000 });
+
+ // ------------------------------------------------------------- public door
+
+ app.post(LEARN_ACCESS_PATH, async (c) => {
+ const decision = limiter.check(rateLimitKey(c.req.header('x-forwarded-for')));
+ if (!decision.allowed) {
+ c.header('retry-after', String(decision.retryAfterSeconds));
+ return c.json(
+ apiError('learn_rate_limited', 'Too many attempts. Try again shortly.'),
+ 429,
+ );
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json(apiError('invalid_json', 'Request body must be valid JSON.'), 400);
+ }
+ const parsed = learnAccessSchema.safeParse(body);
+ if (!parsed.success) {
+ return c.json(apiError('invalid_request', 'Supply an access code.', parsed.error.issues), 400);
+ }
+
+ const accessCode = await currentAccessCode(db);
+ if (!accessCode || !safeEqual(parsed.data.code.trim(), accessCode)) {
+ // One message for "wrong code" and "no code configured". Distinguishing
+ // them tells a guesser whether to keep going.
+ return c.json(apiError('invalid_code', 'That code is not valid.'), 401);
+ }
+
+ const expiresAt = Date.now() + LEARN_TOKEN_TTL_MS;
+ return c.json({
+ token: mintLearnToken(accessCode, expiresAt),
+ expiresAt: new Date(expiresAt).toISOString(),
+ /**
+ * Stated in the response because the front end has to be able to explain
+ * to a code-holder why the Concepts section is locked, and hardcoding
+ * that in the browser would be a second place to change it.
+ */
+ track: LEARN_CODE_TRACK,
+ });
+ });
+
+ /**
+ * The only read a non-member can perform.
+ *
+ * Two literal predicates and no parameters. There is nothing in this handler
+ * that a caller can influence except the token, which decides whether it
+ * runs at all — not what it returns.
+ */
+ app.get(LEARN_PUBLIC_PATH, async (c) => {
+ const header = c.req.header('authorization');
+ const supplied = header?.startsWith('Bearer ') ? header.slice(7).trim() : undefined;
+ const result = verifyLearnToken(await currentAccessCode(db), supplied);
+ if (!result.valid) {
+ return c.json(
+ apiError(
+ result.reason === 'expired' ? 'learn_token_expired' : 'learn_token_invalid',
+ result.reason === 'expired'
+ ? 'That access has expired. Enter the code again.'
+ : 'A valid access code is required.',
+ ),
+ 401,
+ );
+ }
+
+ const rows = await db
+ .select(viewColumns)
+ .from(learnResources)
+ .where(
+ and(
+ eq(learnResources.visibility, 'code'),
+ eq(learnResources.track, LEARN_CODE_TRACK),
+ isNull(learnResources.archivedAt),
+ ),
+ )
+ .orderBy(asc(learnResources.sortOrder), desc(learnResources.publishedAt));
+
+ return c.json({
+ track: LEARN_CODE_TRACK,
+ expiresAt: new Date(result.expiresAt).toISOString(),
+ resources: renderable(rows),
+ /** So the locked Concepts panel can name what is behind it. */
+ lockedTracks: LEARN_TRACKS.filter((track) => track !== LEARN_CODE_TRACK),
+ });
+ });
+
+ // ------------------------------------------------------------ member reads
+
+ /**
+ * The whole curriculum. Any member may read it: this is training material,
+ * and gating supply concepts behind supply-team membership would stop a new
+ * demand seller learning how the other side works, which is the opposite of
+ * what the page is for.
+ */
+ app.get('/api/learn', async (c) => {
+ const rows = await db
+ .select(viewColumns)
+ .from(learnResources)
+ .where(isNull(learnResources.archivedAt))
+ .orderBy(asc(learnResources.sortOrder), desc(learnResources.publishedAt));
+
+ const views = renderable(rows);
+ return c.json({
+ tracks: Object.fromEntries(
+ LEARN_TRACKS.map((track) => [track, views.filter((view) => view.track === track)]),
+ ) as Record,
+ canManage: canManageLearn(c.get('principal')),
+ });
+ });
+
+ // ------------------------------------------------------------- admin write
+
+ app.post(
+ '/api/learn/resources',
+ mutation(db, {
+ schema: learnResourceCreateSchema,
+ permission: { capability: 'settings:admin' },
+ invalidMessage: 'Invalid learn resource.',
+ async mutate({ input, principal, tx, now }) {
+ const resolved = resolveEmbedOrThrow(input.url);
+ const [created] = await tx
+ .insert(learnResources)
+ .values({
+ track: input.track,
+ title: input.title,
+ summary: input.summary ?? null,
+ // The canonical form from the allowlist, not the pasted string —
+ // so the stored value is one we generated even in the column
+ // nothing renders.
+ url: resolved.watchUrl,
+ provider: resolved.provider,
+ externalId: resolved.externalId,
+ visibility: input.visibility,
+ durationSeconds: input.durationSeconds ?? null,
+ sortOrder: input.sortOrder ?? 100,
+ publishedAt: input.publishedAt ? new Date(input.publishedAt) : now,
+ addedByUserId: principal.userId,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning();
+ if (!created) throw new Error('Learn resource insert returned no row');
+
+ return {
+ data: viewOrThrow(created),
+ activity: {
+ type: 'agent_action',
+ subject: `Added learn resource: ${created.title}`,
+ meta: {
+ action: 'learn_resource.created',
+ resourceId: created.id,
+ track: created.track,
+ visibility: created.visibility,
+ provider: created.provider,
+ },
+ },
+ };
+ },
+ }),
+ );
+
+ app.patch(
+ '/api/learn/resources/:id',
+ mutation(db, {
+ schema: learnResourceUpdateSchema,
+ permission: { capability: 'settings:admin' },
+ invalidMessage: 'Invalid learn resource change.',
+ async mutate({ input, params, tx, now }) {
+ const id = requiredId(params);
+ const [existing] = await tx
+ .select()
+ .from(learnResources)
+ .where(eq(learnResources.id, id))
+ .limit(1);
+ if (!existing) throw MutationError.notFound('Learn resource');
+
+ /*
+ * Checked against the MERGED row, not the input. A PATCH that sets
+ * only `visibility: 'code'` on a supply resource carries no track at
+ * all, so validating the input alone would wave it straight through
+ * into the CHECK constraint and a 500.
+ */
+ const track = input.track ?? existing.track;
+ const visibility = input.visibility ?? existing.visibility;
+ if (!learnVisibilityPermitted(track, visibility)) {
+ throw new MutationError(
+ 'visibility_not_permitted',
+ `Only ${LEARN_CODE_TRACK}-track resources may be unlocked by the share code.`,
+ 409,
+ );
+ }
+
+ const set: Partial = { updatedAt: now };
+ if (input.track !== undefined) set.track = input.track;
+ if (input.title !== undefined) set.title = input.title;
+ if (input.summary !== undefined) set.summary = input.summary;
+ if (input.visibility !== undefined) set.visibility = input.visibility;
+ if (input.durationSeconds !== undefined) set.durationSeconds = input.durationSeconds;
+ if (input.sortOrder !== undefined) set.sortOrder = input.sortOrder;
+ if (input.publishedAt !== undefined) set.publishedAt = new Date(input.publishedAt);
+ if (input.archived !== undefined) set.archivedAt = input.archived ? now : null;
+ if (input.url !== undefined) {
+ const resolved = resolveEmbedOrThrow(input.url);
+ set.url = resolved.watchUrl;
+ set.provider = resolved.provider;
+ set.externalId = resolved.externalId;
+ }
+
+ const [updated] = await tx
+ .update(learnResources)
+ .set(set)
+ .where(eq(learnResources.id, id))
+ .returning();
+ if (!updated) throw MutationError.notFound('Learn resource');
+
+ return {
+ data: viewOrThrow(updated),
+ activity: {
+ type: 'agent_action',
+ subject: `Updated learn resource: ${updated.title}`,
+ meta: {
+ action: 'learn_resource.updated',
+ resourceId: updated.id,
+ fields: Object.keys(input),
+ track: updated.track,
+ visibility: updated.visibility,
+ },
+ },
+ };
+ },
+ }),
+ );
+
+ app.delete(
+ '/api/learn/resources/:id',
+ bodylessMutation(db, {
+ schema: z.object({}).strict(),
+ permission: { capability: 'settings:admin' },
+ invalidMessage: 'Invalid learn resource removal.',
+ // Archive, never delete: the activity log references the row, and "what
+ // did onboarding say in March?" is a real question.
+ async mutate({ params, tx, now }) {
+ const id = requiredId(params);
+ const [existing] = await tx
+ .select()
+ .from(learnResources)
+ .where(eq(learnResources.id, id))
+ .limit(1);
+ if (!existing) throw MutationError.notFound('Learn resource');
+
+ const [archived] = existing.archivedAt
+ ? [existing]
+ : await tx
+ .update(learnResources)
+ .set({ archivedAt: now, updatedAt: now })
+ .where(eq(learnResources.id, id))
+ .returning();
+ if (!archived) throw MutationError.notFound('Learn resource');
+
+ return {
+ data: { id: archived.id, archivedAt: archived.archivedAt },
+ activity: {
+ type: 'agent_action',
+ subject: `Archived learn resource: ${archived.title}`,
+ meta: {
+ action: 'learn_resource.archived',
+ resourceId: archived.id,
+ alreadyArchived: existing.archivedAt !== null,
+ },
+ },
+ };
+ },
+ }),
+ );
+
+ // ------------------------------------------------------------ the code itself
+
+ /**
+ * Returned in clear to a platform administrator, deliberately.
+ *
+ * It is a passphrase they have to be able to read out to the person they are
+ * sharing a demo with — a code an admin cannot see is a code nobody can use.
+ * It is not a credential for anything but the platform track, and the read
+ * requires `settings:admin`.
+ */
+ app.get('/api/learn/access-code', async (c) => {
+ requireCapability(c.get('principal'), 'settings:admin');
+ const [row] = await db
+ .select({
+ code: platformSettings.learnAccessCode,
+ updatedAt: platformSettings.learnAccessCodeUpdatedAt,
+ })
+ .from(platformSettings)
+ .where(eq(platformSettings.id, SETTINGS_ID))
+ .limit(1);
+ if (!row) {
+ return c.json(
+ apiError('settings_uninitialised', 'Open Settings once to initialise this workspace.'),
+ 409,
+ );
+ }
+ return c.json({ code: row.code, updatedAt: row.updatedAt, url: '/learn' });
+ });
+
+ app.patch(
+ '/api/learn/access-code',
+ mutation(db, {
+ schema: learnAccessCodeSchema,
+ permission: { capability: 'settings:admin' },
+ invalidMessage: 'Invalid access code.',
+ async mutate({ input, tx, now }) {
+ /*
+ * UPDATE, never upsert. Inserting the row here would give it default
+ * Piggy configuration rather than the environment-derived values
+ * `ensurePlatformSettings` writes, silently disabling Piggy — a much
+ * worse outcome than telling an administrator to open Settings first.
+ */
+ const [updated] = await tx
+ .update(platformSettings)
+ .set({ learnAccessCode: input.code, learnAccessCodeUpdatedAt: now, updatedAt: now })
+ .where(eq(platformSettings.id, SETTINGS_ID))
+ .returning({
+ code: platformSettings.learnAccessCode,
+ updatedAt: platformSettings.learnAccessCodeUpdatedAt,
+ });
+ if (!updated) {
+ throw new MutationError(
+ 'settings_uninitialised',
+ 'Open Settings once to initialise this workspace.',
+ 409,
+ );
+ }
+
+ return {
+ data: updated,
+ activity: {
+ type: 'agent_action',
+ // The code itself is never written to the activity log: that log
+ // is readable by every member, and rotating a code into it would
+ // defeat the rotation.
+ subject: 'Rotated the Learn share code',
+ meta: { action: 'learn_access_code.rotated' },
+ },
+ };
+ },
+ }),
+ );
+
+ return app;
+}
+
+// ------------------------------------------------------------------- helpers
+
+/** Curating the curriculum is an administrative act, not a GTM one. */
+export function canManageLearn(principal: { isPlatformAdmin: boolean; scopes: string[] }): boolean {
+ return principal.isPlatformAdmin && principal.scopes.includes('write');
+}
+
+/**
+ * A row that has just passed `resolveEmbedOrThrow` must be renderable, so a
+ * null here is a contradiction between the resolver and the rebuilder rather
+ * than a resource that is merely unavailable. Fail loudly.
+ */
+function viewOrThrow(row: LearnRowForView): LearnResourceView {
+ const view = learnResourceView(row);
+ if (!view) throw new Error(`Learn resource ${row.id} was written but cannot be rendered`);
+ return view;
+}
+
+function requiredId(params: Readonly>): string {
+ const id = params.id;
+ if (!id) throw new MutationError('invalid_route_parameter', "Route parameter 'id' is required.", 400);
+ return id;
+}
+
+/**
+ * The write-path half of the allowlist. An unmatched URL is a 400, not a row —
+ * which is what makes "no unresolvable resource exists" an invariant rather
+ * than a hope.
+ */
+function resolveEmbedOrThrow(url: string) {
+ const resolved = resolveLearnEmbed(url);
+ if (!resolved.ok) {
+ throw new MutationError(
+ 'invalid_video_url',
+ LEARN_EMBED_REJECTION_MESSAGES[resolved.reason],
+ 400,
+ );
+ }
+ return resolved;
+}
diff --git a/apps/api/src/routes/notion-import.ts b/apps/api/src/routes/notion-import.ts
index fdffdc0..53be367 100644
--- a/apps/api/src/routes/notion-import.ts
+++ b/apps/api/src/routes/notion-import.ts
@@ -4,7 +4,7 @@ import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { Hono } from 'hono';
import { z } from 'zod';
import type { Config } from '../lib/config';
-import { requireCapability } from '../lib/auth';
+import { requireAnyTeamCapability } from '../lib/auth';
import type { ApiEnv } from '../lib/mutation';
import { decryptSecret, encryptSecret, encryptionReady } from '../lib/secrets';
import {
@@ -32,9 +32,19 @@ export function createNotionImportRoutes(
const oauthCookieName = config.isProduction ? '__Host-pig_notion_oauth' : 'pig_notion_oauth';
const oauthCookiePath = config.isProduction ? '/' : NOTION_OAUTH_CALLBACK_PATH;
+ /*
+ * Connecting a Notion workspace is `integration:connect`; materialising a
+ * data source into PIG rows is `data:import`. See the same split in
+ * google-sheets.ts for why they are not the same authority.
+ */
routes.use('/api/imports/notion/*', async (context, next) => {
- if (new URL(context.req.url).pathname === NOTION_OAUTH_CALLBACK_PATH) return next();
- requireCapability(context.get('principal'), 'data:import');
+ const path = new URL(context.req.url).pathname;
+ if (path === NOTION_OAUTH_CALLBACK_PATH) return next();
+ const writesRows = path.endsWith('/materialize');
+ requireAnyTeamCapability(
+ context.get('principal'),
+ writesRows ? 'data:import' : 'integration:connect',
+ );
await next();
});
diff --git a/apps/api/src/routes/piggy-chat.ts b/apps/api/src/routes/piggy-chat.ts
index c7b8603..0c67c87 100644
--- a/apps/api/src/routes/piggy-chat.ts
+++ b/apps/api/src/routes/piggy-chat.ts
@@ -1,7 +1,33 @@
+import { PIGGY_PAGE_ROUTES, PIGGY_RECORD_TYPES } from '@pig/core';
+import type { Database } from '@pig/db';
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { z } from 'zod';
+import type { Config } from '../lib/config';
import type { ApiEnv } from '../lib/mutation';
+import { ensurePlatformSettings } from './admin-settings';
+
+/**
+ * Derived from the @pig/core tuples, and kept in step with the identical
+ * schema in the Piggy chat server. Both are `.strict()`, so a context arm
+ * missing from either one is a 400 at that hop rather than a degraded answer.
+ */
+const contextSchema = z.discriminatedUnion('type', [
+ z
+ .object({
+ type: z.enum(PIGGY_RECORD_TYPES),
+ id: z.string().uuid(),
+ label: z.string().max(240).optional(),
+ })
+ .strict(),
+ z
+ .object({
+ type: z.literal('page'),
+ route: z.enum(PIGGY_PAGE_ROUTES),
+ label: z.string().max(240).optional(),
+ })
+ .strict(),
+]);
const requestSchema = z
.object({
@@ -15,20 +41,7 @@ const requestSchema = z
)
.max(20)
.optional(),
- context: z
- .object({
- type: z.enum([
- 'account',
- 'contact',
- 'demand_deal',
- 'supply_deal',
- 'contract',
- 'commitment',
- ]),
- id: z.string().uuid(),
- label: z.string().max(240).optional(),
- })
- .optional(),
+ context: contextSchema.optional(),
})
.strict();
@@ -37,15 +50,44 @@ export interface PiggyChatProxyOptions {
internalUrl?: string;
internalToken?: string;
fetchImpl?: typeof fetch;
+ /**
+ * The admin toggle, read per request. Omitted, the environment gate alone
+ * decides — which is what shipped, and why turning Piggy off in the admin UI
+ * did nothing.
+ */
+ resolvePiggyEnabled?: () => Promise;
+}
+
+/** The stored toggle. Paired with `createPiggyChatRoutes` at composition. */
+export function platformPiggyEnabled(config: Config, db: Database): () => Promise {
+ return async () => (await ensurePlatformSettings(config, db)).piggyEnabled;
}
export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
const routes = new Hono();
const fetchImpl = options.fetchImpl ?? fetch;
- const available = Boolean(options.enabled && options.internalUrl && options.internalToken);
+ // Configuration cannot change under a running process; the toggle can.
+ const configured = Boolean(options.enabled && options.internalUrl && options.internalToken);
- routes.get('/api/piggy/status', (c) => {
+ /**
+ * The environment variable is the outer gate and the stored setting the
+ * inner one: an operator who has not provisioned Piggy cannot have it
+ * switched on from the admin UI. A failed settings read falls back to the
+ * outer gate rather than 503-ing every dock on the site over one bad query.
+ */
+ async function isAvailable(): Promise {
+ if (!configured) return false;
+ if (!options.resolvePiggyEnabled) return true;
+ try {
+ return await options.resolvePiggyEnabled();
+ } catch {
+ return true;
+ }
+ }
+
+ routes.get('/api/piggy/status', async (c) => {
const principal = c.get('principal');
+ const available = await isAvailable();
return c.json({
enabled: available,
canUse: available && principal.scopes.includes('read'),
@@ -60,7 +102,7 @@ export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
403,
);
}
- if (!available || !options.internalUrl || !options.internalToken) {
+ if (!(await isAvailable()) || !options.internalUrl || !options.internalToken) {
return c.json({ error: 'Piggy chat is not available.', code: 'piggy_unavailable' }, 503);
}
diff --git a/apps/api/src/routes/read-guards.ts b/apps/api/src/routes/read-guards.ts
new file mode 100644
index 0000000..e3ba51a
--- /dev/null
+++ b/apps/api/src/routes/read-guards.ts
@@ -0,0 +1,60 @@
+/**
+ * Which capability each read requires — the whole policy, in one table.
+ *
+ * It lives in a table rather than beside each handler because the question a
+ * reviewer needs to answer is "who can see cost?", and that question is
+ * unanswerable if the answer is spread across nine route files. Adding a GET
+ * without adding a row here leaves it ungoverned, which is the failure this
+ * exists to end; `read-governance.test.ts` fails when a new read path appears
+ * that no row covers.
+ *
+ * Mounted before every other route in `createApp`, and the order is
+ * load-bearing: Hono runs matched handlers in registration order, so a guard
+ * registered after its handler never runs.
+ */
+import type { ReadCapability } from '@pig/core';
+import { Hono } from 'hono';
+import { readGuard } from '../lib/read-guard';
+import type { ApiEnv } from '../lib/mutation';
+
+export interface ReadRule {
+ method: 'GET' | 'POST';
+ path: string;
+ capability: ReadCapability;
+}
+
+/**
+ * `economics:read` covers anything carrying supplier cost, break-even price or
+ * a margin total. `/api/capacity/match` is a POST only because a requirement
+ * is too big for a query string — it returns break-even per block, so it is a
+ * read and is gated as one.
+ */
+export const READ_RULES: readonly ReadRule[] = [
+ { method: 'GET', path: '/api/capacity/availability', capability: 'economics:read' },
+ { method: 'GET', path: '/api/capacity/idle', capability: 'economics:read' },
+ { method: 'GET', path: '/api/capacity/margin', capability: 'economics:read' },
+ { method: 'POST', path: '/api/capacity/match', capability: 'economics:read' },
+ { method: 'GET', path: '/api/inventory', capability: 'economics:read' },
+ { method: 'GET', path: '/api/commitments', capability: 'economics:read' },
+ { method: 'GET', path: '/api/allocations', capability: 'economics:read' },
+ { method: 'GET', path: '/api/dashboard', capability: 'economics:read' },
+
+ { method: 'GET', path: '/api/accounts', capability: 'book:read' },
+ { method: 'GET', path: '/api/accounts/:id', capability: 'book:read' },
+ { method: 'GET', path: '/api/contacts', capability: 'book:read' },
+ { method: 'GET', path: '/api/deals/demand', capability: 'book:read' },
+ { method: 'GET', path: '/api/deals/supply', capability: 'book:read' },
+ { method: 'GET', path: '/api/contracts', capability: 'book:read' },
+ { method: 'GET', path: '/api/contracts/:id', capability: 'book:read' },
+ { method: 'GET', path: '/api/growth', capability: 'book:read' },
+ { method: 'GET', path: '/api/growth/accounts/:id', capability: 'book:read' },
+ { method: 'GET', path: '/api/facts', capability: 'book:read' },
+
+ { method: 'GET', path: '/api/team', capability: 'team:read' },
+];
+
+export function createReadGuardRoutes(rules: readonly ReadRule[] = READ_RULES): Hono {
+ const routes = new Hono();
+ for (const rule of rules) routes.on(rule.method, rule.path, readGuard(rule.capability));
+ return routes;
+}
diff --git a/apps/api/src/services/calendar.ts b/apps/api/src/services/calendar.ts
new file mode 100644
index 0000000..f6846bd
--- /dev/null
+++ b/apps/api/src/services/calendar.ts
@@ -0,0 +1,974 @@
+/**
+ * The quarterly calendar — a projection, not a table.
+ *
+ * Everything with a date on it already lives somewhere: contracts expire,
+ * obligations fall due, commitments open and close, holds lapse, export
+ * authorisations run out. This service reads those columns where they are and
+ * emits one common shape. Nothing here is stored, and nothing here can drift
+ * from the record it describes.
+ *
+ * Three things shape the implementation.
+ *
+ * **One query per source, each with its own date predicate and its own
+ * limit.** The convention elsewhere in this API is a flat `.limit(300)`
+ * ordered by `updated_at`, with the caller filtering by date in the browser —
+ * which means the deals actually closing this quarter are not guaranteed to be
+ * in the response at all. That is precisely the bug this endpoint exists to
+ * fix, so every predicate is server-side and every source is bounded
+ * independently rather than competing for one budget.
+ *
+ * **Totals are separate aggregate queries.** If the header counted the rows in
+ * the list it would under-report the moment any source truncated, and a
+ * quarterly figure that silently shrinks is worse than no figure. The counts
+ * are exact even when the list is cut short.
+ *
+ * **Renewal comes from `renewalAlarm()`.** The rule — expiry minus notice
+ * days, only when auto-renewal is on — is defined once, in the contracts
+ * service. The SQL below narrows candidates with the same arithmetic so the
+ * scan stays bounded, but every date and every state on an emitted event comes
+ * from calling that function. If the rule changes, it changes there.
+ */
+import {
+ and,
+ asc,
+ count,
+ eq,
+ gt,
+ gte,
+ isNotNull,
+ isNull,
+ lt,
+ or,
+ sql,
+} from 'drizzle-orm';
+import {
+ calendarEventId,
+ completableSpanState,
+ eventState,
+ quarterOf,
+ spanState,
+ type CalendarEvent,
+ type CalendarEventKind,
+ type Quarter,
+} from '@pig/core';
+import {
+ accounts,
+ allocations,
+ calendarEntries,
+ capacityCommitments,
+ complianceArtifacts,
+ contractObligations,
+ contracts,
+ demandDeals,
+ exportAuthorizations,
+ supplyDeals,
+ users,
+ type Database,
+} from '@pig/db';
+import { renewalAlarm } from './contracts';
+
+/** Per-source ceiling. Generous enough that a real quarter never reaches it. */
+const DEFAULT_SOURCE_LIMIT = 500;
+
+export interface CalendarQuery {
+ from: Date;
+ /** Exclusive. Quarters are half-open so consecutive ones do not double-count. */
+ to: Date;
+ kinds?: readonly CalendarEventKind[];
+ accountId?: string;
+ ownerUserId?: string;
+ fiscalYearStartMonth?: number;
+ timeZone?: string;
+ sourceLimit?: number;
+}
+
+export interface CalendarTotals {
+ /**
+ * Σ acv × probability for deals whose expected close date falls in range.
+ * The number a GTM lead reads first, and nothing in PIG computed it before.
+ */
+ weightedPipelineCents: number;
+ closingCount: number;
+ renewalCount: number;
+ obligationCount: number;
+ expiringAuthorizationCount: number;
+}
+
+export interface CalendarProjection {
+ from: string;
+ to: string;
+ quarter: Quarter;
+ events: CalendarEvent[];
+ /** True when any single source hit its limit; the totals are still exact. */
+ truncated: boolean;
+ totals: CalendarTotals;
+}
+
+/**
+ * Where the front end should go when an event is clicked.
+ *
+ * There is no record-detail route convention in this app yet — every page is
+ * flat — so the page is the load-bearing half and the query parameter is a
+ * hint the detail sheet can honour once one exists.
+ */
+function href(page: string, param: string, id: string): string {
+ return `/${page}?${param}=${id}`;
+}
+
+/** Drizzle returns numeric columns as strings; `probability` is one of them. */
+function numeric(value: string | null): number | null {
+ if (value === null) return null;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+export class CalendarService {
+ constructor(
+ private readonly db: Database,
+ private readonly clock: () => Date = () => new Date(),
+ ) {}
+
+ /**
+ * The reader's own quarter boundary.
+ *
+ * `users.timezone` is settable through PATCH /api/me/preferences and until
+ * now was read by nothing at all. A quarter is a local-midnight question, so
+ * this is the first place it genuinely matters — and UTC remains the honest
+ * fallback for a user who has never set one.
+ */
+ async timeZoneFor(userId: string): Promise {
+ const [row] = await this.db
+ .select({ timezone: users.timezone })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+ return row?.timezone ?? 'UTC';
+ }
+
+ async project(query: CalendarQuery): Promise {
+ const now = this.clock();
+ const timeZone = query.timeZone ?? 'UTC';
+ const fiscalYearStartMonth = query.fiscalYearStartMonth ?? 0;
+ const limit = query.sourceLimit ?? DEFAULT_SOURCE_LIMIT;
+ const wanted = query.kinds?.length ? new Set(query.kinds) : null;
+ const wants = (kind: CalendarEventKind): boolean => !wanted || wanted.has(kind);
+
+ const collected: { events: CalendarEvent[]; truncated: boolean }[] = await Promise.all([
+ wants('expected_close') ? this.expectedClose(query, now, limit) : empty(),
+ wants('contract_effective')
+ ? this.contractDate(query, now, limit, 'contract_effective')
+ : empty(),
+ wants('contract_expiry')
+ ? this.contractDate(query, now, limit, 'contract_expiry')
+ : empty(),
+ wants('contract_executed')
+ ? this.contractDate(query, now, limit, 'contract_executed')
+ : empty(),
+ wants('renewal_notice') ? this.renewalNotices(query, now, limit) : empty(),
+ wants('obligation_due') ? this.obligations(query, now, limit) : empty(),
+ wants('capacity_window') ? this.capacityWindows(query, now, limit) : empty(),
+ wants('allocation_window') ? this.allocationWindows(query, now, limit) : empty(),
+ wants('hold_expiry') ? this.holdExpiries(query, now, limit) : empty(),
+ wants('supply_available_from') ? this.supplyAvailability(query, now, limit) : empty(),
+ wants('authorization_expiry') ? this.authorizationExpiries(query, now, limit) : empty(),
+ wants('artifact_expiry') ? this.artifactExpiries(query, now, limit) : empty(),
+ wants('calendar_entry') ? this.entries(query, now, limit) : empty(),
+ ]);
+
+ const events = collected
+ .flatMap((source) => source.events)
+ .sort((a, b) => a.startsAt.localeCompare(b.startsAt) || a.id.localeCompare(b.id));
+
+ return {
+ from: query.from.toISOString(),
+ to: query.to.toISOString(),
+ quarter: quarterOf(query.from, fiscalYearStartMonth, timeZone),
+ events,
+ truncated: collected.some((source) => source.truncated),
+ totals: await this.totals(query),
+ };
+ }
+
+ // ------------------------------------------------------------------ totals
+
+ /**
+ * Counted in SQL rather than off the event list, so a truncated source
+ * cannot quietly shrink a quarterly figure. The kind filter is deliberately
+ * ignored here: narrowing the list to one kind should not blank the header
+ * the reader is narrowing against.
+ */
+ private async totals(query: CalendarQuery): Promise {
+ const { from, to, accountId, ownerUserId } = query;
+
+ const [pipeline, renewals, obligations, authorizations] = await Promise.all([
+ this.db
+ .select({
+ /**
+ * A closed-won deal forecasts at certainty and a closed-lost one at
+ * nothing, whatever `probability` still says; an open deal with no
+ * forecast contributes nothing rather than its full value, because
+ * an unfilled field is not a prediction of 100%.
+ */
+ weightedCents: sql`coalesce(sum(round(${demandDeals.acvCents} * (case
+ when ${demandDeals.stage} = 'closed_won' then 1
+ when ${demandDeals.stage} = 'closed_lost' then 0
+ else coalesce(${demandDeals.probability}, 0) end))), 0)`,
+ closing: sql`count(*) filter (where ${demandDeals.stage} <> 'closed_lost')::int`,
+ })
+ .from(demandDeals)
+ .where(
+ and(
+ gte(demandDeals.expectedCloseDate, from),
+ lt(demandDeals.expectedCloseDate, to),
+ accountId ? eq(demandDeals.accountId, accountId) : undefined,
+ ownerUserId ? eq(demandDeals.ownerUserId, ownerUserId) : undefined,
+ ),
+ ),
+ this.db
+ .select({ value: count() })
+ .from(contracts)
+ .where(this.renewalPredicate(query)),
+ this.db
+ .select({ value: count() })
+ .from(contractObligations)
+ .innerJoin(contracts, eq(contracts.id, contractObligations.contractId))
+ .where(
+ and(
+ gte(contractObligations.dueAt, from),
+ lt(contractObligations.dueAt, to),
+ // Outstanding only. A count that includes work already done reads
+ // as a backlog that is not there.
+ isNull(contractObligations.completedAt),
+ accountId ? eq(contracts.accountId, accountId) : undefined,
+ ownerUserId ? eq(contractObligations.ownerUserId, ownerUserId) : undefined,
+ ),
+ ),
+ // An expiring authorisation has no owner column, so an owner filter can
+ // only ever exclude it — reporting zero rather than the whole book.
+ ownerUserId
+ ? Promise.resolve([{ value: 0 }])
+ : this.db
+ .select({ value: count() })
+ .from(exportAuthorizations)
+ .where(
+ and(
+ gte(exportAuthorizations.expiresAt, from),
+ lt(exportAuthorizations.expiresAt, to),
+ accountId ? eq(exportAuthorizations.accountId, accountId) : undefined,
+ ),
+ ),
+ ]);
+
+ return {
+ weightedPipelineCents: Math.round(Number(pipeline[0]?.weightedCents ?? 0)),
+ closingCount: pipeline[0]?.closing ?? 0,
+ renewalCount: renewals[0]?.value ?? 0,
+ obligationCount: obligations[0]?.value ?? 0,
+ expiringAuthorizationCount: authorizations[0]?.value ?? 0,
+ };
+ }
+
+ // ----------------------------------------------------------------- sources
+
+ private async expectedClose(query: CalendarQuery, now: Date, limit: number) {
+ const rows = await this.db
+ .select({ deal: demandDeals, accountName: accounts.name })
+ .from(demandDeals)
+ .leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
+ .where(
+ and(
+ gte(demandDeals.expectedCloseDate, query.from),
+ lt(demandDeals.expectedCloseDate, query.to),
+ query.accountId ? eq(demandDeals.accountId, query.accountId) : undefined,
+ query.ownerUserId ? eq(demandDeals.ownerUserId, query.ownerUserId) : undefined,
+ ),
+ )
+ .orderBy(asc(demandDeals.expectedCloseDate))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, ({ deal, accountName }) => {
+ const at = deal.expectedCloseDate!;
+ const probability = numeric(deal.probability);
+ return {
+ id: calendarEventId('demand_deal', deal.id, 'expectedCloseDate'),
+ kind: 'expected_close' as const,
+ title: deal.name,
+ startsAt: at.toISOString(),
+ endsAt: null,
+ isSpan: false,
+ state: eventState({ at, now, completedAt: deal.closedAt }),
+ accountId: deal.accountId,
+ accountName,
+ ownerUserId: deal.ownerUserId,
+ amountCents: deal.acvCents,
+ currency: deal.currency,
+ recordType: 'demand_deal',
+ recordId: deal.id,
+ href: href('demand', 'deal', deal.id),
+ meta: {
+ stage: deal.stage,
+ probability,
+ productLine: deal.productLine,
+ weightedCents:
+ deal.acvCents !== null && probability !== null
+ ? Math.round(deal.acvCents * probability)
+ : null,
+ },
+ };
+ });
+ }
+
+ private async contractDate(
+ query: CalendarQuery,
+ now: Date,
+ limit: number,
+ kind: 'contract_effective' | 'contract_expiry' | 'contract_executed',
+ ) {
+ const column =
+ kind === 'contract_effective'
+ ? contracts.effectiveAt
+ : kind === 'contract_expiry'
+ ? contracts.expiresAt
+ : contracts.executedAt;
+ const field =
+ kind === 'contract_effective'
+ ? 'effectiveAt'
+ : kind === 'contract_expiry'
+ ? 'expiresAt'
+ : 'executedAt';
+ const label =
+ kind === 'contract_effective'
+ ? 'takes effect'
+ : kind === 'contract_expiry'
+ ? 'expires'
+ : 'executed';
+
+ const rows = await this.db
+ .select({ contract: contracts, accountName: accounts.name })
+ .from(contracts)
+ .leftJoin(accounts, eq(accounts.id, contracts.accountId))
+ .where(
+ and(
+ gte(column, query.from),
+ lt(column, query.to),
+ query.accountId ? eq(contracts.accountId, query.accountId) : undefined,
+ query.ownerUserId ? eq(contracts.ownerUserId, query.ownerUserId) : undefined,
+ ),
+ )
+ .orderBy(asc(column))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, ({ contract, accountName }) => {
+ const at = contract[field]!;
+ return {
+ id: calendarEventId('contract', contract.id, field),
+ kind,
+ title: `${contract.title} ${label}`,
+ startsAt: at.toISOString(),
+ endsAt: null,
+ isSpan: false,
+ // An executed date is a fact about the past, not an errand: it is
+ // recorded as done so it does not sit in the overdue list forever.
+ state:
+ kind === 'contract_executed'
+ ? ('done' as const)
+ : eventState({ at, now, completedAt: contract.terminatedAt }),
+ accountId: contract.accountId,
+ accountName,
+ ownerUserId: contract.ownerUserId,
+ amountCents: contract.valueCents,
+ currency: contract.currency,
+ recordType: 'contract',
+ recordId: contract.id,
+ href: href('contracts', 'contract', contract.id),
+ meta: {
+ contractType: contract.type,
+ status: contract.status,
+ side: contract.side,
+ terminatedAt: contract.terminatedAt?.toISOString() ?? null,
+ },
+ };
+ });
+ }
+
+ /**
+ * The SQL narrows; `renewalAlarm()` decides.
+ *
+ * The predicate repeats the expiry-minus-notice arithmetic only to keep the
+ * scan bounded — the alternative is loading every auto-renewing contract in
+ * the book. Every date and state that reaches a caller comes from the shared
+ * function, so there is still exactly one definition of the rule.
+ */
+ private renewalPredicate(query: CalendarQuery) {
+ return and(
+ eq(contracts.isAutoRenew, true),
+ isNotNull(contracts.noticeDays),
+ isNotNull(contracts.expiresAt),
+ // A terminated contract will not renew, so its notice date is not a
+ // deadline anyone should be chased about.
+ isNull(contracts.terminatedAt),
+ // The bounds are bound as ISO text and cast, not as `Date`: drizzle types
+ // parameters from the column in a comparison, and a raw template has no
+ // column to learn from, so postgres-js receives a Date it cannot encode
+ // and the whole request 500s. Found by calling the endpoint.
+ sql`${contracts.expiresAt} - make_interval(days => ${contracts.noticeDays}) >= ${query.from.toISOString()}::timestamptz`,
+ sql`${contracts.expiresAt} - make_interval(days => ${contracts.noticeDays}) < ${query.to.toISOString()}::timestamptz`,
+ query.accountId ? eq(contracts.accountId, query.accountId) : undefined,
+ query.ownerUserId ? eq(contracts.ownerUserId, query.ownerUserId) : undefined,
+ );
+ }
+
+ private async renewalNotices(query: CalendarQuery, now: Date, limit: number) {
+ const rows = await this.db
+ .select({ contract: contracts, accountName: accounts.name })
+ .from(contracts)
+ .leftJoin(accounts, eq(accounts.id, contracts.accountId))
+ .where(this.renewalPredicate(query))
+ .orderBy(asc(contracts.expiresAt))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, ({ contract, accountName }) => {
+ const alarm = renewalAlarm(contract, now);
+ const at = alarm.renewalNoticeAt!;
+ return {
+ id: calendarEventId('contract', contract.id, 'renewalNoticeAt'),
+ kind: 'renewal_notice' as const,
+ title: `Renewal notice — ${contract.title}`,
+ startsAt: at.toISOString(),
+ endsAt: null,
+ isSpan: false,
+ // 'expired' means the window to give notice has gone; the notice date
+ // itself is simply late until then.
+ state:
+ alarm.renewalState === 'expired'
+ ? ('overdue' as const)
+ : eventState({ at, now }),
+ accountId: contract.accountId,
+ accountName,
+ ownerUserId: contract.ownerUserId,
+ amountCents: contract.valueCents,
+ currency: contract.currency,
+ recordType: 'contract',
+ recordId: contract.id,
+ href: href('contracts', 'contract', contract.id),
+ meta: {
+ renewalState: alarm.renewalState,
+ expiresAt: contract.expiresAt?.toISOString() ?? null,
+ noticeDays: contract.noticeDays,
+ side: contract.side,
+ },
+ };
+ });
+ }
+
+ /**
+ * Every obligation on every contract, in one query.
+ *
+ * Obligations were reachable only inside GET /api/contracts/:id, so a
+ * quarter of them meant one request per contract. They are the dated things
+ * most likely to be missed, which makes that the wrong place for them to be.
+ */
+ private async obligations(query: CalendarQuery, now: Date, limit: number) {
+ const rows = await this.db
+ .select({
+ obligation: contractObligations,
+ contract: contracts,
+ accountName: accounts.name,
+ })
+ .from(contractObligations)
+ .innerJoin(contracts, eq(contracts.id, contractObligations.contractId))
+ .leftJoin(accounts, eq(accounts.id, contracts.accountId))
+ .where(
+ and(
+ gte(contractObligations.dueAt, query.from),
+ lt(contractObligations.dueAt, query.to),
+ query.accountId ? eq(contracts.accountId, query.accountId) : undefined,
+ query.ownerUserId
+ ? eq(contractObligations.ownerUserId, query.ownerUserId)
+ : undefined,
+ ),
+ )
+ .orderBy(asc(contractObligations.dueAt))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, ({ obligation, contract, accountName }) => ({
+ id: calendarEventId('contract_obligation', obligation.id, 'dueAt'),
+ kind: 'obligation_due' as const,
+ title: obligation.title,
+ startsAt: obligation.dueAt.toISOString(),
+ endsAt: null,
+ isSpan: false,
+ state: eventState({
+ at: obligation.dueAt,
+ now,
+ completedAt: obligation.completedAt,
+ }),
+ accountId: contract.accountId,
+ accountName,
+ ownerUserId: obligation.ownerUserId,
+ amountCents: null,
+ currency: null,
+ recordType: 'contract_obligation',
+ recordId: obligation.id,
+ href: href('contracts', 'contract', contract.id),
+ meta: {
+ obligationKind: obligation.kind,
+ contractId: contract.id,
+ contractTitle: contract.title,
+ completedAt: obligation.completedAt?.toISOString() ?? null,
+ },
+ }));
+ }
+
+ /**
+ * Commitment windows, split on the capacity shape where one is present.
+ *
+ * A commitment ramps and steps — it is not a rectangle — and `shape` is
+ * authoritative over `startsAt`/`endsAt` when set. Drawing one bar across
+ * the whole term shows a seller capacity in a month it does not exist in,
+ * which is exactly the mistake the shape column was added to prevent.
+ */
+ private async capacityWindows(query: CalendarQuery, now: Date, limit: number) {
+ // No owner column anywhere on the supply chain of custody, so an owner
+ // filter cannot be satisfied and must exclude the source outright.
+ if (query.ownerUserId) return { events: [], truncated: false };
+
+ const rows = await this.db
+ .select({ commitment: capacityCommitments, accountName: accounts.name })
+ .from(capacityCommitments)
+ .leftJoin(accounts, eq(accounts.id, capacityCommitments.accountId))
+ .where(
+ and(
+ lt(capacityCommitments.startsAt, query.to),
+ gt(capacityCommitments.endsAt, query.from),
+ query.accountId ? eq(capacityCommitments.accountId, query.accountId) : undefined,
+ ),
+ )
+ .orderBy(asc(capacityCommitments.startsAt))
+ .limit(limit + 1);
+
+ const truncated = rows.length > limit;
+ if (truncated) rows.length = limit;
+
+ const events: CalendarEvent[] = [];
+ for (const { commitment, accountName } of rows) {
+ const base = {
+ kind: 'capacity_window' as const,
+ isSpan: true,
+ accountId: commitment.accountId,
+ accountName,
+ ownerUserId: null,
+ amountCents: null,
+ currency: commitment.currency,
+ recordType: 'capacity_commitment',
+ recordId: commitment.id,
+ href: href('capacity', 'commitment', commitment.id),
+ };
+
+ const shape = commitment.shape;
+ const subSpans =
+ shape && shape.intervals.length >= 2 && shape.quantities.length >= 1
+ ? shape.intervals.slice(0, -1).map((boundary, index) => ({
+ index,
+ startsAt: new Date(boundary),
+ endsAt: new Date(shape.intervals[index + 1]!),
+ gpuCount: shape.quantities[index] ?? commitment.gpuCount,
+ }))
+ : [
+ {
+ index: null,
+ startsAt: commitment.startsAt,
+ endsAt: commitment.endsAt,
+ gpuCount: commitment.gpuCount,
+ },
+ ];
+
+ for (const span of subSpans) {
+ if (Number.isNaN(span.startsAt.getTime()) || Number.isNaN(span.endsAt.getTime())) {
+ continue;
+ }
+ if (span.startsAt >= query.to || span.endsAt <= query.from) continue;
+ events.push({
+ ...base,
+ id: calendarEventId(
+ 'capacity_commitment',
+ commitment.id,
+ span.index === null ? 'window' : `shape.${span.index}`,
+ ),
+ title:
+ span.index === null
+ ? commitment.name
+ : `${commitment.name} — ${span.gpuCount}× ${commitment.gpuType}`,
+ startsAt: span.startsAt.toISOString(),
+ endsAt: span.endsAt.toISOString(),
+ state: commitment.terminatedAt
+ ? ('done' as const)
+ : spanState({ startsAt: span.startsAt, endsAt: span.endsAt, now }),
+ meta: {
+ gpuType: commitment.gpuType,
+ gpuCount: span.gpuCount,
+ envelopeGpuCount: commitment.gpuCount,
+ shaped: span.index !== null,
+ costPerGpuHourCents: commitment.costPerGpuHourCents,
+ terminatedAt: commitment.terminatedAt?.toISOString() ?? null,
+ },
+ });
+ }
+ }
+
+ return { events, truncated };
+ }
+
+ private async allocationWindows(query: CalendarQuery, now: Date, limit: number) {
+ if (query.ownerUserId) return { events: [], truncated: false };
+
+ const rows = await this.db
+ .select({
+ allocation: allocations,
+ commitmentName: capacityCommitments.name,
+ dealName: demandDeals.name,
+ accountId: demandDeals.accountId,
+ accountName: accounts.name,
+ })
+ .from(allocations)
+ .leftJoin(
+ capacityCommitments,
+ eq(capacityCommitments.id, allocations.capacityCommitmentId),
+ )
+ .leftJoin(demandDeals, eq(demandDeals.id, allocations.demandDealId))
+ .leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
+ .where(
+ and(
+ lt(allocations.startsAt, query.to),
+ gt(allocations.endsAt, query.from),
+ query.accountId ? eq(demandDeals.accountId, query.accountId) : undefined,
+ ),
+ )
+ .orderBy(asc(allocations.startsAt))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, (row) => {
+ const { allocation } = row;
+ const gpuHours = numeric(allocation.gpuHours) ?? 0;
+ return {
+ id: calendarEventId('allocation', allocation.id, 'window'),
+ kind: 'allocation_window' as const,
+ title:
+ row.dealName ??
+ (allocation.internalTeam
+ ? `Internal — ${allocation.internalTeam}`
+ : (row.commitmentName ?? 'Allocation')),
+ startsAt: allocation.startsAt.toISOString(),
+ endsAt: allocation.endsAt.toISOString(),
+ isSpan: true,
+ state:
+ allocation.releasedAt !== null
+ ? ('done' as const)
+ : spanState({
+ startsAt: allocation.startsAt,
+ endsAt: allocation.endsAt,
+ now,
+ }),
+ accountId: row.accountId ?? null,
+ accountName: row.accountName ?? null,
+ ownerUserId: null,
+ // Revenue over the window, in cents — hours are fractional, money is not.
+ amountCents: Math.round(gpuHours * allocation.pricePerGpuHourCents),
+ currency: allocation.currency,
+ recordType: 'allocation',
+ recordId: allocation.id,
+ href: href('capacity', 'allocation', allocation.id),
+ meta: {
+ status: allocation.status,
+ guaranteeType: allocation.guaranteeType,
+ gpuHours,
+ internalTeam: allocation.internalTeam,
+ commitmentId: allocation.capacityCommitmentId,
+ releasedAt: allocation.releasedAt?.toISOString() ?? null,
+ },
+ };
+ });
+ }
+
+ /**
+ * A hold expiring is the one date on this calendar that changes what can be
+ * sold: the moment it passes, the capacity returns to everyone else's
+ * availability. It has never been visible anywhere.
+ */
+ private async holdExpiries(query: CalendarQuery, now: Date, limit: number) {
+ if (query.ownerUserId) return { events: [], truncated: false };
+
+ const rows = await this.db
+ .select({
+ allocation: allocations,
+ dealName: demandDeals.name,
+ accountId: demandDeals.accountId,
+ accountName: accounts.name,
+ })
+ .from(allocations)
+ .leftJoin(demandDeals, eq(demandDeals.id, allocations.demandDealId))
+ .leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
+ .where(
+ and(
+ gte(allocations.holdExpiresAt, query.from),
+ lt(allocations.holdExpiresAt, query.to),
+ query.accountId ? eq(demandDeals.accountId, query.accountId) : undefined,
+ ),
+ )
+ .orderBy(asc(allocations.holdExpiresAt))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, (row) => {
+ const at = row.allocation.holdExpiresAt!;
+ return {
+ id: calendarEventId('allocation', row.allocation.id, 'holdExpiresAt'),
+ kind: 'hold_expiry' as const,
+ title: `Hold expires — ${row.dealName ?? 'unassigned capacity'}`,
+ startsAt: at.toISOString(),
+ endsAt: null,
+ isSpan: false,
+ state: eventState({ at, now, completedAt: row.allocation.releasedAt }),
+ accountId: row.accountId ?? null,
+ accountName: row.accountName ?? null,
+ ownerUserId: null,
+ // What was turned away to keep the hold. Makes the deadline honest.
+ amountCents: row.allocation.holdOpportunityCostCents,
+ currency: row.allocation.currency,
+ recordType: 'allocation',
+ recordId: row.allocation.id,
+ href: href('capacity', 'allocation', row.allocation.id),
+ meta: {
+ status: row.allocation.status,
+ gpuHours: numeric(row.allocation.gpuHours),
+ commitmentId: row.allocation.capacityCommitmentId,
+ },
+ };
+ });
+ }
+
+ private async supplyAvailability(query: CalendarQuery, now: Date, limit: number) {
+ const rows = await this.db
+ .select({ deal: supplyDeals, accountName: accounts.name })
+ .from(supplyDeals)
+ .leftJoin(accounts, eq(accounts.id, supplyDeals.accountId))
+ .where(
+ and(
+ gte(supplyDeals.availableFrom, query.from),
+ lt(supplyDeals.availableFrom, query.to),
+ query.accountId ? eq(supplyDeals.accountId, query.accountId) : undefined,
+ query.ownerUserId ? eq(supplyDeals.ownerUserId, query.ownerUserId) : undefined,
+ ),
+ )
+ .orderBy(asc(supplyDeals.availableFrom))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, ({ deal, accountName }) => {
+ const at = deal.availableFrom!;
+ return {
+ id: calendarEventId('supply_deal', deal.id, 'availableFrom'),
+ kind: 'supply_available_from' as const,
+ title: `Capacity available — ${deal.name}`,
+ startsAt: at.toISOString(),
+ endsAt: null,
+ isSpan: false,
+ state: eventState({ at, now, completedAt: deal.closedAt }),
+ accountId: deal.accountId,
+ accountName,
+ ownerUserId: deal.ownerUserId,
+ amountCents: null,
+ currency: null,
+ recordType: 'supply_deal',
+ recordId: deal.id,
+ href: href('supply', 'deal', deal.id),
+ meta: {
+ stage: deal.stage,
+ gpuType: deal.gpuType,
+ gpuCount: deal.gpuCount,
+ targetCostPerGpuHourCents: deal.targetCostPerGpuHourCents,
+ },
+ };
+ });
+ }
+
+ /**
+ * An expired export authorisation silently converts lawful business into
+ * unlawful business. The schema says so and indexes the column for it, and
+ * until this endpoint nothing in PIG read it — no endpoint, no screen.
+ */
+ private async authorizationExpiries(query: CalendarQuery, now: Date, limit: number) {
+ if (query.ownerUserId) return { events: [], truncated: false };
+
+ const rows = await this.db
+ .select({ authorization: exportAuthorizations, accountName: accounts.name })
+ .from(exportAuthorizations)
+ .leftJoin(accounts, eq(accounts.id, exportAuthorizations.accountId))
+ .where(
+ and(
+ gte(exportAuthorizations.expiresAt, query.from),
+ lt(exportAuthorizations.expiresAt, query.to),
+ query.accountId ? eq(exportAuthorizations.accountId, query.accountId) : undefined,
+ ),
+ )
+ .orderBy(asc(exportAuthorizations.expiresAt))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, ({ authorization, accountName }) => {
+ const at = authorization.expiresAt!;
+ return {
+ id: calendarEventId('export_authorization', authorization.id, 'expiresAt'),
+ kind: 'authorization_expiry' as const,
+ title: `Export authorisation expires — ${accountName ?? 'account'}`,
+ startsAt: at.toISOString(),
+ endsAt: null,
+ isSpan: false,
+ // Never 'done': an authorisation is not something anyone completes,
+ // and marking a lapsed one finished is the failure mode itself.
+ state: eventState({ at, now }),
+ accountId: authorization.accountId,
+ accountName,
+ ownerUserId: null,
+ amountCents: null,
+ currency: null,
+ recordType: 'export_authorization',
+ recordId: authorization.id,
+ href: href('accounts', 'account', authorization.accountId),
+ meta: {
+ authorizationType: authorization.authorizationType,
+ reference: authorization.reference,
+ // Rules in flux for this counterparty: re-verify, do not trust the date.
+ volatile: authorization.volatile,
+ evidenceUrl: authorization.evidenceUrl,
+ verifiedByUserId: authorization.verifiedByUserId,
+ },
+ };
+ });
+ }
+
+ private async artifactExpiries(query: CalendarQuery, now: Date, limit: number) {
+ if (query.ownerUserId) return { events: [], truncated: false };
+
+ const rows = await this.db
+ .select({ artifact: complianceArtifacts, accountName: accounts.name })
+ .from(complianceArtifacts)
+ .leftJoin(accounts, eq(accounts.id, complianceArtifacts.accountId))
+ .where(
+ and(
+ gte(complianceArtifacts.expiresAt, query.from),
+ lt(complianceArtifacts.expiresAt, query.to),
+ query.accountId ? eq(complianceArtifacts.accountId, query.accountId) : undefined,
+ ),
+ )
+ .orderBy(asc(complianceArtifacts.expiresAt))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, ({ artifact, accountName }) => {
+ const at = artifact.expiresAt!;
+ return {
+ id: calendarEventId('compliance_artifact', artifact.id, 'expiresAt'),
+ kind: 'artifact_expiry' as const,
+ title: `${artifact.claim} expires — ${accountName ?? 'account'}`,
+ startsAt: at.toISOString(),
+ endsAt: null,
+ isSpan: false,
+ state: eventState({ at, now }),
+ accountId: artifact.accountId,
+ accountName,
+ ownerUserId: null,
+ amountCents: null,
+ currency: null,
+ recordType: 'compliance_artifact',
+ recordId: artifact.id,
+ href: href('accounts', 'account', artifact.accountId),
+ meta: {
+ claim: artifact.claim,
+ scope: artifact.scope,
+ // Certification versus self-declared alignment decides procurement,
+ // so it travels with the deadline rather than being looked up later.
+ isCertified: artifact.isCertified,
+ soc2Type: artifact.soc2Type,
+ verifiedByUserId: artifact.verifiedByUserId,
+ },
+ };
+ });
+ }
+
+ private async entries(query: CalendarQuery, now: Date, limit: number) {
+ const rows = await this.db
+ .select({ entry: calendarEntries, accountName: accounts.name })
+ .from(calendarEntries)
+ .leftJoin(accounts, eq(accounts.id, calendarEntries.accountId))
+ .where(
+ and(
+ // A dated entry with no end is a point; one with an end is a span,
+ // and a span overlaps the window whenever it has not already closed.
+ lt(calendarEntries.startsAt, query.to),
+ or(
+ and(isNull(calendarEntries.endsAt), gte(calendarEntries.startsAt, query.from)),
+ and(isNotNull(calendarEntries.endsAt), gt(calendarEntries.endsAt, query.from)),
+ ),
+ query.accountId ? eq(calendarEntries.accountId, query.accountId) : undefined,
+ query.ownerUserId ? eq(calendarEntries.ownerUserId, query.ownerUserId) : undefined,
+ ),
+ )
+ .orderBy(asc(calendarEntries.startsAt))
+ .limit(limit + 1);
+
+ return bounded(rows, limit, ({ entry, accountName }) => ({
+ id: calendarEventId('calendar_entry', entry.id, 'startsAt'),
+ kind: 'calendar_entry' as const,
+ title: entry.title,
+ startsAt: entry.startsAt.toISOString(),
+ endsAt: entry.endsAt?.toISOString() ?? null,
+ isSpan: entry.endsAt !== null,
+ // Not `spanState`: this is the one projected row type with a completion
+ // column, so a closed window is overdue until `completed_at` says
+ // otherwise. Whether a missed QBR is flagged must not depend on whether
+ // its author happened to type an end time.
+ state: entry.endsAt
+ ? completableSpanState({
+ startsAt: entry.startsAt,
+ endsAt: entry.endsAt,
+ now,
+ completedAt: entry.completedAt,
+ })
+ : eventState({ at: entry.startsAt, now, completedAt: entry.completedAt }),
+ accountId: entry.accountId,
+ accountName,
+ ownerUserId: entry.ownerUserId,
+ amountCents: null,
+ currency: null,
+ recordType: 'calendar_entry',
+ recordId: entry.id,
+ href: href('calendar', 'entry', entry.id),
+ meta: {
+ entryKind: entry.kind,
+ allDay: entry.allDay,
+ description: entry.description,
+ demandDealId: entry.demandDealId,
+ supplyDealId: entry.supplyDealId,
+ completedAt: entry.completedAt?.toISOString() ?? null,
+ },
+ }));
+ }
+}
+
+// ------------------------------------------------------------------- helpers
+
+async function empty(): Promise<{ events: CalendarEvent[]; truncated: boolean }> {
+ return { events: [], truncated: false };
+}
+
+/**
+ * Each source asks for one row more than its budget. Detecting truncation any
+ * other way means either a second count query per source or silently returning
+ * a partial quarter as if it were whole.
+ */
+function bounded(
+ rows: Row[],
+ limit: number,
+ toEvent: (row: Row) => CalendarEvent,
+): { events: CalendarEvent[]; truncated: boolean } {
+ const truncated = rows.length > limit;
+ if (truncated) rows.length = limit;
+ return { events: rows.map(toEvent), truncated };
+}
diff --git a/apps/api/test/activities.test.ts b/apps/api/test/activities.test.ts
new file mode 100644
index 0000000..d754478
--- /dev/null
+++ b/apps/api/test/activities.test.ts
@@ -0,0 +1,167 @@
+/**
+ * The write that used to bypass everything.
+ *
+ * `POST /api/activities` lived inline in app.ts with no capability check at
+ * all: any member, and any write-scoped API key, could insert an activity
+ * against an arbitrary `accountId` and move that account's `lastActivityAt`.
+ * These pin the three things that stopped it, not the SQL that carries them
+ * out.
+ */
+import { strict as assert } from 'node:assert';
+import { describe, it } from 'node:test';
+import type { Database } from '@pig/db';
+import { AuthError } from '../src/lib/auth';
+import { executeMutation } from '../src/lib/mutation';
+import { createActivityMutationDefinition, type LoggedActivity } from '../src/routes/activities';
+import { onTeam, principal } from './helpers/principal';
+
+interface Recorded {
+ events: string[];
+ inserted: unknown[];
+ updated: unknown[];
+}
+
+/** A transaction whose account lookup answers with a chosen side. */
+function database(accountSide: string | null): { db: Database; log: Recorded } {
+ const log: Recorded = { events: [], inserted: [], updated: [] };
+ const accountRows = accountSide ? [{ side: accountSide }] : [];
+
+ const tx = {
+ select: () => {
+ log.events.push('select');
+ return { from: () => ({ where: () => ({ limit: async () => accountRows }) }) };
+ },
+ insert: () => ({
+ values: (row: unknown) => {
+ log.events.push('insert');
+ log.inserted.push(row);
+ return { onConflictDoNothing: () => ({ returning: async () => [row] }) };
+ },
+ }),
+ update: () => ({
+ set: (values: unknown) => ({
+ where: async () => {
+ log.events.push('touch-account');
+ log.updated.push(values);
+ },
+ }),
+ }),
+ };
+
+ return {
+ db: {
+ transaction: async (work: (t: unknown) => Promise) => {
+ log.events.push('transaction');
+ return work(tx);
+ },
+ } as unknown as Database,
+ log,
+ };
+}
+
+const body = {
+ type: 'call' as const,
+ subject: 'Spoke to the CTO',
+ accountId: '00000000-0000-4000-8000-0000000000ff',
+};
+
+function log(db: Database, actor = principal()) {
+ return executeMutation(
+ db,
+ actor,
+ async () => body,
+ createActivityMutationDefinition(),
+ ) as Promise;
+}
+
+describe('logging an activity', () => {
+ it('refuses a principal with no activity:write anywhere, before reading the body', async () => {
+ const { db, log: recorded } = database('demand');
+ let bodyWasRead = false;
+
+ await assert.rejects(
+ executeMutation(
+ db,
+ principal(onTeam('demand', 'viewer')),
+ async () => {
+ bodyWasRead = true;
+ return body;
+ },
+ createActivityMutationDefinition(),
+ ),
+ (error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
+ );
+ assert.equal(bodyWasRead, false);
+ assert.deepEqual(recorded.events, []);
+ });
+
+ /**
+ * The escalation the old handler allowed: a research member logging a call
+ * against a demand account they have no relationship with, and pushing it to
+ * the top of somebody else's account list.
+ */
+ it('refuses a research member writing against a demand account', async () => {
+ const { db, log: recorded } = database('demand');
+
+ await assert.rejects(
+ log(db, principal(onTeam('research', 'admin'))),
+ (error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
+ );
+ assert.equal(recorded.inserted.length, 0);
+ assert.equal(recorded.updated.length, 0);
+ });
+
+ it('admits a demand member against a dual-sided account', async () => {
+ const { db, log: recorded } = database('both');
+
+ const result = await log(db);
+
+ assert.equal(result.deduplicated, false);
+ assert.deepEqual(recorded.events, ['transaction', 'select', 'insert', 'touch-account']);
+ });
+
+ it('writes one row, not two — the activity is its own audit event', async () => {
+ const { db, log: recorded } = database('demand');
+
+ await log(db);
+
+ assert.equal(
+ recorded.inserted.length,
+ 1,
+ 'an audit row alongside the activity would double every synced call in the feed',
+ );
+ });
+
+ it('attributes an API key to the agent, not silently to the person', async () => {
+ const { db, log: recorded } = database('demand');
+
+ await log(db, principal({ via: 'api_key', apiKeyId: 'key-1' }));
+
+ assert.deepEqual(
+ recorded.inserted[0] as Record,
+ {
+ ...(recorded.inserted[0] as Record),
+ actorAgent: 'agent',
+ source: 'agent',
+ },
+ );
+ });
+
+ it('keeps the caller\'s timestamp, because sync backfills', async () => {
+ const { db, log: recorded } = database('demand');
+ const when = '2026-01-05T09:30:00.000Z';
+
+ await executeMutation(
+ db,
+ principal(),
+ async () => ({ ...body, occurredAt: when }),
+ createActivityMutationDefinition(),
+ );
+
+ const row = recorded.inserted[0] as { occurredAt: Date };
+ assert.equal(row.occurredAt.toISOString(), when);
+ // And the account stamp follows the event, not the clock, or a backfilled
+ // call from March would jump the account to the top of the list today.
+ assert.deepEqual(recorded.updated, [{ lastActivityAt: new Date(when) }]);
+ });
+});
diff --git a/apps/api/test/admin-settings.test.ts b/apps/api/test/admin-settings.test.ts
index 8f47bd5..469a497 100644
--- a/apps/api/test/admin-settings.test.ts
+++ b/apps/api/test/admin-settings.test.ts
@@ -45,6 +45,11 @@ describe('admin settings decisions', () => {
piggyEnabled: true,
primeApiKeyEncrypted: 'v1.iv.tag.ciphertext',
primeApiKeyUpdatedAt: now,
+ // Present so the row is a complete PlatformSettings. The assertion
+ // below is that nothing secret escapes into the metadata, and the
+ // Learn share code is exactly the sort of thing that must not.
+ learnAccessCode: 'carlthefog',
+ learnAccessCodeUpdatedAt: null,
primeSyncEnabled: true,
primeSyncIntervalMinutes: 30,
updatedByUserId: null,
diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts
index 93c35c7..254b191 100644
--- a/apps/api/test/auth.test.ts
+++ b/apps/api/test/auth.test.ts
@@ -1,20 +1,13 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
-import type { Principal } from '../src/lib/auth';
-import { AuthError, effectivePermissions, requireCapability } from '../src/lib/auth';
-
-function principal(overrides: Partial = {}): Principal {
- return {
- userId: '00000000-0000-0000-0000-000000000001',
- email: 'seller@example.com',
- name: 'Seller',
- isPlatformAdmin: false,
- teams: [{ team: 'demand', role: 'member' }],
- via: 'jwt',
- scopes: ['read', 'write'],
- ...overrides,
- };
-}
+import {
+ AuthError,
+ effectivePermissions,
+ requireAnyTeamCapability,
+ requireCapability,
+ requireReadCapability,
+} from '../src/lib/auth';
+import { onTeam, principal } from './helpers/principal';
describe('capability enforcement', () => {
it('rejects a role grant from the wrong team', () => {
@@ -24,13 +17,78 @@ describe('capability enforcement', () => {
);
});
- it('removes write grants from a read-only API key', () => {
+ it('removes write grants from a read-only API key but keeps its reads', () => {
const readOnly = principal({ via: 'api_key', scopes: ['read'] });
- assert.deepEqual(effectivePermissions(readOnly), []);
+ // The point of a read-only key. Before read capabilities existed this
+ // resolved to nothing at all, which was right then and would now tell the
+ // front end that a reader may not read.
+ assert.deepEqual(
+ effectivePermissions(readOnly).map((grant) => grant.capability),
+ ['book:read', 'economics:read', 'team:read'],
+ );
assert.throws(
() => requireCapability(readOnly, 'deal:write', 'demand'),
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_scope',
);
});
+
+ it('grants no reads to a write-only credential', () => {
+ const writeOnly = principal({ via: 'api_key', scopes: ['write'] });
+
+ assert.throws(
+ () => requireReadCapability(writeOnly, 'economics:read'),
+ (error: unknown) => error instanceof AuthError && error.code === 'insufficient_scope',
+ );
+ });
+
+ it('keeps supplier economics away from research, whatever their rank', () => {
+ const researchAdmin = principal(onTeam('research', 'admin'));
+
+ requireReadCapability(researchAdmin, 'book:read');
+ assert.throws(
+ () => requireReadCapability(researchAdmin, 'economics:read'),
+ (error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
+ );
+ });
+
+ it('gives a viewer the book and nothing that writes to it', () => {
+ const viewer = principal(onTeam('demand', 'viewer'));
+
+ requireReadCapability(viewer, 'book:read');
+ requireReadCapability(viewer, 'team:read');
+ for (const capability of ['deal:write', 'activity:write'] as const) {
+ assert.throws(
+ () => requireCapability(viewer, capability, 'demand'),
+ (error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
+ `viewer should not hold ${capability}`,
+ );
+ }
+ });
+
+ /**
+ * The bug this pins: `requireCapability(p, 'data:import')` with no team
+ * passed if the principal held it anywhere, so a research-team admin could
+ * rewrite the demand pipeline. The overload no longer accepts a team-scoped
+ * capability without a team; the any-team question has to be asked by name.
+ */
+ it('separates "holds it here" from "holds it somewhere"', () => {
+ const researchAdmin = principal(onTeam('research', 'admin'));
+
+ requireAnyTeamCapability(researchAdmin, 'data:import');
+ assert.throws(
+ () => requireCapability(researchAdmin, 'data:import', 'demand'),
+ (error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
+ );
+ });
+
+ it('will not let fact review borrow bulk-import authority', () => {
+ const demandAdmin = principal(onTeam('demand', 'admin'));
+
+ requireCapability(demandAdmin, 'data:import', 'demand');
+ assert.throws(
+ () => requireAnyTeamCapability(demandAdmin, 'fact:review'),
+ (error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
+ );
+ });
});
diff --git a/apps/api/test/calendar.test.ts b/apps/api/test/calendar.test.ts
new file mode 100644
index 0000000..fa896a3
--- /dev/null
+++ b/apps/api/test/calendar.test.ts
@@ -0,0 +1,323 @@
+/**
+ * Tests for the calendar boundary.
+ *
+ * The projection itself is exercised against a real Postgres by the seeded
+ * demo book; what is pinned here are the decisions that would otherwise fail
+ * silently — a mistyped `kinds` filter that looks like a quiet quarter, an
+ * authorization gate that mistakes authentication for permission, and the
+ * relationship checks that the nullable foreign keys cannot enforce
+ * themselves.
+ */
+import { strict as assert } from 'node:assert';
+import { describe, it } from 'node:test';
+import type { Database } from '@pig/db';
+import { AuthError, type Principal } from '../src/lib/auth';
+import { MutationError, executeMutation } from '../src/lib/mutation';
+import {
+ calendarReadAllowed,
+ createEntryMutationDefinition,
+ deleteEntryMutationDefinition,
+ entriesQuerySchema,
+ parseKinds,
+ querySchema,
+ requireCalendarWrite,
+} from '../src/routes/calendar';
+
+function principal(overrides: Partial = {}): Principal {
+ return {
+ userId: '10000000-0000-4000-8000-000000000001',
+ email: 'seller@example.com',
+ name: 'Seller',
+ isPlatformAdmin: false,
+ teams: [{ team: 'demand', role: 'member' }],
+ via: 'jwt',
+ scopes: ['read', 'write'],
+ ...overrides,
+ };
+}
+
+describe('calendar read boundary', () => {
+ it('requires an explicit read scope rather than treating a token as permission', () => {
+ assert.equal(calendarReadAllowed([]), false);
+ assert.equal(calendarReadAllowed(['write']), false);
+ assert.equal(calendarReadAllowed(['read']), true);
+ });
+});
+
+describe('calendar write boundary', () => {
+ it('accepts either pipeline, because a dated item belongs to whoever runs the motion', () => {
+ assert.doesNotThrow(() =>
+ requireCalendarWrite(principal({ teams: [{ team: 'demand', role: 'member' }] })),
+ );
+ assert.doesNotThrow(() =>
+ requireCalendarWrite(principal({ teams: [{ team: 'supply', role: 'member' }] })),
+ );
+ });
+
+ it('refuses a read-only credential even when its owner has the role', () => {
+ // The credential's scope caps the person's authority; an agent key issued
+ // for reading must not be able to write because a human somewhere may.
+ assert.throws(
+ () => requireCalendarWrite(principal({ scopes: ['read'] })),
+ (error: unknown) =>
+ error instanceof AuthError && error.code === 'insufficient_scope',
+ );
+ });
+
+ it('refuses a member of neither pipeline', () => {
+ assert.throws(
+ () => requireCalendarWrite(principal({ teams: [{ team: 'research', role: 'admin' }] })),
+ (error: unknown) =>
+ error instanceof AuthError && error.code === 'insufficient_permission',
+ );
+ });
+});
+
+describe('kinds filter', () => {
+ it('rejects an unknown kind rather than returning nothing', () => {
+ // A typo that silently filters everything out is indistinguishable from a
+ // genuinely empty quarter, which is the worst possible failure for a view
+ // whose whole job is to show what is coming.
+ assert.throws(
+ () => parseKinds('renewal'),
+ (error: unknown) => error instanceof MutationError && error.code === 'invalid_kinds',
+ );
+ assert.throws(() => parseKinds('obligation_due,expected_clos'), MutationError);
+ });
+
+ it('treats absent and empty as no filter at all', () => {
+ assert.equal(parseKinds(undefined), undefined);
+ assert.equal(parseKinds(''), undefined);
+ assert.equal(parseKinds(' , '), undefined);
+ });
+
+ it('accepts a spaced list of known kinds', () => {
+ assert.deepEqual(parseKinds('obligation_due, renewal_notice'), [
+ 'obligation_due',
+ 'renewal_notice',
+ ]);
+ });
+});
+
+describe('calendar query validation', () => {
+ it('rejects a malformed account id on both reads, not just one', () => {
+ // Fed straight into `eq()` on a uuid column, `not-a-uuid` came back as a
+ // 500 from Postgres 22P02. The two endpoints take the identical parameter
+ // and must answer it identically.
+ assert.equal(querySchema.safeParse({ accountId: 'not-a-uuid' }).success, false);
+ assert.equal(entriesQuerySchema.safeParse({ accountId: 'not-a-uuid' }).success, false);
+ assert.equal(
+ entriesQuerySchema.safeParse({ accountId: '30000000-0000-4000-8000-000000000003' })
+ .success,
+ true,
+ );
+ assert.equal(entriesQuerySchema.safeParse({}).success, true);
+ });
+
+ it('rejects a time zone the runtime cannot use rather than silently answering in UTC', () => {
+ // The cache in @pig/core is keyed on this string, so an unvalidated one is
+ // both a wrong answer and a way to make a long-lived process grow.
+ assert.equal(querySchema.safeParse({ timezone: 'Mars/Olympus' }).success, false);
+ assert.equal(querySchema.safeParse({ timezone: 'Europe/London' }).success, true);
+ });
+});
+
+/** A transaction stub that records the order of writes, as in capacity-writes. */
+function recordingDb(rows: {
+ select?: unknown[];
+ insertReturns?: unknown[];
+ deleteReturns?: unknown[];
+}) {
+ const events: string[] = [];
+ const tx = {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ limit: async () => {
+ events.push('select');
+ return rows.select ?? [];
+ },
+ }),
+ }),
+ }),
+ insert: () => ({
+ // A thenable rather than a promise: the audit write is awaited directly
+ // while the entity write goes through `.returning()`, and constructing a
+ // real promise here would record the audit write that never happened.
+ values: (values: Record) => {
+ const record = () => events.push('subject' in values ? 'activity' : 'insert');
+ return {
+ then: (resolve: (value: unknown) => unknown) => {
+ record();
+ return Promise.resolve().then(() => resolve(undefined));
+ },
+ returning: async () => {
+ record();
+ return rows.insertReturns ?? [];
+ },
+ };
+ },
+ }),
+ delete: () => ({
+ where: () => ({
+ returning: async () => {
+ events.push('delete');
+ return rows.deleteReturns ?? [];
+ },
+ }),
+ }),
+ };
+ const db = {
+ transaction: async (work: (transaction: unknown) => Promise) => {
+ events.push('begin');
+ const result = await work(tx);
+ events.push('commit');
+ return result;
+ },
+ } as unknown as Database;
+ return { db, events };
+}
+
+describe('calendar entry mutation', () => {
+ const entry = {
+ id: '20000000-0000-4000-8000-000000000002',
+ title: 'Q business review',
+ kind: 'qbr' as const,
+ accountId: null,
+ demandDealId: null,
+ supplyDealId: null,
+ startsAt: new Date('2026-09-03T14:00:00.000Z'),
+ endsAt: new Date('2026-09-03T15:30:00.000Z'),
+ completedAt: null,
+ };
+
+ it('writes the entry and its audit event inside one transaction', async () => {
+ const { db, events } = recordingDb({ insertReturns: [entry] });
+ const created = await executeMutation(
+ db,
+ principal(),
+ async () => ({
+ title: 'Q business review',
+ kind: 'qbr',
+ startsAt: '2026-09-03T14:00:00.000Z',
+ endsAt: '2026-09-03T15:30:00.000Z',
+ }),
+ createEntryMutationDefinition(),
+ );
+ assert.equal(created.id, entry.id);
+ assert.deepEqual(events, ['begin', 'insert', 'activity', 'commit']);
+ });
+
+ it('defaults the owner to the author, because unassigned work is work nobody does', async () => {
+ let written: Record | undefined;
+ const capturing = {
+ transaction: async (work: (transaction: unknown) => Promise) =>
+ work({
+ insert: () => ({
+ values: (values: Record) => {
+ written ??= values;
+ return Object.assign(Promise.resolve(), {
+ returning: async () => [entry],
+ });
+ },
+ }),
+ }),
+ } as unknown as Database;
+ await executeMutation(
+ capturing,
+ principal(),
+ async () => ({ title: 'Reminder', startsAt: '2026-09-03T14:00:00.000Z' }),
+ createEntryMutationDefinition(),
+ );
+ assert.equal(written?.ownerUserId, '10000000-0000-4000-8000-000000000001');
+ assert.equal(written?.createdByUserId, '10000000-0000-4000-8000-000000000001');
+ });
+
+ it('refuses a window that ends before it starts', async () => {
+ const { db } = recordingDb({ insertReturns: [entry] });
+ await assert.rejects(
+ executeMutation(
+ db,
+ principal(),
+ async () => ({
+ title: 'Backwards',
+ startsAt: '2026-09-03T16:00:00.000Z',
+ endsAt: '2026-09-03T14:00:00.000Z',
+ }),
+ createEntryMutationDefinition(),
+ ),
+ (error: unknown) => error instanceof MutationError && error.code === 'invalid_window',
+ );
+ });
+
+ it('refuses a deal that belongs to a different account', async () => {
+ // Nothing in the schema can catch this: both columns are independently
+ // nullable foreign keys, so the disagreement is only visible here.
+ const { db } = recordingDb({
+ select: [{ accountId: '90000000-0000-4000-8000-000000000009' }],
+ });
+ await assert.rejects(
+ executeMutation(
+ db,
+ principal(),
+ async () => ({
+ title: 'Mismatch',
+ startsAt: '2026-09-03T14:00:00.000Z',
+ accountId: '30000000-0000-4000-8000-000000000003',
+ demandDealId: '40000000-0000-4000-8000-000000000004',
+ }),
+ createEntryMutationDefinition(),
+ ),
+ (error: unknown) =>
+ error instanceof MutationError && error.code === 'relationship_mismatch',
+ );
+ });
+
+ it('reports a stale owner as 404, the way every other reference here does', async () => {
+ // The column is a foreign key with no check in front of it, so assigning
+ // to a user who has been removed produced a 500 from the constraint. It is
+ // an ordinary client mistake and deserves the ordinary answer.
+ const { db } = recordingDb({ select: [], insertReturns: [entry] });
+ await assert.rejects(
+ executeMutation(
+ db,
+ principal(),
+ async () => ({
+ title: 'Handover',
+ startsAt: '2026-09-03T14:00:00.000Z',
+ ownerUserId: '50000000-0000-4000-8000-000000000005',
+ }),
+ createEntryMutationDefinition(),
+ ),
+ (error: unknown) => error instanceof MutationError && error.status === 404,
+ );
+ });
+
+ it('does not re-read the author when it defaults the owner to them', async () => {
+ // The request already proved that user exists; a lookup per create to
+ // confirm it would be a query bought with nothing.
+ const { db, events } = recordingDb({ insertReturns: [entry] });
+ await executeMutation(
+ db,
+ principal(),
+ async () => ({ title: 'Reminder', startsAt: '2026-09-03T14:00:00.000Z' }),
+ createEntryMutationDefinition(),
+ );
+ assert.deepEqual(events, ['begin', 'insert', 'activity', 'commit']);
+ });
+
+ it('reports a missing entry as 404 rather than a silent no-op delete', async () => {
+ const { db } = recordingDb({ deleteReturns: [] });
+ await assert.rejects(
+ executeMutation(
+ db,
+ principal(),
+ async () => ({}),
+ deleteEntryMutationDefinition(),
+ { id: '20000000-0000-4000-8000-000000000002' },
+ ),
+ (error: unknown) =>
+ error instanceof MutationError && error.status === 404,
+ );
+ });
+});
diff --git a/apps/api/test/growth.test.ts b/apps/api/test/growth.test.ts
index bd10386..18774c7 100644
--- a/apps/api/test/growth.test.ts
+++ b/apps/api/test/growth.test.ts
@@ -1,12 +1,24 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
-import { growthReadAllowed } from '../src/routes/growth';
+import { READ_RULES } from '../src/routes/read-guards';
+/**
+ * This file used to test `growthReadAllowed`, a scope predicate local to
+ * growth.ts. The predicate is gone and the boundary it guarded is now one row
+ * in the read table, so what is worth pinning is that growth did not quietly
+ * lose its guard in the move — a deletion that would leave the endpoint open
+ * and every test still green.
+ */
describe('growth read boundary', () => {
- it('requires an explicit read scope instead of treating authentication as authorization', () => {
- assert.equal(growthReadAllowed([]), false);
- assert.equal(growthReadAllowed(['write']), false);
- assert.equal(growthReadAllowed(['read']), true);
- assert.equal(growthReadAllowed(['read', 'write']), true);
+ it('is still governed after moving from a local scope check to the table', () => {
+ const governed = READ_RULES.filter((rule) => rule.path.startsWith('/api/growth'));
+
+ assert.deepEqual(
+ governed.map((rule) => `${rule.method} ${rule.path} ${rule.capability}`),
+ [
+ 'GET /api/growth book:read',
+ 'GET /api/growth/accounts/:id book:read',
+ ],
+ );
});
});
diff --git a/apps/api/test/helpers/principal.ts b/apps/api/test/helpers/principal.ts
new file mode 100644
index 0000000..e4025c4
--- /dev/null
+++ b/apps/api/test/helpers/principal.ts
@@ -0,0 +1,105 @@
+/**
+ * Shared test fixtures for authorisation.
+ *
+ * Before this, `Principal` was re-declared as a literal in auth.test.ts,
+ * records.test.ts, mutation.test.ts and half a dozen others — ten copies of the
+ * same nine fields. Adding a field to `Principal` meant editing every one of
+ * them, and the copies had already drifted on `scopes`, which is precisely the
+ * field the read/write split now turns on. One factory, overridden per case.
+ */
+import type { Team, TeamRole } from '@pig/core';
+import type { Database } from '@pig/db';
+import type { Principal } from '../../src/lib/auth';
+
+/**
+ * A demand-team member with a full-scope session: the ordinary user, chosen as
+ * the default because it is the case most tests want to vary *away* from.
+ */
+export function principal(overrides: Partial = {}): Principal {
+ return {
+ userId: '00000000-0000-4000-8000-000000000001',
+ email: 'seller@example.com',
+ name: 'Seller',
+ isPlatformAdmin: false,
+ teams: [{ team: 'demand', role: 'member' }],
+ via: 'jwt',
+ scopes: ['read', 'write'],
+ ...overrides,
+ };
+}
+
+/** One membership, spelled out — the common override, and easy to get wrong. */
+export function onTeam(team: Team, role: TeamRole): Partial {
+ return { teams: [{ team, role }] };
+}
+
+export interface FakeDatabaseOptions {
+ /** Appended to in call order, so a test can assert what ran and in what order. */
+ events?: string[];
+ /** Rows handed to `insert().values()`, chiefly the audit activity. */
+ inserted?: unknown[];
+ /** Rows a `select()` chain resolves to. Defaults to empty. */
+ selected?: unknown[];
+}
+
+/**
+ * The minimum Drizzle surface `executeMutation` touches: a transaction, an
+ * insert that records its row, and a select chain that resolves to fixed rows.
+ * Deliberately not a database — a test that needs real SQL semantics needs a
+ * real Postgres, and pretending otherwise is how a fake starts asserting that
+ * broken queries work.
+ */
+export function fakeDatabase(options: FakeDatabaseOptions = {}): Database {
+ const events = options.events ?? [];
+ const inserted = options.inserted ?? [];
+ const selected = options.selected ?? [];
+
+ const selectChain = {
+ from: () => selectChain,
+ leftJoin: () => selectChain,
+ innerJoin: () => selectChain,
+ where: () => selectChain,
+ orderBy: () => selectChain,
+ limit: async () => selected,
+ then: (resolve: (rows: unknown[]) => unknown) => resolve(selected),
+ };
+
+ const insertChain = {
+ values: (row: unknown) => {
+ events.push('insert');
+ inserted.push(row);
+ return {
+ onConflictDoNothing: () => ({ returning: async () => [row] }),
+ returning: async () => [row],
+ then: (resolve: (value: unknown) => unknown) => resolve(undefined),
+ };
+ },
+ };
+
+ const updateChain = {
+ set: () => ({
+ where: async () => {
+ events.push('update');
+ },
+ }),
+ };
+
+ const tx = {
+ select: () => {
+ events.push('select');
+ return selectChain;
+ },
+ insert: () => insertChain,
+ update: () => updateChain,
+ };
+
+ return {
+ transaction: async (work: (transaction: unknown) => Promise) => {
+ events.push('transaction');
+ return work(tx);
+ },
+ select: tx.select,
+ insert: tx.insert,
+ update: tx.update,
+ } as unknown as Database;
+}
diff --git a/apps/api/test/http-auth.test.ts b/apps/api/test/http-auth.test.ts
new file mode 100644
index 0000000..38d6333
--- /dev/null
+++ b/apps/api/test/http-auth.test.ts
@@ -0,0 +1,231 @@
+/**
+ * The first tests that go through `createApp()`.
+ *
+ * Every other test in this directory calls a mutation definition, or a helper,
+ * directly. That checks the rule and skips the wiring — and the wiring is where
+ * this codebase has actually been wrong: a guard mounted after its handler
+ * never runs, an AuthError thrown inside a mounted sub-app has to reach the
+ * parent's `onError` to become a 403 rather than a 500, and a route added to
+ * the public allowlist by mistake is invisible to a unit test. `grep createApp
+ * apps/api/test` used to return nothing.
+ *
+ * So these assert on status codes and error envelopes over real HTTP, and
+ * nothing else. They are deliberately cheap: no Postgres, a fake that answers
+ * only the handful of queries authentication and the read guard reach.
+ */
+import { strict as assert } from 'node:assert';
+import { describe, it } from 'node:test';
+import type { Team, TeamRole } from '@pig/core';
+import type { Database } from '@pig/db';
+import { apiKeys, teamMemberships, users } from '@pig/db';
+import { createApp } from '../src/app';
+import type { AuthProvider } from '../src/lib/auth-provider';
+import { hashApiKey } from '../src/lib/auth';
+import { loadConfig, type Config } from '../src/lib/config';
+
+const USER_ID = '00000000-0000-4000-8000-0000000000aa';
+const SUBJECT = 'auth-subject-1';
+const API_KEY = 'pig_test_key_value';
+
+interface Fixture {
+ /** Absent means a verified token with no PIG profile — the `needs_profile` case. */
+ user?: { id: string; email: string; name: string; authSubject: string; deactivatedAt: Date | null; isPlatformAdmin: boolean };
+ memberships?: { team: Team; role: TeamRole }[];
+ apiKey?: { scopes: string[] };
+}
+
+/**
+ * Answers by table identity rather than by call order, because the order in
+ * which `loadPrincipal` and a handler query is an implementation detail and a
+ * fake that depends on it fails for the wrong reason later.
+ */
+function fixtureDatabase(fixture: Fixture): Database {
+ const userRows = fixture.user ? [fixture.user] : [];
+ const membershipRows = fixture.memberships ?? [];
+ const keyRows = fixture.apiKey
+ ? [{
+ id: 'key-1',
+ userId: USER_ID,
+ keyHash: hashApiKey(API_KEY),
+ scopes: fixture.apiKey.scopes,
+ revokedAt: null,
+ expiresAt: null,
+ }]
+ : [];
+
+ function rowsFor(table: unknown): unknown[] {
+ if (table === users) return userRows;
+ if (table === teamMemberships) return membershipRows;
+ if (table === apiKeys) return keyRows;
+ return [];
+ }
+
+ function chain(rows: unknown[]) {
+ const self: Record = {
+ leftJoin: () => self,
+ innerJoin: () => self,
+ where: () => self,
+ orderBy: () => self,
+ limit: async () => rows,
+ then: (resolve: (value: unknown[]) => unknown) => resolve(rows),
+ };
+ return self;
+ }
+
+ return {
+ select: () => ({
+ from: (table: unknown) => {
+ // `/api/team` joins users to memberships and expects the flattened
+ // shape, which the users fixture already carries enough of.
+ if (table === users) {
+ return chain(userRows.map((row) => ({ ...row, team: membershipRows[0]?.team ?? null, role: membershipRows[0]?.role ?? null })));
+ }
+ return chain(rowsFor(table));
+ },
+ }),
+ update: () => ({ set: () => ({ where: async () => undefined }) }),
+ transaction: async (work: (tx: unknown) => Promise) => work({}),
+ } as unknown as Database;
+}
+
+const provider: AuthProvider = {
+ name: 'test',
+ async verifyAccessToken(token: string) {
+ if (token !== 'good-token') throw new Error('bad token');
+ return { subject: SUBJECT, email: 'seller@example.com' };
+ },
+};
+
+function config(): Config {
+ // A real `loadConfig`, not a literal: the production guards live in it, and a
+ // hand-rolled Config object would let this suite pass under a configuration
+ // the server would refuse to start on.
+ return loadConfig({
+ NODE_ENV: 'test',
+ DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig-not-connected',
+ PIG_PUBLIC_URL: 'http://localhost:8920',
+ PIG_ADMIN_EMAILS: '',
+ } as NodeJS.ProcessEnv);
+}
+
+function member(team: Team, role: TeamRole): Fixture {
+ return {
+ user: {
+ id: USER_ID,
+ email: 'seller@example.com',
+ name: 'Seller',
+ authSubject: SUBJECT,
+ deactivatedAt: null,
+ isPlatformAdmin: false,
+ },
+ memberships: [{ team, role }],
+ };
+}
+
+function request(fixture: Fixture, path: string, init: RequestInit = {}) {
+ return createApp(config(), fixtureDatabase(fixture), provider).request(path, init);
+}
+
+const bearer = (token: string) => ({ headers: { authorization: `Bearer ${token}` } });
+
+async function envelope(response: Response) {
+ return (await response.json()) as { code?: string; error?: string };
+}
+
+describe('authentication over HTTP', () => {
+ it('answers 401 no_token when nothing is presented', async () => {
+ const response = await request(member('demand', 'member'), '/api/dashboard');
+
+ assert.equal(response.status, 401);
+ assert.equal((await envelope(response)).code, 'no_token');
+ });
+
+ it('answers 401 invalid_token without saying which knob to turn', async () => {
+ const response = await request(member('demand', 'member'), '/api/dashboard', bearer('rubbish'));
+
+ assert.equal(response.status, 401);
+ assert.equal((await envelope(response)).code, 'invalid_token');
+ });
+
+ /**
+ * The distinction the whole auth file exists for: the identity provider is
+ * shared with another application, so a verified token proves an account
+ * somewhere, not membership here.
+ */
+ it('answers 403 needs_profile for a verified token with no PIG user', async () => {
+ const response = await request({}, '/api/team', bearer('good-token'));
+
+ assert.equal(response.status, 403);
+ assert.equal((await envelope(response)).code, 'needs_profile');
+ });
+
+ it('answers 403 deactivated rather than pretending the account is unknown', async () => {
+ const fixture = member('demand', 'member');
+ fixture.user!.deactivatedAt = new Date('2026-01-01T00:00:00Z');
+
+ const response = await request(fixture, '/api/team', bearer('good-token'));
+
+ assert.equal(response.status, 403);
+ assert.equal((await envelope(response)).code, 'deactivated');
+ });
+
+ it('leaves health and config reachable without a token', async () => {
+ for (const path of ['/api/health', '/api/config']) {
+ const response = await request({}, path);
+ assert.equal(response.status, 200, path);
+ }
+ });
+});
+
+describe('credential scope over HTTP', () => {
+ it('refuses a write from a read-only API key', async () => {
+ const fixture = { ...member('demand', 'admin'), apiKey: { scopes: ['read'] } };
+
+ const response = await request(fixture, '/api/contracts', {
+ method: 'POST',
+ headers: { authorization: `Bearer ${API_KEY}`, 'content-type': 'application/json' },
+ body: JSON.stringify({ accountId: USER_ID, type: 'msa', side: 'demand', title: 'MSA' }),
+ });
+
+ // Scope, not permission: this person IS a demand admin. The credential
+ // they are acting through is what lacks the authority, and saying so is
+ // the difference between "ask your administrator" and "use another key".
+ assert.equal(response.status, 403);
+ assert.equal((await envelope(response)).code, 'insufficient_scope');
+ });
+
+ it('admits a read from the same read-only key', async () => {
+ const fixture = { ...member('demand', 'admin'), apiKey: { scopes: ['read'] } };
+
+ const response = await request(fixture, '/api/me', {
+ headers: { authorization: `Bearer ${API_KEY}` },
+ });
+
+ assert.equal(response.status, 200);
+ });
+});
+
+describe('capability over HTTP', () => {
+ it('refuses a write from a viewer', async () => {
+ const response = await request(member('demand', 'viewer'), '/api/contracts', {
+ method: 'POST',
+ headers: { authorization: 'Bearer good-token', 'content-type': 'application/json' },
+ body: JSON.stringify({ accountId: USER_ID, type: 'msa', side: 'demand', title: 'MSA' }),
+ });
+
+ assert.equal(response.status, 403);
+ assert.equal((await envelope(response)).code, 'insufficient_permission');
+ });
+
+ it('reports a viewer\'s grants on /api/me as reads only', async () => {
+ const response = await request(member('demand', 'viewer'), '/api/me', bearer('good-token'));
+ const body = (await response.json()) as { permissions: { capability: string }[] };
+
+ assert.equal(response.status, 200);
+ assert.deepEqual(
+ body.permissions.map((grant) => grant.capability),
+ ['book:read', 'team:read'],
+ );
+ });
+
+});
diff --git a/apps/api/test/learn.test.ts b/apps/api/test/learn.test.ts
new file mode 100644
index 0000000..9007b95
--- /dev/null
+++ b/apps/api/test/learn.test.ts
@@ -0,0 +1,280 @@
+/**
+ * Tests for the Learn boundary.
+ *
+ * The one that matters is `learn token is not a credential for anything else`.
+ * Every other assertion here is supporting evidence for it: the design's whole
+ * claim is that a code-holder cannot become a principal, and the way that
+ * claim fails in practice is not a dramatic bug — it is somebody later
+ * deciding it would be simpler to mint a `Principal` with an empty team list
+ * and rely on capability checks downstream. That refactor passes every test
+ * about learn resources and fails this one.
+ *
+ * The rest pin decisions that would otherwise fail silently: an embed resolver
+ * that accepts a hostile host, a PATCH that promotes a supply video to
+ * anon-visible because it validated the input instead of the merged row, and
+ * a rate limiter whose window never closes.
+ */
+import { strict as assert } from 'node:assert';
+import { describe, it } from 'node:test';
+import {
+ LEARN_CODE_TRACK,
+ LEARN_FRAME_SRC_HOSTS,
+ formatLearnDuration,
+ learnEmbedUrl,
+ learnVisibilityPermitted,
+ resolveLearnEmbed,
+} from '@pig/core';
+import type { Database } from '@pig/db';
+import { createApp } from '../src/app';
+import { loadConfig } from '../src/lib/config';
+import {
+ LEARN_TOKEN_TTL_MS,
+ createAttemptLimiter,
+ learnResourceCreateSchema,
+ mintLearnToken,
+ rateLimitKey,
+ verifyLearnToken,
+} from '../src/routes/learn';
+
+const ACCESS_CODE = 'carlthefog';
+
+// ---------------------------------------------------------------- the embed
+
+describe('embed allowlist', () => {
+ it('resolves a Cap share link to an embed rebuilt from the table', () => {
+ const resolved = resolveLearnEmbed('https://video.karti.ai/s/0n6n9p83efnxbs2');
+ assert.equal(resolved.ok, true);
+ assert.equal(resolved.ok && resolved.provider, 'cap');
+ assert.equal(resolved.ok && resolved.externalId, '0n6n9p83efnxbs2');
+ assert.equal(resolved.ok && resolved.embedUrl, 'https://video.karti.ai/embed/0n6n9p83efnxbs2');
+ });
+
+ it('accepts an embed link too, because that is what people copy', () => {
+ const resolved = resolveLearnEmbed('https://video.karti.ai/embed/0n6n9p83efnxbs2');
+ assert.equal(resolved.ok && resolved.watchUrl, 'https://video.karti.ai/s/0n6n9p83efnxbs2');
+ });
+
+ it('refuses every shape that would put someone else’s bytes in an iframe src', () => {
+ // Each of these is a real technique, not a hypothetical. The suffix case
+ // is why `hosts` is an exact-match list rather than an `endsWith` check,
+ // and the credential case is why a URL that READS as trusted to a human is
+ // rejected on the parsed hostname instead.
+ const hostile = [
+ 'javascript:alert(1)',
+ 'data:text/html,',
+ 'http://video.karti.ai/s/0n6n9p83efnxbs2',
+ 'https://video.karti.ai@evil.example/s/0n6n9p83efnxbs2',
+ 'https://evil-video.karti.ai.attacker.test/s/0n6n9p83efnxbs2',
+ 'https://notvideo.karti.ai/s/0n6n9p83efnxbs2',
+ 'https://video.karti.ai:8443/s/0n6n9p83efnxbs2',
+ 'https://video.karti.ai/s/../../admin',
+ 'https://video.karti.ai/s/0n6n9p83efnxbs2/edit',
+ 'https://video.karti.ai/s/">',
+ 'https://video.karti.ai/',
+ 'not a url at all',
+ ];
+ for (const candidate of hostile) {
+ assert.equal(resolveLearnEmbed(candidate).ok, false, `should reject: ${candidate}`);
+ }
+ });
+
+ it('refuses a recognised but not-yet-enabled provider rather than framing it', () => {
+ // Loom is in the table so that enabling it is a flag and a CSP host. Until
+ // the CSP host exists, a Loom row would be a card that silently never
+ // plays — so the row cannot be created at all.
+ const resolved = resolveLearnEmbed('https://www.loom.com/share/0123456789abcdef');
+ assert.equal(resolved.ok, false);
+ assert.equal(resolved.ok === false && resolved.reason, 'provider_disabled');
+ });
+
+ it('re-validates a stored id rather than trusting the database', () => {
+ // A row written before the pattern tightened, or by a path that skipped
+ // the resolver, must not be framed on the strength of having persisted.
+ assert.equal(learnEmbedUrl('cap', '">