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:
+34
-50
@@ -26,7 +26,6 @@ import {
|
||||
} from '@pig/db';
|
||||
import {
|
||||
ACCENTS,
|
||||
ACTIVITY_TYPES,
|
||||
DEMAND_STAGES,
|
||||
SECURITY_TIERS,
|
||||
SUPPLY_STAGES,
|
||||
@@ -58,13 +57,17 @@ import { createRecordRoutes } from './routes/records';
|
||||
import { createImportRoutes } from './routes/imports';
|
||||
import { createGoogleSheetsRoutes } from './routes/google-sheets';
|
||||
import { createContractRoutes } from './routes/contracts';
|
||||
import { createPiggyChatRoutes } from './routes/piggy-chat';
|
||||
import { createPiggyChatRoutes, platformPiggyEnabled } from './routes/piggy-chat';
|
||||
import { createAdminSettingsRoutes } from './routes/admin-settings';
|
||||
import { createSlackRoutes, SLACK_CAPACITY_COMMAND_PATH } from './routes/slack';
|
||||
import { createBuzzRoutes } from './routes/buzz';
|
||||
import { createIntegrationSettingsRoutes } from './routes/integration-settings';
|
||||
import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/notion-import';
|
||||
import { createGrowthRoutes } from './routes/growth';
|
||||
import { createCalendarRoutes } from './routes/calendar';
|
||||
import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn';
|
||||
import { createReadGuardRoutes } from './routes/read-guards';
|
||||
import { createActivityRoutes } from './routes/activities';
|
||||
import { NotificationOutbox } from './services/notification-outbox';
|
||||
|
||||
type Env = { Variables: { principal: Principal } };
|
||||
@@ -142,6 +145,13 @@ export function createApp(
|
||||
path === '/api/register'
|
||||
|| path === SLACK_CAPACITY_COMMAND_PATH
|
||||
|| path === NOTION_OAUTH_CALLBACK_PATH
|
||||
// Learn is reachable with a share code and no account. These two paths
|
||||
// are exact-string matches, deliberately: /api/learn and
|
||||
// /api/learn/resources/* stay behind the authenticator, and the public
|
||||
// reader is structurally incapable of naming a row that is not both
|
||||
// platform-track and code-visible.
|
||||
|| path === LEARN_ACCESS_PATH
|
||||
|| path === LEARN_PUBLIC_PATH
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
@@ -156,6 +166,19 @@ export function createApp(
|
||||
return next();
|
||||
});
|
||||
|
||||
/*
|
||||
* Read authorisation, mounted before every handler it guards.
|
||||
*
|
||||
* Hono runs matched handlers in registration order, so a guard registered
|
||||
* after its route never runs and returns 200 while looking correct. That is
|
||||
* why this sits here rather than beside the feature routes below, and why
|
||||
* read-governance.test.ts pins the ordering in both directions.
|
||||
*
|
||||
* The policy is one table in read-guards.ts precisely so that "who can see
|
||||
* cost?" has a single answer rather than one per route.
|
||||
*/
|
||||
app.route('/', createReadGuardRoutes());
|
||||
|
||||
// ---------------------------------------------------------------- identity
|
||||
|
||||
app.get('/api/me', (c) => {
|
||||
@@ -220,12 +243,20 @@ export function createApp(
|
||||
}));
|
||||
app.route('/', createContractRoutes(db));
|
||||
app.route('/', createGrowthRoutes(db));
|
||||
app.route('/', createCalendarRoutes(db));
|
||||
app.route('/', createLearnRoutes(db));
|
||||
app.route(
|
||||
'/',
|
||||
createPiggyChatRoutes({
|
||||
enabled: config.PIGGY_ENABLED,
|
||||
internalUrl: config.PIGGY_INTERNAL_URL,
|
||||
internalToken: config.PIGGY_INTERNAL_TOKEN,
|
||||
// Without this the stored toggle is never consulted and isAvailable()
|
||||
// short-circuits to the environment variable, which is the bug the
|
||||
// resolver exists to fix. The tests inject their own resolver, so they
|
||||
// stay green whether or not this line is here — it is the composition
|
||||
// that has to be right.
|
||||
resolvePiggyEnabled: platformPiggyEnabled(config, db),
|
||||
}),
|
||||
);
|
||||
app.route('/', createSlackRoutes(config, db, capacity));
|
||||
@@ -358,54 +389,7 @@ export function createApp(
|
||||
app.route('/', createCapacityWriteRoutes(db));
|
||||
app.route('/', createFactsRoute(db));
|
||||
|
||||
// ------------------------------------------------------------- activities
|
||||
|
||||
const activitySchema = z.object({
|
||||
accountId: z.string().uuid().optional(),
|
||||
contactId: z.string().uuid().optional(),
|
||||
demandDealId: z.string().uuid().optional(),
|
||||
supplyDealId: z.string().uuid().optional(),
|
||||
type: z.enum(ACTIVITY_TYPES),
|
||||
subject: z.string().min(1).max(200),
|
||||
body: z.string().max(8000).optional(),
|
||||
occurredAt: z.string().datetime().optional(),
|
||||
externalId: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
app.post('/api/activities', async (c) => {
|
||||
const p = c.get('principal');
|
||||
const parsed = activitySchema.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid activity', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
const { occurredAt, ...rest } = parsed.data;
|
||||
const when = occurredAt ? new Date(occurredAt) : new Date();
|
||||
|
||||
const [created] = await db
|
||||
.insert(activities)
|
||||
.values({
|
||||
...rest,
|
||||
occurredAt: when,
|
||||
actorUserId: p.userId,
|
||||
// An agent acting for someone is recorded as such, so the log
|
||||
// distinguishes what a person did from what was done on their behalf.
|
||||
actorAgent: p.via === 'api_key' ? 'agent' : null,
|
||||
source: p.via === 'api_key' ? 'agent' : 'manual',
|
||||
})
|
||||
// An `externalId` collision means this event was already synced from
|
||||
// Slack or Buzz; silently ignoring the duplicate keeps sync idempotent.
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (rest.accountId) {
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({ lastActivityAt: when })
|
||||
.where(eq(accounts.id, rest.accountId));
|
||||
}
|
||||
|
||||
return c.json(created ?? { deduplicated: true }, created ? 201 : 200);
|
||||
});
|
||||
app.route('/', createActivityRoutes(db));
|
||||
|
||||
// ---------------------------------------------------------------- capacity
|
||||
|
||||
|
||||
Reference in New Issue
Block a user