@celilo/e2e 0.9.2 → 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.
- package/config/proxy/squid.conf +7 -2
- package/config/resolver/unbound.conf +13 -3
- package/docker/Dockerfile.minio +1 -1
- package/docker/Dockerfile.target-machine +1 -1
- package/docker/Dockerfile.target-machine-docker +1 -1
- package/docker/Dockerfile.target-technitium +1 -1
- package/package.json +3 -3
- package/scripts/pack-celilo-packages.ts +79 -51
- package/src/cli/build.ts +28 -9
- package/src/cli/command-registry.ts +34 -6
- package/src/cli/index.ts +53 -21
- package/src/container-manager.ts +1 -1
- package/src/runner.ts +128 -57
- package/config/dns/example.net.zone +0 -16
- package/config/dns/iamtheinternet.org.zone +0 -16
- package/registry-server/package.json +0 -17
- package/registry-server/src/auth.test.ts +0 -76
- package/registry-server/src/auth.ts +0 -105
- package/registry-server/src/bootstrap-packaging.test.ts +0 -71
- package/registry-server/src/bootstrap.ts +0 -246
- package/registry-server/src/index.ts +0 -16
- package/registry-server/src/landing.ts +0 -90
- package/registry-server/src/rate-limit.test.ts +0 -62
- package/registry-server/src/rate-limit.ts +0 -83
- package/registry-server/src/scoped-token-store.test.ts +0 -93
- package/registry-server/src/scoped-token-store.ts +0 -110
- package/registry-server/src/server.test.ts +0 -509
- package/registry-server/src/server.ts +0 -572
- package/registry-server/src/storage.test.ts +0 -148
- package/registry-server/src/storage.ts +0 -150
- package/registry-server/src/validation.test.ts +0 -86
- package/registry-server/src/validation.ts +0 -60
- package/registry-server/tsconfig.json +0 -15
|
@@ -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, '&')
|
|
24
|
-
.replace(/</g, '<')
|
|
25
|
-
.replace(/>/g, '>')
|
|
26
|
-
.replace(/"/g, '"');
|
|
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
|
-
});
|