@bongos/core 1.18.3 → 1.18.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +1575 -285
- package/.claude/skills/builder-box/SKILL.md +6 -0
- package/.claude/skills/builder-sequence/SKILL.md +97 -0
- package/.claude/skills/builder-ship/SKILL.md +8 -2
- package/.claude/skills/builder-start/SKILL.md +11 -0
- package/.claude/skills/builder-sync/SKILL.md +41 -0
- package/.claude/skills/fix-task/SKILL.md +64 -0
- package/.claude/skills/goal-create/SKILL.md +158 -0
- package/.claude/skills/goal-review/SKILL.md +3 -1
- package/.claude/skills/idea-triage/SKILL.md +14 -8
- package/.claude/skills/merge-mode/SKILL.md +2 -2
- package/.claude/skills/new-project/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +45 -18
- package/.env.local.example +4 -3
- package/README.md +4 -2
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +2 -2
- package/clients/bongos-client/bongos-client.global.js +82 -18
- package/clients/bongos-client/index.cjs +82 -18
- package/clients/bongos-client/index.d.ts +131 -29
- package/clients/bongos-client/index.mjs +82 -18
- package/config/branding.neutral.json +6 -1
- package/config/governance.neutral.json +21 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
- package/docs/adr/0090-metic-task-authoring.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
- package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
- package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
- package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
- package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
- package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
- package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
- package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
- package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
- package/docs/adr/0152-metic-task-abandonment.md +67 -0
- package/docs/adr/0153-sequential-chain-runner.md +51 -0
- package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
- package/docs/adr/README.md +10 -0
- package/docs/api/openapi.json +843 -17
- package/docs/api-reference.md +23 -10
- package/docs/architecture.md +4 -2
- package/docs/branding-contract.md +3 -1
- package/docs/canonical-permissions.md +21 -8
- package/docs/file-map.md +8 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
- package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
- package/docs/onboarding/diagrams/assertions.json +4 -7
- package/docs/onboarding/drachmae-and-karma.md +3 -3
- package/docs/onboarding/primer.md +17 -16
- package/docs/onboarding/slash-commands.md +124 -0
- package/docs/recipes/builder-box-lifecycle.md +23 -0
- package/docs/recipes/devbox-app-release.md +14 -4
- package/docs/recipes/instance-first-run.md +16 -0
- package/docs/recipes/local-dev.md +81 -0
- package/docs/recipes/ops-gotchas.md +19 -1
- package/docs/recipes/overnight-builder.md +1 -1
- package/docs/recipes/parallel-goal-waves.md +174 -0
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/standalone-live-docs.md +48 -0
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
- package/migrations/003_pms.sql +6 -7
- package/migrations/006_pms_v2.sql +3 -31
- package/migrations/core_193_task_needs_rebase.sql +28 -0
- package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
- package/migrations/core_195_idea_triage_provenance.sql +57 -0
- package/migrations/core_196_session_reward_topup.sql +69 -0
- package/modules/autonomy/pollers/routine-timers.js +9 -8
- package/modules/builder-settings/wandering-prefs.js +61 -18
- package/modules/dev-box/app/README.md +73 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
- package/modules/dev-box/app/electron-builder.yml +95 -0
- package/modules/dev-box/app/package-lock.json +5291 -0
- package/modules/dev-box/app/package.json +30 -0
- package/modules/dev-box/app/renderer/app.js +319 -0
- package/modules/dev-box/app/renderer/index.html +45 -0
- package/modules/dev-box/app/renderer/styles.css +246 -0
- package/modules/dev-box/app/renderer/theme.generated.css +16 -0
- package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
- package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
- package/modules/dev-box/app/scripts/gen-brand.js +138 -0
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
- package/modules/dev-box/app/scripts/gen-icons.js +286 -0
- package/modules/dev-box/app/scripts/vendor-client.js +23 -0
- package/modules/dev-box/app/src/box-controller.js +287 -0
- package/modules/dev-box/app/src/brand.generated.json +13 -0
- package/modules/dev-box/app/src/brand.js +16 -0
- package/modules/dev-box/app/src/config.js +19 -0
- package/modules/dev-box/app/src/gds-client.js +90 -0
- package/modules/dev-box/app/src/main.js +610 -0
- package/modules/dev-box/app/src/pairing.js +57 -0
- package/modules/dev-box/app/src/preload.js +29 -0
- package/modules/dev-box/app/src/session-store.js +58 -0
- package/modules/dev-box/app/src/ssh-setup.js +569 -0
- package/modules/dev-box/app/src/task-picker.js +70 -0
- package/modules/dev-box/app/src/tray.js +99 -0
- package/modules/dev-box/app/src/updater.js +55 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
- package/modules/dev-box/app/test/unit.mjs +526 -0
- package/modules/dev-box/box-access.js +121 -4
- package/modules/dev-box/boxes.js +20 -0
- package/modules/dev-box/module.json +1 -1
- package/modules/dev-box/routes/box.js +87 -3
- package/modules/economy/credits.js +116 -25
- package/modules/economy/reward.js +8 -0
- package/modules/economy/streak.js +2 -1
- package/modules/governance/CLAUDE.md +15 -0
- package/modules/governance/catalog.js +419 -0
- package/modules/governance/config.js +174 -0
- package/modules/governance/db.js +125 -0
- package/modules/governance/migrations/governance_001_tables.sql +81 -0
- package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
- package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
- package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
- package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
- package/modules/governance/module.json +10 -0
- package/modules/governance/protected-surfaces.js +88 -0
- package/modules/governance/protected-surfaces.json +80 -0
- package/modules/governance/rank-roles.js +83 -0
- package/modules/governance/resolver.js +221 -0
- package/modules/governance/routes/governance.js +89 -0
- package/modules/governance/routes/role-sync.js +49 -0
- package/modules/governance/session-scopes.js +127 -0
- package/modules/governance/session-scopes.json +38 -0
- package/modules/hall-ui/public/atlas.css +117 -0
- package/modules/hall-ui/public/atlas.html +3 -114
- package/modules/hall-ui/public/brand-apply.js +34 -6
- package/modules/hall-ui/public/builders.js +187 -219
- package/modules/hall-ui/public/diagrams.html +2 -0
- package/modules/hall-ui/public/dom-utils.js +27 -0
- package/modules/hall-ui/public/drachmae.html +2 -0
- package/modules/hall-ui/public/drachmae.js +54 -1
- package/modules/hall-ui/public/gate.css +98 -0
- package/modules/hall-ui/public/gate.html +3 -95
- package/modules/hall-ui/public/goal-inbox.js +4 -1
- package/modules/hall-ui/public/goals-page.js +626 -114
- package/modules/hall-ui/public/goals.css +79 -1
- package/modules/hall-ui/public/goals.html +38 -11
- package/modules/hall-ui/public/hall-widgets.js +62 -52
- package/modules/hall-ui/public/harbor.css +123 -0
- package/modules/hall-ui/public/harbor.html +3 -120
- package/modules/hall-ui/public/index.html +11 -4
- package/modules/hall-ui/public/instances-panel.js +4 -1
- package/modules/hall-ui/public/modules.css +63 -0
- package/modules/hall-ui/public/modules.html +3 -60
- package/modules/hall-ui/public/new-project-wizard.js +7 -3
- package/modules/hall-ui/public/not-ready.html +2 -0
- package/modules/hall-ui/public/pair.css +73 -0
- package/modules/hall-ui/public/pair.html +3 -70
- package/modules/hall-ui/public/palette.js +302 -0
- package/modules/hall-ui/public/primer.css +13 -0
- package/modules/hall-ui/public/primer.html +2 -0
- package/modules/hall-ui/public/profile.html +2 -0
- package/modules/hall-ui/public/ranks.html +2 -0
- package/modules/hall-ui/public/roadmap.css +179 -0
- package/modules/hall-ui/public/roadmap.html +9 -172
- package/modules/hall-ui/public/roadmap.js +329 -316
- package/modules/hall-ui/public/sessions.css +16 -0
- package/modules/hall-ui/public/sessions.html +3 -13
- package/modules/hall-ui/public/settings.html +2 -0
- package/modules/hall-ui/public/settings.js +4 -4
- package/modules/hall-ui/public/shell.js +143 -18
- package/modules/hall-ui/public/style.css +231 -39
- package/modules/hall-ui/public/task-detail.js +347 -26
- package/modules/hall-ui/public/task.html +6 -1
- package/modules/hall-ui/public/watch.html +2 -0
- package/modules/hall-ui/public/work.html +8 -6
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/ideas/inbox.js +61 -11
- package/modules/ideas/routes/inbox.js +23 -0
- package/modules/lifecycle/claim-eligibility.js +72 -0
- package/modules/lifecycle/db.js +519 -139
- package/modules/lifecycle/done-when.js +18 -3
- package/modules/lifecycle/github-push.js +19 -2
- package/modules/lifecycle/goal-advisory.js +114 -0
- package/modules/lifecycle/goal-authz.js +62 -0
- package/modules/lifecycle/lifecycle.js +9 -0
- package/modules/lifecycle/publish-reconciler.js +109 -6
- package/modules/lifecycle/routes/claims.js +68 -23
- package/modules/lifecycle/routes/done-when.js +110 -15
- package/modules/lifecycle/routes/goals.js +8 -38
- package/modules/lifecycle/routes/tasks.js +38 -6
- package/modules/lifecycle/ship-card.js +6 -0
- package/modules/onboarding/onboarding-state.js +1 -1
- package/modules/platform-identity/CLAUDE.md +15 -0
- package/modules/platform-identity/hub-device.js +147 -0
- package/modules/platform-identity/hub-keys.js +151 -0
- package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
- package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
- package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
- package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
- package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
- package/modules/platform-identity/module.json +18 -0
- package/modules/platform-identity/platform-identity.js +531 -0
- package/modules/platform-identity/pollers/code-sweep.js +43 -0
- package/modules/platform-identity/routes/my-projects.js +140 -0
- package/modules/platform-identity/routes/projects.js +107 -0
- package/modules/platform-identity/routes/scouting.js +35 -0
- package/modules/platform-identity/routes/sso.js +402 -0
- package/modules/platform-identity/tests/platform-identity.mjs +388 -0
- package/modules/platform-identity/ui/.gitkeep +0 -0
- package/modules/security/routes/reports.js +10 -0
- package/modules/sessions/db.js +166 -21
- package/modules/sessions/module.json +1 -1
- package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
- package/modules/sessions/routes/sessions.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/anim/idle/frame_00.webp +0 -0
- package/public-landing/assets/anim/idle/frame_01.webp +0 -0
- package/public-landing/assets/anim/idle/frame_02.webp +0 -0
- package/public-landing/assets/anim/idle/frame_03.webp +0 -0
- package/public-landing/assets/anim/idle/frame_04.webp +0 -0
- package/public-landing/assets/anim/idle/frame_05.webp +0 -0
- package/public-landing/assets/anim/idle/frame_06.webp +0 -0
- package/public-landing/assets/anim/idle/frame_07.webp +0 -0
- package/public-landing/assets/anim/idle/frame_08.webp +0 -0
- package/public-landing/assets/anim/idle/frame_09.webp +0 -0
- package/public-landing/assets/anim/idle/frame_10.webp +0 -0
- package/public-landing/assets/anim/idle/frame_11.webp +0 -0
- package/public-landing/assets/anim/idle/frame_12.webp +0 -0
- package/public-landing/assets/anim/idle/frame_13.webp +0 -0
- package/public-landing/assets/anim/idle/frame_14.webp +0 -0
- package/public-landing/assets/anim/idle/frame_15.webp +0 -0
- package/public-landing/assets/anim/idle/frame_16.webp +0 -0
- package/public-landing/assets/anim/idle/frame_17.webp +0 -0
- package/public-landing/assets/anim/idle/frame_18.webp +0 -0
- package/public-landing/assets/anim/idle/frame_19.webp +0 -0
- package/public-landing/assets/anim/idle/frame_20.webp +0 -0
- package/public-landing/assets/anim/idle/frame_21.webp +0 -0
- package/public-landing/assets/anim/idle/frame_22.webp +0 -0
- package/public-landing/assets/anim/idle/frame_23.webp +0 -0
- package/public-landing/assets/anim/idle/frame_24.webp +0 -0
- package/public-landing/assets/anim/idle/frame_25.webp +0 -0
- package/public-landing/assets/anim/idle/frame_26.webp +0 -0
- package/public-landing/assets/anim/idle/frame_27.webp +0 -0
- package/public-landing/assets/anim/idle/frame_28.webp +0 -0
- package/public-landing/assets/anim/idle/frame_29.webp +0 -0
- package/public-landing/assets/anim/idle/frame_30.webp +0 -0
- package/public-landing/assets/anim/idle/frame_31.webp +0 -0
- package/public-landing/assets/anim/idle/frame_32.webp +0 -0
- package/public-landing/assets/anim/idle/frame_33.webp +0 -0
- package/public-landing/assets/anim/idle/frame_34.webp +0 -0
- package/public-landing/assets/anim/idle/frame_35.webp +0 -0
- package/public-landing/assets/anim/idle/frame_36.webp +0 -0
- package/public-landing/assets/anim/idle/frame_37.webp +0 -0
- package/public-landing/assets/anim/idle/frame_38.webp +0 -0
- package/public-landing/assets/anim/idle/frame_39.webp +0 -0
- package/public-landing/assets/anim/idle/frame_40.webp +0 -0
- package/public-landing/assets/anim/idle/frame_41.webp +0 -0
- package/public-landing/assets/anim/idle/frame_42.webp +0 -0
- package/public-landing/assets/anim/idle/frame_43.webp +0 -0
- package/public-landing/create.html +51 -58
- package/public-landing/gate/README.md +137 -0
- package/public-landing/gate/dispmaps/bar.png +0 -0
- package/public-landing/gate/dispmaps/flat.png +0 -0
- package/public-landing/gate/dispmaps/key.png +0 -0
- package/public-landing/gate/index.html +587 -0
- package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
- package/public-landing/gate/previews/off-the-boats.jpg +0 -0
- package/public-landing/gate/space.css +799 -0
- package/public-landing/gate/space.js +1826 -0
- package/public-landing/gate/sprites/enter.png +0 -0
- package/public-landing/gate/sprites/search.png +0 -0
- package/public-landing/gate/sprites/terminal-blank.png +0 -0
- package/public-landing/gate/sprites/tile.png +0 -0
- package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
- package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
- package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
- package/public-landing/world/assets/plates/scene-establishing.png +0 -0
- package/scripts/gds/abandon.js +6 -4
- package/scripts/gds/api.js +10 -5
- package/scripts/gds/api.ps1 +26 -0
- package/scripts/gds/api.sh +6 -0
- package/scripts/gds/audit-ships.js +7 -1
- package/scripts/gds/backfill-sessions.js +78 -18
- package/scripts/gds/backup.js +23 -1
- package/scripts/gds/box-connect-lib.js +124 -31
- package/scripts/gds/box-sync.js +66 -0
- package/scripts/gds/box.js +225 -53
- package/scripts/gds/cf-tunnel.js +145 -6
- package/scripts/gds/claim.js +34 -3
- package/scripts/gds/cli-lib.js +161 -27
- package/scripts/gds/code.js +6 -2
- package/scripts/gds/deploy-divergence.js +209 -0
- package/scripts/gds/dev-lib.js +309 -0
- package/scripts/gds/dev.js +322 -0
- package/scripts/gds/doctor.js +90 -61
- package/scripts/gds/fetch-art-key.js +1 -1
- package/scripts/gds/fetch-sound-prefs.js +2 -2
- package/scripts/gds/fitness.js +228 -12
- package/scripts/gds/gds-db-backup.service +16 -1
- package/scripts/gds/gds-db-offsite.service +9 -1
- package/scripts/gds/gen-api-client.js +1 -1
- package/scripts/gds/gen-api-docs.js +36 -14
- package/scripts/gds/gen-diagrams.js +45 -28
- package/scripts/gds/gen-file-map.js +2 -1
- package/scripts/gds/gen-freshness.js +32 -0
- package/scripts/gds/gen-repo-map.js +3 -2
- package/scripts/gds/gen-session-index.js +14 -5
- package/scripts/gds/hooks/pre-push.js +2 -2
- package/scripts/gds/init.js +60 -3
- package/scripts/gds/install-git-hooks.js +1 -1
- package/scripts/gds/key-card.js +15 -6
- package/scripts/gds/key-status.js +1 -1
- package/scripts/gds/linkify-refs.js +35 -9
- package/scripts/gds/login.js +16 -1
- package/scripts/gds/paste-token.js +7 -1
- package/scripts/gds/preflight.js +126 -0
- package/scripts/gds/provision.js +123 -12
- package/scripts/gds/publish-manifest.js +7 -1
- package/scripts/gds/reauth.js +7 -3
- package/scripts/gds/regen-instance-docs.js +47 -0
- package/scripts/gds/run-unit-tests.js +4 -2
- package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
- package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
- package/scripts/gds/sequence.js +425 -0
- package/scripts/gds/session-cost.js +10 -1
- package/scripts/gds/setup.js +31 -5
- package/scripts/gds/shell.js +5 -3
- package/scripts/gds/ship.js +319 -29
- package/scripts/gds/smoke-gds.js +139 -8
- package/scripts/gds/start.js +171 -22
- package/scripts/gds/status.js +1 -1
- package/scripts/gds/triage.js +10 -9
- package/scripts/gds/upgrade.js +124 -2
- package/scripts/status-mirror-sync.js +114 -84
- package/src/bongos/api-errors.js +1 -0
- package/src/bongos/auth.js +447 -102
- package/src/bongos/db-kernel.js +27 -0
- package/src/bongos/db.js +39 -0
- package/src/bongos/module-scope-map.js +27 -1
- package/src/bongos/permission-path-check.js +136 -102
- package/src/bongos/repo-info.js +47 -11
- package/src/bongos/route-rank-check.js +429 -46
- package/src/bongos/routes/_helpers.js +12 -1
- package/src/bongos/routes/auth.js +219 -2
- package/src/bongos/routes/backup.js +58 -2
- package/src/bongos/routes/me.js +35 -1
- package/src/bongos/serve-internal.js +84 -15
- package/src/branding.js +32 -0
- package/src/instance-config.js +30 -0
- package/src/module-api.js +112 -1
- package/src/platform-server.js +86 -9
- package/tests/abandon_task.mjs +59 -3
- package/tests/api_cli_flags.mjs +53 -0
- package/tests/auth_page_gate.mjs +13 -0
- package/tests/backfill_sessions.mjs +127 -1
- package/tests/backup_cli.mjs +54 -0
- package/tests/bongos_box_connect.mjs +77 -0
- package/tests/box_access.mjs +130 -1
- package/tests/box_credential.mjs +125 -0
- package/tests/box_host_keys.mjs +24 -0
- package/tests/box_infra_resolve.mjs +149 -0
- package/tests/box_scope_session.mjs +6 -2
- package/tests/box_sync.mjs +55 -0
- package/tests/box_task_scope.mjs +99 -0
- package/tests/branding.mjs +12 -0
- package/tests/cf_ruleset_preflight.mjs +142 -0
- package/tests/cf_tunnel.mjs +37 -0
- package/tests/claim_gate_rebase.mjs +98 -0
- package/tests/clawback_confirmed_backlog.mjs +103 -0
- package/tests/cli_lib_formatters.mjs +20 -0
- package/tests/confirm_attribution.mjs +30 -46
- package/tests/credit_grant.mjs +198 -10
- package/tests/criterion_authoring_gate.mjs +272 -0
- package/tests/criterion_progress.mjs +33 -1
- package/tests/dev_lib.mjs +253 -0
- package/tests/devbox_artifact_gate.mjs +75 -0
- package/tests/devbox_dmg_backdrop.mjs +137 -0
- package/tests/devbox_icons.mjs +304 -0
- package/tests/devbox_installer_brand.mjs +232 -0
- package/tests/devbox_key_register.mjs +234 -0
- package/tests/docs_openapi_route.mjs +110 -0
- package/tests/doctor_hooks.mjs +141 -0
- package/tests/extract_json.mjs +140 -0
- package/tests/fitness.mjs +89 -0
- package/tests/gen_freshness.mjs +96 -0
- package/tests/goal_advisory.mjs +251 -0
- package/tests/goal_criterion_confirm_ui.mjs +269 -0
- package/tests/goal_routes.mjs +59 -4
- package/tests/goal_tier.mjs +12 -3
- package/tests/governance_catalog.mjs +129 -0
- package/tests/governance_claim_eligibility.mjs +262 -0
- package/tests/governance_config.mjs +187 -0
- package/tests/governance_ownership_scope.mjs +410 -0
- package/tests/governance_page_gates.mjs +324 -0
- package/tests/governance_parity.mjs +336 -0
- package/tests/governance_principals.mjs +232 -0
- package/tests/governance_protected_surfaces.mjs +208 -0
- package/tests/governance_require_permission.mjs +223 -0
- package/tests/governance_resolver.mjs +84 -0
- package/tests/governance_role_sync.mjs +231 -0
- package/tests/governance_seed.mjs +127 -0
- package/tests/governance_session_scope.mjs +434 -0
- package/tests/governance_vanilla_boot.mjs +203 -0
- package/tests/grade_attribution.mjs +7 -7
- package/tests/hall_audit.mjs +223 -0
- package/tests/hall_nav.mjs +191 -0
- package/tests/hall_palette.mjs +206 -0
- package/tests/hall_signout.mjs +124 -0
- package/tests/hook_user_agents.mjs +10 -0
- package/tests/idea_triage.mjs +162 -0
- package/tests/idp_device_exchange.mjs +93 -0
- package/tests/init.mjs +51 -0
- package/tests/instance_config.mjs +65 -0
- package/tests/instance_mismatch_guard.mjs +23 -6
- package/tests/instance_release_config.mjs +104 -0
- package/tests/linkify_refs.mjs +15 -2
- package/tests/module_contributions.mjs +55 -0
- package/tests/module_loader.mjs +10 -5
- package/tests/module_scope_active_claims.mjs +94 -0
- package/tests/needs_rebase.mjs +92 -0
- package/tests/one_active_claim_per_session.mjs +76 -0
- package/tests/override_attribution.mjs +10 -8
- package/tests/override_request_ownership.mjs +8 -4
- package/tests/parse_cookie_decode.mjs +42 -0
- package/tests/pay_on_land.mjs +129 -0
- package/tests/platform_boot.mjs +100 -1
- package/tests/post_login_landing.mjs +42 -0
- package/tests/preflight_checks.mjs +72 -0
- package/tests/provision.mjs +132 -7
- package/tests/publish_branch_route.mjs +53 -0
- package/tests/publish_reconciler.mjs +187 -2
- package/tests/repo_info_binding.mjs +248 -0
- package/tests/reward_gate.mjs +11 -2
- package/tests/reward_mode_cost_plus_only.mjs +116 -0
- package/tests/reward_mode_surfacing.mjs +168 -0
- package/tests/route_permission_check.mjs +301 -0
- package/tests/route_rank_check.mjs +254 -12
- package/tests/route_validation.mjs +30 -0
- package/tests/sequence.mjs +267 -0
- package/tests/session_digest.mjs +44 -0
- package/tests/session_index.mjs +18 -6
- package/tests/session_records.mjs +39 -1
- package/tests/session_reward_on_land.mjs +381 -0
- package/tests/session_reward_reconcile.mjs +229 -0
- package/tests/ship_api_client_pathspec.mjs +100 -0
- package/tests/ship_base_flag.mjs +93 -0
- package/tests/ship_ci_deploy.mjs +80 -3
- package/tests/ship_upstream_deletion.mjs +57 -0
- package/tests/smoke_target.mjs +233 -0
- package/tests/start_env_guard.mjs +87 -0
- package/tests/start_rebase_warning.mjs +103 -0
- package/tests/status_mirror_sync.mjs +122 -0
- package/tests/stop_refcheck.mjs +19 -1
- package/tests/task_goal_link.mjs +15 -6
- package/tests/upgrade.mjs +139 -0
- package/tests/web_admission_status.mjs +162 -0
- package/tests/write_validation_gaps.mjs +110 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// tests/devbox_installer_brand.mjs — the Dev Box installer's BUILD INPUTS are
|
|
2
|
+
// branding-driven and match what the generator produces (task 1002512).
|
|
3
|
+
//
|
|
4
|
+
// The app is built separately from the server (ADR 0045), so its brand is baked
|
|
5
|
+
// in at BUILD time by modules/dev-box/app/scripts/gen-brand.js reading the
|
|
6
|
+
// instance branding pack (ADR 0145). Three artifacts are COMMITTED —
|
|
7
|
+
// src/brand.generated.json, renderer/theme.generated.css, electron-builder.yml —
|
|
8
|
+
// and until now nothing checked that they still equal the generator's output.
|
|
9
|
+
// A hand-edit to electron-builder.yml (the template's own "edit THIS file, never
|
|
10
|
+
// the generated one") survived silently until the next unrelated regeneration
|
|
11
|
+
// quietly reverted it, and a stale committed artifact means CI ships an installer
|
|
12
|
+
// whose appId / product name / artifact filenames are not what the pack says.
|
|
13
|
+
//
|
|
14
|
+
// PURE by construction: it calls the exported builders and NEVER main(), so a run
|
|
15
|
+
// writes nothing and cannot dirty the tree. That is also why gen-brand.js gained
|
|
16
|
+
// a `require.main === module` guard — importing it used to rewrite all three files.
|
|
17
|
+
//
|
|
18
|
+
// EOL: electron-builder.yml and theme.generated.css are CRLF on a default Windows
|
|
19
|
+
// checkout while the generator emits LF. Comparisons go through gen-freshness.js's
|
|
20
|
+
// sameContent() — a raw byte compare is red forever on Windows and green in Linux
|
|
21
|
+
// CI, the worst kind of gate.
|
|
22
|
+
//
|
|
23
|
+
// Regex/line based on the YAML (like tests/devbox_artifact_gate.mjs) — the repo
|
|
24
|
+
// declares no YAML parser. DB-free, no network. Run: node --test tests/devbox_installer_brand.mjs
|
|
25
|
+
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { test } from 'node:test';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const APP_DIR = path.join(ROOT, 'modules', 'dev-box', 'app');
|
|
36
|
+
|
|
37
|
+
const { sameContent } = require(path.join(ROOT, 'scripts', 'gds', 'gen-freshness.js'));
|
|
38
|
+
const genBrand = require(path.join(APP_DIR, 'scripts', 'gen-brand.js'));
|
|
39
|
+
const { buildBrand, buildThemeCss, renderElectronBuilder } = genBrand;
|
|
40
|
+
|
|
41
|
+
const neutral = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'));
|
|
42
|
+
const read = (...p) => fs.readFileSync(path.join(APP_DIR, ...p), 'utf8');
|
|
43
|
+
|
|
44
|
+
const ORIGIN_ENV = ['DEVBOX_PUBLIC_ORIGIN', 'DEVBOX_BUILDERS_ORIGIN'];
|
|
45
|
+
|
|
46
|
+
// buildBrand reads the origin env vars at CALL time, so a developer's stray
|
|
47
|
+
// export would otherwise fake a drift failure. Run fn with a known env.
|
|
48
|
+
function withEnv(overrides, fn) {
|
|
49
|
+
const saved = Object.fromEntries(ORIGIN_ENV.map((k) => [k, process.env[k]]));
|
|
50
|
+
try {
|
|
51
|
+
for (const k of ORIGIN_ENV) {
|
|
52
|
+
if (overrides[k] === undefined) delete process.env[k];
|
|
53
|
+
else process.env[k] = overrides[k];
|
|
54
|
+
}
|
|
55
|
+
return fn();
|
|
56
|
+
} finally {
|
|
57
|
+
for (const k of ORIGIN_ENV) {
|
|
58
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
59
|
+
else process.env[k] = saved[k];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The committed artifacts are the NEUTRAL (env-free) build — that is what a
|
|
65
|
+
// release build re-renders over, so this is the expectation to compare against.
|
|
66
|
+
const neutralBrand = () => withEnv({}, () => buildBrand(neutral));
|
|
67
|
+
|
|
68
|
+
// A YAML value by key at any indent, first match wins (no parser in the repo deps).
|
|
69
|
+
function yamlValue(yml, key) {
|
|
70
|
+
const m = new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, 'm').exec(yml);
|
|
71
|
+
return m ? m[1] : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Comment lines carry rationale prose that names the tokens on purpose (the
|
|
75
|
+
// template header says "@@TOKENS@@ are substituted") — scan the config only.
|
|
76
|
+
function settingsOnly(yml) {
|
|
77
|
+
return yml.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── (a) drift gate: committed build inputs === generator output ────────────────
|
|
81
|
+
|
|
82
|
+
test('committed brand.generated.json is what the generator produces from the pack', () => {
|
|
83
|
+
assert.deepEqual(JSON.parse(read('src', 'brand.generated.json')), neutralBrand(),
|
|
84
|
+
'src/brand.generated.json is stale — run `npm run gen-brand` in modules/dev-box/app and commit');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('committed theme.generated.css is what the generator produces from the pack', () => {
|
|
88
|
+
assert.ok(sameContent(read('renderer', 'theme.generated.css'), buildThemeCss(neutral)),
|
|
89
|
+
'renderer/theme.generated.css is stale — run `npm run gen-brand` and commit');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('committed electron-builder.yml is what the template renders to', () => {
|
|
93
|
+
assert.ok(sameContent(read('electron-builder.yml'), renderElectronBuilder(neutralBrand())),
|
|
94
|
+
'electron-builder.yml was hand-edited or is stale — edit electron-builder.tmpl.yml, ' +
|
|
95
|
+
'run `npm run gen-brand`, and commit the rendered file');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── (b) installer identity, read out of the RENDERED yml ──────────────────────
|
|
99
|
+
|
|
100
|
+
test('the rendered installer declares the pack identity and no leftover token', () => {
|
|
101
|
+
const brand = neutralBrand();
|
|
102
|
+
const yml = renderElectronBuilder(brand);
|
|
103
|
+
assert.equal(yamlValue(yml, 'appId'), brand.APP_ID);
|
|
104
|
+
assert.equal(yamlValue(yml, 'appId'), 'com.cloudbongos.devbox');
|
|
105
|
+
assert.equal(yamlValue(yml, 'productName'), 'Cloud Bongos Dev Box');
|
|
106
|
+
assert.ok((yamlValue(yml, 'copyright') || '').length > 0, 'copyright must not render empty');
|
|
107
|
+
assert.doesNotMatch(settingsOnly(yml), /@@[A-Z_]+@@/,
|
|
108
|
+
'an unsubstituted @@TOKEN@@ reached the rendered config');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('every installer filename carries the slug, the version and the arch', () => {
|
|
112
|
+
const { PRODUCT_SLUG } = neutralBrand();
|
|
113
|
+
const yml = renderElectronBuilder(neutralBrand());
|
|
114
|
+
const names = [...yml.matchAll(/^\s*artifactName:\s*(.+?)\s*$/gm)].map(([, v]) => v);
|
|
115
|
+
assert.ok(names.length >= 3, `expected mac + dmg + nsis artifactName, found ${names.length}`);
|
|
116
|
+
for (const name of names) {
|
|
117
|
+
assert.ok(name.startsWith(PRODUCT_SLUG), `artifactName "${name}" does not start with the brand slug`);
|
|
118
|
+
assert.match(name, /\$\{version\}/, `artifactName "${name}" has no \${version}`);
|
|
119
|
+
// #908: an arch-less name makes the x64 and arm64 builds collide on one file.
|
|
120
|
+
assert.match(name, /\$\{arch\}/, `artifactName "${name}" has no \${arch} — two arches would collide`);
|
|
121
|
+
}
|
|
122
|
+
assert.ok(names.some((n) => /-Setup-/.test(n)), 'the NSIS installer name must be distinguishable');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('the Windows target still builds both arches', () => {
|
|
126
|
+
const yml = renderElectronBuilder(neutralBrand());
|
|
127
|
+
const winBlock = yml.slice(yml.indexOf('\nwin:'));
|
|
128
|
+
const arch = /arch:\s*\[([^\]]+)\]/.exec(winBlock);
|
|
129
|
+
assert.ok(arch, 'win target declares no arch list');
|
|
130
|
+
const arches = arch[1].split(',').map((s) => s.trim());
|
|
131
|
+
for (const a of ['x64', 'arm64']) assert.ok(arches.includes(a), `win arch list is missing ${a}`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── (c) one source rebrands — the ADR 0145 promise, mechanized ────────────────
|
|
135
|
+
|
|
136
|
+
test('a different pack produces a different installer with no Cloud Bongos left in it', () => {
|
|
137
|
+
const pack = {
|
|
138
|
+
identity: { worldName: 'Kettle Drum', company: { name: 'Kettle Drum Co' } },
|
|
139
|
+
domains: { publicOrigin: 'https://kettle.example' },
|
|
140
|
+
configDir: 'kettle',
|
|
141
|
+
theme: { ui: { colors: { bg: '#101010' } } },
|
|
142
|
+
};
|
|
143
|
+
const brand = withEnv({}, () => buildBrand(pack));
|
|
144
|
+
assert.equal(brand.APP_ID, 'com.kettledrum.devbox');
|
|
145
|
+
assert.equal(brand.PRODUCT_NAME, 'Kettle Drum Dev Box');
|
|
146
|
+
assert.equal(brand.PRODUCT_SLUG, 'Kettle-Drum-Dev-Box');
|
|
147
|
+
assert.equal(brand.CONFIG_DIR, 'kettle');
|
|
148
|
+
assert.equal(brand.BG_COLOR, '#101010');
|
|
149
|
+
assert.equal(brand.COPYRIGHT, '© Kettle Drum Co');
|
|
150
|
+
const HOST_BRAND = /cloud ?bongos/i;
|
|
151
|
+
assert.doesNotMatch(JSON.stringify(brand), HOST_BRAND, 'a host pack still carries Cloud Bongos strings');
|
|
152
|
+
assert.doesNotMatch(settingsOnly(renderElectronBuilder(brand)), HOST_BRAND,
|
|
153
|
+
'the rendered installer config still names Cloud Bongos on a host pack');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── (d) origins are a build input ─────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
test('the release origin env beats the pack', () => {
|
|
159
|
+
const brand = withEnv({
|
|
160
|
+
DEVBOX_PUBLIC_ORIGIN: 'https://cloudbongos.example',
|
|
161
|
+
DEVBOX_BUILDERS_ORIGIN: 'https://builders.cloudbongos.example',
|
|
162
|
+
}, () => buildBrand(neutral));
|
|
163
|
+
assert.equal(brand.PUBLIC_ORIGIN, 'https://cloudbongos.example');
|
|
164
|
+
assert.equal(brand.BUILDERS_ORIGIN, 'https://builders.cloudbongos.example');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('with no env the pack origins are used, and buildersOrigin falls back to publicOrigin', () => {
|
|
168
|
+
const fromPack = withEnv({}, () => buildBrand(neutral));
|
|
169
|
+
assert.equal(fromPack.PUBLIC_ORIGIN, neutral.domains.publicOrigin);
|
|
170
|
+
assert.equal(fromPack.BUILDERS_ORIGIN, neutral.domains.buildersOrigin);
|
|
171
|
+
|
|
172
|
+
const noBuilders = withEnv({}, () => buildBrand({ domains: { publicOrigin: 'https://only.example' } }));
|
|
173
|
+
assert.equal(noBuilders.BUILDERS_ORIGIN, 'https://only.example',
|
|
174
|
+
'a pack without buildersOrigin must fall back to publicOrigin, not to localhost');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('the COMMITTED artifact is the neutral localhost build — no host origin is checked in', () => {
|
|
178
|
+
const committed = JSON.parse(read('src', 'brand.generated.json'));
|
|
179
|
+
for (const key of ['PUBLIC_ORIGIN', 'BUILDERS_ORIGIN']) {
|
|
180
|
+
assert.match(committed[key], /^http:\/\/localhost(:\d+)?$/,
|
|
181
|
+
`${key} in the committed brand.generated.json is a host origin — a release build sets it via env`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── (e) CI passes the origins through ─────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
test('both build jobs pass the origin repo variables into the build', () => {
|
|
188
|
+
const wf = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'build-devbox-app.yml'), 'utf8');
|
|
189
|
+
const lines = wf.split(/\r?\n/);
|
|
190
|
+
const jobStarts = lines.reduce((acc, l, i) => (/^ {2}[a-z0-9-]+:\s*$/.test(l) ? [...acc, i] : acc), []);
|
|
191
|
+
function jobBlock(name) {
|
|
192
|
+
const at = lines.findIndex((l) => l === ` ${name}:`);
|
|
193
|
+
assert.ok(at >= 0, `workflow has no ${name} job`);
|
|
194
|
+
const end = jobStarts.find((i) => i > at) ?? lines.length;
|
|
195
|
+
return lines.slice(at, end).join('\n');
|
|
196
|
+
}
|
|
197
|
+
for (const job of ['build-mac', 'build-win']) {
|
|
198
|
+
const block = jobBlock(job);
|
|
199
|
+
for (const v of ORIGIN_ENV) {
|
|
200
|
+
assert.match(block, new RegExp(`${v}:\\s*\\$\\{\\{\\s*vars\\.${v}\\s*\\}\\}`),
|
|
201
|
+
`${job} does not pass ${v} from repo variables — the release build would pair against localhost`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── (f) published names are names the builder can emit ────────────────────────
|
|
207
|
+
|
|
208
|
+
test('the published asset filenames match what electron-builder will emit', () => {
|
|
209
|
+
// INSTANCE-ONLY config (publish-manifest.js INSTANCE_EXCLUDE) — absent in the
|
|
210
|
+
// core checkout, so this is a guarded cross-check, never a hard require.
|
|
211
|
+
const cfgPath = path.join(ROOT, 'config', 'devbox-app.json');
|
|
212
|
+
if (!fs.existsSync(cfgPath)) {
|
|
213
|
+
console.log(' (skipped: config/devbox-app.json is instance-only and absent in this checkout)');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
217
|
+
const yml = renderElectronBuilder(neutralBrand());
|
|
218
|
+
const nsis = yamlValue(yml.slice(yml.indexOf('\nnsis:')), 'artifactName');
|
|
219
|
+
const mac = yamlValue(yml.slice(yml.indexOf('\ndmg:')), 'artifactName');
|
|
220
|
+
for (const [key, filename] of Object.entries(cfg.assets || {})) {
|
|
221
|
+
const m = /^(mac|win)-(\S+)$/.exec(key);
|
|
222
|
+
if (!m) continue;
|
|
223
|
+
const [, platform, arch] = m;
|
|
224
|
+
const pattern = platform === 'mac' ? mac : nsis;
|
|
225
|
+
const expected = pattern
|
|
226
|
+
.replace(/\$\{version\}/g, cfg.version)
|
|
227
|
+
.replace(/\$\{arch\}/g, arch)
|
|
228
|
+
.replace(/\$\{ext\}/g, platform === 'mac' ? 'dmg' : 'exe');
|
|
229
|
+
assert.equal(filename, expected,
|
|
230
|
+
`config/devbox-app.json assets["${key}"] names a file electron-builder will not produce`);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// tests/devbox_key_register.mjs — the Dev Box app's SSH-key registration and
|
|
2
|
+
// known_hosts replacement (task 1002509).
|
|
3
|
+
//
|
|
4
|
+
// Two user-visible defects are pinned here:
|
|
5
|
+
// 1. Key registration was ONE un-retried POST whose rejection was logged and
|
|
6
|
+
// dropped. A flaky network at sign-in left the builder with no key in the
|
|
7
|
+
// box's authorized_keys and nothing ever tried again — every connect refused.
|
|
8
|
+
// registerKeyWithVerify retries, then VERIFIES against the server's own list,
|
|
9
|
+
// because a 201 is not proof.
|
|
10
|
+
// 2. mergeKnownHosts only matched plain `host` / `host,ip` field-1 values, so a
|
|
11
|
+
// HASHED entry (`|1|salt|hash` — HashKnownHosts is on by default on Debian/
|
|
12
|
+
// Ubuntu) survived a "replace" and the stale line kept winning: "Host denied
|
|
13
|
+
// (verification failed)".
|
|
14
|
+
//
|
|
15
|
+
// It requires the app source directly (node builtins only — no Electron, no
|
|
16
|
+
// network, no fs): the app is a separately-packaged subproject whose own
|
|
17
|
+
// test/unit.mjs is NOT part of the root unit gate, so the gate lives here.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/devbox_key_register.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const {
|
|
28
|
+
registerKeyWithVerify,
|
|
29
|
+
sshFingerprintSha256,
|
|
30
|
+
publicKeyFingerprint,
|
|
31
|
+
mergeKnownHosts,
|
|
32
|
+
knownHostsLineMatchesHost,
|
|
33
|
+
reportedKeysAreStale,
|
|
34
|
+
} = require('../modules/dev-box/app/src/ssh-setup.js');
|
|
35
|
+
const boxOnboard = require('../modules/dev-box/box-onboard.js');
|
|
36
|
+
const boxes = require('../modules/dev-box/boxes.js');
|
|
37
|
+
|
|
38
|
+
// A REAL ed25519 public key (the same fixture tests/box_host_keys.mjs uses).
|
|
39
|
+
const ED = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPCoIEgcBiGqM2Rd0rgfw0d7rHccvbpSe3vsSN7brif/';
|
|
40
|
+
const ED_BODY = ED.split(' ')[1];
|
|
41
|
+
const HOST = 'box-alice.dev.example.com';
|
|
42
|
+
|
|
43
|
+
// --- fingerprint parity with the server ------------------------------------
|
|
44
|
+
|
|
45
|
+
test('sshFingerprintSha256 matches the server-side implementation byte for byte', () => {
|
|
46
|
+
const mine = sshFingerprintSha256(ED_BODY);
|
|
47
|
+
assert.match(mine, /^SHA256:[A-Za-z0-9+/]+$/, 'SHA256: prefix, no base64 padding');
|
|
48
|
+
assert.equal(mine, boxOnboard.sshFingerprintSha256(ED_BODY),
|
|
49
|
+
'the app must compute the SAME fingerprint the GDS stores, or verification can never match');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('publicKeyFingerprint ignores the comment and rejects a non-key line', () => {
|
|
53
|
+
assert.equal(publicKeyFingerprint(`${ED} someone@laptop`), sshFingerprintSha256(ED_BODY));
|
|
54
|
+
assert.equal(publicKeyFingerprint(ED), sshFingerprintSha256(ED_BODY));
|
|
55
|
+
assert.equal(publicKeyFingerprint('ssh-ed25519'), null);
|
|
56
|
+
assert.equal(publicKeyFingerprint(''), null);
|
|
57
|
+
assert.equal(publicKeyFingerprint(null), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- registerKeyWithVerify -------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const FP = sshFingerprintSha256(ED_BODY);
|
|
63
|
+
|
|
64
|
+
// Fake clock: records every backoff so a test can prove the retry actually waited.
|
|
65
|
+
function fakeSleep() {
|
|
66
|
+
const waits = [];
|
|
67
|
+
const fn = async (ms) => { waits.push(ms); };
|
|
68
|
+
fn.waits = waits;
|
|
69
|
+
return fn;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('registration verified on the first attempt', async () => {
|
|
73
|
+
const calls = [];
|
|
74
|
+
const sleep = fakeSleep();
|
|
75
|
+
const r = await registerKeyWithVerify({
|
|
76
|
+
publicKey: ED,
|
|
77
|
+
label: 'devbox-app test',
|
|
78
|
+
registerKey: async (pk, label) => { calls.push([pk, label]); },
|
|
79
|
+
listKeys: async () => ({ keys: [{ fingerprint: FP }] }),
|
|
80
|
+
sleep,
|
|
81
|
+
});
|
|
82
|
+
assert.deepEqual(r, { ok: true, fingerprint: FP, verified: true, attempts: 1, reason: 'registered_verified' });
|
|
83
|
+
assert.equal(calls.length, 1, 'one POST');
|
|
84
|
+
assert.deepEqual(calls[0], [ED, 'devbox-app test']);
|
|
85
|
+
assert.deepEqual(sleep.waits, [], 'no backoff on a clean run');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a transient failure is RETRIED with backoff, not dropped', async () => {
|
|
89
|
+
let n = 0;
|
|
90
|
+
const sleep = fakeSleep();
|
|
91
|
+
const r = await registerKeyWithVerify({
|
|
92
|
+
publicKey: ED,
|
|
93
|
+
registerKey: async () => { n += 1; if (n < 3) throw new Error('socket hang up'); },
|
|
94
|
+
listKeys: async () => ({ keys: [{ fingerprint: FP }] }),
|
|
95
|
+
sleep,
|
|
96
|
+
});
|
|
97
|
+
assert.equal(r.ok, true);
|
|
98
|
+
assert.equal(r.attempts, 3, 'kept trying instead of giving up on the first error');
|
|
99
|
+
assert.deepEqual(sleep.waits, [1000, 2000], 'backed off 1s then 2s between attempts');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a 201 that did NOT land is reported as verify_failed, not success', async () => {
|
|
103
|
+
const r = await registerKeyWithVerify({
|
|
104
|
+
publicKey: ED,
|
|
105
|
+
registerKey: async () => ({ ok: true }),
|
|
106
|
+
listKeys: async () => ({ keys: [] }),
|
|
107
|
+
sleep: fakeSleep(),
|
|
108
|
+
});
|
|
109
|
+
assert.equal(r.ok, false);
|
|
110
|
+
assert.equal(r.verified, false);
|
|
111
|
+
assert.equal(r.reason, 'verify_failed');
|
|
112
|
+
assert.equal(r.fingerprint, FP);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('every attempt failing returns register_failed and never throws', async () => {
|
|
116
|
+
const sleep = fakeSleep();
|
|
117
|
+
const r = await registerKeyWithVerify({
|
|
118
|
+
publicKey: ED,
|
|
119
|
+
registerKey: async () => { throw new Error('HTTP 500'); },
|
|
120
|
+
listKeys: async () => ({ keys: [] }),
|
|
121
|
+
sleep,
|
|
122
|
+
});
|
|
123
|
+
assert.equal(r.ok, false);
|
|
124
|
+
assert.equal(r.reason, 'register_failed');
|
|
125
|
+
assert.equal(r.attempts, 3);
|
|
126
|
+
assert.deepEqual(sleep.waits, [1000, 2000], 'exhausted the budget before conceding');
|
|
127
|
+
assert.match(r.detail, /HTTP 500/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a key already on the account verifies even when the POST keeps failing', async () => {
|
|
131
|
+
const r = await registerKeyWithVerify({
|
|
132
|
+
publicKey: ED,
|
|
133
|
+
registerKey: async () => { throw new Error('offline'); },
|
|
134
|
+
listKeys: async () => ({ keys: [{ fingerprint: FP }] }),
|
|
135
|
+
sleep: fakeSleep(),
|
|
136
|
+
});
|
|
137
|
+
assert.equal(r.ok, true, 'presence on the server is the goal; the POST is only the means');
|
|
138
|
+
assert.equal(r.reason, 'registered_verified');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('an unreadable key list is list_failed — never a false green', async () => {
|
|
142
|
+
const r = await registerKeyWithVerify({
|
|
143
|
+
publicKey: ED,
|
|
144
|
+
registerKey: async () => ({ ok: true }),
|
|
145
|
+
listKeys: async () => { throw new Error('HTTP 401'); },
|
|
146
|
+
sleep: fakeSleep(),
|
|
147
|
+
});
|
|
148
|
+
assert.equal(r.ok, false);
|
|
149
|
+
assert.equal(r.reason, 'list_failed');
|
|
150
|
+
assert.equal(r.verified, false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a bare array from listKeys is accepted too', async () => {
|
|
154
|
+
const r = await registerKeyWithVerify({
|
|
155
|
+
publicKey: ED,
|
|
156
|
+
registerKey: async () => ({ ok: true }),
|
|
157
|
+
listKeys: async () => [{ fingerprint: FP }],
|
|
158
|
+
sleep: fakeSleep(),
|
|
159
|
+
});
|
|
160
|
+
assert.equal(r.ok, true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// --- hashed known_hosts ----------------------------------------------------
|
|
164
|
+
|
|
165
|
+
// Build the hashed field-1 OpenSSH writes for `host`: |1|<b64 salt>|<b64 HMAC-SHA1>.
|
|
166
|
+
function hashedField(host, saltBytes = crypto.randomBytes(20)) {
|
|
167
|
+
const hash = crypto.createHmac('sha1', saltBytes).update(host).digest('base64');
|
|
168
|
+
return `|1|${saltBytes.toString('base64')}|${hash}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
test('mergeKnownHosts drops a HASHED line for the host and keeps one for another host', () => {
|
|
172
|
+
const mine = `${hashedField(HOST)} ssh-ed25519 STALEKEY`;
|
|
173
|
+
const other = `${hashedField('unrelated.example.com')} ssh-ed25519 OTHERKEY`;
|
|
174
|
+
const out = mergeKnownHosts(`${mine}\n${other}\n`, HOST, [`${HOST} ${ED}`]);
|
|
175
|
+
assert.ok(!out.includes('STALEKEY'), 'the hashed entry for this host is REPLACED, not appended beside');
|
|
176
|
+
assert.ok(out.includes('OTHERKEY'), 'another host stays put');
|
|
177
|
+
assert.ok(out.includes(`${HOST} ${ED}`), 'the fresh key is written');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('mergeKnownHosts still handles plain and host,ip entries, and keeps comments', () => {
|
|
181
|
+
const existing = [
|
|
182
|
+
'# my known hosts',
|
|
183
|
+
`${HOST} ssh-ed25519 STALE1`,
|
|
184
|
+
`${HOST},203.0.113.9 ssh-ed25519 STALE2`,
|
|
185
|
+
'other.example.com ssh-ed25519 KEEPME',
|
|
186
|
+
].join('\n');
|
|
187
|
+
const out = mergeKnownHosts(existing, HOST, [`${HOST} ${ED}`]);
|
|
188
|
+
assert.ok(out.startsWith('# my known hosts'), 'comments survive');
|
|
189
|
+
assert.ok(!out.includes('STALE1'));
|
|
190
|
+
assert.ok(!out.includes('STALE2'), 'a host,ip entry names this host too');
|
|
191
|
+
assert.ok(out.includes('KEEPME'));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('mergeKnownHosts is idempotent — a re-pin does not accumulate', () => {
|
|
195
|
+
const first = mergeKnownHosts('', HOST, [`${HOST} ${ED}`]);
|
|
196
|
+
const second = mergeKnownHosts(first, HOST, [`${HOST} ${ED}`]);
|
|
197
|
+
assert.equal(second, first);
|
|
198
|
+
assert.equal(second.split('\n').filter((l) => l.trim()).length, 1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('knownHostsLineMatchesHost: hashed match, wrong host, junk salt, comments', () => {
|
|
202
|
+
const salt = crypto.randomBytes(20);
|
|
203
|
+
assert.equal(knownHostsLineMatchesHost(`${hashedField(HOST, salt)} ssh-ed25519 K`, HOST), true);
|
|
204
|
+
assert.equal(knownHostsLineMatchesHost(`${hashedField(HOST, salt)} ssh-ed25519 K`, 'nope.example.com'), false);
|
|
205
|
+
assert.equal(knownHostsLineMatchesHost('|1|| ssh-ed25519 K', HOST), false, 'malformed hashed field never matches');
|
|
206
|
+
assert.equal(knownHostsLineMatchesHost(`# ${HOST}`, HOST), false, 'comments are not host lines');
|
|
207
|
+
assert.equal(knownHostsLineMatchesHost('', HOST), false);
|
|
208
|
+
assert.equal(knownHostsLineMatchesHost(`${HOST} ssh-ed25519 K`, ''), false, 'no hostname → match nothing');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// --- host-key staleness ----------------------------------------------------
|
|
212
|
+
|
|
213
|
+
test('reportedKeysAreStale agrees with the server-side boxes.hostKeysAreStale', () => {
|
|
214
|
+
const cases = [
|
|
215
|
+
{ hostKeysAt: '2026-07-01T10:00:00Z', activeSince: '2026-07-01T09:00:00Z' }, // fresh
|
|
216
|
+
{ hostKeysAt: '2026-07-01T08:00:00Z', activeSince: '2026-07-01T09:00:00Z' }, // rebuilt since
|
|
217
|
+
{ hostKeysAt: null, activeSince: '2026-07-01T09:00:00Z' },
|
|
218
|
+
{ hostKeysAt: '2026-07-01T10:00:00Z', activeSince: null },
|
|
219
|
+
{ hostKeysAt: null, activeSince: null },
|
|
220
|
+
{ hostKeysAt: 'not-a-date', activeSince: '2026-07-01T09:00:00Z' },
|
|
221
|
+
];
|
|
222
|
+
const expected = [false, true, true, true, true, true];
|
|
223
|
+
for (const [i, c] of cases.entries()) {
|
|
224
|
+
assert.equal(reportedKeysAreStale(c), expected[i], `app helper, case ${i}: ${JSON.stringify(c)}`);
|
|
225
|
+
assert.equal(boxes.hostKeysAreStale(c), expected[i], `server helper, case ${i}: ${JSON.stringify(c)}`);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('hostKeysAreStale accepts Date objects and an equal timestamp is fresh', () => {
|
|
230
|
+
const t = new Date('2026-07-01T09:00:00Z');
|
|
231
|
+
assert.equal(boxes.hostKeysAreStale({ hostKeysAt: t, activeSince: t }), false);
|
|
232
|
+
assert.equal(reportedKeysAreStale({ hostKeysAt: t, activeSince: t }), false);
|
|
233
|
+
assert.equal(boxes.hostKeysAreStale({ hostKeysAt: new Date('2026-07-01T08:59:59Z'), activeSince: t }), true);
|
|
234
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// tests/docs_openapi_route.mjs
|
|
2
|
+
//
|
|
3
|
+
// /docs/openapi.json is SELF-HEALING (task 1002200): it serves the committed
|
|
4
|
+
// OpenAPI spec when present (instance repo → core package) and otherwise GENERATES
|
|
5
|
+
// it in-process from the live route source. This guards both the happy path (the
|
|
6
|
+
// route returns a valid 3.1 spec with CORS) and the fallback ladder
|
|
7
|
+
// (resolveOpenapiJson prefers committed files, skips corrupt/missing ones, and
|
|
8
|
+
// falls back to generation) — the exact failure that 404'd cloudbongos.com's /docs
|
|
9
|
+
// (a pinned @bongos/core whose package shipped public-docs/ but not docs/api/).
|
|
10
|
+
//
|
|
11
|
+
// serve-internal is DB-free (the pg pool connects lazily), so we mount it on a
|
|
12
|
+
// throwaway express app and hit the route over loopback — same pattern as
|
|
13
|
+
// tests/client_bundle_route.mjs.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test tests/docs_openapi_route.mjs
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const { resolveOpenapiJson } = require('../src/bongos/serve-internal.js');
|
|
28
|
+
|
|
29
|
+
async function withServer(fn) {
|
|
30
|
+
const express = require('express');
|
|
31
|
+
const { mountInternalSurfaces } = require('../src/bongos/serve-internal.js');
|
|
32
|
+
const app = express();
|
|
33
|
+
mountInternalSurfaces(app);
|
|
34
|
+
const server = await new Promise((resolve) => {
|
|
35
|
+
const s = app.listen(0, () => resolve(s));
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
await fn(`http://127.0.0.1:${server.address().port}`);
|
|
39
|
+
} finally {
|
|
40
|
+
await new Promise((r) => server.close(r));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('/docs/openapi.json serves a valid OpenAPI 3.1 spec with CORS', async () => {
|
|
45
|
+
await withServer(async (base) => {
|
|
46
|
+
const res = await fetch(base + '/docs/openapi.json');
|
|
47
|
+
assert.equal(res.status, 200);
|
|
48
|
+
assert.match(res.headers.get('content-type') || '', /application\/json/);
|
|
49
|
+
assert.equal(res.headers.get('access-control-allow-origin'), '*', 'spec is CORS-open for external tools');
|
|
50
|
+
const body = await res.text();
|
|
51
|
+
const spec = JSON.parse(body);
|
|
52
|
+
assert.equal(spec.openapi, '3.1.0');
|
|
53
|
+
assert.ok(spec.paths && Object.keys(spec.paths).length > 0, 'spec advertises paths');
|
|
54
|
+
assert.ok(spec.info && spec.info.title, 'spec carries an info.title');
|
|
55
|
+
// Zero behaviour change on a healthy deploy: when the committed spec is present
|
|
56
|
+
// (the common case) it is served verbatim — the committed file wins, generation
|
|
57
|
+
// is only the fallback tier.
|
|
58
|
+
const committed = fs.readFileSync(path.join(ROOT, 'docs/api/openapi.json'), 'utf8');
|
|
59
|
+
assert.equal(body, committed, 'served bytes equal the committed spec (committed file preferred over generation)');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('/docs Redoc page points at the same-origin spec URL it now always serves', async () => {
|
|
64
|
+
await withServer(async (base) => {
|
|
65
|
+
const res = await fetch(base + '/docs');
|
|
66
|
+
assert.equal(res.status, 200);
|
|
67
|
+
const html = await res.text();
|
|
68
|
+
assert.match(html, /spec-url="\/docs\/openapi\.json"/, 'Redoc host page fetches /docs/openapi.json');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('resolveOpenapiJson: serves the first committed candidate that parses', () => {
|
|
73
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-'));
|
|
74
|
+
const first = path.join(dir, 'a.json');
|
|
75
|
+
const second = path.join(dir, 'b.json');
|
|
76
|
+
fs.writeFileSync(first, '{"openapi":"3.1.0","from":"a"}');
|
|
77
|
+
fs.writeFileSync(second, '{"openapi":"3.1.0","from":"b"}');
|
|
78
|
+
const out = resolveOpenapiJson({ candidates: [first, second], generate: () => 'GENERATED' });
|
|
79
|
+
assert.equal(JSON.parse(out).from, 'a', 'first existing candidate wins (instance repo before core package)');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('resolveOpenapiJson: skips a missing candidate, then a corrupt one, then generates', () => {
|
|
83
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-'));
|
|
84
|
+
const corrupt = path.join(dir, 'corrupt.json');
|
|
85
|
+
fs.writeFileSync(corrupt, '{ this is not valid json ');
|
|
86
|
+
const out = resolveOpenapiJson({
|
|
87
|
+
candidates: [path.join(dir, 'missing.json'), corrupt],
|
|
88
|
+
generate: () => 'GENERATED',
|
|
89
|
+
});
|
|
90
|
+
assert.equal(out, 'GENERATED', 'a missing file and a corrupt file both fall through to generation');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('resolveOpenapiJson: degrades to empty string (→ 404) instead of crashing', () => {
|
|
94
|
+
assert.equal(resolveOpenapiJson({ candidates: [], generate: () => '' }), '');
|
|
95
|
+
assert.equal(
|
|
96
|
+
resolveOpenapiJson({ candidates: ['/no/such/file.json'], generate() { throw new Error('boom'); } }),
|
|
97
|
+
'',
|
|
98
|
+
'a throwing generator degrades to 404, never takes down the route',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('the in-process generation tier really yields a structurally valid spec', () => {
|
|
103
|
+
// This is the tier that heals a standalone/missing-file deploy: no file on disk,
|
|
104
|
+
// spec built from the live route source. Prove it produces a valid OpenAPI 3.1.
|
|
105
|
+
const gen = require('../scripts/gds/gen-api-docs.js');
|
|
106
|
+
const { spec } = gen.build();
|
|
107
|
+
assert.equal(spec.openapi, '3.1.0');
|
|
108
|
+
assert.ok(Object.keys(spec.paths).length > 0, 'generated spec advertises paths');
|
|
109
|
+
assert.equal(gen.validateOpenapi(spec).length, 0, 'generated spec is structurally valid OpenAPI 3.1');
|
|
110
|
+
});
|