@celilo/e2e 0.9.2 → 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/proxy/squid.conf +7 -2
- package/config/resolver/unbound.conf +39 -3
- 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.minio +1 -1
- 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 +10 -1
- package/docker/Dockerfile.target-machine-docker +1 -1
- package/docker/Dockerfile.target-technitium +1 -1
- package/package.json +1 -1
- package/registry-server/src/auth.ts +2 -2
- package/registry-server/src/introspection.test.ts +247 -0
- package/registry-server/src/introspection.ts +204 -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/server.test.ts +262 -0
- package/registry-server/src/server.ts +139 -10
- package/scripts/pack-celilo-packages.ts +79 -51
- package/scripts/stage-libsignal.ts +116 -0
- package/simulators/signal-cli/server.ts +272 -0
- package/src/cli/build.ts +35 -9
- package/src/cli/command-registry.ts +34 -6
- package/src/cli/index.ts +59 -21
- package/src/container-manager.ts +6 -2
- package/src/docker-compose-generator.ts +108 -4
- package/src/network-builder.ts +52 -0
- package/src/runner.ts +128 -57
- package/src/simulator-ips.ts +9 -0
- package/src/types.ts +34 -0
- package/config/dns/example.net.zone +0 -16
- package/config/dns/iamtheinternet.org.zone +0 -16
|
@@ -6,21 +6,21 @@
|
|
|
6
6
|
* install.sh runs:
|
|
7
7
|
* bun add -g @celilo/cli @celilo/event-bus @celilo/e2e
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* @celilo/cli-display → (none)
|
|
9
|
+
* Which packages must land in the simulator is the transitive runtime closure
|
|
10
|
+
* of those roots — DERIVED from the live workspace by `installClosure()`, never
|
|
11
|
+
* hand-listed here. A hand-maintained list is exactly what turned this red for
|
|
12
|
+
* days when `@celilo/core` was added as a cli dep and forgotten
|
|
13
|
+
* (apps/celilo/designs/WORKSPACE_PACKAGE_GRAPH.md).
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* dependents to an unresolvable old range (ISS-0103: a `0.3→0.4`
|
|
15
|
+
* We rewrite each packed package's `workspace:^` deps to concrete
|
|
16
|
+
* `^<workspace-version>` ranges OURSELVES before `bun pm pack` — we do NOT rely
|
|
17
|
+
* on bun's lock-based expansion, which goes stale after a cross-caret version
|
|
18
|
+
* bump and pins dependents to an unresolvable old range (ISS-0103: a `0.3→0.4`
|
|
21
19
|
* 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).
|
|
23
|
-
*
|
|
20
|
+
* `^0.3.0-alpha.0`, excluding the 0.4.x the sim served). The rewrite map is the
|
|
21
|
+
* FULL workspace version map (every @celilo/* package), so any `workspace:` dep
|
|
22
|
+
* that can't be resolved is a genuine error and throws (see rewriteWorkspacePins).
|
|
23
|
+
* package.json is restored after packing so `workspace:^` stays in source.
|
|
24
24
|
*
|
|
25
25
|
* Output directory is typically `packages/e2e/.npm-registry-cache/` —
|
|
26
26
|
* staged into the npm-registry-sim Docker image at build time.
|
|
@@ -30,11 +30,53 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { execSync } from 'node:child_process';
|
|
33
|
-
import { existsSync, mkdirSync,
|
|
34
|
-
import { join } from 'node:path';
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { join, resolve } from 'node:path';
|
|
35
|
+
import {
|
|
36
|
+
installClosure,
|
|
37
|
+
readWorkspace,
|
|
38
|
+
workspaceVersionMap,
|
|
39
|
+
} from '../../../scripts/workspace-graph';
|
|
35
40
|
import { ensureRegistryServerBundle } from '../src/registry-bundle';
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the celilo monorepo root by walking up from `startDir` (the caller's
|
|
44
|
+
* cwd by default) looking for `apps/celilo/package.json`.
|
|
45
|
+
*
|
|
46
|
+
* This replaces a hardcoded `join(import.meta.dir, '..', '..', '..')`. That
|
|
47
|
+
* derivation assumed the script always lives at `<repo>/packages/e2e/scripts/`,
|
|
48
|
+
* which holds in a checkout and nowhere else: the script ships inside the
|
|
49
|
+
* published `@celilo/e2e` tarball, where `../../..` resolves to the consumer's
|
|
50
|
+
* `node_modules/` and every path built from it points at
|
|
51
|
+
* `node_modules/apps/celilo/...` → ENOENT (ce-dae). `build.ts` already spawns
|
|
52
|
+
* this script with `cwd: <repoRoot>`, so the caller is the single source of
|
|
53
|
+
* truth for where the workspace is — take it from there, don't re-derive it.
|
|
54
|
+
* Walking up (rather than requiring cwd to BE the root exactly) also lets it run
|
|
55
|
+
* from a subdir of the checkout, e.g. `bun test` from `packages/e2e`.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveRepoRoot(startDir: string = process.cwd()): string {
|
|
58
|
+
let dir = startDir;
|
|
59
|
+
for (let i = 0; i < 10; i++) {
|
|
60
|
+
if (existsSync(join(dir, 'apps', 'celilo', 'package.json'))) return dir;
|
|
61
|
+
const parent = resolve(dir, '..');
|
|
62
|
+
if (parent === dir) break;
|
|
63
|
+
dir = parent;
|
|
64
|
+
}
|
|
65
|
+
throw new Error(
|
|
66
|
+
`pack-celilo-packages: no celilo checkout found from '${startDir}' (looked for apps/celilo/package.json walking up). This is a monorepo-internal dev script — run it from a checkout via 'cele2e build-infra'.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Lazily memoized so importing this module (e.g. the rewriteWorkspacePins unit
|
|
71
|
+
// test) never triggers resolution — only the packing path, which runs inside a
|
|
72
|
+
// checkout, resolves the root.
|
|
73
|
+
let _repoRoot: string | undefined;
|
|
74
|
+
function repoRoot(): string {
|
|
75
|
+
if (_repoRoot === undefined) {
|
|
76
|
+
_repoRoot = resolveRepoRoot();
|
|
77
|
+
}
|
|
78
|
+
return _repoRoot;
|
|
79
|
+
}
|
|
38
80
|
|
|
39
81
|
interface PackageJson {
|
|
40
82
|
name?: string;
|
|
@@ -83,7 +125,14 @@ export function rewriteWorkspacePins(
|
|
|
83
125
|
for (const [name, spec] of Object.entries(deps)) {
|
|
84
126
|
if (!spec.startsWith('workspace:')) continue;
|
|
85
127
|
const version = versions.get(name);
|
|
86
|
-
if (!version)
|
|
128
|
+
if (!version) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Cannot rewrite ${bucket} "${name}": "${spec}" — ${name} is not a known ` +
|
|
131
|
+
`@celilo/* workspace package. Add it to the workspace, or fix the dep name. ` +
|
|
132
|
+
`(This is the loud failure the old hardcoded pack list lacked — ` +
|
|
133
|
+
`see apps/celilo/designs/WORKSPACE_PACKAGE_GRAPH.md.)`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
87
136
|
const range = spec.slice('workspace:'.length);
|
|
88
137
|
let newSpec: string;
|
|
89
138
|
if (range === '*' || range === '') newSpec = '*';
|
|
@@ -98,27 +147,12 @@ export function rewriteWorkspacePins(
|
|
|
98
147
|
return rewrites;
|
|
99
148
|
}
|
|
100
149
|
|
|
101
|
-
/**
|
|
102
|
-
* Workspace packages that install.sh's transitive @celilo/* closure depends
|
|
103
|
-
* on. Hardcoded by name → relative path so a typo in package.json or a future
|
|
104
|
-
* rename gets caught at build time rather than at test time. If a new
|
|
105
|
-
* @celilo/* workspace package becomes a runtime dep of cli/event-bus/e2e,
|
|
106
|
-
* add it here.
|
|
107
|
-
*/
|
|
108
|
-
const PACKAGES_TO_PACK = [
|
|
109
|
-
{ name: '@celilo/cli', path: 'apps/celilo' },
|
|
110
|
-
{ name: '@celilo/capabilities', path: 'packages/capabilities' },
|
|
111
|
-
{ name: '@celilo/cli-display', path: 'packages/cli-display' },
|
|
112
|
-
{ name: '@celilo/e2e', path: 'packages/e2e' },
|
|
113
|
-
{ name: '@celilo/event-bus', path: 'packages/event-bus' },
|
|
114
|
-
] as const;
|
|
115
|
-
|
|
116
150
|
interface CliArgs {
|
|
117
151
|
destination: string;
|
|
118
152
|
}
|
|
119
153
|
|
|
120
154
|
function parseArgs(): CliArgs {
|
|
121
|
-
const defaultDest = join(
|
|
155
|
+
const defaultDest = join(repoRoot(), 'packages', 'e2e', '.npm-registry-cache');
|
|
122
156
|
let destination = defaultDest;
|
|
123
157
|
for (const arg of process.argv.slice(2)) {
|
|
124
158
|
if (arg.startsWith('--dest=')) {
|
|
@@ -132,7 +166,7 @@ function parseArgs(): CliArgs {
|
|
|
132
166
|
}
|
|
133
167
|
|
|
134
168
|
function pack(pkgPath: string, destination: string, versions: Map<string, string>): string {
|
|
135
|
-
const fullPath = join(
|
|
169
|
+
const fullPath = join(repoRoot(), pkgPath);
|
|
136
170
|
const pkgJsonPath = join(fullPath, 'package.json');
|
|
137
171
|
if (!existsSync(pkgJsonPath)) {
|
|
138
172
|
throw new Error(`No package.json in ${fullPath}`);
|
|
@@ -173,13 +207,6 @@ function pack(pkgPath: string, destination: string, versions: Map<string, string
|
|
|
173
207
|
}
|
|
174
208
|
}
|
|
175
209
|
|
|
176
|
-
function workspaceVersion(pkgPath: string): string {
|
|
177
|
-
const json = JSON.parse(readFileSync(join(REPO_ROOT, pkgPath, 'package.json'), 'utf-8')) as {
|
|
178
|
-
version: string;
|
|
179
|
-
};
|
|
180
|
-
return json.version;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
210
|
function main(): void {
|
|
184
211
|
const { destination } = parseArgs();
|
|
185
212
|
|
|
@@ -195,22 +222,23 @@ function main(): void {
|
|
|
195
222
|
// "published without its registry-server bundle"). Regenerate it from the
|
|
196
223
|
// sibling packages/registry-server before packing, exactly as buildDockerImages
|
|
197
224
|
// does in-monorepo. (This is why npm-consumer-smoke was red — ce-3rs.)
|
|
198
|
-
ensureRegistryServerBundle(join(
|
|
225
|
+
ensureRegistryServerBundle(join(repoRoot(), 'packages', 'e2e'));
|
|
199
226
|
|
|
200
227
|
console.log(`Packing @celilo/* packages → ${destination}`);
|
|
201
228
|
console.log('');
|
|
202
229
|
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
);
|
|
230
|
+
// Derive the package set + version map from the live workspace (never a
|
|
231
|
+
// hardcoded list). The rewrite map is the FULL workspace (every @celilo/*
|
|
232
|
+
// package) so any workspace: dep resolves or throws; the pack set is the
|
|
233
|
+
// runtime install closure of install.sh's roots.
|
|
234
|
+
const workspace = readWorkspace(repoRoot());
|
|
235
|
+
const versions = workspaceVersionMap(workspace);
|
|
236
|
+
const toPack = installClosure(workspace);
|
|
208
237
|
|
|
209
238
|
const results: Array<{ name: string; version: string; tarball: string }> = [];
|
|
210
|
-
for (const { name,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const tarball = pack(path, destination, versions);
|
|
239
|
+
for (const { name, dir, version } of toPack) {
|
|
240
|
+
console.log(`▸ ${name}@${version} (${dir})`);
|
|
241
|
+
const tarball = pack(dir, destination, versions);
|
|
214
242
|
results.push({ name, version, tarball });
|
|
215
243
|
console.log(` → ${tarball}`);
|
|
216
244
|
console.log('');
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stage-libsignal.ts — put the libsignal JNI native (linux-aarch64) into the
|
|
3
|
+
* apt-repo staging pool, building it if we don't already have it.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS BUILT ON THE DEV MACHINE AND NOT IN THE E2E NETWORK
|
|
6
|
+
*
|
|
7
|
+
* The signal module needs a native that upstream does not publish for
|
|
8
|
+
* linux-aarch64 (see modules/signal/build/). Compiling it takes minutes,
|
|
9
|
+
* pulls hundreds of crates and a BoringSSL submodule, and needs crates.io and
|
|
10
|
+
* GitHub — none of which exist inside the sealed e2e network, and all of which
|
|
11
|
+
* would blow a 5-minute test budget.
|
|
12
|
+
*
|
|
13
|
+
* So the compile happens HERE, at `cele2e build-infra` time, on the host's own
|
|
14
|
+
* network, using the module's own recipe. The deploy then just installs a
|
|
15
|
+
* .deb, which is exactly what it does in production. The e2e network stays
|
|
16
|
+
* sealed and the deploy test stays fast, while the recipe is still exercised
|
|
17
|
+
* for real on every rebuild rather than rotting untested.
|
|
18
|
+
*
|
|
19
|
+
* Cached by version: the compile only re-runs when the version changes or the
|
|
20
|
+
* cached .deb is deleted.
|
|
21
|
+
*
|
|
22
|
+
* Graceful no-op when Docker is unavailable or the build fails — build-infra
|
|
23
|
+
* still completes, and the signal deploy test is what fails loudly (with an
|
|
24
|
+
* empty repo) rather than breaking the build for everyone. Mirrors how
|
|
25
|
+
* stageAptRepo treats a missing nfpm.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
const green = '\x1b[32m';
|
|
33
|
+
const yellow = '\x1b[33m';
|
|
34
|
+
const dim = '\x1b[2m';
|
|
35
|
+
const reset = '\x1b[0m';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Must match `modules/signal/manifest.yml`'s `libsignal_deb_url` default and
|
|
39
|
+
* `modules/signal/build/build.sh`. The build script verifies the pairing
|
|
40
|
+
* against the real signal-cli release, so a mismatch here fails the build
|
|
41
|
+
* loudly rather than producing a native that silently will not load.
|
|
42
|
+
*/
|
|
43
|
+
const LIBSIGNAL_VERSION = '0.96.3';
|
|
44
|
+
const DEB_NAME = `libsignal-jni_${LIBSIGNAL_VERSION}_arm64.deb`;
|
|
45
|
+
|
|
46
|
+
export function stageLibsignal(repoRoot: string, pkgDir: string): boolean {
|
|
47
|
+
const buildDir = join(repoRoot, 'modules', 'signal', 'build');
|
|
48
|
+
if (!existsSync(buildDir)) return false;
|
|
49
|
+
|
|
50
|
+
// Only aarch64 needs this native at all — an x86_64 host uses the one already
|
|
51
|
+
// bundled in the libsignal-client JAR. Building it there would mean an
|
|
52
|
+
// emulated compile of something nothing would install.
|
|
53
|
+
if (process.arch !== 'arm64') {
|
|
54
|
+
console.log(
|
|
55
|
+
` ${'libsignal-jni'.padEnd(28)} ${dim}skip (host is ${process.arch}; JAR ships that native)${reset}`,
|
|
56
|
+
);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pool = join(pkgDir, '.apt-repo-cache', 'pool');
|
|
61
|
+
const cache = join(pkgDir, '.libsignal-cache');
|
|
62
|
+
mkdirSync(pool, { recursive: true });
|
|
63
|
+
mkdirSync(cache, { recursive: true });
|
|
64
|
+
|
|
65
|
+
const cached = join(cache, DEB_NAME);
|
|
66
|
+
|
|
67
|
+
if (!existsSync(cached)) {
|
|
68
|
+
process.stdout.write(` ${'libsignal-jni (build)'.padEnd(28)} `);
|
|
69
|
+
const t0 = Date.now();
|
|
70
|
+
|
|
71
|
+
const image = 'celilo-e2e/libsignal-builder';
|
|
72
|
+
const build = spawnSync(
|
|
73
|
+
'docker',
|
|
74
|
+
['build', '--platform', 'linux/arm64', '-q', '-t', image, buildDir],
|
|
75
|
+
{ stdio: 'pipe' },
|
|
76
|
+
);
|
|
77
|
+
if (build.status !== 0) {
|
|
78
|
+
console.log(`${yellow}skip${reset} ${dim}(docker build failed)${reset}`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Named volumes keep the cargo registry and the checkout across runs, so a
|
|
83
|
+
// version bump is a few minutes rather than a fresh fetch of everything.
|
|
84
|
+
const run = spawnSync(
|
|
85
|
+
'docker',
|
|
86
|
+
[
|
|
87
|
+
'run',
|
|
88
|
+
'--rm',
|
|
89
|
+
'--platform',
|
|
90
|
+
'linux/arm64',
|
|
91
|
+
'-v',
|
|
92
|
+
`${cache}:/out`,
|
|
93
|
+
'-v',
|
|
94
|
+
'celilo-libsignal-work:/work',
|
|
95
|
+
'-v',
|
|
96
|
+
'celilo-libsignal-cargo:/root/.cargo/registry',
|
|
97
|
+
image,
|
|
98
|
+
],
|
|
99
|
+
{ stdio: 'pipe' },
|
|
100
|
+
);
|
|
101
|
+
if (run.status !== 0 || !existsSync(cached)) {
|
|
102
|
+
console.log(`${yellow}skip${reset} ${dim}(build failed)${reset}`);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
console.log(
|
|
106
|
+
`${green}✔${reset} ${dim}${Math.round((Date.now() - t0) / 1000)}s${reset}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
process.stdout.write(` ${'libsignal-jni (stage)'.padEnd(28)} `);
|
|
111
|
+
for (const file of readdirSync(cache).filter((f) => f.endsWith('.deb'))) {
|
|
112
|
+
copyFileSync(join(cache, file), join(pool, file));
|
|
113
|
+
}
|
|
114
|
+
console.log(`${green}✔${reset} ${dim}${DEB_NAME}${reset}`);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* signal-cli JSON-RPC daemon simulator.
|
|
3
|
+
*
|
|
4
|
+
* Mimics `signal-cli daemon --http <addr>`: JSON-RPC 2.0 over POST, with the
|
|
5
|
+
* behaviours that make the real thing awkward, because those are the ones
|
|
6
|
+
* celilo has to survive:
|
|
7
|
+
*
|
|
8
|
+
* - errors arrive as a JSON-RPC `error` member with HTTP **200**, so code
|
|
9
|
+
* that checks `response.ok` believes a rejected send succeeded;
|
|
10
|
+
* - an unlinked/revoked account answers HTTP perfectly and can send nothing;
|
|
11
|
+
* - `receive` returns receipts and typing indicators alongside real
|
|
12
|
+
* messages, and those are not replies.
|
|
13
|
+
*
|
|
14
|
+
* It pushes back rather than saying yes. Sending to a number that has not been
|
|
15
|
+
* registered with the simulator fails the way Signal fails.
|
|
16
|
+
*
|
|
17
|
+
* ── FIDELITY ───────────────────────────────────────────────────────────────
|
|
18
|
+
* Every shape below is verified against signal-cli 0.14.6 running in a
|
|
19
|
+
* container, plus its published JSON schemas — not against recollection. The
|
|
20
|
+
* contract test in e2e re-checks the real daemon so this file cannot silently
|
|
21
|
+
* drift from it. See design.md D16 for the verified table.
|
|
22
|
+
*
|
|
23
|
+
* Still true: this cannot prove that a LINKED account delivers to a phone.
|
|
24
|
+
* That needs Signal's real network, and CDSI/SVR2 attestation makes a local
|
|
25
|
+
* substitute impossible. Everything short of delivery is verified.
|
|
26
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* Control surface (simulator-only, not part of signal-cli):
|
|
29
|
+
* POST /_control/inbound {from, body} queue a reply as if a human sent it
|
|
30
|
+
* POST /_control/soft-fail {recipient} make a recipient fail INSIDE a success
|
|
31
|
+
* POST /_control/unlink revoke the device link
|
|
32
|
+
* POST /_control/relink restore it
|
|
33
|
+
* GET /_control/sent every message sent, for assertions
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const ACCOUNT = process.env.SIGNAL_ACCOUNT ?? '+15551234567';
|
|
37
|
+
const PORT = Number(process.env.SIGNAL_RPC_PORT ?? 8080);
|
|
38
|
+
|
|
39
|
+
/** Numbers the simulator will accept as recipients. Anything else is rejected. */
|
|
40
|
+
const KNOWN_RECIPIENTS = new Set(
|
|
41
|
+
(process.env.SIGNAL_KNOWN_RECIPIENTS ?? '')
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((n) => n.trim())
|
|
44
|
+
.filter(Boolean),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
interface SentMessage {
|
|
48
|
+
recipient: string;
|
|
49
|
+
message: string;
|
|
50
|
+
timestamp: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface QueuedInbound {
|
|
54
|
+
from: string;
|
|
55
|
+
body: string;
|
|
56
|
+
timestamp: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sent: SentMessage[] = [];
|
|
60
|
+
/**
|
|
61
|
+
* Recipients that fail INSIDE a successful response, with a non-SUCCESS
|
|
62
|
+
* `type`. signal-cli only raises a JSON-RPC error when nothing succeeded, so
|
|
63
|
+
* this is how a partial failure looks — and how a client that checks only
|
|
64
|
+
* `error` ends up recording an undelivered page as sent.
|
|
65
|
+
*/
|
|
66
|
+
const unregisteredRecipients = new Set<string>();
|
|
67
|
+
const inboundQueue: QueuedInbound[] = [];
|
|
68
|
+
let linked = true;
|
|
69
|
+
let clock = 1_722_200_000_000;
|
|
70
|
+
|
|
71
|
+
const nextTimestamp = () => ++clock;
|
|
72
|
+
|
|
73
|
+
function rpcResult(id: unknown, result: unknown): Response {
|
|
74
|
+
return Response.json({ jsonrpc: '2.0', id, result });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A JSON-RPC error, returned with HTTP 200 exactly as signal-cli does. This is
|
|
79
|
+
* the single most important fidelity detail in the simulator.
|
|
80
|
+
*/
|
|
81
|
+
function rpcError(id: unknown, code: number, message: string): Response {
|
|
82
|
+
return Response.json({ jsonrpc: '2.0', id, error: { code, message } }, { status: 200 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleSend(id: unknown, params: Record<string, unknown>): Response {
|
|
86
|
+
if (!linked) {
|
|
87
|
+
return rpcError(id, -32000, 'Account is not registered or the device link was removed');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const recipients = Array.isArray(params.recipient) ? (params.recipient as string[]) : [];
|
|
91
|
+
const message = typeof params.message === 'string' ? params.message : '';
|
|
92
|
+
|
|
93
|
+
if (recipients.length === 0) return rpcError(id, -32602, 'No recipient given');
|
|
94
|
+
|
|
95
|
+
for (const recipient of recipients) {
|
|
96
|
+
// Push back like Signal: an unregistered number is a hard failure, not a
|
|
97
|
+
// silent no-op.
|
|
98
|
+
if (KNOWN_RECIPIENTS.size > 0 && !KNOWN_RECIPIENTS.has(recipient)) {
|
|
99
|
+
return rpcError(id, -32000, `Unregistered user: ${recipient}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const timestamp = nextTimestamp();
|
|
104
|
+
for (const recipient of recipients) {
|
|
105
|
+
sent.push({ recipient, message, timestamp });
|
|
106
|
+
console.log(`[signal-sim] -> ${recipient}: ${message.split('\n')[0]}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Verified shape: { timestamp, results: [ { type, recipientAddress } ] }.
|
|
110
|
+
// The per-recipient `type` is the THIRD failure mode — a call can succeed
|
|
111
|
+
// at the JSON-RPC level and still not have delivered.
|
|
112
|
+
return rpcResult(id, {
|
|
113
|
+
timestamp,
|
|
114
|
+
results: recipients.map((recipient) => ({
|
|
115
|
+
type: unregisteredRecipients.has(recipient) ? 'UNREGISTERED_FAILURE' : 'SUCCESS',
|
|
116
|
+
recipientAddress: { number: recipient },
|
|
117
|
+
})),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Drain the inbound queue.
|
|
123
|
+
*
|
|
124
|
+
* Interleaves a delivery receipt and a typing indicator with real messages —
|
|
125
|
+
* the real daemon does, and a consumer that treats every envelope as a reply
|
|
126
|
+
* will misbehave on them.
|
|
127
|
+
*/
|
|
128
|
+
function handleReceive(id: unknown): Response {
|
|
129
|
+
if (!linked) {
|
|
130
|
+
return rpcError(id, -32000, 'Account is not registered or the device link was removed');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const envelopes: unknown[] = [
|
|
134
|
+
{
|
|
135
|
+
envelope: {
|
|
136
|
+
sourceNumber: ACCOUNT,
|
|
137
|
+
timestamp: nextTimestamp(),
|
|
138
|
+
receiptMessage: { isDelivery: true },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
while (inboundQueue.length > 0) {
|
|
144
|
+
const item = inboundQueue.shift() as QueuedInbound;
|
|
145
|
+
envelopes.push({
|
|
146
|
+
envelope: {
|
|
147
|
+
sourceNumber: item.from,
|
|
148
|
+
timestamp: item.timestamp,
|
|
149
|
+
dataMessage: { message: item.body },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
console.log(`[signal-sim] <- ${item.from}: ${item.body}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
envelopes.push({
|
|
156
|
+
envelope: {
|
|
157
|
+
sourceNumber: ACCOUNT,
|
|
158
|
+
timestamp: nextTimestamp(),
|
|
159
|
+
typingMessage: { action: 'STARTED' },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return rpcResult(id, envelopes);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handleListAccounts(id: unknown): Response {
|
|
167
|
+
// A revoked link answers happily with an empty account list — reachability
|
|
168
|
+
// is not health.
|
|
169
|
+
return rpcResult(id, linked ? [{ number: ACCOUNT }] : []);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function handleControl(url: URL, req: Request): Promise<Response> {
|
|
173
|
+
switch (url.pathname) {
|
|
174
|
+
case '/_control/inbound': {
|
|
175
|
+
const body = (await req.json()) as { from?: string; body?: string };
|
|
176
|
+
if (!body.from || !body.body) {
|
|
177
|
+
return Response.json({ error: 'from and body required' }, { status: 400 });
|
|
178
|
+
}
|
|
179
|
+
inboundQueue.push({ from: body.from, body: body.body, timestamp: nextTimestamp() });
|
|
180
|
+
return Response.json({ queued: inboundQueue.length });
|
|
181
|
+
}
|
|
182
|
+
case '/_control/soft-fail': {
|
|
183
|
+
const body = (await req.json()) as { recipient?: string };
|
|
184
|
+
if (!body.recipient) {
|
|
185
|
+
return Response.json({ error: 'recipient required' }, { status: 400 });
|
|
186
|
+
}
|
|
187
|
+
unregisteredRecipients.add(body.recipient);
|
|
188
|
+
return Response.json({ softFailing: [...unregisteredRecipients] });
|
|
189
|
+
}
|
|
190
|
+
case '/_control/unlink':
|
|
191
|
+
linked = false;
|
|
192
|
+
console.log('[signal-sim] device link revoked');
|
|
193
|
+
return Response.json({ linked });
|
|
194
|
+
case '/_control/relink':
|
|
195
|
+
linked = true;
|
|
196
|
+
console.log('[signal-sim] device link restored');
|
|
197
|
+
return Response.json({ linked });
|
|
198
|
+
case '/_control/sent':
|
|
199
|
+
return Response.json({ sent });
|
|
200
|
+
case '/_control/reset':
|
|
201
|
+
sent.length = 0;
|
|
202
|
+
inboundQueue.length = 0;
|
|
203
|
+
unregisteredRecipients.clear();
|
|
204
|
+
linked = true;
|
|
205
|
+
return Response.json({ ok: true });
|
|
206
|
+
default:
|
|
207
|
+
return new Response('Not found', { status: 404 });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
Bun.serve({
|
|
212
|
+
port: PORT,
|
|
213
|
+
hostname: '0.0.0.0',
|
|
214
|
+
async fetch(req) {
|
|
215
|
+
const url = new URL(req.url);
|
|
216
|
+
|
|
217
|
+
if (url.pathname.startsWith('/_control/')) return handleControl(url, req);
|
|
218
|
+
|
|
219
|
+
// Verified endpoints on the real daemon.
|
|
220
|
+
if (url.pathname === '/api/v1/check') {
|
|
221
|
+
return req.method === 'GET' ? new Response(null, { status: 200 }) : new Response(null, { status: 405 });
|
|
222
|
+
}
|
|
223
|
+
if (url.pathname === '/api/v1/events') {
|
|
224
|
+
return new Response('', { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
|
225
|
+
}
|
|
226
|
+
if (url.pathname !== '/api/v1/rpc') return new Response('Not found', { status: 404 });
|
|
227
|
+
|
|
228
|
+
// Verified: the real daemon returns 415 when Content-Type is not JSON.
|
|
229
|
+
const contentType = req.headers.get('content-type');
|
|
230
|
+
if (!contentType?.startsWith('application/json')) {
|
|
231
|
+
return new Response(null, { status: 415 });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
235
|
+
|
|
236
|
+
let payload: { id?: unknown; method?: string; params?: Record<string, unknown> };
|
|
237
|
+
try {
|
|
238
|
+
payload = (await req.json()) as typeof payload;
|
|
239
|
+
} catch {
|
|
240
|
+
return Response.json(
|
|
241
|
+
{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
|
|
242
|
+
{ status: 200 },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { id = null, method, params = {} } = payload;
|
|
247
|
+
|
|
248
|
+
switch (method) {
|
|
249
|
+
case 'send':
|
|
250
|
+
return handleSend(id, params);
|
|
251
|
+
case 'receive':
|
|
252
|
+
return handleReceive(id);
|
|
253
|
+
case 'listAccounts':
|
|
254
|
+
return handleListAccounts(id);
|
|
255
|
+
case 'version':
|
|
256
|
+
// Verified: { "version": "0.14.6" }
|
|
257
|
+
return rpcResult(id, { version: '0.14.6' });
|
|
258
|
+
default:
|
|
259
|
+
return rpcError(id, -32601, `Method not found: ${method}`);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
console.log(`[signal-sim] signal-cli JSON-RPC simulator on :${PORT} as ${ACCOUNT}`);
|
|
265
|
+
if (KNOWN_RECIPIENTS.size > 0) {
|
|
266
|
+
console.log(`[signal-sim] known recipients: ${[...KNOWN_RECIPIENTS].join(', ')}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Marks this file a module so its top-level constants get their own scope.
|
|
270
|
+
// Without it, `PORT` here collides with the same name in the isitup simulator,
|
|
271
|
+
// which shares a tsconfig.
|
|
272
|
+
export {};
|
package/src/cli/build.ts
CHANGED
|
@@ -23,13 +23,25 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } fro
|
|
|
23
23
|
import { basename, join, resolve } from 'node:path';
|
|
24
24
|
import { gunzipSync } from 'node:zlib';
|
|
25
25
|
import { stageAptRepo } from '../../scripts/stage-apt-repo';
|
|
26
|
+
import { stageLibsignal } from '../../scripts/stage-libsignal';
|
|
26
27
|
import { ensureRegistryServerBundle } from '../registry-bundle';
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Walk up from pkgDir to find the celilo monorepo root.
|
|
31
|
+
*
|
|
32
|
+
* The marker is `apps/celilo/package.json` — a file that only a real celilo
|
|
33
|
+
* checkout has — NOT the presence of a `modules/` directory. A bare `modules/`
|
|
34
|
+
* is too weak a signal: a stray empty `node_modules/modules` (left by a botched
|
|
35
|
+
* `bun add -g`) made this return the global node_modules as "the monorepo root",
|
|
36
|
+
* which skipped `stageFromPublic()` and crashed the npm-consumer path in the
|
|
37
|
+
* workspace packer (ce-dbc). Matches the marker `container-manager.ts`'s
|
|
38
|
+
* findCeliloRoot already uses. Returns null in any consumer install, so callers
|
|
39
|
+
* correctly fall through to the published-source path.
|
|
40
|
+
*/
|
|
41
|
+
export function findMonorepoRoot(pkgDir: string): string | null {
|
|
30
42
|
let dir = pkgDir;
|
|
31
43
|
for (let i = 0; i < 10; i++) {
|
|
32
|
-
if (existsSync(join(dir, '
|
|
44
|
+
if (existsSync(join(dir, 'apps', 'celilo', 'package.json'))) return dir;
|
|
33
45
|
const parent = resolve(dir, '..');
|
|
34
46
|
if (parent === dir) break;
|
|
35
47
|
dir = parent;
|
|
@@ -165,22 +177,36 @@ async function stageSimulatorInputs(pkgDir: string): Promise<void> {
|
|
|
165
177
|
// time, so this must run before buildDockerImages().
|
|
166
178
|
stageAptRepo(repoRoot, pkgDir);
|
|
167
179
|
|
|
180
|
+
// (d) Build + stage the libsignal aarch64 native as a .deb, into the SAME
|
|
181
|
+
// pool stageAptRepo just populated (so it must run after it — that function
|
|
182
|
+
// recreates the pool empty). Compiled here on the host's own network rather
|
|
183
|
+
// than inside the sealed e2e network; see stage-libsignal.ts.
|
|
184
|
+
stageLibsignal(repoRoot, pkgDir);
|
|
185
|
+
|
|
168
186
|
console.log('');
|
|
169
187
|
}
|
|
170
188
|
|
|
171
189
|
/**
|
|
172
190
|
* The published @celilo/* packages the npm-registry-sim must serve so
|
|
173
191
|
* install.sh's `bun add -g @celilo/cli @celilo/event-bus @celilo/e2e`
|
|
174
|
-
* resolves its full transitive closure against the sim.
|
|
175
|
-
*
|
|
176
|
-
*
|
|
192
|
+
* resolves its full transitive closure against the sim.
|
|
193
|
+
*
|
|
194
|
+
* This is the ONE place the install closure is a literal rather than derived:
|
|
195
|
+
* build.ts runs consumer-side (inside the published @celilo/e2e tarball, with
|
|
196
|
+
* NO monorepo present), so it cannot read the workspace package.json set at
|
|
197
|
+
* runtime the way the monorepo packer does. `installClosure(readWorkspace())`
|
|
198
|
+
* is the source of truth; this literal must match it, and
|
|
199
|
+
* published-packages-in-sync.test.ts fails the instant they drift (that test is
|
|
200
|
+
* why forgetting a package here can't recur — see WORKSPACE_PACKAGE_GRAPH.md).
|
|
201
|
+
* Keep this sorted the same as installClosure()'s topo output for easy diffing.
|
|
177
202
|
*/
|
|
178
|
-
const PUBLISHED_CELILO_PACKAGES = [
|
|
179
|
-
'@celilo/cli',
|
|
203
|
+
export const PUBLISHED_CELILO_PACKAGES = [
|
|
180
204
|
'@celilo/capabilities',
|
|
181
205
|
'@celilo/cli-display',
|
|
182
|
-
'@celilo/
|
|
206
|
+
'@celilo/core',
|
|
183
207
|
'@celilo/event-bus',
|
|
208
|
+
'@celilo/e2e',
|
|
209
|
+
'@celilo/cli',
|
|
184
210
|
] as const;
|
|
185
211
|
|
|
186
212
|
/**
|