-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathpackage-routes.ts
More file actions
225 lines (203 loc) · 7.94 KB
/
Copy pathpackage-routes.ts
File metadata and controls
225 lines (203 loc) · 7.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { IHttpServer } from '@objectstack/core';
import type { PackageService } from '@objectstack/service-package';
/**
* Options for package route registration.
*/
export interface PackageRoutesOptions {
/**
* Protocol service (ObjectStackProtocol) — provides access to in-memory
* SchemaRegistry packages loaded via defineStack()/AppPlugin at boot time,
* and (#2747) the full `deletePackage` uninstall semantics: package
* metadata rows, the durable `sys_packages` record, and the registered
* data-plane cleanups (e.g. plugin-security revoking the package's
* permission sets and bindings).
*/
protocol?: {
getMetaItems?(req: { type: string }): Promise<{ items: any[] }>;
deletePackage?(req: { packageId: string; actor?: string }): Promise<{
success: boolean;
deletedCount: number;
failedCount: number;
failed: Array<{ type: string; name: string; error: string; code?: string }>;
cleanups: Array<{ name: string; success: boolean; removed: number; error?: string }>;
}>;
};
}
/**
* Register package management API routes
*
* Provides endpoints for publishing, retrieving, and managing packages.
* Routes:
* - POST /api/v1/packages/publish - Publish a package to the marketplace registry
* - GET /api/v1/packages - List all packages (merges registry + database)
* - GET /api/v1/packages/:id - Get a specific package
* - DELETE /api/v1/packages/:id - Delete a package
*
* Marketplace publish lives at `/packages/publish`, NOT at the bare
* `POST /packages` (#3610): that verb+path is the dispatcher packages
* domain's *install* route, and this registrar registers first in the
* production stack (first-match-wins), so claiming it here silently
* swallowed every `client.packages.install` call with a 400. The
* dispatcher's own `POST /packages/:id/publish` (ADR-0033 draft publish)
* is two segments — different shape, no clash.
*/
export function registerPackageRoutes(
server: IHttpServer,
packageService: PackageService,
basePath: string = '/api/v1',
options: PackageRoutesOptions = {},
) {
const packagesPath = `${basePath}/packages`;
// POST /api/v1/packages/publish - Publish a package to the marketplace
server.post(`${packagesPath}/publish`, async (req, res) => {
try {
const { manifest, metadata } = req.body || {};
if (!manifest || !metadata) {
res.status(400).json({ error: 'Missing required fields: manifest, metadata' });
return;
}
if (!manifest.id || !manifest.version) {
res.status(400).json({ error: 'Invalid manifest: id and version are required' });
return;
}
const result = await packageService.publish({ manifest, metadata });
if (result.success) {
res.json({
success: true,
message: `Published ${manifest.id}@${manifest.version}`,
package: {
id: manifest.id,
version: manifest.version,
},
});
return;
}
res.status(400).json({ success: false, error: result.error });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// GET /api/v1/packages - List all packages (merges registry + database)
server.get(packagesPath, async (_req, res) => {
try {
// Merge two sources:
// 1. Registry packages (in-memory, loaded at boot via defineStack/AppPlugin)
// 2. Database packages (published via POST /packages)
const packagesMap = new Map<string, any>();
// Registry packages (via protocol service → SchemaRegistry)
if (options.protocol && typeof options.protocol.getMetaItems === 'function') {
try {
const result = await options.protocol.getMetaItems({ type: 'package' });
if (result?.items) {
for (const item of result.items) {
const id = item.manifest?.id || item.id;
if (id) {
packagesMap.set(id, {
...item,
source: 'registry',
});
}
}
}
} catch {
// Protocol unavailable — continue with database only
}
}
// Database packages (published artifacts)
try {
const dbPackages = await packageService.list();
for (const pkg of dbPackages) {
const id = pkg.manifest?.id || pkg.id;
if (id) {
// Database entry takes precedence (has richer metadata from publish)
packagesMap.set(id, {
...packagesMap.get(id),
...pkg,
source: packagesMap.has(id) ? 'both' : 'database',
});
}
}
} catch {
// Database query failed — continue with registry-only packages
}
const packages = Array.from(packagesMap.values());
res.json({ packages, total: packages.length });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// GET /api/v1/packages/:id - Get a specific package
server.get(`${packagesPath}/:id`, async (req, res) => {
try {
const packageId = req.params.id;
const version = req.query?.version || 'latest';
// Try database first (richer data from publish)
const pkg = await packageService.get(packageId, version);
if (pkg) {
res.json({ package: { ...pkg, source: 'database' } });
return;
}
// Fall back to registry (in-memory loaded packages)
if (options.protocol && typeof options.protocol.getMetaItems === 'function') {
try {
const result = await options.protocol.getMetaItems({ type: 'package' });
const match = result?.items?.find((item: any) =>
(item.manifest?.id || item.id) === packageId
);
if (match) {
res.json({ package: { ...match, source: 'registry' } });
return;
}
} catch {
// Protocol unavailable
}
}
res.status(404).json({ error: 'Package not found' });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// DELETE /api/v1/packages/:id - Delete a package
server.delete(`${packagesPath}/:id`, async (req, res) => {
try {
const packageId = req.params.id;
const version = req.query?.version;
// [#2747] A FULL uninstall (no version pin) goes through
// protocol.deletePackage — one uninstall semantic, not three dialects:
// it removes the package's metadata rows, drops the durable
// sys_packages record, and runs the registered data-plane cleanups
// (plugin-security revokes the package's permission sets/bindings —
// no ghost grants). A version-scoped delete keeps the narrow durable
// registry semantics, as does a deployment without the protocol.
if (!version && typeof options.protocol?.deletePackage === 'function') {
const result = await options.protocol.deletePackage({ packageId });
// Zero metadata rows is still a successful uninstall (e.g. a
// runtime-registered package that never published metadata) —
// only per-item failures make it a failure.
if (result.failedCount === 0) {
res.json({
success: true,
message: `Deleted ${packageId}`,
deletedCount: result.deletedCount,
cleanups: result.cleanups,
});
return;
}
res.status(400).json({ success: false, failed: result.failed, cleanups: result.cleanups });
return;
}
const result = await packageService.delete(packageId, version);
if (result.success) {
res.json({
success: true,
message: `Deleted ${packageId}${version ? `@${version}` : ''}`,
});
return;
}
res.status(400).json({ success: false });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
}