diff --git a/package-lock.json b/package-lock.json index 138eeab..602d9bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "drizzle-orm": "^0.45.2", "hono": "^4.13.0", "semver": "^7.8.5", + "spdx-license-ids": "^3.0.23", "zod": "^4.4.3" }, "devDependencies": { @@ -5559,6 +5560,12 @@ "source-map": "^0.6.0" } }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index 5a91804..7ba9cd7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "drizzle-orm": "^0.45.2", "hono": "^4.13.0", "semver": "^7.8.5", + "spdx-license-ids": "^3.0.23", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/services/extensions/v2/schemas/extensions.ts b/src/services/extensions/v2/schemas/extensions.ts index 5750c8f..346850f 100644 --- a/src/services/extensions/v2/schemas/extensions.ts +++ b/src/services/extensions/v2/schemas/extensions.ts @@ -1,4 +1,5 @@ import { z } from "@hono/zod-openapi"; +import SPDX_LICENSE_IDS from "spdx-license-ids/index.json"; import { httpUrl, lowercaseId, PaginationSchema } from "./common"; import { PublicDeveloperSchema } from "./developers"; @@ -47,9 +48,30 @@ export const RepositorySchema = z export type Repository = z.infer; +// Re-exported so callers (and tests) validate against the exact same set +// this schema uses, rather than a hand-copied list that can drift. +// `spdx-license-ids` ships only the current (non-deprecated) identifiers — +// https://github.com/jslicense/spdx-license-ids — so submitters are steered +// toward the license SPDX currently recommends, not a retired alias. +export { SPDX_LICENSE_IDS }; + +const spdxLicenseId = () => + z + .string() + .refine((value) => SPDX_LICENSE_IDS.includes(value), { + message: "must be a current (non-deprecated) SPDX license identifier" + }) + .openapi({ + description: + "A current SPDX license identifier (https://spdx.org/licenses/). " + + "Omitted for custom or proprietary licenses.", + example: "MIT" + }); + export const LicenseSchema = z .object({ name: z.string().min(1).max(100), + spdx_id: spdxLicenseId().optional(), URL: httpUrl().optional() }) .strict() diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index a3ddfee..9925861 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -83,6 +83,60 @@ describe("Extensions API v2 writes", () => { expect(data.error.code).toBe("VALIDATION_ERROR"); }); + it("rejects a spdx_id that isn't a current SPDX license identifier", async () => { + await seedDeveloper("new-developer", "user-1"); + const res = await post( + "/extensions/v2/extensions", + await authHeaders("user-1"), + { + ...sampleCreate(), + license: { name: "Not Real", spdx_id: "NOT-A-REAL-SPDX-ID" } + } + ); + expect(res.status).toBe(422); + expect(await countRevisions(db)).toBe(0); + }); + + it("rejects a deprecated SPDX identifier", async () => { + await seedDeveloper("new-developer", "user-1"); + const res = await post( + "/extensions/v2/extensions", + await authHeaders("user-1"), + // "GPL-3.0" is deprecated in favor of GPL-3.0-only / GPL-3.0-or-later. + { ...sampleCreate(), license: { name: "GPL 3.0", spdx_id: "GPL-3.0" } } + ); + expect(res.status).toBe(422); + }); + + it("stores a recognized spdx_id alongside the display name", async () => { + await seedDeveloper("new-developer", "user-1"); + const res = await post( + "/extensions/v2/extensions", + await authHeaders("user-1"), + { + ...sampleCreate(), + license: { name: "Apache License 2.0", spdx_id: "Apache-2.0" } + } + ); + expect(res.status).toBe(201); + const data = (await res.json()) as { result: { revision_id: string } }; + const revision = await getRevision(db, data.result.revision_id); + expect(JSON.parse(revision!.content).license).toEqual({ + name: "Apache License 2.0", + spdx_id: "Apache-2.0" + }); + }); + + it("accepts a license with no spdx_id, for custom/proprietary licenses", async () => { + await seedDeveloper("new-developer", "user-1"); + const res = await post( + "/extensions/v2/extensions", + await authHeaders("user-1"), + { ...sampleCreate(), license: { name: "Acme Proprietary License" } } + ); + expect(res.status).toBe(201); + }); + it("rejects the reserved extension id mine", async () => { await seedDeveloper("new-developer", "user-1"); const res = await createExtension("user-1", { extensionId: "mine" });