Skip to content
11 changes: 6 additions & 5 deletions apps/web/app/lib/personal-kits/kit-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ export const kitNotFound = () => releaseError(404, "not_found", "Not found");
export const badRequest = (message: string) => releaseError(400, "invalid_request", message);
export const conflict = (code: string, message: string) => releaseError(409, code, message);

/** Review and activation of a session-derived revision: a human in a web session, never an API key. */
export function requireInteractive(caller: { interactive: boolean }): void {
if (!caller.interactive) {
throw releaseError(403, "kit.interactive_approval_required", "approve session-derived revisions signed in on the web, not with an API key");
}
/**
* Review and activation of a session-derived revision, and any activation
* that makes a kit public: a human in a web session, never an API key.
*/
export function requireInteractive(caller: { interactive: boolean }, message = "approve session-derived revisions signed in on the web, not with an API key"): void {
if (!caller.interactive) throw releaseError(403, "kit.interactive_approval_required", message);
}

/** `@handle/name` of the route; malformed is 400. */
Expand Down
59 changes: 49 additions & 10 deletions apps/web/app/lib/personal-kits/kit-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
* exact `revisionDigest`, re-checks every base of the effective lineage and
* moves `effective_revision_id` under the generation guard; nothing becomes
* effective implicitly. A proposal (`proposed`) must be reviewed first, and
* a session-derived revision activates only on a private kit and only from
* an interactive web session. A quarantined revision anywhere in the chain
* a revision with a session-derived revision anywhere in its history (its
* revision chain, any origin, and the revision chain of every parent revision
* its document lineage names; an unreadable parent counts) activates only on a private kit,
* as a private document, and only from an interactive web session. Any
* activation that makes a kit public needs a web session too. A quarantined revision anywhere in the chain
* fails the lock with `kit.locked_incompatible`.
* Rejection is final for that revision; a proposal is rejected only through
* its web-session review, never through the generic reject. The lock is derived on read from the
Expand All @@ -20,13 +23,16 @@ import type { KitCaller } from "./kit-http";
import { commitKitMutation, documentOf, getRevision, ownedKit, writableKit } from "./kit-store";
import type { KitRow, RevisionRow } from "./kit-store";
import { chainView, loadChain } from "./kit-verify";
import type { LoadedChain } from "./kit-verify";
import { generationConflict } from "./kit-revisions";

const ACTIVATABLE = new Set(["draft", "reviewed"]);

interface LockCheck {
/** Null when the lineage is incomplete; `reasons` then says why. */
lock: Awaited<ReturnType<typeof buildKitLock>> | null;
/** The parent revisions the document lineage loaded. */
parents: LoadedChain["parents"];
documents: unknown[];
reasons: string[];
warnings: string[];
Expand All @@ -36,7 +42,7 @@ interface LockCheck {
export async function checkLock(caller: KitCaller, rev: RevisionRow): Promise<LockCheck> {
const reasons: string[] = rev.state === "quarantined" ? ["the revision is quarantined"] : [];
const loaded = await loadChain(caller, documentOf(rev), reasons);
if (loaded.incomplete) return { lock: null, documents: [], reasons, warnings: [] };
if (loaded.incomplete) return { lock: null, parents: loaded.parents, documents: [], reasons, warnings: [] };
const { chain } = loaded;
const warnings: string[] = [];
for (const { entry } of resolveLineage(chain)) {
Expand All @@ -50,7 +56,7 @@ export async function checkLock(caller: KitCaller, rev: RevisionRow): Promise<Lo
}
}
const lock = await buildKitLock(chain, { id: rev.id, number: Number(rev.revision_number), digest: rev.revision_digest });
return { lock, documents: chainView(loaded), reasons, warnings };
return { lock, parents: loaded.parents, documents: chainView(loaded), reasons, warnings };
}

/** 409 with the failing pins listed in `error.reasons`. */
Expand All @@ -62,6 +68,30 @@ function requireApproval(raw: unknown, rev: RevisionRow): void {
if (digest !== rev.revision_digest) throw conflict("kit.approval_required", "approval.revisionDigest must name the exact revision being activated");
}

/**
* True when `rev` or any revision in its parent chain, whatever its origin,
* is session-derived: it inherits the web-session activation gate. A parent
* that cannot be read (missing, or in another kit) counts as session-derived,
* so the check fails closed.
*/
async function derivesFromSession(db: D1Database, kitId: string, rev: RevisionRow): Promise<boolean> {
if (rev.origin === "session-derived") return true;
const row = await db
.prepare(
`WITH RECURSIVE chain(id, origin, parent) AS (
SELECT id, origin, parent_revision_id FROM personal_kit_revisions WHERE id = ? AND kit_id = ?
UNION
SELECT r.id, r.origin, r.parent_revision_id FROM personal_kit_revisions r JOIN chain c ON r.id = c.parent WHERE r.kit_id = ?)
SELECT COUNT(*) AS n,
COALESCE(SUM(origin = 'session-derived'), 0) AS tainted,
COALESCE(SUM(parent IS NOT NULL AND parent NOT IN (SELECT id FROM chain)), 0) AS dangling
FROM chain`,
)
.bind(rev.id, kitId, kitId)
.first<{ n: number; tainted: number; dangling: number }>();
return row === null || Number(row.n) === 0 || Number(row.tainted) > 0 || Number(row.dangling) > 0;
}

/** `POST .../activate` and `POST .../rollback` (rollback: a revision that was effective before). */
export async function activateRevision(caller: KitCaller, kitName: string, body: Record<string, unknown>, generation: number, rollback: boolean): Promise<Response> {
const kit = await writableKit(caller.env.DB, kitName, caller.userId);
Expand All @@ -71,17 +101,26 @@ export async function activateRevision(caller: KitCaller, kitName: string, body:
if (rev.state === "proposed") throw conflict("kit.review_required", "a proposed revision must be reviewed before activation");
if (!ACTIVATABLE.has(rev.state)) throw conflict("kit.revision_unusable", `the revision is ${rev.state}`);
const sessionDerived = rev.origin === "session-derived";
if (sessionDerived) {
if (rev.state !== "reviewed") throw conflict("kit.review_required", "a session-derived revision must be reviewed");
if (sessionDerived && rev.state !== "reviewed") throw conflict("kit.review_required", "a session-derived revision must be reviewed");
const doc = documentOf(rev);
// The lock loads the document lineage: a parent revision in another kit carries its own history.
const check = await checkLock(caller, rev);
const histories = [{ kitId: kit.id, row: rev }, ...check.parents];
let gated = false;
for (const { kitId, row } of histories) if (!gated) gated = await derivesFromSession(caller.env.DB, kitId, row);
if (gated) {
requireInteractive(caller);
// Activation would make the kit private; a public kit is forked to a private one instead.
if (kit.visibility !== "private") throw conflict("proposal.private_kit_required", "a session-derived revision activates on a private kit only; fork this kit to a private kit");
// Both the kit and the visibility the activation writes stay private; a public kit is forked to a private one instead.
if (kit.visibility !== "private" || doc.visibility !== "private") {
throw conflict("proposal.private_kit_required", "a revision derived from a session-derived revision activates on a private kit, as a private document, only");
}
}
// Publishing is a human decision whatever the origin: an API key never makes a kit public.
if (doc.visibility !== "private") requireInteractive(caller, "activate a public revision signed in on the web, not with an API key");
if (rollback && (rev.activated_at === null || rev.id === kit.effective_revision_id)) throw badRequest("rollback targets an earlier effective revision");
if (!rollback && rev.id === kit.effective_revision_id) throw badRequest("the revision is already effective");
const check = await checkLock(caller, rev);
// A parent that could not be loaded fails here, so the gate never trusts a lineage it did not read.
if (check.reasons.length > 0) return incompatible(check.reasons);
const doc = documentOf(rev);
const at = caller.now.getTime();
// A reviewed proposal keeps its review proof next to the activation approval.
const review = sessionDerived && rev.approval_json !== null ? (JSON.parse(rev.approval_json) as { review?: unknown }) : null;
Expand Down
8 changes: 6 additions & 2 deletions apps/web/app/lib/personal-kits/kit-revisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ export async function readRevision(caller: KitCaller, kitName: string, id: strin
return json(revisionView(await getRevision(caller.env.DB, kit.id, id), true));
}

/** Validates, verifies and stores one revision under the generation guard. */
/**
* Validates, verifies and stores one revision under the generation guard. A
* new kit always starts private: only an activation sets its visibility, so
* an API key never creates a public kit.
*/
export async function storeRevision(caller: KitCaller, req: StoreRequest): Promise<{ revision: RevisionRow; generation: number; warnings: string[]; replayed: boolean }> {
const db = caller.env.DB;
const existing = await findKit(db, req.kitName);
Expand Down Expand Up @@ -107,7 +111,7 @@ export async function storeRevision(caller: KitCaller, req: StoreRequest): Promi
const ok = await commitKitMutation(db, {
kitId,
generation: req.expectedGeneration,
create: existing === null ? { name: req.kitName, publisherId, ownerUserId: caller.userId, localId: doc.localId ?? null, visibility: doc.visibility } : undefined,
create: existing === null ? { name: req.kitName, publisherId, ownerUserId: caller.userId, localId: doc.localId ?? null, visibility: "private" } : undefined,
statements: [
{
sql: `INSERT INTO personal_kit_revisions (id, kit_id, revision_number, parent_revision_id, revision_digest, document_json, state, origin,
Expand Down
8 changes: 6 additions & 2 deletions apps/web/app/lib/personal-kits/kit-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface LoadedChain {
foreign: Set<number>;
/** A parent was unavailable (reported in `reasons`), so `chain` stops before it. */
incomplete: boolean;
/** The parent revision rows behind `chain[1..]`, with the id of their kit. */
parents: Array<{ kitId: string; row: RevisionRow }>;
}

/**
Expand All @@ -76,6 +78,7 @@ export async function loadChain(caller: KitCaller, doc: PersonalKitDocument, rea
const { env, userId } = caller;
const chain = [doc];
const foreign = new Set<number>();
const parents: LoadedChain["parents"] = [];
let parentPublic = true;
for (let cur = doc; cur.parent !== undefined; cur = chain[chain.length - 1]!) {
if (chain.length >= MAX_LINEAGE_DEPTH || chain.some((d, i) => i > 0 && d.kit === cur.parent!.kit) || cur.parent.kit === doc.kit) {
Expand All @@ -92,14 +95,15 @@ export async function loadChain(caller: KitCaller, doc: PersonalKitDocument, rea
const detail = `parent revision ${cur.parent.revisionDigest} of ${cur.parent.kit} is not available`;
if (reasons === undefined) throw kitError(KIT_CODES.baseUnavailable, "parent", detail);
reasons.push(detail);
return { chain, parentPublic: false, foreign, incomplete: true };
return { chain, parentPublic: false, foreign, incomplete: true, parents };
}
if (kit!.visibility !== "public") parentPublic = false;
if (!owned) foreign.add(chain.length);
parents.push({ kitId: kit!.id, row: row! });
chain.push(documentOf(row!));
}
resolveLineage(chain);
return { chain, parentPublic, foreign, incomplete: false };
return { chain, parentPublic, foreign, incomplete: false, parents };
}

/** The chain as returned to the caller: own documents in full, foreign ancestors as digest references only. */
Expand Down
Loading
Loading