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
+231
View File
@@ -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<string, unknown> = {
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<unknown>) => 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'],
);
});
});