Rebuild the shell, add Calendar and Learn, and govern reads
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped

Seven parallel agents and an adversarial verification pass. The three things
worth knowing before reading the diff:

RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is
stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago.
So this does not rebuild them; it closes the gaps an audit found. The big one
is that reads were entirely ungoverned: every GET was "any authenticated
member", so a junior demand rep and a research contractor could both pull
per-block supplier cost and break-even prices from /api/capacity/margin, and
every contract's negotiated terms. For a company whose margin is the business,
that was the hole that mattered. Adds book:read / economics:read / team:read,
a readGuard middleware, and a `viewer` role below member.

THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen.
Contracts.tsx never called can() at all, so its save button was always enabled
against a server requiring contract:sign; Capacity.tsx gated commitment
creation on deal:write/demand while the server wanted commitment:write/supply.

POST /api/activities was the one write bypassing executeMutation: no capability
check, and any member could mutate accounts.lastActivityAt as a side effect.
It is now a proper mutation() behind activity:write.

The shell becomes three panes — a collapsible shadcn sidebar with an account
switcher on the Piggy accent, a header with real search, and Piggy docked to
the right, page-aware and persistent across navigation. The phone keeps its
bottom tab bar, which is the thing this product already beat trycompai/crm on,
and gains the sidebar as a sheet.

Calendar is a projection over thirteen dated sources rather than a new table,
because a table would duplicate dates that already live on contracts, deals and
commitments and would drift — and one ledger answering the question is the
whole argument. It surfaces export_authorizations and compliance_artifacts,
which had indexed expires_at columns, schema comments saying they must be
alerted on, and no read endpoint or UI anywhere.

Learn carries two tracks. Concepts are members-only; the platform track can be
opened with a share code by someone with no account. The code mints a scoped
learn-only token and never a Principal — every route here resolves a principal
and then checks capabilities, so a principal-minting code would be one missing
check away from leaking the book. "Only platform-track rows may be code-visible"
is a database CHECK constraint as well as a write-path rule, and a test asserts
a valid learn token still gets 401 on /api/dashboard, /api/accounts and
/api/contracts — the same invariant scripts/deploy.sh refuses to ship without.

CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a
release-* tag and cloud-2 pulls it, so no credential on the shared runner can
execute anything on production — by construction rather than by policy. Both
halves of deploy.sh's original rule survive: nothing on the runner reaches the
host, and a human still decides when it ships. deploy.sh gains a rollback and a
public-origin check, and PIG_IMAGE now reaches compose through `sudo env`,
without which sudo's env_reset silently resolved every release to pig:local.

Tests 141 -> 261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 15:02:48 -07:00
parent 6cf80747cc
commit 13dec6b4b8
102 changed files with 28638 additions and 913 deletions
+307
View File
@@ -0,0 +1,307 @@
/**
* Learn — the two tracks, and the host allowlist that turns a pasted link into
* an iframe source.
*
* **Two tracks, and they are not the same kind of thing.** `supply` and
* `demand` are CONCEPT material: how this market actually works, taught to the
* GTM team that runs that side. `platform` is PIG itself — onboarding, feature
* walkthroughs, demos. The distinction is load-bearing rather than cosmetic,
* because the access code unlocks exactly one of them.
*
* **Only the platform track may be visible to a code-holder.** Someone holding
* the share code has no account and no principal; they may see how the product
* works, because that is a sales asset. They may not see how we source and
* price capacity. This predicate is enforced three times on purpose — here, in
* the API write path, and in a database CHECK constraint — because a concept
* video becoming anon-visible through a mistake in a form is the failure that
* matters, and a UI-only rule does not survive an API caller.
*
* **The allowlist is the whole XSS surface of the feature.** A learn resource
* is a URL somebody pasted, and it ends up as an `iframe src`. So a pasted URL
* is never stored as a source and never rendered as one: it is resolved
* through the table below into a *provider* and an *external id*, and every
* embed URL is rebuilt from a hardcoded template and a pattern-checked id.
* Anything the table does not match is rejected at the write path, so a row
* that cannot be rendered safely cannot exist.
*
* Adding a provider is one row here plus one host in the proxy's `frame-src`
* (see `LEARN_FRAME_SRC_HOSTS`). Do not add a row whose URL shape has not been
* checked against the running service — the id extraction is what decides
* whether a hostile path becomes a trusted embed.
*/
// ---------------------------------------------------------------------------
// Tracks and visibility
// ---------------------------------------------------------------------------
export const LEARN_TRACKS = ['supply', 'demand', 'platform'] as const;
export type LearnTrack = (typeof LEARN_TRACKS)[number];
/** The tracks that teach the market rather than the product. Members only. */
export const LEARN_CONCEPT_TRACKS = ['supply', 'demand'] as const satisfies readonly LearnTrack[];
export type LearnConceptTrack = (typeof LEARN_CONCEPT_TRACKS)[number];
/** The one track a code-holder may reach. Named once; referenced everywhere. */
export const LEARN_CODE_TRACK = 'platform' as const satisfies LearnTrack;
export const LEARN_TRACK_LABELS: Record<LearnTrack, string> = {
supply: 'Supply',
demand: 'Demand',
platform: 'Platform',
};
export const LEARN_TRACK_DESCRIPTIONS: Record<LearnTrack, string> = {
supply: 'How capacity is sourced, qualified, priced and contracted.',
demand: 'How compute is sold, renewed and expanded.',
platform: 'Onboarding, feature walkthroughs and product demos of PIG itself.',
};
export const LEARN_VISIBILITIES = ['members', 'code'] as const;
export type LearnVisibility = (typeof LEARN_VISIBILITIES)[number];
export function isLearnTrack(value: string): value is LearnTrack {
return (LEARN_TRACKS as readonly string[]).includes(value);
}
export function isLearnVisibility(value: string): value is LearnVisibility {
return (LEARN_VISIBILITIES as readonly string[]).includes(value);
}
/**
* May this track carry this visibility?
*
* Phrased as a predicate over the pair rather than "is this track public", so
* that the check reads the same in the write path and in the CHECK constraint
* and neither can drift into asking a subtly different question.
*/
export function learnVisibilityPermitted(track: LearnTrack, visibility: LearnVisibility): boolean {
return visibility !== 'code' || track === LEARN_CODE_TRACK;
}
// ---------------------------------------------------------------------------
// The provider allowlist
// ---------------------------------------------------------------------------
export const LEARN_PROVIDERS = ['cap', 'loom', 'youtube_nocookie'] as const;
export type LearnProvider = (typeof LEARN_PROVIDERS)[number];
export interface LearnProviderDefinition {
provider: LearnProvider;
label: string;
/**
* Disabled providers are inert: a pasted link matching one is rejected, so a
* row can never be created and nothing can ever be framed from it. They are
* listed so that turning one on is a flag and a CSP host rather than a
* design exercise under time pressure.
*/
enabled: boolean;
/** Exact hostnames. Never a suffix match — `evil-loom.com` ends in loom.com. */
hosts: readonly string[];
/** Path prefixes whose NEXT segment is the id, and nothing after it. */
idSegmentPrefixes: readonly string[];
/** The id charset, anchored. Everything downstream trusts this. */
idPattern: RegExp;
embed(externalId: string): string;
watch(externalId: string): string;
/** What the proxy's `frame-src` needs before this provider can render. */
frameSrc: string;
}
/**
* Cap (cap.so) self-hosted at video.karti.ai.
*
* Verified against the running instance rather than assumed: the Next.js app
* carries `app/s/[videoId]` and `app/embed/[videoId]`, both of which answer 404
* for an unknown id — which is how we know the routes exist at all, since
* every unrouted path there answers 307 instead. Ids observed in that
* instance's database are lowercase alphanumeric, 15 characters; the pattern
* is deliberately a little wider than that and no wider.
*
* HyperFrames (app.heygen.com) is the next one wanted. It is absent rather
* than disabled because its share/embed path shape has not been checked
* against the live service, and guessing that is precisely how an id
* extraction ends up accepting a path it should not.
*/
export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
{
provider: 'cap',
label: 'Cap',
enabled: true,
hosts: ['video.karti.ai'],
idSegmentPrefixes: ['s', 'embed'],
idPattern: /^[a-z0-9]{8,32}$/,
embed: (id) => `https://video.karti.ai/embed/${id}`,
watch: (id) => `https://video.karti.ai/s/${id}`,
frameSrc: 'https://video.karti.ai',
},
{
provider: 'loom',
label: 'Loom',
enabled: false,
hosts: ['www.loom.com', 'loom.com'],
idSegmentPrefixes: ['share', 'embed'],
idPattern: /^[a-f0-9]{16,64}$/,
embed: (id) => `https://www.loom.com/embed/${id}`,
watch: (id) => `https://www.loom.com/share/${id}`,
frameSrc: 'https://www.loom.com',
},
{
provider: 'youtube_nocookie',
label: 'YouTube',
enabled: false,
// The nocookie host only, never youtube.com: the point of listing YouTube
// at all is the privacy-preserving embed, and accepting the ordinary host
// would quietly reintroduce the tracking this avoids.
hosts: ['www.youtube-nocookie.com', 'youtube-nocookie.com'],
idSegmentPrefixes: ['embed'],
idPattern: /^[A-Za-z0-9_-]{11}$/,
embed: (id) => `https://www.youtube-nocookie.com/embed/${id}`,
watch: (id) => `https://www.youtube-nocookie.com/embed/${id}`,
frameSrc: 'https://www.youtube-nocookie.com',
},
];
/** Hosts the proxy must allow in `frame-src` for the enabled providers. */
export const LEARN_FRAME_SRC_HOSTS: readonly string[] = LEARN_PROVIDER_TABLE.filter(
(definition) => definition.enabled,
).map((definition) => definition.frameSrc);
export function learnProviderDefinition(
provider: LearnProvider,
): LearnProviderDefinition | undefined {
return LEARN_PROVIDER_TABLE.find((definition) => definition.provider === provider);
}
export const LEARN_EMBED_REJECTIONS = [
'malformed_url',
'insecure_scheme',
'unknown_host',
'provider_disabled',
'unrecognised_path',
'malformed_id',
] as const;
export type LearnEmbedRejection = (typeof LEARN_EMBED_REJECTIONS)[number];
export type LearnEmbedResolution =
| {
ok: true;
provider: LearnProvider;
externalId: string;
/** Canonical share link. Safe to show a human; never an iframe source. */
watchUrl: string;
embedUrl: string;
}
| { ok: false; reason: LearnEmbedRejection };
export const LEARN_EMBED_REJECTION_MESSAGES: Record<LearnEmbedRejection, string> = {
malformed_url: 'That is not a URL.',
insecure_scheme: 'Only https links can be embedded.',
unknown_host: `Links from that host are not allowed. Allowed: ${LEARN_PROVIDER_TABLE.filter((d) => d.enabled).map((d) => d.hosts[0]).join(', ')}.`,
provider_disabled: 'That provider is recognised but not enabled yet.',
unrecognised_path: 'That looks like the right host but not a share link.',
malformed_id: 'The video id in that link is not a shape we recognise.',
};
/**
* Extract the id from a path, or null.
*
* The segment must be the LAST one. `/s/<id>/../../anything` and
* `/s/<id>/edit` are both rejected rather than silently truncated to `<id>`,
* because "close enough to a share link" is not a category this function is
* allowed to have.
*/
function externalIdFromPath(definition: LearnProviderDefinition, pathname: string): string | null {
const segments = pathname.split('/').filter(Boolean);
if (segments.length !== 2) return null;
const [prefix, candidate] = segments;
if (!prefix || !candidate) return null;
if (!definition.idSegmentPrefixes.includes(prefix)) return null;
return candidate;
}
/**
* Turn a pasted URL into a provider and an id, or say why not.
*
* Everything a caller may render is rebuilt from the template in the table.
* The input string itself is never returned as a URL, so a resolution result
* cannot carry an attacker's bytes into an attribute.
*/
export function resolveLearnEmbed(raw: string): LearnEmbedResolution {
let parsed: URL;
try {
parsed = new URL(raw.trim());
} catch {
return { ok: false, reason: 'malformed_url' };
}
// `javascript:` and `data:` are the obvious ones; `http:` matters too,
// because framing it from an https page is blocked anyway and storing it
// produces a resource that silently never plays.
if (parsed.protocol !== 'https:') return { ok: false, reason: 'insecure_scheme' };
// `https://video.karti.ai@evil.example/` parses with hostname `evil.example`
// and reads to a human as the trusted host. Never legitimate here.
if (parsed.username || parsed.password) return { ok: false, reason: 'malformed_url' };
// A trusted hostname on an unexpected port is a different service.
if (parsed.port) return { ok: false, reason: 'malformed_url' };
const host = parsed.hostname.toLowerCase();
const definition = LEARN_PROVIDER_TABLE.find((candidate) => candidate.hosts.includes(host));
if (!definition) return { ok: false, reason: 'unknown_host' };
if (!definition.enabled) return { ok: false, reason: 'provider_disabled' };
const externalId = externalIdFromPath(definition, parsed.pathname);
if (externalId === null) return { ok: false, reason: 'unrecognised_path' };
if (!definition.idPattern.test(externalId)) return { ok: false, reason: 'malformed_id' };
return {
ok: true,
provider: definition.provider,
externalId,
watchUrl: definition.watch(externalId),
embedUrl: definition.embed(externalId),
};
}
/**
* Rebuild an embed source from stored columns.
*
* Re-validates the id rather than trusting the database. A row written before
* a pattern was tightened, or by a future code path that skipped the resolver,
* must not be framed on the strength of having been persisted once.
*/
export function learnEmbedUrl(provider: LearnProvider, externalId: string): string | null {
const definition = learnProviderDefinition(provider);
if (!definition || !definition.enabled) return null;
if (!definition.idPattern.test(externalId)) return null;
return definition.embed(externalId);
}
/** The human-facing share link, on the same terms. */
export function learnWatchUrl(provider: LearnProvider, externalId: string): string | null {
const definition = learnProviderDefinition(provider);
if (!definition || !definition.enabled) return null;
if (!definition.idPattern.test(externalId)) return null;
return definition.watch(externalId);
}
// ---------------------------------------------------------------------------
// Presentation helpers
// ---------------------------------------------------------------------------
/**
* `4:32`, or `1:04:12` past the hour.
*
* Here rather than in the web app because the duration is also rendered by the
* public code-holder view, and two formatters would eventually disagree about
* whether a 61-minute video is `61:00` or `1:01:00`.
*/
export function formatLearnDuration(seconds: number | null | undefined): string | null {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null;
const whole = Math.round(seconds);
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const secs = whole % 60;
const pad = (value: number) => String(value).padStart(2, '0');
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}