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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ apply when modifying the code.
## Cache Revalidation

Any endpoint that mutates catalogue-visible content (revision approve/reject,
delist, developer approve, developer profile upsert via `PUT /developers/me`,
delist/relist, developer approve, developer profile upsert via `PUT /developers/me`,
profile deletion via `DELETE /developers/me`, claim approve/reject, extension
withdraw) must call `revalidateCatalogue(c)` from
`src/services/extensions/v2/revalidate.ts` after a successful write. Skipping it
Expand Down
33 changes: 20 additions & 13 deletions src/services/extensions/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ against it.
the public catalogue for cause (its upstream source disappearing, for
example). Moderator-only, and the inverse of neither `approve` nor
`reject`: content and history are kept, so the owner can still see and edit
the extension, and a moderator can re-list it by hand later. There is no
`relist` endpoint yet - see `ExtensionsDatabase.delist()`.
the extension. `POST /extensions/{id}/relist` restores it (optional
`review_note`, same `?notify` opt-out); see `ExtensionsDatabase.delist()`
and `relist()`.
- `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 All @@ -52,8 +53,11 @@ against it.

### Moderation Notification Emails

Revision approve/reject, delist, developer approve, and claim approve/reject
email the affected author unless the moderator opts out with `?notify=false`.
Revision approve/reject, delist/relist, developer approve, and claim
approve/reject email the affected author unless the moderator opts out with
`?notify=false`. Automatic decisions by the FOSSBilling Bot account use the
same routes and mail path; they are identified by the `[auto policy=…]`
`review_note` prefix (no schema change).
The recipient is the developer's `contact_email`, falling back to the owning
account's `email`; claim decisions go to the claimant's account email.
Sending is best-effort and never fails the write: the result carries
Expand Down Expand Up @@ -110,20 +114,23 @@ newest first, for its owner or any moderator. `GET /revisions` is the global
review queue (moderator only, `?status=` defaulting to `pending`, oldest
first).

`GET /developers?status=` (`all` default, `unapproved` for the review queue)
replaces `GET /developers/unapproved`. `GET /developers/claims?scope=mine`
(the caller's claims) and `?scope=pending` (moderator queue) replace
`GET /developers/claims/mine` and `GET /developers/claims`; both return the
enriched pending shape. `GET /developers/{id}` is role-aware like
`GET /developers?scope=` (`all` default, `unapproved` for the review queue;
`?status=` remains as a deprecated alias during the coordinated migration and
422s when it disagrees with `?scope=`) replaces `GET /developers/unapproved`.
`GET /developers/claims?scope=mine` (the caller's claims) and `?scope=pending`
(moderator queue) replace `GET /developers/claims/mine` and
`GET /developers/claims`; both return the enriched pending shape, and a
`status` filter disagreeing with `scope=pending` is rejected with 422 rather
than silently ignored. `GET /developers/{id}` is role-aware like
`GET /extensions/{id}`: public view anonymously, full view for the owner or a
moderator. `PATCH /users/me` returns the full account projection, like
`GET /users/me`.

`GET /developers`, `GET /developers/claims`, and `GET /developers/{id}/history`
support offset pagination via `?limit=` (1-100) and `?offset=`. `offset`
without `limit` is rejected with 422; with no params at all the routes apply
a bounded default window (100 rows) instead of streaming every row, and the
response always carries `pagination: {limit, offset, has_more}`.
page by opaque keyset cursor like every other v2 list: `?limit=` (1-100,
default 50) with `?cursor=` carried from the previous page's
`pagination.next_cursor`. An invalid cursor is rejected with `INVALID_CURSOR`
(422); the response envelope is always `pagination: {next_cursor, has_more}`.

## Authentication

Expand Down
188 changes: 141 additions & 47 deletions src/services/extensions/v2/db/developer-claims.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
import { and, asc, desc, eq, gt, isNull, lt, or, sql, SQL } from "drizzle-orm";
import { DatabaseResult } from "../../../../lib/interfaces";
import { ExtensionsDb } from "../../../../lib/db";
import { encodeCursor as encode, decodeCursor as decode } from "./cursor";
import { developerClaims, developers, users } from "./schema";
import {
databaseError,
Expand Down Expand Up @@ -272,10 +273,16 @@ export class DeveloperClaimsDatabase {
// Unified reader for the merged GET /developers/claims?scope=. Always
// returns the enriched Pending shape so mine and pending share one
// contract; scope=mine is caller-filtered (any status unless narrowed),
// scope=pending is moderator-wide (pending by default). The filter is a
// discriminated union so scope=mine cannot be called without the caller's
// id, which would otherwise drop the ownership predicate and return every
// claim in the table.
// scope=pending is moderator-wide (pending only — the route rejects any
// other status filter with 422). The filter is a discriminated union so
// scope=mine cannot be called without the caller's id, which would
// otherwise drop the ownership predicate and return every claim in the
// table.
//
// Keyset (cursor) pagination: mine orders newest-first, pending
// oldest-first (same keys, opposite directions — the cursor comparison
// flips like ExtensionRevisionsDatabase.page). Ties on created_at are
// broken by rowid (insertion order), as the offset implementation did.
async listScoped(
filters:
| {
Expand All @@ -287,14 +294,46 @@ export class DeveloperClaimsDatabase {
scope: "pending";
status?: "pending" | "approved" | "rejected" | "all";
},
page?: { limit: number; offset: number }
page?: { limit?: number; cursor?: string }
): Promise<
DatabaseResult<{ items: PendingDeveloperClaim[]; hasMore: boolean }>
DatabaseResult<{
items: PendingDeveloperClaim[];
nextCursor: string | null;
hasMore: boolean;
}>
> {
const limit = page?.limit ?? 50;
const decoded = page?.cursor ? decodeClaimCursor(page.cursor) : null;
// Scopes order oppositely (mine newest-first, pending oldest-first),
// so a cursor from one scope would seek from the wrong key boundary in
// the other: reject it rather than return a silently wrong page. (The
// status filter within scope=mine shares the same ordering, so it needs
// no tag.) Mine cursors are additionally bound to the claimant, like
// history cursors to their developer: claimantId is the primary filter
// of that projection, and another user's cursor would otherwise skip
// this caller's newest claims.
if (
page?.cursor &&
(!decoded ||
decoded.s !== filters.scope ||
(filters.scope === "mine" && decoded.c !== filters.claimantId))
) {
return {
data: null,
error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" }
};
}
const afterRowid = decoded ? Number(decoded.k2) : NaN;
if (decoded && !Number.isInteger(afterRowid)) {
return {
data: null,
error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" }
};
}
const status = filters.status ?? "all";
let rows;
const newestFirst = filters.scope === "mine";
try {
const conditions = [];
const conditions: SQL[] = [];
if (filters.scope === "mine") {
conditions.push(eq(developerClaims.claimantId, filters.claimantId));
}
Expand All @@ -303,55 +342,77 @@ export class DeveloperClaimsDatabase {
} else if (status !== "all") {
conditions.push(eq(developerClaims.status, status));
}
const base = this.db
if (decoded) {
conditions.push(
newestFirst
? or(
lt(developerClaims.createdAt, decoded.k1),
and(
eq(developerClaims.createdAt, decoded.k1),
sql`"developer_claims".rowid < ${afterRowid}`
)
)!
: or(
gt(developerClaims.createdAt, decoded.k1),
and(
eq(developerClaims.createdAt, decoded.k1),
sql`"developer_claims".rowid > ${afterRowid}`
)
)!
);
}
const rows = await this.db
.select({
claim: developerClaims,
developerName: developers.name,
developerType: developers.type,
claimantName: users.name,
claimantGithubLogin: users.githubLogin
claimantGithubLogin: users.githubLogin,
rowid: sql<number>`"developer_claims".rowid`
})
.from(developerClaims)
.innerJoin(developers, eq(developers.id, developerClaims.developerId))
.leftJoin(users, eq(users.id, developerClaims.claimantId));
const filtered = conditions.length
? base.where(and(...conditions))
: base;
// Offset pagination needs a deterministic total order: created_at
// ties are broken by rowid (insertion order), matching listHistory.
// limit+1 probe - see DeveloperProfilesDatabase.listWithOwnerPaged.
const ordered = filtered.orderBy(
filters.scope === "mine"
? desc(developerClaims.createdAt)
: asc(developerClaims.createdAt),
filters.scope === "mine"
? sql`"developer_claims".rowid DESC`
: sql`"developer_claims".rowid ASC`
);
rows = page
? await ordered.offset(page.offset).limit(page.limit + 1)
: await ordered;
.leftJoin(users, eq(users.id, developerClaims.claimantId))
.where(conditions.length ? and(...conditions)! : undefined)
.orderBy(
newestFirst
? desc(developerClaims.createdAt)
: asc(developerClaims.createdAt),
newestFirst
? sql`"developer_claims".rowid DESC`
: sql`"developer_claims".rowid ASC`
)
.limit(limit + 1);

const hasMore = rows.length > limit;
const pageRows = rows.slice(0, limit);
const last = pageRows.at(-1);
return {
data: {
items: pageRows.map((row) => ({
...parseClaimRow(row.claim),
developer_name: row.developerName,
developer_type:
row.developerType as PendingDeveloperClaim["developer_type"],
claimant_name: row.claimantName,
claimant_github_login: row.claimantGithubLogin
})),
hasMore,
nextCursor:
hasMore && last
? encodeClaimCursor(
last.claim.createdAt,
String(last.rowid),
filters.scope,
filters.scope === "mine" ? filters.claimantId : undefined
)
: null
},
error: null
};
} catch (error) {
return databaseError("listScoped", error);
}

const hasMore = page ? rows.length > page.limit : false;
const trimmed = page && hasMore ? rows.slice(0, page.limit) : rows;

return {
data: {
items: trimmed.map((row) => ({
...parseClaimRow(row.claim),
developer_name: row.developerName,
developer_type:
row.developerType as PendingDeveloperClaim["developer_type"],
claimant_name: row.claimantName,
claimant_github_login: row.claimantGithubLogin
})),
hasMore
},
error: null
};
}

private async explainClaimApprovalNoOp(
Expand Down Expand Up @@ -589,3 +650,36 @@ export class DeveloperClaimsDatabase {
return this.getClaimById(claimId);
}
}

interface ClaimCursor {
k1: string;
k2: string;
s: "mine" | "pending";
c?: string;
}

function encodeClaimCursor(
k1: string,
k2: string,
s: "mine" | "pending",
claimantId?: string
): string {
return encode(
claimantId === undefined ? { k1, k2, s } : { k1, k2, s, c: claimantId }
);
}

function isClaimCursor(
parsed: Record<string, unknown>
): parsed is ClaimCursor & Record<string, unknown> {
return (
typeof parsed.k1 === "string" &&
typeof parsed.k2 === "string" &&
(parsed.s === "mine" || parsed.s === "pending") &&
(parsed.c === undefined || typeof parsed.c === "string")
);
}

function decodeClaimCursor(cursor: string): ClaimCursor | null {
return decode(cursor, isClaimCursor);
}
Loading
Loading