@sap/cds-dk 10.1.0 → 10.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/bind.js +4 -0
  3. package/bin/build.js +1 -0
  4. package/bin/compile/index.js +12 -11
  5. package/bin/completion/index.js +21 -10
  6. package/bin/completion/scripts/cds.fish +179 -127
  7. package/bin/completion/scripts/cds.ps1 +88 -14
  8. package/bin/completion/scripts/cds.sh +71 -5
  9. package/bin/completion/scripts/cds.zsh +81 -15
  10. package/bin/completion/templates/cds-tmpl.ps1 +15 -6
  11. package/bin/deploy/index.js +7 -3
  12. package/bin/env.js +4 -1
  13. package/bin/export.js +9 -1
  14. package/bin/import.js +4 -1
  15. package/bin/init.js +1 -0
  16. package/bin/inspect.js +3 -1
  17. package/bin/lint.js +5 -3
  18. package/bin/minify.js +6 -1
  19. package/bin/repl.js +11 -2
  20. package/bin/run.js +12 -1
  21. package/bin/serve.js +13 -2
  22. package/bin/up.js +4 -1
  23. package/bin/util/containerize.js +2 -0
  24. package/bin/watch/index.js +8 -1
  25. package/lib/build/util.js +2 -4
  26. package/lib/init/constants.js +1 -1
  27. package/lib/init/template/data/as-json.js +3 -0
  28. package/node_modules/@cap-js/db-service/CHANGELOG.md +17 -0
  29. package/node_modules/@cap-js/db-service/lib/common/generic-pool.js +4 -7
  30. package/node_modules/@cap-js/db-service/lib/cqn2pql.js +1 -1
  31. package/node_modules/@cap-js/db-service/lib/cqn2sql.js +15 -11
  32. package/node_modules/@cap-js/db-service/lib/cqn4sql.js +119 -19
  33. package/node_modules/@cap-js/db-service/lib/infer/index.js +27 -16
  34. package/node_modules/@cap-js/db-service/lib/recursion-guard.js +40 -0
  35. package/node_modules/@cap-js/db-service/lib/search.js +1 -1
  36. package/node_modules/@cap-js/db-service/package.json +1 -1
  37. package/node_modules/@cap-js/sqlite/CHANGELOG.md +19 -0
  38. package/node_modules/@cap-js/sqlite/lib/SQLiteService.js +110 -14
  39. package/node_modules/@cap-js/sqlite/package.json +3 -3
  40. package/node_modules/@sap/cds-compiler/CHANGELOG.md +15 -0
  41. package/node_modules/@sap/cds-compiler/lib/compiler/resolve.js +7 -3
  42. package/node_modules/@sap/cds-compiler/lib/model/csnUtils.js +2 -0
  43. package/node_modules/@sap/cds-compiler/lib/transform/db/applyTransformations.js +12 -0
  44. package/node_modules/@sap/cds-compiler/lib/transform/db/associations.js +5 -3
  45. package/node_modules/@sap/cds-compiler/lib/transform/db/flattening.js +4 -3
  46. package/node_modules/@sap/cds-compiler/lib/transform/effective/flattening.js +17 -1
  47. package/node_modules/@sap/cds-compiler/lib/transform/effective/main.js +19 -0
  48. package/node_modules/@sap/cds-compiler/lib/transform/fioriTreeViews.js +12 -1
  49. package/node_modules/@sap/cds-compiler/lib/transform/forRelationalDB.js +1 -1
  50. package/node_modules/@sap/cds-compiler/lib/transform/odata/adaptAnnotationRefs.js +11 -5
  51. package/node_modules/@sap/cds-compiler/lib/transform/translateAssocsToJoins.js +29 -2
  52. package/node_modules/@sap/cds-compiler/package.json +1 -1
  53. package/node_modules/@sap/cds-mtxs/CHANGELOG.md +34 -0
  54. package/node_modules/@sap/cds-mtxs/bin/cds-mtx-migrate.js +26 -5
  55. package/node_modules/@sap/cds-mtxs/bin/cds-mtx.js +3 -0
  56. package/node_modules/@sap/cds-mtxs/lib/utils.js +5 -4
  57. package/node_modules/@sap/cds-mtxs/package.json +1 -1
  58. package/node_modules/@sap/cds-mtxs/srv/cf/abstract-provisioning-service.js +62 -7
  59. package/node_modules/@sap/cds-mtxs/srv/cf/saas-provisioning-service.js +1 -1
  60. package/node_modules/@sap/cds-mtxs/srv/cf/sms-provisioning-service.js +23 -3
  61. package/node_modules/@sap/cds-mtxs/srv/extensibility/compat/update.js +15 -0
  62. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/annotations.js +2 -2
  63. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/config.js +2 -0
  64. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/index.js +2 -0
  65. package/node_modules/@sap/cds-mtxs/srv/extensibility/linter/protected-namespace.js +45 -0
  66. package/node_modules/@sap/cds-mtxs/srv/extensibility-service.js +8 -3
  67. package/node_modules/@sap/cds-mtxs/srv/jobs-service.js +49 -10
  68. package/node_modules/@sap/cds-mtxs/srv/model-provider.js +11 -2
  69. package/node_modules/@sap/cds-mtxs/srv/plugins/common.js +19 -12
  70. package/node_modules/@sap/cds-mtxs/srv/plugins/hana/hdi-ctnr-mgr.js +6 -3
  71. package/node_modules/@sap/cds-mtxs/srv/plugins/hana/serverless.js +271 -0
  72. package/node_modules/@sap/cds-mtxs/srv/plugins/hana.js +3 -2
  73. package/node_modules/@sap/cds-mtxs/srv/worker/job-worker-pool.js +212 -0
  74. package/node_modules/@sap/cds-mtxs/srv/worker/job-worker.js +61 -0
  75. package/package.json +2 -2
  76. /package/node_modules/@sap/cds-mtxs/srv/worker/{pool.js → model-worker-pool.js} +0 -0
  77. /package/node_modules/@sap/cds-mtxs/srv/worker/{load.js → model-worker.js} +0 -0
@@ -612,7 +612,7 @@ function translateAssocsToJoins(model, inputOptions = {}) {
612
612
  args.push({
613
613
  op: { val: '=' },
614
614
  args: [ constructPathNode( [ srcAlias, prefixFK(assoc.$elementPrefix, assoc.$flatSrcFKs[i]) ] ),
615
- constructPathNode( [ tgtAlias, assoc.$flatTgtFKs[i] ] ) ],
615
+ constructPathNode( [ tgtAlias, resolveTgtFKToAlias(assoc.$flatTgtFKs[i], tgtAlias) ] ) ],
616
616
  });
617
617
  }
618
618
  return parenthesise((args.length > 1 ? { op: { val: 'and' }, args } : args[0] ));
@@ -808,7 +808,19 @@ function translateAssocsToJoins(model, inputOptions = {}) {
808
808
  const assocSource = assoc._main;
809
809
  if (assoc.kind === 'mixin' && (i > 0 || fwdTarget !== assocSource) && fwdOrigin.kind !== 'mixin') {
810
810
  const tas = Object.values(env.lead.$tableAliases);
811
- const j = tas.findIndex(ta => ta._artifact === fwdOrigin.target._artifact);
811
+ // The backlink association's target may be reached through a view
812
+ // over it, whose alias is not the target artifact itself; fall back
813
+ // to the alias whose `_origin` chain reaches the target.
814
+ let j = tas.findIndex(ta => ta._artifact === fwdOrigin.target._artifact);
815
+ if (j < 0) {
816
+ j = tas.findIndex((ta) => {
817
+ for (let a = ta._artifact; a; a = a._origin) {
818
+ if (a === fwdOrigin.target._artifact)
819
+ return true;
820
+ }
821
+ return false;
822
+ });
823
+ }
812
824
  if (j >= 0 && tas[j].$QA) {
813
825
  newTgtAlias.id = tas[j].$QA.name.id;
814
826
  newTgtAlias._artifact = tas[j]._effectiveType;
@@ -1061,6 +1073,21 @@ function translateAssocsToJoins(model, inputOptions = {}) {
1061
1073
  const backlink = backlinkAssoc.$backlink;
1062
1074
  return backlink && (backlink.foreignKeys || backlink.on) ? backlink : fwdAssoc;
1063
1075
  }
1076
+
1077
+ // Resolve the target foreign key to the alias of the target table.
1078
+ // If the target foreign key is not found in the scope of the target alias,
1079
+ // try to find it in the origin chain of the foreign key artifact.
1080
+ function resolveTgtFKToAlias( fk, tgtAlias ) {
1081
+ const env = tgtAlias && (tgtAlias._effectiveType || tgtAlias._artifact);
1082
+ const scope = env && env.elements;
1083
+ if (!scope || !fk || scope[fk.id] || !fk._artifact || !fk._artifact._origin)
1084
+ return fk;
1085
+ for (let o = fk._artifact._origin; o; o = o._origin) {
1086
+ if (o.name && scope[o.name.id])
1087
+ return { id: o.name.id, _artifact: fk._artifact, acc: fk.acc };
1088
+ }
1089
+ return fk;
1090
+ }
1064
1091
  } // createOnCondition
1065
1092
  } // createJoinQA
1066
1093
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds-compiler",
3
- "version": "7.1.0",
3
+ "version": "7.1.1",
4
4
  "description": "CDS (Core Data Services) compiler and backends",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "author": "SAP SE (https://www.sap.com)",
@@ -6,6 +6,39 @@ This project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  The format is based on [Keep a Changelog](https://keepachangelog.com/).
8
8
 
9
+ ## Version 4.1.0 - 2026-09-04
10
+
11
+ ### Added
12
+
13
+ - Beta: Multitenant provisioning now supports HANA Cloud serverless, resolving one HDI container per tenant through the serverless Tenant API.
14
+ - Beta: The MTX job service can now use native javascript workers to make the MTX sidecar more responsive while jobs (e. g. tenant upgrade) are running.
15
+ Native workers can be enabled by `nativeWorkers` in the jobs configuration, e. g.
16
+ ```jsonc
17
+ "cds.xt.SaasProvisioningService": {
18
+ "jobs": {
19
+ "nativeWorkers": true,
20
+ "workerSize": 3,
21
+ "clusterSize": 3
22
+ }
23
+ },
24
+ ```
25
+ - With configuration `cds.requires['cds.xt.SaasProvisioning'].upgrade.skipAlreadyUpgraded: true` or `cds.requires['cds.xt.SmsProvisioning'].upgrade.skipAlreadyUpgraded: true`,
26
+ already upgraded tenants are skipped when running an upgrade of all tenants (`tenants: ['*']`). Tenants are considered as already upgraded if the last upgrade was run after the
27
+ last deployment of the application.
28
+
29
+ ### Fixed
30
+
31
+ - `SmsProvisioningService` certificate check is now better aligned with the Java implementation.
32
+ - Extension validation checks have been refined.
33
+ - `POST /-/cds/model-provider/getEdmx` now checks the requested service name more exactly.
34
+ - Callback url for Saas Registry and Subscription Manager is now determined in a more robust way.
35
+
36
+ ## Version 4.0.4 - 2026-08-21
37
+
38
+ ### Fixed
39
+
40
+ - Version bump
41
+
9
42
  ## Version 4.0.3 - 2026-08-21
10
43
 
11
44
  ### Added
@@ -20,6 +53,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
20
53
 
21
54
  - HANA credential health-check now detects the available driver via `require.resolve()` instead of reading `package.json`, fixing `MODULE_NOT_FOUND` for apps that follow the "Plug & Play" guidance and don't declare `hdb` or `@sap/hana-client` directly.
22
55
  - Tenant upgrade now reports a clear per-tenant `422` error when a stored extension is incompatible with the changed base model, instead of aborting with an uncaught compilation error.
56
+ - `cds-mtx-migrate` now runs more robust with uncommon model configurations.
23
57
 
24
58
  ## Version 4.0.2 - 2026-08-07
25
59
 
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /* eslint-disable no-console */
3
3
 
4
+ const cds = require('@sap/cds')
5
+ const { _import, isfile, local, path } = cds.utils
6
+
4
7
  async function run(param, options) {
5
8
  const { dry, genTenantList, syncTenantList, cleanup, directory, tag, tagRule } = options
6
9
  const skipVerification = options['skip-verification'] || options.skipVerification
@@ -26,7 +29,6 @@ async function run(param, options) {
26
29
  _handleError(`The migration from @sap/cds-mtx to @sap/cds-mtxs has been removed. Only --init-tenant-list and --sync-tenant-list are still supported.`)
27
30
  }
28
31
 
29
- const cds = require('@sap/cds')
30
32
  const { syncTenantList: synchronizeTenantList, _hasMtEnv } = require('../lib/migration/migration')
31
33
 
32
34
  async function cds_mtx_migrate(tenants, options) {
@@ -45,11 +47,20 @@ async function run(param, options) {
45
47
  services.push('@sap/cds-mtxs/srv/extensibility-service')
46
48
  }
47
49
 
48
- await cds.connect.to('db')
49
- await cds.serve(services)
50
- cds.env.requires.multitenancy = cds.env.requires.multitenancy || true // needed for current sidecar config that only enables services
50
+ await _local_server_js()
51
+ cds.emit ('bootstrap')
52
+ const model = await cds.load([...services, '@sap/cds-mtxs/db/extensions'])
53
+ cds.model = cds.compile.for.nodejs(model)
54
+ const d = 'cds.xt.DeploymentService'
55
+ const unserved = !cds.services[d]
56
+ if (unserved) {
57
+ await cds.serve(services)
58
+ }
59
+ await cds.plugins
60
+
61
+ await cds.emit('served', cds.services)
51
62
 
52
- await cds.emit('served')
63
+ cds.env.requires.multitenancy ??= true // needed for current sidecar config that only enables services
53
64
 
54
65
  if (genTenantList) {
55
66
  const tenantListResult = await synchronizeTenantList(tenantList, options)
@@ -73,6 +84,16 @@ async function run(param, options) {
73
84
  await cds_mtx_migrate(param, options).catch(console.error)
74
85
  }
75
86
 
87
+ // copied from cds.serve
88
+ async function _local_server_js() {
89
+ const _local = file => isfile(file) || isfile (path.join(cds.env.folders.srv,file))
90
+ const cli_js = process.env.CDS_TYPESCRIPT && _local('cli.ts') || _local('cli.js')
91
+ if (cli_js) {
92
+ console.log ('[cds] - loading server from', { file: local(cli_js) })
93
+ await _import(cli_js)
94
+ }
95
+ }
96
+
76
97
  function getParameterValue(options, parameter) {
77
98
  const index = options.indexOf(parameter)
78
99
  const value = (index > -1) ? options[index + 1] : undefined
@@ -78,6 +78,9 @@ async function cds_mtx(cmd, tenant, body) {
78
78
  cds.db.disconnect(cds.env.requires.multitenancy.t0 ?? 't0')
79
79
  for (const t of connectedTenants) cds.db.disconnect(t)
80
80
  }
81
+ if (isCli) {
82
+ await cds.exit()
83
+ }
81
84
  }
82
85
  }
83
86
 
@@ -104,14 +104,15 @@ function authUrl(baseUrl, subdomain, pathname) {
104
104
  return u
105
105
  }
106
106
 
107
- async function token(credentials, { query = {}, form = {}, subdomain } = {}) {
107
+ async function token(credentials, { query = {}, form = {}, subdomain, path = '/oauth/token' } = {}) {
108
108
  const { clientid, clientsecret, certurl, url, certificate, key } = credentials ?? {}
109
109
  if (!form.grant_type) throw cds.error('grant_type is required', { status: 400 })
110
- const u = authUrl(certurl || url, subdomain, '/oauth/token')
110
+ const u = authUrl(certurl || url, subdomain, path)
111
111
  Object.entries(query).forEach(([k, v]) => u.searchParams.append(k, v))
112
112
  const body = new URLSearchParams({ client_id: clientid, ...form })
113
113
  const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }
114
114
  let raw
115
+ const _fail = (statusCode, respBody) => new Error(`token request to ${u.origin}${u.pathname} for client ${clientid} failed (${statusCode}): ${respBody}`, { status: statusCode })
115
116
  if (certificate) {
116
117
  raw = await new Promise((resolve, reject) => {
117
118
  const req = require('node:https').request({
@@ -126,7 +127,7 @@ async function token(credentials, { query = {}, form = {}, subdomain } = {}) {
126
127
  res.on('data', x => chunks.push(x))
127
128
  res.on('end', () => {
128
129
  if (res.statusCode >= 200 && res.statusCode < 300) resolve(Buffer.concat(chunks).toString())
129
- else reject(new Error(Buffer.concat(chunks).toString(), { status: res.statusCode }))
130
+ else reject(_fail(res.statusCode, Buffer.concat(chunks).toString()))
130
131
  })
131
132
  })
132
133
  req.on('error', reject)
@@ -176,7 +177,7 @@ async function fetchResiliently(url, req, { retriesLeft = maxRetries, maxRetryAf
176
177
  const data = contentType.includes('application/json') ? await res.json() : await res.text()
177
178
 
178
179
  if (!res.ok) {
179
- let msg = [status, statusText, data?.message, typeof data?.error === 'object' ? JSON.stringify(data.error) : data?.error, data?.description, data?.cause].filter(Boolean).join(' – ')
180
+ let msg = [status, statusText, data?.message, typeof data?.error === 'object' ? JSON.stringify(data.error) : data?.error, data?.description, data?.cause, typeof data?.details === 'object' ? JSON.stringify(data.details) : data?.details].filter(Boolean).join(' – ')
180
181
  if (res.status === 429 && res.headers.get('retry-after')) msg += ` – retry after ${res.headers.get('retry-after')}`
181
182
  throw Object.assign(new Error(msg), { status, statusText, headers, code: data?.error?.code })
182
183
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds-mtxs",
3
- "version": "4.0.3",
3
+ "version": "4.1.0",
4
4
  "description": "SAP Cloud Application Programming Model - Multitenancy library",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [
@@ -5,6 +5,7 @@ const { fetchResiliently, token } = require('../../lib/utils')
5
5
  const DeploymentService = 'cds.xt.DeploymentService'
6
6
  const JobsService = 'cds.xt.JobsService'
7
7
  const Tenants = 'cds.xt.Tenants'
8
+ const { SKIPPED_DB } = require('../jobs-service')
8
9
 
9
10
  // we need to run in multitenancy mode for t0 ops
10
11
  if (!cds.env.requires.multitenancy) cds.env.requires.multitenancy = true
@@ -112,9 +113,9 @@ module.exports = class ProvisioningService extends cds.ApplicationService {
112
113
  )).map(({ ID, metadata, createdAt, modifiedAt }) => ({ subscribedTenantId: ID, ...this._toMetadataJson(metadata, ID), createdAt, modifiedAt }))
113
114
  }
114
115
 
115
- async _getTenants() {
116
+ async _getTenants(options) {
116
117
  const ds = await cds.connect.to(DeploymentService)
117
- const all = await ds.getTenants()
118
+ const all = await ds.getTenants(options)
118
119
  if (cds.env.requires[this.name]?.upgrade?.ignoreNonExistingContainers) {
119
120
  const containers = await ds.getContainers()
120
121
  return all.filter(t => containers.includes(t))
@@ -129,9 +130,37 @@ module.exports = class ProvisioningService extends cds.ApplicationService {
129
130
  const all = tenantIds.includes('*')
130
131
  const sharedGenDir = !main.requires.extensibility
131
132
  if (sharedGenDir) options.skipResources ??= sharedGenDir
132
- const tenants = all ? await this._getTenants() : tenantIds
133
+
134
+ // Merge static upgrade config with dynamic options (dynamic takes precedence)
135
+ const upgradeConfig = cds.env.requires[this.name]?.upgrade
136
+ if (upgradeConfig) {
137
+ options.upgrade = cds.utils.merge({ ...upgradeConfig }, options.upgrade)
138
+ }
139
+
140
+ // Determine skipAfter: from explicit option or derived from package.json mtime
141
+ let skipAfter = options?.upgrade?.skipAfter
142
+ if (!skipAfter && options?.upgrade?.skipAlreadyUpgraded) {
143
+ const { stat } = require('fs/promises')
144
+ const { mtime } = await stat(cds.root + '/package.json')
145
+ skipAfter = mtime.toISOString()
146
+ }
147
+
148
+ const tenantsOptions = skipAfter ? { skipAfter } : undefined
149
+ let tenants
150
+ let skipped = []
151
+ if (all) {
152
+ tenants = await this._getTenants(tenantsOptions)
153
+ } else if (skipAfter) {
154
+ const upgradeable = await this._getTenants(tenantsOptions)
155
+ tenants = tenantIds.filter(t => upgradeable.includes(t))
156
+ // Requested tenants filtered out by skipAfter are considered already upgraded.
157
+ skipped = tenantIds.filter(t => !upgradeable.includes(t))
158
+ } else {
159
+ tenants = tenantIds
160
+ }
161
+
133
162
  const { isSync } = this._parseHeaders(cds.context.http?.req.headers)
134
- if (!tenants.length && isSync) return
163
+ if (!tenants.length && !skipped.length && isSync) return
135
164
  if (sharedGenDir && cds.requires.db.kind === 'hana') { // REVISIT: Ideally part of HANA plugin
136
165
  const { resources4, csvs4, directory4 } = require('../plugins/hana')
137
166
  const out = await directory4('base', true)
@@ -141,13 +170,26 @@ module.exports = class ProvisioningService extends cds.ApplicationService {
141
170
  const { 'cds.xt.DeploymentService': ds } = cds.services
142
171
  const js = await cds.connect.to(JobsService)
143
172
  const { clusterSize = 3 } = js._config()
144
- const dbToTenants = (clusterSize > 1 && ds) ? await ds.tenantsByDb(tenants) : [new Set(tenants)]
145
- LOG.info('upgrading', { tenants })
173
+ const dbToTenants = (tenants.length && clusterSize > 1 && ds) ? await ds.tenantsByDb(tenants) : [new Set(tenants)]
174
+ // Skipped tenants go into a separate pseudo-db cluster so JobsService records them (and their
175
+ // tasks) as FINISHED without upgrading.
176
+ let clusters = dbToTenants
177
+ if (skipped.length) {
178
+ // Normalize array-form to a db-id-keyed object so skipped tenants get their own cluster.
179
+ clusters = { ...dbToTenants }
180
+ // Drop skipped tenants from regular clusters (some tenantsByDb impls ignore the tenant list).
181
+ const skippedSet = new Set(skipped)
182
+ for (const db of Object.keys(clusters)) {
183
+ clusters[db] = new Set([...clusters[db]].filter(t => tenants.includes(t) && !skippedSet.has(t)))
184
+ }
185
+ clusters[SKIPPED_DB] = skippedSet
186
+ }
187
+ LOG.info('upgrading', { tenants, skipped })
146
188
 
147
189
 
148
190
  return await new Promise((resolve, reject) => {
149
191
  const tx = js.tx({ tenant: cds.context.tenant, user: new cds.User.Privileged() })
150
- const job = tx.enqueue(DeploymentService, 'upgrade', dbToTenants, { options }, async error => {
192
+ const job = tx.enqueue(DeploymentService, 'upgrade', clusters, { options }, async error => {
151
193
  if (error) {
152
194
  await this._sendCallback('FAILED', `Tenant upgrade failed with error '${error}'`)
153
195
  if (isSync) reject(new Error(error))
@@ -303,4 +345,17 @@ module.exports = class ProvisioningService extends cds.ApplicationService {
303
345
  const cred = s?.credentials ?? multitenancy?.credentials ?? {}
304
346
  return cred
305
347
  }
348
+
349
+ _resolveCallbackUrl(callbackPath, baseUrl) {
350
+ if (!callbackPath || !baseUrl) return undefined
351
+ if (typeof callbackPath !== 'string' || !/^\/(?![/\\])/.test(callbackPath) || callbackPath.includes('\\')) {
352
+ throw cds.error(`Invalid callback path: ${callbackPath}`, { status: 400 })
353
+ }
354
+ const base = new URL(baseUrl)
355
+ const resolved = new URL(callbackPath, base)
356
+ if (resolved.origin !== base.origin) {
357
+ throw cds.error(`Invalid callback path: ${callbackPath}`, { status: 400 })
358
+ }
359
+ return resolved.toString()
360
+ }
306
361
  }
@@ -28,7 +28,7 @@ module.exports = class SaasProvisioningService extends require('./abstract-provi
28
28
  _parseHeaders(headers) {
29
29
  const { prefer, status_callback, mtx_status_callback } = headers ?? {}
30
30
  const { saas_registry_url } = this._getCredentials()
31
- const callbackUrl = mtx_status_callback ?? (status_callback && saas_registry_url && new URL(status_callback, saas_registry_url).toString())
31
+ const callbackUrl = mtx_status_callback ?? this._resolveCallbackUrl(status_callback, saas_registry_url)
32
32
  return {
33
33
  callbackUrl,
34
34
  isCustomCallback: !!mtx_status_callback,
@@ -53,7 +53,7 @@ module.exports = class SmsProvisioningService extends require('./abstract-provis
53
53
  _parseHeaders(headers) {
54
54
  const { prefer, status_callback } = headers ?? {}
55
55
  const { subscription_manager_url } = this._getCredentials()
56
- const callbackUrl = (status_callback && subscription_manager_url && new URL(status_callback, subscription_manager_url).toString())
56
+ const callbackUrl = this._resolveCallbackUrl(status_callback, subscription_manager_url)
57
57
  return {
58
58
  callbackUrl,
59
59
  isSync: !(prefer?.includes('respond-async') || callbackUrl)
@@ -102,11 +102,31 @@ module.exports = class SmsProvisioningService extends require('./abstract-provis
102
102
  const isIssuerValid = isValid(callback_certificate_issuer, cert.issuer)
103
103
  const isSubjectValid = isValid(callback_certificate_subject, cert.subject)
104
104
 
105
+ function parseDN(dn) {
106
+ // Node's X509Certificate.issuer/subject uses newline as RDN separator.
107
+ // Each line is a single "attribute=value" pair. Multiple lines with the
108
+ // same attribute (e.g. multiple OU= entries) are all collected into an array.
109
+ const fields = {}
110
+ for (const line of dn.split('\n')) {
111
+ const eq = line.indexOf('=')
112
+ if (eq === -1) continue
113
+ const attr = line.slice(0, eq).trim().toUpperCase()
114
+ const val = line.slice(eq + 1).trim()
115
+ if (fields[attr]) fields[attr].push(val)
116
+ else fields[attr] = [val]
117
+ }
118
+ return fields
119
+ }
120
+
105
121
  function isValid(expected, fromCert) {
122
+ const certFields = parseDN(fromCert)
106
123
  return Object.entries(JSON.parse(expected)).every(([k, v]) => {
107
124
  if (v === '*') return true
108
- if (Array.isArray(v)) return v.some(v => fromCert.includes(`${k}=${v}`))
109
- else return fromCert.includes(`${k}=${v}`)
125
+ const certVals = certFields[k.toUpperCase()] ?? []
126
+ // For array expected values every element must be present in the cert.
127
+ // For a scalar expected value the cert must contain that exact value.
128
+ if (Array.isArray(v)) return v.every(allowed => certVals.includes(allowed))
129
+ else return certVals.includes(v)
110
130
  })
111
131
  }
112
132
 
@@ -1,9 +1,15 @@
1
1
  const cds = require('@sap/cds')
2
+ const { path } = cds.utils
2
3
  module.exports = (srv) => {
3
4
  // mainly normalizing the input
4
5
  srv.before(['UPDATE', 'UPSERT'], 'Extensions', req => { // must be synchronous to be handled first
5
6
  const { csn: extension, i18n: bundles, csvs: csvData } = req.data
6
7
 
8
+ // reject file names with path components (path traversal, see DbAdapter.put)
9
+ for (const files of [bundles, csvData]) {
10
+ if (Array.isArray(files)) files.forEach(_assertPlainFileName)
11
+ }
12
+
7
13
  // Validation of extension
8
14
  if (!extension) throw new cds.error({ message: 'Property "extension" is missing', code: 400 })
9
15
  if (Array.isArray(extension)) {
@@ -41,6 +47,15 @@ module.exports = (srv) => {
41
47
  req.data.csn = extCsn
42
48
  })
43
49
 
50
+ // rejects a FILE name that is not a plain file name (prevents path traversal)
51
+ const _assertPlainFileName = function (file) {
52
+ const name = file?.name
53
+ if (typeof name !== 'string' || !name.length) return
54
+ if (path.isAbsolute(name) || name.includes('/') || name.includes('\\') || path.basename(name) !== name) {
55
+ throw new cds.error({ message: `Invalid file name: "${name}"`, code: 400 })
56
+ }
57
+ }
58
+
44
59
  const _isCSN = str => str.substring(0, 1) === '{'
45
60
 
46
61
  const _mergeCSN = function (extension, merged) {
@@ -143,8 +143,8 @@ module.exports = class AnnotationsChecker {
143
143
  // get element permissions from allowlist
144
144
  const kind = this._getExtendedKind(fullCsn, extCsn, entityOrService)
145
145
  const permissions = allowList.getPermission(kind, entityOrService)
146
- // check if annotation is allowed
147
- if (permissions && permissions.annotations && permissions.annotations.includes(annotationName)) return null
146
+ // skip only this annotation; keep checking the remaining ones on the same element
147
+ if (permissions && permissions.annotations && permissions.annotations.includes(annotationName)) continue
148
148
 
149
149
  const fn = checkedExtensionAnnotations.get(annotationName) ?? _createGenericAnnotationWarning
150
150
  const warning = fn(annotationName, annotation);
@@ -2,6 +2,8 @@ const EXTENSION_ALLOWLIST = 'extension-allowlist'
2
2
 
3
3
  const PROTECTED_NAMESPACES = ['cds.xt']
4
4
 
5
+ module.exports.PROTECTED_NAMESPACES = PROTECTED_NAMESPACES
6
+
5
7
  const BUILT_IN_NAMESPACES = ['cds.core', 'cds.outbox'] //> cds.outbox for legacy apps
6
8
 
7
9
  module.exports.isBuiltIn = (name) => {
@@ -4,6 +4,7 @@ const { deduplicateMessages } = require('@sap/cds-compiler')
4
4
  const NamespaceChecker = require('./namespace')
5
5
  const AnnotationsChecker = require('./annotations')
6
6
  const AllowlistChecker = require('./allowlist')
7
+ const ProtectedNamespaceChecker = require('./protected-namespace')
7
8
 
8
9
  const customLinters = []
9
10
 
@@ -39,6 +40,7 @@ module.exports.lint = (extCsn, fullCsn, env = cds.env, cloneExt = false) => {
39
40
  const reflectedFullCsn = cds.reflect(fullCsn)
40
41
  const compileBaseDir = cds.root
41
42
  const messages = [
43
+ ...new ProtectedNamespaceChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options),
42
44
  ...new NamespaceChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options),
43
45
  ...new AnnotationsChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options, extCsn),
44
46
  ...new AllowlistChecker().check(reflectedCsn, reflectedFullCsn, compileBaseDir, linter_options)
@@ -0,0 +1,45 @@
1
+ const LinterMessage = require('./message')
2
+ const { PROTECTED_NAMESPACES } = require('./config')
3
+
4
+ function _protectedNamespaceOf(name) {
5
+ if (!name) return null
6
+ return PROTECTED_NAMESPACES.find(ns => name === ns || name.startsWith(`${ns}.`)) ?? null
7
+ }
8
+
9
+ function locationString(element) {
10
+ const loc = element?.$location
11
+ if (!loc) return ''
12
+ const line = loc.line ? `${loc.line}:` : ''
13
+ return loc.col ? `${loc.file}:${line}${loc.col}:` : `${loc.file}:${line}`
14
+ }
15
+
16
+ // Rejects defining, extending or annotating artifacts in a protected namespace (e.g. 'cds.xt').
17
+ // Runs unconditionally, independent of linter configuration.
18
+ module.exports = class ProtectedNamespaceChecker {
19
+
20
+ // eslint-disable-next-line no-unused-vars
21
+ check(reflectedCsn, fullCsn, compileDir, mtxConfig) {
22
+ const messages = []
23
+
24
+ for (const name of Object.keys(reflectedCsn.definitions ?? {})) {
25
+ if (_protectedNamespaceOf(name)) {
26
+ messages.push(this._createWarning(name, reflectedCsn.definitions[name]))
27
+ }
28
+ }
29
+
30
+ for (const extension of reflectedCsn.extensions ?? []) {
31
+ const target = extension.extend ?? extension.annotate
32
+ if (_protectedNamespaceOf(target)) {
33
+ messages.push(this._createWarning(target, extension))
34
+ }
35
+ }
36
+
37
+ return messages
38
+ }
39
+
40
+ _createWarning(name, element) {
41
+ const namespace = _protectedNamespaceOf(name)
42
+ const message = `Extending, annotating or defining '${name}' in the protected namespace '${namespace}' is not supported in extensions`
43
+ return new LinterMessage(locationString(element) + message, element)
44
+ }
45
+ }
@@ -58,7 +58,12 @@ class DbAdapter {
58
58
  try {
59
59
  const root = await mkdirp(path.join(temp, 'data'))
60
60
  for (const csv in ext.csvs) {
61
- await write(path.join(root, csv), ext.csvs[csv])
61
+ // defense-in-depth: 'csv' is user-supplied and must not escape 'root'
62
+ const target = path.join(root, csv)
63
+ if (path.relative(root, target).match(/^\.\.($|[/\\])/) || path.isAbsolute(path.relative(root, target))) {
64
+ throw Error(`Invalid file name: "${csv}"`)
65
+ }
66
+ await write(target, ext.csvs[csv])
62
67
  }
63
68
  ext.archive = await tar.cz(temp)
64
69
  } finally {
@@ -74,7 +79,7 @@ class DbAdapter {
74
79
  const csn = JSON.stringify(ext.csn)
75
80
  const i18n = ext.i18n ? JSON.stringify(ext.i18n) : null
76
81
  const tag = ext.ID
77
- await cds.tx({ tenant, user: new cds.User.Privileged() }, async tx => {
82
+ await cds.tx({ tenant }, async tx => {
78
83
  // remove current inactive extension with tag
79
84
  if (tag) await tx.run(DELETE.from('cds.xt.Extensions').where({ tag, activated: { '!=': this.activated(ACTIVE) } }))
80
85
  // will break with missing appid
@@ -455,7 +460,7 @@ module.exports = class ExtensibilityService extends cds.ApplicationService {
455
460
  let extEntry
456
461
  try {
457
462
  // TODO: handle '*'?
458
- await cds.tx(async () => {
463
+ await cds.tx({ tenant },async () => {
459
464
  // use native table names to avoid issues with missing appid
460
465
  extEntry = await SELECT(['ID','csn', 'tag','activated']).one.from('cds.xt.Extensions').where({ tag, activated: { '!=': 'database' } }).forUpdate() // select only entries with activated != 'database'
461
466
  if (!extEntry) {