Rebuild the shell, add Calendar and Learn, and govern reads
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 15:02:48 -07:00
parent 6cf80747cc
commit 13dec6b4b8
102 changed files with 28638 additions and 913 deletions
+211 -2
View File
@@ -1,9 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { platformSettings, teamMemberships, users, type Database } from '@pig/db';
import { createApp } from '../src/app';
import type { Principal } from '../src/lib/auth';
import { loadConfig } from '../src/lib/config';
import type { ApiEnv } from '../src/lib/mutation';
import { createPiggyChatRoutes } from '../src/routes/piggy-chat';
import {
createPiggyChatRoutes,
type PiggyChatProxyOptions,
} from '../src/routes/piggy-chat';
const principal: Principal = {
userId: '10000000-0000-4000-8000-000000000001',
@@ -15,7 +21,11 @@ const principal: Principal = {
scopes: ['read', 'write'],
};
function appFor(fetchImpl: typeof fetch, identity: Principal = principal) {
function appFor(
fetchImpl: typeof fetch,
identity: Principal = principal,
overrides: Partial<PiggyChatProxyOptions> = {},
) {
const app = new Hono<ApiEnv>();
app.use('*', async (context, next) => {
context.set('principal', identity);
@@ -28,11 +38,18 @@ function appFor(fetchImpl: typeof fetch, identity: Principal = principal) {
internalUrl: 'http://127.0.0.1:8931',
internalToken: 'internal-token-with-at-least-32-characters',
fetchImpl,
...overrides,
}),
);
return app;
}
const ndjson = () =>
new Response(`${JSON.stringify({ type: 'done', inputTokens: 1, outputTokens: 1 })}\n`, {
status: 200,
headers: { 'content-type': 'application/x-ndjson' },
});
test('the authenticated proxy forwards bounded identity and relays NDJSON unchanged', async () => {
let forwarded: Record<string, unknown> | undefined;
const fetchImpl: typeof fetch = async (input, init) => {
@@ -97,3 +114,195 @@ test('a credential without read scope never reaches the internal service', async
assert.equal(response.status, 403);
assert.equal(fetched, false);
});
test('a docked page context reaches the chat service unaltered', async () => {
let forwarded: Record<string, unknown> | undefined;
const app = appFor(async (_input, init) => {
forwarded = JSON.parse(String(init?.body)) as Record<string, unknown>;
return ndjson();
});
const response = await app.request('/api/piggy/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
message: 'What is idle?',
context: { type: 'page', route: '/capacity', label: 'Capacity' },
}),
});
assert.equal(response.status, 200);
assert.deepEqual(forwarded?.context, {
type: 'page',
route: '/capacity',
label: 'Capacity',
});
});
// A page context carries no record, so admitting one would put a nonsense
// shape in front of the model rather than failing at the boundary.
test('a page context may not smuggle a record id, and an unknown route is refused', async () => {
let fetched = false;
const app = appFor(async () => {
fetched = true;
return ndjson();
});
for (const context of [
{ type: 'page', route: '/not-a-page' },
{ type: 'page', route: '/margin', id: '20000000-0000-4000-8000-000000000002' },
{ type: 'page' },
]) {
const response = await app.request('/api/piggy/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: 'Where are we?', context }),
});
assert.equal(response.status, 400);
assert.equal(((await response.json()) as { code: string }).code, 'invalid_request');
}
assert.equal(fetched, false);
});
test('the stored admin toggle disables chat without the environment changing', async () => {
let fetched = false;
let piggyEnabled = true;
const app = appFor(
async () => {
fetched = true;
return ndjson();
},
principal,
{ resolvePiggyEnabled: async () => piggyEnabled },
);
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: true,
canUse: true,
});
piggyEnabled = false;
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: false,
canUse: false,
});
const response = await app.request('/api/piggy/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: 'Where are we?' }),
});
assert.equal(response.status, 503);
assert.equal(fetched, false);
});
// Losing the settings row must degrade to the environment gate. A dock on
// every page turns one failed query into a site-wide outage otherwise.
test('an unreadable settings row falls back to the environment gate', async () => {
const app = appFor(async () => ndjson(), principal, {
resolvePiggyEnabled: async () => {
throw new Error('platform settings unavailable');
},
});
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: true,
canUse: true,
});
});
test('the environment gate still overrides a stored toggle that says yes', async () => {
const app = appFor(async () => ndjson(), principal, {
enabled: false,
resolvePiggyEnabled: async () => true,
});
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: false,
canUse: false,
});
});
// ---------------------------------------------------------------------------
// Composition
// ---------------------------------------------------------------------------
/**
* Enough of a Database to authenticate a development request and read the
* settings row, and nothing more.
*
* Predicates are ignored on purpose: this asserts a WIRING, and a fake that
* tried to execute SQL semantics would be a worse test of the wiring and a
* pointless test of Drizzle. Anything the app queries beyond these three
* tables comes back empty, which is what an untouched deployment looks like.
*/
function stubDatabase(store: { piggyEnabled: boolean }): Database {
const rowsFor = (table: unknown): Record<string, unknown>[] => {
if (table === users) {
return [
{
id: principal.userId,
email: principal.email,
name: principal.name,
isPlatformAdmin: false,
deactivatedAt: null,
},
];
}
if (table === teamMemberships) return [{ team: 'demand', role: 'member' }];
if (table === platformSettings) return [{ piggyEnabled: store.piggyEnabled }];
return [];
};
const query = (rows: Record<string, unknown>[]): Record<string, unknown> => {
const chain: Record<string, unknown> = {
from: (table: unknown) => query(rowsFor(table)),
where: () => chain,
limit: () => chain,
orderBy: () => chain,
innerJoin: () => chain,
leftJoin: () => chain,
values: () => chain,
onConflictDoNothing: () => chain,
returning: () => chain,
then: (resolve: (value: Record<string, unknown>[]) => unknown) => resolve(rows),
};
return chain;
};
return {
select: () => query([]),
insert: (table: unknown) => query(rowsFor(table)),
} as unknown as Database;
}
/**
* The regression this file could not previously catch.
*
* The three toggle tests above build the routes themselves and inject a
* resolver, so every one of them stayed green through a release in which
* `createApp` never passed one — turning Piggy off in the admin UI did nothing
* at all in production. Only a request through the composed app proves the
* stored setting is consulted, so this one goes through `createApp`.
*/
test('createApp wires the stored toggle into the chat routes', async () => {
const store = { piggyEnabled: false };
const config = loadConfig({
NODE_ENV: 'development',
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
PIGGY_ENABLED: 'true',
PIGGY_INTERNAL_URL: 'http://127.0.0.1:8931',
PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters',
});
// Null provider is the development path: no token, principal comes from the
// first user in the table. What is under test is the toggle, not the auth.
const app = createApp(config, stubDatabase(store), null);
assert.equal(config.PIGGY_ENABLED, true);
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: false,
canUse: false,
});
store.piggyEnabled = true;
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
enabled: true,
canUse: true,
});
});