@celilo/e2e 0.9.3 → 0.10.2
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/cpanel-host/cpanel-host-ca.crt +20 -0
- package/config/cpanel-host/docroot/.htaccess +14 -0
- package/config/cpanel-host/docroot/cms.html +4 -0
- package/config/cpanel-host/site-tls.crt +20 -0
- package/config/cpanel-host/site-tls.key +28 -0
- package/config/cpanel-host/site.conf +27 -0
- package/config/dns/com.zone +7 -0
- package/config/dns/knot-namecheap.conf +6 -0
- package/config/dns/tangohost.com.zone +14 -0
- package/config/resolver/unbound.conf +26 -0
- package/config/routing/fw-ext-routes.sh +16 -1
- package/config/routing/management-routes.sh +14 -4
- package/config/routing/minio-startup.sh +9 -0
- package/config/routing/public-sim-entrypoint.sh +24 -0
- package/config/routing/resolver-internal-routes.sh +10 -0
- package/docker/Dockerfile.apt-repo-sim +8 -0
- package/docker/Dockerfile.cpanel-host-sim +75 -0
- package/docker/Dockerfile.firewall +12 -0
- package/docker/Dockerfile.management +6 -0
- package/docker/Dockerfile.minio +7 -0
- package/docker/Dockerfile.npm-registry-sim +8 -0
- package/docker/Dockerfile.observer +11 -0
- package/docker/Dockerfile.registry +8 -0
- package/docker/Dockerfile.signal-cli +60 -0
- package/docker/Dockerfile.signal-release +44 -0
- package/docker/Dockerfile.signal-sim +17 -0
- package/docker/Dockerfile.target-machine +9 -0
- package/package.json +3 -3
- package/registry-server/package.json +17 -0
- package/registry-server/src/auth.test.ts +76 -0
- package/registry-server/src/auth.ts +105 -0
- package/registry-server/src/bootstrap-packaging.test.ts +71 -0
- package/registry-server/src/bootstrap.ts +250 -0
- package/registry-server/src/index.ts +16 -0
- package/registry-server/src/introspection.test.ts +247 -0
- package/registry-server/src/introspection.ts +204 -0
- package/registry-server/src/landing.ts +90 -0
- package/registry-server/src/module-owner-store.test.ts +85 -0
- package/registry-server/src/module-owner-store.ts +108 -0
- package/registry-server/src/rate-limit.test.ts +62 -0
- package/registry-server/src/rate-limit.ts +83 -0
- 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 +771 -0
- package/registry-server/src/server.ts +701 -0
- package/registry-server/src/storage.test.ts +148 -0
- package/registry-server/src/storage.ts +150 -0
- package/registry-server/src/validation.test.ts +86 -0
- package/registry-server/src/validation.ts +60 -0
- package/registry-server/tsconfig.json +15 -0
- package/scripts/stage-libsignal.ts +116 -0
- package/simulators/signal-cli/server.ts +328 -0
- package/src/cli/build.ts +7 -0
- package/src/cli/index.ts +6 -0
- package/src/container-manager.ts +47 -10
- package/src/docker-compose-generator.ts +120 -16
- package/src/exit-cleanup.test.ts +56 -0
- package/src/index.ts +7 -0
- package/src/network-builder.ts +66 -0
- package/src/public-sim-routes.test.ts +117 -0
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +71 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persisted module-owner table (SECURE_MODULE_PUBLISH.md §5[D-C], ce-1ch).
|
|
6
|
+
*
|
|
7
|
+
* The introspection verify-bridge (ce-s7e) proves *who* a publish token belongs
|
|
8
|
+
* to (a verified `sub` + group claims). This table gates *which modules* that
|
|
9
|
+
* identity may publish: a module name is **first-publish-claims** — the first
|
|
10
|
+
* verified publisher of an unclaimed name records ownership; thereafter only the
|
|
11
|
+
* owner (or an admin) may publish it. This defends the confused-deputy case:
|
|
12
|
+
* Author-A holding a valid idp token cannot publish Author-B's module.
|
|
13
|
+
*
|
|
14
|
+
* Ownership is low-friction / npm-style: no pre-registration, admin can reassign.
|
|
15
|
+
* Mirrors {@link ScopedTokenStore} (JSON file + I/O seam for unit testing).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface ModuleOwnerEntry {
|
|
19
|
+
/** Module name this ownership record governs. */
|
|
20
|
+
moduleName: string;
|
|
21
|
+
/** Verified idp subject that owns the name (from introspection `sub`). */
|
|
22
|
+
ownerSub: string;
|
|
23
|
+
/** ISO timestamp the name was first claimed. */
|
|
24
|
+
claimedAt: string;
|
|
25
|
+
/** Group claim that granted the claim (admin or publisher group at claim time). */
|
|
26
|
+
sourceGroup: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** I/O seam (Rule 2.3) so the store is unit-testable without the filesystem. */
|
|
30
|
+
export interface ModuleOwnerPersistence {
|
|
31
|
+
load(): ModuleOwnerEntry[];
|
|
32
|
+
save(entries: ModuleOwnerEntry[]): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** File-backed persistence: a JSON array at `filePath`, tolerant of a missing file. */
|
|
36
|
+
export function fileModuleOwnerPersistence(filePath: string): ModuleOwnerPersistence {
|
|
37
|
+
return {
|
|
38
|
+
load() {
|
|
39
|
+
if (!existsSync(filePath)) return [];
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
42
|
+
return Array.isArray(parsed) ? (parsed as ModuleOwnerEntry[]) : [];
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
save(entries) {
|
|
48
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
49
|
+
writeFileSync(filePath, JSON.stringify(entries, null, 2));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ModuleOwnerStore {
|
|
55
|
+
private entries: ModuleOwnerEntry[];
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly persistence: ModuleOwnerPersistence,
|
|
59
|
+
private readonly now: () => string = () => new Date().toISOString(),
|
|
60
|
+
) {
|
|
61
|
+
this.entries = persistence.load();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
list(): ModuleOwnerEntry[] {
|
|
65
|
+
return [...this.entries];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The ownership record for `moduleName`, or undefined when unclaimed. */
|
|
69
|
+
get(moduleName: string): ModuleOwnerEntry | undefined {
|
|
70
|
+
return this.entries.find((e) => e.moduleName === moduleName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Record ownership of an **unclaimed** name for `ownerSub`. If the name is
|
|
75
|
+
* already claimed this is a no-op and returns the *existing* entry — claiming
|
|
76
|
+
* never steals a name (use {@link reassign} for admin overwrite). Returns the
|
|
77
|
+
* effective owner entry either way.
|
|
78
|
+
*/
|
|
79
|
+
claim(moduleName: string, ownerSub: string, sourceGroup: string): ModuleOwnerEntry {
|
|
80
|
+
const existing = this.get(moduleName);
|
|
81
|
+
if (existing) return existing;
|
|
82
|
+
const entry: ModuleOwnerEntry = {
|
|
83
|
+
moduleName,
|
|
84
|
+
ownerSub,
|
|
85
|
+
claimedAt: this.now(),
|
|
86
|
+
sourceGroup,
|
|
87
|
+
};
|
|
88
|
+
this.entries = [...this.entries, entry];
|
|
89
|
+
this.persistence.save(this.entries);
|
|
90
|
+
return entry;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Overwrite (or create) the ownership record for `moduleName` — admin
|
|
95
|
+
* reassignment. Unlike {@link claim} this steals an already-owned name.
|
|
96
|
+
*/
|
|
97
|
+
reassign(moduleName: string, ownerSub: string, sourceGroup: string): ModuleOwnerEntry {
|
|
98
|
+
const entry: ModuleOwnerEntry = {
|
|
99
|
+
moduleName,
|
|
100
|
+
ownerSub,
|
|
101
|
+
claimedAt: this.now(),
|
|
102
|
+
sourceGroup,
|
|
103
|
+
};
|
|
104
|
+
this.entries = [...this.entries.filter((e) => e.moduleName !== moduleName), entry];
|
|
105
|
+
this.persistence.save(this.entries);
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|