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,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 else’s 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user