diff --git a/nodejs/.npmrc b/nodejs/.npmrc index 100721e01a..9d4297362f 100644 --- a/nodejs/.npmrc +++ b/nodejs/.npmrc @@ -1 +1,2 @@ omit-lockfile-registry-resolved=true +save-exact=true diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 0de83bc883..4214cc8cd7 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,13 +9,14 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "koffi": "^3.2.1", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" + "koffi": "3.2.1", + "vscode-jsonrpc": "8.2.1", + "zod": "4.3.6" }, "devDependencies": { "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", + "@types/semver": "7.8.0", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", @@ -1351,6 +1352,12 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/semver": { + "version": "7.8.0", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", diff --git a/nodejs/package.json b/nodejs/package.json index 1c255f7e09..440c533d7d 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -37,7 +37,8 @@ "clean": "rimraf --glob dist *.tgz", "build": "tsx esbuild-copilotsdk-nodejs.ts", "acquire:runtime-packages": "tsx scripts/runtime-package-acquisition.ts", - "pack:release": "tsx scripts/package-sdk.ts", + "pack:release": "npm run verify:dependency-policy && tsx scripts/package-sdk.ts", + "verify:dependency-policy": "tsx scripts/verify-dependency-policy.ts", "verify:release-packages": "tsx scripts/verify-release-packages.ts", "release:manifest": "tsx scripts/release-manifest.ts", "prepare:runtime": "tsx scripts/prepare-runtime.ts", @@ -64,13 +65,14 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "koffi": "^3.2.1", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" + "koffi": "3.2.1", + "vscode-jsonrpc": "8.2.1", + "zod": "4.3.6" }, "devDependencies": { "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", + "@types/semver": "7.8.0", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 6c29f98383..03cdb82911 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,13 +18,14 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "koffi": "^3.2.1", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" + "koffi": "3.2.1", + "vscode-jsonrpc": "8.2.1", + "zod": "4.3.6" }, "devDependencies": { "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", + "@types/semver": "7.8.0", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", diff --git a/nodejs/scripts/dependency-policy.ts b/nodejs/scripts/dependency-policy.ts new file mode 100644 index 0000000000..735a2acdf6 --- /dev/null +++ b/nodejs/scripts/dependency-policy.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import * as semver from "semver"; + +const NPM_REGISTRY_URL = "https://registry.npmjs.org"; + +export const MINIMUM_DEPENDENCY_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export interface ProductionDependencyManifest { + dependencies?: Record; + optionalDependencies?: Record; +} + +export interface ProductionDependency { + name: string; + version: string; +} + +export interface RegistryResponse { + json(): Promise; + ok: boolean; + status: number; + statusText: string; +} + +export type RegistryRequest = (url: string) => Promise; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function dependencyKey(dependency: ProductionDependency): string { + return `${dependency.name}@${dependency.version}`; +} + +function readDependencySection( + manifest: Record, + section: "dependencies" | "optionalDependencies" +): Record | undefined { + const value = manifest[section]; + if (value === undefined) { + return undefined; + } + assert(isRecord(value), `${section} must be a JSON object`); + const dependencies: Record = {}; + for (const [name, version] of Object.entries(value)) { + assert(typeof version === "string", `${section}.${name} must be a string`); + dependencies[name] = version; + } + return dependencies; +} + +export function readProductionDependencyManifest(path: string): ProductionDependencyManifest { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + assert(isRecord(parsed), "package.json must contain a JSON object"); + return { + dependencies: readDependencySection(parsed, "dependencies"), + optionalDependencies: readDependencySection(parsed, "optionalDependencies"), + }; +} + +export function assertExactProductionDependencies( + manifest: ProductionDependencyManifest +): ProductionDependency[] { + const dependencies: ProductionDependency[] = []; + for (const section of ["dependencies", "optionalDependencies"] as const) { + for (const [name, version] of Object.entries(manifest[section] ?? {})) { + assert.equal( + semver.valid(version), + version, + `${section}.${name} must use an exact SemVer version; found '${version}'` + ); + dependencies.push({ name, version }); + } + } + return dependencies; +} + +function packageNameFromLockfilePath(path: string): string | undefined { + return /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/.exec(path)?.[1]; +} + +export function parseResolvedProductionDependencies(lockfile: unknown): ProductionDependency[] { + assert(isRecord(lockfile), "package-lock.json must contain a JSON object"); + assert.equal(lockfile.lockfileVersion, 3, "package-lock.json must use lockfileVersion 3"); + assert(isRecord(lockfile.packages), "package-lock.json must contain a packages object"); + + const dependencies = new Map(); + for (const [path, value] of Object.entries(lockfile.packages)) { + if (path === "") { + continue; + } + assert(isRecord(value), `Package-lock entry '${path}' must be a JSON object`); + // npm marks dev=true only when a package is strictly dev-only. Optional and + // devOptional packages reachable from production dependencies remain included. + if (value.dev === true || value.link === true || value.inBundle === true) { + continue; + } + const name = packageNameFromLockfilePath(path); + assert(name, `Unsupported production package-lock path '${path}'`); + const version = value.version; + assert( + typeof version === "string" && semver.valid(version) === version, + `Resolved production dependency ${name} must use an exact SemVer version` + ); + dependencies.set(`${name}@${version}`, { name, version }); + } + return [...dependencies.values()]; +} + +export function readResolvedProductionDependencies(path: string): ProductionDependency[] { + return parseResolvedProductionDependencies(JSON.parse(readFileSync(path, "utf8")) as unknown); +} + +export function requiresPublicationCooldown(dependencyName: string): boolean { + return !dependencyName.startsWith("@github/"); +} + +export async function loadNpmPublicationTimes( + dependencies: readonly ProductionDependency[], + request: RegistryRequest = (url) => fetch(url) +): Promise> { + const externalDependencies = dependencies.filter((dependency) => + requiresPublicationCooldown(dependency.name) + ); + const packageNames = [...new Set(externalDependencies.map((dependency) => dependency.name))]; + const metadata = new Map>(); + + await Promise.all( + packageNames.map(async (packageName) => { + const response = await request( + `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}` + ); + assert( + response.ok, + `npm metadata request for ${packageName} failed: ${response.status} ${response.statusText}` + ); + const body = await response.json(); + assert(isRecord(body), `npm metadata for ${packageName} must be a JSON object`); + const times = body.time; + assert(isRecord(times), `npm metadata for ${packageName} is missing publication times`); + metadata.set(packageName, times); + }) + ); + + return new Map( + externalDependencies.map((dependency) => { + const publishedAt = metadata.get(dependency.name)?.[dependency.version]; + assert( + typeof publishedAt === "string", + `npm metadata is missing a publication time for ${dependencyKey(dependency)}` + ); + return [dependencyKey(dependency), publishedAt]; + }) + ); +} + +export function assertMinimumPublicationAge( + dependencies: readonly ProductionDependency[], + publicationTimes: ReadonlyMap, + now = Date.now() +): void { + assert(Number.isFinite(now), "Dependency policy verification time must be finite"); + + for (const dependency of dependencies) { + if (!requiresPublicationCooldown(dependency.name)) { + continue; + } + const key = dependencyKey(dependency); + const publishedAt = publicationTimes.get(key); + assert(publishedAt, `Missing npm publication time for ${key}`); + const publishedAtMs = Date.parse(publishedAt); + assert(Number.isFinite(publishedAtMs), `Invalid npm publication time for ${key}`); + const eligibleAtMs = publishedAtMs + MINIMUM_DEPENDENCY_AGE_MS; + assert( + now >= eligibleAtMs, + `${key} was published at ${publishedAt} and is not eligible until ${new Date( + eligibleAtMs + ).toISOString()}` + ); + } +} diff --git a/nodejs/scripts/verify-dependency-policy.ts b/nodejs/scripts/verify-dependency-policy.ts new file mode 100644 index 0000000000..b3cc2e59c6 --- /dev/null +++ b/nodejs/scripts/verify-dependency-policy.ts @@ -0,0 +1,32 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertExactProductionDependencies, + assertMinimumPublicationAge, + loadNpmPublicationTimes, + readProductionDependencyManifest, + readResolvedProductionDependencies, + requiresPublicationCooldown, +} from "./dependency-policy.js"; + +async function main(): Promise { + const nodeRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + const manifest = readProductionDependencyManifest(join(nodeRoot, "package.json")); + const directDependencies = assertExactProductionDependencies(manifest); + const resolvedDependencies = readResolvedProductionDependencies( + join(nodeRoot, "package-lock.json") + ); + const publicationTimes = await loadNpmPublicationTimes(resolvedDependencies); + assertMinimumPublicationAge(resolvedDependencies, publicationTimes); + const externalCount = resolvedDependencies.filter((dependency) => + requiresPublicationCooldown(dependency.name) + ).length; + console.log( + `Verified ${directDependencies.length} exact direct production dependencies; ${externalCount} resolved production package versions meet the seven-day npm publication age requirement.` + ); +} + +main().catch((error) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/nodejs/scripts/verify-release-packages.ts b/nodejs/scripts/verify-release-packages.ts index 0d2f4589d9..93306d3d98 100644 --- a/nodejs/scripts/verify-release-packages.ts +++ b/nodejs/scripts/verify-release-packages.ts @@ -6,13 +6,15 @@ import { fileURLToPath } from "node:url"; import { globSync } from "glob"; import { t as listTar, x as extractTar } from "tar"; import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; +import { assertExactProductionDependencies } from "./dependency-policy.js"; interface PackedPackage { manifest: { + dependencies?: Record; name: string; + optionalDependencies?: Record; version: string; repository?: string | { type?: string; url?: string }; - optionalDependencies?: Record; }; entries: Set; } @@ -27,6 +29,7 @@ assert( : sourceManifest.repository?.url?.trim(), "Main package is missing repository metadata" ); +assertExactProductionDependencies(sourceManifest); const expectedRuntimePackages = Object.fromEntries( RUNTIME_PLATFORMS.map((platform) => [getRuntimePackageName(platform), sourceManifest.version]) ); @@ -77,11 +80,17 @@ assert.deepEqual( const mainPackage = packages.get(sourceManifest.name); assert(mainPackage, `Missing ${sourceManifest.name} tarball`); +assert.deepEqual( + mainPackage.manifest.dependencies, + sourceManifest.dependencies, + "Main package dependencies do not match the source manifest" +); assert.deepEqual( mainPackage.manifest.optionalDependencies, expectedRuntimePackages, "Main package optional dependencies do not match the platform packages" ); +assertExactProductionDependencies(mainPackage.manifest); assert(mainPackage.entries.has("package/dist/index.js"), "Main package is missing dist/index.js"); assert( mainPackage.entries.has("package/dist/cjs/index.js"), diff --git a/nodejs/test/dependency-policy.test.ts b/nodejs/test/dependency-policy.test.ts new file mode 100644 index 0000000000..eaa1fc7c34 --- /dev/null +++ b/nodejs/test/dependency-policy.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { + assertExactProductionDependencies, + assertMinimumPublicationAge, + loadNpmPublicationTimes, + MINIMUM_DEPENDENCY_AGE_MS, + parseResolvedProductionDependencies, + type ProductionDependency, + type RegistryRequest, +} from "../scripts/dependency-policy.js"; + +const dependency: ProductionDependency = { + name: "koffi", + version: "3.2.1", +}; +const publishedAt = "2026-09-04T07:39:01.277Z"; + +describe("production dependency policy", () => { + it("accepts exact dependencies and optional dependencies", () => { + expect( + assertExactProductionDependencies({ + dependencies: { koffi: "3.2.1" }, + optionalDependencies: { "optional-package": "1.0.0-beta.1" }, + }) + ).toEqual([ + dependency, + { + name: "optional-package", + version: "1.0.0-beta.1", + }, + ]); + }); + + it.each(["^3.2.1", "~3.2.1", ">=3.2.1", "latest", "file:../package"])( + "rejects non-exact requirement %s", + (version) => { + expect(() => + assertExactProductionDependencies({ dependencies: { koffi: version } }) + ).toThrow(`dependencies.koffi must use an exact SemVer version; found '${version}'`); + } + ); + + it("accepts a version at the exact seven-day boundary", () => { + const now = Date.parse(publishedAt) + MINIMUM_DEPENDENCY_AGE_MS; + expect(() => + assertMinimumPublicationAge([dependency], new Map([["koffi@3.2.1", publishedAt]]), now) + ).not.toThrow(); + }); + + it("rejects a version newer than seven full days", () => { + const now = Date.parse(publishedAt) + MINIMUM_DEPENDENCY_AGE_MS - 1; + expect(() => + assertMinimumPublicationAge([dependency], new Map([["koffi@3.2.1", publishedAt]]), now) + ).toThrow("is not eligible until 2026-09-11T07:39:01.277Z"); + }); + + it("exempts GitHub packages from the publication cooldown", () => { + expect(() => + assertMinimumPublicationAge( + [ + { + name: "@github/copilot", + version: "1.0.0", + }, + ], + new Map(), + Date.parse("2026-09-16T00:00:00Z") + ) + ).not.toThrow(); + }); + + it("loads authoritative publication times from the npm registry", async () => { + const requestedUrls: string[] = []; + const request: RegistryRequest = async (url) => { + requestedUrls.push(url); + return { + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ + time: { + "3.2.1": publishedAt, + }, + }), + }; + }; + + await expect(loadNpmPublicationTimes([dependency], request)).resolves.toEqual( + new Map([["koffi@3.2.1", publishedAt]]) + ); + expect(requestedUrls).toEqual(["https://registry.npmjs.org/koffi"]); + }); + + it("surfaces npm metadata request failures", async () => { + const request: RegistryRequest = async () => ({ + ok: false, + status: 503, + statusText: "Service Unavailable", + json: async () => ({}), + }); + + await expect(loadNpmPublicationTimes([dependency], request)).rejects.toThrow( + "npm metadata request for koffi failed: 503 Service Unavailable" + ); + }); + + it("reads the complete resolved production graph from package-lock v3", () => { + expect( + parseResolvedProductionDependencies({ + lockfileVersion: 3, + packages: { + "": { + dependencies: { koffi: "3.2.1" }, + devDependencies: { eslint: "^9.0.0" }, + }, + "node_modules/koffi": { version: "3.2.1" }, + "node_modules/@koromix/koffi-linux-x64": { + version: "3.2.1", + optional: true, + }, + "node_modules/shared-optional": { + version: "2.0.0", + devOptional: true, + }, + "node_modules/eslint": { version: "9.0.0", dev: true }, + "node_modules/dev-optional": { + version: "1.0.0", + dev: true, + optional: true, + }, + "node_modules/local-package": { link: true }, + "node_modules/bundled-package": { + version: "1.0.0", + inBundle: true, + }, + "node_modules/parent/node_modules/nested": { version: "4.0.0" }, + }, + }) + ).toEqual([ + { name: "koffi", version: "3.2.1" }, + { + name: "@koromix/koffi-linux-x64", + version: "3.2.1", + }, + { name: "shared-optional", version: "2.0.0" }, + { name: "nested", version: "4.0.0" }, + ]); + }); + + it("keeps distinct resolved versions and removes duplicate package/version pairs", () => { + expect( + parseResolvedProductionDependencies({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/example": { version: "1.0.0" }, + "node_modules/parent/node_modules/example": { version: "1.0.0" }, + "node_modules/other/node_modules/example": { version: "2.0.0" }, + }, + }) + ).toEqual([ + { name: "example", version: "1.0.0" }, + { name: "example", version: "2.0.0" }, + ]); + }); + + it("rejects unsupported production lockfile entries", () => { + expect(() => + parseResolvedProductionDependencies({ + lockfileVersion: 3, + packages: { + "": {}, + "packages/example": { version: "1.0.0" }, + }, + }) + ).toThrow("Unsupported production package-lock path 'packages/example'"); + expect(() => + parseResolvedProductionDependencies({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/example": { version: "github:owner/example" }, + }, + }) + ).toThrow("Resolved production dependency example must use an exact SemVer version"); + }); +}); diff --git a/nodejs/tsconfig.test.json b/nodejs/tsconfig.test.json index cc659e9a3a..8d24d6dfd8 100644 --- a/nodejs/tsconfig.test.json +++ b/nodejs/tsconfig.test.json @@ -7,6 +7,7 @@ }, "include": [ "src/**/*", + "test/dependency-policy.test.ts", "test/ffiRuntimeHost.test.ts", "test/session-event-types.test.ts", "test/message-source.test.ts"