Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A playground component's Published switch publishes its source too

The Published switch on an admin component page called the generic publication endpoint for every
kind. For a browser-authored component that endpoint promoted the description and marked it
published without copying the playground draft, so Bots were offered a component the renderer could
not draw. Playground components now publish and withdraw both rows in one transaction; a missing or
empty description or HTML is refused instead of leaving a half-published component, and repeating
an unchanged publish no longer advances the revision.


### A proxy password containing `%` no longer stops every shell command

A proxy password with a `%` that does not start an escape, such as `p%zz`, made decoding it throw.
Expand Down
8 changes: 7 additions & 1 deletion server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1537,7 +1537,13 @@ export function createApp(
if (componentStore) {
app.route(
"/api/components",
createComponentRoutes(componentStore, requireUser, auditStore, canUseBot),
createComponentRoutes(
componentStore,
requireUser,
auditStore,
canUseBot,
sandboxedStore,
),
);
}

Expand Down
53 changes: 50 additions & 3 deletions server/src/components/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import { recordAuditEvent } from "../audit";
import type { AppVariables } from "../auth/guards";
import { requireAdmin } from "../auth/guards";
import { DATA_FUNCTIONS, dataFunction } from "./functions";
import { ComponentNotFoundError, type ComponentStore } from "./store";
import {
SandboxedNotFoundError,
SandboxedPublicationRefusedError,
type SandboxedStore,
} from "./sandboxed";
import {
ComponentNotFoundError,
type ComponentStore,
SandboxedPublicationRequiredError,
} from "./store";

/**
* The local development actor, which is not a row in `users`.
Expand Down Expand Up @@ -39,6 +48,14 @@ export function createComponentRoutes(
* behind `requireAdmin`.
*/
canUseBot: BotAccessCheck,
/**
* The source half of a playground component's publication.
*
* The generic switch is the only publication UI for both kinds, so this route owns the choice to
* delegate. The component store still refuses a generic write for a sandboxed row, which keeps a
* caller that reaches `publish` directly from creating the same half-published state.
*/
sandboxedStore?: SandboxedStore,
) {
const routes = new Hono<{ Variables: AppVariables }>();

Expand Down Expand Up @@ -485,19 +502,49 @@ export function createComponentRoutes(
}
const published = body.published;

let recordedBySource = false;
try {
if (published) {
await store.publish(name, context.var.actor.email);
} else {
await store.unpublish(name, context.var.actor.email);
}
} catch (error) {
if (error instanceof ComponentNotFoundError) {
if (error instanceof SandboxedPublicationRequiredError) {
if (!sandboxedStore) {
return context.json(
{
error:
"The playground source is unavailable, so the component was not published.",
},
409,
);
}
try {
if (published) {
await sandboxedStore.publish(name, context.var.actor.email);
} else {
await sandboxedStore.unpublish(name, context.var.actor.email);
}
recordedBySource = true;
} catch (sourceError) {
if (sourceError instanceof SandboxedPublicationRefusedError) {
return context.json({ error: sourceError.message }, 409);
}
if (sourceError instanceof SandboxedNotFoundError) {
return context.json({ error: sourceError.message }, 404);
}
throw sourceError;
}
} else if (error instanceof ComponentNotFoundError) {
return context.json({ error: error.message }, 404);
} else {
throw error;
}
throw error;
}

if (recordedBySource) return context.json({ published });

await audit(
context,
published ? "component.published" : "component.unpublished",
Expand Down
185 changes: 156 additions & 29 deletions server/src/components/sandboxed.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDeepStrictEqual } from "node:util";
import { asc, eq } from "drizzle-orm";
import { type AuditStore, recordAuditEvent } from "../audit";
import type { Database } from "../db/client";
Expand Down Expand Up @@ -73,9 +74,21 @@ export class SandboxedNameRefusedError extends Error {
}
}

/** A playground component cannot be published as a drawable thing until its source is usable. */
export class SandboxedPublicationRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = "SandboxedPublicationRefusedError";
}
}

const iso = (value: Date | string | null): string | null =>
value === null ? null : value instanceof Date ? value.toISOString() : value;

function sameJson(left: unknown, right: unknown): boolean {
return isDeepStrictEqual(left, right);
}

/**
* The one place a sandboxed component's name is decided.
*
Expand Down Expand Up @@ -238,42 +251,156 @@ export function createSandboxedStore(
* nobody wants and both would be reachable if this were two endpoints.
*/
async publish(name: string, by: string): Promise<SandboxedRecord> {
const row = await requireRow(name);
const outcome = await database.transaction(async (transaction) => {
const [row] = await transaction
.select()
.from(sandboxedComponents)
.where(eq(sandboxedComponents.name, name))
.limit(1)
.for("update");
if (!row) {
throw new SandboxedPublicationRefusedError(
`${name} has no stored playground source to publish.`,
);
}

await database
.update(sandboxedComponents)
.set({
publishedDescription: row.draftDescription,
publishedHtml: row.draftHtml,
publishedCss: row.draftCss,
publishedJsFunctions: row.draftJsFunctions,
publishedArgumentSchema: row.draftArgumentSchema,
published: true,
publishedAt: new Date(),
revision: row.revision + 1,
updatedAt: new Date(),
})
.where(eq(sandboxedComponents.name, name));
const [governance] = await transaction
.select()
.from(components)
.where(eq(components.name, name))
.limit(1)
.for("update");
if (governance?.kind !== "sandboxed") {
throw new SandboxedPublicationRefusedError(
`${name} has no playground governance row to publish.`,
);
}

await database
.update(components)
.set({
publishedDescription: row.draftDescription,
published: true,
publishedAt: new Date(),
updatedBy: by,
updatedAt: new Date(),
})
.where(eq(components.name, name));
if (!row.draftDescription.trim()) {
throw new SandboxedPublicationRefusedError(
`${name} needs a description before it can be published.`,
);
}
if (!row.draftHtml.trim()) {
throw new SandboxedPublicationRefusedError(
`${name} needs rendered HTML before it can be published.`,
);
}

const unchanged =
row.published &&
governance.published &&
row.publishedDescription === row.draftDescription &&
row.publishedHtml === row.draftHtml &&
row.publishedCss === row.draftCss &&
row.publishedJsFunctions === row.draftJsFunctions &&
sameJson(row.publishedArgumentSchema, row.draftArgumentSchema);
if (unchanged) return { record: toRecord(row), changed: false };

const now = new Date();
const revision = row.revision + 1;
await transaction
.update(sandboxedComponents)
.set({
publishedDescription: row.draftDescription,
publishedHtml: row.draftHtml,
publishedCss: row.draftCss,
publishedJsFunctions: row.draftJsFunctions,
publishedArgumentSchema: row.draftArgumentSchema,
published: true,
publishedAt: now,
revision,
updatedAt: now,
})
.where(eq(sandboxedComponents.name, name));

await transaction
.update(components)
.set({
publishedDescription: row.draftDescription,
published: true,
publishedAt: now,
updatedBy: by,
updatedAt: now,
})
.where(eq(components.name, name));

const [updated] = await transaction
.select()
.from(sandboxedComponents)
.where(eq(sandboxedComponents.name, name))
.limit(1);
if (!updated) {
throw new SandboxedPublicationRefusedError(
`${name} disappeared while it was being published.`,
);
}
return { record: toRecord(updated), changed: true };
});

if (outcome.changed) {
await recordAuditEvent(auditStore, {
eventType: "component.published",
targetType: "component",
targetId: name,
payload: {
actor: by,
kind: "sandboxed",
revision: outcome.record.revision,
},
});
}
return outcome.record;
},

/**
* Withdraw a playground component from every Bot.
*
* The source and the governance row are two halves of one publication, so they are withdrawn
* together. The published columns stay put: re-publishing the same draft is a decision to make
* it drawable again, not a request to reconstruct source that was deliberately retained.
*/
async unpublish(name: string, by: string): Promise<void> {
const changed = await database.transaction(async (transaction) => {
const [governance] = await transaction
.select()
.from(components)
.where(eq(components.name, name))
.limit(1)
.for("update");
if (governance?.kind !== "sandboxed") {
throw new SandboxedNotFoundError(name);
}

const [row] = await transaction
.select()
.from(sandboxedComponents)
.where(eq(sandboxedComponents.name, name))
.limit(1)
.for("update");
if (!governance.published && !row?.published) return false;

const now = new Date();
if (row) {
await transaction
.update(sandboxedComponents)
.set({ published: false, updatedAt: now })
.where(eq(sandboxedComponents.name, name));
}
await transaction
.update(components)
.set({ published: false, updatedBy: by, updatedAt: now })
.where(eq(components.name, name));
return true;
});

if (!changed) return;
await recordAuditEvent(auditStore, {
eventType: "component.published",
eventType: "component.unpublished",
targetType: "component",
targetId: name,
payload: { actor: by, kind: "sandboxed", revision: row.revision + 1 },
payload: { actor: by, kind: "sandboxed" },
});

return toRecord(await requireRow(name));
},

async remove(name: string, by: string): Promise<void> {
Expand Down
23 changes: 22 additions & 1 deletion server/src/components/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ export class ComponentNotFoundError extends Error {
}
}

/**
* The generic publication path found a playground component.
*
* Its source is not in this table and a generic write would publish the governance half alone. The
* route catches this and delegates to the sandboxed store, which owns the two-row transaction.
*/
export class SandboxedPublicationRequiredError extends Error {
constructor(name: string) {
super(
`${name} is a playground component and must be published from its source.`,
);
this.name = "SandboxedPublicationRequiredError";
}
}

/** What a build says it can draw. The app announces this; the server keeps no copy of its own. */
export type CatalogueEntry = {
name: string;
Expand Down Expand Up @@ -307,6 +322,9 @@ export function createComponentStore(database: Database): ComponentStore {

async publish(name, by) {
const row = await requireComponent(name);
if (row.kind === "sandboxed") {
throw new SandboxedPublicationRequiredError(name);
}
await database
.update(components)
.set({
Expand All @@ -321,7 +339,10 @@ export function createComponentStore(database: Database): ComponentStore {
},

async unpublish(name, by) {
await requireComponent(name);
const row = await requireComponent(name);
if (row.kind === "sandboxed") {
throw new SandboxedPublicationRequiredError(name);
}
await database
.update(components)
.set({ published: false, updatedBy: by, updatedAt: new Date() })
Expand Down
Loading