@sentinel-atl/registry 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Trust Registry HTTP API — REST endpoints for publishing and querying STCs.
3
+ *
4
+ * Endpoints:
5
+ * POST /api/v1/certificates Register a new STC
6
+ * GET /api/v1/certificates/:id Get certificate by ID
7
+ * GET /api/v1/certificates Query certificates
8
+ * DELETE /api/v1/certificates/:id Remove a certificate
9
+ *
10
+ * GET /api/v1/packages/:name Get latest certificate for a package
11
+ * GET /api/v1/packages/:name/history Get all certificates for a package
12
+ * GET /api/v1/packages/:name/badge SVG badge for a package
13
+ * GET /api/v1/packages/:name/badge/score Score badge
14
+ *
15
+ * GET /api/v1/stats Registry stats
16
+ * GET /health Health check
17
+ */
18
+
19
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
20
+ import { CertificateStore, type RegistryEntry } from './store.js';
21
+ import { gradeBadge, scoreBadge, verifiedBadge, notFoundBadge, type BadgeStyle } from './badge.js';
22
+ import {
23
+ authenticate, hasScope, sendUnauthorized, sendForbidden,
24
+ applyCors, defaultCorsConfig,
25
+ createSecureServer,
26
+ applySecurityHeaders,
27
+ type AuthConfig, type CorsConfig, type TlsConfig,
28
+ } from '@sentinel-atl/hardening';
29
+ import type { SentinelTrustCertificate } from '@sentinel-atl/scanner';
30
+
31
+ // ─── Types ───────────────────────────────────────────────────────────
32
+
33
+ export interface RegistryServerOptions {
34
+ /** Port to listen on */
35
+ port?: number;
36
+ /** Pre-configured certificate store */
37
+ store?: CertificateStore;
38
+ /** API key authentication config */
39
+ auth?: AuthConfig;
40
+ /** CORS configuration */
41
+ cors?: CorsConfig;
42
+ /** TLS configuration */
43
+ tls?: TlsConfig;
44
+ }
45
+
46
+ // ─── Server ──────────────────────────────────────────────────────────
47
+
48
+ export class RegistryServer {
49
+ private server: Server | null = null;
50
+ private store: CertificateStore;
51
+ private port: number;
52
+ private authConfig: AuthConfig;
53
+ private corsConfig: CorsConfig;
54
+ private tlsConfig?: TlsConfig;
55
+
56
+ constructor(options?: RegistryServerOptions) {
57
+ this.port = options?.port ?? 3200;
58
+ this.store = options?.store ?? new CertificateStore();
59
+ this.authConfig = options?.auth ?? { enabled: false, keys: [] };
60
+ this.corsConfig = options?.cors ?? defaultCorsConfig();
61
+ this.tlsConfig = options?.tls;
62
+
63
+ // Badge endpoints are always public (for README embeds)
64
+ if (this.authConfig.enabled && !this.authConfig.publicPaths) {
65
+ this.authConfig.publicPaths = ['/health'];
66
+ }
67
+ }
68
+
69
+ getStore(): CertificateStore {
70
+ return this.store;
71
+ }
72
+
73
+ async start(): Promise<{ port: number }> {
74
+ // Load persisted certificates from backend (no-op if in-memory only)
75
+ await this.store.load();
76
+
77
+ return new Promise((resolve, reject) => {
78
+ this.server = createSecureServer(
79
+ (req, res) => this.handleRequest(req, res),
80
+ this.tlsConfig
81
+ );
82
+ this.server.on('error', reject);
83
+ this.server.listen(this.port, () => {
84
+ resolve({ port: this.port });
85
+ });
86
+ });
87
+ }
88
+
89
+ async stop(): Promise<void> {
90
+ return new Promise((resolve) => {
91
+ if (this.server) {
92
+ this.server.close(() => resolve());
93
+ } else {
94
+ resolve();
95
+ }
96
+ });
97
+ }
98
+
99
+ isTLS(): boolean {
100
+ return !!this.tlsConfig?.certPath || !!this.tlsConfig?.cert;
101
+ }
102
+
103
+ // ─── Router ────────────────────────────────────────────────────
104
+
105
+ private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
106
+ const url = new URL(req.url ?? '/', `http://localhost`);
107
+ const path = url.pathname;
108
+ const method = req.method ?? 'GET';
109
+
110
+ // CORS (configurable origins)
111
+ if (applyCors(req, res, this.corsConfig)) return; // preflight handled
112
+
113
+ // Security headers
114
+ applySecurityHeaders(res, { hsts: this.isTLS() });
115
+
116
+ // Authentication
117
+ const authResult = authenticate(req, this.authConfig);
118
+
119
+ // Badge endpoints are public even when auth is enabled
120
+ const isBadgePath = path.includes('/badge');
121
+ if (!authResult.authenticated && !isBadgePath) {
122
+ return sendUnauthorized(res, this.authConfig, authResult.error);
123
+ }
124
+
125
+ try {
126
+ // Health
127
+ if (path === '/health' && method === 'GET') {
128
+ return this.sendJson(res, 200, { status: 'ok', certificates: this.store.count() });
129
+ }
130
+
131
+ // Stats
132
+ if (path === '/api/v1/stats' && method === 'GET') {
133
+ return this.sendJson(res, 200, this.store.getStats());
134
+ }
135
+
136
+ // POST /api/v1/certificates — register (requires write scope)
137
+ if (path === '/api/v1/certificates' && method === 'POST') {
138
+ if (!hasScope(authResult, 'write')) return sendForbidden(res, 'Write scope required');
139
+ return await this.handleRegister(req, res);
140
+ }
141
+
142
+ // GET /api/v1/certificates — query
143
+ if (path === '/api/v1/certificates' && method === 'GET') {
144
+ return this.handleQuery(url, res);
145
+ }
146
+
147
+ // GET/DELETE /api/v1/certificates/:id
148
+ const certMatch = path.match(/^\/api\/v1\/certificates\/(.+)$/);
149
+ if (certMatch) {
150
+ const id = decodeURIComponent(certMatch[1]);
151
+ if (method === 'GET') return this.handleGetById(id, res);
152
+ if (method === 'DELETE') {
153
+ if (!hasScope(authResult, 'admin')) return sendForbidden(res, 'Admin scope required');
154
+ return this.handleDelete(id, res);
155
+ }
156
+ }
157
+
158
+ // Package routes: /api/v1/packages/:name[/history|/badge|/badge/score]
159
+ const pkgMatch = path.match(/^\/api\/v1\/packages\/(@[^/]+\/[^/]+|[^/]+)(\/.*)?$/);
160
+ if (pkgMatch) {
161
+ const packageName = decodeURIComponent(pkgMatch[1]);
162
+ const suffix = pkgMatch[2] ?? '';
163
+
164
+ if (suffix === '' && method === 'GET') return this.handleGetPackage(packageName, res);
165
+ if (suffix === '/history' && method === 'GET') return this.handlePackageHistory(packageName, res);
166
+ if (suffix === '/badge' && method === 'GET') return this.handleBadge(packageName, url, res);
167
+ if (suffix === '/badge/score' && method === 'GET') return this.handleScoreBadge(packageName, url, res);
168
+ }
169
+
170
+ this.sendJson(res, 404, { error: 'Not found' });
171
+ } catch (err) {
172
+ this.sendJson(res, 500, { error: 'Internal server error' });
173
+ }
174
+ }
175
+
176
+ // ─── Handlers ──────────────────────────────────────────────────
177
+
178
+ private async handleRegister(req: IncomingMessage, res: ServerResponse): Promise<void> {
179
+ // Content-Type enforcement
180
+ const contentType = req.headers['content-type'];
181
+ if (!contentType || !contentType.includes('application/json')) {
182
+ return this.sendJson(res, 415, { error: 'Content-Type must be application/json' });
183
+ }
184
+
185
+ const body = await readBody(req);
186
+ let certificate: SentinelTrustCertificate;
187
+
188
+ try {
189
+ certificate = JSON.parse(body);
190
+ } catch {
191
+ return this.sendJson(res, 400, { error: 'Invalid JSON' });
192
+ }
193
+
194
+ // Comprehensive STC validation
195
+ const errors = validateSTC(certificate);
196
+ if (errors.length > 0) {
197
+ return this.sendJson(res, 400, { error: 'Invalid STC', details: errors });
198
+ }
199
+
200
+ // Check for duplicates
201
+ if (this.store.get(certificate.id)) {
202
+ return this.sendJson(res, 409, { error: 'Certificate already registered', id: certificate.id });
203
+ }
204
+
205
+ const entry = await this.store.register(certificate);
206
+
207
+ this.sendJson(res, 201, {
208
+ id: entry.id,
209
+ packageName: entry.packageName,
210
+ trustScore: entry.trustScore,
211
+ grade: entry.grade,
212
+ verified: entry.verified,
213
+ registeredAt: entry.registeredAt,
214
+ });
215
+ }
216
+
217
+ private handleGetById(id: string, res: ServerResponse): void {
218
+ const entry = this.store.get(id);
219
+ if (!entry) {
220
+ return this.sendJson(res, 404, { error: 'Certificate not found' });
221
+ }
222
+ this.sendJson(res, 200, this.formatEntry(entry));
223
+ }
224
+
225
+ private async handleDelete(id: string, res: ServerResponse): Promise<void> {
226
+ const removed = await this.store.remove(id);
227
+ if (!removed) {
228
+ return this.sendJson(res, 404, { error: 'Certificate not found' });
229
+ }
230
+ this.sendJson(res, 200, { deleted: true, id });
231
+ }
232
+
233
+ private handleQuery(url: URL, res: ServerResponse): void {
234
+ const parseIntSafe = (val: string | null): number | undefined => {
235
+ if (val === null) return undefined;
236
+ const n = parseInt(val, 10);
237
+ return Number.isNaN(n) ? undefined : n;
238
+ };
239
+
240
+ const q = {
241
+ packageName: url.searchParams.get('package') ?? undefined,
242
+ minScore: parseIntSafe(url.searchParams.get('minScore')),
243
+ minGrade: url.searchParams.get('minGrade') ?? undefined,
244
+ verified: url.searchParams.has('verified') ? url.searchParams.get('verified') === 'true' : undefined,
245
+ limit: parseIntSafe(url.searchParams.get('limit')),
246
+ offset: parseIntSafe(url.searchParams.get('offset')),
247
+ };
248
+
249
+ const results = this.store.query(q);
250
+ this.sendJson(res, 200, {
251
+ total: this.store.count(),
252
+ count: results.length,
253
+ certificates: results.map(e => this.formatEntry(e)),
254
+ });
255
+ }
256
+
257
+ private handleGetPackage(packageName: string, res: ServerResponse): void {
258
+ const entry = this.store.getLatestForPackage(packageName);
259
+ if (!entry) {
260
+ return this.sendJson(res, 404, { error: 'No certificates found for package', packageName });
261
+ }
262
+ this.sendJson(res, 200, this.formatEntry(entry));
263
+ }
264
+
265
+ private handlePackageHistory(packageName: string, res: ServerResponse): void {
266
+ const entries = this.store.getForPackage(packageName);
267
+ this.sendJson(res, 200, {
268
+ packageName,
269
+ count: entries.length,
270
+ certificates: entries.map(e => this.formatEntry(e)),
271
+ });
272
+ }
273
+
274
+ private handleBadge(packageName: string, url: URL, res: ServerResponse): void {
275
+ const style = (url.searchParams.get('style') ?? 'flat') as BadgeStyle;
276
+ const entry = this.store.getLatestForPackage(packageName);
277
+
278
+ let svg: string;
279
+ if (!entry) {
280
+ svg = notFoundBadge(style);
281
+ } else if (entry.verified) {
282
+ svg = gradeBadge(entry.grade, style);
283
+ } else {
284
+ svg = verifiedBadge(false, style);
285
+ }
286
+
287
+ res.writeHead(200, {
288
+ 'Content-Type': 'image/svg+xml',
289
+ 'Cache-Control': 'max-age=300',
290
+ });
291
+ res.end(svg);
292
+ }
293
+
294
+ private handleScoreBadge(packageName: string, url: URL, res: ServerResponse): void {
295
+ const style = (url.searchParams.get('style') ?? 'flat') as BadgeStyle;
296
+ const entry = this.store.getLatestForPackage(packageName);
297
+
298
+ let svg: string;
299
+ if (!entry) {
300
+ svg = notFoundBadge(style);
301
+ } else {
302
+ svg = scoreBadge(entry.trustScore, style);
303
+ }
304
+
305
+ res.writeHead(200, {
306
+ 'Content-Type': 'image/svg+xml',
307
+ 'Cache-Control': 'max-age=300',
308
+ });
309
+ res.end(svg);
310
+ }
311
+
312
+ // ─── Helpers ───────────────────────────────────────────────────
313
+
314
+ private formatEntry(entry: RegistryEntry) {
315
+ return {
316
+ id: entry.id,
317
+ packageName: entry.packageName,
318
+ packageVersion: entry.packageVersion,
319
+ trustScore: entry.trustScore,
320
+ grade: entry.grade,
321
+ verified: entry.verified,
322
+ registeredAt: entry.registeredAt,
323
+ issuerDid: entry.issuerDid,
324
+ certificate: entry.certificate,
325
+ };
326
+ }
327
+
328
+ private sendJson(res: ServerResponse, status: number, data: unknown): void {
329
+ res.writeHead(status, { 'Content-Type': 'application/json' });
330
+ res.end(JSON.stringify(data));
331
+ }
332
+ }
333
+
334
+ // ─── STC Validation ───────────────────────────────────────────────────
335
+
336
+ function validateSTC(cert: any): string[] {
337
+ const errors: string[] = [];
338
+
339
+ if (!cert || typeof cert !== 'object') {
340
+ return ['Certificate must be a JSON object'];
341
+ }
342
+
343
+ // Required fields
344
+ if (!cert.id || typeof cert.id !== 'string') errors.push('Missing or invalid "id" (string)');
345
+ if (cert.type !== 'SentinelTrustCertificate') errors.push('"type" must be "SentinelTrustCertificate"');
346
+ if (cert['@context'] !== 'https://sentinel.trust/stc/v1') errors.push('"@context" must be "https://sentinel.trust/stc/v1"');
347
+
348
+ // Timestamps
349
+ if (!cert.issuedAt || typeof cert.issuedAt !== 'string') errors.push('Missing "issuedAt" (ISO date)');
350
+ if (!cert.expiresAt || typeof cert.expiresAt !== 'string') errors.push('Missing "expiresAt" (ISO date)');
351
+
352
+ // Issuer
353
+ if (!cert.issuer || typeof cert.issuer !== 'object') {
354
+ errors.push('Missing "issuer" object');
355
+ } else {
356
+ if (!cert.issuer.did || typeof cert.issuer.did !== 'string') errors.push('Missing "issuer.did"');
357
+ }
358
+
359
+ // Subject
360
+ if (!cert.subject || typeof cert.subject !== 'object') {
361
+ errors.push('Missing "subject" object');
362
+ } else {
363
+ if (!cert.subject.packageName || typeof cert.subject.packageName !== 'string') errors.push('Missing "subject.packageName"');
364
+ if (!cert.subject.packageVersion || typeof cert.subject.packageVersion !== 'string') errors.push('Missing "subject.packageVersion"');
365
+ }
366
+
367
+ // Trust score
368
+ if (!cert.trustScore || typeof cert.trustScore !== 'object') {
369
+ errors.push('Missing "trustScore" object');
370
+ } else {
371
+ if (typeof cert.trustScore.overall !== 'number' || cert.trustScore.overall < 0 || cert.trustScore.overall > 100) {
372
+ errors.push('"trustScore.overall" must be a number 0-100');
373
+ }
374
+ if (!cert.trustScore.grade || typeof cert.trustScore.grade !== 'string') {
375
+ errors.push('Missing "trustScore.grade"');
376
+ }
377
+ }
378
+
379
+ // Proof
380
+ if (!cert.proof || typeof cert.proof !== 'object') {
381
+ errors.push('Missing "proof" object');
382
+ }
383
+
384
+ return errors;
385
+ }
386
+
387
+ // ─── Body Reader ──────────────────────────────────────────────────────
388
+
389
+ function readBody(req: IncomingMessage): Promise<string> {
390
+ return new Promise((resolve, reject) => {
391
+ const chunks: Buffer[] = [];
392
+ let size = 0;
393
+ const MAX_BODY = 2_097_152; // 2 MB (STC max)
394
+
395
+ req.on('data', (chunk: Buffer) => {
396
+ size += chunk.length;
397
+ if (size > MAX_BODY) {
398
+ reject(new Error('Request body too large'));
399
+ req.destroy();
400
+ return;
401
+ }
402
+ chunks.push(chunk);
403
+ });
404
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
405
+ req.on('error', reject);
406
+ });
407
+ }
package/src/store.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Certificate Store — persistent storage for Sentinel Trust Certificates.
3
+ *
4
+ * Backed by SentinelStore interface — supports in-memory, Redis, Postgres, SQLite.
5
+ * By default uses in-memory Map for zero-config development.
6
+ */
7
+
8
+ import { verifySTC, type SentinelTrustCertificate, type STCVerifyResult } from '@sentinel-atl/scanner';
9
+ import type { SentinelStore } from '@sentinel-atl/store';
10
+
11
+ // ─── Types ───────────────────────────────────────────────────────────
12
+
13
+ export interface RegistryEntry {
14
+ /** STC ID */
15
+ id: string;
16
+ /** Full certificate */
17
+ certificate: SentinelTrustCertificate;
18
+ /** Package name (for lookup) */
19
+ packageName: string;
20
+ /** Package version */
21
+ packageVersion: string;
22
+ /** Trust score */
23
+ trustScore: number;
24
+ /** Grade */
25
+ grade: string;
26
+ /** Whether signature is verified */
27
+ verified: boolean;
28
+ /** When the entry was registered */
29
+ registeredAt: string;
30
+ /** Issuer DID */
31
+ issuerDid: string;
32
+ }
33
+
34
+ export interface RegistryQuery {
35
+ /** Filter by package name */
36
+ packageName?: string;
37
+ /** Filter by minimum trust score */
38
+ minScore?: number;
39
+ /** Filter by minimum grade */
40
+ minGrade?: string;
41
+ /** Filter by verified status */
42
+ verified?: boolean;
43
+ /** Maximum results */
44
+ limit?: number;
45
+ /** Offset for pagination */
46
+ offset?: number;
47
+ }
48
+
49
+ export interface RegistryStats {
50
+ totalCertificates: number;
51
+ verifiedCertificates: number;
52
+ uniquePackages: number;
53
+ averageScore: number;
54
+ gradeDistribution: Record<string, number>;
55
+ }
56
+
57
+ // ─── Grade Helpers ───────────────────────────────────────────────────
58
+
59
+ const GRADE_ORDER: Record<string, number> = { A: 4, B: 3, C: 2, D: 1, F: 0 };
60
+
61
+ function gradeAtLeast(actual: string, required: string): boolean {
62
+ return (GRADE_ORDER[actual] ?? 0) >= (GRADE_ORDER[required] ?? 0);
63
+ }
64
+
65
+ // ─── Store ───────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Options for creating a CertificateStore.
69
+ * If `backend` is provided, certificates are persisted via SentinelStore.
70
+ * Otherwise, an in-memory Map is used (development only).
71
+ */
72
+ export interface CertificateStoreOptions {
73
+ /** Persistent backend — data survives restarts */
74
+ backend?: SentinelStore;
75
+ /** Key prefix for the backend (default: 'registry:') */
76
+ prefix?: string;
77
+ }
78
+
79
+ const KEY_PREFIX_DEFAULT = 'registry:';
80
+ const CERT_PREFIX = 'cert:';
81
+ const PKG_INDEX_PREFIX = 'pkg:';
82
+
83
+ export class CertificateStore {
84
+ private cache = new Map<string, RegistryEntry>();
85
+ /** Index: packageName → entry IDs (newest first) */
86
+ private pkgIndex = new Map<string, string[]>();
87
+ private backend?: SentinelStore;
88
+ private prefix: string;
89
+ private loaded = false;
90
+
91
+ constructor(options?: CertificateStoreOptions) {
92
+ this.backend = options?.backend;
93
+ this.prefix = options?.prefix ?? KEY_PREFIX_DEFAULT;
94
+ }
95
+
96
+ /** Load all entries from the backend into the in-memory cache. Call once on startup. */
97
+ async load(): Promise<void> {
98
+ if (this.loaded || !this.backend) return;
99
+ const keys = await this.backend.keys(`${this.prefix}${CERT_PREFIX}`);
100
+ const values = await this.backend.getMany(keys);
101
+ for (const [, json] of values) {
102
+ const entry: RegistryEntry = JSON.parse(json);
103
+ this.cache.set(entry.id, entry);
104
+ const existing = this.pkgIndex.get(entry.packageName) ?? [];
105
+ existing.push(entry.id);
106
+ this.pkgIndex.set(entry.packageName, existing);
107
+ }
108
+ // Sort each package's IDs by registeredAt descending
109
+ for (const [pkg, ids] of this.pkgIndex) {
110
+ ids.sort((a, b) => {
111
+ const ea = this.cache.get(a)!;
112
+ const eb = this.cache.get(b)!;
113
+ return eb.registeredAt.localeCompare(ea.registeredAt);
114
+ });
115
+ }
116
+ this.loaded = true;
117
+ }
118
+
119
+ /**
120
+ * Register a new certificate. Verifies signature before storing.
121
+ */
122
+ async register(certificate: SentinelTrustCertificate): Promise<RegistryEntry> {
123
+ const result: STCVerifyResult = await verifySTC(certificate);
124
+
125
+ const entry: RegistryEntry = {
126
+ id: certificate.id,
127
+ certificate,
128
+ packageName: certificate.subject.packageName,
129
+ packageVersion: certificate.subject.packageVersion,
130
+ trustScore: certificate.trustScore.overall,
131
+ grade: certificate.trustScore.grade,
132
+ verified: result.valid,
133
+ registeredAt: new Date().toISOString(),
134
+ issuerDid: certificate.issuer.did,
135
+ };
136
+
137
+ this.cache.set(entry.id, entry);
138
+
139
+ // Update package index
140
+ const existing = this.pkgIndex.get(entry.packageName) ?? [];
141
+ existing.unshift(entry.id); // newest first
142
+ this.pkgIndex.set(entry.packageName, existing);
143
+
144
+ // Persist to backend
145
+ if (this.backend) {
146
+ await this.backend.set(`${this.prefix}${CERT_PREFIX}${entry.id}`, JSON.stringify(entry));
147
+ await this.backend.set(
148
+ `${this.prefix}${PKG_INDEX_PREFIX}${entry.packageName}`,
149
+ JSON.stringify(existing)
150
+ );
151
+ }
152
+
153
+ return entry;
154
+ }
155
+
156
+ /**
157
+ * Get a certificate by ID.
158
+ */
159
+ get(id: string): RegistryEntry | undefined {
160
+ return this.cache.get(id);
161
+ }
162
+
163
+ /**
164
+ * Get the latest certificate for a package.
165
+ */
166
+ getLatestForPackage(packageName: string): RegistryEntry | undefined {
167
+ const ids = this.pkgIndex.get(packageName);
168
+ if (!ids || ids.length === 0) return undefined;
169
+ return this.cache.get(ids[0]);
170
+ }
171
+
172
+ /**
173
+ * Get all certificates for a package.
174
+ */
175
+ getForPackage(packageName: string): RegistryEntry[] {
176
+ const ids = this.pkgIndex.get(packageName) ?? [];
177
+ return ids.map(id => this.cache.get(id)!).filter(Boolean);
178
+ }
179
+
180
+ /**
181
+ * Query certificates with filters.
182
+ */
183
+ query(q: RegistryQuery): RegistryEntry[] {
184
+ let results = Array.from(this.cache.values());
185
+
186
+ if (q.packageName) {
187
+ results = results.filter(e => e.packageName === q.packageName);
188
+ }
189
+ if (q.minScore !== undefined) {
190
+ results = results.filter(e => e.trustScore >= q.minScore!);
191
+ }
192
+ if (q.minGrade) {
193
+ results = results.filter(e => gradeAtLeast(e.grade, q.minGrade!));
194
+ }
195
+ if (q.verified !== undefined) {
196
+ results = results.filter(e => e.verified === q.verified);
197
+ }
198
+
199
+ // Sort by registeredAt descending
200
+ results.sort((a, b) => b.registeredAt.localeCompare(a.registeredAt));
201
+
202
+ const offset = q.offset ?? 0;
203
+ const limit = q.limit ?? 50;
204
+ return results.slice(offset, offset + limit);
205
+ }
206
+
207
+ /**
208
+ * Remove a certificate by ID.
209
+ */
210
+ async remove(id: string): Promise<boolean> {
211
+ const entry = this.cache.get(id);
212
+ if (!entry) return false;
213
+
214
+ this.cache.delete(id);
215
+
216
+ const ids = this.pkgIndex.get(entry.packageName);
217
+ if (ids) {
218
+ const idx = ids.indexOf(id);
219
+ if (idx !== -1) ids.splice(idx, 1);
220
+ if (ids.length === 0) this.pkgIndex.delete(entry.packageName);
221
+ }
222
+
223
+ // Persist deletion to backend
224
+ if (this.backend) {
225
+ await this.backend.delete(`${this.prefix}${CERT_PREFIX}${id}`);
226
+ if (ids && ids.length > 0) {
227
+ await this.backend.set(
228
+ `${this.prefix}${PKG_INDEX_PREFIX}${entry.packageName}`,
229
+ JSON.stringify(ids)
230
+ );
231
+ } else {
232
+ await this.backend.delete(`${this.prefix}${PKG_INDEX_PREFIX}${entry.packageName}`);
233
+ }
234
+ }
235
+
236
+ return true;
237
+ }
238
+
239
+ /**
240
+ * Get registry statistics.
241
+ */
242
+ getStats(): RegistryStats {
243
+ const all = Array.from(this.cache.values());
244
+ const gradeDistribution: Record<string, number> = { A: 0, B: 0, C: 0, D: 0, F: 0 };
245
+
246
+ let totalScore = 0;
247
+ for (const entry of all) {
248
+ totalScore += entry.trustScore;
249
+ gradeDistribution[entry.grade] = (gradeDistribution[entry.grade] ?? 0) + 1;
250
+ }
251
+
252
+ return {
253
+ totalCertificates: all.length,
254
+ verifiedCertificates: all.filter(e => e.verified).length,
255
+ uniquePackages: this.pkgIndex.size,
256
+ averageScore: all.length > 0 ? Math.round(totalScore / all.length) : 0,
257
+ gradeDistribution,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Get total count (for pagination).
263
+ */
264
+ count(): number {
265
+ return this.cache.size;
266
+ }
267
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/__tests__"]
9
+ }