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
28 changes: 28 additions & 0 deletions src/services/extensions/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ against it.
the extension. `POST /extensions/{id}/relist` restores it (optional
`review_note`, same `?notify` opt-out); see `ExtensionsDatabase.delist()`
and `relist()`.
- `POST /extensions/{id}/moderator-correct` corrects a published extension's
live content as a moderator (api#251): truncated readmes, broken links, or
other corruption that should not wait for the author to resubmit. One write
inserts an already-approved revision row (`submitted_by` and `reviewer_id`
both the moderator, `correction_note` as the `review_note`) and publishes
it, leaving `published_at` and ownership untouched; the catalogue is
revalidated afterwards. Requires a published, listed extension with no
pending revision (409 otherwise — approve or reject the pending edit
first, never supersede it). Unlike every other moderation write there is no
`?notify` query and no author email: the correction is recorded in revision
history and surfaced by the directory UI. See `ExtensionsDatabase.moderatorCorrect()`.
- `GET /extensions/{id}` is role-aware: anonymous and unrelated callers get
the published projection (or 404, which hides existence for drafts and
delisted rows); the owner and moderators get the full `OwnedExtension`,
Expand Down Expand Up @@ -70,6 +81,23 @@ missing mail credentials skip the send before the response and report
`https://smtpapi.mxroute.com/`, `resend`, or `disabled`) and the root README
for the `EXTENSIONS_V2_EMAIL_*` configuration.

### Correcting Live Content

Three paths, in order of preference (api#251):

- **Moderator edit** (`POST /extensions/{id}/moderator-correct`): live
catalogue corruption — a truncated readme, a broken link — on a published,
listed extension. Immediate, audited (an approved revision row), no author
email.
- **Ask the author to resubmit**: wording disputes, new releases, unpublished
or delisted extensions, or anything while a pending edit exists. The normal
propose/review queue, with its notification emails.
- **Direct D1 edit (break-glass only)**: the API is down, or the stored data
violates a constraint the API cannot express a fix through. Back up the row
first, write down why in the change record, and trigger a catalogue
revalidate manually afterwards — the audit trail and purge that the API
would have done do not happen by themselves.

The id and the developer are properties of the extension, not of a revision: an
edit cannot rename an extension or move it to another developer, and approving
one no longer rewrites the developer profile as a side effect. A user owns at
Expand Down
210 changes: 210 additions & 0 deletions src/services/extensions/v2/db/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { encodeCursor as encode, decodeCursor as decode } from "./cursor";
import {
Extension,
ExtensionContent,
ExtensionContentSchema,
ExtensionListItem,
License,
OwnedExtension,
Expand Down Expand Up @@ -886,6 +887,215 @@ export class ExtensionsDatabase {
error: { message: "Extension could not be relisted", code: "CONFLICT" }
};
}

// Moderator correction of live catalogue content (api#251): a truncated
// readme or broken link the owner should not have to resubmit to fix. One
// D1 batch() like approve(): the first statement inserts an
// already-approved revision row (submitted_by and reviewer both the
// moderator, ownership_epoch carried from the owner row), the second
// publishes it gated on `changes() = 1`. published_at is left untouched
// and ownership never written; guards on published, listed, no pending
// revision, and active moderator.
async moderatorCorrect(
id: string,
moderatorId: string,
content: ExtensionContent,
correctionNote: string
): Promise<DatabaseResult<{ id: string; revisionId: string }>> {
const parsed = ExtensionContentSchema.safeParse(content);
if (!parsed.success) {
return {
data: null,
error: {
message: "Extension content failed validation",
code: "CONFLICT"
}
};
}
const valid = parsed.data;
const revisionId = crypto.randomUUID();

let results;
try {
const correctStmt = toD1Statement(this.db.$client, {
sql: `INSERT INTO extension_revisions
(id, extension_id, developer_id, submitted_by, status, content,
reviewer_id, review_note, reviewed_at, ownership_epoch)
SELECT ?, e.id, e.developer_id, ?, 'approved', ?, ?, ?, CURRENT_TIMESTAMP, d.ownership_epoch
FROM extensions e
JOIN developers d ON d.id = e.developer_id
WHERE LOWER(e.id) = LOWER(?)
AND e.published_at IS NOT NULL
AND e.delisted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM extension_revisions r
WHERE r.extension_id = e.id AND r.status = 'pending'
)
AND EXISTS (
SELECT 1 FROM users u
WHERE u.id = ? AND u.deleted_at IS NULL AND u.is_moderator = 1
)`,
params: [
revisionId,
moderatorId,
JSON.stringify(valid),
moderatorId,
correctionNote,
id,
moderatorId
]
});

const publishStmt = toD1Statement(this.db.$client, {
sql: `UPDATE extensions
SET type = ?, name = ?, description = ?, releases = ?, website = ?,
license = ?, icon_url = ?, readme = ?, source = ?, version = ?,
download_url = ?,
published_revision_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE changes() = 1 AND LOWER(id) = LOWER(?)`,
params: [
valid.type,
valid.name,
valid.description,
JSON.stringify(valid.releases),
valid.website,
JSON.stringify(valid.license),
valid.icon_url ?? null,
valid.readme,
JSON.stringify(valid.source),
valid.version,
valid.download_url,
revisionId,
id
]
});

results = await this.db.$client.batch([correctStmt, publishStmt]);
} catch (error) {
return databaseError("moderatorCorrect", error);
}

if (!results[0]?.meta?.changes) {
return this.moderatorCorrectBlockedError(id, moderatorId);
}

// Canonical id for the response (the path param may differ in case).
// Inside error handling like every other read here: the correction is
// already committed, so a failure must report a database error rather
// than throw past the route.
let row: { canonicalId: string } | undefined;
try {
[row] = await this.db
.select({ canonicalId: extensions.id })
.from(extensions)
.where(sql`LOWER(${extensions.id}) = LOWER(${id})`);
} catch (error) {
return databaseError("moderatorCorrect", error);
}
return {
data: { id: row?.canonicalId ?? id, revisionId },
error: null
};
}

// Separates the ways moderatorCorrect()'s guard can affect no rows, so the
// route can answer 403/404/409 rather than one opaque failure. Mirrors
// relistBlockedError's actor check first: a moderator deactivated or
// demoted after requireModerator() ran fails the write and must be told so.
private async moderatorCorrectBlockedError(
id: string,
moderatorId: string
): Promise<DatabaseResult<never>> {
const access = await new UsersDatabase(this.db).moderatorAccess(
moderatorId
);
if (access.error || !access.data) {
return {
data: null,
error: access.error ?? {
message: "Active account required",
code: "ACCOUNT_INACTIVE"
}
};
}
if (!access.data.active) {
return {
data: null,
error: {
message: "Active account required",
code: "ACCOUNT_INACTIVE"
}
};
}
if (!access.data.moderator) {
return {
data: null,
error: { message: "Moderator access required", code: "FORBIDDEN" }
};
}

let existing:
{ publishedAt: string | null; delistedAt: string | null } | undefined;
try {
[existing] = await this.db
.select({
publishedAt: extensions.publishedAt,
delistedAt: extensions.delistedAt
})
.from(extensions)
.where(sql`LOWER(${extensions.id}) = LOWER(${id})`);
} catch (error) {
return databaseError("moderatorCorrect", error);
}
if (!existing) return notFound(id);
if (!existing.publishedAt) {
return {
data: null,
error: {
message: "Only a published extension can be corrected",
code: "CONFLICT"
}
};
}
if (existing.delistedAt) {
return {
data: null,
error: {
message: "A delisted extension cannot be corrected; relist it first",
code: "CONFLICT"
}
};
}

try {
const [pending] = await this.db
.select({ one: sql`1` })
.from(extensionRevisions)
.where(
and(
sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${id})`,
eq(extensionRevisions.status, "pending")
)
);
if (pending) {
return {
data: null,
error: {
message:
"An edit to this extension is already awaiting review; approve or reject it first",
code: "CONFLICT"
}
};
}
} catch (error) {
return databaseError("moderatorCorrect", error);
}
return {
data: null,
error: { message: "Extension could not be corrected", code: "CONFLICT" }
};
}
}

// Escapes SQLite LIKE metacharacters in a caller-supplied search term so a
Expand Down
88 changes: 88 additions & 0 deletions src/services/extensions/v2/routes/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
DeveloperHistoryEntrySchema
} from "../schemas/developers";
import { RevisionIdParamSchema } from "../schemas/revisions";
import { ExtensionUpdateSchema } from "../schemas/extensions";
import { DeveloperProfilesDatabase } from "../db/developer-profiles";
import { ExtensionsDatabase } from "../db/extensions";
import { ExtensionRevisionsDatabase } from "../db/revisions";
Expand Down Expand Up @@ -352,6 +353,93 @@ export function registerModerationRoutes(app: ExtensionsV2App): void {
);
});

// Moderator correction of live content (api#251): unlike approve, which
// only publishes what an author proposed, this inserts an already-approved
// revision row and publishes it at once. No ?notify query and no mail
// path - the correction is recorded in history, and the author is not
// emailed.
const correctRoute = createRoute({
method: "post",
path: "/extensions/{id}/moderator-correct",
tags: ["Moderation"],
summary: "Correct a published extension's live content as a moderator",
security: [{ Bearer: [] }],
middleware: [requireModerator()] as const,
request: {
params: IdParamSchema,
body: {
content: {
"application/json": {
schema: ExtensionUpdateSchema.extend({
correction_note: z.string().trim().min(1).max(2000)
})
.strict()
.openapi("ModeratorCorrect")
}
}
}
},
responses: {
200: {
content: {
"application/json": {
schema: z.object({
result: z.object({
id: z.string(),
revision_id: z.string(),
status: z.literal("approved")
})
})
}
},
description:
"Content corrected and published. Recorded as an approved moderator revision; no author email is sent."
},
401: errorResponse("Missing or invalid bearer token"),
403: {
...ActiveAccountRequiredResponse,
description: "The account is inactive or the caller is not a moderator"
},
404: errorResponse("No such extension"),
409: errorResponse(
"Extension is unpublished or delisted, or an edit is already awaiting review"
),
422: errorResponse(
"Path params, content, or correction_note failed validation"
),
500: errorResponse("Database error")
}
});

app.openapi(correctRoute, async (c) => {
const auth = getAuth(c);
const { id } = c.req.valid("param");
const { correction_note, ...content } = c.req.valid("json");
const extDb = getExtensionsDb(c.env.DB_EXTENSIONS);
const db = new ExtensionsDatabase(extDb);
const { data, error } = await db.moderatorCorrect(
id,
auth.userId,
content,
correction_note
);
if (error || !data) {
const status = statusFromWriteErrorCode(error?.code);
return c.json(errorBody(error, "Unable to correct extension"), status);
}
revalidateCatalogue(c);
return c.json(
{
result: {
id: data.id,
revision_id: data.revisionId,
status: "approved" as const
}
},
200
);
});

const approveDeveloperRoute = createRoute({
method: "post",
path: "/developers/{id}/approve",
Expand Down
Loading
Loading