@celilo/cli 0.14.4 → 0.16.1

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 (64) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +19 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +3 -3
  6. package/src/cli/commands/alerts-list.ts +10 -0
  7. package/src/cli/commands/alerts-poll.ts +12 -6
  8. package/src/cli/commands/alerts-sweep.ts +22 -81
  9. package/src/cli/commands/backup-sweep.ts +65 -0
  10. package/src/cli/commands/module-config.test.ts +77 -1
  11. package/src/cli/commands/module-config.ts +45 -3
  12. package/src/cli/commands/module-journal.test.ts +47 -0
  13. package/src/cli/commands/module-journal.ts +98 -0
  14. package/src/cli/commands/module-operations.test.ts +93 -0
  15. package/src/cli/commands/module-operations.ts +134 -0
  16. package/src/cli/commands/module-upgrade.test.ts +32 -20
  17. package/src/cli/commands/module-upgrade.ts +37 -32
  18. package/src/cli/commands/monitor.ts +26 -6
  19. package/src/cli/commands/system-audit.ts +3 -30
  20. package/src/cli/completion.ts +20 -1
  21. package/src/cli/generate-zsh-completion.ts +4 -0
  22. package/src/cli/index.ts +14 -0
  23. package/src/db/schema.ts +5 -3
  24. package/src/manifest/schema.ts +4 -1
  25. package/src/module/packaging/build.ts +4 -0
  26. package/src/services/alerting/builtin-source.ts +17 -2
  27. package/src/services/alerting/delivery-loop.test.ts +5 -1
  28. package/src/services/alerting/format.test.ts +0 -1
  29. package/src/services/alerting/inbound-poller.test.ts +235 -8
  30. package/src/services/alerting/inbound-poller.ts +95 -34
  31. package/src/services/alerting/inbound.test.ts +213 -2
  32. package/src/services/alerting/inbound.ts +161 -32
  33. package/src/services/alerting/interview-responder.test.ts +0 -32
  34. package/src/services/alerting/interview-responder.ts +6 -17
  35. package/src/services/alerting/notify-deps.ts +113 -0
  36. package/src/services/alerting/run-monitor.ts +0 -1
  37. package/src/services/alerting/store.test.ts +1 -1
  38. package/src/services/alerting/store.ts +0 -2
  39. package/src/services/alerting/sweep-runner.test.ts +11 -2
  40. package/src/services/alerting/sweep-runner.ts +14 -7
  41. package/src/services/alerting/tokens.ts +39 -1
  42. package/src/services/audit/backup-source.ts +54 -0
  43. package/src/services/audit/backups.test.ts +7 -2
  44. package/src/services/audit/backups.ts +10 -18
  45. package/src/services/backup-cipher.test.ts +188 -0
  46. package/src/services/backup-cipher.ts +178 -0
  47. package/src/services/backup-create.ts +20 -30
  48. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  49. package/src/services/backup-restore.ts +10 -16
  50. package/src/services/backup-schedule.ts +35 -0
  51. package/src/services/backup-sweep.test.ts +148 -0
  52. package/src/services/backup-sweep.ts +124 -0
  53. package/src/services/deploy-posture.ts +15 -2
  54. package/src/services/module-journal.test.ts +302 -0
  55. package/src/services/module-journal.ts +160 -0
  56. package/src/services/module-operations.test.ts +67 -6
  57. package/src/services/module-operations.ts +69 -19
  58. package/src/services/module-subscriptions.test.ts +33 -2
  59. package/src/services/module-subscriptions.ts +10 -1
  60. package/src/services/module-validator/typescript-build.test.ts +20 -1
  61. package/src/services/module-validator/typescript-build.ts +9 -5
  62. package/src/services/restore-from-file.ts +6 -21
  63. package/src/templates/generator.test.ts +88 -0
  64. package/src/templates/generator.ts +119 -16
@@ -75,7 +75,10 @@ describe('auditBackups', () => {
75
75
  expect(result).toEqual([]);
76
76
  });
77
77
 
78
- test('no schedule declared treated as manual (no stale flag)', async () => {
78
+ // Silence-by-default is the bug: an undeclared cadence is how celilo-mgmt
79
+ // went 55 days without a backup and nobody was told. Opting out takes an
80
+ // explicit `manual` — see services/backup-schedule.ts.
81
+ test('no schedule declared → daily, so a year-old backup is stale', async () => {
79
82
  const result = await auditBackups({
80
83
  modules: [
81
84
  makeModule('lunacycle', {
@@ -85,7 +88,9 @@ describe('auditBackups', () => {
85
88
  ],
86
89
  now: () => NOW,
87
90
  });
88
- expect(result).toEqual([]);
91
+ expect(result).toHaveLength(1);
92
+ expect(result[0]).toMatchObject({ code: 'backup_stale', subject: 'lunacycle' });
93
+ expect(result[0].message).toContain('daily');
89
94
  });
90
95
 
91
96
  test('daily schedule: 26h-old is stale', async () => {
@@ -8,14 +8,17 @@
8
8
  * scheduled run doesn't flag drift on every audit.
9
9
  *
10
10
  * Modules without an `on_backup` hook are skipped — there's nothing
11
- * to back up. Modules whose schedule is `manual` (or unset) skip the
12
- * staleness check (the user decides cadence) but still get a
13
- * `backup_missing` finding if no backup has ever been recorded.
11
+ * to back up. Modules whose schedule is explicitly `manual` skip the
12
+ * staleness check (the operator decides cadence) but still get a
13
+ * `backup_missing` finding if no backup has ever been recorded. An
14
+ * unset schedule is `daily`, not `manual` — see
15
+ * [[services/backup-schedule.ts]] for why that default matters.
14
16
  *
15
17
  * Time is injected so tests can pin "now" deterministically.
16
18
  */
17
19
 
18
20
  import type { ModuleManifest } from '../../manifest/schema';
21
+ import { effectiveBackupSchedule } from '../backup-schedule';
19
22
  import type { DriftFinding } from './types';
20
23
 
21
24
  export interface InstalledModuleBackupInfo {
@@ -50,10 +53,7 @@ const DAY = 24 * HOUR;
50
53
  * - daily → 25h (24h + 1h grace)
51
54
  * - weekly → 8d (7d + 1d grace)
52
55
  * - monthly → 32d (~30d + 2d grace)
53
- * - manual → null (no staleness check; user-driven cadence)
54
- *
55
- * `undefined` (no `backup:` block in manifest) is treated as
56
- * `manual` — author opted out of declaring a cadence.
56
+ * - manual → null (no staleness check; operator-driven cadence)
57
57
  */
58
58
  const SCHEDULE_THRESHOLDS = {
59
59
  hourly: 2 * HOUR,
@@ -63,21 +63,13 @@ const SCHEDULE_THRESHOLDS = {
63
63
  manual: null,
64
64
  } as const;
65
65
 
66
- type ScheduleKey = keyof typeof SCHEDULE_THRESHOLDS;
67
-
68
66
  function moduleHasBackupHook(manifest: ModuleManifest): boolean {
69
67
  return Boolean(manifest.hooks?.on_backup);
70
68
  }
71
69
 
72
- function scheduleFor(manifest: ModuleManifest): ScheduleKey {
73
- const s = manifest.backup?.schedule;
74
- if (s === 'hourly' || s === 'daily' || s === 'weekly' || s === 'monthly') return s;
75
- return 'manual';
76
- }
77
-
78
70
  function thresholdFor(manifest: ModuleManifest, override: number | undefined): number | null {
79
71
  if (override !== undefined) return override;
80
- return SCHEDULE_THRESHOLDS[scheduleFor(manifest)];
72
+ return SCHEDULE_THRESHOLDS[effectiveBackupSchedule(manifest)];
81
73
  }
82
74
 
83
75
  function formatAge(ms: number): string {
@@ -109,7 +101,7 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
109
101
  category: 'backups',
110
102
  severity: 'drift',
111
103
  code: 'backup_missing',
112
- message: `${m.id}: no successful backup recorded (schedule: ${scheduleFor(m.manifest)})`,
104
+ message: `${m.id}: no successful backup recorded (schedule: ${effectiveBackupSchedule(m.manifest)})`,
113
105
  remediation: `celilo backup create ${m.id} --force`,
114
106
  actionable: true,
115
107
  subject: m.id,
@@ -126,7 +118,7 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
126
118
  category: 'backups',
127
119
  severity: 'drift',
128
120
  code: 'backup_stale',
129
- message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${scheduleFor(m.manifest)}, threshold: ${formatAge(threshold)})`,
121
+ message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${effectiveBackupSchedule(m.manifest)}, threshold: ${formatAge(threshold)})`,
130
122
  remediation: `celilo backup create ${m.id} --force`,
131
123
  actionable: true,
132
124
  subject: m.id,
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Backup artifact encryption.
3
+ *
4
+ * The load-bearing test here is `writes a streamed artifact, not a JSON
5
+ * envelope`. Everything else could pass while someone quietly reintroduces
6
+ * the whole-file-in-memory path — the format assertion is what goes red if
7
+ * they do, without needing an 800 MB fixture to prove it.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+ import { randomBytes } from 'node:crypto';
12
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { encryptSecret } from '../secrets/encryption';
16
+ import {
17
+ ARTIFACT_FORMAT_VERSION,
18
+ ARTIFACT_MAGIC,
19
+ decryptFileToFile,
20
+ encryptFileToFile,
21
+ isStreamedArtifact,
22
+ } from './backup-cipher';
23
+
24
+ const MASTER_KEY = Buffer.alloc(32, 7);
25
+ const OTHER_KEY = Buffer.alloc(32, 9);
26
+
27
+ let dir: string;
28
+
29
+ beforeEach(() => {
30
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cipher-test-'));
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ });
36
+
37
+ function paths(name: string) {
38
+ return {
39
+ plain: join(dir, `${name}.tar`),
40
+ enc: join(dir, `${name}.enc`),
41
+ out: join(dir, `${name}.out`),
42
+ };
43
+ }
44
+
45
+ /** An artifact in the pre-streaming format: JSON envelope of base64-of-hex. */
46
+ function writeLegacyArtifact(path: string, payload: Buffer): void {
47
+ writeFileSync(path, JSON.stringify(encryptSecret(payload.toString('base64'), MASTER_KEY)));
48
+ }
49
+
50
+ describe('backup-cipher', () => {
51
+ test('round-trips a payload byte-for-byte', async () => {
52
+ const p = paths('roundtrip');
53
+ const payload = randomBytes(3 * 1024 * 1024);
54
+ writeFileSync(p.plain, payload);
55
+
56
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
57
+ await decryptFileToFile(p.enc, p.out, MASTER_KEY);
58
+
59
+ expect(readFileSync(p.out).equals(payload)).toBe(true);
60
+ });
61
+
62
+ test('writes a streamed artifact, not a JSON envelope', async () => {
63
+ const p = paths('format');
64
+ writeFileSync(p.plain, randomBytes(4096));
65
+
66
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
67
+
68
+ const written = readFileSync(p.enc);
69
+ expect(written.subarray(0, ARTIFACT_MAGIC.length).equals(ARTIFACT_MAGIC)).toBe(true);
70
+ expect(written[ARTIFACT_MAGIC.length]).toBe(ARTIFACT_FORMAT_VERSION);
71
+ expect(isStreamedArtifact(p.enc)).toBe(true);
72
+
73
+ // The old path produced base64-of-hex wrapped in JSON, which is 2.67x
74
+ // the input and starts with '{'. Both are what made it OOM.
75
+ expect(written[0]).not.toBe('{'.charCodeAt(0));
76
+ expect(written.length).toBeLessThan(4096 * 2);
77
+ });
78
+
79
+ test('artifact is barely larger than its plaintext', async () => {
80
+ const p = paths('overhead');
81
+ const size = 1024 * 1024;
82
+ writeFileSync(p.plain, randomBytes(size));
83
+
84
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
85
+
86
+ // header (25) + tag (16). AES-GCM is a stream cipher — no block padding.
87
+ expect(statSync(p.enc).size).toBe(size + 41);
88
+ });
89
+
90
+ test('still reads artifacts in the legacy JSON format', async () => {
91
+ const p = paths('legacy');
92
+ const payload = randomBytes(64 * 1024);
93
+ writeLegacyArtifact(p.enc, payload);
94
+
95
+ expect(isStreamedArtifact(p.enc)).toBe(false);
96
+ await decryptFileToFile(p.enc, p.out, MASTER_KEY);
97
+
98
+ expect(readFileSync(p.out).equals(payload)).toBe(true);
99
+ });
100
+
101
+ test('rejects a wrong master key rather than emitting garbage', async () => {
102
+ const p = paths('wrongkey');
103
+ writeFileSync(p.plain, randomBytes(8192));
104
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
105
+
106
+ expect(decryptFileToFile(p.enc, p.out, OTHER_KEY)).rejects.toThrow();
107
+ });
108
+
109
+ test('rejects tampered ciphertext', async () => {
110
+ const p = paths('tamper');
111
+ writeFileSync(p.plain, randomBytes(8192));
112
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
113
+
114
+ const bytes = readFileSync(p.enc);
115
+ bytes[100] ^= 0xff;
116
+ writeFileSync(p.enc, bytes);
117
+
118
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow();
119
+ });
120
+
121
+ test('rejects a tampered auth tag', async () => {
122
+ const p = paths('tamper-tag');
123
+ writeFileSync(p.plain, randomBytes(8192));
124
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
125
+
126
+ const bytes = readFileSync(p.enc);
127
+ bytes[bytes.length - 1] ^= 0xff;
128
+ writeFileSync(p.enc, bytes);
129
+
130
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow();
131
+ });
132
+
133
+ test('reports a truncated artifact clearly', async () => {
134
+ const p = paths('truncated');
135
+ writeFileSync(p.enc, Buffer.concat([ARTIFACT_MAGIC, Buffer.from([ARTIFACT_FORMAT_VERSION])]));
136
+
137
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/truncated/i);
138
+ });
139
+
140
+ test('refuses an artifact written by a newer celilo', async () => {
141
+ const p = paths('future');
142
+ writeFileSync(p.plain, randomBytes(1024));
143
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
144
+
145
+ const bytes = readFileSync(p.enc);
146
+ bytes[ARTIFACT_MAGIC.length] = ARTIFACT_FORMAT_VERSION + 1;
147
+ writeFileSync(p.enc, bytes);
148
+
149
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/Upgrade celilo/);
150
+ });
151
+
152
+ test('handles an empty plaintext', async () => {
153
+ const p = paths('empty');
154
+ writeFileSync(p.plain, Buffer.alloc(0));
155
+
156
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
157
+ // Header + tag only — nothing to stream back, and a real tar is never
158
+ // empty, so this is reported rather than silently producing 0 bytes.
159
+ expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/no data/i);
160
+ });
161
+
162
+ /**
163
+ * The ceiling the old format could not clear: hex-of-base64 is 2.67 chars
164
+ * per input byte against a ~2^31 max string length, so anything over
165
+ * ~805 MB was unrepresentable regardless of available RAM.
166
+ *
167
+ * Opt-in — it writes ~900 MB to disk and takes tens of seconds, which does
168
+ * not belong in every CI run. Run deliberately after touching this file:
169
+ * CELILO_TEST_LARGE_BACKUP=1 bun test src/services/backup-cipher.test.ts
170
+ */
171
+ test.skipIf(!process.env.CELILO_TEST_LARGE_BACKUP)(
172
+ 'round-trips an artifact past the old format ceiling',
173
+ async () => {
174
+ const p = paths('huge');
175
+ const chunk = randomBytes(1024 * 1024);
176
+ const file = Bun.file(p.plain).writer();
177
+ for (let i = 0; i < 900; i++) file.write(chunk);
178
+ await file.end();
179
+
180
+ await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
181
+ await decryptFileToFile(p.enc, p.out, MASTER_KEY);
182
+
183
+ expect(existsSync(p.out)).toBe(true);
184
+ expect(statSync(p.out).size).toBe(statSync(p.plain).size);
185
+ },
186
+ 300_000,
187
+ );
188
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Encryption for backup artifacts — file in, file out, streamed.
3
+ *
4
+ * Deliberately NOT `encryptSecret`/`decryptSecret`. Those are string-in,
5
+ * string-out and correct for what they were built for: short values headed
6
+ * for a DB column. Backup artifacts are the opposite shape, and running them
7
+ * through a string API cost three full in-memory copies with an expansion
8
+ * factor at each step:
9
+ *
10
+ * read the tar 774 MB Buffer
11
+ * .toString('base64') 1032 MB string
12
+ * encrypt to hex 2064 MB string (hex is 2 chars per byte)
13
+ * JSON.stringify 2064 MB string
14
+ *
15
+ * — about 6.9 GB live for one 774 MB module, which is what OOM-killed the
16
+ * forgejo backup. Worse, it had a ceiling no amount of RAM could raise:
17
+ * hex-of-base64 is 2.67 chars per input byte against a ~2^31 max string
18
+ * length, so the old path simply could not represent a tar over ~805 MB.
19
+ *
20
+ * Here the plaintext never exists in memory at all. `createCipheriv` is a
21
+ * Transform, so file → cipher → file runs in constant memory regardless of
22
+ * artifact size, and both intermediate encodings disappear (the ciphertext
23
+ * is written as raw bytes, so an artifact is now *smaller* than its tar
24
+ * rather than 2.67x larger).
25
+ *
26
+ * On-disk format, all binary:
27
+ *
28
+ * magic 8 bytes "CELILOBK"
29
+ * version 1 byte currently 1
30
+ * iv 16 bytes
31
+ * ciphertext ... streamed
32
+ * auth tag 16 bytes trailer — GCM only produces it after final()
33
+ *
34
+ * The tag has to be a trailer because it does not exist until the last byte
35
+ * has been encrypted, and seeking back to patch a header would mean the
36
+ * writer could no longer be a plain stream. Reading it costs one 16-byte
37
+ * positional read before the stream starts.
38
+ *
39
+ * `decryptFileToFile` also reads the previous format (a JSON envelope of
40
+ * base64-of-hex). The magic bytes are the discriminator: the old writer
41
+ * always emitted JSON, so a file starting with `{` is legacy. The envelope's
42
+ * own schemaVersion cannot serve — it lives *inside* the encrypted tar and
43
+ * is unreadable until after decryption.
44
+ */
45
+
46
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
47
+ import {
48
+ appendFileSync,
49
+ closeSync,
50
+ createReadStream,
51
+ createWriteStream,
52
+ openSync,
53
+ readFileSync,
54
+ readSync,
55
+ statSync,
56
+ writeFileSync,
57
+ } from 'node:fs';
58
+ import { pipeline } from 'node:stream/promises';
59
+ import { decryptSecret } from '../secrets/encryption';
60
+ import { EncryptionEnvelopeSchema, parseJsonWithValidation } from '../validation/schemas';
61
+
62
+ const ALGORITHM = 'aes-256-gcm';
63
+
64
+ /** Identifies a streamed artifact. Legacy artifacts begin with `{`. */
65
+ export const ARTIFACT_MAGIC = Buffer.from('CELILOBK', 'ascii');
66
+
67
+ /** Bumped only for an incompatible layout change; readers reject unknown values. */
68
+ export const ARTIFACT_FORMAT_VERSION = 1;
69
+
70
+ const IV_LENGTH = 16;
71
+ const AUTH_TAG_LENGTH = 16;
72
+ const HEADER_LENGTH = ARTIFACT_MAGIC.length + 1 + IV_LENGTH;
73
+
74
+ /** Read `length` bytes at `offset` without opening a stream. */
75
+ function readBytesAt(path: string, offset: number, length: number): Buffer {
76
+ const buffer = Buffer.alloc(length);
77
+ const fd = openSync(path, 'r');
78
+ try {
79
+ readSync(fd, buffer, 0, length, offset);
80
+ } finally {
81
+ closeSync(fd);
82
+ }
83
+ return buffer;
84
+ }
85
+
86
+ /** Whether this artifact uses the streamed format rather than the JSON envelope. */
87
+ export function isStreamedArtifact(path: string): boolean {
88
+ if (statSync(path).size < ARTIFACT_MAGIC.length) return false;
89
+ return readBytesAt(path, 0, ARTIFACT_MAGIC.length).equals(ARTIFACT_MAGIC);
90
+ }
91
+
92
+ /**
93
+ * Encrypt `srcPath` to `destPath` in constant memory.
94
+ */
95
+ export async function encryptFileToFile(
96
+ srcPath: string,
97
+ destPath: string,
98
+ masterKey: Buffer,
99
+ ): Promise<void> {
100
+ const iv = randomBytes(IV_LENGTH);
101
+ const cipher = createCipheriv(ALGORITHM, masterKey, iv);
102
+
103
+ const out = createWriteStream(destPath);
104
+ out.write(Buffer.concat([ARTIFACT_MAGIC, Buffer.from([ARTIFACT_FORMAT_VERSION]), iv]));
105
+ await pipeline(createReadStream(srcPath), cipher, out);
106
+
107
+ // Available only once the stream has run final(), i.e. after the pipeline
108
+ // resolves. Appending 16 bytes is O(1) and keeps the writer a plain stream.
109
+ appendFileSync(destPath, cipher.getAuthTag());
110
+ }
111
+
112
+ /**
113
+ * Decrypt `srcPath` to `destPath`. Handles both the streamed format and the
114
+ * legacy JSON envelope.
115
+ *
116
+ * Throws on a truncated artifact, an unknown format version, or a failed
117
+ * authentication tag (wrong master key, or tampered/corrupted ciphertext).
118
+ */
119
+ export async function decryptFileToFile(
120
+ srcPath: string,
121
+ destPath: string,
122
+ masterKey: Buffer,
123
+ ): Promise<void> {
124
+ if (!isStreamedArtifact(srcPath)) {
125
+ decryptLegacyArtifact(srcPath, destPath, masterKey);
126
+ return;
127
+ }
128
+
129
+ const size = statSync(srcPath).size;
130
+ const overhead = HEADER_LENGTH + AUTH_TAG_LENGTH;
131
+ if (size < overhead) {
132
+ throw new Error(
133
+ `Backup artifact is truncated: ${size} bytes, but the format needs at least ${overhead}.`,
134
+ );
135
+ }
136
+ if (size === overhead) {
137
+ throw new Error('Backup artifact contains no data (header and auth tag only).');
138
+ }
139
+
140
+ const header = readBytesAt(srcPath, 0, HEADER_LENGTH);
141
+ const version = header[ARTIFACT_MAGIC.length];
142
+ if (version !== ARTIFACT_FORMAT_VERSION) {
143
+ throw new Error(
144
+ `Backup artifact uses format version ${version}, but this celilo understands ${ARTIFACT_FORMAT_VERSION}. Upgrade celilo to restore it.`,
145
+ );
146
+ }
147
+
148
+ const decipher = createDecipheriv(
149
+ ALGORITHM,
150
+ masterKey,
151
+ header.subarray(ARTIFACT_MAGIC.length + 1),
152
+ );
153
+ decipher.setAuthTag(readBytesAt(srcPath, size - AUTH_TAG_LENGTH, AUTH_TAG_LENGTH));
154
+
155
+ // `end` is inclusive, so the last ciphertext byte is the one before the tag.
156
+ await pipeline(
157
+ createReadStream(srcPath, { start: HEADER_LENGTH, end: size - AUTH_TAG_LENGTH - 1 }),
158
+ decipher,
159
+ createWriteStream(destPath),
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Read an artifact written before the streamed format.
165
+ *
166
+ * Reads the whole thing into memory, which is fine precisely because these
167
+ * are the artifacts the old writer produced: it could not emit one much over
168
+ * ~805 MB without dying, so the bound this function relies on is the same bug
169
+ * that motivated the new format. New artifacts never take this path.
170
+ */
171
+ function decryptLegacyArtifact(srcPath: string, destPath: string, masterKey: Buffer): void {
172
+ const envelope = parseJsonWithValidation(
173
+ readFileSync(srcPath, 'utf-8'),
174
+ EncryptionEnvelopeSchema,
175
+ 'backup artifact envelope',
176
+ );
177
+ writeFileSync(destPath, Buffer.from(decryptSecret(envelope, masterKey), 'base64'));
178
+ }
@@ -3,15 +3,7 @@
3
3
  * Orchestrates the backup workflow: create temp files, encrypt, upload to storage.
4
4
  */
5
5
 
6
- import {
7
- copyFileSync,
8
- existsSync,
9
- mkdirSync,
10
- readFileSync,
11
- rmSync,
12
- statSync,
13
- writeFileSync,
14
- } from 'node:fs';
6
+ import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
7
  import { tmpdir } from 'node:os';
16
8
  import { join } from 'node:path';
17
9
  import { eq } from 'drizzle-orm';
@@ -21,11 +13,13 @@ import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
21
13
  import { invokeHook } from '../hooks/executor';
22
14
  import { createConsoleLogger } from '../hooks/logger';
23
15
  import type { ModuleManifest } from '../manifest/schema';
24
- import { decryptSecret, encryptSecret } from '../secrets/encryption';
16
+ import { decryptSecret } from '../secrets/encryption';
25
17
  import { getOrCreateMasterKey } from '../secrets/master-key';
26
18
  import { shellEscape } from '../utils/shell';
19
+ import { encryptFileToFile } from './backup-cipher';
27
20
  import { buildManifest } from './backup-manifest';
28
21
  import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
22
+ import type { BackupSchedule } from './backup-schedule';
29
23
  import {
30
24
  createStorageProvider,
31
25
  getBackupStorage,
@@ -55,7 +49,7 @@ export interface BackupCreateResult {
55
49
  error?: string;
56
50
  }
57
51
 
58
- export type BackupSchedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
52
+ export type { BackupSchedule } from './backup-schedule';
59
53
 
60
54
  /**
61
55
  * Resolve the target storage destination
@@ -155,15 +149,13 @@ export async function createSystemStateBackup(
155
149
  const { execSync } = await import('node:child_process');
156
150
  execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
157
151
 
158
- // Encrypt the tar.
159
- const tarData = readFileSync(tarPath);
152
+ // Encrypt the tar, streamed — see backup-cipher.ts. The plaintext is
153
+ // never held in memory, so a large fleet DB can't OOM the snapshot.
160
154
  const masterKey = await getOrCreateMasterKey();
161
- const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
162
-
163
- // Write encrypted payload to temp file
164
155
  const encryptedPath = join(tempDir, 'system.enc');
165
- writeFileSync(encryptedPath, JSON.stringify(encrypted));
156
+ await encryptFileToFile(tarPath, encryptedPath, masterKey);
166
157
 
158
+ const tarSize = statSync(tarPath).size;
167
159
  const encryptedSize = statSync(encryptedPath).size;
168
160
 
169
161
  // Upload to storage
@@ -173,7 +165,7 @@ export async function createSystemStateBackup(
173
165
  completeBackup(record.id, {
174
166
  sizeBytes: encryptedSize,
175
167
  metadata: {
176
- originalSizeBytes: tarData.length,
168
+ originalSizeBytes: tarSize,
177
169
  dbPath,
178
170
  envelopeSchemaVersion: manifest.schemaVersion,
179
171
  },
@@ -411,14 +403,14 @@ export async function createModuleBackup(
411
403
  const { execSync } = await import('node:child_process');
412
404
  execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
413
405
 
414
- // Encrypt the tar
415
- const tarData = readFileSync(tarPath);
406
+ // Encrypt the tar, streamed — see backup-cipher.ts. Module artifacts run
407
+ // to hundreds of MB (forgejo's are ~774 MB); holding one in memory is
408
+ // what OOM-killed that backup.
416
409
  const masterKey = await getOrCreateMasterKey();
417
- const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
418
-
419
410
  const encryptedPath = join(tempDir, 'backup.tar.enc');
420
- writeFileSync(encryptedPath, JSON.stringify(encrypted));
411
+ await encryptFileToFile(tarPath, encryptedPath, masterKey);
421
412
 
413
+ const tarSize = statSync(tarPath).size;
422
414
  const encryptedSize = statSync(encryptedPath).size;
423
415
 
424
416
  // Upload to storage
@@ -429,7 +421,7 @@ export async function createModuleBackup(
429
421
  sizeBytes: encryptedSize,
430
422
  metadata: {
431
423
  artifactCount: hookResult.outputs.artifact_count,
432
- originalSizeBytes: tarData.length,
424
+ originalSizeBytes: tarSize,
433
425
  envelopeSchemaVersion: backupManifest.schemaVersion,
434
426
  },
435
427
  schemaVersion: dataSchemaVersion,
@@ -566,14 +558,12 @@ export async function importModuleBackup(
566
558
  const { execSync } = await import('node:child_process');
567
559
  execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(artifactDir)} .`);
568
560
 
569
- // Encrypt the tar
570
- const tarData = readFileSync(tarPath);
561
+ // Encrypt the tar, streamed — see backup-cipher.ts.
571
562
  const masterKey = await getOrCreateMasterKey();
572
- const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
573
-
574
563
  const encryptedPath = join(tempDir, 'backup.tar.enc');
575
- writeFileSync(encryptedPath, JSON.stringify(encrypted));
564
+ await encryptFileToFile(tarPath, encryptedPath, masterKey);
576
565
 
566
+ const tarSize = statSync(tarPath).size;
577
567
  const encryptedSize = statSync(encryptedPath).size;
578
568
 
579
569
  // Upload to storage
@@ -584,7 +574,7 @@ export async function importModuleBackup(
584
574
  sizeBytes: encryptedSize,
585
575
  metadata: {
586
576
  ...analyzedMetadata,
587
- originalSizeBytes: tarData.length,
577
+ originalSizeBytes: tarSize,
588
578
  },
589
579
  schemaVersion: analyzedSchemaVersion,
590
580
  });
@@ -22,8 +22,8 @@ import { join } from 'node:path';
22
22
  import { closeDb, getDb } from '../db/client';
23
23
  import { runMigrations } from '../db/migrate';
24
24
  import { backups, systemConfig } from '../db/schema';
25
- import { decryptSecret } from '../secrets/encryption';
26
25
  import { getOrCreateMasterKey } from '../secrets/master-key';
26
+ import { decryptFileToFile, encryptFileToFile } from './backup-cipher';
27
27
  import { createSystemStateBackup } from './backup-create';
28
28
  import { MANIFEST_SCHEMA_VERSION, parseManifest } from './backup-manifest';
29
29
  import { restoreSystemStateBackup } from './backup-restore';
@@ -77,15 +77,11 @@ describe('backup envelope round-trip', () => {
77
77
  const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
78
78
  expect(existsSync(artifactPath)).toBe(true);
79
79
 
80
- const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
81
80
  const masterKey = await getOrCreateMasterKey();
82
- const base64 = decryptSecret(encrypted, masterKey);
83
- const tarBytes = Buffer.from(base64, 'base64');
84
-
85
81
  const extractDir = join(dir, 'extract');
86
82
  mkdirSync(extractDir, { recursive: true });
87
83
  const tarPath = join(dir, 'envelope.tar');
88
- writeFileSync(tarPath, tarBytes);
84
+ await decryptFileToFile(artifactPath, tarPath, masterKey);
89
85
  execSync(`tar -xf '${tarPath}' -C '${extractDir}'`);
90
86
 
91
87
  // manifest.json present at root, valid, matches expectations.
@@ -129,14 +125,11 @@ describe('backup envelope round-trip', () => {
129
125
  // Create a valid backup, then poison the manifest by re-packing.
130
126
  const result = await createSystemStateBackup();
131
127
  const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
132
- const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
133
128
  const masterKey = await getOrCreateMasterKey();
134
- const tarBytes = Buffer.from(decryptSecret(encrypted, masterKey), 'base64');
135
-
136
129
  const repackDir = join(dir, 'repack');
137
130
  mkdirSync(repackDir, { recursive: true });
138
131
  const tarPath = join(dir, 'orig.tar');
139
- writeFileSync(tarPath, tarBytes);
132
+ await decryptFileToFile(artifactPath, tarPath, masterKey);
140
133
  execSync(`tar -xf '${tarPath}' -C '${repackDir}'`);
141
134
 
142
135
  // Rewrite manifest to claim kind='module'.
@@ -148,12 +141,7 @@ describe('backup envelope round-trip', () => {
148
141
  // Re-tar + re-encrypt + overwrite the storage entry.
149
142
  const repackedTarPath = join(dir, 'repacked.tar');
150
143
  execSync(`tar -cf '${repackedTarPath}' -C '${repackDir}' .`);
151
- const { encryptSecret } = await import('../secrets/encryption');
152
- const repackedEncrypted = encryptSecret(
153
- readFileSync(repackedTarPath).toString('base64'),
154
- masterKey,
155
- );
156
- writeFileSync(artifactPath, JSON.stringify(repackedEncrypted));
144
+ await encryptFileToFile(repackedTarPath, artifactPath, masterKey);
157
145
 
158
146
  const db = getDb();
159
147
  const backupRow = db.select().from(backups).all()[0];
@@ -165,14 +153,11 @@ describe('backup envelope round-trip', () => {
165
153
  it('system restore refuses an incompatible schemaVersion', async () => {
166
154
  const result = await createSystemStateBackup();
167
155
  const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
168
- const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
169
156
  const masterKey = await getOrCreateMasterKey();
170
- const tarBytes = Buffer.from(decryptSecret(encrypted, masterKey), 'base64');
171
-
172
157
  const repackDir = join(dir, 'repack-schema');
173
158
  mkdirSync(repackDir, { recursive: true });
174
159
  const tarPath = join(dir, 'orig-schema.tar');
175
- writeFileSync(tarPath, tarBytes);
160
+ await decryptFileToFile(artifactPath, tarPath, masterKey);
176
161
  execSync(`tar -xf '${tarPath}' -C '${repackDir}'`);
177
162
 
178
163
  // Bump schemaVersion to a different MAJOR version.
@@ -183,12 +168,7 @@ describe('backup envelope round-trip', () => {
183
168
 
184
169
  const repackedTarPath = join(dir, 'repacked-schema.tar');
185
170
  execSync(`tar -cf '${repackedTarPath}' -C '${repackDir}' .`);
186
- const { encryptSecret } = await import('../secrets/encryption');
187
- const repackedEncrypted = encryptSecret(
188
- readFileSync(repackedTarPath).toString('base64'),
189
- masterKey,
190
- );
191
- writeFileSync(artifactPath, JSON.stringify(repackedEncrypted));
171
+ await encryptFileToFile(repackedTarPath, artifactPath, masterKey);
192
172
 
193
173
  const db = getDb();
194
174
  const backupRow = db.select().from(backups).all()[0];