From 13dec6b4b8664c7a92d7becd6556b7533d0aab31 Mon Sep 17 00:00:00 2001 From: Kartios Date: Thu, 13 Aug 2026 15:02:48 -0700 Subject: [PATCH] Rebuild the shell, add Calendar and Learn, and govern reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven parallel agents and an adversarial verification pass. The three things worth knowing before reading the diff: RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago. So this does not rebuild them; it closes the gaps an audit found. The big one is that reads were entirely ungoverned: every GET was "any authenticated member", so a junior demand rep and a research contractor could both pull per-block supplier cost and break-even prices from /api/capacity/margin, and every contract's negotiated terms. For a company whose margin is the business, that was the hole that mattered. Adds book:read / economics:read / team:read, a readGuard middleware, and a `viewer` role below member. THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen. Contracts.tsx never called can() at all, so its save button was always enabled against a server requiring contract:sign; Capacity.tsx gated commitment creation on deal:write/demand while the server wanted commitment:write/supply. POST /api/activities was the one write bypassing executeMutation: no capability check, and any member could mutate accounts.lastActivityAt as a side effect. It is now a proper mutation() behind activity:write. The shell becomes three panes — a collapsible shadcn sidebar with an account switcher on the Piggy accent, a header with real search, and Piggy docked to the right, page-aware and persistent across navigation. The phone keeps its bottom tab bar, which is the thing this product already beat trycompai/crm on, and gains the sidebar as a sheet. Calendar is a projection over thirteen dated sources rather than a new table, because a table would duplicate dates that already live on contracts, deals and commitments and would drift — and one ledger answering the question is the whole argument. It surfaces export_authorizations and compliance_artifacts, which had indexed expires_at columns, schema comments saying they must be alerted on, and no read endpoint or UI anywhere. Learn carries two tracks. Concepts are members-only; the platform track can be opened with a share code by someone with no account. The code mints a scoped learn-only token and never a Principal — every route here resolves a principal and then checks capabilities, so a principal-minting code would be one missing check away from leaking the book. "Only platform-track rows may be code-visible" is a database CHECK constraint as well as a write-path rule, and a test asserts a valid learn token still gets 401 on /api/dashboard, /api/accounts and /api/contracts — the same invariant scripts/deploy.sh refuses to ship without. CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a release-* tag and cloud-2 pulls it, so no credential on the shared runner can execute anything on production — by construction rather than by policy. Both halves of deploy.sh's original rule survive: nothing on the runner reaches the host, and a human still decides when it ships. deploy.sh gains a rollback and a public-origin check, and PIG_IMAGE now reaches compose through `sudo env`, without which sudo's env_reset silently resolved every release to pig:local. Tests 141 -> 261. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 44 + .gitea/workflows/ci.yml | 90 + AGENTS.md | 72 +- README.md | 535 +- apps/api/src/app.ts | 84 +- apps/api/src/lib/auth.ts | 77 +- apps/api/src/lib/mutation.ts | 38 +- apps/api/src/lib/read-guard.ts | 30 + apps/api/src/routes/activities.ts | 136 + apps/api/src/routes/calendar.ts | 494 ++ apps/api/src/routes/facts.ts | 6 +- apps/api/src/routes/google-sheets.ts | 25 +- apps/api/src/routes/growth.ts | 29 +- apps/api/src/routes/hubspot.ts | 7 +- apps/api/src/routes/imports.ts | 58 +- apps/api/src/routes/learn.ts | 782 ++ apps/api/src/routes/notion-import.ts | 16 +- apps/api/src/routes/piggy-chat.ts | 76 +- apps/api/src/routes/read-guards.ts | 60 + apps/api/src/services/calendar.ts | 974 +++ apps/api/test/activities.test.ts | 167 + apps/api/test/admin-settings.test.ts | 5 + apps/api/test/auth.test.ts | 92 +- apps/api/test/calendar.test.ts | 323 + apps/api/test/growth.test.ts | 24 +- apps/api/test/helpers/principal.ts | 105 + apps/api/test/http-auth.test.ts | 231 + apps/api/test/learn.test.ts | 280 + apps/api/test/mutation.test.ts | 40 +- apps/api/test/piggy-chat.test.ts | 213 +- apps/api/test/read-governance.test.ts | 197 + apps/api/test/records.test.ts | 12 +- apps/piggy/e2e/page-tools.test.ts | 408 + apps/piggy/package.json | 4 +- apps/piggy/src/chat-server.ts | 44 +- apps/piggy/src/chat-tools.ts | 53 +- apps/piggy/src/chat.ts | 34 +- apps/piggy/src/page-routes.ts | 61 + apps/piggy/src/page-tools.ts | 650 ++ apps/piggy/test/chat-tools.test.ts | 105 + apps/piggy/test/chat.test.ts | 35 + apps/piggy/tsconfig.json | 2 +- apps/web/src/App.tsx | 50 +- apps/web/src/components/AccountSwitcher.tsx | 159 + apps/web/src/components/AppHeader.tsx | 124 + apps/web/src/components/AppSidebar.tsx | 119 + apps/web/src/components/CommandPalette.tsx | 47 +- apps/web/src/components/PiggyChat.tsx | 87 +- apps/web/src/components/PiggyDock.tsx | 144 + apps/web/src/components/Shell.tsx | 319 +- apps/web/src/components/ui/breadcrumb.tsx | 100 + apps/web/src/components/ui/button.tsx | 83 +- apps/web/src/components/ui/command.tsx | 13 +- apps/web/src/components/ui/index.tsx | 43 +- apps/web/src/components/ui/input.tsx | 33 + apps/web/src/components/ui/sidebar.tsx | 530 ++ apps/web/src/components/ui/skeleton.tsx | 7 + apps/web/src/hooks/use-media-query.ts | 46 + apps/web/src/index.css | 33 + apps/web/src/lib/identity.tsx | 67 + apps/web/src/lib/layout.tsx | 96 + apps/web/src/lib/nav.ts | 109 + apps/web/src/lib/permissions.ts | 17 + apps/web/src/lib/piggy-chat.ts | 19 +- apps/web/src/lib/piggy-context.tsx | 75 + apps/web/src/pages/Calendar.tsx | 1458 ++++ apps/web/src/pages/Capacity.tsx | 51 +- apps/web/src/pages/Contracts.tsx | 109 +- apps/web/src/pages/Learn.tsx | 851 ++ apps/web/tailwind.config.js | 16 + deploy/Caddyfile.example | 2 +- deploy/README.md | 193 +- deploy/pig-autodeploy.service | 38 + deploy/pig-autodeploy.timer | 26 + docker-compose.yml | 11 + docs/agents.md | 40 +- docs/build-plan.md | 316 +- docs/ontology.md | 9 + package.json | 2 +- packages/core/src/calendar.ts | 408 + packages/core/src/index.ts | 3 + packages/core/src/learn.ts | 307 + packages/core/src/ontology.ts | 13 +- packages/core/src/permissions.ts | 129 +- packages/core/src/piggy-context.ts | 84 + packages/core/test/calendar.test.ts | 245 + packages/core/test/permissions.test.ts | 172 +- .../db/migrations/0010_calendar_entries.sql | 26 + .../db/migrations/0011_learn_resources.sql | 29 + .../db/migrations/0012_viewer_team_role.sql | 16 + .../db/migrations/meta/0010_snapshot.json | 7349 ++++++++++++++++ .../db/migrations/meta/0011_snapshot.json | 7559 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 23 +- packages/db/src/schema/calendar.ts | 83 + packages/db/src/schema/index.ts | 3 + packages/db/src/schema/learn.ts | 117 + packages/db/src/schema/settings.ts | 15 + packages/db/src/seed/demo.ts | 401 +- packages/db/src/seed/index.ts | 17 + pnpm-lock.yaml | 3 + scripts/autodeploy.sh | 185 + scripts/deploy.sh | 204 +- 102 files changed, 28638 insertions(+), 913 deletions(-) create mode 100644 apps/api/src/lib/read-guard.ts create mode 100644 apps/api/src/routes/activities.ts create mode 100644 apps/api/src/routes/calendar.ts create mode 100644 apps/api/src/routes/learn.ts create mode 100644 apps/api/src/routes/read-guards.ts create mode 100644 apps/api/src/services/calendar.ts create mode 100644 apps/api/test/activities.test.ts create mode 100644 apps/api/test/calendar.test.ts create mode 100644 apps/api/test/helpers/principal.ts create mode 100644 apps/api/test/http-auth.test.ts create mode 100644 apps/api/test/learn.test.ts create mode 100644 apps/api/test/read-governance.test.ts create mode 100644 apps/piggy/e2e/page-tools.test.ts create mode 100644 apps/piggy/src/page-routes.ts create mode 100644 apps/piggy/src/page-tools.ts create mode 100644 apps/piggy/test/chat-tools.test.ts create mode 100644 apps/web/src/components/AccountSwitcher.tsx create mode 100644 apps/web/src/components/AppHeader.tsx create mode 100644 apps/web/src/components/AppSidebar.tsx create mode 100644 apps/web/src/components/PiggyDock.tsx create mode 100644 apps/web/src/components/ui/breadcrumb.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/sidebar.tsx create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/hooks/use-media-query.ts create mode 100644 apps/web/src/lib/identity.tsx create mode 100644 apps/web/src/lib/layout.tsx create mode 100644 apps/web/src/lib/nav.ts create mode 100644 apps/web/src/lib/piggy-context.tsx create mode 100644 apps/web/src/pages/Calendar.tsx create mode 100644 apps/web/src/pages/Learn.tsx create mode 100644 deploy/pig-autodeploy.service create mode 100644 deploy/pig-autodeploy.timer create mode 100644 packages/core/src/calendar.ts create mode 100644 packages/core/src/learn.ts create mode 100644 packages/core/src/piggy-context.ts create mode 100644 packages/core/test/calendar.test.ts create mode 100644 packages/db/migrations/0010_calendar_entries.sql create mode 100644 packages/db/migrations/0011_learn_resources.sql create mode 100644 packages/db/migrations/0012_viewer_team_role.sql create mode 100644 packages/db/migrations/meta/0010_snapshot.json create mode 100644 packages/db/migrations/meta/0011_snapshot.json create mode 100644 packages/db/src/schema/calendar.ts create mode 100644 packages/db/src/schema/learn.ts create mode 100755 scripts/autodeploy.sh 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: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./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', '">