@celilo/e2e 0.9.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/management-routes.sh +9 -4
- package/config/routing/resolver-internal-routes.sh +10 -0
- package/docker/Dockerfile.cpanel-host-sim +70 -0
- package/docker/Dockerfile.firewall +12 -0
- package/docker/Dockerfile.management +6 -0
- package/docker/Dockerfile.observer +11 -0
- package/docker/Dockerfile.signal-cli +60 -0
- package/docker/Dockerfile.signal-release +36 -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 +246 -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 +272 -0
- package/src/cli/build.ts +7 -0
- package/src/cli/index.ts +6 -0
- package/src/container-manager.ts +5 -1
- package/src/docker-compose-generator.ts +108 -4
- package/src/network-builder.ts +52 -0
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +34 -0
|
@@ -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
|
+
}
|