@open-mercato/cli 0.6.3-develop.3604.1.abcb8d1963 → 0.6.3-develop.3609.1.01983ef87a

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.3-develop.3604.1.abcb8d1963",
3
+ "version": "0.6.3-develop.3609.1.01983ef87a",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.0.17",
60
60
  "@mikro-orm/migrations": "^7.0.17",
61
61
  "@mikro-orm/postgresql": "^7.0.17",
62
- "@open-mercato/queue": "0.6.3-develop.3604.1.abcb8d1963",
63
- "@open-mercato/shared": "0.6.3-develop.3604.1.abcb8d1963",
62
+ "@open-mercato/queue": "0.6.3-develop.3609.1.01983ef87a",
63
+ "@open-mercato/shared": "0.6.3-develop.3609.1.01983ef87a",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.21.0",
66
66
  "semver": "^7.8.0",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.3-develop.3604.1.abcb8d1963"
73
+ "@open-mercato/shared": "0.6.3-develop.3609.1.01983ef87a"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.3-develop.3604.1.abcb8d1963",
76
+ "@open-mercato/shared": "0.6.3-develop.3609.1.01983ef87a",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.9"
@@ -768,6 +768,148 @@ export function GET() {
768
768
  )
769
769
  })
770
770
 
771
+ it('emits imports for third-party scoped npm packages registered via src/modules.ts', async () => {
772
+ // Simulate a third-party module package published at @dainamite/cpq
773
+ // (see issue #1998). The generator must accept @<vendor>/<package> specifiers
774
+ // and emit imports that point at the package's modules/<id> subtree.
775
+ const pkgModuleBase = path.join(
776
+ tmpDir,
777
+ 'packages',
778
+ 'thirdparty',
779
+ 'src',
780
+ 'modules',
781
+ 'cpq',
782
+ )
783
+ touchFile(
784
+ path.join(pkgModuleBase, 'subscribers', 'on-event.ts'),
785
+ `export const metadata = { event: 'cpq.quote.created' }\nexport default async function handler() {}\n`,
786
+ )
787
+
788
+ const baseResolver = createMockResolver(tmpDir, [
789
+ { id: 'cpq', from: '@dainamite/cpq' },
790
+ ])
791
+ const resolver: PackageResolver = {
792
+ ...baseResolver,
793
+ getModulePaths: (entry: ModuleEntry) => ({
794
+ appBase: path.join(tmpDir, 'app', 'src', 'modules', entry.id),
795
+ pkgBase: pkgModuleBase,
796
+ }),
797
+ getModuleImportBase: (entry: ModuleEntry) => ({
798
+ appBase: `@/modules/${entry.id}`,
799
+ pkgBase: `@dainamite/cpq/modules/${entry.id}`,
800
+ }),
801
+ }
802
+
803
+ const result = await generateModuleRegistry({ resolver, quiet: true })
804
+ expect(result.errors).toEqual([])
805
+
806
+ const output = readGenerated(tmpDir, 'modules.generated.ts')!
807
+ expect(output).toContain('id: "cpq"')
808
+ expect(output).toContain('@dainamite/cpq/modules/cpq/subscribers/on-event')
809
+ })
810
+
811
+ it('emits imports for bare (unscoped) third-party npm packages', async () => {
812
+ const pkgModuleBase = path.join(
813
+ tmpDir,
814
+ 'packages',
815
+ 'thirdparty-bare',
816
+ 'src',
817
+ 'modules',
818
+ 'foo',
819
+ )
820
+ touchFile(
821
+ path.join(pkgModuleBase, 'subscribers', 'on-event.ts'),
822
+ `export const metadata = { event: 'foo.thing.created' }\nexport default async function handler() {}\n`,
823
+ )
824
+
825
+ const baseResolver = createMockResolver(tmpDir, [
826
+ { id: 'foo', from: 'acme-modules' },
827
+ ])
828
+ const resolver: PackageResolver = {
829
+ ...baseResolver,
830
+ getModulePaths: (entry: ModuleEntry) => ({
831
+ appBase: path.join(tmpDir, 'app', 'src', 'modules', entry.id),
832
+ pkgBase: pkgModuleBase,
833
+ }),
834
+ getModuleImportBase: (entry: ModuleEntry) => ({
835
+ appBase: `@/modules/${entry.id}`,
836
+ pkgBase: `acme-modules/modules/${entry.id}`,
837
+ }),
838
+ }
839
+
840
+ const result = await generateModuleRegistry({ resolver, quiet: true })
841
+ expect(result.errors).toEqual([])
842
+
843
+ const output = readGenerated(tmpDir, 'modules.generated.ts')!
844
+ expect(output).toContain('id: "foo"')
845
+ expect(output).toContain('acme-modules/modules/foo/subscribers/on-event')
846
+ })
847
+
848
+ it('fails fast when a third-party module entry points at a package without the expected OM module shape', async () => {
849
+ // No files scaffolded — the package directory does not exist, so the
850
+ // entry is almost certainly not an Open Mercato module.
851
+ const enabled: ModuleEntry[] = [{ id: 'cpq', from: '@dainamite/cpq' }]
852
+ const baseResolver = createMockResolver(tmpDir, enabled)
853
+ const resolver: PackageResolver = {
854
+ ...baseResolver,
855
+ getModulePaths: (entry: ModuleEntry) => ({
856
+ appBase: path.join(tmpDir, 'app', 'src', 'modules', entry.id),
857
+ pkgBase: path.join(
858
+ tmpDir,
859
+ 'node_modules',
860
+ '@dainamite',
861
+ 'cpq',
862
+ 'dist',
863
+ 'modules',
864
+ entry.id,
865
+ ),
866
+ }),
867
+ getModuleImportBase: (entry: ModuleEntry) => ({
868
+ appBase: `@/modules/${entry.id}`,
869
+ pkgBase: `@dainamite/cpq/modules/${entry.id}`,
870
+ }),
871
+ }
872
+
873
+ await expect(generateModuleRegistry({ resolver, quiet: true })).rejects.toThrow(
874
+ /no Open Mercato module shape was found/,
875
+ )
876
+ })
877
+
878
+ it('rejects third-party specifiers with path-traversal segments', async () => {
879
+ const pkgModuleBase = path.join(
880
+ tmpDir,
881
+ 'packages',
882
+ 'thirdparty-traversal',
883
+ 'src',
884
+ 'modules',
885
+ 'evil',
886
+ )
887
+ touchFile(
888
+ path.join(pkgModuleBase, 'subscribers', 'on-event.ts'),
889
+ `export const metadata = { event: 'evil.x' }\nexport default async function handler() {}\n`,
890
+ )
891
+
892
+ const baseResolver = createMockResolver(tmpDir, [
893
+ { id: 'evil', from: '@vendor/pkg' },
894
+ ])
895
+ const resolver: PackageResolver = {
896
+ ...baseResolver,
897
+ getModulePaths: (entry: ModuleEntry) => ({
898
+ appBase: path.join(tmpDir, 'app', 'src', 'modules', entry.id),
899
+ pkgBase: pkgModuleBase,
900
+ }),
901
+ // Inject a malicious specifier with a ".." segment.
902
+ getModuleImportBase: (entry: ModuleEntry) => ({
903
+ appBase: `@/modules/${entry.id}`,
904
+ pkgBase: `@vendor/pkg/../escape/modules/${entry.id}`,
905
+ }),
906
+ }
907
+
908
+ await expect(generateModuleRegistry({ resolver, quiet: true })).rejects.toThrow(
909
+ /Unsafe generated module specifier/,
910
+ )
911
+ })
912
+
771
913
  it('mixed subset: core module + app module, then remove core module', async () => {
772
914
  scaffoldModule(tmpDir, 'core_mod', 'pkg', [
773
915
  'backend/page.tsx',
@@ -925,8 +925,12 @@ function toLiteral(value: unknown): string {
925
925
  .replace(/\u2029/g, '\\u2029')
926
926
  }
927
927
 
928
- const GENERATED_MODULE_SPECIFIER_PREFIXES = ['@/', '@open-mercato/', '../../src/modules/', './'] as const
928
+ const GENERATED_MODULE_RELATIVE_PREFIXES = ['@/', '../../src/modules/', './'] as const
929
929
  const GENERATED_MODULE_SPECIFIER_SEGMENT = /^[A-Za-z0-9_.\-[\]()']+$/
930
+ // Lenient npm package name matcher. Accepts legacy uppercase packages and
931
+ // tilde-prefixed names, bans leading "." / "_", and reserves "@" for scope
932
+ // markers so the dispatcher below can split scope-vs-bare unambiguously.
933
+ const NPM_PACKAGE_NAME_SEGMENT = /^[A-Za-z0-9~][A-Za-z0-9_.~-]*$/
930
934
 
931
935
  function sanitizeGeneratedModuleSpecifierSegment(segment: string, importPath: string): string {
932
936
  const match = GENERATED_MODULE_SPECIFIER_SEGMENT.exec(segment)
@@ -936,22 +940,83 @@ function sanitizeGeneratedModuleSpecifierSegment(segment: string, importPath: st
936
940
  return match[0]
937
941
  }
938
942
 
939
- function sanitizeGeneratedModuleSpecifier(importPath: string): string {
940
- const prefix = GENERATED_MODULE_SPECIFIER_PREFIXES.find((candidate) => importPath.startsWith(candidate))
941
- if (!prefix) {
942
- throw new Error(`Unsafe generated module specifier prefix: ${importPath}`)
943
+ function sanitizeNpmPackageNameSegment(segment: string, importPath: string): void {
944
+ if (!NPM_PACKAGE_NAME_SEGMENT.test(segment) || segment === '.' || segment === '..') {
945
+ throw new Error(`Unsafe generated module specifier: ${importPath}`)
943
946
  }
947
+ }
944
948
 
945
- const suffix = importPath.slice(prefix.length)
946
- if (!suffix) {
949
+ function sanitizeGeneratedModuleSpecifier(importPath: string): string {
950
+ if (!importPath) {
947
951
  throw new Error(`Unsafe generated module specifier: ${importPath}`)
948
952
  }
949
953
 
950
- const segments = suffix
954
+ const relativePrefix = GENERATED_MODULE_RELATIVE_PREFIXES.find((candidate) =>
955
+ importPath.startsWith(candidate),
956
+ )
957
+ if (relativePrefix) {
958
+ const suffix = importPath.slice(relativePrefix.length)
959
+ if (!suffix) {
960
+ throw new Error(`Unsafe generated module specifier: ${importPath}`)
961
+ }
962
+ const segments = suffix
963
+ .split('/')
964
+ .map((segment) => sanitizeGeneratedModuleSpecifierSegment(segment, importPath))
965
+ return `${relativePrefix}${segments.join('/')}`
966
+ }
967
+
968
+ if (importPath.startsWith('@')) {
969
+ // Scoped npm package: @scope/name[/subpath]
970
+ const firstSlash = importPath.indexOf('/')
971
+ if (firstSlash < 2) {
972
+ throw new Error(`Unsafe generated module specifier prefix: ${importPath}`)
973
+ }
974
+ const scope = importPath.slice(1, firstSlash)
975
+ sanitizeNpmPackageNameSegment(scope, importPath)
976
+ const rest = importPath.slice(firstSlash + 1)
977
+ if (!rest) {
978
+ throw new Error(`Unsafe generated module specifier: ${importPath}`)
979
+ }
980
+ const secondSlash = rest.indexOf('/')
981
+ const name = secondSlash >= 0 ? rest.slice(0, secondSlash) : rest
982
+ sanitizeNpmPackageNameSegment(name, importPath)
983
+ const subpath = secondSlash >= 0 ? rest.slice(secondSlash + 1) : ''
984
+ if (!subpath) {
985
+ return `@${scope}/${name}`
986
+ }
987
+ const segments = subpath
988
+ .split('/')
989
+ .map((segment) => sanitizeGeneratedModuleSpecifierSegment(segment, importPath))
990
+ return `@${scope}/${name}/${segments.join('/')}`
991
+ }
992
+
993
+ // Bare npm package: name[/subpath]
994
+ const firstSlash = importPath.indexOf('/')
995
+ const name = firstSlash >= 0 ? importPath.slice(0, firstSlash) : importPath
996
+ sanitizeNpmPackageNameSegment(name, importPath)
997
+ const subpath = firstSlash >= 0 ? importPath.slice(firstSlash + 1) : ''
998
+ if (!subpath) {
999
+ return name
1000
+ }
1001
+ const segments = subpath
951
1002
  .split('/')
952
1003
  .map((segment) => sanitizeGeneratedModuleSpecifierSegment(segment, importPath))
953
-
954
- return `${prefix}${segments.join('/')}`
1004
+ return `${name}/${segments.join('/')}`
1005
+ }
1006
+
1007
+ // Defense in depth: when src/modules.ts registers a module from a third-party
1008
+ // npm scope (anything outside @app / @open-mercato/*), verify the resolved
1009
+ // pkgBase directory exists. If it doesn't, the package referenced via `from:`
1010
+ // is almost certainly not an Open Mercato module (or the id is wrong), and
1011
+ // silently emitting an empty module registration would hide the misconfig.
1012
+ function verifyThirdPartyModuleShape(entry: { id: string; from?: string }, pkgBase: string): void {
1013
+ const from = entry.from
1014
+ if (!from || from === '@app' || from.startsWith('@open-mercato/')) return
1015
+ if (fs.existsSync(pkgBase)) return
1016
+ throw new Error(
1017
+ `Module '${entry.id}' is registered with from: '${from}' but no Open Mercato module shape was found at '${pkgBase}'. ` +
1018
+ `Ensure the package ships a 'modules/${entry.id}' subtree (either under 'dist/modules/' or 'src/modules/'), or correct the 'from' value in src/modules.ts.`,
1019
+ )
955
1020
  }
956
1021
 
957
1022
  function buildImportStatement(importClause: string, importPath: string): string {
@@ -2502,6 +2567,9 @@ export async function generateModuleRegistry(options: ModuleRegistryOptions): Pr
2502
2567
  const legacySubscribersChecksumFile = path.join(outputDir, 'subscribers.generated.checksum')
2503
2568
 
2504
2569
  const enabled = resolver.loadEnabledModules()
2570
+ for (const entry of enabled) {
2571
+ verifyThirdPartyModuleShape(entry, resolver.getModulePaths(entry).pkgBase)
2572
+ }
2505
2573
  const extensions = loadGeneratorExtensions()
2506
2574
 
2507
2575
  // Pre-pass: collect generator plugins from each enabled module's generators.ts
@@ -3118,6 +3186,9 @@ export async function generateModuleRegistryApp(options: ModuleRegistryOptions):
3118
3186
  const enabledIdsChecksumFile = path.join(outputDir, 'enabled-module-ids.generated.checksum')
3119
3187
 
3120
3188
  const enabled = resolver.loadEnabledModules()
3189
+ for (const entry of enabled) {
3190
+ verifyThirdPartyModuleShape(entry, resolver.getModulePaths(entry).pkgBase)
3191
+ }
3121
3192
  const imports: string[] = []
3122
3193
  const moduleDecls: WriterFunction[] = []
3123
3194
  const importIdRef = { value: 0 }
@@ -3449,6 +3520,9 @@ export async function generateModuleRegistryCli(options: ModuleRegistryOptions):
3449
3520
  const legacyChecksumFile = path.join(outputDir, 'cli-modules.generated.checksum')
3450
3521
 
3451
3522
  const enabled = resolver.loadEnabledModules()
3523
+ for (const entry of enabled) {
3524
+ verifyThirdPartyModuleShape(entry, resolver.getModulePaths(entry).pkgBase)
3525
+ }
3452
3526
  const imports: string[] = []
3453
3527
  const moduleDecls: WriterFunction[] = []
3454
3528
  // Mutable ref so extracted helper functions can increment the shared counter