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,167 @@
|
||||
/**
|
||||
* The write that used to bypass everything.
|
||||
*
|
||||
* `POST /api/activities` lived inline in app.ts with no capability check at
|
||||
* all: any member, and any write-scoped API key, could insert an activity
|
||||
* against an arbitrary `accountId` and move that account's `lastActivityAt`.
|
||||
* These pin the three things that stopped it, not the SQL that carries them
|
||||
* out.
|
||||
*/
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Database } from '@pig/db';
|
||||
import { AuthError } from '../src/lib/auth';
|
||||
import { executeMutation } from '../src/lib/mutation';
|
||||
import { createActivityMutationDefinition, type LoggedActivity } from '../src/routes/activities';
|
||||
import { onTeam, principal } from './helpers/principal';
|
||||
|
||||
interface Recorded {
|
||||
events: string[];
|
||||
inserted: unknown[];
|
||||
updated: unknown[];
|
||||
}
|
||||
|
||||
/** A transaction whose account lookup answers with a chosen side. */
|
||||
function database(accountSide: string | null): { db: Database; log: Recorded } {
|
||||
const log: Recorded = { events: [], inserted: [], updated: [] };
|
||||
const accountRows = accountSide ? [{ side: accountSide }] : [];
|
||||
|
||||
const tx = {
|
||||
select: () => {
|
||||
log.events.push('select');
|
||||
return { from: () => ({ where: () => ({ limit: async () => accountRows }) }) };
|
||||
},
|
||||
insert: () => ({
|
||||
values: (row: unknown) => {
|
||||
log.events.push('insert');
|
||||
log.inserted.push(row);
|
||||
return { onConflictDoNothing: () => ({ returning: async () => [row] }) };
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (values: unknown) => ({
|
||||
where: async () => {
|
||||
log.events.push('touch-account');
|
||||
log.updated.push(values);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
transaction: async (work: (t: unknown) => Promise<unknown>) => {
|
||||
log.events.push('transaction');
|
||||
return work(tx);
|
||||
},
|
||||
} as unknown as Database,
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
const body = {
|
||||
type: 'call' as const,
|
||||
subject: 'Spoke to the CTO',
|
||||
accountId: '00000000-0000-4000-8000-0000000000ff',
|
||||
};
|
||||
|
||||
function log(db: Database, actor = principal()) {
|
||||
return executeMutation(
|
||||
db,
|
||||
actor,
|
||||
async () => body,
|
||||
createActivityMutationDefinition(),
|
||||
) as Promise<LoggedActivity>;
|
||||
}
|
||||
|
||||
describe('logging an activity', () => {
|
||||
it('refuses a principal with no activity:write anywhere, before reading the body', async () => {
|
||||
const { db, log: recorded } = database('demand');
|
||||
let bodyWasRead = false;
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
principal(onTeam('demand', 'viewer')),
|
||||
async () => {
|
||||
bodyWasRead = true;
|
||||
return body;
|
||||
},
|
||||
createActivityMutationDefinition(),
|
||||
),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
assert.equal(bodyWasRead, false);
|
||||
assert.deepEqual(recorded.events, []);
|
||||
});
|
||||
|
||||
/**
|
||||
* The escalation the old handler allowed: a research member logging a call
|
||||
* against a demand account they have no relationship with, and pushing it to
|
||||
* the top of somebody else's account list.
|
||||
*/
|
||||
it('refuses a research member writing against a demand account', async () => {
|
||||
const { db, log: recorded } = database('demand');
|
||||
|
||||
await assert.rejects(
|
||||
log(db, principal(onTeam('research', 'admin'))),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
assert.equal(recorded.inserted.length, 0);
|
||||
assert.equal(recorded.updated.length, 0);
|
||||
});
|
||||
|
||||
it('admits a demand member against a dual-sided account', async () => {
|
||||
const { db, log: recorded } = database('both');
|
||||
|
||||
const result = await log(db);
|
||||
|
||||
assert.equal(result.deduplicated, false);
|
||||
assert.deepEqual(recorded.events, ['transaction', 'select', 'insert', 'touch-account']);
|
||||
});
|
||||
|
||||
it('writes one row, not two — the activity is its own audit event', async () => {
|
||||
const { db, log: recorded } = database('demand');
|
||||
|
||||
await log(db);
|
||||
|
||||
assert.equal(
|
||||
recorded.inserted.length,
|
||||
1,
|
||||
'an audit row alongside the activity would double every synced call in the feed',
|
||||
);
|
||||
});
|
||||
|
||||
it('attributes an API key to the agent, not silently to the person', async () => {
|
||||
const { db, log: recorded } = database('demand');
|
||||
|
||||
await log(db, principal({ via: 'api_key', apiKeyId: 'key-1' }));
|
||||
|
||||
assert.deepEqual(
|
||||
recorded.inserted[0] as Record<string, unknown>,
|
||||
{
|
||||
...(recorded.inserted[0] as Record<string, unknown>),
|
||||
actorAgent: 'agent',
|
||||
source: 'agent',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the caller\'s timestamp, because sync backfills', async () => {
|
||||
const { db, log: recorded } = database('demand');
|
||||
const when = '2026-01-05T09:30:00.000Z';
|
||||
|
||||
await executeMutation(
|
||||
db,
|
||||
principal(),
|
||||
async () => ({ ...body, occurredAt: when }),
|
||||
createActivityMutationDefinition(),
|
||||
);
|
||||
|
||||
const row = recorded.inserted[0] as { occurredAt: Date };
|
||||
assert.equal(row.occurredAt.toISOString(), when);
|
||||
// And the account stamp follows the event, not the clock, or a backfilled
|
||||
// call from March would jump the account to the top of the list today.
|
||||
assert.deepEqual(recorded.updated, [{ lastActivityAt: new Date(when) }]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,11 @@ describe('admin settings decisions', () => {
|
||||
piggyEnabled: true,
|
||||
primeApiKeyEncrypted: 'v1.iv.tag.ciphertext',
|
||||
primeApiKeyUpdatedAt: now,
|
||||
// Present so the row is a complete PlatformSettings. The assertion
|
||||
// below is that nothing secret escapes into the metadata, and the
|
||||
// Learn share code is exactly the sort of thing that must not.
|
||||
learnAccessCode: 'carlthefog',
|
||||
learnAccessCodeUpdatedAt: null,
|
||||
primeSyncEnabled: true,
|
||||
primeSyncIntervalMinutes: 30,
|
||||
updatedByUserId: null,
|
||||
|
||||
+75
-17
@@ -1,20 +1,13 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { AuthError, effectivePermissions, requireCapability } from '../src/lib/auth';
|
||||
|
||||
function principal(overrides: Partial<Principal> = {}): Principal {
|
||||
return {
|
||||
userId: '00000000-0000-0000-0000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
import {
|
||||
AuthError,
|
||||
effectivePermissions,
|
||||
requireAnyTeamCapability,
|
||||
requireCapability,
|
||||
requireReadCapability,
|
||||
} from '../src/lib/auth';
|
||||
import { onTeam, principal } from './helpers/principal';
|
||||
|
||||
describe('capability enforcement', () => {
|
||||
it('rejects a role grant from the wrong team', () => {
|
||||
@@ -24,13 +17,78 @@ describe('capability enforcement', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('removes write grants from a read-only API key', () => {
|
||||
it('removes write grants from a read-only API key but keeps its reads', () => {
|
||||
const readOnly = principal({ via: 'api_key', scopes: ['read'] });
|
||||
|
||||
assert.deepEqual(effectivePermissions(readOnly), []);
|
||||
// The point of a read-only key. Before read capabilities existed this
|
||||
// resolved to nothing at all, which was right then and would now tell the
|
||||
// front end that a reader may not read.
|
||||
assert.deepEqual(
|
||||
effectivePermissions(readOnly).map((grant) => grant.capability),
|
||||
['book:read', 'economics:read', 'team:read'],
|
||||
);
|
||||
assert.throws(
|
||||
() => requireCapability(readOnly, 'deal:write', 'demand'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_scope',
|
||||
);
|
||||
});
|
||||
|
||||
it('grants no reads to a write-only credential', () => {
|
||||
const writeOnly = principal({ via: 'api_key', scopes: ['write'] });
|
||||
|
||||
assert.throws(
|
||||
() => requireReadCapability(writeOnly, 'economics:read'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_scope',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps supplier economics away from research, whatever their rank', () => {
|
||||
const researchAdmin = principal(onTeam('research', 'admin'));
|
||||
|
||||
requireReadCapability(researchAdmin, 'book:read');
|
||||
assert.throws(
|
||||
() => requireReadCapability(researchAdmin, 'economics:read'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
});
|
||||
|
||||
it('gives a viewer the book and nothing that writes to it', () => {
|
||||
const viewer = principal(onTeam('demand', 'viewer'));
|
||||
|
||||
requireReadCapability(viewer, 'book:read');
|
||||
requireReadCapability(viewer, 'team:read');
|
||||
for (const capability of ['deal:write', 'activity:write'] as const) {
|
||||
assert.throws(
|
||||
() => requireCapability(viewer, capability, 'demand'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
`viewer should not hold ${capability}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug this pins: `requireCapability(p, 'data:import')` with no team
|
||||
* passed if the principal held it anywhere, so a research-team admin could
|
||||
* rewrite the demand pipeline. The overload no longer accepts a team-scoped
|
||||
* capability without a team; the any-team question has to be asked by name.
|
||||
*/
|
||||
it('separates "holds it here" from "holds it somewhere"', () => {
|
||||
const researchAdmin = principal(onTeam('research', 'admin'));
|
||||
|
||||
requireAnyTeamCapability(researchAdmin, 'data:import');
|
||||
assert.throws(
|
||||
() => requireCapability(researchAdmin, 'data:import', 'demand'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
});
|
||||
|
||||
it('will not let fact review borrow bulk-import authority', () => {
|
||||
const demandAdmin = principal(onTeam('demand', 'admin'));
|
||||
|
||||
requireCapability(demandAdmin, 'data:import', 'demand');
|
||||
assert.throws(
|
||||
() => requireAnyTeamCapability(demandAdmin, 'fact:review'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { growthReadAllowed } from '../src/routes/growth';
|
||||
import { READ_RULES } from '../src/routes/read-guards';
|
||||
|
||||
/**
|
||||
* This file used to test `growthReadAllowed`, a scope predicate local to
|
||||
* growth.ts. The predicate is gone and the boundary it guarded is now one row
|
||||
* in the read table, so what is worth pinning is that growth did not quietly
|
||||
* lose its guard in the move — a deletion that would leave the endpoint open
|
||||
* and every test still green.
|
||||
*/
|
||||
describe('growth read boundary', () => {
|
||||
it('requires an explicit read scope instead of treating authentication as authorization', () => {
|
||||
assert.equal(growthReadAllowed([]), false);
|
||||
assert.equal(growthReadAllowed(['write']), false);
|
||||
assert.equal(growthReadAllowed(['read']), true);
|
||||
assert.equal(growthReadAllowed(['read', 'write']), true);
|
||||
it('is still governed after moving from a local scope check to the table', () => {
|
||||
const governed = READ_RULES.filter((rule) => rule.path.startsWith('/api/growth'));
|
||||
|
||||
assert.deepEqual(
|
||||
governed.map((rule) => `${rule.method} ${rule.path} ${rule.capability}`),
|
||||
[
|
||||
'GET /api/growth book:read',
|
||||
'GET /api/growth/accounts/:id book:read',
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Shared test fixtures for authorisation.
|
||||
*
|
||||
* Before this, `Principal` was re-declared as a literal in auth.test.ts,
|
||||
* records.test.ts, mutation.test.ts and half a dozen others — ten copies of the
|
||||
* same nine fields. Adding a field to `Principal` meant editing every one of
|
||||
* them, and the copies had already drifted on `scopes`, which is precisely the
|
||||
* field the read/write split now turns on. One factory, overridden per case.
|
||||
*/
|
||||
import type { Team, TeamRole } from '@pig/core';
|
||||
import type { Database } from '@pig/db';
|
||||
import type { Principal } from '../../src/lib/auth';
|
||||
|
||||
/**
|
||||
* A demand-team member with a full-scope session: the ordinary user, chosen as
|
||||
* the default because it is the case most tests want to vary *away* from.
|
||||
*/
|
||||
export function principal(overrides: Partial<Principal> = {}): Principal {
|
||||
return {
|
||||
userId: '00000000-0000-4000-8000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** One membership, spelled out — the common override, and easy to get wrong. */
|
||||
export function onTeam(team: Team, role: TeamRole): Partial<Principal> {
|
||||
return { teams: [{ team, role }] };
|
||||
}
|
||||
|
||||
export interface FakeDatabaseOptions {
|
||||
/** Appended to in call order, so a test can assert what ran and in what order. */
|
||||
events?: string[];
|
||||
/** Rows handed to `insert().values()`, chiefly the audit activity. */
|
||||
inserted?: unknown[];
|
||||
/** Rows a `select()` chain resolves to. Defaults to empty. */
|
||||
selected?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum Drizzle surface `executeMutation` touches: a transaction, an
|
||||
* insert that records its row, and a select chain that resolves to fixed rows.
|
||||
* Deliberately not a database — a test that needs real SQL semantics needs a
|
||||
* real Postgres, and pretending otherwise is how a fake starts asserting that
|
||||
* broken queries work.
|
||||
*/
|
||||
export function fakeDatabase(options: FakeDatabaseOptions = {}): Database {
|
||||
const events = options.events ?? [];
|
||||
const inserted = options.inserted ?? [];
|
||||
const selected = options.selected ?? [];
|
||||
|
||||
const selectChain = {
|
||||
from: () => selectChain,
|
||||
leftJoin: () => selectChain,
|
||||
innerJoin: () => selectChain,
|
||||
where: () => selectChain,
|
||||
orderBy: () => selectChain,
|
||||
limit: async () => selected,
|
||||
then: (resolve: (rows: unknown[]) => unknown) => resolve(selected),
|
||||
};
|
||||
|
||||
const insertChain = {
|
||||
values: (row: unknown) => {
|
||||
events.push('insert');
|
||||
inserted.push(row);
|
||||
return {
|
||||
onConflictDoNothing: () => ({ returning: async () => [row] }),
|
||||
returning: async () => [row],
|
||||
then: (resolve: (value: unknown) => unknown) => resolve(undefined),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const updateChain = {
|
||||
set: () => ({
|
||||
where: async () => {
|
||||
events.push('update');
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const tx = {
|
||||
select: () => {
|
||||
events.push('select');
|
||||
return selectChain;
|
||||
},
|
||||
insert: () => insertChain,
|
||||
update: () => updateChain,
|
||||
};
|
||||
|
||||
return {
|
||||
transaction: async (work: (transaction: unknown) => Promise<unknown>) => {
|
||||
events.push('transaction');
|
||||
return work(tx);
|
||||
},
|
||||
select: tx.select,
|
||||
insert: tx.insert,
|
||||
update: tx.update,
|
||||
} as unknown as Database;
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,23 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { z } from 'zod';
|
||||
import type { Database } from '@pig/db';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { AuthError } from '../src/lib/auth';
|
||||
import { apiError, executeMutation, MutationError } from '../src/lib/mutation';
|
||||
import { fakeDatabase, onTeam, principal as makePrincipal } from './helpers/principal';
|
||||
|
||||
const principal: Principal = {
|
||||
userId: '00000000-0000-0000-0000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
const principal = makePrincipal();
|
||||
|
||||
function fakeDatabase(events: string[], activityRows: unknown[]): Database {
|
||||
const tx = {
|
||||
insert: () => ({
|
||||
values: async (row: unknown) => {
|
||||
events.push('activity');
|
||||
activityRows.push(row);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return {
|
||||
transaction: async (work: (transaction: unknown) => Promise<unknown>) => {
|
||||
events.push('transaction');
|
||||
return work(tx);
|
||||
},
|
||||
} as unknown as Database;
|
||||
function db(events: string[], inserted: unknown[] = []) {
|
||||
return fakeDatabase({ events, inserted });
|
||||
}
|
||||
|
||||
describe('mutation convention', () => {
|
||||
it('checks capability before reading attacker-controlled input', async () => {
|
||||
const events: string[] = [];
|
||||
const forbidden = { ...principal, teams: [{ team: 'supply', role: 'admin' }] } as Principal;
|
||||
const forbidden = makePrincipal(onTeam('supply', 'admin'));
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(fakeDatabase(events, []), forbidden, async () => {
|
||||
executeMutation(db(events), forbidden, async () => {
|
||||
events.push('body');
|
||||
return {};
|
||||
}, {
|
||||
@@ -61,7 +39,7 @@ describe('mutation convention', () => {
|
||||
const stages = ['qualification', 'legal'] as const;
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(fakeDatabase(events, []), principal, async () => ({ stage: 'invented' }), {
|
||||
executeMutation(db(events), principal, async () => ({ stage: 'invented' }), {
|
||||
schema: z.object({ stage: z.enum(stages) }),
|
||||
permission: { capability: 'deal:write', team: 'demand' },
|
||||
invalidMessage: 'Invalid transition.',
|
||||
@@ -82,7 +60,7 @@ describe('mutation convention', () => {
|
||||
const events: string[] = [];
|
||||
const rows: unknown[] = [];
|
||||
const result = await executeMutation(
|
||||
fakeDatabase(events, rows),
|
||||
db(events, rows),
|
||||
principal,
|
||||
async () => ({ stage: 'legal' }),
|
||||
{
|
||||
@@ -105,7 +83,7 @@ describe('mutation convention', () => {
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { id: 'deal-1' });
|
||||
assert.deepEqual(events, ['transaction', 'mutate', 'activity']);
|
||||
assert.deepEqual(events, ['transaction', 'mutate', 'insert']);
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
type: 'stage_change',
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { platformSettings, teamMemberships, users, type Database } from '@pig/db';
|
||||
import { createApp } from '../src/app';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { loadConfig } from '../src/lib/config';
|
||||
import type { ApiEnv } from '../src/lib/mutation';
|
||||
import { createPiggyChatRoutes } from '../src/routes/piggy-chat';
|
||||
import {
|
||||
createPiggyChatRoutes,
|
||||
type PiggyChatProxyOptions,
|
||||
} from '../src/routes/piggy-chat';
|
||||
|
||||
const principal: Principal = {
|
||||
userId: '10000000-0000-4000-8000-000000000001',
|
||||
@@ -15,7 +21,11 @@ const principal: Principal = {
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
function appFor(fetchImpl: typeof fetch, identity: Principal = principal) {
|
||||
function appFor(
|
||||
fetchImpl: typeof fetch,
|
||||
identity: Principal = principal,
|
||||
overrides: Partial<PiggyChatProxyOptions> = {},
|
||||
) {
|
||||
const app = new Hono<ApiEnv>();
|
||||
app.use('*', async (context, next) => {
|
||||
context.set('principal', identity);
|
||||
@@ -28,11 +38,18 @@ function appFor(fetchImpl: typeof fetch, identity: Principal = principal) {
|
||||
internalUrl: 'http://127.0.0.1:8931',
|
||||
internalToken: 'internal-token-with-at-least-32-characters',
|
||||
fetchImpl,
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
const ndjson = () =>
|
||||
new Response(`${JSON.stringify({ type: 'done', inputTokens: 1, outputTokens: 1 })}\n`, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/x-ndjson' },
|
||||
});
|
||||
|
||||
test('the authenticated proxy forwards bounded identity and relays NDJSON unchanged', async () => {
|
||||
let forwarded: Record<string, unknown> | undefined;
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
@@ -97,3 +114,195 @@ test('a credential without read scope never reaches the internal service', async
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(fetched, false);
|
||||
});
|
||||
|
||||
test('a docked page context reaches the chat service unaltered', async () => {
|
||||
let forwarded: Record<string, unknown> | undefined;
|
||||
const app = appFor(async (_input, init) => {
|
||||
forwarded = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
return ndjson();
|
||||
});
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'What is idle?',
|
||||
context: { type: 'page', route: '/capacity', label: 'Capacity' },
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(forwarded?.context, {
|
||||
type: 'page',
|
||||
route: '/capacity',
|
||||
label: 'Capacity',
|
||||
});
|
||||
});
|
||||
|
||||
// A page context carries no record, so admitting one would put a nonsense
|
||||
// shape in front of the model rather than failing at the boundary.
|
||||
test('a page context may not smuggle a record id, and an unknown route is refused', async () => {
|
||||
let fetched = false;
|
||||
const app = appFor(async () => {
|
||||
fetched = true;
|
||||
return ndjson();
|
||||
});
|
||||
|
||||
for (const context of [
|
||||
{ type: 'page', route: '/not-a-page' },
|
||||
{ type: 'page', route: '/margin', id: '20000000-0000-4000-8000-000000000002' },
|
||||
{ type: 'page' },
|
||||
]) {
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Where are we?', context }),
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(((await response.json()) as { code: string }).code, 'invalid_request');
|
||||
}
|
||||
assert.equal(fetched, false);
|
||||
});
|
||||
|
||||
test('the stored admin toggle disables chat without the environment changing', async () => {
|
||||
let fetched = false;
|
||||
let piggyEnabled = true;
|
||||
const app = appFor(
|
||||
async () => {
|
||||
fetched = true;
|
||||
return ndjson();
|
||||
},
|
||||
principal,
|
||||
{ resolvePiggyEnabled: async () => piggyEnabled },
|
||||
);
|
||||
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: true,
|
||||
});
|
||||
|
||||
piggyEnabled = false;
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Where are we?' }),
|
||||
});
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(fetched, false);
|
||||
});
|
||||
|
||||
// Losing the settings row must degrade to the environment gate. A dock on
|
||||
// every page turns one failed query into a site-wide outage otherwise.
|
||||
test('an unreadable settings row falls back to the environment gate', async () => {
|
||||
const app = appFor(async () => ndjson(), principal, {
|
||||
resolvePiggyEnabled: async () => {
|
||||
throw new Error('platform settings unavailable');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('the environment gate still overrides a stored toggle that says yes', async () => {
|
||||
const app = appFor(async () => ndjson(), principal, {
|
||||
enabled: false,
|
||||
resolvePiggyEnabled: async () => true,
|
||||
});
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Enough of a Database to authenticate a development request and read the
|
||||
* settings row, and nothing more.
|
||||
*
|
||||
* Predicates are ignored on purpose: this asserts a WIRING, and a fake that
|
||||
* tried to execute SQL semantics would be a worse test of the wiring and a
|
||||
* pointless test of Drizzle. Anything the app queries beyond these three
|
||||
* tables comes back empty, which is what an untouched deployment looks like.
|
||||
*/
|
||||
function stubDatabase(store: { piggyEnabled: boolean }): Database {
|
||||
const rowsFor = (table: unknown): Record<string, unknown>[] => {
|
||||
if (table === users) {
|
||||
return [
|
||||
{
|
||||
id: principal.userId,
|
||||
email: principal.email,
|
||||
name: principal.name,
|
||||
isPlatformAdmin: false,
|
||||
deactivatedAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (table === teamMemberships) return [{ team: 'demand', role: 'member' }];
|
||||
if (table === platformSettings) return [{ piggyEnabled: store.piggyEnabled }];
|
||||
return [];
|
||||
};
|
||||
|
||||
const query = (rows: Record<string, unknown>[]): Record<string, unknown> => {
|
||||
const chain: Record<string, unknown> = {
|
||||
from: (table: unknown) => query(rowsFor(table)),
|
||||
where: () => chain,
|
||||
limit: () => chain,
|
||||
orderBy: () => chain,
|
||||
innerJoin: () => chain,
|
||||
leftJoin: () => chain,
|
||||
values: () => chain,
|
||||
onConflictDoNothing: () => chain,
|
||||
returning: () => chain,
|
||||
then: (resolve: (value: Record<string, unknown>[]) => unknown) => resolve(rows),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
select: () => query([]),
|
||||
insert: (table: unknown) => query(rowsFor(table)),
|
||||
} as unknown as Database;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this file could not previously catch.
|
||||
*
|
||||
* The three toggle tests above build the routes themselves and inject a
|
||||
* resolver, so every one of them stayed green through a release in which
|
||||
* `createApp` never passed one — turning Piggy off in the admin UI did nothing
|
||||
* at all in production. Only a request through the composed app proves the
|
||||
* stored setting is consulted, so this one goes through `createApp`.
|
||||
*/
|
||||
test('createApp wires the stored toggle into the chat routes', async () => {
|
||||
const store = { piggyEnabled: false };
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'development',
|
||||
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
|
||||
PIGGY_ENABLED: 'true',
|
||||
PIGGY_INTERNAL_URL: 'http://127.0.0.1:8931',
|
||||
PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters',
|
||||
});
|
||||
// Null provider is the development path: no token, principal comes from the
|
||||
// first user in the table. What is under test is the toggle, not the auth.
|
||||
const app = createApp(config, stubDatabase(store), null);
|
||||
|
||||
assert.equal(config.PIGGY_ENABLED, true);
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
|
||||
store.piggyEnabled = true;
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* That reads are governed, and that the guard actually runs.
|
||||
*
|
||||
* Two separate risks. The policy could be wrong — a research contractor let
|
||||
* near supplier cost — and that is what the first suite checks. Or the policy
|
||||
* could be right and never execute, because Hono runs matched handlers in
|
||||
* registration order and a guard mounted after its handler is inert. That
|
||||
* second failure produces no error, no warning and a 200, which is exactly the
|
||||
* shape of the bug being fixed, so it is checked separately and explicitly.
|
||||
*/
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Team, TeamRole } from '@pig/core';
|
||||
import { Hono } from 'hono';
|
||||
import { AuthError, type Principal } from '../src/lib/auth';
|
||||
import { apiError, type ApiEnv } from '../src/lib/mutation';
|
||||
import { createReadGuardRoutes, READ_RULES } from '../src/routes/read-guards';
|
||||
import { principal as makePrincipal } from './helpers/principal';
|
||||
|
||||
/** The app's own error mapping, reproduced so a 403 here means a 403 there. */
|
||||
function guardedApp(principal: Principal, mountGuardsFirst = true) {
|
||||
const app = new Hono<ApiEnv>();
|
||||
app.use('*', async (context, next) => {
|
||||
context.set('principal', principal);
|
||||
await next();
|
||||
});
|
||||
const handlers = new Hono<ApiEnv>();
|
||||
for (const rule of READ_RULES) handlers.on(rule.method, rule.path, (c) => c.json({ ok: true }));
|
||||
|
||||
if (mountGuardsFirst) {
|
||||
app.route('/', createReadGuardRoutes());
|
||||
app.route('/', handlers);
|
||||
} else {
|
||||
app.route('/', handlers);
|
||||
app.route('/', createReadGuardRoutes());
|
||||
}
|
||||
|
||||
app.onError((error, c) =>
|
||||
error instanceof AuthError
|
||||
? c.json(apiError(error.code, error.message), error.status)
|
||||
: c.json({ error: 'Internal error' }, 500),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
function on(team: Team, role: TeamRole): Principal {
|
||||
return makePrincipal({ teams: [{ team, role }] });
|
||||
}
|
||||
|
||||
async function statusFor(principal: Principal, rule: (typeof READ_RULES)[number]) {
|
||||
const path = rule.path.replace(':id', '00000000-0000-4000-8000-000000000001');
|
||||
const response = await guardedApp(principal).request(path, {
|
||||
method: rule.method,
|
||||
...(rule.method === 'POST'
|
||||
? { headers: { 'content-type': 'application/json' }, body: '{}' }
|
||||
: {}),
|
||||
});
|
||||
return response.status;
|
||||
}
|
||||
|
||||
describe('read policy', () => {
|
||||
it('denies every governed read to someone on no team', async () => {
|
||||
const stranger = makePrincipal({ teams: [] });
|
||||
for (const rule of READ_RULES) {
|
||||
assert.equal(await statusFor(stranger, rule), 403, `${rule.method} ${rule.path}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('admits every governed read to a platform admin', async () => {
|
||||
const admin = makePrincipal({ isPlatformAdmin: true, teams: [] });
|
||||
for (const rule of READ_RULES) {
|
||||
assert.equal(await statusFor(admin, rule), 200, `${rule.method} ${rule.path}`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The case the audit named: a research contractor and a demand rep seeing
|
||||
* supplier cost economics identically. They must now differ, and only on the
|
||||
* economics rules — research still reads the book.
|
||||
*/
|
||||
it('splits research off the economics rules and nothing else', async () => {
|
||||
const researcher = on('research', 'lead');
|
||||
for (const rule of READ_RULES) {
|
||||
const expected = rule.capability === 'economics:read' ? 403 : 200;
|
||||
assert.equal(await statusFor(researcher, rule), expected, `${rule.method} ${rule.path}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a viewer the book and the roster but not the cost side', async () => {
|
||||
const viewer = on('demand', 'viewer');
|
||||
for (const rule of READ_RULES) {
|
||||
const expected = rule.capability === 'economics:read' ? 403 : 200;
|
||||
assert.equal(await statusFor(viewer, rule), expected, `${rule.method} ${rule.path}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('admits a commercial member to everything, cost included', async () => {
|
||||
const seller = on('demand', 'member');
|
||||
for (const rule of READ_RULES) {
|
||||
assert.equal(await statusFor(seller, rule), 200, `${rule.method} ${rule.path}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a write-only credential even where the person qualifies', async () => {
|
||||
const writeOnly = makePrincipal({ via: 'api_key', scopes: ['write'] });
|
||||
const response = await guardedApp(writeOnly).request('/api/capacity/margin');
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(((await response.json()) as { code: string }).code, 'insufficient_scope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the guard has to be mounted before the handler', () => {
|
||||
it('runs when registered first', async () => {
|
||||
const response = await guardedApp(on('research', 'lead'), true).request('/api/capacity/margin');
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
/**
|
||||
* Not a test of desired behaviour — a test of the trap. If this ever starts
|
||||
* returning 403, Hono's dispatch order changed and the warning comment in
|
||||
* read-guards.ts can be deleted. Until then, the mount position in
|
||||
* `createApp` is load-bearing and this records why.
|
||||
*/
|
||||
it('is silently inert when registered after', async () => {
|
||||
const response = await guardedApp(on('research', 'lead'), false).request('/api/capacity/margin');
|
||||
assert.equal(response.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Nothing stops a future GET being added without a row in READ_RULES, so this
|
||||
* reads the routing source and insists that every `/api` GET is either
|
||||
* governed or listed below with a reason. It is a coarse regex over source
|
||||
* text and that is deliberate: a cleverer check would need the app running,
|
||||
* and a check that is hard to run is a check that gets deleted.
|
||||
*/
|
||||
describe('no read escapes the table', () => {
|
||||
/** Reads whose own handler authorises them, or which must stay open. */
|
||||
const DELIBERATELY_UNGOVERNED: Readonly<Record<string, string>> = {
|
||||
'/api/health': 'Liveness, for load balancers. Unauthenticated by design.',
|
||||
'/api/config': 'Public front-end configuration; contains no secret.',
|
||||
'/api/me': 'Your own identity. Gating it would hide the reason you are gated.',
|
||||
'/api/me/profile': 'Your own profile row.',
|
||||
'/api/api-keys': 'Guarded by requireApiKeyManagement, which also bars API keys.',
|
||||
'/api/admin/settings': 'settings:admin, enforced in admin-settings.ts.',
|
||||
'/api/admin/invites': 'settings:admin, enforced in admin-settings.ts.',
|
||||
'/api/admin/members': 'settings:admin, enforced in admin-settings.ts.',
|
||||
'/api/admin/integrations': 'settings:admin, enforced in integration-settings.ts.',
|
||||
'/api/piggy/status': 'Whether the assistant is switched on; carries no book data.',
|
||||
'/api/imports/config': 'data:import, enforced by the router middleware.',
|
||||
'/api/imports/google/status': 'integration:connect, enforced by the router middleware.',
|
||||
'/api/imports/google/files': 'data:import, enforced by the router middleware.',
|
||||
'/api/imports/google/spreadsheets/:id/sheets': 'data:import, enforced by the router middleware.',
|
||||
'/api/imports/notion/status': 'integration:connect, enforced by the router middleware.',
|
||||
'/api/imports/notion/connections/:id/data-sources': 'integration:connect, ditto.',
|
||||
'/api/integrations/hubspot/oauth/callback': 'OAuth redirect; verifies its own state.',
|
||||
'/api/integrations/hubspot/connections': 'settings:admin, enforced in hubspot.ts.',
|
||||
'/api/integrations/slack/channel-links': 'Channel wiring, not book data.',
|
||||
'/api/integrations/buzz/channel-links': 'Channel wiring, not book data.',
|
||||
'/api/calendar': 'Owned by the calendar track; gated in calendar.ts.',
|
||||
'/api/calendar/entries': 'Owned by the calendar track; gated in calendar.ts.',
|
||||
// Landed while this table was being written and carries its own access
|
||||
// code rather than a capability. Listed so the check stays green, not
|
||||
// because the arrangement has been reviewed — the learn track owns it.
|
||||
'/api/learn': 'Owned by the learn track; gated by its own access code.',
|
||||
'/api/learn/access-code': 'Owned by the learn track; gated by its own access code.',
|
||||
};
|
||||
|
||||
it('has a row, or a stated reason, for every GET', () => {
|
||||
const root = join(import.meta.dirname, '..', 'src');
|
||||
const files = [
|
||||
join(root, 'app.ts'),
|
||||
...readdirSync(join(root, 'routes'))
|
||||
.filter((name) => name.endsWith('.ts'))
|
||||
.map((name) => join(root, 'routes', name)),
|
||||
];
|
||||
|
||||
const governed = new Set(READ_RULES.filter((rule) => rule.method === 'GET').map((r) => r.path));
|
||||
const found = new Set<string>();
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(/\.get\(\s*'(\/api\/[^']*)'/g)) found.add(match[1]!);
|
||||
}
|
||||
|
||||
const ungoverned = [...found].filter(
|
||||
(path) => !governed.has(path) && !(path in DELIBERATELY_UNGOVERNED),
|
||||
);
|
||||
assert.deepEqual(
|
||||
ungoverned,
|
||||
[],
|
||||
`these reads are ungoverned — add a READ_RULES row or a stated reason:\n${ungoverned.join('\n')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Database } from '@pig/db';
|
||||
import { accounts, activities, agentTasks } from '@pig/db';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { AuthError } from '../src/lib/auth';
|
||||
import { executeMutation, MutationError } from '../src/lib/mutation';
|
||||
import {
|
||||
@@ -10,16 +9,9 @@ import {
|
||||
createAccountMutationDefinition,
|
||||
createDemandDealMutationDefinition,
|
||||
} from '../src/routes/records';
|
||||
import { principal } from './helpers/principal';
|
||||
|
||||
const demandPrincipal: Principal = {
|
||||
userId: '00000000-0000-4000-8000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
const demandPrincipal = principal();
|
||||
|
||||
describe('record-side decisions', () => {
|
||||
it('makes dual-side accounts available to both commercial teams', () => {
|
||||
|
||||
Reference in New Issue
Block a user