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
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"drizzle-orm": "0.45.4",
"hono": "4.13.16",
"semver": "7.8.6",
"uuid": "14.0.3",
"zod": "4.6.7"
},
"devDependencies": {
Expand Down
13 changes: 8 additions & 5 deletions src/services/extensions/v1/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import type { Release as ManifestRelease } from "../v2/schemas/extensions";
import { Extension, Release, Author, Repository } from "./interfaces";

const EXTENSION_COLUMNS = {
id: extensions.id,
id: extensions.identifier,
type: extensions.type,
developerId: extensions.developerId,
developerId: developers.identifier,
authorType: developers.type,
authorName: developers.name,
authorUrl: developers.url,
Expand Down Expand Up @@ -120,7 +120,10 @@ export class ExtensionsDatabase {
// the published-extension index and is unique.
rows = page
? ((await base
.orderBy(sql`LOWER(${extensions.id})`, extensions.id)
.orderBy(
sql`LOWER(${extensions.identifier})`,
extensions.identifier
)
.offset(page.offset)
.limit(page.limit + 1)) as ExtensionRow[])
: ((await base) as ExtensionRow[]);
Expand Down Expand Up @@ -150,7 +153,7 @@ export class ExtensionsDatabase {
.innerJoin(developers, eq(extensions.developerId, developers.id))
.where(
and(
sql`LOWER(${extensions.id}) = LOWER(${id})`,
sql`LOWER(${extensions.identifier}) = LOWER(${id})`,
isNotNull(extensions.publishedAt),
isNull(extensions.delistedAt)
)
Expand Down Expand Up @@ -181,7 +184,7 @@ export class ExtensionsDatabase {
.from(extensions)
.where(
and(
sql`LOWER(${extensions.id}) = LOWER(${id})`,
sql`LOWER(${extensions.identifier}) = LOWER(${id})`,
isNotNull(extensions.publishedAt),
isNull(extensions.delistedAt)
)
Expand Down
12 changes: 8 additions & 4 deletions src/services/extensions/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Self-service extension publishing, developer-profile ownership, moderation, and

This service owns the complete Extensions domain and its `DB_EXTENSIONS` schema. The separate Extensions site keeps OIDC/session state but reaches this domain through the generated HTTPS API client; it must not bind or migrate `DB_EXTENSIONS`.

## Directory Identity

Extensions and developers have generated UUIDv7 `id` primary keys and separate, globally unique `identifier` fields. Database relationships and machine publisher bindings use UUIDs. Public profile/extension paths use identifiers; the moderator-only `/developers/{id}/history` path uses the internal UUID so audit records remain accessible after profile deletion; `name` is the editable display name. Creation requests supply `identifier`, never the internal ID. The package manifest's `id` remains the public extension identifier.

## Endpoints

Endpoints are not listed here. The service publishes its own contract:
Expand Down Expand Up @@ -56,21 +60,21 @@ The frontend revalidation tags are `extensions` and `developers`, shared with th

An extension exists from `POST /extensions`, not from approval. There is no separate submission record.

- `POST /extensions` creates the record and reserves its id. It stays out of public listings until its first revision is approved.
- `POST /extensions` creates the record and reserves its public identifier. 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.
- `DELETE /extensions/{id}` withdraws an unpublished extension and releases its public identifier. 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.
An edit can change the display `name`, but cannot change the public `identifier` or move the extension 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.
The response is `202` with a new `revision_id` and the extension's canonical `identifier` 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.

Expand Down
4 changes: 2 additions & 2 deletions src/services/extensions/v2/db/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type BadgeData = {
export class ExtensionBadgesDatabase {
constructor(private db: ExtensionsDb) {}

async getById(id: string): Promise<DatabaseResult<BadgeData>> {
async getByIdentifier(id: string): Promise<DatabaseResult<BadgeData>> {
try {
// Select only badge fields so large README content does not affect badges.
const [row] = await this.db
Expand All @@ -31,7 +31,7 @@ export class ExtensionBadgesDatabase {
.from(extensions)
.where(
and(
sql`LOWER(${extensions.id}) = LOWER(${id})`,
sql`LOWER(${extensions.identifier}) = LOWER(${id})`,
isNotNull(extensions.publishedAt),
isNull(extensions.delistedAt)
)
Expand Down
180 changes: 79 additions & 101 deletions src/services/extensions/v2/db/developer-claims.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, isNull, lt, or, sql, SQL } from "drizzle-orm";
import { and, asc, desc, eq, gt, lt, or, sql, SQL } from "drizzle-orm";
import { DatabaseResult } from "../../../../lib/interfaces";
import { ExtensionsDb } from "../../../../lib/db";
import { createCursorCodec } from "./cursor";
Expand Down Expand Up @@ -117,123 +117,99 @@ export class DeveloperClaimsDatabase {
}

async claim(
developerId: string,
identifier: string,
claimantId: string,
note?: string,
githubToken?: string
): Promise<DatabaseResult<DeveloperClaim>> {
try {
let githubOrgVerified: number | null = null;
let githubVerificationNote: string | null = null;

const [developer] = await this.db
.select({ type: developers.type })
.select({
id: developers.id,
identifier: developers.identifier,
owner: developers.ownerUserId,
type: developers.type
})
.from(developers)
.where(sql`lower(${developers.identifier}) = lower(${identifier})`);

if (!developer)
return {
data: null,
error: { code: "NOT_FOUND", message: "Developer not found" }
};
const developerId = developer.id;
if (developer.owner !== null)
return {
data: null,
error: await this.claimIneligibilityError(developerId, claimantId)
};
// Reject duplicates before spending the GitHub verification budget.
// The insert below rechecks ownership and claimant eligibility.
const [hasPendingClaim] = await this.db
.select({ one: sql`1` })
.from(developerClaims)
.where(
and(eq(developers.id, developerId), isNull(developers.ownerUserId))
and(
eq(developerClaims.developerId, developerId),
eq(developerClaims.claimantId, claimantId),
eq(developerClaims.status, "pending")
)
);

if (developer) {
// Cheap short-circuit ahead of the GitHub lookup below: a claimant
// replaying an already-pending claim on this id would otherwise
// trigger a fresh GitHub API call every time, purely to be told the
// INSERT's own guard rejects it as a duplicate — letting one caller
// burn through the shared service-level GitHub quota for free. This
// is safe precisely because it only ever *returns* here when the
// read observes `pending` — it never falls through to verification
// or the INSERT in that case, so it can't itself create an
// unverified claim. Anything else (no claim yet, or one already
// resolved to approved/rejected) always continues through full
// verification below. A pending claim that resolves between this
// read and the response going out can make the message stale
// relative to that instant, but never lets a row get created
// without verification.
const [hasPendingClaim] = await this.db
.select({ one: sql`1` })
.from(developerClaims)
.where(
and(
eq(developerClaims.developerId, developerId),
eq(developerClaims.claimantId, claimantId),
eq(developerClaims.status, "pending")
)
);

if (hasPendingClaim) {
return {
data: null,
error: {
code: "CONFLICT",
message: "You already have a pending claim on this profile"
}
};
}

if (
!(await reserveClaimVerification(this.db, claimantId, developerId))
) {
return {
data: null,
error: {
code: "RATE_LIMITED",
message: "Claim verification allowance exhausted; try again later"
}
};
}
if (hasPendingClaim) {
return {
data: null,
error: {
code: "CONFLICT",
message: "You already have a pending claim on this profile"
}
};
}

const check = await verifyGithubOwnership(
this.db,
developerId,
developer.type as Developer["type"],
claimantId,
githubToken
);
if (!(await reserveClaimVerification(this.db, claimantId, developerId))) {
return {
data: null,
error: {
code: "RATE_LIMITED",
message: "Claim verification allowance exhausted; try again later"
}
};
}

if ("error" in check) {
return { data: null, error: check.error };
}
const check = await verifyGithubOwnership(
this.db,
developer.identifier,
developer.type as Developer["type"],
claimantId,
githubToken
);

if (check.mismatch) {
return {
data: null,
error: {
code: "GITHUB_MISMATCH",
message:
"Your linked GitHub account doesn't match this developer's GitHub organization or username, so it can't be claimed automatically. Make sure you're signed in with the right GitHub account, then try again."
}
};
}
if ("error" in check) {
return { data: null, error: check.error };
}

githubOrgVerified = check.githubOrgVerified;
githubVerificationNote = check.note;
if (check.mismatch) {
return {
data: null,
error: {
code: "GITHUB_MISMATCH",
message:
"Your linked GitHub account doesn't match this developer's GitHub organization or username, so it can't be claimed automatically. Make sure you're signed in with the right GitHub account, then try again."
}
};
}

const id = crypto.randomUUID();
let result;
try {
// Both eligibility checks are folded into the INSERT itself, rather
// than a separate SELECT beforehand — a caller who loses eligibility
// (developer gets claimed/transferred, or the caller picks up a
// different profile) between an up-front check and the write could
// otherwise still slip a stale claim through. (The SELECT above is
// only used to decide the GitHub verification signal, and is always
// re-checked here — it can't itself grant eligibility.) Kept as raw
// sql: an INSERT...SELECT...WHERE EXISTS isn't expressible via
// .insert().values().
// ON CONFLICT DO NOTHING turns a lost race against the pending-claim
// partial unique index into changes = 0, which the ineligibility
// diagnosis below already has to explain.
result = await this.db.run(sql`
INSERT INTO ${developerClaims} (id, developer_id, claimant_id, note, github_org_verified, github_verification_note)
SELECT ${id}, ${developerId}, ${claimantId}, ${note ?? null}, ${githubOrgVerified}, ${githubVerificationNote}
WHERE EXISTS (SELECT 1 FROM ${developers} WHERE id = ${developerId} AND owner_user_id IS NULL)
AND NOT EXISTS (SELECT 1 FROM ${developers} WHERE owner_user_id = ${claimantId})
AND EXISTS (SELECT 1 FROM ${users} WHERE ${users.id} = ${claimantId} AND ${users.deletedAt} IS NULL)
ON CONFLICT DO NOTHING
`);
} catch (error) {
return databaseError("claim", error);
}
// Ownership can change during verification; keep eligibility checks in the write.
const result = await this.db.run(sql`
INSERT INTO ${developerClaims} (id, developer_id, claimant_id, note, github_org_verified, github_verification_note)
SELECT ${id}, ${developerId}, ${claimantId}, ${note ?? null}, ${check.githubOrgVerified}, ${check.note}
WHERE EXISTS (SELECT 1 FROM ${developers} WHERE id = ${developerId} AND owner_user_id IS NULL)
AND NOT EXISTS (SELECT 1 FROM ${developers} WHERE owner_user_id = ${claimantId})
AND EXISTS (SELECT 1 FROM ${users} WHERE ${users.id} = ${claimantId} AND ${users.deletedAt} IS NULL)
ON CONFLICT DO NOTHING
`);

if (!result.meta?.changes) {
return {
Expand Down Expand Up @@ -379,6 +355,7 @@ export class DeveloperClaimsDatabase {
const rows = await this.db
.select({
claim: developerClaims,
developerIdentifier: developers.identifier,
developerName: developers.name,
developerType: developers.type,
claimantName: users.name,
Expand Down Expand Up @@ -406,6 +383,7 @@ export class DeveloperClaimsDatabase {
data: {
items: pageRows.map((row) => ({
...parseClaimRow(row.claim),
developer_identifier: row.developerIdentifier,
developer_name: row.developerName,
developer_type:
row.developerType as PendingDeveloperClaim["developer_type"],
Expand Down
Loading
Loading