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
9 changes: 9 additions & 0 deletions src/services/extensions/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,22 @@ An extension exists from `POST /extensions`, not from approval. There is no sepa

- `POST /extensions` creates the record and reserves its id. It stays out of public listings until its first revision is approved.
- `PUT /extensions/{id}` proposes an edit. Published content is unchanged until approval. One unreviewed revision per extension at a time.
- `PUT /extensions/{id}/revisions/{revisionId}` lets the active owner replace that pending revision; see [Editing a pending submission](#editing-a-pending-submission).
- `DELETE /extensions/{id}` withdraws an unpublished extension and releases its id. Published extensions cannot be withdrawn by owners.
- `POST /extensions/{id}/revisions/{revisionId}/approve` publishes. `reject` leaves published content untouched.
- `POST /extensions/{id}/delist` (moderator-only) hides a published extension for cause without deleting content or history. `POST /extensions/{id}/relist` restores it. Both accept `?notify=false`.
- `POST /extensions/{id}/moderator-correct` (moderator-only) fixes live extension content corruption without waiting for the author. Writes an already-approved revision, leaves `published_at` and ownership untouched. Requires a published, listed extension with no pending revision (409 otherwise). No `?notify` option and no author email; the fix is recorded in revision history.

An edit cannot rename an extension or move it to another developer. A user owns at most one developer profile, so no request body names one.

### Editing a pending submission

`PUT /extensions/{id}/revisions/{revisionId}` replaces pending content using the same body as `PUT /extensions/{id}`. Only an active human owner can call it, including for publisher-created submissions. The replacement records that owner as `submitted_by` and counts toward their pending-revision and write limits.

The response is `202` with a new `revision_id` and the extension's canonical `id` casing. The supplied revision must still be pending at the current ownership epoch. A stale or reviewed revision returns `409`; refresh before retrying.

Deletion and insertion run in one transaction: a failed write keeps the original pending revision. The new ID prevents stale edits or moderator decisions from applying to the replacement. Published content and reviewed history stay unchanged.

## Reads

`GET /extensions/{id}` and `GET /developers/{id}` are role-aware: anonymous callers get the published projection; owners and moderators get the full object. For extensions, 404 hides drafts and delisted rows.
Expand Down
79 changes: 79 additions & 0 deletions src/services/extensions/v2/db/revisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,85 @@ export class ExtensionRevisionsDatabase {
return { data: { id }, error: null };
}

// Owners can replace publisher submissions; attribute the new row to the owner.
// A fresh ID invalidates stale edits and reviews. Both writes must be atomic
// so admission failures keep the original pending revision.
async replacePending(input: {
extensionId: string;
revisionId: string;
callerId: string;
content: ExtensionContent;
}): Promise<DatabaseResult<{ id: string; extensionId: string }>> {
const id = crypto.randomUUID();
try {
const results = await this.db.$client.batch<{ extensionId: string }>([
this.db.$client
.prepare(
`
DELETE FROM extension_revisions
WHERE id = ? AND status = 'pending'
AND EXISTS (
SELECT 1 FROM extensions e JOIN developers d ON d.id = e.developer_id
JOIN users u ON u.id = d.owner_user_id
WHERE LOWER(e.id) = LOWER(?) AND e.id = extension_revisions.extension_id
AND d.id = extension_revisions.developer_id
AND d.ownership_epoch = extension_revisions.ownership_epoch
AND d.owner_user_id = ? AND u.deleted_at IS NULL
)
AND (submitted_by = ? OR (
SELECT COUNT(*) FROM extension_revisions WHERE submitted_by = ? AND status = 'pending'
) < ?)
`
)
.bind(
input.revisionId,
input.extensionId,
input.callerId,
input.callerId,
input.callerId,
MAX_PENDING_REVISIONS_PER_USER
),
this.db.$client
.prepare(
`
INSERT INTO extension_revisions
(id, extension_id, developer_id, submitted_by, status, content, ownership_epoch)
SELECT ?, e.id, d.id, ?, 'pending', ?, d.ownership_epoch
FROM extensions e JOIN developers d ON d.id = e.developer_id
WHERE LOWER(e.id) = LOWER(?) AND changes() = 1
RETURNING extension_id AS extensionId
`
)
.bind(
id,
input.callerId,
JSON.stringify(input.content),
input.extensionId
)
]);
const [replacement] = results[1].results;
if (replacement)
return {
data: { id, extensionId: replacement.extensionId },
error: null
};
const error = await this.proposeBlockedError(input);
return {
data: null,
error:
error.code === "CONFLICT"
? {
code: "CONFLICT",
message:
"The pending submission changed, was reviewed, or the pending-revision limit was reached. Reload before editing again."
}
: error
};
} catch (error) {
return contentAdmissionError("replacePending", error);
}
}

// Distinguishes the three ways the guard above can reject a write, so the
// route can map them to 404/403/409 rather than one opaque conflict.
private async proposeBlockedError(input: {
Expand Down
2 changes: 1 addition & 1 deletion src/services/extensions/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ extensionsV2.on(
);
extensionsV2.on(
"PUT",
"/extensions/:id",
["/extensions/:id", "/extensions/:id/revisions/:revisionId"],
paceContentIp(),
boundContentRequest()
);
Expand Down
73 changes: 73 additions & 0 deletions src/services/extensions/v2/routes/owner-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,79 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void {
);
});

const replacePendingRoute = createRoute({
method: "put",
path: "/extensions/{id}/revisions/{revisionId}",
tags: ["Extensions"],
summary: "Replace a pending submission for an extension the caller owns",
description:
"The active owner can replace publisher-created submissions. The replacement records the owner as submitter.",
security: [{ Bearer: [] }],
middleware: [requireActiveAuth(), paceContentAccount()] as const,
request: {
params: RevisionIdParamSchema,
body: {
content: { "application/json": { schema: ExtensionUpdateSchema } }
}
},
responses: {
202: {
content: { "application/json": { schema: AcceptedRevisionSchema } },
description:
"Replaced with a new pending revision ID. Published content and reviewed history are unchanged."
},
...authErrorResponses(),
403: {
...ActiveAccountRequiredResponse,
description:
"The account is inactive, or the caller does not own this extension"
},
404: errorResponse("No extension with that id"),
409: errorResponse(
"The pending revision changed, was reviewed, ownership changed, or the pending-revision limit was reached"
),
413: errorResponse("Raw request exceeds 512 KiB"),
429: errorResponse(
"Extension write allowance exhausted; see Retry-After"
),
503: errorResponse("Write admission unavailable"),
422: errorResponse("Body failed validation"),
500: errorResponse("Database error")
}
});

app.openapi(replacePendingRoute, async (c) => {
const auth = getAuth(c);
const { id, revisionId } = c.req.valid("param");
const content = c.req.valid("json");
const db = new ExtensionRevisionsDatabase(
getExtensionsDb(c.env.DB_EXTENSIONS)
);
const { data, error } = await db.replacePending({
extensionId: id,
revisionId,
callerId: auth.userId,
content
});
if (error || !data) {
setContentRetryAfter(c, error?.code);
return c.json(
errorBody(error, "Unable to submit edit"),
statusFromContentWriteError(error?.code)
);
}
return c.json(
{
result: {
id: data.extensionId,
revision_id: data.id,
status: "pending" as const
}
},
202
);
});

const withdrawRoute = createRoute({
method: "delete",
path: "/extensions/{id}",
Expand Down
Loading
Loading