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
+280
View File
@@ -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 elses 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,<script>alert(1)</script>',
'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/"><script>alert(1)</script>',
'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', '"><iframe src=x'), null);
assert.equal(learnEmbedUrl('cap', '0n6n9p83efnxbs2'), 'https://video.karti.ai/embed/0n6n9p83efnxbs2');
});
it('names every enabled host, so the CSP handoff cannot drift', () => {
assert.deepEqual(LEARN_FRAME_SRC_HOSTS, ['https://video.karti.ai']);
});
});
// ------------------------------------------------------------- the two rules
describe('code visibility', () => {
it('permits code visibility on the platform track only', () => {
assert.equal(learnVisibilityPermitted('platform', 'code'), true);
assert.equal(learnVisibilityPermitted('supply', 'code'), false);
assert.equal(learnVisibilityPermitted('demand', 'code'), false);
// Members-only is legal everywhere, including on the platform track.
for (const track of ['supply', 'demand', 'platform'] as const) {
assert.equal(learnVisibilityPermitted(track, 'members'), true);
}
});
it('refuses a code-visible concept resource at the write schema', () => {
const rejected = learnResourceCreateSchema.safeParse({
track: 'supply',
title: 'How capacity is priced',
url: 'https://video.karti.ai/s/0n6n9p83efnxbs2',
visibility: 'code',
});
assert.equal(rejected.success, false);
const accepted = learnResourceCreateSchema.safeParse({
track: LEARN_CODE_TRACK,
title: 'Your first hour in PIG',
url: 'https://video.karti.ai/s/0n6n9p83efnxbs2',
visibility: 'code',
});
assert.equal(accepted.success, true);
});
});
// ----------------------------------------------------------------- the token
describe('learn token', () => {
it('verifies a token it minted, and refuses one minted under another code', () => {
const token = mintLearnToken(ACCESS_CODE, Date.now() + LEARN_TOKEN_TTL_MS);
assert.equal(verifyLearnToken(ACCESS_CODE, token).valid, true);
// Rotation is total precisely because the signing key is derived from the
// code — there is no revocation list to forget to write to.
const afterRotation = verifyLearnToken('anothercode', token);
assert.equal(afterRotation.valid, false);
assert.equal(afterRotation.valid === false && afterRotation.reason, 'mismatch');
});
it('refuses an expired token, a forged signature and a rewritten expiry', () => {
const expiry = Date.now() + LEARN_TOKEN_TTL_MS;
const token = mintLearnToken(ACCESS_CODE, expiry);
assert.equal(verifyLearnToken(ACCESS_CODE, token, expiry + 1).valid, false);
assert.equal(verifyLearnToken(ACCESS_CODE, `${token}x`).valid, false);
assert.equal(verifyLearnToken(ACCESS_CODE, 'learn_v1.99999999999999.aaaa').valid, false);
// The expiry is signed, so extending it invalidates the token rather than
// extending the session.
const [, , signature] = token.slice('learn_'.length).split('.');
assert.equal(verifyLearnToken(ACCESS_CODE, `learn_v1.${expiry + 60_000}.${signature}`).valid, false);
assert.equal(verifyLearnToken(ACCESS_CODE, undefined).valid, false);
assert.equal(verifyLearnToken(null, token).valid, false);
});
});
// ----------------------------------------------------------- the whole point
/**
* A learn token must be worthless everywhere except one handler.
*
* This runs against the real `createApp`, not a stub, because the property
* being asserted is about composition: what the authenticator does with a
* bearer token it does not recognise, on routes this feature never mentions.
* A fake would assert my own assumptions back at me.
*
* No database is touched — every path here fails in the auth middleware,
* before a handler runs — so the stub below is a placeholder that would throw
* loudly if anything ever reached it. That is deliberate: if a future change
* lets a learn token past the middleware, this test fails with a database
* error rather than passing quietly.
*/
describe('a learn token is not a credential for anything else', () => {
const config = loadConfig({
NODE_ENV: 'production',
DATABASE_URL: 'postgres://unused:unused@127.0.0.1:1/unused',
PIG_PUBLIC_URL: 'https://pig-learn-test.invalid',
SUPABASE_URL: 'https://identity-learn-test.invalid',
SUPABASE_ANON_KEY: 'learn-test-anon-key',
SUPABASE_SERVICE_KEY: '',
PIG_ADMIN_EMAILS: '',
PIGGY_ENABLED: 'false',
});
const db = new Proxy(
{},
{
get() {
throw new Error('A learn token reached the database. It must never resolve a principal.');
},
},
) as unknown as Database;
const authProvider = {
name: 'learn-test-stub',
async verifyAccessToken(): Promise<{ subject: string; email: string }> {
// A learn token is not a JWT. If this is ever called with one, the
// authenticator has started treating it as an identity assertion.
throw new Error('Not a valid identity token.');
},
};
const app = createApp(config, db, authProvider);
const token = mintLearnToken(ACCESS_CODE, Date.now() + LEARN_TOKEN_TTL_MS);
// The routes a leak would be worth having. `/api/dashboard` is the one
// scripts/deploy.sh probes before it will finish a release.
for (const path of ['/api/dashboard', '/api/accounts', '/api/contracts']) {
it(`answers 401 on ${path} for a valid learn token`, async () => {
const response = await app.request(`https://pig-learn-test.invalid${path}`, {
headers: { authorization: `Bearer ${token}` },
});
assert.equal(response.status, 401, `${path} must refuse a learn token`);
});
}
it('answers 401 on those routes with no credential at all, unchanged', async () => {
// The deploy gate asserts exactly this. Adding a public path must not move
// it, so it is pinned next to the token case rather than trusted.
const response = await app.request('https://pig-learn-test.invalid/api/dashboard');
assert.equal(response.status, 401);
});
it('is not a learn token once it is dressed as a PIG API key', () => {
// `pig_` is the one prefix that reaches a database lookup, so the two
// token vocabularies must not overlap in either direction. Asserted on the
// verifier rather than through the app because the API-key branch needs a
// real database to answer 401 `invalid_key`, and the e2e suite covers that
// path with one.
const dressed = `pig_${token}`;
assert.equal(verifyLearnToken(ACCESS_CODE, dressed).valid, false);
assert.equal(dressed.startsWith('learn_'), false);
});
});
// ----------------------------------------------------------- the rate limiter
describe('attempt limiter', () => {
it('allows the quota, refuses past it, and reopens after the window', () => {
const limiter = createAttemptLimiter({ limit: 3, windowMs: 60_000 });
const start = 1_000_000;
for (let attempt = 0; attempt < 3; attempt += 1) {
assert.equal(limiter.check('10.0.0.9', start).allowed, true);
}
const refused = limiter.check('10.0.0.9', start);
assert.equal(refused.allowed, false);
assert.ok(refused.retryAfterSeconds > 0);
// A window that never reopens is a self-inflicted outage, not security.
assert.equal(limiter.check('10.0.0.9', start + 60_001).allowed, true);
// Buckets are per key.
assert.equal(limiter.check('10.0.0.10', start).allowed, true);
});
it('buckets on the last forwarded hop, not the first', () => {
// Caddy APPENDS the peer address, so the first entry is whatever the
// client sent. Keying on it hands anyone unlimited buckets and the limiter
// becomes decorative.
assert.equal(rateLimitKey('203.0.113.7, 10.0.0.2'), '10.0.0.2');
assert.equal(rateLimitKey('10.0.0.2'), '10.0.0.2');
assert.equal(rateLimitKey(undefined), 'unknown');
});
});
describe('duration formatting', () => {
it('crosses the hour without renaming the minutes', () => {
assert.equal(formatLearnDuration(272), '4:32');
assert.equal(formatLearnDuration(3_852), '1:04:12');
assert.equal(formatLearnDuration(60), '1:00');
assert.equal(formatLearnDuration(null), null);
assert.equal(formatLearnDuration(-1), null);
});
});