@bongos/core 1.19.1073 → 1.19.1074
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 +320 -465
- package/.claude/skills/feedback/SKILL.md +2 -2
- 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 +171 -1661
- 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 +2 -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 +9 -9
- 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/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/lifecycle/db-claim-reads.js +1 -58
- package/modules/lifecycle/db-ship.js +0 -5
- package/modules/lifecycle/db.js +0 -4
- 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/routes/tasks.js +5 -7
- package/modules/memory/module.json +1 -0
- 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 +2 -2
- package/modules/platform-identity/tests/platform-identity.mjs +17 -3
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/provisioning.js +2 -2
- 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/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 +78 -0
- package/scripts/gds/artifact-format.js +203 -0
- package/scripts/gds/audit-authorship.sh +1 -13
- package/scripts/gds/claim.js +0 -15
- package/scripts/gds/cli-lib.js +8 -8
- 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 +1 -1
- package/scripts/gds/feedback-latest.js +139 -9
- package/scripts/gds/fitness-checks-identity.js +2 -7
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/gds/fitness-ratchets.js +4 -4
- package/scripts/gds/fitness.js +10 -10
- package/scripts/gds/gen-diagrams.js +49 -71
- package/scripts/gds/http-api-client.js +12 -19
- package/scripts/gds/init.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/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/regen-instance-docs.js +1 -1
- package/scripts/gds/run-unit-tests.js +2 -16
- package/scripts/gds/sandbox-stage.js +39 -341
- package/scripts/gds/ship-flow.js +6 -6
- package/scripts/gds/ship-land.js +3 -4
- package/scripts/gds/ship-regen.js +10 -41
- package/scripts/gds/ship.js +4 -24
- 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/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/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 +6 -22
- package/src/bongos/routes/modules.js +83 -0
- package/src/bongos/routes.js +5 -0
- 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 +15 -7
- package/src/module-seams.js +2 -3
- package/src/modules.js +42 -14
- package/tests/agents_spend_guard.mjs +1 -1
- package/tests/api_client.mjs +0 -15
- package/tests/auth_page_gate.mjs +10 -10
- 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/consumer_layout_boot.mjs +2 -0
- package/tests/copy_desk_page_docx.mjs +33 -2
- package/tests/core_upgrade_runner.mjs +6 -6
- package/tests/deploy_divergence_line.mjs +11 -11
- 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/gate_approvals.mjs +0 -1
- package/tests/go_live.mjs +3 -3
- package/tests/government_abuse_matrix.mjs +20 -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_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/host_topology_skips.mjs +1 -2
- 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/module-scope-map.mjs +9 -9
- package/tests/module_catalog.mjs +1 -1
- package/tests/module_cli.mjs +15 -15
- package/tests/module_contributions.mjs +6 -6
- package/tests/module_loader.mjs +6 -6
- package/tests/module_manifest.mjs +13 -4
- 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/onboarding_route_signals.mjs +21 -31
- package/tests/onboarding_state.mjs +35 -49
- package/tests/permission_path.mjs +0 -3
- package/tests/platform_boot.mjs +1 -2
- package/tests/profile_route.mjs +1 -6
- package/tests/projects_hub.mjs +5 -5
- package/tests/projects_hub_app_step.mjs +138 -0
- package/tests/projects_hub_module_picker.mjs +2 -2
- package/tests/projects_hub_pre_uat.mjs +4 -5
- package/tests/provision.mjs +7 -7
- package/tests/provisioning_starter_bundles.mjs +0 -22
- package/tests/publish_branch_route.mjs +10 -18
- package/tests/publish_manifest.mjs +1 -2
- package/tests/rank_tier_single_source.mjs +1 -13
- package/tests/repo_map.mjs +3 -4
- package/tests/runner_drift.mjs +3 -21
- package/tests/sandbox_stage.mjs +57 -390
- package/tests/session_rename_fallback.mjs +0 -21
- package/tests/ship_error_shape.mjs +1 -1
- package/tests/task_visual_slots.mjs +31 -0
- package/tests/terms_acceptance.mjs +1 -1
- 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
|
@@ -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
|
+
});
|