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
20 changes: 19 additions & 1 deletion src/app/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { getExtensionsDb } from "../lib/db";
import {
maintainExtensionResources,
reportExtensionResources
} from "../services/extensions/v2/db/resource-maintenance";
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import centralAlertsV1 from "../services/central-alerts/v1";
Expand Down Expand Up @@ -69,6 +74,19 @@ app.all("/*", (c) => {
);
});

export default app;
export default {
fetch: app.fetch,
request: app.request.bind(app),
scheduled: async (
_event: ScheduledController,
env: CloudflareBindings
): Promise<void> => {
const db = getExtensionsDb(env.DB_EXTENSIONS);
await maintainExtensionResources(db, {
mode: env.EXTENSIONS_RETENTION_MODE
});
await reportExtensionResources(db, env.EXTENSIONS_RETENTION_MODE);
}
};

export { PreviewGitHubBudget } from "../lib/adapters/cloudflare/preview-github-budget";
127 changes: 127 additions & 0 deletions src/services/extensions/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,130 @@ enforced atomically with the write using indexed, immutable history and survives
profile deletion, recreation, and ownership transfer. Audit records remain
append-only; this bounds growth per account per day rather than total retention.
Apply migration `0025_luxuriant_franklin_richards.sql` before deploying.

## Extension resource admission and retention

Content writes (`POST /extensions`, `PUT /extensions/{id}`, and moderator
correction) count the actual request stream before JSON parsing. The raw request
limit is **512 KiB**, including whitespace, escapes, and unknown fields. An
oversized stream is canceled and returns `413 BODY_TOO_LARGE`; `Content-Length`
cannot bypass counting. The separate normalized JSON content limit remains
**256 KiB**, with the existing field/release maxima. New extension slug IDs are at most
200 characters. Previously stored IDs had no length cap and remain supported.

The `EXTENSION_WRITE_RATE_LIMITER` binding paces IP and authenticated-account
attempts at 60/minute, including validation failures. This edge control is
approximate and must not be used as the durable quota. Missing/unavailable
attempt pacing fails closed with `503 ADMISSION_UNAVAILABLE`. Unavailable durable
write admission returns the same response with `Retry-After: 60`.

Migration `0026_resource_bounds.sql` enforces the following accepted-write and
retained-resource limits **inside the write transaction**, including moderator
corrections. Its trigger guards and accounting are shared by create, edit,
approval, correction, withdrawal and retention; a failed batch leaves neither
an extension nor an accepted-write charge behind.

| Resource | Account | Developer |
| ------------------------------------- | ------- | --------- |
| Accepted revisions per rolling minute | 5 | 5 |
| Accepted revisions per rolling day | 50 | 50 |
| Retained content bytes | 25 MiB | 25 MiB |
| Extension records | 100 | 100 |
| Revision metadata records | 1,000 | 1,000 |

Aggregate storage and record counts are measured for visibility; they do not
impose a whole-system admission limit.

The pending budget remains ten revisions per submitter and one per extension.
The byte quota includes retained revision JSON **and** published content
columns. It counts UTF-8 bytes, not JavaScript string length. Original creators
are charged for extension records and published projections; revision bodies
and metadata remain charged to their submitters and original developer IDs.
Transferring a profile does not silently shift these charges to the recipient.
The `created_by` attribution is immutable. Legacy unowned publications are
charged to a synthetic `legacy` account bucket.

Each accepted write also removes up to 50 expired ledger entries, so expiry
cleanup scales with write traffic. The hourly job handles idle cleanup.

The accepted-write ledger has no deletion-cascading foreign keys: review,
withdrawal, profile replacement and account reactivation do not refund a
rolling allowance. Minute/day exhaustion returns `429` with a domain
code (`WRITE_RATE_MINUTE`, `WRITE_RATE_DAY`) and a conservative
`Retry-After` of 60 or 86400 seconds respectively. Retained quota exhaustion
returns `409 RESOURCE_QUOTA`. Withdrawing an unpublished extension releases its
stored bytes and record counts, but not its accepted-write allowance.

`resource-limits.ts` mirrors the policy values used by middleware and maintenance.

### Revision list/detail contract

`GET /revisions` and `GET /extensions/{id}/revisions` now return
`ExtensionRevisionSummary` pages. They select stored, bounded summary fields
(`name`, `version`, `description`), decision metadata, `content_bytes`,
`content_available`, `content_hash` and `compacted_at`. They do **not** select,
transfer or parse content JSON, including the pagination lookahead row.
Timestamp/ID keyset ordering, limit 1–100 and default 50 are unchanged.

Fetch `GET /extensions/{id}/revisions/{revisionId}` for full content on demand.
The caller must be the current owner or an active moderator, and authorization
is repeated in the actual data query. A compacted revision returns `content:
null`, `content_available: false`, a SHA-256 hash of the original stored bytes,
and its compaction timestamp. `content_bytes` measures the currently retained
body (the two-byte `{}` placeholder after compaction), not the historical body.

Oversized legacy content remains stored and counted, but is never fetched or
parsed by list or detail handlers. Its revision detail returns `409
CONTENT_UNAVAILABLE`. An owner/moderator extension detail with an oversized
Comment thread
admdly marked this conversation as resolved.
published or pending body also returns this error. Oversized published legacy
rows are excluded from public catalogue cards. Historical release collections
are checked for safe count/tag bounds before version sorting; unsafe collections
also return `CONTENT_UNAVAILABLE`; their summaries report `content_available: false`.
The stored readability flag is calculated alongside summary metadata, including
the safe legacy release count and tag bounds (100 Unicode code points).
Anonymous public extension detail reads also return `409 CONTENT_UNAVAILABLE`
for oversized published bodies. Moderator lists still expose
the published card for correction without reading its oversized body.
Cards bound display fields and project license/repository metadata; oversized
legacy URLs are null (or an omitted optional icon), rather than clipped links.
Detail reads preserve the original fields when the body is safe to fetch.
Reject an oversized pending revision, then ask its
owner to resubmit under current bounds; use moderator correction for published
content. Administrative export is required if the original oversized body must
be recovered. The migration backfills byte counts/summaries in SQL without
loading old bodies into a Worker or discarding any rows. Existing over-quota
collections cannot grow until usage is reduced.

### Maintenance and monitoring

The Worker runs scheduled maintenance **hourly**, handling at most
**20 bodies** and 500 expired ledger rows per invocation. Retention defaults to
`dry-run`: it reports eligible bodies and reclaimable bytes without fetching or
changing bodies. Only `EXTENSIONS_RETENTION_MODE=compact` enables compaction;
missing or invalid mode values keep retention non-destructive. Expired admission
ledger entries and empty usage counters are cleaned in either mode. Reviewed revision
bodies older than **180 days** are compacted to `{}` while retaining their
bounded summaries, review note, reviewer, dates, status and original content
hash. Pending bodies and the currently published revision are always protected.
Oversized legacy bodies are left for administrative handling. Metadata is
retained under the count quota; reaching that quota needs deliberate export
and administrative cleanup rather than silent deletion of decisions.

Compaction rechecks age, status, body equality and the published pointer in the
UPDATE, so concurrent review/publication changes cannot discard protected
content. Accounting is updated in the same transaction. Repeated runs are
idempotent. Expired write events and empty usage counters are cleaned in bounded
batches.

Full resource inventory is logged after each hourly maintenance run.
Structured logs record revision-response bytes/duration, admission reason codes and maintenance/inventory totals:
`retained_bytes`, `extensions`, `revisions`, `pending`,
`oversized_legacy_revisions`, `cleanup_backlog`, `compacted`, and
`expired_events`. They contain no revision bodies or user identifiers. Warning-level signals flag
oversized legacy bodies and an active
compaction backlog of 300 or more bodies. Dry-run eligibility is informational.

`db/resource-inventory.sql` is a manual, read-only reconciliation diagnostic,
separate from the hourly usage report. It lists at most 100 accounting
discrepancies using stored scalar sizes without returning content bodies,
ordered by descending byte/count drift with stable scope/subject tie-breaks.
78 changes: 68 additions & 10 deletions src/services/extensions/v2/db/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@ import { DatabaseError } from "../../../../lib/interfaces";
import { ExtensionsDb } from "../../../../lib/db";
import { UsersDatabase } from "./users";
import { DatabaseResult } from "../../../../lib/interfaces";
import { logError } from "../../../../lib/logger";
import { logInfo, logError } from "../../../../lib/logger";

// Drizzle wraps the real D1 driver error in a DrizzleError whose own
// .message is a generic "Failed to run the query '<sql>'" - the actual
// SQLite/D1 message (e.g. "UNIQUE constraint failed: ...") lives in
// .cause, not .message. Regex-matching driver error text (see
// the ownership/id conflict classifiers need the whole chain, not just the
// outermost message.
// Drizzle wraps driver errors; constraint classifiers inspect the cause chain.
export function errorMessageChain(error: unknown): string {
const parts: string[] = [];
let current: unknown = error;
Expand Down Expand Up @@ -55,21 +50,84 @@ export const isOwnershipEpochRollback = (error: unknown) =>
// pre-flight check instead of exposing it as a generic database error.
export const isDeveloperIdConflict = uniqueConstraintMatcher(/developers\.id/);

// Logs the real error server-side and returns a generic message to the
// caller — DB exception text can leak schema/backend details otherwise.
// Log safe driver diagnostics; exception messages can contain SQL and content.
export function databaseError(
context: string,
error: unknown
): DatabaseResult<never> {
let cause = error;
while (cause instanceof Error && cause.cause instanceof Error)
cause = cause.cause;
const message = cause instanceof Error ? cause.message : "";
const policy = [
[
"extension_write_rate_minute",
"WRITE_RATE_MINUTE",
"Five proposals per rolling minute allowed"
],
[
"extension_write_rate_day",
"WRITE_RATE_DAY",
"Fifty proposals per rolling day allowed"
],
[
"extension_resource_quota",
"RESOURCE_QUOTA",
"The retained extension resource quota is exhausted"
],
[
"extension_content_size",
"CONFLICT",
"Extension content must not exceed 256 KiB"
]
].find(([marker]) => message.includes(marker));
if (policy) {
logInfo("extensions-v2", "Resource admission rejected", {
reason: policy[1]
});
return { data: null, error: { code: policy[1], message: policy[2] } };
}
const driverCode =
cause instanceof Error && "code" in cause ? cause.code : undefined;
const backendCode =
typeof driverCode === "number"
? driverCode
: typeof driverCode === "string" &&
/^[A-Z][A-Z0-9_]{0,63}$/.test(driverCode)
? driverCode
: message.match(/\b(?:SQLITE|D1)_[A-Z_]+\b/)?.[0];
logError("extensions-v2", context, {
error: error instanceof Error ? errorMessageChain(error) : String(error)
reason: "backend_failure",
error_type:
cause instanceof Error && /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(cause.name)
? cause.name
: "UnknownError",
backend_code: backendCode
});
return {
data: null,
error: { message: "A database error occurred", code: "DATABASE_ERROR" }
};
}

// Content insertion and its durable budget share one transaction. A backend
// failure means admission is unavailable; policy rejections retain their code.
export function contentAdmissionError(
context: string,
error: unknown
): DatabaseResult<never> {
const result = databaseError(context, error);
return result.error?.code === "DATABASE_ERROR"
Comment thread
admdly marked this conversation as resolved.
? {
data: null,
error: {
code: "ADMISSION_UNAVAILABLE",
message: "Write admission unavailable"
}
}
: result;
}

// Every guarded write in this service repeats an active-account check inside
// its own statement, because requireActiveAuth() can only reject before the
// write. When such a statement affects no rows the diagnosis has to ask this
Expand Down
Loading
Loading