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
+323
View File
@@ -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> = {}): 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<string, unknown>) => {
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<unknown>) => {
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<string, unknown> | undefined;
const capturing = {
transaction: async (work: (transaction: unknown) => Promise<unknown>) =>
work({
insert: () => ({
values: (values: Record<string, unknown>) => {
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,
);
});
});