Rebuild the shell, add Calendar and Learn, and govern reads
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user