Skip to content
Closed
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
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The worker exposes five services:
- **Central Alerts** (`/central-alerts/v1`)
Allows the project to push critical notifications to all FOSSBilling installations—useful for security hotfixes or major announcements.

- **Extensions** (`/extensions/v1`, `/extensions/v2`)
- **Extensions** (`/extensions/v1`, `/v2`)
Owns the complete Extensions domain and its `DB_EXTENSIONS` schema. `v2` is authoritative; `v1` is legacy read-only. The separate Extensions site keeps OIDC/session state but accesses this domain through the
generated HTTPS API client; it must not bind or migrate `DB_EXTENSIONS`.
See [`src/services/extensions/v2/README.md`](src/services/extensions/v2/README.md).
Expand Down Expand Up @@ -45,15 +45,15 @@ Cron and Durable Object code lives with its owning service and is wired in `src/

Each service documents its own endpoints and behaviour:

| Service | Base path | Docs |
| -------------- | ---------------------------------- | -------------------------------------------------------------------------------------- |
| Versions | `/versions/v1` | [`src/services/versions/v1/README.md`](src/services/versions/v1/README.md) |
| Central Alerts | `/central-alerts/v1` | [`src/services/central-alerts/v1/README.md`](src/services/central-alerts/v1/README.md) |
| Stats | `/stats/v1` | [`src/services/stats/v1/README.md`](src/services/stats/v1/README.md) |
| Extensions | `/extensions/v1`, `/extensions/v2` | [`src/services/extensions/v2/README.md`](src/services/extensions/v2/README.md) |
| Previews | `/previews/v1` | [`src/services/previews/v1/README.md`](src/services/previews/v1/README.md) |
| Service | Base path | Docs |
| -------------- | ----------------------- | -------------------------------------------------------------------------------------- |
| Versions | `/versions/v1` | [`src/services/versions/v1/README.md`](src/services/versions/v1/README.md) |
| Central Alerts | `/central-alerts/v1` | [`src/services/central-alerts/v1/README.md`](src/services/central-alerts/v1/README.md) |
| Stats | `/stats/v1` | [`src/services/stats/v1/README.md`](src/services/stats/v1/README.md) |
| Extensions | `/extensions/v1`, `/v2` | [`src/services/extensions/v2/README.md`](src/services/extensions/v2/README.md) |
| Previews | `/previews/v1` | [`src/services/previews/v1/README.md`](src/services/previews/v1/README.md) |

Extensions v2 and Previews v1 also publish a live OpenAPI document (`/extensions/v2/docs/openapi.json`, `/previews/v1/docs/openapi.json`) and a reference UI (`/extensions/v2/docs`, `/previews/v1/docs`).
Extensions v2 and Previews v1 also publish a live OpenAPI document (`/v2/docs/openapi.json`, `/previews/v1/docs/openapi.json`) and a reference UI (`/v2/docs`, `/previews/v1/docs`).

## Configuration

Expand Down
2 changes: 1 addition & 1 deletion src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ app.use("*", async (c, next) => {

app.route("/central-alerts/v1", centralAlertsV1);
app.route("/extensions/v1", extensionsV1);
app.route("/extensions/v2", extensionsV2);
app.route("/v2", extensionsV2);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
app.route("/previews/v1", previewsV1);
app.route("/versions/v1", versionsV1);
app.route("/stats/v1", statsV1);
Expand Down
12 changes: 7 additions & 5 deletions src/services/extensions/v2/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Extensions v2

**Base Path:** `/extensions/v2`
**Base Path:** `/v2`

All v2 resources use this version-first namespace: `/v2/extensions`, `/v2/developers`, `/v2/users`, and `/v2/m2m`. The previous `/extensions/v2` base is removed; the extensions site, modules publisher, and FOSSBilling installer must switch with the API deployment. Legacy `/extensions/v1` routes keep their existing URLs.

Self-service extension publishing, developer-profile ownership, moderation, and browsing published extensions.

Expand All @@ -10,8 +12,8 @@ This service owns the complete Extensions domain and its `DB_EXTENSIONS` schema.

Endpoints are not listed here. The service publishes its own contract:

- **OpenAPI document:** `GET /extensions/v2/docs/openapi.json`
- **Reference UI:** `GET /extensions/v2/docs`
- **OpenAPI document:** `GET /v2/docs/openapi.json`
- **Reference UI:** `GET /v2/docs`

## Manifest contract

Expand Down Expand Up @@ -136,9 +138,9 @@ Provisioning spans both repos: create the client and its scopes/resource link in

## Public badges

`GET /extensions/v2/extensions/{id}/badges/{type}` returns a public SVG. Types are `version` (latest release tag), `fossbilling` (that release's full `requires.fossbilling` string), and `license` (package license). Requirements are displayed unchanged, including upper bounds, exclusions, and compound ranges.
`GET /v2/extensions/{id}/badges/{type}` returns a public SVG. Types are `version` (latest release tag), `fossbilling` (that release's full `requires.fossbilling` string), and `license` (package license). Requirements are displayed unchanged, including upper bounds, exclusions, and compound ranges.

For example: `/extensions/v2/extensions/branding/badges/fossbilling?color=green`. The optional `color` accepts badge-maker colors, up to 100 characters; unrecognized values use the library's default color. Badges are cached for five minutes in browsers and one hour at the CDN, matching v1.
For example: `/v2/extensions/branding/badges/fossbilling?color=green`. The optional `color` accepts badge-maker colors, up to 100 characters; unrecognized values use the library's default color. Badges are cached for five minutes in browsers and one hour at the CDN, matching v1.

Missing, unpublished, and delisted extensions return 404. Unsupported badge types or color lengths outside 1–100 return 422; unusable stored badge fields return 409 for manual correction. With no releases, version and requirement badges show `unknown`; license badges still work.

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 @@ -109,7 +109,7 @@ extensionsV2.route(
description:
"Self-service extension publishing, ownership, moderation, and public browsing. v1 (/extensions/v1) remains available for existing integrations."
},
servers: [{ url: "/extensions/v2" }]
servers: [{ url: "/v2" }]
})),
pageTitle: "FOSSBilling Extensions API (v2)",
agent: { disabled: true },
Expand Down
38 changes: 19 additions & 19 deletions test/services/extensions/v2/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("Extensions API v2", () => {

it("rejects ordinary assertions before storing attacker-selected identity", async () => {
const response = await put(
"/extensions/v2/users/me/identity",
"/v2/users/me/identity",
await authHeaders("attacker"),
identity
);
Expand All @@ -83,7 +83,7 @@ describe("Extensions API v2", () => {
["github_orgs_expires_at", "2099-02-01T00:00:00.000Z"]
])("rejects tampering with signed %s", async (field, value) => {
const headers = await identityHeaders("new-identity", identity);
const response = await put("/extensions/v2/users/me/identity", headers, {
const response = await put("/v2/users/me/identity", headers, {
...identity,
[field as string]: value
});
Expand All @@ -108,7 +108,7 @@ describe("Extensions API v2", () => {
async (rawBody) => {
const ctx = createExecutionContext();
const res = await app.request(
"/extensions/v2/users/me/identity",
"/v2/users/me/identity",
{
method: "PUT",
headers: await identityHeaders("raw-body", identity),
Expand Down Expand Up @@ -142,7 +142,7 @@ describe("Extensions API v2", () => {
});
const ctx = createExecutionContext();
const res = await app.request(
"/extensions/v2/users/me/identity",
"/v2/users/me/identity",
{
method: "PUT",
headers: {
Expand All @@ -161,7 +161,7 @@ describe("Extensions API v2", () => {

it("keeps identity proofs out of user API authorization", async () => {
const response = await get(
"/extensions/v2/users/me",
"/v2/users/me",
await identityHeaders("service-sub", identity)
);
expect(response.status).toBe(401);
Expand Down Expand Up @@ -223,7 +223,7 @@ describe("Extensions API v2", () => {
}
});

const profile = await patch("/extensions/v2/users/me", headers, {
const profile = await patch("/v2/users/me", headers, {
display_name: "Account Display"
});
expect(profile.status).toBe(200);
Expand All @@ -236,7 +236,7 @@ describe("Extensions API v2", () => {
}
});

const developer = await get("/extensions/v2/developers/me", headers);
const developer = await get("/v2/developers/me", headers);
expect(developer.status).toBe(200);
expect(await developer.json()).toEqual({ result: null });

Expand All @@ -262,15 +262,15 @@ describe("Extensions API v2", () => {
download_url: "https://example.com/download.zip"
});

const owned = await get("/extensions/v2/extensions?scope=mine", headers);
const owned = await get("/v2/extensions?scope=mine", headers);
expect(owned.status).toBe(200);
expect(await owned.json()).toMatchObject({
result: [{ id: "account-extension" }],
pagination: { has_more: false, next_cursor: null }
});

const filtered = await get(
"/extensions/v2/extensions?scope=mine&developer_id=someone-else",
"/v2/extensions?scope=mine&developer_id=someone-else",
headers
);
expect(filtered.status).toBe(422);
Expand All @@ -281,7 +281,7 @@ describe("Extensions API v2", () => {

it("validates a mine cursor before returning an empty owner page", async () => {
const res = await get(
"/extensions/v2/extensions?scope=mine&cursor=not-a-cursor",
"/v2/extensions?scope=mine&cursor=not-a-cursor",
await authHeaders("no-developer")
);
expect(res.status).toBe(422);
Expand Down Expand Up @@ -345,11 +345,11 @@ describe("Extensions API v2", () => {

it("tombstones and later reactivates an account", async () => {
const headers = await authHeaders("delete-me");
const deleted = await del("/extensions/v2/users/me", headers);
const deleted = await del("/v2/users/me", headers);
expect(deleted.status).toBe(200);
expect(await deleted.json()).toEqual({ result: { deleted: true } });

const afterDelete = await get("/extensions/v2/users/me", headers);
const afterDelete = await get("/v2/users/me", headers);
expect(afterDelete.status).toBe(200);
expect(await afterDelete.json()).toMatchObject({
result: { active: false, display_name: null }
Expand Down Expand Up @@ -385,7 +385,7 @@ describe("Extensions API v2", () => {
expect(row?.deleted_at).toBeTruthy();

const blockedWrite = await put(
"/extensions/v2/developers/me",
"/v2/developers/me",
headers,
sampleDeveloper({ id: "deleted-developer" })
);
Expand Down Expand Up @@ -414,7 +414,7 @@ describe("Extensions API v2", () => {
// cleanup statement must run in the same batch or the developers
// delete would fail on the foreign key.
await put(
"/extensions/v2/developers/me",
"/v2/developers/me",
await authHeaders("mapping-owner"),
sampleDeveloper({ id: "mapped-developer" })
);
Expand All @@ -430,7 +430,7 @@ describe("Extensions API v2", () => {
expect(before).not.toBeNull();

const deleted = await del(
"/extensions/v2/users/me",
"/v2/users/me",
await authHeaders("mapping-owner")
);
expect(deleted.status).toBe(200);
Expand All @@ -444,7 +444,7 @@ describe("Extensions API v2", () => {
it("blocks deletion while published extensions remain owned", async () => {
await seedOwnedExtension();
const headers = await authHeaders("owner-1");
const deleted = await del("/extensions/v2/users/me", headers);
const deleted = await del("/v2/users/me", headers);
expect(deleted.status).toBe(409);
const row = await db
.prepare("SELECT deleted_at FROM users WHERE id = ?")
Expand All @@ -468,7 +468,7 @@ describe("Extensions API v2", () => {
});

const deleted = await del(
"/extensions/v2/users/me",
"/v2/users/me",
await authHeaders("pending-owner")
);
expect(deleted.status).toBe(409);
Expand Down Expand Up @@ -544,7 +544,7 @@ describe("Extensions API v2", () => {
.run();

const deleted = await del(
"/extensions/v2/users/me",
"/v2/users/me",
await authHeaders("cleanup-user")
);
expect(deleted.status).toBe(200);
Expand Down Expand Up @@ -618,7 +618,7 @@ describe("Extensions API v2", () => {

try {
const deleted = await del(
"/extensions/v2/users/me",
"/v2/users/me",
await authHeaders("rollback-user")
);
expect(deleted.status).toBe(500);
Expand Down
6 changes: 3 additions & 3 deletions test/services/extensions/v2/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const release = (tag: string, fossbilling: string) => ({
requires: { fossbilling, php: ">=8.3" }
});
const badge = (type: string) =>
get(`/extensions/v2/extensions/example/badges/${type}`, {});
get(`/v2/extensions/example/badges/${type}`, {});

beforeEach(async () => {
await insertDeveloper(db, {
Expand Down Expand Up @@ -68,7 +68,7 @@ describe("v2 public badges", () => {

it("supports case-insensitive extension lookup and color overrides", async () => {
const response = await get(
"/extensions/v2/extensions/EXAMPLE/badges/version?color=green",
"/v2/extensions/EXAMPLE/badges/version?color=green",
{}
);
expect(response.status).toBe(200);
Expand Down Expand Up @@ -178,7 +178,7 @@ describe("v2 public badges", () => {

it("publishes the badge contract in OpenAPI", async () => {
const document = (await (
await get("/extensions/v2/docs/openapi.json", {})
await get("/v2/docs/openapi.json", {})
).json()) as { paths: Record<string, unknown> };
expect(document.paths["/extensions/{id}/badges/{type}"]).toMatchObject({
get: {
Expand Down
14 changes: 5 additions & 9 deletions test/services/extensions/v2/claim-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock("@octokit/request", async () =>
(await import("../../../mocks/octokit")).octokitRequestMock()
);
setupExtensionsV2Tests();
const path = "/extensions/v2/developers/target/claim";
const path = "/v2/developers/target/claim";

describe("durable claim verification budgets", () => {
it("stops mismatches before GitHub, including after changing targets", async () => {
Expand All @@ -29,7 +29,7 @@ describe("durable claim verification budgets", () => {
for (let i = 0; i < 4; i++) {
await seedUnownedDeveloper(`target-${i}`);
const response = await post(
`/extensions/v2/developers/target-${i}/claim`,
`/v2/developers/target-${i}/claim`,
headers,
{}
);
Expand All @@ -46,12 +46,8 @@ describe("durable claim verification budgets", () => {
expect(response.status).toBe(201);
const body = (await response.json()) as { result: { id: string } };
expect(
(
await post(
`/extensions/v2/developers/claims/${body.result.id}/cancel`,
headers
)
).status
(await post(`/v2/developers/claims/${body.result.id}/cancel`, headers))
.status
).toBe(200);
}
expect((await post(path, headers, {})).status).toBe(429);
Expand All @@ -77,7 +73,7 @@ describe("durable claim verification budgets", () => {
expect(
(
await post(
"/extensions/v2/developers/independent-target/claim",
"/v2/developers/independent-target/claim",
await authHeaders("independent"),
{}
)
Expand Down
Loading
Loading