@celilo/e2e 0.9.2-alpha.0 → 0.9.3

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.
@@ -1,246 +0,0 @@
1
- /**
2
- * Bootstrap mode — optional behavior for the e2e stand-in registry.
3
- *
4
- * When BOOTSTRAP_MODULES_DIR is set, the server also serves modules whose
5
- * source lives in that directory. Each immediate subdirectory containing a
6
- * manifest.yml is treated as a module; its manifest's `id` and `version`
7
- * become the sparse index entry. On download, the directory is packaged
8
- * into a .netapp tarball on the fly (cached on disk).
9
- *
10
- * This preserves the existing packages/e2e/docker/Dockerfile.registry
11
- * behavior where tests can import any module from the repo without having
12
- * to explicitly publish each dependency first.
13
- *
14
- * In production, BOOTSTRAP_MODULES_DIR is unset and this whole layer is
15
- * inert — storage.ts is the only code that touches disk.
16
- */
17
-
18
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
19
- import { basename, dirname, join, relative } from 'node:path';
20
- import { createHash } from 'node:crypto';
21
- import type { IndexEntry } from './storage';
22
-
23
- export interface BootstrapEntry {
24
- name: string;
25
- version: string;
26
- /** Source directory to package on demand (source mode). */
27
- sourceDir?: string;
28
- /** Pre-built .netapp to serve directly (upload mode). */
29
- netappPath?: string;
30
- /**
31
- * Manifest's `description` field, when present. Used by the search
32
- * endpoint to populate the module-list payload — see
33
- * apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
34
- */
35
- description?: string;
36
- }
37
-
38
- /**
39
- * Scan a directory for modules. Each subdirectory is a module iff it contains
40
- * a manifest.yml. Manifest parsing is line-regex (not full YAML) so we don't
41
- * need a YAML dependency at runtime — this matches packages/e2e/src/registry-server.ts.
42
- */
43
- export function scanBootstrapDir(bootstrapDir: string): Map<string, BootstrapEntry> {
44
- const entries = new Map<string, BootstrapEntry>();
45
- if (!existsSync(bootstrapDir)) return entries;
46
-
47
- for (const entry of readdirSync(bootstrapDir, { withFileTypes: true })) {
48
- if (!entry.isDirectory()) continue;
49
- const manifestPath = join(bootstrapDir, entry.name, 'manifest.yml');
50
- if (!existsSync(manifestPath)) continue;
51
- try {
52
- const yaml = readFileSync(manifestPath, 'utf-8');
53
- const idMatch = yaml.match(/^id:\s*['"]?([^\s'"#]+)['"]?/m);
54
- const versionMatch = yaml.match(/^version:\s*['"]?([^\s'"#]+)['"]?/m);
55
- // Description is the rest-of-line up to (but not including) the
56
- // newline. The manifest schema allows multi-line scalars, but
57
- // existing modules use the single-line form so the line-regex is
58
- // sufficient. We strip surrounding quotes if present.
59
- const descriptionMatch = yaml.match(/^description:\s*(.+?)\s*$/m);
60
- const name = idMatch?.[1]?.trim() ?? entry.name;
61
- const version = versionMatch?.[1]?.trim() ?? '0.0.0';
62
- const description = descriptionMatch?.[1]?.trim().replace(/^['"]|['"]$/g, '');
63
- entries.set(name, {
64
- name,
65
- version,
66
- sourceDir: join(bootstrapDir, entry.name),
67
- description,
68
- });
69
- } catch {
70
- // skip malformed manifests — matches e2e server behavior
71
- }
72
- }
73
- return entries;
74
- }
75
-
76
- /**
77
- * Scan a directory for pre-built .netapp files. Filename (minus suffix) is
78
- * the module name. Version defaults to the filename's version suffix or to
79
- * 0.0.0+1 — real version data is in the archive's manifest but parsing that
80
- * at startup is overkill for the e2e bootstrap use case.
81
- *
82
- * Used by the shared-infra stand-in: the e2e harness's publishModule helper
83
- * copies .netapps into BOOTSTRAP_UPLOADS_DIR with `docker cp`, and the
84
- * server picks them up without needing the full HTTP publish protocol.
85
- */
86
- export function scanUploadsDir(uploadsDir: string): Map<string, BootstrapEntry> {
87
- const entries = new Map<string, BootstrapEntry>();
88
- if (!existsSync(uploadsDir)) return entries;
89
-
90
- for (const file of readdirSync(uploadsDir)) {
91
- if (!file.endsWith('.netapp')) continue;
92
- const name = file.slice(0, -7);
93
- entries.set(name, {
94
- name,
95
- version: '0.0.0',
96
- netappPath: join(uploadsDir, file),
97
- });
98
- }
99
- return entries;
100
- }
101
-
102
- /** Render a bootstrap entry as an IndexEntry suitable for the sparse index. */
103
- export function bootstrapIndexEntry(entry: BootstrapEntry): IndexEntry {
104
- const vers = entry.version.includes('+') ? entry.version : `${entry.version}+1`;
105
- return {
106
- name: entry.name,
107
- vers,
108
- deps: [],
109
- cksum: 'bootstrap',
110
- yanked: false,
111
- };
112
- }
113
-
114
- /**
115
- * Walk a directory, skipping hidden entries, *.test.ts files, the
116
- * module's e2e/ tree (tests + their deps don't ship with the module),
117
- * and most of node_modules.
118
- *
119
- * `scripts/node_modules` (the module's hook runtime) is bundled IN FULL
120
- * (ISS-0046): hook scripts run on the target, where the package registry can
121
- * be unreachable, so the `.netapp` must carry @celilo/capabilities AND its
122
- * third-party deps (tldts, drizzle-orm, …). Any OTHER node_modules ships only
123
- * `@celilo/capabilities` — the framework SDK the module was authored against.
124
- *
125
- * This is the structural twin of the canonical `includeNodeModulesPath` rule in
126
- * apps/celilo/src/module/packaging/package-rules.ts. We can't import that at
127
- * runtime (this server ships in a standalone Docker image with no @celilo deps),
128
- * so the two are held in lockstep by bootstrap-packaging.test.ts, which asserts
129
- * `walkDir`'s output conforms to the canonical rule. `walkDir` is exported for
130
- * that test.
131
- */
132
- export function walkDir(dir: string, base = dir): string[] {
133
- const results: string[] = [];
134
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
135
- if (entry.name.startsWith('.')) continue;
136
- if (entry.name.endsWith('.test.ts')) continue;
137
-
138
- const fullPath = join(dir, entry.name);
139
-
140
- // Module's e2e/ directory at the root is tests + their devDependencies.
141
- // Detected by being a sibling of manifest.yml.
142
- if (entry.name === 'e2e' && existsSync(join(dir, 'manifest.yml'))) {
143
- continue;
144
- }
145
-
146
- if (entry.name === 'node_modules') {
147
- if (basename(dir) === 'scripts') {
148
- // The hook-script runtime closure — bundle it whole (incl. nested deps).
149
- // walkAll keeps the hidden-entry / *.test.ts skips, so .bin shims drop.
150
- results.push(...walkAll(fullPath, base));
151
- } else {
152
- // Other node_modules: collect only @celilo/capabilities.
153
- const capDir = join(fullPath, '@celilo', 'capabilities');
154
- if (existsSync(capDir)) {
155
- results.push(...walkDir(capDir, base));
156
- }
157
- }
158
- continue;
159
- }
160
-
161
- if (entry.isDirectory()) {
162
- results.push(...walkDir(fullPath, base));
163
- } else {
164
- results.push(relative(base, fullPath));
165
- }
166
- }
167
- return results;
168
- }
169
-
170
- /**
171
- * Collect every file under `dir` (recursing into nested node_modules too),
172
- * skipping only hidden entries (`.bin` symlink shims) and `*.test.ts`. Used to
173
- * bundle a module's full `scripts/node_modules` runtime closure.
174
- */
175
- function walkAll(dir: string, base: string): string[] {
176
- const results: string[] = [];
177
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
178
- if (entry.name.startsWith('.')) continue;
179
- if (entry.name.endsWith('.test.ts')) continue;
180
- const fullPath = join(dir, entry.name);
181
- if (entry.isDirectory()) {
182
- results.push(...walkAll(fullPath, base));
183
- } else {
184
- results.push(relative(base, fullPath));
185
- }
186
- }
187
- return results;
188
- }
189
-
190
- /**
191
- * Package a module directory into a .netapp tarball. Cached by module name
192
- * in cacheDir; cache is keyed by name only (assume the source is immutable
193
- * for the lifetime of the server — fine for e2e).
194
- */
195
- export async function packageBootstrapModule(
196
- entry: BootstrapEntry,
197
- cacheDir: string,
198
- ): Promise<string> {
199
- // Upload-mode entries are already .netapp files — serve as-is.
200
- if (entry.netappPath) return entry.netappPath;
201
- const sourceDir = entry.sourceDir;
202
- if (!sourceDir) {
203
- throw new Error(`bootstrap entry ${entry.name} has neither sourceDir nor netappPath`);
204
- }
205
- mkdirSync(cacheDir, { recursive: true });
206
- const cachePath = join(cacheDir, `${entry.name}.netapp`);
207
- if (existsSync(cachePath)) return cachePath;
208
-
209
- const filePaths = walkDir(sourceDir);
210
- const fileHashes: Record<string, string> = {};
211
- for (const relPath of filePaths) {
212
- const data = readFileSync(join(sourceDir, relPath));
213
- fileHashes[relPath] = createHash('sha256').update(data).digest('hex');
214
- }
215
-
216
- const checksumsJson = JSON.stringify(
217
- { version: '1', generated: new Date().toISOString(), files: fileHashes },
218
- null,
219
- 2,
220
- );
221
-
222
- // Source dir may be read-only (volume-mounted) — stage in /tmp first.
223
- const stagingDir = join('/tmp', `celilo-bootstrap-${entry.name}-${Date.now()}`);
224
- mkdirSync(stagingDir, { recursive: true });
225
- try {
226
- for (const relPath of filePaths) {
227
- mkdirSync(dirname(join(stagingDir, relPath)), { recursive: true });
228
- writeFileSync(join(stagingDir, relPath), readFileSync(join(sourceDir, relPath)));
229
- }
230
- writeFileSync(join(stagingDir, 'checksums.json'), checksumsJson);
231
-
232
- const proc = Bun.spawn(['tar', '-czf', cachePath, '-C', stagingDir, '.'], {
233
- stderr: 'inherit',
234
- });
235
- const exitCode = await proc.exited;
236
- if (exitCode !== 0) throw new Error(`tar exited with code ${exitCode}`);
237
- } finally {
238
- try {
239
- Bun.spawnSync(['rm', '-rf', stagingDir]);
240
- } catch {
241
- // best-effort cleanup
242
- }
243
- }
244
-
245
- return cachePath;
246
- }
@@ -1,16 +0,0 @@
1
- export { RegistryStorage } from './storage';
2
- export type { IndexEntry, ModuleVersion } from './storage';
3
- export { TokenAuth } from './auth';
4
- export { startServer, startFromEnv } from './server';
5
- export {
6
- scanBootstrapDir,
7
- bootstrapIndexEntry,
8
- packageBootstrapModule,
9
- } from './bootstrap';
10
- export type { BootstrapEntry } from './bootstrap';
11
-
12
- import { startFromEnv } from './server';
13
-
14
- if (import.meta.main) {
15
- startFromEnv();
16
- }
@@ -1,90 +0,0 @@
1
- /**
2
- * Landing-page response for `GET /` (and `/index.html`) on the registry.
3
- *
4
- * This is the polite-301 from the design doc:
5
- * (apps/celilo/designs/REGISTRY_BROWSE_UI.md, decision D2). The response
6
- * carries `301 Moved Permanently` with `Location:` pointing at the
7
- * celilo.computer site's `/modules/` browse UI — interactive browsers
8
- * follow the redirect. Clients that don't follow by default (curl
9
- * without `-L`, naive bots, bare-bones HTTP libraries) see the body,
10
- * which carries the install one-liner, a link to the browse UI, and
11
- * the API endpoints they probably actually wanted.
12
- *
13
- * Single endpoint, no Astro dep, no client JS, no asset pipeline.
14
- */
15
-
16
- /**
17
- * HTML-escape `<`, `>`, `&`, `"` so values interpolated into the body
18
- * can't inject markup. Operator-controlled values (publicUrl) are
19
- * trusted in practice, but defensive escaping costs nothing.
20
- */
21
- function escapeHtml(s: string): string {
22
- return s
23
- .replace(/&/g, '&amp;')
24
- .replace(/</g, '&lt;')
25
- .replace(/>/g, '&gt;')
26
- .replace(/"/g, '&quot;');
27
- }
28
-
29
- /**
30
- * Build the polite-301 response.
31
- *
32
- * @param publicUrl The registry's public URL (typically
33
- * `https://celilo.computer/registry`). The site's
34
- * origin is derived by stripping the path. The
35
- * `/modules/` browse URL on that origin is the
36
- * redirect target.
37
- */
38
- export function landingResponse(publicUrl: string): Response {
39
- const u = new URL(publicUrl);
40
- const siteOrigin = u.origin;
41
- const browseUrl = `${siteOrigin}/modules/`;
42
- const installUrl = `${siteOrigin}/install.sh`;
43
- const apiBase = publicUrl.replace(/\/$/, '');
44
-
45
- const body = `<!DOCTYPE html>
46
- <html lang="en">
47
- <head>
48
- <meta charset="UTF-8">
49
- <meta name="viewport" content="width=device-width, initial-scale=1">
50
- <title>Celilo Module Registry</title>
51
- <style>
52
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
53
- max-width: 640px; margin: 4em auto; padding: 0 1em;
54
- line-height: 1.6; color: #222; }
55
- h1 { margin-bottom: 0.2em; }
56
- .lead { color: #666; margin-top: 0; }
57
- pre { background: #f5f5f5; padding: 0.8em 1em; border-radius: 6px;
58
- overflow-x: auto; font-size: 0.9em; }
59
- a { color: #0d72b9; }
60
- small { color: #888; }
61
- </style>
62
- </head>
63
- <body>
64
- <h1>Celilo Module Registry</h1>
65
- <p class="lead">Home-lab orchestration modules — published, versioned, install via the Celilo CLI.</p>
66
-
67
- <p>If you're a person, head to <a href="${escapeHtml(browseUrl)}">browse the modules →</a></p>
68
-
69
- <p>Install the Celilo CLI:</p>
70
- <pre><code>curl -fsSL ${escapeHtml(installUrl)} | bash</code></pre>
71
-
72
- <p>If you're a tool, the JSON API is here:</p>
73
- <pre><code>GET ${escapeHtml(apiBase)}/api/v1/modules
74
- GET ${escapeHtml(apiBase)}/api/v1/modules/{name}
75
- GET ${escapeHtml(apiBase)}/api/v1/modules/{name}/{version}/download</code></pre>
76
-
77
- <p><small>This URL is a 301 to <a href="${escapeHtml(browseUrl)}">${escapeHtml(browseUrl)}</a>. Most browsers followed it before you saw this.</small></p>
78
- </body>
79
- </html>
80
- `;
81
-
82
- return new Response(body, {
83
- status: 301,
84
- headers: {
85
- Location: browseUrl,
86
- 'Content-Type': 'text/html; charset=utf-8',
87
- 'Cache-Control': 'public, max-age=300',
88
- },
89
- });
90
- }
@@ -1,62 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { clientIp, createRateLimiter } from './rate-limit';
3
-
4
- describe('createRateLimiter', () => {
5
- test('allows up to max, then rejects', () => {
6
- const limiter = createRateLimiter({ max: 3, windowMs: 60_000, now: () => 0 });
7
- expect(limiter.take('1.1.1.1').ok).toBe(true);
8
- expect(limiter.take('1.1.1.1').ok).toBe(true);
9
- expect(limiter.take('1.1.1.1').ok).toBe(true);
10
- const over = limiter.take('1.1.1.1');
11
- expect(over.ok).toBe(false);
12
- if (!over.ok) expect(over.retryAfterSec).toBeGreaterThan(0);
13
- });
14
-
15
- test('distinct IPs are tracked separately', () => {
16
- const limiter = createRateLimiter({ max: 1, windowMs: 60_000, now: () => 0 });
17
- expect(limiter.take('1.1.1.1').ok).toBe(true);
18
- expect(limiter.take('2.2.2.2').ok).toBe(true); // different IP, fresh allowance
19
- expect(limiter.take('1.1.1.1').ok).toBe(false);
20
- });
21
-
22
- test('bucket resets after window elapses', () => {
23
- let t = 0;
24
- const limiter = createRateLimiter({ max: 1, windowMs: 1000, now: () => t });
25
- expect(limiter.take('1.1.1.1').ok).toBe(true);
26
- expect(limiter.take('1.1.1.1').ok).toBe(false);
27
- t = 1001;
28
- expect(limiter.take('1.1.1.1').ok).toBe(true);
29
- });
30
-
31
- test('retryAfterSec reflects remaining window', () => {
32
- const limiter = createRateLimiter({ max: 1, windowMs: 60_000, now: () => 0 });
33
- limiter.take('1.1.1.1');
34
- const r = limiter.take('1.1.1.1');
35
- expect(r.ok).toBe(false);
36
- if (!r.ok) {
37
- expect(r.retryAfterSec).toBeLessThanOrEqual(60);
38
- expect(r.retryAfterSec).toBeGreaterThan(0);
39
- }
40
- });
41
- });
42
-
43
- describe('clientIp', () => {
44
- function req(headers: Record<string, string>): Request {
45
- return new Request('http://x/', { headers });
46
- }
47
- const stubServer = {
48
- requestIP: () => ({ address: '10.0.0.1' }),
49
- };
50
-
51
- test('uses X-Forwarded-For first entry when present', () => {
52
- expect(clientIp(req({ 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }), stubServer)).toBe('1.1.1.1');
53
- });
54
-
55
- test('falls back to Bun.Server.requestIP when XFF absent', () => {
56
- expect(clientIp(req({}), stubServer)).toBe('10.0.0.1');
57
- });
58
-
59
- test('returns "unknown" when neither available', () => {
60
- expect(clientIp(req({}), { requestIP: () => null })).toBe('unknown');
61
- });
62
- });
@@ -1,83 +0,0 @@
1
- /**
2
- * Simple in-memory per-IP rate limiter for the auth-gated endpoints
3
- * (publish, yank, unyank). The public-read endpoints (download, index,
4
- * search) are left unlimited — they're cacheable and the body-size cap
5
- * on publish already covers the main DoS vector.
6
- *
7
- * We implement this in-process rather than in Caddy because the stock
8
- * apt-installed Caddy doesn't include the community `caddy-ratelimit`
9
- * plugin. If we ever switch the caddy module to `xcaddy build` with the
10
- * plugin, we can move this upstream and delete this file.
11
- *
12
- * The limiter is a fixed window counter: each IP gets a fresh allowance
13
- * every `windowMs`. No burst smoothing, which is fine for the tiny
14
- * traffic a registry actually sees — we just want to keep a bad actor
15
- * from flooding the disk via repeated publish attempts.
16
- */
17
-
18
- interface Bucket {
19
- count: number;
20
- /** Epoch ms when this bucket resets to 0 and `windowMs` restarts. */
21
- resetAt: number;
22
- }
23
-
24
- export interface RateLimiter {
25
- /**
26
- * Record one hit from `ip`. Returns {ok: true, remaining} if under the
27
- * limit; {ok: false, retryAfterSec} if the window is exhausted.
28
- */
29
- take(ip: string): { ok: true; remaining: number } | { ok: false; retryAfterSec: number };
30
- }
31
-
32
- export function createRateLimiter(opts: {
33
- /** Max events per IP per window. */
34
- max: number;
35
- /** Window length in ms. */
36
- windowMs: number;
37
- /** Inject a clock for tests; defaults to Date.now. */
38
- now?: () => number;
39
- }): RateLimiter {
40
- const buckets = new Map<string, Bucket>();
41
- const now = opts.now ?? (() => Date.now());
42
-
43
- // Opportunistic cleanup: if the map grows past a size threshold, drop
44
- // expired entries. Keeps memory bounded even under churny attackers
45
- // hitting from rotating IPs.
46
- const CLEANUP_AT = 1000;
47
-
48
- return {
49
- take(ip: string) {
50
- const t = now();
51
- let bucket = buckets.get(ip);
52
- if (!bucket || t >= bucket.resetAt) {
53
- bucket = { count: 0, resetAt: t + opts.windowMs };
54
- buckets.set(ip, bucket);
55
- }
56
- bucket.count += 1;
57
- if (bucket.count > opts.max) {
58
- return { ok: false, retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - t) / 1000)) };
59
- }
60
- if (buckets.size > CLEANUP_AT) {
61
- for (const [k, v] of buckets) if (t >= v.resetAt) buckets.delete(k);
62
- }
63
- return { ok: true, remaining: opts.max - bucket.count };
64
- },
65
- };
66
- }
67
-
68
- /**
69
- * Extract the caller's IP for rate-limit keying. Prefers the first entry
70
- * of X-Forwarded-For (trusted-proxy assumption — we sit behind Caddy);
71
- * falls back to the raw socket peer via Bun.Server.requestIP.
72
- */
73
- export function clientIp(
74
- req: Request,
75
- server: { requestIP(r: Request): { address: string } | null },
76
- ): string {
77
- const xff = req.headers.get('x-forwarded-for');
78
- if (xff) {
79
- const first = xff.split(',')[0]?.trim();
80
- if (first) return first;
81
- }
82
- return server.requestIP(req)?.address ?? 'unknown';
83
- }
@@ -1,93 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { hashToken } from './auth';
3
- import {
4
- type ScopedTokenEntry,
5
- type ScopedTokenPersistence,
6
- ScopedTokenStore,
7
- } from './scoped-token-store';
8
-
9
- /** In-memory persistence so the store is testable without the filesystem. */
10
- function memoryPersistence(seed: ScopedTokenEntry[] = []): ScopedTokenPersistence & {
11
- saved: ScopedTokenEntry[];
12
- } {
13
- const state = { saved: [...seed] };
14
- return {
15
- saved: state.saved,
16
- load() {
17
- return [...state.saved];
18
- },
19
- save(entries) {
20
- state.saved.length = 0;
21
- state.saved.push(...entries);
22
- },
23
- };
24
- }
25
-
26
- /** Deterministic token generator (avoids randomness in assertions). */
27
- function seqGen() {
28
- let n = 0;
29
- return () => `tok-${++n}`;
30
- }
31
-
32
- describe('ScopedTokenStore.mint (ISS-0140)', () => {
33
- test('mints a scoped token and persists only the hash', () => {
34
- const p = memoryPersistence();
35
- const store = new ScopedTokenStore(p, seqGen(), () => '2026-06-20T00:00:00Z');
36
- const { token, entry, revokedHashes } = store.mint('celilo/lunacycle', 'lunacycle');
37
-
38
- expect(token).toBe('tok-1');
39
- expect(entry.scope).toBe('lunacycle');
40
- expect(entry.repo).toBe('celilo/lunacycle');
41
- expect(entry.hash).toBe(hashToken('tok-1'));
42
- expect(revokedHashes).toEqual([]);
43
- // Persisted form holds the hash, never the cleartext token.
44
- expect(JSON.stringify(p.saved)).not.toContain('tok-1');
45
- expect(p.saved).toHaveLength(1);
46
- });
47
-
48
- test('re-minting for the same repo rotates the token (reconcile)', () => {
49
- const p = memoryPersistence();
50
- const store = new ScopedTokenStore(p, seqGen(), () => 't');
51
- const first = store.mint('celilo/lunacycle', 'lunacycle');
52
- const second = store.mint('celilo/lunacycle', 'lunacycle');
53
-
54
- expect(second.token).toBe('tok-2');
55
- expect(second.revokedHashes).toEqual([first.entry.hash]);
56
- // Still exactly one active token for the repo.
57
- expect(store.list().filter((e) => e.repo === 'celilo/lunacycle')).toHaveLength(1);
58
- expect(store.list()).toHaveLength(1);
59
- });
60
-
61
- test('different repos coexist independently', () => {
62
- const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
63
- store.mint('celilo/lunacycle', 'lunacycle');
64
- store.mint('celilo/caddy', 'caddy');
65
- expect(store.list()).toHaveLength(2);
66
- });
67
- });
68
-
69
- describe('ScopedTokenStore.revoke', () => {
70
- test('removes all tokens for a repo and returns their hashes', () => {
71
- const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
72
- const { entry } = store.mint('celilo/lunacycle', 'lunacycle');
73
- const removed = store.revoke('celilo/lunacycle');
74
- expect(removed).toEqual([entry.hash]);
75
- expect(store.list()).toHaveLength(0);
76
- });
77
-
78
- test('revoking an unknown repo is a no-op', () => {
79
- const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
80
- expect(store.revoke('celilo/nope')).toEqual([]);
81
- });
82
- });
83
-
84
- describe('ScopedTokenStore startup load', () => {
85
- test('loads persisted entries on construction', () => {
86
- const seed: ScopedTokenEntry[] = [
87
- { hash: hashToken('x'), scope: 'caddy', repo: 'celilo/caddy', mintedAt: 't' },
88
- ];
89
- const store = new ScopedTokenStore(memoryPersistence(seed), seqGen(), () => 't');
90
- expect(store.list()).toHaveLength(1);
91
- expect(store.list()[0]?.repo).toBe('celilo/caddy');
92
- });
93
- });