@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.
Files changed (46) hide show
  1. package/config/cpanel-host/cpanel-host-ca.crt +20 -0
  2. package/config/cpanel-host/docroot/.htaccess +14 -0
  3. package/config/cpanel-host/docroot/cms.html +4 -0
  4. package/config/cpanel-host/site-tls.crt +20 -0
  5. package/config/cpanel-host/site-tls.key +28 -0
  6. package/config/cpanel-host/site.conf +27 -0
  7. package/config/dns/com.zone +7 -0
  8. package/config/dns/knot-namecheap.conf +6 -0
  9. package/config/dns/tangohost.com.zone +14 -0
  10. package/config/proxy/squid.conf +7 -2
  11. package/config/resolver/unbound.conf +39 -3
  12. package/config/routing/management-routes.sh +9 -4
  13. package/config/routing/resolver-internal-routes.sh +10 -0
  14. package/docker/Dockerfile.cpanel-host-sim +70 -0
  15. package/docker/Dockerfile.firewall +12 -0
  16. package/docker/Dockerfile.management +6 -0
  17. package/docker/Dockerfile.minio +1 -1
  18. package/docker/Dockerfile.observer +11 -0
  19. package/docker/Dockerfile.signal-cli +60 -0
  20. package/docker/Dockerfile.signal-release +36 -0
  21. package/docker/Dockerfile.signal-sim +17 -0
  22. package/docker/Dockerfile.target-machine +10 -1
  23. package/docker/Dockerfile.target-machine-docker +1 -1
  24. package/docker/Dockerfile.target-technitium +1 -1
  25. package/package.json +1 -1
  26. package/registry-server/src/auth.ts +2 -2
  27. package/registry-server/src/introspection.test.ts +247 -0
  28. package/registry-server/src/introspection.ts +204 -0
  29. package/registry-server/src/module-owner-store.test.ts +85 -0
  30. package/registry-server/src/module-owner-store.ts +108 -0
  31. package/registry-server/src/server.test.ts +262 -0
  32. package/registry-server/src/server.ts +139 -10
  33. package/scripts/pack-celilo-packages.ts +79 -51
  34. package/scripts/stage-libsignal.ts +116 -0
  35. package/simulators/signal-cli/server.ts +272 -0
  36. package/src/cli/build.ts +35 -9
  37. package/src/cli/command-registry.ts +34 -6
  38. package/src/cli/index.ts +59 -21
  39. package/src/container-manager.ts +6 -2
  40. package/src/docker-compose-generator.ts +108 -4
  41. package/src/network-builder.ts +52 -0
  42. package/src/runner.ts +128 -57
  43. package/src/simulator-ips.ts +9 -0
  44. package/src/types.ts +34 -0
  45. package/config/dns/example.net.zone +0 -16
  46. 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
- * Their transitive @celilo/* dependency closure is:
10
- * @celilo/cli → @celilo/capabilities, @celilo/cli-display, @celilo/event-bus
11
- * @celilo/event-bus → (none)
12
- * @celilo/e2e → @celilo/cli-display, @celilo/event-bus
13
- * @celilo/capabilities → (none)
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
- * 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`
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). package.json is
23
- * restored after packing so `workspace:^` stays in source.
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, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
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
- const REPO_ROOT = join(import.meta.dir, '..', '..', '..');
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) continue;
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(REPO_ROOT, 'packages', 'e2e', '.npm-registry-cache');
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(REPO_ROOT, pkgPath);
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(REPO_ROOT, 'packages', 'e2e'));
225
+ ensureRegistryServerBundle(join(repoRoot(), 'packages', 'e2e'));
199
226
 
200
227
  console.log(`Packing @celilo/* packages → ${destination}`);
201
228
  console.log('');
202
229
 
203
- // The name→version map used to rewrite each package's workspace:^ deps to
204
- // concrete ranges that resolve against the co-packed versions (ISS-0103).
205
- const versions = new Map<string, string>(
206
- PACKAGES_TO_PACK.map(({ name, path }) => [name, workspaceVersion(path)]),
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, path } of PACKAGES_TO_PACK) {
211
- const version = workspaceVersion(path);
212
- console.log(`▸ ${name}@${version} (${path})`);
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
- /** Walk up from pkgDir to find the monorepo root (contains a `modules/` dir). */
29
- function findMonorepoRoot(pkgDir: string): string | null {
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, 'modules'))) return 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. Mirrors
175
- * PACKAGES_TO_PACK in scripts/pack-celilo-packages.ts (the monorepo
176
- * equivalent that packs from workspace source instead of fetching).
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/e2e',
206
+ '@celilo/core',
183
207
  '@celilo/event-bus',
208
+ '@celilo/e2e',
209
+ '@celilo/cli',
184
210
  ] as const;
185
211
 
186
212
  /**