@bongos/core 1.19.1073 → 1.19.1075
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 +404 -549
- package/.claude/skills/feedback/SKILL.md +2 -2
- package/bin/bongos.js +1 -3
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -60
- package/clients/bongos-client/index.cjs +4 -60
- package/clients/bongos-client/index.d.ts +5 -90
- package/clients/bongos-client/index.mjs +4 -60
- package/config/branding.neutral.json +1 -5
- package/config/modules.neutral.json +2 -3
- package/config/scheduled-routines.json +0 -2
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
- package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
- package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
- package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +2 -0
- package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +175 -1665
- package/docs/api-reference.md +9 -41
- package/docs/architecture.md +16 -1
- package/docs/branding-contract.md +0 -3
- package/docs/copy-inventory.md +454 -479
- package/docs/copy-registry.json +746 -978
- package/docs/file-map.md +5 -34
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +13 -1
- package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
- package/docs/onboarding/diagrams/README.md +3 -3
- package/docs/onboarding/diagrams/assertions.json +2 -22
- package/docs/onboarding/primer.md +9 -28
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +1032 -1077
- package/docs/recipes/render-pilot.md +29 -10
- package/migrations/core_257_module_store_registry.sql +87 -0
- package/migrations/core_258_drop_dev_box_tables.sql +55 -0
- package/modules/agents/module.json +1 -0
- package/modules/autonomy/module.json +1 -0
- package/modules/builder-settings/module.json +1 -0
- package/modules/builder-settings/render-prefs.js +4 -5
- package/modules/copy-desk/docx.js +78 -5
- package/modules/copy-desk/module.json +1 -0
- package/modules/copy-desk/routes/copy-desk.js +10 -10
- package/modules/copy-desk/tests/copy_docx.mjs +107 -1
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
- package/modules/discord/module.json +1 -0
- package/modules/discord/ship-broadcast.js +3 -3
- package/modules/economy/credits.js +2 -2
- package/modules/economy/module.json +1 -0
- package/modules/economy/routes/credits.js +1 -2
- package/modules/government/catalog.js +13 -17
- package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
- package/modules/government/module.json +1 -0
- package/modules/government/protected-surfaces.json +3 -11
- package/modules/government/resolver.js +3 -46
- package/modules/government/routes/government.js +0 -2
- package/modules/government/session-scopes.js +43 -83
- package/modules/government/session-scopes.json +8 -18
- package/modules/grading/module.json +1 -0
- package/modules/hall-ui/module.json +1 -0
- package/modules/hall-ui/public/board-lib.js +2 -2
- package/modules/hall-ui/public/builders.js +29 -40
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/diagrams.html +3 -3
- package/modules/hall-ui/public/dom-utils.js +1 -1
- package/modules/hall-ui/public/gate.js +2 -2
- package/modules/hall-ui/public/gate.states.json +1 -1
- package/modules/hall-ui/public/hall-render.js +43 -79
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.js +1 -0
- package/modules/hall-ui/public/oversight.css +5 -5
- package/modules/hall-ui/public/palette.js +0 -11
- package/modules/hall-ui/public/primer.js +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-sessions.js +1 -1
- package/modules/hall-ui/public/settings.css +0 -1
- package/modules/hall-ui/public/settings.html +4 -33
- package/modules/hall-ui/public/settings.js +3 -33
- package/modules/hall-ui/public/settings.states.json +2 -2
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +10 -13
- package/modules/hall-ui/public/task.html +4 -1
- package/modules/hall-ui/public/task.js +36 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
- package/modules/hall-ui/public/tweak-editor.css +16 -0
- package/modules/hall-ui/public/tweak-editor.html +26 -3
- package/modules/hall-ui/public/tweak-editor.js +32 -1
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/hall-ui/records/tweak-editor.md +1 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/projection.js +1 -1
- package/modules/lifecycle/db-claim-reads.js +1 -58
- package/modules/lifecycle/db-ship.js +4 -9
- package/modules/lifecycle/db.js +0 -4
- package/modules/lifecycle/github-push.js +12 -14
- package/modules/lifecycle/kickoff-checklist.js +0 -4
- package/modules/lifecycle/lifecycle.js +0 -8
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/publish-reconciler.js +10 -10
- package/modules/lifecycle/routes/tasks.js +13 -15
- package/modules/lifecycle/ship-card.js +1 -1
- package/modules/lifecycle/task-visuals.js +2 -2
- package/modules/memory/module.json +1 -0
- package/modules/memory/routes/memory.js +2 -2
- package/modules/npm-release/module.json +1 -0
- package/modules/onboarding/module.json +1 -0
- package/modules/onboarding/onboarding-state.js +17 -36
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/module.json +1 -0
- package/modules/platform-identity/platform-identity.js +13 -5
- package/modules/platform-identity/routes/sso.js +5 -4
- package/modules/platform-identity/tests/platform-identity.mjs +17 -3
- package/modules/provisioning/capacity.js +1 -1
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/provisioning.js +6 -5
- package/modules/provisioning/routes/provisioning.js +4 -4
- package/modules/provisioning/starter-bundles.js +5 -25
- package/modules/public-landing/module.json +1 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/contact.html +1 -1
- package/modules/public-landing/public/privacy.html +1 -1
- package/modules/public-landing/public/projects.html +101 -32
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +4 -3
- package/modules/public-landing/public/terms.html +1 -1
- package/modules/security/module.json +1 -0
- package/modules/sessions/db.js +1 -1
- package/modules/sessions/module.json +1 -0
- package/modules/specialities/module.json +1 -0
- package/modules/status-ui/module.json +1 -0
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +84 -0
- package/scripts/gds/artifact-format.js +203 -0
- package/scripts/gds/artifact-staleness.js +2 -2
- package/scripts/gds/audit-authorship.sh +1 -13
- package/scripts/gds/backfill-task-rewards.js +5 -5
- package/scripts/gds/claim.js +0 -15
- package/scripts/gds/claude-materialize.js +2 -2
- package/scripts/gds/cli-lib.js +15 -15
- package/scripts/gds/codemod-rename-src-gds.js +1 -1
- package/scripts/gds/context-pack.js +5 -7
- package/scripts/gds/diagram-facts.js +12 -23
- package/scripts/gds/do-api.js +13 -80
- package/scripts/gds/doc-cli-guard.js +2 -0
- package/scripts/gds/doctor.js +4 -4
- package/scripts/gds/feedback-latest.js +139 -9
- package/scripts/gds/fitness-checks-identity.js +2 -7
- package/scripts/gds/fitness-checks-packaging.js +4 -4
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/gds/fitness-ratchets.js +5 -5
- package/scripts/gds/fitness.js +10 -10
- package/scripts/gds/gen-api-client.js +4 -4
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/gen-diagrams.js +49 -71
- package/scripts/gds/gen-repo-map.js +13 -14
- package/scripts/gds/gen-session-index.js +8 -8
- package/scripts/gds/http-api-client.js +12 -19
- package/scripts/gds/init.js +1 -1
- package/scripts/gds/leak-scan-allowlist.js +1 -1
- package/scripts/gds/lint-infra-exec.js +1 -1
- package/scripts/gds/local-preview-lib.js +8 -39
- package/scripts/gds/local-preview.js +2 -15
- package/scripts/gds/migration-namespace.js +3 -3
- package/scripts/gds/module-artifact.js +212 -0
- package/scripts/gds/module.js +95 -6
- package/scripts/gds/package-core.js +5 -116
- package/scripts/gds/plain-cards.js +1 -1
- package/scripts/gds/provision-config.js +2 -2
- package/scripts/gds/provision-net.js +2 -3
- package/scripts/gds/provision.js +9 -9
- package/scripts/gds/publish-manifest.js +1 -2
- package/scripts/gds/push-path-brief.js +10 -10
- package/scripts/gds/regen-instance-docs.js +1 -1
- package/scripts/gds/rename-history-check.js +1 -1
- package/scripts/gds/run-unit-tests.js +2 -16
- package/scripts/gds/sandbox-stage.js +39 -341
- package/scripts/gds/session-digest-build.js +2 -2
- package/scripts/gds/ship-deploy-target.js +13 -13
- package/scripts/gds/ship-flow.js +7 -7
- package/scripts/gds/ship-land.js +9 -10
- package/scripts/gds/ship-merge.js +4 -4
- package/scripts/gds/ship-preflight-steps.js +4 -4
- package/scripts/gds/ship-regen.js +15 -49
- package/scripts/gds/ship.js +13 -33
- package/scripts/gds/skill-preflight.js +9 -8
- package/scripts/gds/start.js +0 -15
- package/scripts/hall-preview/README.md +5 -5
- package/scripts/hall-preview/server.js +6 -6
- package/scripts/render-diagrams.sh +1 -1
- package/src/bongos/api-errors.js +1 -1
- package/src/bongos/api-prefix.js +4 -4
- package/src/bongos/auth-github.js +2 -3
- package/src/bongos/auth.js +51 -126
- package/src/bongos/db-kernel.js +2 -3
- package/src/bongos/db.js +5 -28
- package/src/bongos/module-scope-map.js +14 -29
- package/src/bongos/module-store.js +141 -0
- package/src/bongos/platform-visibility-gate.js +1 -1
- package/src/bongos/route-rank-check.js +3 -16
- package/src/bongos/routes/auth.js +0 -116
- package/src/bongos/routes/builders.js +5 -15
- package/src/bongos/routes/instance.js +8 -14
- package/src/bongos/routes/me.js +7 -23
- package/src/bongos/routes/modules.js +83 -0
- package/src/bongos/routes/security.js +3 -3
- package/src/bongos/routes.js +7 -2
- package/src/bongos/serve-internal.js +5 -6
- package/src/bongos-downloads.js +1 -6
- package/src/branding.js +2 -2
- package/src/build-info.js +4 -6
- package/src/instance-config.js +2 -2
- package/src/module-api.js +13 -6
- package/src/module-loader/catalog.js +3 -0
- package/src/module-loader/loader.js +4 -4
- package/src/module-loader/manifest-schema.js +16 -8
- package/src/module-seams.js +2 -3
- package/src/modules.js +42 -14
- package/tests/agents_spend_guard.mjs +1 -1
- package/tests/api_alias_caller_ratchet.mjs +1 -1
- package/tests/api_alias_exceptions.mjs +1 -1
- package/tests/api_client.mjs +1 -16
- package/tests/api_path_404.mjs +1 -1
- package/tests/auth_page_gate.mjs +10 -10
- package/tests/backfill_task_rewards.mjs +1 -1
- package/tests/canonical_profile_url.mjs +1 -3
- package/tests/claim_action.mjs +4 -1
- package/tests/claim_from_task_and_home.mjs +127 -0
- package/tests/{box_ship_permissions.mjs → cli_allowlist_permissions.mjs} +5 -5
- package/tests/cli_sessions.mjs +1 -1
- package/tests/consumer_layout_boot.mjs +2 -0
- package/tests/context_pack.mjs +13 -13
- package/tests/copy_desk_page_docx.mjs +33 -2
- package/tests/copy_inventory.mjs +7 -7
- package/tests/core_upgrade_runner.mjs +6 -6
- package/tests/credit_grant.mjs +2 -2
- package/tests/criterion_suggest.mjs +1 -1
- package/tests/deploy_divergence_line.mjs +11 -11
- package/tests/design_tokens_sync.mjs +3 -2
- package/tests/diagram_facts_offset.mjs +1 -1
- package/tests/do_api.mjs +106 -0
- package/tests/feedback_latest.mjs +122 -0
- package/tests/fitness.mjs +44 -42
- package/tests/fitness_ratchets.mjs +2 -2
- package/tests/gate_approvals.mjs +0 -1
- package/tests/github_push_land_proof.mjs +1 -1
- package/tests/go_live.mjs +3 -3
- package/tests/goal_advisory.mjs +3 -3
- package/tests/goal_suggest.mjs +3 -3
- package/tests/government_abuse_matrix.mjs +21 -19
- package/tests/government_ownership_scope.mjs +33 -35
- package/tests/government_parity.mjs +2 -2
- package/tests/government_protected_surfaces.mjs +56 -37
- package/tests/government_require_permission.mjs +3 -3
- package/tests/government_seed.mjs +37 -4
- package/tests/government_session_scope.mjs +92 -366
- package/tests/hall_audit.mjs +1 -1
- package/tests/hall_error_envelope.mjs +4 -0
- package/tests/hall_landing_boot.mjs +4 -1
- package/tests/hall_palette.mjs +1 -16
- package/tests/hall_record_world.mjs +6 -0
- package/tests/hall_tweak_editor.mjs +52 -1
- package/tests/helpers.mjs +1 -1
- package/tests/host_topology_skips.mjs +3 -4
- package/tests/html_comment_nesting.mjs +137 -0
- package/tests/infra_exec_paths.mjs +9 -10
- package/tests/init.mjs +4 -5
- package/tests/instance_manifest.mjs +18 -25
- package/tests/kickoff_checklist.mjs +1 -1
- package/tests/lib_sh_resolution.mjs +0 -221
- package/tests/local_preview.mjs +0 -12
- package/tests/migration_namespace.mjs +6 -6
- package/tests/module-scope-map.mjs +9 -9
- package/tests/module_api.mjs +2 -2
- package/tests/module_catalog.mjs +1 -1
- package/tests/module_cli.mjs +20 -20
- package/tests/module_contributions.mjs +6 -6
- package/tests/module_loader.mjs +6 -6
- package/tests/module_manifest.mjs +26 -17
- package/tests/module_route_rank.mjs +2 -2
- package/tests/module_store_publish.mjs +307 -0
- package/tests/module_store_publish_route.mjs +121 -0
- package/tests/module_store_registry_migration.mjs +70 -0
- package/tests/modules.mjs +56 -26
- package/tests/modules_route_wiring.mjs +1 -1
- package/tests/onboarding_route_signals.mjs +21 -31
- package/tests/onboarding_state.mjs +35 -49
- package/tests/paste_token_session_store.mjs +1 -1
- package/tests/permission_path.mjs +0 -3
- package/tests/platform_boot.mjs +1 -2
- package/tests/platform_visibility_gate.mjs +1 -1
- package/tests/profile_route.mjs +1 -6
- package/tests/project_modules_ui.mjs +17 -17
- package/tests/projects_hub.mjs +5 -5
- package/tests/projects_hub_app_step.mjs +138 -0
- package/tests/projects_hub_module_picker.mjs +77 -76
- package/tests/projects_hub_pre_uat.mjs +4 -5
- package/tests/provision.mjs +7 -7
- package/tests/provisioning_capacity.mjs +1 -1
- package/tests/provisioning_recommendations.mjs +2 -1
- package/tests/provisioning_starter_bundles.mjs +0 -22
- package/tests/publish_branch_route.mjs +11 -19
- package/tests/publish_manifest.mjs +2 -3
- package/tests/publish_reconciler.mjs +8 -8
- package/tests/push_path_brief.mjs +10 -9
- package/tests/rank_tier_single_source.mjs +1 -13
- package/tests/regrade_eligibility.mjs +1 -1
- package/tests/rename_history_restraint.mjs +1 -1
- package/tests/repo_map.mjs +12 -13
- package/tests/runner_drift.mjs +3 -21
- package/tests/sandbox_stage.mjs +57 -390
- package/tests/seam_wiring_guard.mjs +11 -11
- package/tests/search_isolation.mjs +3 -3
- package/tests/session_records.mjs +1 -1
- package/tests/session_rename_fallback.mjs +1 -22
- package/tests/session_start_freshness.mjs +3 -3
- package/tests/session_token_hash_db.mjs +1 -1
- package/tests/ship_card.mjs +1 -1
- package/tests/ship_ci_deploy.mjs +17 -17
- package/tests/ship_error_shape.mjs +1 -1
- package/tests/ship_premerge.mjs +1 -1
- package/tests/ship_resume.mjs +1 -1
- package/tests/skill_preflight.mjs +2 -2
- package/tests/skip_is_not_pass.mjs +1 -1
- package/tests/task_visual_slots.mjs +31 -0
- package/tests/terms_acceptance.mjs +2 -2
- package/tests/upgrade.mjs +4 -4
- package/tests/watch_sealed_floor.mjs +0 -1
- package/tests/wizard_draft_resume.mjs +2 -2
- package/tests/wizard_intent_resume.mjs +21 -10
- package/tests/wizard_preselect_why.mjs +18 -18
- package/docs/onboarding/browser-terminal-guide.md +0 -73
- package/docs/recipes/managed-settings-remote-control.md +0 -416
- package/modules/dev-box/CLAUDE.md +0 -15
- package/modules/dev-box/box-access.js +0 -623
- package/modules/dev-box/box-credential.js +0 -94
- package/modules/dev-box/box-onboard.js +0 -486
- package/modules/dev-box/boxes.js +0 -1038
- package/modules/dev-box/db.js +0 -39
- package/modules/dev-box/module.json +0 -19
- package/modules/dev-box/routes/box.js +0 -1223
- package/scripts/gds/box-auth-check.js +0 -160
- package/scripts/gds/box-infra.js +0 -321
- package/scripts/gds/box-sync.js +0 -216
- package/scripts/gds/box.js +0 -1332
- package/scripts/gds/cf-tunnel.js +0 -558
- package/scripts/gds/smoke-box.sh +0 -150
- package/scripts/gds/tree-preflight.js +0 -241
- package/src/bongos/app-pair.js +0 -172
- package/tests/app_pair.mjs +0 -168
- package/tests/box_access.mjs +0 -578
- package/tests/box_auth_check.mjs +0 -114
- package/tests/box_code_staleness.mjs +0 -129
- package/tests/box_connect_e2e.mjs +0 -196
- package/tests/box_cost.mjs +0 -70
- package/tests/box_credential.mjs +0 -125
- package/tests/box_dns_repoint.mjs +0 -153
- package/tests/box_env.mjs +0 -64
- package/tests/box_host_keys.mjs +0 -126
- package/tests/box_infra_resolve.mjs +0 -246
- package/tests/box_onboard.mjs +0 -317
- package/tests/box_scope_session.mjs +0 -119
- package/tests/box_sweep_docs.mjs +0 -119
- package/tests/box_sync.mjs +0 -55
- package/tests/box_sync_scope_report.mjs +0 -126
- package/tests/box_task_scope.mjs +0 -169
- package/tests/box_task_scope_pause.mjs +0 -66
- package/tests/box_terminal_ssrf.mjs +0 -96
- package/tests/boxes.mjs +0 -1585
- package/tests/cf_ruleset_preflight.mjs +0 -142
- package/tests/cf_tunnel.mjs +0 -496
- package/tests/docs_scrubber_damage.mjs +0 -111
- package/tests/module_scope_active_claims.mjs +0 -94
- package/tests/ship_api_client_pathspec.mjs +0 -100
- package/tests/tree_preflight.mjs +0 -243
|
@@ -12,11 +12,11 @@ const { satisfies, isValidRange, parseVersion } = require(path.join(ROOT, 'src',
|
|
|
12
12
|
const { validateManifest, assertValidManifest } = require(path.join(ROOT, 'src', 'module-loader', 'manifest-schema.js'));
|
|
13
13
|
|
|
14
14
|
const baseManifest = () => ({
|
|
15
|
-
key: '
|
|
16
|
-
title: '
|
|
17
|
-
description: '
|
|
18
|
-
coreVersion: '^1.0.0',
|
|
19
|
-
contributes: { routes: ['
|
|
15
|
+
key: 'discord',
|
|
16
|
+
title: 'Discord',
|
|
17
|
+
description: 'A bridge to the team chat.',
|
|
18
|
+
version: '1.0.0', coreVersion: '^1.0.0',
|
|
19
|
+
contributes: { routes: ['discord'], pollers: ['discord-bot'], uiSections: ['chat'] },
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
// --- semver ---
|
|
@@ -62,7 +62,7 @@ test('isValidRange', () => {
|
|
|
62
62
|
// --- manifest validation ---
|
|
63
63
|
|
|
64
64
|
test('a minimal valid manifest passes', () => {
|
|
65
|
-
const r = validateManifest({ key: 'x', title: 'X', description: 'd', coreVersion: '^1.0.0', contributes: {} });
|
|
65
|
+
const r = validateManifest({ key: 'x', title: 'X', description: 'd', version: '1.0.0', coreVersion: '^1.0.0', contributes: {} });
|
|
66
66
|
assert.equal(r.valid, true, r.errors.join('; '));
|
|
67
67
|
});
|
|
68
68
|
|
|
@@ -81,7 +81,7 @@ test('a full valid manifest passes (provides/consumes/prerequisites/migrations)'
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
test('missing required fields each produce an error', () => {
|
|
84
|
-
for (const field of ['key', 'title', 'description', 'coreVersion']) {
|
|
84
|
+
for (const field of ['key', 'title', 'description', 'version', 'coreVersion']) {
|
|
85
85
|
const m = baseManifest();
|
|
86
86
|
delete m[field];
|
|
87
87
|
const r = validateManifest(m);
|
|
@@ -91,25 +91,34 @@ test('missing required fields each produce an error', () => {
|
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
test('bad key format is rejected', () => {
|
|
94
|
-
const r = validateManifest({ ...baseManifest(), key: '
|
|
94
|
+
const r = validateManifest({ ...baseManifest(), key: 'Discord_Bot' });
|
|
95
95
|
assert.equal(r.valid, false);
|
|
96
96
|
assert.ok(r.errors.some((e) => e.startsWith('key')));
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
test('invalid coreVersion range is rejected', () => {
|
|
100
|
-
const r = validateManifest({ ...baseManifest(), coreVersion: 'whenever' });
|
|
100
|
+
const r = validateManifest({ ...baseManifest(), version: '1.0.0', coreVersion: 'whenever' });
|
|
101
101
|
assert.equal(r.valid, false);
|
|
102
102
|
assert.ok(r.errors.some((e) => e.startsWith('coreVersion')));
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test('version must be an exact X.Y.Z — a range or junk is rejected (task 1003781)', () => {
|
|
106
|
+
for (const bad of ['^1.0.0', '1.0', 'v1.0.0', '1.0.0-beta', '']) {
|
|
107
|
+
const r = validateManifest({ ...baseManifest(), version: bad });
|
|
108
|
+
assert.equal(r.valid, false, `"${bad}" should be rejected`);
|
|
109
|
+
assert.ok(r.errors.some((e) => e.startsWith('version')));
|
|
110
|
+
}
|
|
111
|
+
assert.equal(validateManifest({ ...baseManifest(), version: '2.10.3' }).valid, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
105
114
|
test('unknown contributes key is rejected (typo guard)', () => {
|
|
106
|
-
const r = validateManifest({ ...baseManifest(), contributes: { routs: ['
|
|
115
|
+
const r = validateManifest({ ...baseManifest(), contributes: { routs: ['discord'] } });
|
|
107
116
|
assert.equal(r.valid, false);
|
|
108
117
|
assert.ok(r.errors.some((e) => e.includes('contributes.routs')));
|
|
109
118
|
});
|
|
110
119
|
|
|
111
120
|
test('non-array contributes.routes is rejected', () => {
|
|
112
|
-
const r = validateManifest({ ...baseManifest(), contributes: { routes: '
|
|
121
|
+
const r = validateManifest({ ...baseManifest(), contributes: { routes: 'discord' } });
|
|
113
122
|
assert.equal(r.valid, false);
|
|
114
123
|
assert.ok(r.errors.some((e) => e.includes('contributes.routes')));
|
|
115
124
|
});
|
|
@@ -287,7 +296,7 @@ test('every bundled module declares a maintenance posture (the catalog has no un
|
|
|
287
296
|
test('declarativeSeams: a valid exemption naming a declared seam + entry passes', () => {
|
|
288
297
|
const r = validateManifest({
|
|
289
298
|
...baseManifest(),
|
|
290
|
-
declarativeSeams: { pollers: ['
|
|
299
|
+
declarativeSeams: { pollers: ['discord-bot'], uiSections: ['chat'] },
|
|
291
300
|
});
|
|
292
301
|
assert.equal(r.valid, true, r.errors.join('; '));
|
|
293
302
|
});
|
|
@@ -295,8 +304,8 @@ test('declarativeSeams: a valid exemption naming a declared seam + entry passes'
|
|
|
295
304
|
test('declarativeSeams: an exemption for a seam the module does not declare is an ERROR', () => {
|
|
296
305
|
const r = validateManifest({
|
|
297
306
|
...baseManifest(),
|
|
298
|
-
contributes: { routes: ['
|
|
299
|
-
declarativeSeams: { pollers: ['
|
|
307
|
+
contributes: { routes: ['discord'] }, // no pollers declared
|
|
308
|
+
declarativeSeams: { pollers: ['discord-bot'] },
|
|
300
309
|
});
|
|
301
310
|
assert.equal(r.valid, false);
|
|
302
311
|
assert.ok(r.errors.some((e) => /does not declare contributes\.pollers/.test(e)), r.errors.join('; '));
|
|
@@ -305,10 +314,10 @@ test('declarativeSeams: an exemption for a seam the module does not declare is a
|
|
|
305
314
|
test('declarativeSeams: an exemption naming an ENTRY the module does not list is an ERROR (the typo case)', () => {
|
|
306
315
|
const r = validateManifest({
|
|
307
316
|
...baseManifest(),
|
|
308
|
-
declarativeSeams: { pollers: ['
|
|
317
|
+
declarativeSeams: { pollers: ['discord-bto'] }, // transposed letters
|
|
309
318
|
});
|
|
310
319
|
assert.equal(r.valid, false);
|
|
311
|
-
assert.ok(r.errors.some((e) => /"
|
|
320
|
+
assert.ok(r.errors.some((e) => /"discord-bto" is not in contributes\.pollers/.test(e)), r.errors.join('; '));
|
|
312
321
|
});
|
|
313
322
|
|
|
314
323
|
test('declarativeSeams: the per-entry check spans EVERY seam, not just bare string arrays', () => {
|
|
@@ -335,7 +344,7 @@ test('declarativeSeams: an unknown seam key is an ERROR, not a silent no-op', ()
|
|
|
335
344
|
|
|
336
345
|
test('declarativeSeams: shape errors — non-object block, non-string-array entries', () => {
|
|
337
346
|
assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: ['pollers'] }).valid, false);
|
|
338
|
-
assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: '
|
|
347
|
+
assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: 'discord-bot' } }).valid, false);
|
|
339
348
|
assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: [''] } }).valid, false);
|
|
340
349
|
});
|
|
341
350
|
|
|
@@ -91,9 +91,9 @@ test('checkRouteRanks: an absent modules/ dir leaves the core result clean (byte
|
|
|
91
91
|
|
|
92
92
|
// ---- the grade-time trigger fires on a module route change ----
|
|
93
93
|
test('ROUTE_FILE_RE matches a module route path (a module route change triggers the rank pre-pass)', () => {
|
|
94
|
-
assert.match('modules/
|
|
94
|
+
assert.match('modules/discord/routes/discord.js', rrc.ROUTE_FILE_RE);
|
|
95
95
|
assert.match('src/bongos/routes/tasks.js', rrc.ROUTE_FILE_RE); // core still matches
|
|
96
|
-
assert.ok(!rrc.ROUTE_FILE_RE.test('modules/
|
|
96
|
+
assert.ok(!rrc.ROUTE_FILE_RE.test('modules/discord/module.json'), 'a non-route module file does not trigger the pre-pass');
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
// ---- a rank DRIFT on a pinned MODULE route reds the build (BV1.R61) ----
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// tests/module_store_publish.mjs — `bongos module publish` end to end without a
|
|
2
|
+
// network or a database (task 1004271, ADR 0338 D1): the pack/verify pair in
|
|
3
|
+
// scripts/gds/module-artifact.js, the store's stage/commit + registry transaction in
|
|
4
|
+
// src/bongos/module-store.js (against a fake pg client), and cmdPublish's refusals.
|
|
5
|
+
//
|
|
6
|
+
// Run: node tests/module_store_publish.mjs
|
|
7
|
+
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const artifact = require('../scripts/gds/module-artifact.js');
|
|
17
|
+
const format = require('../scripts/gds/artifact-format.js');
|
|
18
|
+
const store = require('../src/bongos/module-store.js');
|
|
19
|
+
const moduleCli = require('../scripts/gds/module.js');
|
|
20
|
+
|
|
21
|
+
const NOW = () => new Date('2026-09-28T00:00:00Z');
|
|
22
|
+
|
|
23
|
+
function moduleJson(over = {}) {
|
|
24
|
+
return {
|
|
25
|
+
key: 'weather', title: 'Weather', description: 'Shows the weather.',
|
|
26
|
+
version: '1.2.0', coreVersion: '^1.0.0', contributes: {}, ...over,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A temp modules/ root holding one module. files: { relPath: content }.
|
|
31
|
+
function fixture(files = {}, mj = moduleJson()) {
|
|
32
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-publish-'));
|
|
33
|
+
const dir = path.join(root, mj.key);
|
|
34
|
+
const all = { 'module.json': JSON.stringify(mj, null, 2), 'routes/weather.js': 'module.exports = () => null;\n', ...files };
|
|
35
|
+
for (const [rel, body] of Object.entries(all)) {
|
|
36
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
38
|
+
}
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const pack = (root, key = 'weather') => artifact.packModule(key, { modulesDir: root, now: NOW, modeOf: () => '644' });
|
|
43
|
+
|
|
44
|
+
// Rebuild a tarball from { name: Buffer } with the real writer, for tamper cases.
|
|
45
|
+
function retar(tgz, edit) {
|
|
46
|
+
const entries = format.readTar(tgz);
|
|
47
|
+
const map = new Map(entries.map((e) => [e.name, e]));
|
|
48
|
+
edit(map);
|
|
49
|
+
return format.buildTar([...map.values()]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- pack ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
test('pack: the tarball is the core release format — package/ root, inner manifest, a tree pin', async () => {
|
|
55
|
+
const { tgz, manifest, sidecar } = pack(fixture());
|
|
56
|
+
const names = format.readTar(tgz).map((e) => e.name).sort();
|
|
57
|
+
assert.deepEqual(names, ['package/.bongos-module.json', 'package/module.json', 'package/routes/weather.js']);
|
|
58
|
+
assert.equal(manifest.artifact, 'bongos-module');
|
|
59
|
+
assert.equal(manifest.module_key, 'weather');
|
|
60
|
+
assert.equal(manifest.module_version, '1.2.0');
|
|
61
|
+
assert.equal(manifest.core_version, '^1.0.0');
|
|
62
|
+
assert.equal(manifest.file_count, 2);
|
|
63
|
+
assert.equal(manifest.tree_sha256, format.canonicalTreeHash(manifest.files));
|
|
64
|
+
assert.equal(sidecar.tarball.sha256, format.sha256hex(tgz));
|
|
65
|
+
assert.equal(sidecar.tarball.name, 'bongos-module-weather-1.2.0.tgz');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('pack: deterministic — the same module packs to the same bytes', async () => {
|
|
69
|
+
const root = fixture();
|
|
70
|
+
assert.equal(format.sha256hex(pack(root).tgz), format.sha256hex(pack(root).tgz));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('pack: refuses a module.json without a version, or naming another key', async () => {
|
|
74
|
+
const noVersion = moduleJson(); delete noVersion.version;
|
|
75
|
+
assert.throws(() => pack(fixture({}, noVersion)), /version/);
|
|
76
|
+
const root = fixture();
|
|
77
|
+
fs.writeFileSync(path.join(root, 'weather', 'module.json'), JSON.stringify(moduleJson({ key: 'other' })));
|
|
78
|
+
assert.throws(() => pack(root), /names key "other"/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('pack: a credential-named file anywhere in the module refuses the whole publish', async () => {
|
|
82
|
+
for (const bad of ['.env', 'config/.env.local', 'certs/server.pem', 'deploy/id_rsa']) {
|
|
83
|
+
assert.throws(() => pack(fixture({ [bad]: 'SECRET=1' })), /denylist refuses/, bad);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('pack: the repo denylist applies at the module\'s canonical path', async () => {
|
|
88
|
+
// modules/game/ is on PUBLISH_DENYLIST — a module keyed "game" can never publish.
|
|
89
|
+
const mj = moduleJson({ key: 'game' });
|
|
90
|
+
assert.throws(() => pack(fixture({}, mj), 'game'), /denylist refuses: modules\/game\//);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('pack: the local sign-off record is never packed', async () => {
|
|
94
|
+
const { tgz } = pack(fixture({ '.upstream-signoff.json': '{}' }));
|
|
95
|
+
assert.ok(!format.readTar(tgz).some((e) => e.name.endsWith('.upstream-signoff.json')));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---- verify (the store's side) ----------------------------------------------
|
|
99
|
+
|
|
100
|
+
test('verify: a freshly packed module passes and reports its own pins', async () => {
|
|
101
|
+
const { tgz, manifest } = pack(fixture());
|
|
102
|
+
const v = (await artifact.verifyModuleArtifact(tgz, { key: 'weather' }));
|
|
103
|
+
assert.equal(v.ok, true, v.message);
|
|
104
|
+
assert.equal(v.version, '1.2.0');
|
|
105
|
+
assert.equal(v.treeSha256, manifest.tree_sha256);
|
|
106
|
+
assert.equal(v.tarballSha256, format.sha256hex(tgz));
|
|
107
|
+
assert.equal(v.bytes, tgz.length);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('verify: an upload for a different key is refused', async () => {
|
|
111
|
+
const { tgz } = pack(fixture());
|
|
112
|
+
assert.equal((await artifact.verifyModuleArtifact(tgz, { key: 'forecast' })).code, 'key_mismatch');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('verify: a file changed after packing is caught by its hash', async () => {
|
|
116
|
+
const { tgz } = pack(fixture());
|
|
117
|
+
const bad = retar(tgz, (m) => { m.get('package/routes/weather.js').buf = Buffer.from('evil()'); });
|
|
118
|
+
assert.equal((await artifact.verifyModuleArtifact(bad, { key: 'weather' })).code, 'hash_mismatch');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('verify: a file smuggled in beside the manifest is caught', async () => {
|
|
122
|
+
const { tgz } = pack(fixture());
|
|
123
|
+
const bad = retar(tgz, (m) => m.set('package/extra.js', { name: 'package/extra.js', mode: 0o644, buf: Buffer.from('x') }));
|
|
124
|
+
assert.equal((await artifact.verifyModuleArtifact(bad, { key: 'weather' })).code, 'hash_mismatch');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('verify: a path that climbs out of the module is refused before anything else', async () => {
|
|
128
|
+
const { tgz } = pack(fixture());
|
|
129
|
+
const bad = retar(tgz, (m) => m.set('package/../../etc/x', { name: 'package/../../etc/x', mode: 0o644, buf: Buffer.from('x') }));
|
|
130
|
+
assert.equal((await artifact.verifyModuleArtifact(bad, { key: 'weather' })).code, 'bad_tarball');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('verify: a secret file re-hashed into a consistent manifest is still refused', async () => {
|
|
134
|
+
// A hostile client can make the hashes agree; the denylist must run server-side anyway.
|
|
135
|
+
const root = fixture();
|
|
136
|
+
const good = pack(root);
|
|
137
|
+
const entries = format.readTar(good.tgz).filter((e) => e.name !== 'package/.bongos-module.json');
|
|
138
|
+
entries.push({ name: 'package/.env', mode: 0o644, buf: Buffer.from('K=1') });
|
|
139
|
+
const files = entries.map((e) => ({ path: e.name.slice(8), mode: '0000644', sha256: format.sha256hex(e.buf) }));
|
|
140
|
+
const manifest = { ...good.manifest, files, file_count: files.length, tree_sha256: format.canonicalTreeHash(files) };
|
|
141
|
+
entries.push({ name: 'package/.bongos-module.json', mode: 0o644, buf: Buffer.from(JSON.stringify(manifest)) });
|
|
142
|
+
assert.equal((await artifact.verifyModuleArtifact(format.buildTar(entries), { key: 'weather' })).code, 'denied_content');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('verify: garbage, an empty body and an oversize body are refused plainly', async () => {
|
|
146
|
+
assert.equal((await artifact.verifyModuleArtifact(Buffer.from('not a tarball'), { key: 'weather' })).code, 'bad_tarball');
|
|
147
|
+
assert.equal((await artifact.verifyModuleArtifact(Buffer.alloc(0), { key: 'weather' })).code, 'empty_artifact');
|
|
148
|
+
const { tgz } = pack(fixture());
|
|
149
|
+
assert.equal((await artifact.verifyModuleArtifact(tgz, { key: 'weather', maxBytes: 10 })).code, 'artifact_too_large');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('verify: a small upload that inflates past the unpacked cap (a gzip bomb) is refused', async () => {
|
|
153
|
+
const bomb = format.buildTar([{ name: 'package/zeros.bin', mode: 0o644, buf: Buffer.alloc(17 * 1024 * 1024) }]);
|
|
154
|
+
assert.ok(bomb.length < artifact.MAX_TARBALL_BYTES, 'it fits the wire cap, so only the unpacked cap can catch it');
|
|
155
|
+
const v = await artifact.verifyModuleArtifact(bomb, { key: 'weather' });
|
|
156
|
+
assert.equal(v.code, 'bad_tarball');
|
|
157
|
+
assert.match(v.message, /within 16777216 bytes/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('readTar: refuses entry types the writer never makes (a symlink)', async () => {
|
|
161
|
+
const tar = Buffer.concat([format.ustarHeader({ name: 'package/link', mode: 0o644, size: 0, typeflag: '2' }), Buffer.alloc(1024)]);
|
|
162
|
+
const zlib = require('node:zlib');
|
|
163
|
+
assert.throws(() => format.readTar(zlib.gzipSync(tar)), /only regular files/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---- the store: disk + registry transaction ---------------------------------
|
|
167
|
+
|
|
168
|
+
test('store: a staged tarball becomes the final file only on commit', async () => {
|
|
169
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-store-'));
|
|
170
|
+
const staged = await store.stageArtifact(Buffer.from('tgz'), { key: 'weather', version: '1.2.0', dir });
|
|
171
|
+
assert.ok(fs.existsSync(staged.tmp));
|
|
172
|
+
assert.ok(!fs.existsSync(staged.final));
|
|
173
|
+
await store.commitArtifact(staged);
|
|
174
|
+
assert.equal(fs.readFileSync(staged.final, 'utf8'), 'tgz');
|
|
175
|
+
assert.equal(store.relativeArtifactPath(staged.final, { dir }), 'weather/weather-1.2.0.tgz');
|
|
176
|
+
await store.removeArtifact(staged.tmp); // already moved — a no-op, never a throw
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('store: a key or version that could escape the store dir is refused before any write', async () => {
|
|
180
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-store-'));
|
|
181
|
+
await assert.rejects(store.stageArtifact(Buffer.from('x'), { key: '../x', version: '1.0.0', dir }));
|
|
182
|
+
await assert.rejects(store.stageArtifact(Buffer.from('x'), { key: 'weather', version: '../../1', dir }));
|
|
183
|
+
assert.deepEqual(fs.readdirSync(dir), []);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// A fake pg pool: `state` is the registry, answers are computed from it.
|
|
187
|
+
function fakeDb(state = { modules: {}, versions: [] }) {
|
|
188
|
+
const log = [];
|
|
189
|
+
const client = {
|
|
190
|
+
async query(sql, params = []) {
|
|
191
|
+
log.push(sql.trim().split(/\s+/).slice(0, 2).join(' '));
|
|
192
|
+
if (/^SELECT module_key, author_id, status FROM store_modules/.test(sql)) {
|
|
193
|
+
const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
|
|
194
|
+
}
|
|
195
|
+
if (/^INSERT INTO store_modules/.test(sql)) {
|
|
196
|
+
state.modules[params[0]] = { module_key: params[0], author_id: params[3], status: 'listed' }; return { rows: [] };
|
|
197
|
+
}
|
|
198
|
+
if (/^SELECT version FROM store_module_versions/.test(sql)) {
|
|
199
|
+
return { rows: state.versions.filter((v) => v.module_key === params[0]) };
|
|
200
|
+
}
|
|
201
|
+
if (/^INSERT INTO store_module_versions/.test(sql)) {
|
|
202
|
+
const row = { id: state.versions.length + 1, module_key: params[0], version: params[1] };
|
|
203
|
+
state.versions.push(row); return { rows: [row] };
|
|
204
|
+
}
|
|
205
|
+
return { rows: [] };
|
|
206
|
+
},
|
|
207
|
+
release() {},
|
|
208
|
+
};
|
|
209
|
+
return { pool: { connect: async () => client }, state, log };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const input = (over = {}) => ({
|
|
213
|
+
key: 'weather', title: 'Weather', description: 'd', version: '1.2.0', coreVersion: '^1.0.0',
|
|
214
|
+
manifest: {}, treeSha256: 'a'.repeat(64), tarballSha256: 'b'.repeat(64), bytes: 10,
|
|
215
|
+
artifactPath: 'weather/weather-1.2.0.tgz', builderId: 7, ...over,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('registry: the first publish creates the module with the publisher as author', async () => {
|
|
219
|
+
const db = fakeDb();
|
|
220
|
+
const r = await store.publishVersion(input(), { db: db.pool });
|
|
221
|
+
assert.equal(r.ok, true);
|
|
222
|
+
assert.equal(r.created, true);
|
|
223
|
+
assert.equal(db.state.modules.weather.author_id, 7);
|
|
224
|
+
assert.deepEqual(db.log.filter((l) => /BEGIN|COMMIT|ROLLBACK/.test(l)), ['BEGIN', 'COMMIT']);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('registry: only the author can publish a later version', async () => {
|
|
228
|
+
const db = fakeDb({ modules: { weather: { module_key: 'weather', author_id: 7, status: 'listed' } }, versions: [] });
|
|
229
|
+
const r = await store.publishVersion(input({ builderId: 8 }), { db: db.pool });
|
|
230
|
+
assert.equal(r.ok, false);
|
|
231
|
+
assert.equal(r.status, 403);
|
|
232
|
+
assert.equal(r.code, 'not_module_author');
|
|
233
|
+
assert.equal(db.state.versions.length, 0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('registry: a delisted module takes no new version (ADR 0338 D2)', async () => {
|
|
237
|
+
const db = fakeDb({ modules: { weather: { module_key: 'weather', author_id: 7, status: 'delisted' } }, versions: [] });
|
|
238
|
+
assert.equal((await store.publishVersion(input(), { db: db.pool })).code, 'module_delisted');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('registry: a version is published once, and never older than the latest', async () => {
|
|
242
|
+
const state = { modules: { weather: { module_key: 'weather', author_id: 7, status: 'listed' } },
|
|
243
|
+
versions: [{ module_key: 'weather', version: '1.2.0' }] };
|
|
244
|
+
assert.equal((await store.publishVersion(input(), { db: fakeDb(state).pool })).code, 'version_exists');
|
|
245
|
+
assert.equal((await store.publishVersion(input({ version: '1.1.9' }), { db: fakeDb(state).pool })).code, 'version_not_newer');
|
|
246
|
+
const ok = await store.publishVersion(input({ version: '1.10.0' }), { db: fakeDb(state).pool });
|
|
247
|
+
assert.equal(ok.ok, true, 'numeric, not string, ordering: 1.10.0 > 1.2.0');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('registry: if putting the file in place fails, the row is rolled back', async () => {
|
|
251
|
+
const db = fakeDb();
|
|
252
|
+
await assert.rejects(
|
|
253
|
+
store.publishVersion(input(), { db: db.pool, beforeCommit: () => { throw new Error('disk full'); } }),
|
|
254
|
+
/disk full/);
|
|
255
|
+
assert.ok(db.log.includes('ROLLBACK'));
|
|
256
|
+
assert.ok(!db.log.includes('COMMIT'));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ---- the CLI ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
const sink = () => { const lines = []; return { lines, log: (s) => lines.push(String(s)) }; };
|
|
262
|
+
|
|
263
|
+
test('cli: --dry-run packs and reports, and never uploads', async () => {
|
|
264
|
+
const out = sink();
|
|
265
|
+
let uploaded = false;
|
|
266
|
+
const code = await moduleCli.cmdPublish(['weather', '--dry-run'], {
|
|
267
|
+
log: out.log, errlog: out.log, modulesDir: fixture(), upload: async () => { uploaded = true; },
|
|
268
|
+
});
|
|
269
|
+
assert.equal(code, 0);
|
|
270
|
+
assert.equal(uploaded, false);
|
|
271
|
+
assert.ok(out.lines.some((l) => /"weather" 1\.2\.0/.test(l)));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('cli: a module the gate refuses never reaches the network', async () => {
|
|
275
|
+
const out = sink();
|
|
276
|
+
let uploaded = false;
|
|
277
|
+
const code = await moduleCli.cmdPublish(['weather'], {
|
|
278
|
+
log: out.log, errlog: out.log, modulesDir: fixture({ '.env': 'K=1' }), upload: async () => { uploaded = true; },
|
|
279
|
+
});
|
|
280
|
+
assert.equal(code, 1);
|
|
281
|
+
assert.equal(uploaded, false);
|
|
282
|
+
assert.ok(out.lines.some((l) => /REFUSING/.test(l)));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('cli: uploads the packed bytes, and a store refusal is a failure exit with its reason', async () => {
|
|
286
|
+
const root = fixture();
|
|
287
|
+
let sent = null;
|
|
288
|
+
const ok = await moduleCli.cmdPublish(['weather'], {
|
|
289
|
+
log: () => {}, errlog: () => {}, modulesDir: root,
|
|
290
|
+
upload: async (k, body) => { sent = { k, body }; return { ok: true, status: 201, data: { created_module: true, version: { version: '1.2.0' } } }; },
|
|
291
|
+
});
|
|
292
|
+
assert.equal(ok, 0);
|
|
293
|
+
assert.equal(sent.k, 'weather');
|
|
294
|
+
assert.equal((await artifact.verifyModuleArtifact(sent.body, { key: 'weather' })).ok, true, 'what is sent is what the store accepts');
|
|
295
|
+
|
|
296
|
+
const out = sink();
|
|
297
|
+
const refused = await moduleCli.cmdPublish(['weather'], {
|
|
298
|
+
log: () => {}, errlog: out.log, modulesDir: root,
|
|
299
|
+
upload: async () => ({ ok: false, status: 409, data: { error: { code: 'version_exists', message: 'weather 1.2.0 is already published' } } }),
|
|
300
|
+
});
|
|
301
|
+
assert.equal(refused, 1);
|
|
302
|
+
assert.ok(out.lines.some((l) => /HTTP 409/.test(l) && /already published/.test(l)));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('cli: no key is a usage error', async () => {
|
|
306
|
+
assert.equal(await moduleCli.cmdPublish([], { log: () => {}, errlog: () => {} }), 2);
|
|
307
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// tests/module_store_publish_route.mjs — the seam test for POST
|
|
2
|
+
// /store/modules/:key/versions (task 1004271). module_store_publish.mjs covers each
|
|
3
|
+
// piece; this drives the real handler off the real router, so a lost require or a
|
|
4
|
+
// mis-wired call between verify → stage → registry → commit is a failure here, not in
|
|
5
|
+
// production. Same harness shape as modules_route_wiring.mjs: no port, no real DB —
|
|
6
|
+
// src/bongos/pool.js is replaced in the require cache before the route loads, and the
|
|
7
|
+
// store dir is a temp dir.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/module_store_publish_route.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const STORE = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-store-route-'));
|
|
20
|
+
process.env.MODULE_STORE_DIR = STORE;
|
|
21
|
+
|
|
22
|
+
// The registry, as the fake pool sees it.
|
|
23
|
+
const state = { modules: {}, versions: [] };
|
|
24
|
+
const client = {
|
|
25
|
+
async query(sql, params = []) {
|
|
26
|
+
if (/^SELECT module_key, author_id, status FROM store_modules/.test(sql)) {
|
|
27
|
+
const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
|
|
28
|
+
}
|
|
29
|
+
if (/^INSERT INTO store_modules/.test(sql)) {
|
|
30
|
+
state.modules[params[0]] = { module_key: params[0], author_id: params[3], status: 'listed' }; return { rows: [] };
|
|
31
|
+
}
|
|
32
|
+
if (/^SELECT version FROM store_module_versions/.test(sql)) {
|
|
33
|
+
return { rows: state.versions.filter((v) => v.module_key === params[0]) };
|
|
34
|
+
}
|
|
35
|
+
if (/^\s*INSERT INTO store_module_versions/.test(sql)) {
|
|
36
|
+
const row = { id: state.versions.length + 1, module_key: params[0], version: params[1], artifact_path: params[7], published_by: params[8] };
|
|
37
|
+
state.versions.push(row); return { rows: [row] };
|
|
38
|
+
}
|
|
39
|
+
return { rows: [] };
|
|
40
|
+
},
|
|
41
|
+
release() {},
|
|
42
|
+
};
|
|
43
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
44
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => client, query: client.query } } };
|
|
45
|
+
|
|
46
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
47
|
+
const artifact = require('../scripts/gds/module-artifact.js');
|
|
48
|
+
|
|
49
|
+
function handlerFor(router, method, p) {
|
|
50
|
+
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
51
|
+
assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
|
|
52
|
+
const stack = layer.route.stack;
|
|
53
|
+
return { handle: stack[stack.length - 1].handle, names: stack.map((s) => s.name) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function invoke(handle, req) {
|
|
57
|
+
const res = {
|
|
58
|
+
statusCode: 200, body: undefined,
|
|
59
|
+
json(b) { this.body = b; return this; },
|
|
60
|
+
status(c) { this.statusCode = c; return this; },
|
|
61
|
+
fail(code, opts) { this.statusCode = opts.status; this.body = { error: code, ...opts }; return this; },
|
|
62
|
+
};
|
|
63
|
+
let nextErr = null;
|
|
64
|
+
await handle(req, res, (err) => { nextErr = err; });
|
|
65
|
+
if (nextErr) throw nextErr;
|
|
66
|
+
return res;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function packed(version = '1.0.0') {
|
|
70
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-src-'));
|
|
71
|
+
const dir = path.join(root, 'weather');
|
|
72
|
+
fs.mkdirSync(dir);
|
|
73
|
+
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
|
74
|
+
key: 'weather', title: 'Weather', description: 'd', version, coreVersion: '^1.0.0', contributes: {},
|
|
75
|
+
}));
|
|
76
|
+
return artifact.packModule('weather', { modulesDir: root, modeOf: () => '644' }).tgz;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const router = buildModulesRouter();
|
|
80
|
+
const route = handlerFor(router, 'post', '/store/modules/:key/versions');
|
|
81
|
+
|
|
82
|
+
test('the route is gated before it reads a byte: builder, then permission, then the capped parser', async () => {
|
|
83
|
+
assert.deepEqual(route.names.slice(0, 3), ['requireBuilder', route.names[1], 'tarballBody']);
|
|
84
|
+
assert.notEqual(route.names[1], 'tarballBody');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a good upload is verified, recorded and kept on disk; the audit sees a marker, not the bytes', async () => {
|
|
88
|
+
const req = { params: { key: 'weather' }, body: packed('1.0.0'), builder: { id: 42 } };
|
|
89
|
+
const res = await invoke(route.handle, req);
|
|
90
|
+
assert.equal(res.statusCode, 201, JSON.stringify(res.body));
|
|
91
|
+
assert.equal(res.body.created_module, true);
|
|
92
|
+
assert.equal(state.modules.weather.author_id, 42);
|
|
93
|
+
assert.equal(state.versions[0].artifact_path, 'weather/weather-1.0.0.tgz');
|
|
94
|
+
const onDisk = fs.readFileSync(path.join(STORE, 'weather', 'weather-1.0.0.tgz'));
|
|
95
|
+
assert.equal((await artifact.verifyModuleArtifact(onDisk, { key: 'weather' })).ok, true);
|
|
96
|
+
assert.deepEqual(fs.readdirSync(path.join(STORE, 'weather')), ['weather-1.0.0.tgz'], 'no temp file left behind');
|
|
97
|
+
assert.deepEqual(req.body, { tarball_bytes: onDisk.length });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a refused publish leaves nothing on disk', async () => {
|
|
101
|
+
const res = await invoke(route.handle, { params: { key: 'weather' }, body: packed('1.0.0'), builder: { id: 42 } });
|
|
102
|
+
assert.equal(res.statusCode, 409);
|
|
103
|
+
assert.equal(res.body.error, 'version_exists');
|
|
104
|
+
assert.deepEqual(fs.readdirSync(path.join(STORE, 'weather')), ['weather-1.0.0.tgz']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('someone else cannot publish over the author', async () => {
|
|
108
|
+
const res = await invoke(route.handle, { params: { key: 'weather' }, body: packed('1.1.0'), builder: { id: 7 } });
|
|
109
|
+
assert.equal(res.statusCode, 403);
|
|
110
|
+
assert.equal(res.body.error, 'not_module_author');
|
|
111
|
+
assert.ok(!fs.existsSync(path.join(STORE, 'weather', 'weather-1.1.0.tgz')));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a tarball for another key, garbage, or no body is refused before the registry is touched', async () => {
|
|
115
|
+
const before = state.versions.length;
|
|
116
|
+
assert.equal((await invoke(route.handle, { params: { key: 'forecast' }, body: packed('2.0.0'), builder: { id: 42 } })).body.error, 'key_mismatch');
|
|
117
|
+
assert.equal((await invoke(route.handle, { params: { key: 'weather' }, body: Buffer.from('nope'), builder: { id: 42 } })).statusCode, 422);
|
|
118
|
+
assert.equal((await invoke(route.handle, { params: { key: 'weather' }, body: {}, builder: { id: 42 } })).body.error, 'empty_artifact');
|
|
119
|
+
assert.equal((await invoke(route.handle, { params: { key: 'Bad_Key' }, body: packed(), builder: { id: 42 } })).body.error, 'bad_module_key');
|
|
120
|
+
assert.equal(state.versions.length, before);
|
|
121
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// tests/module_store_registry_migration.mjs — the module store registry tables
|
|
2
|
+
// (task 1003812, ADR 0338 D1). DB-free: the unit gate has no Postgres, so this
|
|
3
|
+
// pins the rules the owner decided directly in the migration text, where a
|
|
4
|
+
// later edit that loosened one would otherwise land silently. The live apply
|
|
5
|
+
// runs in the PG lane (scripts/verify-gds-only-schema.sh) and at deploy.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/module_store_registry_migration.mjs
|
|
8
|
+
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const FILE = 'core_257_module_store_registry.sql';
|
|
19
|
+
const SQL = readFileSync(path.join(ROOT, 'migrations', FILE), 'utf8');
|
|
20
|
+
// Comments stripped so an assertion can only be satisfied by real DDL.
|
|
21
|
+
const DDL = SQL.replace(/--.*$/gm, '');
|
|
22
|
+
|
|
23
|
+
// The body of one CREATE TABLE, up to its closing ");".
|
|
24
|
+
function table(name) {
|
|
25
|
+
const m = new RegExp(`CREATE TABLE IF NOT EXISTS ${name} \\(([\\s\\S]*?)\\n\\);`).exec(DDL);
|
|
26
|
+
assert.ok(m, `${name} is created`);
|
|
27
|
+
return m[1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('the three registry tables exist, idempotently', () => {
|
|
31
|
+
for (const t of ['store_modules', 'store_module_versions', 'store_module_prices']) table(t);
|
|
32
|
+
assert.doesNotMatch(DDL, /CREATE TABLE (?!IF NOT EXISTS)/, 'every CREATE TABLE is IF NOT EXISTS');
|
|
33
|
+
assert.doesNotMatch(DDL, /CREATE INDEX (?!IF NOT EXISTS)/, 'every CREATE INDEX is IF NOT EXISTS');
|
|
34
|
+
assert.match(DDL, /INSERT INTO schema_migrations \(version\) VALUES \('core_257_module_store_registry'\)\s+ON CONFLICT \(version\) DO NOTHING;/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a price of zero is allowed in both currencies — free modules are first-class', () => {
|
|
38
|
+
const t = table('store_module_prices');
|
|
39
|
+
assert.match(t, /price_cents\s+integer CHECK \(price_cents >= 0\)/);
|
|
40
|
+
assert.match(t, /price_credits\s+integer CHECK \(price_credits >= 0\)/);
|
|
41
|
+
assert.doesNotMatch(t, /price_(cents|credits)[^,\n]*> 0\)/, 'no price column may forbid zero');
|
|
42
|
+
// money OR credits (or both), never neither
|
|
43
|
+
assert.match(t, /CHECK \(price_cents IS NOT NULL OR price_credits IS NOT NULL\)/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a version is exact semver and can be published only once per module', () => {
|
|
47
|
+
const t = table('store_module_versions');
|
|
48
|
+
assert.match(t, /version\s+text NOT NULL\s+CHECK \(version ~ '\^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$'\)/);
|
|
49
|
+
assert.match(t, /UNIQUE \(module_key, version\)/);
|
|
50
|
+
// the package-core.js pins install verifies against
|
|
51
|
+
assert.match(t, /tree_sha256\s+text NOT NULL CHECK \(tree_sha256 ~ '\^\[0-9a-f\]\{64\}\$'\)/);
|
|
52
|
+
assert.match(t, /tarball_sha256\s+text NOT NULL/);
|
|
53
|
+
assert.match(t, /artifact_path\s+text NOT NULL/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('delisting is a status, never a delete — and the timestamp moves with it (ADR 0338 D2)', () => {
|
|
57
|
+
const t = table('store_modules');
|
|
58
|
+
assert.match(t, /CHECK \(status IN \('listed', 'delisted'\)\)/);
|
|
59
|
+
assert.match(t, /CHECK \(\(status = 'delisted'\) = \(delisted_at IS NOT NULL\)\)/);
|
|
60
|
+
assert.doesNotMatch(DDL, /ON DELETE CASCADE/, 'nothing cascades away a version or price');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the store surface models no payment, balance or payout (area 8 owns those)', () => {
|
|
64
|
+
assert.doesNotMatch(DDL, /balance|payout|payment|fee/i);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the migration is forward-safe under the lint gate', () => {
|
|
68
|
+
const lint = require('../scripts/gds/lint-migrations.js');
|
|
69
|
+
assert.deepEqual(lint.scanMigrationSql(SQL).unsafe || [], []);
|
|
70
|
+
});
|