@bongos/core 1.17.3 → 1.18.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 (95) hide show
  1. package/.bongos-core.json +161 -91
  2. package/.devcontainer/healthcheck.sh +0 -0
  3. package/.devcontainer/init-firewall.sh +0 -0
  4. package/.devcontainer/post-create.sh +0 -0
  5. package/GOVERNANCE.md +1 -1
  6. package/README.md +26 -22
  7. package/bin/bongos.js +0 -0
  8. package/docker-entrypoint.sh +0 -0
  9. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +94 -0
  10. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +43 -0
  11. package/docs/adr/README.md +3 -0
  12. package/docs/api/openapi.json +4 -345
  13. package/docs/api-reference.md +5 -17
  14. package/docs/file-map.md +7 -25
  15. package/docs/onboarding/diagrams/05-versioning.mmd +4 -6
  16. package/docs/onboarding/diagrams/assertions.json +5 -9
  17. package/modules/hall-ui/public/builders.js +37 -0
  18. package/modules/hall-ui/public/goals-page.js +98 -176
  19. package/modules/hall-ui/public/goals.css +183 -0
  20. package/modules/hall-ui/public/goals.html +35 -261
  21. package/modules/hall-ui/public/goals.js +7 -2
  22. package/modules/hall-ui/public/hall-widgets.js +12 -4
  23. package/modules/hall-ui/public/settings.css +355 -0
  24. package/modules/hall-ui/public/settings.html +86 -414
  25. package/modules/hall-ui/public/settings.js +96 -35
  26. package/modules/hall-ui/public/style.css +31 -0
  27. package/modules/hall-ui/public/watch.css +184 -0
  28. package/modules/hall-ui/public/watch.html +111 -285
  29. package/modules/hall-ui/public/watch.js +59 -21
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/public-landing/create.html +83 -7
  33. package/public-landing/index.html +11 -76
  34. package/scripts/cron/style_guide_audit.sh +0 -0
  35. package/scripts/gds/api.js +0 -0
  36. package/scripts/gds/architect-audit.js +0 -0
  37. package/scripts/gds/audit-authorship.sh +0 -0
  38. package/scripts/gds/audit-replay.sh +0 -0
  39. package/scripts/gds/box.js +0 -0
  40. package/scripts/gds/capture.js +0 -0
  41. package/scripts/gds/conductor-dispatch.js +0 -0
  42. package/scripts/gds/coreB-carve-proof.sh +0 -0
  43. package/scripts/gds/db-backup-nightly.sh +0 -0
  44. package/scripts/gds/db-backup-offsite.sh +0 -0
  45. package/scripts/gds/doctor.js +0 -0
  46. package/scripts/gds/grade-smoke.js +0 -0
  47. package/scripts/gds/hooks/pre-push.js +0 -0
  48. package/scripts/gds/install-git-hooks.js +0 -0
  49. package/scripts/gds/main-audit.js +0 -0
  50. package/scripts/gds/oauth-secret.js +0 -0
  51. package/scripts/gds/onboarding-analyze.js +0 -0
  52. package/scripts/gds/onboarding-ship.js +0 -0
  53. package/scripts/gds/paste-token.js +0 -0
  54. package/scripts/gds/portability-smoke-art.sh +0 -0
  55. package/scripts/gds/portability-smoke-memory.sh +0 -0
  56. package/scripts/gds/provision.js +125 -4
  57. package/scripts/gds/push-main.js +0 -0
  58. package/scripts/gds/render-ideas.js +0 -0
  59. package/scripts/gds/security-baseline-check.sh +0 -0
  60. package/scripts/gds/session.js +0 -0
  61. package/scripts/gds/ship.js +11 -1
  62. package/scripts/gds/smoke-batch-claim.sh +0 -0
  63. package/scripts/gds/smoke-batch-release.sh +0 -0
  64. package/scripts/gds/smoke-box.sh +0 -0
  65. package/scripts/gds/smoke-claim-consistency.sh +0 -0
  66. package/scripts/gds/smoke-dependencies.sh +0 -0
  67. package/scripts/gds/smoke-write.sh +0 -0
  68. package/scripts/hall-preview/fixtures/access-requests.json +20 -1
  69. package/scripts/hall-preview/fixtures/audit-log.json +68 -1
  70. package/scripts/hall-preview/fixtures/blockers__detail.json +12 -0
  71. package/scripts/hall-preview/fixtures/box__me.json +5 -0
  72. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +69 -1
  73. package/scripts/hall-preview/fixtures/builders__roster.json +48 -1
  74. package/scripts/hall-preview/fixtures/grades__by-builder.json +32 -1
  75. package/scripts/hall-preview/fixtures/inbox__detail.json +15 -0
  76. package/scripts/hall-preview/fixtures/me__art-key__own.json +6 -0
  77. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +209 -0
  78. package/scripts/hall-preview/fixtures/me__render.json +5 -0
  79. package/scripts/hall-preview/fixtures/me__skill-prefs.json +143 -0
  80. package/scripts/hall-preview/fixtures/me__sound-prefs.json +67 -0
  81. package/scripts/hall-preview/fixtures/me__wandering.json +43 -0
  82. package/scripts/hall-preview/fixtures/override-requests.json +15 -1
  83. package/scripts/hall-preview/fixtures/security__docs.json +10 -1
  84. package/scripts/hall-preview/fixtures/security__reports.json +31 -1
  85. package/scripts/hall-preview/fixtures/sessions__search.json +9 -1
  86. package/scripts/hall-preview/server.js +18 -1
  87. package/scripts/migrate.sh +0 -0
  88. package/scripts/migrate_cost_ledger.sh +0 -0
  89. package/scripts/render-diagrams.sh +0 -0
  90. package/scripts/security/dep-audit.js +0 -0
  91. package/scripts/setup-builder.js +0 -0
  92. package/scripts/setup-builder.sh +0 -0
  93. package/scripts/snapshots/maybe-render-on-deploy.sh +0 -0
  94. package/src/module-api.js +20 -1
  95. package/tests/provision.mjs +69 -0
@@ -0,0 +1,67 @@
1
+ {
2
+ "prefs": {},
3
+ "effective": [
4
+ {
5
+ "sound": "chime",
6
+ "label": "Chime",
7
+ "desc": "When Claude finishes a turn",
8
+ "default_enabled": true,
9
+ "override_enabled": null,
10
+ "enabled": true
11
+ },
12
+ {
13
+ "sound": "alert",
14
+ "label": "Alert",
15
+ "desc": "When Claude needs your input",
16
+ "default_enabled": true,
17
+ "override_enabled": null,
18
+ "enabled": true
19
+ },
20
+ {
21
+ "sound": "ship",
22
+ "label": "Ship",
23
+ "desc": "When a task ships",
24
+ "default_enabled": true,
25
+ "override_enabled": null,
26
+ "enabled": true
27
+ },
28
+ {
29
+ "sound": "triumph",
30
+ "label": "Triumph",
31
+ "desc": "When a big task ships",
32
+ "default_enabled": true,
33
+ "override_enabled": null,
34
+ "enabled": true
35
+ },
36
+ {
37
+ "sound": "fanfare",
38
+ "label": "Fanfare",
39
+ "desc": "When you earn a rank promotion",
40
+ "default_enabled": true,
41
+ "override_enabled": null,
42
+ "enabled": true
43
+ }
44
+ ],
45
+ "effective_map": {
46
+ "chime": true,
47
+ "alert": true,
48
+ "ship": true,
49
+ "triumph": true,
50
+ "fanfare": true
51
+ },
52
+ "volume": 100,
53
+ "defaults": {
54
+ "chime": true,
55
+ "alert": true,
56
+ "ship": true,
57
+ "triumph": true,
58
+ "fanfare": true
59
+ },
60
+ "known_sounds": [
61
+ "chime",
62
+ "alert",
63
+ "ship",
64
+ "triumph",
65
+ "fanfare"
66
+ ]
67
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "rank": "metic",
3
+ "levels": [
4
+ {
5
+ "n": 0,
6
+ "key": "locked",
7
+ "label": "Locked",
8
+ "blurb": "Strictly the claimed task. Off-task notes are filed silently, never raised.",
9
+ "allowed": true
10
+ },
11
+ {
12
+ "n": 1,
13
+ "key": "focused",
14
+ "label": "Focused",
15
+ "blurb": "On task, full in-task initiative; at most one captured follow-up at the end.",
16
+ "allowed": true
17
+ },
18
+ {
19
+ "n": 2,
20
+ "key": "balanced",
21
+ "label": "Balanced",
22
+ "blurb": "May surface a high-value tangent mid-task as \"capture or pursue?\" \u2014 your call.",
23
+ "allowed": true
24
+ },
25
+ {
26
+ "n": 3,
27
+ "key": "exploratory",
28
+ "label": "Exploratory",
29
+ "blurb": "A thinking partner \u2014 actively surfaces adjacent work and investigations.",
30
+ "allowed": true
31
+ }
32
+ ],
33
+ "default_level": 2,
34
+ "default_key": "balanced",
35
+ "max_level": 3,
36
+ "max_key": "exploratory",
37
+ "override_level": null,
38
+ "effective_level": 2,
39
+ "effective_key": "balanced",
40
+ "effective_label": "Balanced",
41
+ "contract": "Wandering: Balanced. Stay anchored to your claim, but if you hit a genuinely high-value or blocking tangent you may surface it briefly and ask \"capture or pursue?\" \u2014 leave the call to the user. Note anomalies as observations; don't rabbit-hole.",
42
+ "inject": false
43
+ }
@@ -1 +1,15 @@
1
- { "requests": [] }
1
+ {
2
+ "requests": [
3
+ {
4
+ "id": "12",
5
+ "task_id": "777",
6
+ "task_title": "Synthetic: grader bypass ask for preview",
7
+ "requester_display_name": "Preview Builder",
8
+ "requester_login": "preview-builder",
9
+ "rationale": "Grader outage window \u2014 synthetic row.",
10
+ "credits_reward": 40,
11
+ "created_at": "2026-07-10T12:00:00.000Z",
12
+ "status": "open"
13
+ }
14
+ ]
15
+ }
@@ -1 +1,10 @@
1
- { "docs": [] }
1
+ {
2
+ "docs": [
3
+ {
4
+ "title": "Synthetic engagement report",
5
+ "slug": "synthetic-engagement",
6
+ "updated_at": "2026-07-01T10:00:00.000Z",
7
+ "bytes": 4096
8
+ }
9
+ ]
10
+ }
@@ -1 +1,31 @@
1
- { "reports": [], "statuses": [] }
1
+ {
2
+ "reports": [
3
+ {
4
+ "id": "31",
5
+ "severity": "high",
6
+ "title": "Synthetic: rate-limit missing on preview endpoint",
7
+ "target": "api/preview",
8
+ "status": "open"
9
+ },
10
+ {
11
+ "id": "32",
12
+ "severity": "medium",
13
+ "title": "Synthetic: verbose error leaks stack trace",
14
+ "target": "api/sample",
15
+ "status": "confirmed"
16
+ },
17
+ {
18
+ "id": "33",
19
+ "severity": "low",
20
+ "title": "Synthetic: cache header on static asset",
21
+ "target": "assets",
22
+ "status": "fixed"
23
+ }
24
+ ],
25
+ "statuses": [
26
+ "open",
27
+ "confirmed",
28
+ "fixed",
29
+ "disputed"
30
+ ]
31
+ }
@@ -1 +1,9 @@
1
- { "totals": {}, "sessions": [] }
1
+ {
2
+ "totals": {
3
+ "total": 214,
4
+ "builders": 6,
5
+ "tokens": 48200000,
6
+ "tool_errors": 12
7
+ },
8
+ "sessions": []
9
+ }
@@ -19,6 +19,8 @@
19
19
  // window.__MODULES__ (verifies moduleOn() gating)
20
20
  // --empty serve every fixture with its arrays emptied — the
21
21
  // empty-state screenshot pass
22
+ // --rank archon override the /me fixture's builder.rank so the Archon-gated
23
+ // pages (watch, harbor, gate) render their content in preview
22
24
  //
23
25
  // Browse http://builders.localhost:<port>/ — Chromium resolves *.localhost,
24
26
  // and the `builders.` host prefix exercises the real subdomain serving path.
@@ -39,6 +41,11 @@ function arg(name) {
39
41
  }
40
42
  const PORT = Number(arg('--port')) || 4600;
41
43
  const EMPTY = process.argv.includes('--empty');
44
+ // --rank: view the hall as a different rank — the /me fixture's builder.rank is
45
+ // overridden in the response, so the Archon-gated pages (watch, harbor, gate)
46
+ // render their real content instead of the sealed notice. Preview-only theatre:
47
+ // the real pages are server-gated (requireArchonPage / ADR 0016).
48
+ const RANK_OVERRIDE = arg('--rank');
42
49
  const MODULES_OFF = String(arg('--modules-off') || '').split(',').map((s) => s.trim()).filter(Boolean);
43
50
  // --modules-on: force-report modules as enabled — a dev box's local config
44
51
  // often lacks prod's instance modules (provisioning, dev-box), and their
@@ -68,6 +75,10 @@ const FIXTURE_FALLBACKS = [
68
75
  [/^goals\/\d+\/requests$/, 'goals__requests'],
69
76
  [/^goals\/\d+$/, 'goals__detail'],
70
77
  [/^tasks\/\d+$/, 'tasks__detail'],
78
+ // the #/idea/N + #/blocker/N deep-link overlay (task-detail.js) — one record
79
+ // fixture serves any id so the overlay seam assertion renders real content.
80
+ [/^inbox\/\d+$/, 'inbox__detail'],
81
+ [/^blockers\/\d+$/, 'blockers__detail'],
71
82
  [/^analytics\/builder\/\d+$/, 'analytics__builder'],
72
83
  [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
73
84
  ];
@@ -134,7 +145,13 @@ app.use((req, res, next) => {
134
145
  console.log(` no fixture: ${apiPath} (add scripts/hall-preview/fixtures/${apiPath.replace(/\//g, '__')}.json)`);
135
146
  return res.status(404).json({ error: { code: 'no_fixture', message: apiPath } });
136
147
  }
137
- res.json(EMPTY ? emptied(fix) : fix);
148
+ let payload = EMPTY ? emptied(fix) : fix;
149
+ if (RANK_OVERRIDE && apiPath === 'me') {
150
+ payload = JSON.parse(JSON.stringify(payload));
151
+ if (payload.builder) payload.builder.rank = RANK_OVERRIDE;
152
+ else if ('rank' in payload) payload.rank = RANK_OVERRIDE;
153
+ }
154
+ res.json(payload);
138
155
  });
139
156
 
140
157
  // The hall pages, pre-auth-gate, through the real seams: boot-time stamped
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/src/module-api.js CHANGED
@@ -199,8 +199,27 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
199
199
  // plus a THROWAWAY `updateCanary` marker removed in the phase-E follow-up. A
200
200
  // backwards-compatible additive change with no API removal is exactly semver
201
201
  // PATCH. Every `^1.x` / `^1.17` module pin is satisfied by 1.17.2.
202
+ // 1.17.3 — task 2183 (R89 cutover prereq): republish so the packaged core carries the
203
+ // F6 migrate.sh fix (task 2180). Post-split, `scripts/migrate.sh` cd'd into the
204
+ // installed @bongos/core package before resolving the instance root, so a literal
205
+ // `npm run migrate` read the core's VANILLA module config and SILENTLY skipped
206
+ // every host + flag-gated module migration (rehearsal: 115 rows, no game schema;
207
+ // from the instance cwd, 121). 2180 fixed the script to resolve INSTANCE_ROOT from
208
+ // the invocation cwd (INIT_CWD/$PWD → the instance's bongos.config.json) and added
209
+ // a DB-free `--print-roots` regression guard. Published 1.17.2 PREDATES 2180, so a
210
+ // consumer pinning 1.17.2 would still skip host migrations — hence a PATCH republish
211
+ // at the F6-inclusive main. A functional-file bug fix with no doorway export change
212
+ // is exactly semver PATCH. Every `^1.x` / `^1.17` module pin is satisfied by 1.17.3.
213
+ // 1.18.0 — task 2190 (R89 follow-up): first version cut from the cloud-bongos AUTHORITATIVE
214
+ // source repo. Re-seeds the standalone core (which had frozen at the 2026-07-03
215
+ // v1.14.0 extraction while core dev continued inside amazonprimea through 1.17.3)
216
+ // from amazonprimea's last-with-core commit 345494f2. Content is 1.17.3 PLUS the
217
+ // builders'-hall UI redesign (developed in amazonprimea as tasks 2171/2172/2173/2184) that
218
+ // post-dates the published 1.17.3 tarball — a new user-facing feature set with no
219
+ // doorway export removal, so semver MINOR. NOT yet published to npm (that is the
220
+ // follow-on release task); amazonprimea still runs the vendored 1.17.3.
202
221
  // ---------------------------------------------------------------------------
203
- const CORE_VERSION = '1.17.2'; // see the changelog above — 1.17.2 is the task-2148 canary upgrade-proof bump (adds /version coreVersion; no doorway export change)
222
+ const CORE_VERSION = '1.18.0'; // see the changelog above — 1.18.0 is the first cut from the cloud-bongos authoritative source (re-seed task 2190); includes the builders'-hall UI redesign beyond published 1.17.3
204
223
 
205
224
  // A namespaced logger so a module's log lines are attributable + consistent.
206
225
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -100,6 +100,35 @@ t('standalone step generators: root under standaloneBase + pull / npm-ci / insta
100
100
  assert.match(P.standaloneMigrateCmd(inst), /PGDATABASE=mercury MEDUSA_GDS_ONLY=1 bash node_modules\/@bongos\/core\/scripts\/migrate\.sh/);
101
101
  });
102
102
 
103
+ // ── nightly DB backup units (task 2213 — productizes the manual 2210 fix) ──────
104
+ t('backup units: per-instance names + the DB set on the unit (fixes the task-2210 unset GDS_BACKUP_DB)', () => {
105
+ assert.equal(P.backupUnitName(coTenant), 'mercury-db-backup');
106
+ assert.equal(P.backupServicePath(coTenant), '/etc/systemd/system/mercury-db-backup.service');
107
+ assert.equal(P.backupTimerPath(coTenant), '/etc/systemd/system/mercury-db-backup.timer');
108
+ const svc = P.backupService(coTenant);
109
+ // the DB is pinned on the unit → no fallback to the nonexistent amazonprimea default
110
+ assert.match(svc, /Environment=GDS_BACKUP_DB=mercury/);
111
+ assert.match(svc, /Environment=GDS_BACKUP_DIR=\/var\/backups\/gds/);
112
+ assert.match(svc, new RegExp(`User=${reEsc('lars')}`), 'runs as the app user (mirrors serviceUnit)');
113
+ assert.match(svc, /ReadWritePaths=\/var\/backups\/gds/, 'the only writable path is the backup dir');
114
+ const timer = P.backupTimer(coTenant);
115
+ assert.match(timer, /OnCalendar=\*-\*-\* 04:00:00 UTC/);
116
+ assert.match(timer, /Unit=mercury-db-backup\.service/, 'the timer drives the per-instance service');
117
+ });
118
+
119
+ t('backupScriptPath resolves the CONSUMER LAYOUT, never the old /home/lars/amazonprimea monorepo path', () => {
120
+ // standalone → the script inside the instance's OWN pinned @bongos/core
121
+ const standalone = { ...coTenant, hosting_shape: 'standalone' };
122
+ const sPath = P.backupScriptPath(standalone);
123
+ assert.ok(sPath.startsWith(P.standaloneRoot(standalone) + '/'), 'under the instance root');
124
+ assert.match(sPath, /node_modules\/@bongos\/core\/scripts\/gds\/db-backup-nightly\.sh$/);
125
+ // co-tenant → this control-plane checkout's scripts/gds (mirrors serviceUnit's REPO_ROOT split)
126
+ assert.match(P.backupScriptPath(coTenant), /\/scripts\/gds\/db-backup-nightly\.sh$/);
127
+ // the regression this whole task fixes: NEVER the hardcoded old monorepo path
128
+ assert.doesNotMatch(P.backupService(standalone), /\/home\/lars\/amazonprimea/);
129
+ assert.doesNotMatch(P.backupService(coTenant), /\/home\/lars\/amazonprimea/);
130
+ });
131
+
103
132
  t('webEnvBody: OAuth creds use the LOADED pack\'s envPrefix (not the slug) + brand/port/db', () => {
104
133
  const b = P.webEnvBody(coTenant); // coTenant.domain === null
105
134
  assert.match(b, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_GITHUB_CLIENT_ID=`));
@@ -280,6 +309,27 @@ await ta('provisionInstance(co-tenant) composes db → migrate → service → h
280
309
  assert.ok(writes.some((w) => /\/mercury\/web\.env$/.test(w)), 'wrote web.env');
281
310
  assert.ok(writes.some((w) => /\/mercury\.service$/.test(w)), 'wrote systemd unit');
282
311
  assert.ok(writes.some((w) => /\/mercury\.caddy$/.test(w)), 'wrote per-instance Caddy snippet');
312
+ // task 2213: EVERY instance is born with backups — dir + per-instance timer/service
313
+ assert.match(joined, /install -d -o lars -g lars -m 0750 \/var\/backups\/gds/, 'creates the backup dir');
314
+ assert.match(joined, /systemctl enable --now mercury-db-backup\.timer/, 'enables the per-instance backup timer');
315
+ assert.ok(writes.some((w) => /\/mercury-db-backup\.service$/.test(w)), 'wrote backup service unit');
316
+ assert.ok(writes.some((w) => /\/mercury-db-backup\.timer$/.test(w)), 'wrote backup timer unit');
317
+ // the backup timer is enabled AFTER the dir exists (else the first run exits 2)
318
+ assert.ok(idx(/install -d .*\/var\/backups\/gds/) < idx(/enable --now mercury-db-backup\.timer/), 'dir before timer enable');
319
+ });
320
+
321
+ await ta('provisionInstance(standalone): the backup unit ExecStart points at the instance\'s OWN pinned core (consumer layout)', async () => {
322
+ const { deps, writes } = recorder();
323
+ // capture the unit BODIES, not just paths, so we can assert the ExecStart path
324
+ const bodies = {};
325
+ deps.writeFile = (p, body) => { writes.push(p); bodies[p] = body; return { ok: true }; };
326
+ const inst = { ...coTenant, hosting_shape: 'standalone', target_ref: 'lars589/mercury' };
327
+ await P.provisionInstance(inst, deps);
328
+ const svc = bodies[P.backupServicePath(inst)];
329
+ assert.ok(svc, 'wrote the backup service unit');
330
+ const root = P.standaloneRoot(inst);
331
+ assert.match(svc, new RegExp('ExecStart=/bin/bash ' + reEsc(root) + '/node_modules/@bongos/core/scripts/gds/db-backup-nightly\\.sh'));
332
+ assert.match(svc, /Environment=GDS_BACKUP_DB=mercury/);
283
333
  });
284
334
 
285
335
  await ta('provisionInstance(standalone) SCAFFOLDS its repo, then syncs (pull + npm ci) + migrates from the instance root', async () => {
@@ -427,6 +477,22 @@ await ta('provisionInstance(dedicated) does NOT run local createdb/migrate/seed
427
477
  assert.ok(!cmds.some((c) => /INSERT INTO versions/.test(c)), 'dedicated does not run the version seed locally (it rides cloud-init)');
428
478
  });
429
479
 
480
+ await ta('dedicatedUserData bakes the nightly backup: unit files (b64) + dir + timer enable (task 2213)', async () => {
481
+ const inst = { ...coTenant, hosting_shape: 'dedicated' };
482
+ const cloudInit = P.dedicatedUserData(inst);
483
+ // the two unit files land via write_files (base64, so no YAML block-scalar escaping)
484
+ assert.match(cloudInit, /write_files:/);
485
+ assert.match(cloudInit, new RegExp('path: ' + reEsc(P.backupServicePath(inst))));
486
+ assert.match(cloudInit, new RegExp('path: ' + reEsc(P.backupTimerPath(inst))));
487
+ assert.match(cloudInit, /encoding: b64/);
488
+ // the base64 content round-trips to a real unit carrying the right DB
489
+ const b64 = cloudInit.match(/content: ([A-Za-z0-9+/=]+)/)[1];
490
+ assert.match(Buffer.from(b64, 'base64').toString('utf8'), /Environment=GDS_BACKUP_DB=mercury/);
491
+ // and the dir is created + the timer enabled on first boot
492
+ assert.match(cloudInit, /install -d -o lars -g lars -m 0750 \/var\/backups\/gds/);
493
+ assert.match(cloudInit, /enable --now mercury mercury-db-backup\.timer/);
494
+ });
495
+
430
496
  console.log('\nDNS UPSERT composition (P4, CONFIG override):');
431
497
 
432
498
  // A recorder that also captures each exec's env (so we can assert the DNS hook env).
@@ -739,6 +805,9 @@ await ta('teardownInstance(privileged): systemctl + rm-under-/etc are sudo-prefi
739
805
  assert.match(joined, /sudo systemctl disable --now mercury/);
740
806
  assert.match(joined, /sudo rm -f \/etc\/systemd\/system\/mercury\.service/);
741
807
  assert.match(joined, /sudo systemctl daemon-reload/);
808
+ // task 2213: the backup units come down too (the dumps + dir are left in place)
809
+ assert.match(joined, /sudo systemctl disable --now mercury-db-backup\.timer/);
810
+ assert.match(joined, /sudo rm -f \/etc\/systemd\/system\/mercury-db-backup\.service \/etc\/systemd\/system\/mercury-db-backup\.timer/);
742
811
  });
743
812
 
744
813
  await ta('teardownInstance(default): NO sudo (bare, unchanged)', async () => {