@celilo/e2e 0.7.15 → 0.7.16
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/startup.sh +14 -0
- package/config/routing/fw-ext-routes.sh +13 -0
- package/package.json +1 -1
- package/registry-server/src/auth.test.ts +56 -30
- package/registry-server/src/auth.ts +88 -19
- package/registry-server/src/scoped-token-store.test.ts +93 -0
- package/registry-server/src/scoped-token-store.ts +110 -0
- package/registry-server/src/server.test.ts +92 -10
- package/registry-server/src/server.ts +104 -31
- package/scripts/pack-celilo-packages.ts +114 -22
- package/src/shared-infra.ts +18 -1
package/config/proxy/startup.sh
CHANGED
|
@@ -7,5 +7,19 @@ ip route add default via 172.30.0.1
|
|
|
7
7
|
|
|
8
8
|
rm -f /run/squid.pid
|
|
9
9
|
|
|
10
|
+
# Initialize the cache swap directories before starting Squid. The image's
|
|
11
|
+
# build-time `squid -z` can leave an INCOMPLETE L1/L2 set (observed: 00–0C
|
|
12
|
+
# present, 0D–0F missing); on a fresh container Squid then FATALs with "Failed
|
|
13
|
+
# to verify one of the swap directories" and exits. With Squid dead, the
|
|
14
|
+
# transparent-proxy REDIRECT (:80→3128, :443→3129) forwards every apt fetch to
|
|
15
|
+
# a closed port → "connection refused" — which surfaced as flaky, non-warming
|
|
16
|
+
# `apt install` failures during module deploys (e.g. knot). `squid -z` is
|
|
17
|
+
# idempotent (creates only missing dirs). `--foreground` is REQUIRED: a bare
|
|
18
|
+
# `squid -z` daemonizes and lingers (holding /run/squid.pid), so the following
|
|
19
|
+
# `squid -N` would FATAL "Squid is already running"; with --foreground, -z runs
|
|
20
|
+
# synchronously, removes the pid file, and exits clean.
|
|
21
|
+
squid --foreground -z -f /etc/squid/squid.conf
|
|
22
|
+
rm -f /run/squid.pid
|
|
23
|
+
|
|
10
24
|
echo "forward-proxy ready on ports 3128 (HTTP) / 3129 (HTTPS)"
|
|
11
25
|
exec squid -N -f /etc/squid/squid.conf
|
|
@@ -19,6 +19,19 @@ iptables -P FORWARD ACCEPT
|
|
|
19
19
|
|
|
20
20
|
# Start Squid transparent proxy (runs on fw-ext itself)
|
|
21
21
|
rm -f /run/squid.pid
|
|
22
|
+
# Complete the cache swap directories before starting. The image's build-time
|
|
23
|
+
# `squid -z` (Dockerfile.router) is killed after a fixed `sleep 1`, which can
|
|
24
|
+
# interrupt it and leave an INCOMPLETE L1/L2 set (observed: 00–0C present,
|
|
25
|
+
# 0D–0F missing). `squid -N` then FATALs ("Failed to verify one of the swap
|
|
26
|
+
# directories") and never binds — so the transparent-proxy REDIRECT below sends
|
|
27
|
+
# every apt fetch to a closed port and target deploys fail with intermittent,
|
|
28
|
+
# non-warming "connection refused" (e.g. knot's `apt install`). `squid -z` is
|
|
29
|
+
# idempotent, so running it here guarantees a complete tree. `--foreground` is
|
|
30
|
+
# REQUIRED: a bare `squid -z` daemonizes and lingers (holding /run/squid.pid),
|
|
31
|
+
# so the following `squid -N` would FATAL "Squid is already running"; with
|
|
32
|
+
# --foreground, -z runs synchronously, removes the pid file, and exits clean.
|
|
33
|
+
squid --foreground -z -f /etc/squid/squid.conf
|
|
34
|
+
rm -f /run/squid.pid
|
|
22
35
|
squid -N -f /etc/squid/squid.conf &
|
|
23
36
|
|
|
24
37
|
# Wait for Squid to be listening on both 3128 (HTTP) and 3129 (HTTPS)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -1,50 +1,76 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { TokenAuth } from './auth';
|
|
2
|
+
import { ADMIN_SCOPE, TokenAuth, hashToken } from './auth';
|
|
3
3
|
|
|
4
|
-
describe('TokenAuth', () => {
|
|
5
|
-
test('empty
|
|
4
|
+
describe('TokenAuth (package-scoped — ISS-0140)', () => {
|
|
5
|
+
test('empty specs → no tokens; nothing authorizes', () => {
|
|
6
6
|
const auth = new TokenAuth([]);
|
|
7
7
|
expect(auth.hasTokens()).toBe(false);
|
|
8
|
-
expect(auth.
|
|
8
|
+
expect(auth.authorize('anything', 'caddy')).toBe(false);
|
|
9
|
+
expect(auth.isAdmin('anything')).toBe(false);
|
|
9
10
|
});
|
|
10
11
|
|
|
11
|
-
test('
|
|
12
|
-
const auth = new TokenAuth(['
|
|
13
|
-
expect(auth.
|
|
14
|
-
expect(auth.
|
|
12
|
+
test('admin-scoped token publishes ANY package', () => {
|
|
13
|
+
const auth = new TokenAuth([{ token: 'admin-tok', scope: ADMIN_SCOPE }]);
|
|
14
|
+
expect(auth.authorize('admin-tok', 'caddy')).toBe(true);
|
|
15
|
+
expect(auth.authorize('admin-tok', 'lunacycle')).toBe(true);
|
|
16
|
+
expect(auth.isAdmin('admin-tok')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('package-scoped token publishes ONLY its package', () => {
|
|
20
|
+
const auth = new TokenAuth([{ token: 'luna-tok', scope: 'lunacycle' }]);
|
|
21
|
+
expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
|
|
22
|
+
expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
|
|
23
|
+
// A scoped token is NOT an admin token — it cannot mint.
|
|
24
|
+
expect(auth.isAdmin('luna-tok')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('unknown token never authorizes', () => {
|
|
28
|
+
const auth = new TokenAuth([{ token: 'known', scope: 'lunacycle' }]);
|
|
29
|
+
expect(auth.authorize('unknown', 'lunacycle')).toBe(false);
|
|
15
30
|
});
|
|
16
31
|
|
|
17
32
|
test('tokens are stored hashed, not in cleartext', () => {
|
|
18
|
-
const auth = new TokenAuth(['secret-token']);
|
|
19
|
-
|
|
20
|
-
// 'secret-token' would appear in the Set. Only hashes should be present.
|
|
21
|
-
const rawDump = JSON.stringify(auth);
|
|
22
|
-
expect(rawDump).not.toContain('secret-token');
|
|
33
|
+
const auth = new TokenAuth([{ token: 'secret-token', scope: ADMIN_SCOPE }]);
|
|
34
|
+
expect(JSON.stringify(auth)).not.toContain('secret-token');
|
|
23
35
|
});
|
|
24
36
|
|
|
25
|
-
test('
|
|
26
|
-
const auth = new TokenAuth(['clean']);
|
|
27
|
-
expect(auth.
|
|
37
|
+
test('tolerates a Bearer prefix and surrounding whitespace', () => {
|
|
38
|
+
const auth = new TokenAuth([{ token: 'clean', scope: ADMIN_SCOPE }]);
|
|
39
|
+
expect(auth.authorize(' clean ', 'caddy')).toBe(true);
|
|
40
|
+
expect(auth.authorize('Bearer clean', 'caddy')).toBe(true);
|
|
28
41
|
});
|
|
29
42
|
|
|
30
|
-
test('
|
|
31
|
-
const auth = new TokenAuth(['secret']);
|
|
32
|
-
expect(auth.
|
|
43
|
+
test('empty header rejected', () => {
|
|
44
|
+
const auth = new TokenAuth([{ token: 'secret', scope: ADMIN_SCOPE }]);
|
|
45
|
+
expect(auth.authorize('', 'caddy')).toBe(false);
|
|
46
|
+
expect(auth.scopeOf('')).toBe(null);
|
|
33
47
|
});
|
|
34
48
|
|
|
35
|
-
test('
|
|
36
|
-
const auth = new TokenAuth([
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
expect(auth.
|
|
40
|
-
|
|
49
|
+
test('addHashed / removeHashed manage minted tokens at runtime', () => {
|
|
50
|
+
const auth = new TokenAuth([]);
|
|
51
|
+
const hash = hashToken('minted-tok');
|
|
52
|
+
auth.addHashed(hash, 'lunacycle');
|
|
53
|
+
expect(auth.authorize('minted-tok', 'lunacycle')).toBe(true);
|
|
54
|
+
auth.removeHashed(hash);
|
|
55
|
+
expect(auth.authorize('minted-tok', 'lunacycle')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('TokenAuth.fromEnv (PUBLISH_TOKENS format)', () => {
|
|
60
|
+
test('bare line → admin scope; "token pkg" line → scoped', () => {
|
|
61
|
+
process.env.PUBLISH_TOKENS = 'admin-tok\nluna-tok lunacycle\n';
|
|
62
|
+
const auth = TokenAuth.fromEnv();
|
|
63
|
+
expect(auth.isAdmin('admin-tok')).toBe(true);
|
|
64
|
+
expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
|
|
65
|
+
expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
|
|
66
|
+
process.env.PUBLISH_TOKENS = '';
|
|
41
67
|
});
|
|
42
68
|
|
|
43
|
-
test('
|
|
44
|
-
|
|
69
|
+
test('blank lines ignored', () => {
|
|
70
|
+
process.env.PUBLISH_TOKENS = '\n \nt1\n';
|
|
71
|
+
const auth = TokenAuth.fromEnv();
|
|
45
72
|
expect(auth.hasTokens()).toBe(true);
|
|
46
|
-
expect(auth.
|
|
47
|
-
|
|
48
|
-
expect(auth.verify('')).toBe(false);
|
|
73
|
+
expect(auth.isAdmin('t1')).toBe(true);
|
|
74
|
+
process.env.PUBLISH_TOKENS = '';
|
|
49
75
|
});
|
|
50
76
|
});
|
|
@@ -1,36 +1,105 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* a
|
|
4
|
+
* Package-scoped token auth (build-bus Phase 3, ISS-0140).
|
|
5
|
+
*
|
|
6
|
+
* Each token carries a SCOPE — either a single package name (the token may
|
|
7
|
+
* publish only that module) or the admin scope `*` (publish anything + mint
|
|
8
|
+
* scoped tokens). Tokens are stored SHA-256 hashed; cleartext never lives in
|
|
9
|
+
* the auth set once the server starts.
|
|
10
|
+
*
|
|
11
|
+
* PUBLISH_TOKENS env (newline-separated), per line:
|
|
12
|
+
* <token> → admin scope `*` (publish any package, mint tokens)
|
|
13
|
+
* <token> <package> → scoped to that one package
|
|
14
|
+
*
|
|
15
|
+
* A bare `<token>` line is the admin/bootstrap token — this preserves the
|
|
16
|
+
* pre-scoping behavior (any configured token publishes anything) AND is the
|
|
17
|
+
* privilege that the `registry_publish` capability uses to mint per-repo
|
|
18
|
+
* scoped tokens. Minted scoped tokens are added at runtime via {@link addHashed}
|
|
19
|
+
* (loaded from the persisted store) and removed via {@link removeHashed}.
|
|
7
20
|
*/
|
|
21
|
+
|
|
22
|
+
/** Admin scope: publish any package + mint scoped tokens. */
|
|
23
|
+
export const ADMIN_SCOPE = '*';
|
|
24
|
+
|
|
25
|
+
export interface TokenSpec {
|
|
26
|
+
token: string;
|
|
27
|
+
/** Package name, or {@link ADMIN_SCOPE}. */
|
|
28
|
+
scope: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** SHA-256 of a token (trimmed). The persisted scoped-token store hashes too. */
|
|
32
|
+
export function hashToken(token: string): string {
|
|
33
|
+
return createHash('sha256').update(token.trim()).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Strip an optional `Bearer ` prefix and surrounding whitespace from a header. */
|
|
37
|
+
function bareToken(header: string): string {
|
|
38
|
+
return header.replace(/^Bearer\s+/i, '').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
export class TokenAuth {
|
|
9
|
-
|
|
42
|
+
/** hash → scope. */
|
|
43
|
+
private readonly scopeByHash = new Map<string, string>();
|
|
10
44
|
|
|
11
|
-
constructor(
|
|
12
|
-
|
|
13
|
-
rawTokens
|
|
14
|
-
.map((t) => t.trim())
|
|
15
|
-
.filter(Boolean)
|
|
16
|
-
.map((t) => createHash('sha256').update(t).digest('hex')),
|
|
17
|
-
);
|
|
45
|
+
constructor(specs: TokenSpec[]) {
|
|
46
|
+
for (const s of specs) this.addRaw(s.token, s.scope);
|
|
18
47
|
}
|
|
19
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Parse the `PUBLISH_TOKENS` env format into the scoped auth set. Each line is
|
|
51
|
+
* `<token>` (admin scope) or `<token> <package>` (scoped to one package).
|
|
52
|
+
*/
|
|
20
53
|
static fromEnv(): TokenAuth {
|
|
21
54
|
const raw = process.env.PUBLISH_TOKENS ?? '';
|
|
22
|
-
const
|
|
23
|
-
|
|
55
|
+
const specs: TokenSpec[] = [];
|
|
56
|
+
for (const line of raw.split('\n')) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed) continue;
|
|
59
|
+
const [token, scope] = trimmed.split(/\s+/, 2);
|
|
60
|
+
specs.push({ token, scope: scope || ADMIN_SCOPE });
|
|
61
|
+
}
|
|
62
|
+
return new TokenAuth(specs);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Add a cleartext token (hashes it). Empty tokens are ignored. */
|
|
66
|
+
addRaw(rawToken: string, scope: string): void {
|
|
67
|
+
const t = rawToken.trim();
|
|
68
|
+
if (!t) return;
|
|
69
|
+
this.scopeByHash.set(hashToken(t), scope.trim() || ADMIN_SCOPE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Add an already-hashed token (used when loading the persisted minted-token store). */
|
|
73
|
+
addHashed(hash: string, scope: string): void {
|
|
74
|
+
this.scopeByHash.set(hash, scope);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Remove a token by its hash (token revocation). */
|
|
78
|
+
removeHashed(hash: string): void {
|
|
79
|
+
this.scopeByHash.delete(hash);
|
|
24
80
|
}
|
|
25
81
|
|
|
26
|
-
/** If no tokens are configured, the server treats every
|
|
82
|
+
/** If no tokens are configured, the server treats every write as unauthorized. */
|
|
27
83
|
hasTokens(): boolean {
|
|
28
|
-
return this.
|
|
84
|
+
return this.scopeByHash.size > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The scope of a token (from an Authorization header value), or null if unknown. */
|
|
88
|
+
scopeOf(header: string): string | null {
|
|
89
|
+
const t = bareToken(header);
|
|
90
|
+
if (!t) return null;
|
|
91
|
+
return this.scopeByHash.get(hashToken(t)) ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when the token may publish/yank the given package (admin or exact scope). */
|
|
95
|
+
authorize(header: string, pkg: string): boolean {
|
|
96
|
+
const scope = this.scopeOf(header);
|
|
97
|
+
if (scope === null) return false;
|
|
98
|
+
return scope === ADMIN_SCOPE || scope === pkg;
|
|
29
99
|
}
|
|
30
100
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return this.hashedTokens.has(hashed);
|
|
101
|
+
/** True when the token is an admin token (scope `*`) — required to mint scoped tokens. */
|
|
102
|
+
isAdmin(header: string): boolean {
|
|
103
|
+
return this.scopeOf(header) === ADMIN_SCOPE;
|
|
35
104
|
}
|
|
36
105
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { hashToken } from './auth';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Persisted store of minted, package-scoped publish tokens (build-bus Phase 3,
|
|
8
|
+
* ISS-0140). The `registry_publish` capability mints one token per registered
|
|
9
|
+
* repo, scoped to the single package that repo publishes — so a compromised
|
|
10
|
+
* runner can ship only its own module, never another.
|
|
11
|
+
*
|
|
12
|
+
* Only the token HASH is persisted (never cleartext) — the raw token is returned
|
|
13
|
+
* exactly once at mint time, for the caller to set as the repo's Forgejo Actions
|
|
14
|
+
* secret. At startup the server loads this store into its {@link TokenAuth} via
|
|
15
|
+
* `addHashed`; mint/revoke update both the in-memory auth and this file, so a
|
|
16
|
+
* minted token works without a server restart.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ScopedTokenEntry {
|
|
20
|
+
/** SHA-256 of the raw token. */
|
|
21
|
+
hash: string;
|
|
22
|
+
/** Package this token may publish. */
|
|
23
|
+
scope: string;
|
|
24
|
+
/** Repo path on the source forge this token was minted for (the reconcile key). */
|
|
25
|
+
repo: string;
|
|
26
|
+
/** ISO timestamp of mint. */
|
|
27
|
+
mintedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MintResult {
|
|
31
|
+
/** The raw token — returned ONCE; never persisted in cleartext. */
|
|
32
|
+
token: string;
|
|
33
|
+
entry: ScopedTokenEntry;
|
|
34
|
+
/** Hashes of tokens this mint superseded for the same repo (revoke them in auth). */
|
|
35
|
+
revokedHashes: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** I/O seam (Rule 2.3) so the store is unit-testable without the filesystem. */
|
|
39
|
+
export interface ScopedTokenPersistence {
|
|
40
|
+
load(): ScopedTokenEntry[];
|
|
41
|
+
save(entries: ScopedTokenEntry[]): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** File-backed persistence: a JSON array at `filePath`, tolerant of a missing file. */
|
|
45
|
+
export function fileScopedTokenPersistence(filePath: string): ScopedTokenPersistence {
|
|
46
|
+
return {
|
|
47
|
+
load() {
|
|
48
|
+
if (!existsSync(filePath)) return [];
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
51
|
+
return Array.isArray(parsed) ? (parsed as ScopedTokenEntry[]) : [];
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
save(entries) {
|
|
57
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
58
|
+
writeFileSync(filePath, JSON.stringify(entries, null, 2));
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultGenToken(): string {
|
|
64
|
+
return `cpt_${randomBytes(32).toString('base64url')}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class ScopedTokenStore {
|
|
68
|
+
private entries: ScopedTokenEntry[];
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly persistence: ScopedTokenPersistence,
|
|
72
|
+
private readonly genToken: () => string = defaultGenToken,
|
|
73
|
+
private readonly now: () => string = () => new Date().toISOString(),
|
|
74
|
+
) {
|
|
75
|
+
this.entries = persistence.load();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
list(): ScopedTokenEntry[] {
|
|
79
|
+
return [...this.entries];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mint a fresh scoped token for `repo`, reconciling: any existing token for the
|
|
84
|
+
* same repo is superseded (its hash returned in `revokedHashes`). Idempotent at
|
|
85
|
+
* the repo level — re-running rotates the token cleanly.
|
|
86
|
+
*/
|
|
87
|
+
mint(repo: string, scope: string): MintResult {
|
|
88
|
+
const revokedHashes = this.entries.filter((e) => e.repo === repo).map((e) => e.hash);
|
|
89
|
+
const token = this.genToken();
|
|
90
|
+
const entry: ScopedTokenEntry = {
|
|
91
|
+
hash: hashToken(token),
|
|
92
|
+
scope,
|
|
93
|
+
repo,
|
|
94
|
+
mintedAt: this.now(),
|
|
95
|
+
};
|
|
96
|
+
this.entries = [...this.entries.filter((e) => e.repo !== repo), entry];
|
|
97
|
+
this.persistence.save(this.entries);
|
|
98
|
+
return { token, entry, revokedHashes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Revoke all tokens for `repo`. Returns the removed hashes. */
|
|
102
|
+
revoke(repo: string): string[] {
|
|
103
|
+
const removed = this.entries.filter((e) => e.repo === repo).map((e) => e.hash);
|
|
104
|
+
if (removed.length > 0) {
|
|
105
|
+
this.entries = this.entries.filter((e) => e.repo !== repo);
|
|
106
|
+
this.persistence.save(this.entries);
|
|
107
|
+
}
|
|
108
|
+
return removed;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { TokenAuth } from './auth';
|
|
5
|
+
import { ADMIN_SCOPE, TokenAuth } from './auth';
|
|
6
6
|
import { createRateLimiter } from './rate-limit';
|
|
7
7
|
import { startServer } from './server';
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ beforeEach(() => {
|
|
|
17
17
|
port: 0, // let Bun pick an ephemeral port
|
|
18
18
|
pathPrefix: '',
|
|
19
19
|
publicUrl: 'https://example.test',
|
|
20
|
-
auth: new TokenAuth(['valid-token']),
|
|
20
|
+
auth: new TokenAuth([{ token: 'valid-token', scope: ADMIN_SCOPE }]),
|
|
21
21
|
});
|
|
22
22
|
baseUrl = `http://localhost:${server.port}`;
|
|
23
23
|
});
|
|
@@ -107,9 +107,10 @@ describe('PUT /api/v1/modules/new', () => {
|
|
|
107
107
|
const res = await publish('homebridge', '1.0.0+1', 'valid-token');
|
|
108
108
|
expect(res.status).toBe(200);
|
|
109
109
|
|
|
110
|
-
const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) =>
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) => r.json())) as {
|
|
111
|
+
name: string;
|
|
112
|
+
versions: { num: string }[];
|
|
113
|
+
};
|
|
113
114
|
expect(meta.name).toBe('homebridge');
|
|
114
115
|
expect(meta.versions).toHaveLength(1);
|
|
115
116
|
expect(meta.versions[0].num).toBe('1.0.0+1');
|
|
@@ -258,9 +259,10 @@ describe('GET /api/v1/modules/{name} (metadata) — total_downloads', () => {
|
|
|
258
259
|
await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
|
|
259
260
|
await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
|
|
260
261
|
|
|
261
|
-
const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) =>
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) => r.json())) as {
|
|
263
|
+
name: string;
|
|
264
|
+
total_downloads: number;
|
|
265
|
+
};
|
|
264
266
|
expect(meta.total_downloads).toBe(2);
|
|
265
267
|
});
|
|
266
268
|
});
|
|
@@ -344,7 +346,7 @@ describe('write endpoints are rate limited', () => {
|
|
|
344
346
|
port: 0,
|
|
345
347
|
pathPrefix: '',
|
|
346
348
|
publicUrl: 'https://example.test',
|
|
347
|
-
auth: new TokenAuth(['tok']),
|
|
349
|
+
auth: new TokenAuth([{ token: 'tok', scope: ADMIN_SCOPE }]),
|
|
348
350
|
rateLimiter: createRateLimiter({ max: 2, windowMs: 60_000 }),
|
|
349
351
|
});
|
|
350
352
|
try {
|
|
@@ -386,7 +388,7 @@ describe('path prefix routing', () => {
|
|
|
386
388
|
port: 0,
|
|
387
389
|
pathPrefix: '/registry',
|
|
388
390
|
publicUrl: 'https://example.test/registry',
|
|
389
|
-
auth: new TokenAuth(['valid-token']),
|
|
391
|
+
auth: new TokenAuth([{ token: 'valid-token', scope: ADMIN_SCOPE }]),
|
|
390
392
|
});
|
|
391
393
|
try {
|
|
392
394
|
const base = `http://localhost:${prefixed.port}`;
|
|
@@ -400,6 +402,86 @@ describe('path prefix routing', () => {
|
|
|
400
402
|
});
|
|
401
403
|
});
|
|
402
404
|
|
|
405
|
+
// ── scoped publish tokens (ISS-0140) ──────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
describe('POST /api/v1/modules/tokens/mint + scoped publish', () => {
|
|
408
|
+
async function mint(repo: string, scope: string, token: string): Promise<Response> {
|
|
409
|
+
return fetch(`${baseUrl}/api/v1/modules/tokens/mint`, {
|
|
410
|
+
method: 'POST',
|
|
411
|
+
headers: token ? { Authorization: token, 'Content-Type': 'application/json' } : {},
|
|
412
|
+
body: JSON.stringify({ repo, scope }),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
test('admin token mints a scoped token; non-admin / no token is 401', async () => {
|
|
417
|
+
const noTok = await mint('celilo/lunacycle', 'lunacycle', '');
|
|
418
|
+
expect(noTok.status).toBe(401);
|
|
419
|
+
|
|
420
|
+
const ok = await mint('celilo/lunacycle', 'lunacycle', 'valid-token');
|
|
421
|
+
expect(ok.status).toBe(200);
|
|
422
|
+
const body = (await ok.json()) as { ok: boolean; token: string; scope: string };
|
|
423
|
+
expect(body.ok).toBe(true);
|
|
424
|
+
expect(body.scope).toBe('lunacycle');
|
|
425
|
+
expect(body.token.length).toBeGreaterThan(10);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('a minted scoped token publishes ONLY its package', async () => {
|
|
429
|
+
const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
430
|
+
token: string;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Can publish its own package…
|
|
434
|
+
const ok = await publish('lunacycle', '1.0.0+1', minted.token);
|
|
435
|
+
expect(ok.status).toBe(200);
|
|
436
|
+
|
|
437
|
+
// …but NOT another module.
|
|
438
|
+
const denied = await publish('caddy', '1.0.0+1', minted.token);
|
|
439
|
+
expect(denied.status).toBe(401);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('a scoped token cannot mint (not admin)', async () => {
|
|
443
|
+
const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
444
|
+
token: string;
|
|
445
|
+
};
|
|
446
|
+
const reMint = await mint('celilo/caddy', 'caddy', minted.token);
|
|
447
|
+
expect(reMint.status).toBe(401);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('re-minting rotates: the old token stops working, the new one publishes', async () => {
|
|
451
|
+
const first = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
452
|
+
token: string;
|
|
453
|
+
};
|
|
454
|
+
const second = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
455
|
+
token: string;
|
|
456
|
+
};
|
|
457
|
+
expect(second.token).not.toBe(first.token);
|
|
458
|
+
|
|
459
|
+
const oldDenied = await publish('lunacycle', '2.0.0+1', first.token);
|
|
460
|
+
expect(oldDenied.status).toBe(401);
|
|
461
|
+
const newOk = await publish('lunacycle', '2.0.0+1', second.token);
|
|
462
|
+
expect(newOk.status).toBe(200);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('revoke disables the token', async () => {
|
|
466
|
+
const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
|
|
467
|
+
token: string;
|
|
468
|
+
};
|
|
469
|
+
const revoke = await fetch(`${baseUrl}/api/v1/modules/tokens/revoke`, {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { Authorization: 'valid-token', 'Content-Type': 'application/json' },
|
|
472
|
+
body: JSON.stringify({ repo: 'celilo/lunacycle' }),
|
|
473
|
+
});
|
|
474
|
+
expect(revoke.status).toBe(200);
|
|
475
|
+
const denied = await publish('lunacycle', '1.0.0+1', minted.token);
|
|
476
|
+
expect(denied.status).toBe(401);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('mint rejects an invalid scope (not a valid package name)', async () => {
|
|
480
|
+
const res = await mint('celilo/lunacycle', 'Bad_Scope', 'valid-token');
|
|
481
|
+
expect(res.status).toBe(400);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
403
485
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
404
486
|
|
|
405
487
|
async function publish(
|
|
@@ -19,19 +19,19 @@
|
|
|
19
19
|
* can publish-then-import against a live registry.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { readFileSync } from 'node:fs';
|
|
23
22
|
import { join } from 'node:path';
|
|
24
23
|
import { TokenAuth } from './auth';
|
|
25
|
-
import { landingResponse } from './landing';
|
|
26
24
|
import {
|
|
25
|
+
type BootstrapEntry,
|
|
27
26
|
bootstrapIndexEntry,
|
|
28
27
|
packageBootstrapModule,
|
|
29
28
|
scanBootstrapDir,
|
|
30
29
|
scanUploadsDir,
|
|
31
|
-
type BootstrapEntry,
|
|
32
30
|
} from './bootstrap';
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
31
|
+
import { landingResponse } from './landing';
|
|
32
|
+
import { type RateLimiter, clientIp, createRateLimiter } from './rate-limit';
|
|
33
|
+
import { ScopedTokenStore, fileScopedTokenPersistence } from './scoped-token-store';
|
|
34
|
+
import { type IndexEntry, RegistryStorage } from './storage';
|
|
35
35
|
import { isValidName, isValidVersion, validateNameAndVersion } from './validation';
|
|
36
36
|
|
|
37
37
|
/** Max total publish body size. Real modules are a few MB; 100MB is generous. */
|
|
@@ -70,14 +70,26 @@ interface ServerOptions {
|
|
|
70
70
|
* to slow a bad actor to a crawl.
|
|
71
71
|
*/
|
|
72
72
|
rateLimiter?: RateLimiter;
|
|
73
|
+
/**
|
|
74
|
+
* Store of minted, per-repo package-scoped tokens (build-bus Phase 3,
|
|
75
|
+
* ISS-0140). Defaults to a JSON file in `dataDir`. Its persisted hashes are
|
|
76
|
+
* loaded into `auth` at startup so minted tokens survive a restart.
|
|
77
|
+
*/
|
|
78
|
+
scopedTokenStore?: ScopedTokenStore;
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve> {
|
|
76
82
|
const { dataDir, port, pathPrefix, publicUrl, auth } = options;
|
|
77
83
|
const storage = new RegistryStorage(dataDir);
|
|
84
|
+
const scopedTokenStore =
|
|
85
|
+
options.scopedTokenStore ??
|
|
86
|
+
new ScopedTokenStore(fileScopedTokenPersistence(join(dataDir, 'scoped-tokens.json')));
|
|
87
|
+
// Load persisted minted tokens into the in-memory authorizer.
|
|
88
|
+
for (const entry of scopedTokenStore.list()) {
|
|
89
|
+
auth.addHashed(entry.hash, entry.scope);
|
|
90
|
+
}
|
|
78
91
|
const bootstrapCacheDir = options.bootstrapCacheDir ?? join(dataDir, '.bootstrap-cache');
|
|
79
|
-
const rateLimiter =
|
|
80
|
-
options.rateLimiter ?? createRateLimiter({ max: 30, windowMs: 60_000 });
|
|
92
|
+
const rateLimiter = options.rateLimiter ?? createRateLimiter({ max: 30, windowMs: 60_000 });
|
|
81
93
|
|
|
82
94
|
/**
|
|
83
95
|
* Rescan every request so .netapps dropped into bootstrapUploadsDir at
|
|
@@ -122,15 +134,24 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
122
134
|
return err('Unauthorized — provide a valid publish token in the Authorization header', 401);
|
|
123
135
|
}
|
|
124
136
|
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
/** True when the request's token may publish/yank `pkg` (admin or exact scope). */
|
|
138
|
+
function authorizePackage(req: Request, pkg: string): boolean {
|
|
139
|
+
return auth.authorize(req.headers.get('Authorization') ?? '', pkg);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True when the request carries an admin (`*`-scoped) token — required to mint. */
|
|
143
|
+
function authorizeAdmin(req: Request): boolean {
|
|
144
|
+
return auth.isAdmin(req.headers.get('Authorization') ?? '');
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
/**
|
|
130
148
|
* Rate-limit check for write endpoints. Returns a 429 response when
|
|
131
149
|
* exceeded (with Retry-After), null when allowed.
|
|
132
150
|
*/
|
|
133
|
-
function rateLimitOrNull(
|
|
151
|
+
function rateLimitOrNull(
|
|
152
|
+
req: Request,
|
|
153
|
+
srv: { requestIP(r: Request): { address: string } | null },
|
|
154
|
+
): Response | null {
|
|
134
155
|
const ip = clientIp(req, srv);
|
|
135
156
|
const result = rateLimiter.take(ip);
|
|
136
157
|
if (result.ok) return null;
|
|
@@ -214,12 +235,15 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
214
235
|
// celilo CLI reads manifest.yml#description and includes it). When
|
|
215
236
|
// not provided we leave it undefined; the search endpoint falls
|
|
216
237
|
// back to bootstrap data or empty.
|
|
217
|
-
const description =
|
|
218
|
-
typeof meta.description === 'string' ? meta.description : undefined;
|
|
238
|
+
const description = typeof meta.description === 'string' ? meta.description : undefined;
|
|
219
239
|
|
|
220
240
|
const validation = validateNameAndVersion(name, vers);
|
|
221
241
|
if (!validation.ok) return err(validation.message);
|
|
222
242
|
|
|
243
|
+
// Scope check AFTER the name is known: a package-scoped token may publish
|
|
244
|
+
// only its own package; an admin token publishes anything (ISS-0140).
|
|
245
|
+
if (!authorizePackage(req, name)) return unauthorized();
|
|
246
|
+
|
|
223
247
|
const fileOffset = 4 + metaLen;
|
|
224
248
|
const fileLen = buf.readUInt32LE(fileOffset);
|
|
225
249
|
if (buf.length < fileOffset + 4 + fileLen) return err('Request body truncated (file)');
|
|
@@ -253,6 +277,51 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
253
277
|
return Response.json({ ok: true });
|
|
254
278
|
}
|
|
255
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Mint a per-repo, package-scoped publish token (ISS-0140). Admin-only. The
|
|
282
|
+
* `registry_publish` capability calls this on behalf of `source_forge.
|
|
283
|
+
* registerRepo`. Reconciled: re-minting for the same repo rotates the token.
|
|
284
|
+
* The raw token is returned ONCE — the caller sets it as the repo's Actions
|
|
285
|
+
* secret; the server keeps only its hash.
|
|
286
|
+
*/
|
|
287
|
+
async function handleMintToken(req: Request): Promise<Response> {
|
|
288
|
+
if (!authorizeAdmin(req)) return unauthorized();
|
|
289
|
+
let body: { repo?: unknown; scope?: unknown };
|
|
290
|
+
try {
|
|
291
|
+
body = (await req.json()) as { repo?: unknown; scope?: unknown };
|
|
292
|
+
} catch {
|
|
293
|
+
return err('Invalid JSON body');
|
|
294
|
+
}
|
|
295
|
+
const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
|
|
296
|
+
const scope = typeof body.scope === 'string' ? body.scope.trim() : '';
|
|
297
|
+
if (!repo) return err('repo is required');
|
|
298
|
+
if (!scope || !isValidName(scope)) return err('scope must be a valid package name');
|
|
299
|
+
|
|
300
|
+
const { token, entry, revokedHashes } = scopedTokenStore.mint(repo, scope);
|
|
301
|
+
for (const h of revokedHashes) auth.removeHashed(h);
|
|
302
|
+
auth.addHashed(entry.hash, entry.scope);
|
|
303
|
+
console.log(`[registry] minted scoped token for ${repo} → ${scope}`);
|
|
304
|
+
return Response.json({ ok: true, token, scope, repo });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Revoke all scoped tokens for a repo (ISS-0140). Admin-only. */
|
|
308
|
+
async function handleRevokeToken(req: Request): Promise<Response> {
|
|
309
|
+
if (!authorizeAdmin(req)) return unauthorized();
|
|
310
|
+
let body: { repo?: unknown };
|
|
311
|
+
try {
|
|
312
|
+
body = (await req.json()) as { repo?: unknown };
|
|
313
|
+
} catch {
|
|
314
|
+
return err('Invalid JSON body');
|
|
315
|
+
}
|
|
316
|
+
const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
|
|
317
|
+
if (!repo) return err('repo is required');
|
|
318
|
+
|
|
319
|
+
const removed = scopedTokenStore.revoke(repo);
|
|
320
|
+
for (const h of removed) auth.removeHashed(h);
|
|
321
|
+
console.log(`[registry] revoked ${removed.length} scoped token(s) for ${repo}`);
|
|
322
|
+
return Response.json({ ok: true, repo, revoked: removed.length });
|
|
323
|
+
}
|
|
324
|
+
|
|
256
325
|
const server = Bun.serve({
|
|
257
326
|
port,
|
|
258
327
|
async fetch(req, srv) {
|
|
@@ -273,10 +342,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
273
342
|
// - suffix='/' → request was prefix + '/', or just '/'
|
|
274
343
|
// - suffix='/index.html'
|
|
275
344
|
// See apps/celilo/designs/REGISTRY_BROWSE_UI.md (decision D2).
|
|
276
|
-
if (
|
|
277
|
-
method === 'GET' &&
|
|
278
|
-
(suffix === '' || suffix === '/' || suffix === '/index.html')
|
|
279
|
-
) {
|
|
345
|
+
if (method === 'GET' && (suffix === '' || suffix === '/' || suffix === '/index.html')) {
|
|
280
346
|
return landingResponse(publicUrl);
|
|
281
347
|
}
|
|
282
348
|
|
|
@@ -329,10 +395,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
329
395
|
// 3. Empty string (older publishes that pre-date the
|
|
330
396
|
// description capture and haven't been republished).
|
|
331
397
|
const bootstrapEntry = bootstrap.get(name);
|
|
332
|
-
const description =
|
|
333
|
-
latest?.description ??
|
|
334
|
-
bootstrapEntry?.description ??
|
|
335
|
-
'';
|
|
398
|
+
const description = latest?.description ?? bootstrapEntry?.description ?? '';
|
|
336
399
|
return {
|
|
337
400
|
name,
|
|
338
401
|
max_version: latest?.vers ?? '0.0.0',
|
|
@@ -359,14 +422,26 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
359
422
|
}
|
|
360
423
|
|
|
361
424
|
// Publish — rate-limit before even checking auth so a bad-token flood
|
|
362
|
-
// can't crowd out legitimate traffic on the shared buckets.
|
|
425
|
+
// can't crowd out legitimate traffic on the shared buckets. The scope
|
|
426
|
+
// check happens inside handlePublish, once the package name is known.
|
|
363
427
|
if (method === 'PUT' && suffix === `${apiBase}/new`) {
|
|
364
428
|
const rl = rateLimitOrNull(req, srv);
|
|
365
429
|
if (rl) return rl;
|
|
366
|
-
if (!requireAuth(req)) return unauthorized();
|
|
367
430
|
return handlePublish(req);
|
|
368
431
|
}
|
|
369
432
|
|
|
433
|
+
// Mint / revoke per-repo scoped publish tokens (admin-only — ISS-0140).
|
|
434
|
+
if (method === 'POST' && suffix === `${apiBase}/tokens/mint`) {
|
|
435
|
+
const rl = rateLimitOrNull(req, srv);
|
|
436
|
+
if (rl) return rl;
|
|
437
|
+
return handleMintToken(req);
|
|
438
|
+
}
|
|
439
|
+
if (method === 'POST' && suffix === `${apiBase}/tokens/revoke`) {
|
|
440
|
+
const rl = rateLimitOrNull(req, srv);
|
|
441
|
+
if (rl) return rl;
|
|
442
|
+
return handleRevokeToken(req);
|
|
443
|
+
}
|
|
444
|
+
|
|
370
445
|
if (suffix.startsWith(`${apiBase}/`)) {
|
|
371
446
|
const modulePath = suffix.slice(apiBase.length + 1);
|
|
372
447
|
// URL-decode path segments — module versions use a literal '+' to
|
|
@@ -387,10 +462,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
387
462
|
// endpoint — see notes there.
|
|
388
463
|
const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
|
|
389
464
|
const bootstrapEntry = resolveBootstrap().get(name);
|
|
390
|
-
const description =
|
|
391
|
-
latest?.description ??
|
|
392
|
-
bootstrapEntry?.description ??
|
|
393
|
-
'';
|
|
465
|
+
const description = latest?.description ?? bootstrapEntry?.description ?? '';
|
|
394
466
|
return Response.json({
|
|
395
467
|
name,
|
|
396
468
|
description,
|
|
@@ -434,9 +506,9 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
434
506
|
if (method === 'DELETE' && parts.length === 3 && parts[2] === 'yank') {
|
|
435
507
|
const rl = rateLimitOrNull(req, srv);
|
|
436
508
|
if (rl) return rl;
|
|
437
|
-
if (!requireAuth(req)) return unauthorized();
|
|
438
509
|
const [name, version] = parts;
|
|
439
510
|
if (!isValidName(name) || !isValidVersion(version)) return notFound();
|
|
511
|
+
if (!authorizePackage(req, name)) return unauthorized();
|
|
440
512
|
return setYanked(name, version, true);
|
|
441
513
|
}
|
|
442
514
|
|
|
@@ -444,9 +516,9 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
|
|
|
444
516
|
if (method === 'PUT' && parts.length === 3 && parts[2] === 'unyank') {
|
|
445
517
|
const rl = rateLimitOrNull(req, srv);
|
|
446
518
|
if (rl) return rl;
|
|
447
|
-
if (!requireAuth(req)) return unauthorized();
|
|
448
519
|
const [name, version] = parts;
|
|
449
520
|
if (!isValidName(name) || !isValidVersion(version)) return notFound();
|
|
521
|
+
if (!authorizePackage(req, name)) return unauthorized();
|
|
450
522
|
return setYanked(name, version, false);
|
|
451
523
|
}
|
|
452
524
|
}
|
|
@@ -464,8 +536,7 @@ export function startFromEnv(): void {
|
|
|
464
536
|
const port = Number(process.env.PORT ?? 3000);
|
|
465
537
|
const pathPrefix = (process.env.PATH_PREFIX ?? '').replace(/\/$/, '');
|
|
466
538
|
const publicUrl =
|
|
467
|
-
process.env.PUBLIC_URL ??
|
|
468
|
-
`https://${process.env.DOMAIN ?? 'celilo.computer'}${pathPrefix}`;
|
|
539
|
+
process.env.PUBLIC_URL ?? `https://${process.env.DOMAIN ?? 'celilo.computer'}${pathPrefix}`;
|
|
469
540
|
const bootstrapModulesDir = process.env.BOOTSTRAP_MODULES_DIR || undefined;
|
|
470
541
|
const bootstrapUploadsDir = process.env.BOOTSTRAP_UPLOADS_DIR || undefined;
|
|
471
542
|
const bootstrapCacheDir = process.env.BOOTSTRAP_CACHE_DIR || undefined;
|
|
@@ -485,7 +556,9 @@ export function startFromEnv(): void {
|
|
|
485
556
|
const bootstrapBits = [
|
|
486
557
|
bootstrapModulesDir ? `modulesDir=${bootstrapModulesDir}` : '',
|
|
487
558
|
bootstrapUploadsDir ? `uploadsDir=${bootstrapUploadsDir}` : '',
|
|
488
|
-
]
|
|
559
|
+
]
|
|
560
|
+
.filter(Boolean)
|
|
561
|
+
.join(', ');
|
|
489
562
|
console.log(
|
|
490
563
|
`[registry] listening on :${server.port} (prefix=${pathPrefix || '(none)'}, publicUrl=${publicUrl}${
|
|
491
564
|
bootstrapBits ? `, bootstrap=[${bootstrapBits}]` : ''
|
|
@@ -13,9 +13,14 @@
|
|
|
13
13
|
* @celilo/capabilities → (none)
|
|
14
14
|
* @celilo/cli-display → (none)
|
|
15
15
|
*
|
|
16
|
-
* So 5 workspace packages need to land in the simulator. We
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* So 5 workspace packages need to land in the simulator. We rewrite each
|
|
17
|
+
* package's `workspace:^` deps to concrete `^<workspace-version>` ranges
|
|
18
|
+
* OURSELVES before `bun pm pack` — we do NOT rely on bun's lock-based
|
|
19
|
+
* expansion, which goes stale after a cross-caret version bump and pins
|
|
20
|
+
* dependents to an unresolvable old range (ISS-0103: a `0.3→0.4`
|
|
21
|
+
* capabilities bump left the lock at 0.3, so packed cli pinned
|
|
22
|
+
* `^0.3.0-alpha.0`, excluding the 0.4.x the sim served). package.json is
|
|
23
|
+
* restored after packing so `workspace:^` stays in source.
|
|
19
24
|
*
|
|
20
25
|
* Output directory is typically `packages/e2e/.npm-registry-cache/` —
|
|
21
26
|
* staged into the npm-registry-sim Docker image at build time.
|
|
@@ -25,11 +30,73 @@
|
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
32
|
import { execSync } from 'node:child_process';
|
|
28
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
33
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
29
34
|
import { join } from 'node:path';
|
|
30
35
|
|
|
31
36
|
const REPO_ROOT = join(import.meta.dir, '..', '..', '..');
|
|
32
37
|
|
|
38
|
+
interface PackageJson {
|
|
39
|
+
name?: string;
|
|
40
|
+
version?: string;
|
|
41
|
+
dependencies?: Record<string, string>;
|
|
42
|
+
devDependencies?: Record<string, string>;
|
|
43
|
+
peerDependencies?: Record<string, string>;
|
|
44
|
+
optionalDependencies?: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WorkspaceRewrite {
|
|
48
|
+
depName: string;
|
|
49
|
+
oldSpec: string;
|
|
50
|
+
newSpec: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEP_BUCKETS = [
|
|
54
|
+
'dependencies',
|
|
55
|
+
'devDependencies',
|
|
56
|
+
'peerDependencies',
|
|
57
|
+
'optionalDependencies',
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Rewrite `workspace:` deps in `pkg` to concrete ranges using the
|
|
62
|
+
* name→version map, mutating `pkg`. Mirrors the publish flow's
|
|
63
|
+
* `rewriteWorkspaceDeps`:
|
|
64
|
+
* workspace:^ → ^<version>
|
|
65
|
+
* workspace:~ → ~<version>
|
|
66
|
+
* workspace:* / workspace: → * (any version)
|
|
67
|
+
* workspace:X.Y.Z → X.Y.Z (explicit, passes through)
|
|
68
|
+
*
|
|
69
|
+
* We do this ourselves rather than trust `bun pm pack`'s lock-based
|
|
70
|
+
* expansion, which goes stale after a cross-caret version bump (ISS-0103).
|
|
71
|
+
* Only deps present in `versions` (our own @celilo/* set) are touched.
|
|
72
|
+
* Returns the rewrites applied.
|
|
73
|
+
*/
|
|
74
|
+
export function rewriteWorkspacePins(
|
|
75
|
+
pkg: PackageJson,
|
|
76
|
+
versions: Map<string, string>,
|
|
77
|
+
): WorkspaceRewrite[] {
|
|
78
|
+
const rewrites: WorkspaceRewrite[] = [];
|
|
79
|
+
for (const bucket of DEP_BUCKETS) {
|
|
80
|
+
const deps = pkg[bucket];
|
|
81
|
+
if (!deps) continue;
|
|
82
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
83
|
+
if (!spec.startsWith('workspace:')) continue;
|
|
84
|
+
const version = versions.get(name);
|
|
85
|
+
if (!version) continue;
|
|
86
|
+
const range = spec.slice('workspace:'.length);
|
|
87
|
+
let newSpec: string;
|
|
88
|
+
if (range === '*' || range === '') newSpec = '*';
|
|
89
|
+
else if (range === '^') newSpec = `^${version}`;
|
|
90
|
+
else if (range === '~') newSpec = `~${version}`;
|
|
91
|
+
else newSpec = range;
|
|
92
|
+
if (newSpec === spec) continue;
|
|
93
|
+
deps[name] = newSpec;
|
|
94
|
+
rewrites.push({ depName: name, oldSpec: spec, newSpec });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return rewrites;
|
|
98
|
+
}
|
|
99
|
+
|
|
33
100
|
/**
|
|
34
101
|
* Workspace packages that install.sh's transitive @celilo/* closure depends
|
|
35
102
|
* on. Hardcoded by name → relative path so a typo in package.json or a future
|
|
@@ -63,27 +130,46 @@ function parseArgs(): CliArgs {
|
|
|
63
130
|
return { destination };
|
|
64
131
|
}
|
|
65
132
|
|
|
66
|
-
function pack(pkgPath: string, destination: string): string {
|
|
133
|
+
function pack(pkgPath: string, destination: string, versions: Map<string, string>): string {
|
|
67
134
|
const fullPath = join(REPO_ROOT, pkgPath);
|
|
68
|
-
|
|
135
|
+
const pkgJsonPath = join(fullPath, 'package.json');
|
|
136
|
+
if (!existsSync(pkgJsonPath)) {
|
|
69
137
|
throw new Error(`No package.json in ${fullPath}`);
|
|
70
138
|
}
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
139
|
+
|
|
140
|
+
// Rewrite workspace:^ deps to concrete ranges ourselves (see header /
|
|
141
|
+
// ISS-0103), then restore the original so workspace:^ stays in source.
|
|
142
|
+
const originalPkgJson = readFileSync(pkgJsonPath, 'utf-8');
|
|
143
|
+
const parsed = JSON.parse(originalPkgJson) as PackageJson;
|
|
144
|
+
const rewrites = rewriteWorkspacePins(parsed, versions);
|
|
145
|
+
if (rewrites.length > 0) {
|
|
146
|
+
const trailingNl = originalPkgJson.endsWith('\n') ? '\n' : '';
|
|
147
|
+
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + trailingNl);
|
|
148
|
+
for (const r of rewrites) {
|
|
149
|
+
console.log(` rewrote ${r.depName}: ${r.oldSpec} → ${r.newSpec}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
// Capture filenames before/after so we can identify the tarball this
|
|
155
|
+
// pack call produced even when the destination already contains tarballs
|
|
156
|
+
// from earlier packages in the loop.
|
|
157
|
+
const before = new Set(readdirSync(destination).filter((f) => f.endsWith('.tgz')));
|
|
158
|
+
execSync(`bun pm pack --destination=${destination}`, {
|
|
159
|
+
cwd: fullPath,
|
|
160
|
+
stdio: 'inherit',
|
|
161
|
+
});
|
|
162
|
+
const after = readdirSync(destination).filter((f) => f.endsWith('.tgz'));
|
|
163
|
+
const produced = after.filter((f) => !before.has(f));
|
|
164
|
+
if (produced.length !== 1) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Expected exactly one new tarball from packing ${pkgPath}, got ${produced.length}: ${produced.join(', ')}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return produced[0];
|
|
170
|
+
} finally {
|
|
171
|
+
writeFileSync(pkgJsonPath, originalPkgJson);
|
|
85
172
|
}
|
|
86
|
-
return produced[0];
|
|
87
173
|
}
|
|
88
174
|
|
|
89
175
|
function workspaceVersion(pkgPath: string): string {
|
|
@@ -104,11 +190,17 @@ function main(): void {
|
|
|
104
190
|
console.log(`Packing @celilo/* packages → ${destination}`);
|
|
105
191
|
console.log('');
|
|
106
192
|
|
|
193
|
+
// The name→version map used to rewrite each package's workspace:^ deps to
|
|
194
|
+
// concrete ranges that resolve against the co-packed versions (ISS-0103).
|
|
195
|
+
const versions = new Map<string, string>(
|
|
196
|
+
PACKAGES_TO_PACK.map(({ name, path }) => [name, workspaceVersion(path)]),
|
|
197
|
+
);
|
|
198
|
+
|
|
107
199
|
const results: Array<{ name: string; version: string; tarball: string }> = [];
|
|
108
200
|
for (const { name, path } of PACKAGES_TO_PACK) {
|
|
109
201
|
const version = workspaceVersion(path);
|
|
110
202
|
console.log(`▸ ${name}@${version} (${path})`);
|
|
111
|
-
const tarball = pack(path, destination);
|
|
203
|
+
const tarball = pack(path, destination, versions);
|
|
112
204
|
results.push({ name, version, tarball });
|
|
113
205
|
console.log(` → ${tarball}`);
|
|
114
206
|
console.log('');
|
package/src/shared-infra.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { execSync } from 'node:child_process';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
-
import { writeFileSync } from 'node:fs';
|
|
15
|
+
import { copyFileSync, existsSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { generateSharedInfraYaml, SHARED_PROJECT_NAME, SHARED_NETWORKS } from './docker-compose-generator';
|
|
17
17
|
import { ensureRegistryServerBundle } from './registry-bundle';
|
|
18
18
|
|
|
@@ -146,6 +146,23 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
146
146
|
const yaml = generateSharedInfraYaml();
|
|
147
147
|
writeFileSync(join(e2eDir, SHARED_COMPOSE_FILE), yaml);
|
|
148
148
|
|
|
149
|
+
// Seed the live DNS zone files from their templates BEFORE the compose
|
|
150
|
+
// mounts them. config/dns/{iamtheinternet.org,example.net}.zone are
|
|
151
|
+
// gitignored runtime state (scrubDnsZones rewrites them per-test); on a
|
|
152
|
+
// fresh checkout they don't exist yet, and a Docker bind-mount of a missing
|
|
153
|
+
// host path silently creates an empty DIRECTORY — so Knot serves no zone and
|
|
154
|
+
// DNS convergence times out. Seeding here makes the bind-mount targets real
|
|
155
|
+
// files, so shared infra bootstraps from a clean checkout (e.g. CI builder).
|
|
156
|
+
for (const zone of ['iamtheinternet.org', 'example.net']) {
|
|
157
|
+
const tpl = join(e2eDir, 'config', 'dns', 'templates', `${zone}.zone`);
|
|
158
|
+
const live = join(e2eDir, 'config', 'dns', `${zone}.zone`);
|
|
159
|
+
// Remove an empty dir left by a prior bind-mount on a missing path.
|
|
160
|
+
if (existsSync(live) && statSync(live).isDirectory()) {
|
|
161
|
+
rmSync(live, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
copyFileSync(tpl, live);
|
|
164
|
+
}
|
|
165
|
+
|
|
149
166
|
// Build and start
|
|
150
167
|
console.log('[progress:start] building shared infrastructure images | shared images built');
|
|
151
168
|
run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} build`,
|