@underpostnet/cyberia 3.3.77 → 3.4.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 (105) hide show
  1. package/.env.example +1 -0
  2. package/.github/workflows/coverall.cyberia.ci.yml +12 -3
  3. package/.github/workflows/cyberia-client.cd.yml +2 -1
  4. package/.github/workflows/cyberia-server.cd.yml +2 -1
  5. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  6. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  7. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  8. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  9. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  10. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  11. package/.github/workflows/engine-cyberia.ci.yml +6 -0
  12. package/.github/workflows/ghpkg.ci.yml +19 -2
  13. package/.github/workflows/gitlab.ci.yml +8 -6
  14. package/.github/workflows/hardhat.ci.yml +4 -1
  15. package/.github/workflows/npmpkg.ci.yml +9 -5
  16. package/.github/workflows/publish.ci.yml +21 -12
  17. package/.github/workflows/publish.cyberia.ci.yml +25 -14
  18. package/.github/workflows/pwa-microservices-template-page.cd.yml +4 -2
  19. package/CHANGELOG.md +75 -140
  20. package/CLI-HELP.md +28 -1
  21. package/Dockerfile +1 -1
  22. package/Dockerfile.dev +1 -1
  23. package/Dockerfile.test +1 -1
  24. package/README.md +1 -1
  25. package/bin/build.js +2 -4
  26. package/bin/cyberia.js +4 -4
  27. package/bin/index.js +4 -4
  28. package/compose.env +1 -0
  29. package/conf.js +1 -1042
  30. package/deploy/cyberia-client/deploy.sh +13 -11
  31. package/deploy/cyberia-server/deploy.sh +13 -11
  32. package/deploy/dd-cyberia/sync-deploy.sh +7 -28
  33. package/deploy/lib/config.sh +1 -1
  34. package/deploy/lib/github-actions-logging.sh +3 -2
  35. package/deploy/pwa-microservices-template/deploy.sh +7 -1
  36. package/deployment.yaml +1 -1
  37. package/docker-compose.yml +7 -7
  38. package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1 -1
  39. package/docs/coverage/cyberia/api/cyberia-instance/index.html +1 -1
  40. package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +1 -1
  41. package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +1 -1
  42. package/docs/coverage/cyberia/api/object-layer/index.html +1 -1
  43. package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1 -1
  44. package/docs/coverage/cyberia/index.html +1 -1
  45. package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1 -1
  46. package/docs/coverage/cyberia/projects/cyberia/index.html +1 -1
  47. package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +1 -1
  48. package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +1 -1
  49. package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +1 -1
  50. package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +1 -1
  51. package/hardhat/package-lock.json +6 -6
  52. package/hardhat/package.json +2 -2
  53. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  54. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  55. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  56. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  57. package/package.json +19 -17
  58. package/scripts/test-monitor.sh +1 -1
  59. package/src/api/file/file.router.js +7 -1
  60. package/src/cli/baremetal.js +1 -1
  61. package/src/cli/docker-compose.js +1 -1
  62. package/src/cli/index.js +36 -0
  63. package/src/cli/kubectl.js +4 -3
  64. package/src/cli/release.js +2 -0
  65. package/src/cli/repository.js +7 -2
  66. package/src/cli/run.js +1 -1
  67. package/src/cli/secrets.js +393 -200
  68. package/src/cli/socketsecurity.js +260 -0
  69. package/src/client/components/core/CssCore.js +3 -0
  70. package/src/client/components/core/Modal.js +160 -36
  71. package/src/client/components/core/SearchBox.js +0 -1
  72. package/src/client/components/underpost/AppShellUnderpost.js +13 -1
  73. package/src/client/components/underpost/CssUnderpost.js +15 -2
  74. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +8 -0
  75. package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
  76. package/src/client/public/cyberia-docs/OFF-CHAIN-ECONOMY.md +54 -56
  77. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  78. package/src/client/public/cyberia-docs/UNDERPOST-PLATFORM.md +30 -29
  79. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  80. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +54 -25
  81. package/src/client/ssr/head/Css.js +12 -10
  82. package/src/client/ssr/views/Cyberia404.js +6 -10
  83. package/src/client/ssr/views/NotFound.js +75 -0
  84. package/src/client-builder/client-bundle.js +2 -2
  85. package/src/index.js +13 -1
  86. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  87. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  88. package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
  89. package/src/runtime/engine-cyberia/compose.env +1 -0
  90. package/src/runtime/engine-cyberia/docker-compose.yml +7 -7
  91. package/src/server/build/catalog.js +1 -0
  92. package/src/server/build/execution.js +19 -5
  93. package/src/server/build/package.js +166 -3
  94. package/src/server/build/testing.js +2 -1
  95. package/src/server/runtime/conf.js +21 -0
  96. package/src/server/runtime/config-scope.js +2 -0
  97. package/src/server/runtime/process.js +4 -3
  98. package/src/server/security/socketsecurity.js +1735 -0
  99. package/test/integration/infra/1-security/config-scope.test.js +3 -0
  100. package/test/integration/infra/1-security/socketsecurity.test.js +1041 -0
  101. package/test/integration/infra/1-security/sops-secret-store.test.js +201 -71
  102. package/test/unit/build-template.test.js +24 -0
  103. package/test/unit/deploy-log-table.test.js +38 -0
  104. package/test/unit/execution-profiles.test.js +1 -0
  105. package/test/unit/package.test.js +118 -0
@@ -205,9 +205,24 @@ describe('sops encrypted secret store', () => {
205
205
  });
206
206
  });
207
207
 
208
- describe('GIT_AUTH_TOKEN rotation', () => {
208
+ describe('GitHub Actions secret rotation', () => {
209
209
  const secretsSource = fs.readFileSync(new URL('../../../../src/cli/secrets.js', import.meta.url), 'utf8');
210
+ const methodBody = (signature) => {
211
+ const start = secretsSource.indexOf(signature);
212
+ expect(start, `${signature} not found`).to.be.greaterThan(-1);
213
+ return secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
214
+ };
210
215
  const saved = {};
216
+ const TARGET_KEYS = [
217
+ 'ENGINE_SRC_REPO',
218
+ 'ENGINE_SRC_PRIVATE_REPO',
219
+ 'GITHUB_USERNAME',
220
+ 'GITHUB_TARGET_TYPE',
221
+ 'GITHUB_ORG_NAME',
222
+ 'GITHUB_SECRET_TOKEN',
223
+ 'GIT_AUTH_TOKEN',
224
+ 'NPM_TOKEN',
225
+ ];
211
226
 
212
227
  // `instanceRepos` reads `./engine-private/conf/<deployId>/conf.instances.json`, and
213
228
  // engine-private is a private repository absent from a CI checkout. A real deploy's
@@ -232,9 +247,8 @@ describe('sops encrypted secret store', () => {
232
247
  });
233
248
 
234
249
  beforeEach(() => {
235
- for (const key of ['ENGINE_SRC_REPO', 'ENGINE_SRC_PRIVATE_REPO', 'GITHUB_USERNAME', 'GIT_AUTH_TOKEN'])
236
- saved[key] = process.env[key];
237
- for (const key of Object.keys(saved)) delete process.env[key];
250
+ for (const key of TARGET_KEYS) saved[key] = process.env[key];
251
+ for (const key of TARGET_KEYS) delete process.env[key];
238
252
  });
239
253
 
240
254
  afterEach(() => {
@@ -243,11 +257,57 @@ describe('sops encrypted secret store', () => {
243
257
  else process.env[key] = value;
244
258
  });
245
259
 
260
+ describe('target resolution', () => {
261
+ it('defaults to the user account, at the repository level, under the gh login', () => {
262
+ expect(sops().githubTargetFactory({})).to.deep.equal({
263
+ type: 'user',
264
+ owner: 'underpostnet',
265
+ level: 'repo',
266
+ token: '',
267
+ });
268
+ });
269
+
270
+ it('takes the user owner from ENGINE_SRC_REPO, then GITHUB_USERNAME', () => {
271
+ process.env.GITHUB_USERNAME = 'someone';
272
+ expect(sops().githubTargetFactory({}).owner).to.equal('someone');
273
+ process.env.ENGINE_SRC_REPO = 'acme/engine-lampp';
274
+ expect(sops().githubTargetFactory({}).owner).to.equal('acme');
275
+ expect(sops().githubTargetFactory({ owner: 'explicit' }).owner).to.equal('explicit');
276
+ });
277
+
278
+ it('addresses an organization through GITHUB_TARGET_TYPE and GITHUB_ORG_NAME', () => {
279
+ process.env.GITHUB_TARGET_TYPE = 'org';
280
+ process.env.GITHUB_ORG_NAME = 'acme-org';
281
+ process.env.GITHUB_SECRET_TOKEN = 'github_pat_organizationWriter0000000000';
282
+ expect(sops().githubTargetFactory({})).to.deep.equal({
283
+ type: 'org',
284
+ owner: 'acme-org',
285
+ level: 'repo',
286
+ token: 'github_pat_organizationWriter0000000000',
287
+ });
288
+ expect(sops().githubTargetFactory({ level: 'org' }).level).to.equal('org');
289
+ // --args wins over the environment, so one run can address the other account type.
290
+ expect(sops().githubTargetFactory({ target: 'user' }).owner).to.equal('underpostnet');
291
+ });
292
+
293
+ it('refuses an organization target without an organization, and an organization level without an organization target', () => {
294
+ expect(() => sops().githubTargetFactory({ target: 'org' })).to.throw(/GITHUB_ORG_NAME/);
295
+ expect(() => sops().githubTargetFactory({ level: 'org' })).to.throw(/target=org/);
296
+ expect(() => sops().githubTargetFactory({ target: 'team' })).to.throw(/user or org/);
297
+ expect(() => sops().githubTargetFactory({ level: 'enterprise' })).to.throw(/repo or org/);
298
+ });
299
+
300
+ it('hands GITHUB_SECRET_TOKEN to gh as GH_TOKEN and otherwise leaves the login alone', () => {
301
+ expect(sops().ghEnvFactory({ token: 'github_pat_x' })).to.include({ GH_TOKEN: 'github_pat_x' });
302
+ expect(sops().ghEnvFactory({ token: '' })).to.equal(process.env);
303
+ });
304
+ });
305
+
246
306
  it('targets the private conf repo, both engine sources and the package mirror of a deploy', () => {
247
307
  // Same naming `deploy_id_from_repo` resolves in deploy/lib/host.sh, reached through the
248
308
  // repository domain rather than re-derived here. dd-core declares no instances, so this is
249
309
  // the naming-derived set on its own.
250
- expect(sops().gitAuthTokenTargets({ deployId: 'dd-core', owner: 'acme' })).to.deep.equal([
310
+ expect(sops().actionsSecretTargets({ deployId: 'dd-core', owner: 'acme' })).to.deep.equal([
251
311
  'acme/engine-core-private',
252
312
  'acme/engine-core',
253
313
  'acme/engine-test-core',
@@ -257,26 +317,45 @@ describe('sops encrypted secret store', () => {
257
317
 
258
318
  it('keeps an instance repository under the owner it declares, not the derived one', () => {
259
319
  // metadata.repository is an explicit slug, so it outranks the --args owner used for naming.
260
- expect(sops().gitAuthTokenTargets({ deployId: FIXTURE_DEPLOY, owner: 'acme' })).to.include(
320
+ expect(sops().actionsSecretTargets({ deployId: FIXTURE_DEPLOY, owner: 'acme' })).to.include(
261
321
  FIXTURE_INSTANCE_REPOS[0],
262
322
  );
263
323
  });
264
324
 
325
+ it('re-owns every derived and declared repository under an organization target', () => {
326
+ process.env.ENGINE_SRC_REPO = 'underpostnet/engine-fixture-sops';
327
+ process.env.ENGINE_SRC_PRIVATE_REPO = 'underpostnet/engine-private';
328
+ const targets = sops().actionsSecretTargets({ deployId: FIXTURE_DEPLOY, target: 'org', owner: 'acme-org' });
329
+ expect(targets).to.deep.equal([
330
+ 'acme-org/engine-fixture-sops-private',
331
+ 'acme-org/engine-fixture-sops',
332
+ 'acme-org/engine-test-fixture-sops',
333
+ 'acme-org/engine-ghpkg-fixture-sops',
334
+ 'acme-org/fixture-sops-server',
335
+ 'acme-org/fixture-sops-client',
336
+ 'acme-org/engine-private',
337
+ ]);
338
+ // An explicit extra target is taken as written.
339
+ expect(sops().actionsSecretTargets({ target: 'org', owner: 'acme-org', repos: 'other/one' })).to.include(
340
+ 'other/one',
341
+ );
342
+ });
343
+
265
344
  it('includes the ghpkg package mirror the engine repository publishes into', () => {
266
345
  // .github/workflows/ghpkg.ci.yml builds engine-ghpkg-<conf_id>, whose own workflows read
267
- // the same token; the monorepo has no ghpkg of its own.
268
- expect(sops().gitAuthTokenTargets({ deployId: 'dd-cyberia', owner: 'acme' })).to.include(
346
+ // the same secrets; the monorepo has no ghpkg of its own.
347
+ expect(sops().actionsSecretTargets({ deployId: 'dd-cyberia', owner: 'acme' })).to.include(
269
348
  'acme/engine-ghpkg-cyberia',
270
349
  );
271
- expect(sops().gitAuthTokenTargets({ owner: 'acme' })).to.not.include('acme/engine-ghpkg');
350
+ expect(sops().actionsSecretTargets({ owner: 'acme' })).to.not.include('acme/engine-ghpkg');
272
351
  });
273
352
 
274
353
  it("includes every repository the deploy's instances are built from", () => {
275
354
  // dd-cyberia builds from cyberia-server and cyberia-client; rotating only the engine repos
276
- // would leave half the deploy on the previous token.
355
+ // would leave half the deploy on the previous value.
277
356
  const declared = Underpost.repo.instanceRepos(FIXTURE_DEPLOY);
278
357
  expect(declared, `${FIXTURE_DEPLOY} declares no instance repositories`).to.deep.equal(FIXTURE_INSTANCE_REPOS);
279
- expect(sops().gitAuthTokenTargets({ deployId: FIXTURE_DEPLOY })).to.include.members(declared);
358
+ expect(sops().actionsSecretTargets({ deployId: FIXTURE_DEPLOY })).to.include.members(declared);
280
359
  });
281
360
 
282
361
  it('contributes nothing for a deploy that declares no instances', () => {
@@ -285,11 +364,21 @@ describe('sops encrypted secret store', () => {
285
364
  });
286
365
 
287
366
  it('resolves the `template` meta id to the template lineage, not through conf-id naming', () => {
288
- expect(sops().gitAuthTokenTargets({ deployId: 'template', owner: 'acme' })).to.deep.equal([
367
+ expect(sops().actionsSecretTargets({ deployId: 'template', owner: 'acme' })).to.deep.equal([
289
368
  'acme/pwa-microservices-template',
290
369
  'acme/pwa-microservices-template-ghpkg',
291
370
  'acme/engine',
292
371
  ]);
372
+ // In a list too, so one rotation covers a product and the template it was built from.
373
+ expect(sops().actionsSecretTargets({ deployId: 'template|dd-core', owner: 'acme' })).to.deep.equal([
374
+ 'acme/pwa-microservices-template',
375
+ 'acme/pwa-microservices-template-ghpkg',
376
+ 'acme/engine',
377
+ 'acme/engine-core-private',
378
+ 'acme/engine-core',
379
+ 'acme/engine-test-core',
380
+ 'acme/engine-ghpkg-core',
381
+ ]);
293
382
  });
294
383
 
295
384
  it('resolves the same targets from every reference that names the deploy', () => {
@@ -300,17 +389,17 @@ describe('sops encrypted secret store', () => {
300
389
  'acme/engine-ghpkg-lampp',
301
390
  ];
302
391
  for (const reference of ['dd-lampp', 'engine-lampp', 'engine-test-lampp', 'engine-lampp-private'])
303
- expect(sops().gitAuthTokenTargets({ deployId: reference, owner: 'acme' }), reference).to.deep.equal(expected);
392
+ expect(sops().actionsSecretTargets({ deployId: reference, owner: 'acme' }), reference).to.deep.equal(expected);
304
393
  });
305
394
 
306
395
  it('falls back to the monorepo pair, deduplicated, when no deploy is named', () => {
307
- expect(sops().gitAuthTokenTargets({ owner: 'acme' })).to.deep.equal(['acme/engine-private', 'acme/engine']);
396
+ expect(sops().actionsSecretTargets({ owner: 'acme' })).to.deep.equal(['acme/engine-private', 'acme/engine']);
308
397
  });
309
398
 
310
399
  it('takes the deploy and owner from the deploy environment when none is passed', () => {
311
400
  process.env.ENGINE_SRC_REPO = 'acme/engine-test-fixture-sops';
312
401
  process.env.ENGINE_SRC_PRIVATE_REPO = 'acme/engine-private';
313
- expect(sops().gitAuthTokenTargets({})).to.deep.equal([
402
+ expect(sops().actionsSecretTargets({})).to.deep.equal([
314
403
  'acme/engine-fixture-sops-private',
315
404
  'acme/engine-fixture-sops',
316
405
  'acme/engine-test-fixture-sops',
@@ -324,7 +413,7 @@ describe('sops encrypted secret store', () => {
324
413
  // `dd` is the meta id every runner reads as "all of dd.routes"; the rotation must cover the
325
414
  // same fleet the cluster deploys, resolved through the one reader rather than a second parse.
326
415
  const routed = resolveDeployList('dd');
327
- const targets = sops().gitAuthTokenTargets({ deployId: 'dd', owner: 'acme' });
416
+ const targets = sops().actionsSecretTargets({ deployId: 'dd', owner: 'acme' });
328
417
  expect(routed.length, 'no deploy routes to fan out over').to.be.greaterThan(0);
329
418
  expect(targets).to.have.lengthOf(new Set(targets).size);
330
419
  for (const deployId of routed) {
@@ -338,7 +427,7 @@ describe('sops encrypted secret store', () => {
338
427
  });
339
428
 
340
429
  it('accepts an explicit multi-deploy list, in route-table order', () => {
341
- expect(sops().gitAuthTokenTargets({ deployId: `${FIXTURE_DEPLOY}|dd-lampp`, owner: 'acme' })).to.deep.equal([
430
+ expect(sops().actionsSecretTargets({ deployId: `${FIXTURE_DEPLOY}|dd-lampp`, owner: 'acme' })).to.deep.equal([
342
431
  'acme/engine-fixture-sops-private',
343
432
  'acme/engine-fixture-sops',
344
433
  'acme/engine-test-fixture-sops',
@@ -353,61 +442,79 @@ describe('sops encrypted secret store', () => {
353
442
 
354
443
  it('unions repositories two deploys share instead of rotating them twice', () => {
355
444
  process.env.ENGINE_SRC_PRIVATE_REPO = 'acme/engine-private';
356
- const targets = sops().gitAuthTokenTargets({ deployId: `${FIXTURE_DEPLOY}|dd-lampp`, owner: 'acme' });
445
+ const targets = sops().actionsSecretTargets({ deployId: `${FIXTURE_DEPLOY}|dd-lampp`, owner: 'acme' });
357
446
  expect(targets.filter((slug) => slug === 'acme/engine-private')).to.have.lengthOf(1);
358
447
  });
359
448
 
360
449
  it('separates extra targets on characters --args does not split on', () => {
361
- const targets = sops().gitAuthTokenTargets({ owner: 'acme', repos: 'other/one|other/two;other/three' });
450
+ const targets = sops().actionsSecretTargets({ owner: 'acme', repos: 'other/one|other/two;other/three' });
362
451
  expect(targets).to.include.members(['other/one', 'other/two', 'other/three']);
363
452
  });
364
453
 
365
454
  it('drops an unresolvable extra target instead of failing the whole rotation', () => {
366
- const targets = sops().gitAuthTokenTargets({ owner: 'acme', repos: 'not-a-slug|other/one' });
455
+ const targets = sops().actionsSecretTargets({ owner: 'acme', repos: 'not-a-slug|other/one' });
367
456
  expect(targets).to.include('other/one');
368
457
  expect(targets).to.not.include('not-a-slug');
369
458
  });
370
459
 
371
460
  it('reports the plan without contacting GitHub or writing anything on a dry run', () => {
372
- // Same reasoning as the 'GIT_AUTH_TOKEN token sources' dry-run test: the reported fields
373
- // don't depend on gh reachability, so probing is stubbed to keep this test from reaching the
374
- // real GitHub API through whatever `gh` session happens to be authenticated on this machine.
461
+ // The reported fields do not depend on gh reachability, so probing is stubbed to keep this
462
+ // test from reaching the real GitHub API through whatever `gh` session happens to be
463
+ // authenticated on this machine.
375
464
  const hasBinary = vi.spyOn(sops(), 'hasBinary').mockReturnValue(false);
465
+ const read = vi.spyOn(Underpost.host, 'read').mockReturnValue({ NPM_TOKEN: 'npm_fixture' });
376
466
  try {
377
- const report = sops().rotateGitAuthToken({ deployId: FIXTURE_DEPLOY, owner: 'acme', dryRun: true });
467
+ const report = sops().rotateActionsSecrets({
468
+ keys: 'GIT_AUTH_TOKEN|NPM_TOKEN',
469
+ deployId: FIXTURE_DEPLOY,
470
+ owner: 'acme',
471
+ dryRun: true,
472
+ });
473
+ expect(report.keys).to.deep.equal(['GIT_AUTH_TOKEN', 'NPM_TOKEN']);
378
474
  expect(report.targets).to.have.lengthOf(4 + FIXTURE_INSTANCE_REPOS.length);
379
- expect(report.rotated).to.deep.equal([]);
475
+ expect(report.written).to.deep.equal({});
380
476
  expect(report.manifest).to.equal('');
381
- expect(report.tokenSource).to.equal('');
477
+ expect(report.sources.NPM_TOKEN).to.equal('host configuration');
478
+ expect(report.target).to.include({ type: 'user', owner: 'acme', level: 'repo' });
382
479
  } finally {
383
480
  hasBinary.mockRestore();
481
+ read.mockRestore();
384
482
  }
385
483
  });
386
484
 
387
- it('takes a piped token, ahead of an inherited environment holding the outgoing one', () => {
388
- const start = secretsSource.indexOf(' stageGitAuthToken(stagePath, options = {}) {');
389
- const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
485
+ it('rejects names GitHub cannot hold, and one value spread over several keys', () => {
486
+ expect(() => sops().rotateActionsSecrets({ keys: 'GITHUB_MINE', owner: 'acme' })).to.throw(/GITHUB_ prefix/);
487
+ expect(() => sops().rotateActionsSecrets({ keys: 'bad-name', owner: 'acme' })).to.throw(/letters, digits/);
488
+ expect(() => sops().rotateActionsSecrets({ keys: '', owner: 'acme' })).to.throw(/at least one secret name/);
489
+ expect(() =>
490
+ sops().rotateActionsSecrets({ keys: 'NPM_TOKEN|NPM_USER', value: 'one', owner: 'acme', dryRun: true }),
491
+ ).to.throw(/one value/);
492
+ });
493
+
494
+ it('takes a piped value, ahead of an inherited environment holding the outgoing one', () => {
495
+ const body = methodBody(' stageActionsSecret(stagePath, key, options = {}, hostValues = {}) {');
390
496
  expect(body).to.include('stdinIsRedirected()');
391
497
  expect(body).to.include("fs.readFileSync(0, 'utf8')");
392
498
  expect(body.indexOf("source = 'piped stdin'")).to.be.lessThan(body.indexOf('} else if (inherited) {'));
499
+ // The durable host configuration is the last implicit source before a prompt.
500
+ expect(body.indexOf('} else if (inherited) {')).to.be.lessThan(body.indexOf('} else if (hostValue) {'));
393
501
  });
394
502
 
395
503
  it('reads stdin only when it is piped or redirected, never a terminal or /dev/null', () => {
396
504
  // fstat rather than isTTY: `< /dev/null` is not a TTY either, and reading it would strand
397
- // the rotation on an empty token instead of falling through to the next source.
505
+ // the rotation on an empty value instead of falling through to the next source.
398
506
  expect(secretsSource).to.include('stat.isFIFO() || stat.isFile()');
399
507
  });
400
508
 
401
509
  it('names piped stdin as the planned source without consuming it', () => {
402
- expect(sops().plannedTokenSource({ token: 'ghp_explicit' })).to.equal('--args token');
403
- expect(sops().plannedTokenSource({})).to.be.a('string').and.not.equal('');
510
+ expect(sops().plannedSecretSource('NPM_TOKEN', { value: 'npm_explicit' })).to.equal('--args value');
511
+ expect(sops().plannedSecretSource('NPM_TOKEN', {})).to.be.a('string').and.not.equal('');
404
512
  });
405
513
 
406
514
  it('treats `gh auth status` as advisory, gating on target reachability instead', () => {
407
515
  // A logged-in account whose token merely lacks an optional scope still exits non-zero here,
408
516
  // so making it the gate blocks a rotation that would have worked.
409
- const start = secretsSource.indexOf(' rotateGitAuthToken(options = {}) {');
410
- const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
517
+ const body = methodBody(' rotateActionsSecrets(options = {}) {');
411
518
  expect(body).to.include('if (!ghAuthenticated)');
412
519
  expect(body).to.not.match(/if \(!ghAuthenticated\)\s*\n?\s*throw/);
413
520
  expect(body).to.include('probed.reachable.length === 0');
@@ -421,46 +528,58 @@ describe('sops encrypted secret store', () => {
421
528
  it('names a shadowing GH_TOKEN/GITHUB_TOKEN as the likely cause of a dead credential', () => {
422
529
  // gh prefers these over the stored login, and `host load` exports GITHUB_TOKEN on every
423
530
  // engine node — so a stale one turns a working `gh auth login` into 15 unreachable targets.
531
+ // An explicit GITHUB_SECRET_TOKEN is the chosen credential, so nothing shadows it.
424
532
  expect(secretsSource).to.include("['GH_TOKEN', 'GITHUB_TOKEN'].filter(");
425
533
  expect(secretsSource).to.include('is set here, and gh uses it in preference to the account');
534
+ expect(methodBody(' rotateActionsSecrets(options = {}) {')).to.include('target.token\n ? []');
426
535
  });
427
536
 
428
- it('probes before staging the token, so an unreachable set never prompts for one', () => {
429
- const start = secretsSource.indexOf(' rotateGitAuthToken(options = {}) {');
430
- const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
431
- expect(body.indexOf('probeGitAuthTokenTargets(targets)')).to.be.lessThan(
432
- body.indexOf('stageGitAuthToken(stagePath, options)'),
537
+ it('probes before staging any value, so an unreachable set never prompts for one', () => {
538
+ const body = methodBody(' rotateActionsSecrets(options = {}) {');
539
+ expect(body.indexOf('probeActionsSecretTargets(targets, target)')).to.be.lessThan(
540
+ body.indexOf('stageActionsSecret(stagePath, key, options, hostValues)'),
433
541
  );
434
542
  });
435
543
 
436
- it('hands the token to gh on stdin, never as a command argument', () => {
437
- // A token in the command string is a token in the process table and in the command log.
438
- expect(secretsSource).to.include('gh secret set ${GIT_AUTH_TOKEN_KEY} --repo "${repo}" < "${stagePath}"');
439
- expect(secretsSource).to.not.include('gh secret set ${GIT_AUTH_TOKEN_KEY} --body');
544
+ it('hands the value to gh on stdin, never as a command argument', () => {
545
+ // A value in the command string is a value in the process table and in the command log.
546
+ expect(secretsSource).to.include('gh secret set ${key} ${scope} < "${stagePath}"');
547
+ expect(secretsSource).to.not.include('gh secret set ${key} --body');
548
+ });
549
+
550
+ it('writes one organization secret for the organization repositories and repository secrets elsewhere', () => {
551
+ const body = methodBody(' writeActionsSecret({ key, stagePath, target, repos }) {');
552
+ expect(body).to.include('--org "${target.owner}" --visibility selected --repos');
553
+ expect(body).to.include("repos.filter((slug) => slug.split('/')[0] === target.owner)");
554
+ expect(body).to.include('--repo "${repo}"');
440
555
  });
441
556
 
442
557
  it('runs the gh write under pipefail with the command kept out of the log', () => {
443
- const start = secretsSource.indexOf(' rotateGitAuthToken(options = {}) {');
444
- expect(start, 'rotateGitAuthToken() not found').to.be.greaterThan(-1);
445
- const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
558
+ const body = methodBody(' writeActionsSecret({ key, stagePath, target, repos }) {');
446
559
  expect(body).to.include("bash -c 'set -o pipefail; gh secret set");
447
560
  expect(body).to.include('disableLog: true');
448
- expect(body).to.include('shred -u "${stagePath}"');
561
+ expect(methodBody(' rotateActionsSecrets(options = {}) {')).to.include(
562
+ 'shred -u "${ACTIONS_SECRET_STAGE_DIR}"/*',
563
+ );
449
564
  });
450
565
 
451
- it('stages the token on tmpfs at mode 600 rather than through a shell heredoc', () => {
452
- expect(secretsSource).to.include("const GIT_AUTH_TOKEN_STAGE_DIR = '/dev/shm/underpost-git-auth';");
453
- const start = secretsSource.indexOf(' stageGitAuthToken(stagePath, options = {}) {');
454
- const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
566
+ it('stages every value on tmpfs at mode 600 rather than through a shell heredoc', () => {
567
+ expect(secretsSource).to.include("const ACTIONS_SECRET_STAGE_DIR = '/dev/shm/underpost-actions-secret';");
568
+ const body = methodBody(' stageActionsSecret(stagePath, key, options = {}, hostValues = {}) {');
455
569
  expect(body).to.include('writeStageFileSync(stagePath');
456
570
  // GITHUB_TOKEN is what gh authenticates with — on a rotation that is the outgoing token.
457
571
  expect(body).to.not.include('process.env.GITHUB_TOKEN');
458
572
  });
459
573
 
574
+ it('validates the token shape for GIT_AUTH_TOKEN only', () => {
575
+ expect(secretsSource).to.include('ACTIONS_SECRET_POLICIES[key]?.validate?.(value)');
576
+ expect(secretsSource).to.match(
577
+ /const ACTIONS_SECRET_POLICIES = Object\.freeze\(\{\n \[GIT_AUTH_TOKEN_KEY\]: \{/,
578
+ );
579
+ });
580
+
460
581
  it('replaces the stored manifest through the validating atomic encrypt path', () => {
461
- const start = secretsSource.indexOf(" writeGitAuthTokenManifest(token, namespace = 'default') {");
462
- expect(start, 'writeGitAuthTokenManifest() not found').to.be.greaterThan(-1);
463
- const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
582
+ const body = methodBody(" writeGitAuthTokenManifest(token, namespace = 'default') {");
464
583
  expect(body).to.include('Underpost.secret.encrypt(plaintextPath, namespace, { force: true })');
465
584
  });
466
585
 
@@ -469,15 +588,12 @@ describe('sops encrypted secret store', () => {
469
588
  expect(EXECUTION_PROFILES.HERMETIC_BUILD.permits).to.not.include('net');
470
589
  });
471
590
 
472
- it('rejects a credential the verb does not rotate, and a rotation with no target', () => {
473
- expect(() => sops().rotate({ args: { secret: 'AWS_SECRET_ACCESS_KEY' } })).to.throw(
474
- /GIT_AUTH_TOKEN is the only one it rotates/,
475
- );
591
+ it('rejects a rotation with no target', () => {
476
592
  expect(() => sops().rotate({ args: {} })).to.throw(/rotate requires a target/);
477
593
  });
478
594
  });
479
595
 
480
- describe('GIT_AUTH_TOKEN token sources', () => {
596
+ describe('Actions secret value sources', () => {
481
597
  const saved = {};
482
598
 
483
599
  beforeEach(() => {
@@ -490,31 +606,45 @@ describe('sops encrypted secret store', () => {
490
606
  else process.env.GIT_AUTH_TOKEN = saved.env;
491
607
  });
492
608
 
493
- it('prefers an explicit token over every implicit source', () => {
609
+ it('prefers an explicit value over every implicit source', () => {
494
610
  process.env.GIT_AUTH_TOKEN = 'ghp_fromEnvironment';
495
- expect(sops().plannedTokenSource({ token: 'ghp_explicit' })).to.equal('--args token');
611
+ expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', { value: 'ghp_explicit' })).to.equal('--args value');
496
612
  });
497
613
 
498
- it('falls back through the environment to a prompt', () => {
614
+ it('falls back through the environment and the host configuration to a prompt', () => {
499
615
  process.env.GIT_AUTH_TOKEN = 'ghp_fromEnvironment';
500
- expect(sops().plannedTokenSource({})).to.equal('GIT_AUTH_TOKEN environment');
616
+ expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', {})).to.equal('GIT_AUTH_TOKEN environment');
501
617
  delete process.env.GIT_AUTH_TOKEN;
502
- expect(sops().plannedTokenSource({})).to.match(/interactive prompt|unavailable/);
618
+ expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', {}, { GIT_AUTH_TOKEN: 'ghp_host' })).to.equal(
619
+ 'host configuration',
620
+ );
621
+ expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', {})).to.match(/interactive prompt|unavailable/);
622
+ });
623
+
624
+ it('pins the host configuration with source=host, even when the environment holds a value', () => {
625
+ process.env.GIT_AUTH_TOKEN = 'ghp_fromEnvironment';
626
+ expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', { source: 'host' }, { GIT_AUTH_TOKEN: 'x' })).to.equal(
627
+ 'host configuration',
628
+ );
629
+ expect(sops().plannedSecretSource('GIT_AUTH_TOKEN', { source: 'host' }, {})).to.match(/not in the host/);
503
630
  });
504
631
 
505
632
  it('does not prompt or write on a dry run', () => {
506
- // Report fields under dryRun are independent of gh reachability (see rotateGitAuthToken's
507
- // early return), so probing is stubbed out here rather than left to whatever `gh` happens to
508
- // be installed and authenticated as on the machine running the suite — otherwise this reaches
509
- // the real GitHub API for a probe of the fictitious 'acme/dd-cyberia' target.
510
633
  const hasBinary = vi.spyOn(sops(), 'hasBinary').mockReturnValue(false);
634
+ const read = vi.spyOn(Underpost.host, 'read').mockReturnValue({});
511
635
  try {
512
- const report = sops().rotateGitAuthToken({ deployId: 'dd-cyberia', owner: 'acme', dryRun: true });
513
- expect(report.tokenSource).to.equal('');
514
- expect(report.rotated).to.deep.equal([]);
636
+ const report = sops().rotateActionsSecrets({
637
+ keys: 'GIT_AUTH_TOKEN',
638
+ deployId: 'dd-cyberia',
639
+ owner: 'acme',
640
+ dryRun: true,
641
+ });
642
+ expect(report.written).to.deep.equal({});
515
643
  expect(report.manifest).to.equal('');
644
+ expect(report.sources.GIT_AUTH_TOKEN).to.be.a('string').and.not.equal('');
516
645
  } finally {
517
646
  hasBinary.mockRestore();
647
+ read.mockRestore();
518
648
  }
519
649
  });
520
650
  });
@@ -11,6 +11,7 @@ import {
11
11
  ensureTemplateCheckout,
12
12
  gitOriginRepositoryName,
13
13
  pruneTemplateWorkTree,
14
+ templateGitignoreFactory,
14
15
  validateTemplatePath,
15
16
  } from '../../src/server/runtime/conf.js';
16
17
  import { TEMPLATE_PRESERVED_ENTRIES } from '../../src/projects/underpost/catalog-underpost.js';
@@ -55,6 +56,29 @@ describe('template checkout guard', () => {
55
56
  });
56
57
  });
57
58
 
59
+ describe('template .gitignore', () => {
60
+ it('carries the engine rules and stops before the prototype rules', () => {
61
+ const content = templateGitignoreFactory();
62
+ expect(content).to.match(/^\/node_modules$/m);
63
+ expect(content).to.not.include('# Ignore ERP / CRM custom prototypes src');
64
+ expect(content).to.not.include('*healthcare*');
65
+ });
66
+
67
+ it('keeps an install out of the next commit', () => {
68
+ const path = gitCheckout();
69
+ try {
70
+ fs.writeFileSync(`${path}/.gitignore`, templateGitignoreFactory());
71
+ fs.outputFileSync(`${path}/node_modules/probe/index.js`, 'module.exports = 1;\n');
72
+ fs.outputFileSync(`${path}/package.json`, '{}\n');
73
+ execSync('git add .', { cwd: path });
74
+ const staged = execSync('git diff --cached --name-only', { cwd: path, encoding: 'utf8' }).trim().split('\n');
75
+ expect(staged).to.have.members(['.gitignore', 'package.json']);
76
+ } finally {
77
+ fs.removeSync(path);
78
+ }
79
+ });
80
+ });
81
+
58
82
  describe('template work tree prune', () => {
59
83
  it('keeps only the entries a rebuild cannot reconstruct', () => {
60
84
  const path = fs.mkdtempSync(`${os.tmpdir()}/underpost-prune-`);
@@ -516,3 +516,41 @@ function render(output, columns, lines) {
516
516
 
517
517
  return screen.filter((line) => line.trim().length > 0);
518
518
  }
519
+
520
+ describe('run_quiet stream wiring', () => {
521
+ let wiringDir;
522
+
523
+ beforeEach(() => {
524
+ wiringDir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-quiet-wiring-'));
525
+ });
526
+
527
+ afterEach(() => fs.removeSync(wiringDir));
528
+
529
+ // A command that finishes before tee holds the merged writer lets awk read EOF, and tee then
530
+ // blocks on an open with no reader until the 10 s drain kills it. A delay between tee's two
531
+ // opens, in the order the helper declares them, turns that scheduling race into a certainty.
532
+ it('finishes a short command even when tee opens its streams late', () => {
533
+ const wiring = /^(\s*)tee -a "\$error_log" (\S+) (\S+) &$/m;
534
+ const source = fs.readFileSync(loggingLib, 'utf8');
535
+ const [line, indent, first, second] = source.match(wiring) ?? [];
536
+ expect(line, 'tee wiring line').to.be.a('string');
537
+ const delayedLib = path.join(wiringDir, 'logging.sh');
538
+ fs.writeFileSync(
539
+ delayedLib,
540
+ source.replace(line, `${indent}{ sleep 0.3; exec tee -a "$error_log" ${second}; } ${first} &`),
541
+ );
542
+ const env = { ...process.env, TERM: 'xterm' };
543
+ for (const key of ['GITHUB_ACTIONS', 'RUN_QUIET_CI', 'RUN_QUIET_DEBUG']) delete env[key];
544
+
545
+ const started = Date.now();
546
+ execFileSync(
547
+ 'bash',
548
+ [
549
+ '-c',
550
+ `source ${JSON.stringify(delayedLib)}; run_quiet "Sync probe" "Target pod:" 14 bash -c 'echo out; echo err >&2'`,
551
+ ],
552
+ { env, stdio: ['ignore', 'pipe', 'pipe'] },
553
+ );
554
+ expect(Date.now() - started).to.be.below(5000);
555
+ }, 20_000);
556
+ });
@@ -61,6 +61,7 @@ describe('command capability classification', () => {
61
61
  expect(classifyCommand('sudo dnf install -y cockpit')).to.equal('host:write');
62
62
  expect(classifyCommand('sudo systemctl enable --now cockpit.socket')).to.equal('host:write');
63
63
  expect(classifyCommand('curl https://example.com')).to.equal('net');
64
+ expect(classifyCommand("'/x/node_modules/.bin/socket' 'scan' 'create' '--json' '.'")).to.equal('net');
64
65
  expect(classifyCommand('git add .')).to.equal('git');
65
66
  });
66
67