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