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:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user