Skip to content

Commit b96ddab

Browse files
os-zhuangclaude
andauthored
fix(packages): reject duplicate package id on POST /packages with 409 (#2971)
POST /api/v1/packages was an upsert — creating a package whose id already exists silently overwrote its manifest (name/version/…) with no warning, a data-loss footgun surfaced in Studio package-create dogfooding. It now returns 409 Conflict when the id already exists, and 400 when the id is missing. Intentional upgrade / re-install flows opt back in with `overwrite: true` (body) or `?overwrite=true`; the registry-level same-id reinstall path is untouched. Adds 4 handlePackages tests (create / duplicate-409 / overwrite / missing-id). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 541b96a commit b96ddab

2 files changed

Lines changed: 122 additions & 1 deletion

File tree

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,103 @@ describe('HttpDispatcher', () => {
11191119
expect(updatePackage).toHaveBeenCalledWith({ packageId: 'a.b', patch: { description: 'hi' } });
11201120
});
11211121

1122+
it('POST /packages creates a new package (201) after checking the id is free', async () => {
1123+
const installPackage = vi
1124+
.fn()
1125+
.mockReturnValue({ manifest: { id: 'com.acme.new', name: 'New', version: '0.1.0' } });
1126+
const mockRegistry = {
1127+
getPackage: vi.fn().mockReturnValue(undefined),
1128+
installPackage,
1129+
getAllPackages: vi.fn().mockReturnValue([]),
1130+
};
1131+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1132+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
1133+
return null; // no protocol service → fall back to registry.installPackage
1134+
});
1135+
1136+
const result = await dispatcher.handlePackages(
1137+
'',
1138+
'POST',
1139+
{ manifest: { id: 'com.acme.new', name: 'New', version: '0.1.0', type: 'app' } },
1140+
{},
1141+
{ request: {} },
1142+
);
1143+
expect(result.response?.status).toBe(201);
1144+
expect(mockRegistry.getPackage).toHaveBeenCalledWith('com.acme.new');
1145+
expect(installPackage).toHaveBeenCalled();
1146+
});
1147+
1148+
it('POST /packages rejects a duplicate id with 409 instead of silently overwriting', async () => {
1149+
const installPackage = vi.fn();
1150+
const mockRegistry = {
1151+
getPackage: vi.fn().mockReturnValue({ manifest: { id: 'com.acme.crm', name: 'Existing' } }),
1152+
installPackage,
1153+
getAllPackages: vi.fn().mockReturnValue([]),
1154+
};
1155+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1156+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
1157+
return null;
1158+
});
1159+
1160+
const result = await dispatcher.handlePackages(
1161+
'',
1162+
'POST',
1163+
{ manifest: { id: 'com.acme.crm', name: 'Clobber', version: '9.9.9' } },
1164+
{},
1165+
{ request: {} },
1166+
);
1167+
expect(result.response?.status).toBe(409);
1168+
// The existing manifest must NOT be overwritten.
1169+
expect(installPackage).not.toHaveBeenCalled();
1170+
});
1171+
1172+
it('POST /packages?overwrite=true allows intentional overwrite of an existing id', async () => {
1173+
const installPackage = vi
1174+
.fn()
1175+
.mockReturnValue({ manifest: { id: 'com.acme.crm', name: 'Upgraded', version: '2.0.0' } });
1176+
const mockRegistry = {
1177+
getPackage: vi.fn().mockReturnValue({ manifest: { id: 'com.acme.crm' } }),
1178+
installPackage,
1179+
getAllPackages: vi.fn().mockReturnValue([]),
1180+
};
1181+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1182+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
1183+
return null;
1184+
});
1185+
1186+
const result = await dispatcher.handlePackages(
1187+
'',
1188+
'POST',
1189+
{ manifest: { id: 'com.acme.crm', name: 'Upgraded', version: '2.0.0' } },
1190+
{ overwrite: 'true' },
1191+
{ request: {} },
1192+
);
1193+
expect(result.response?.status).toBe(201);
1194+
expect(installPackage).toHaveBeenCalled();
1195+
});
1196+
1197+
it('POST /packages rejects a missing id with 400', async () => {
1198+
const mockRegistry = {
1199+
getPackage: vi.fn(),
1200+
installPackage: vi.fn(),
1201+
getAllPackages: vi.fn().mockReturnValue([]),
1202+
};
1203+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1204+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
1205+
return null;
1206+
});
1207+
1208+
const result = await dispatcher.handlePackages(
1209+
'',
1210+
'POST',
1211+
{ manifest: { name: 'No Id' } },
1212+
{},
1213+
{ request: {} },
1214+
);
1215+
expect(result.response?.status).toBe(400);
1216+
expect(mockRegistry.installPackage).not.toHaveBeenCalled();
1217+
});
1218+
11221219
it('PATCH /packages/:id rejects an empty patch with 400', async () => {
11231220
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
11241221
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
@@ -1409,7 +1506,11 @@ describe('HttpDispatcher', () => {
14091506
package: { manifest: { id: 'app.demo' }, status: 'installed' },
14101507
message: 'Installed package: app.demo',
14111508
});
1412-
const mockRegistry = { installPackage: vi.fn(), getAllPackages: vi.fn().mockReturnValue([]) };
1509+
const mockRegistry = {
1510+
installPackage: vi.fn(),
1511+
getPackage: vi.fn().mockReturnValue(undefined),
1512+
getAllPackages: vi.fn().mockReturnValue([]),
1513+
};
14131514
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
14141515
if (name === 'protocol') return Promise.resolve({ installPackage });
14151516
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
@@ -1429,6 +1530,7 @@ describe('HttpDispatcher', () => {
14291530
it('falls back to registry.installPackage when the protocol lacks the method', async () => {
14301531
const mockRegistry = {
14311532
installPackage: vi.fn().mockReturnValue({ manifest: { id: 'app.fb' }, status: 'installed' }),
1533+
getPackage: vi.fn().mockReturnValue(undefined),
14321534
getAllPackages: vi.fn().mockReturnValue([]),
14331535
};
14341536
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {

packages/runtime/src/http-dispatcher.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2503,6 +2503,25 @@ export class HttpDispatcher {
25032503
// registry write only when the protocol service/method is unavailable.
25042504
if (parts.length === 0 && m === 'POST') {
25052505
const manifest = body.manifest || body;
2506+
const pkgId = typeof manifest?.id === 'string' ? manifest.id.trim() : '';
2507+
// A package id is mandatory — without one the install cannot be keyed.
2508+
if (!pkgId) {
2509+
return { handled: true, response: this.error('Package id is required', 400) };
2510+
}
2511+
// Duplicate-detection: POST /packages CREATES a package. If one with
2512+
// this id already exists, silently overwriting it destroys the existing
2513+
// manifest (name/version/…) with no warning — a data-loss footgun
2514+
// surfaced in Studio package-create dogfooding. Reject with 409 Conflict
2515+
// instead. Intentional upgrade / re-install flows opt back in with
2516+
// `overwrite: true` (body) or `?overwrite=true`.
2517+
const overwrite =
2518+
body?.overwrite === true || query?.overwrite === 'true' || query?.overwrite === true;
2519+
if (!overwrite && registry.getPackage(pkgId)) {
2520+
return {
2521+
handled: true,
2522+
response: this.error(`Package '${pkgId}' already exists`, 409),
2523+
};
2524+
}
25062525
let pkg: any;
25072526
const protocolSvc: any = await this.resolveService('protocol').catch(() => null);
25082527
if (protocolSvc && typeof protocolSvc.installPackage === 'function') {

0 commit comments

Comments
 (0)