@sap/cds-dk 10.2.0 → 10.2.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 (90) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/bin/bind.js +5 -4
  3. package/bin/completion/scripts/cds.fish +4 -3
  4. package/bin/completion/scripts/cds.ps1 +4 -4
  5. package/bin/completion/scripts/cds.sh +4 -4
  6. package/bin/completion/scripts/cds.zsh +4 -4
  7. package/bin/debug.js +3 -3
  8. package/bin/deploy/index.js +2 -2
  9. package/bin/deploy/to-hana/hana.js +1 -1
  10. package/bin/deploy/to-hana/hdiDeployUtil.js +25 -14
  11. package/bin/up.js +3 -3
  12. package/bin/version.js +1 -1
  13. package/bin/watch/vite-watch.js +12 -6
  14. package/lib/bind/shared.js +1 -1
  15. package/lib/init/index.js +1 -0
  16. package/lib/init/projectReader.js +3 -2
  17. package/lib/init/template/approuter/files/values.yaml.hbs +0 -9
  18. package/lib/init/template/approuter/files/xs-app.json.hbs +0 -22
  19. package/lib/init/template/approuter/index.js +8 -9
  20. package/lib/init/template/html5-repo/index.js +12 -0
  21. package/lib/init/template/react/files/vite.config.{{extension}}.hbs +2 -3
  22. package/lib/init/template/react/index.js +1 -3
  23. package/lib/init/template/sample/files/app/index.html.hbs +18 -0
  24. package/lib/init/template/sample/index.js +1 -0
  25. package/lib/init/template/vue/files/vite.config.{{extension}}.hbs +2 -3
  26. package/lib/init/template/vue/index.js +1 -3
  27. package/lib/mtx/push.js +7 -1
  28. package/node_modules/@cap-js/asyncapi/README.md +4 -14
  29. package/node_modules/@cap-js/asyncapi/cds-plugin.js +12 -0
  30. package/node_modules/@cap-js/asyncapi/index.js +3 -2
  31. package/node_modules/@cap-js/asyncapi/lib/compile/components/messages/index.js +4 -2
  32. package/node_modules/@cap-js/asyncapi/lib/compile/components/schemas/definition.js +11 -1
  33. package/node_modules/@cap-js/asyncapi/lib/compile/components/schemas/utils.js +16 -0
  34. package/node_modules/@cap-js/asyncapi/lib/compile/utils.js +4 -3
  35. package/node_modules/@cap-js/asyncapi/lib/import/importAsyncAPI.js +34 -0
  36. package/node_modules/@cap-js/asyncapi/lib/import/index.js +18 -0
  37. package/node_modules/@cap-js/asyncapi/lib/import/message.js +9 -0
  38. package/node_modules/@cap-js/asyncapi/lib/import/parserContext.js +368 -0
  39. package/node_modules/@cap-js/asyncapi/lib/import/utils.js +85 -0
  40. package/node_modules/@cap-js/asyncapi/package.json +89 -1
  41. package/node_modules/@sap/cds/CHANGELOG.md +10 -0
  42. package/node_modules/@sap/cds/app/index.js +1 -1
  43. package/node_modules/@sap/cds/lib/env/schemas/cds-package.js +0 -2
  44. package/node_modules/@sap/cds/lib/env/schemas/cds-rc.js +3 -3
  45. package/node_modules/@sap/cds/lib/env/schemas/index.js +4 -4
  46. package/node_modules/@sap/cds/lib/utils/cds-utils.js +21 -8
  47. package/node_modules/@sap/cds/libx/_runtime/messaging/common-utils/connections.js +45 -39
  48. package/node_modules/@sap/cds/libx/odata/ODataAdapter.js +1 -1
  49. package/node_modules/@sap/cds/libx/queue/TaskRunner.js +22 -5
  50. package/node_modules/@sap/cds/package.json +1 -1
  51. package/node_modules/@sap/cds-mtxs/CHANGELOG.md +8 -0
  52. package/node_modules/@sap/cds-mtxs/package.json +1 -1
  53. package/node_modules/@sap/cds-mtxs/srv/extensibility-service.js +57 -41
  54. package/node_modules/@sap/cds-mtxs/srv/jobs-service.js +27 -12
  55. package/node_modules/@sap/cds-mtxs/srv/plugins/hana.js +31 -25
  56. package/node_modules/body-parser/README.md +17 -13
  57. package/node_modules/body-parser/lib/read.js +74 -57
  58. package/node_modules/body-parser/lib/types/json.js +11 -11
  59. package/node_modules/body-parser/lib/types/urlencoded.js +5 -5
  60. package/node_modules/body-parser/lib/utils.js +0 -15
  61. package/node_modules/body-parser/package.json +7 -12
  62. package/node_modules/encodeurl/README.md +14 -0
  63. package/node_modules/encodeurl/index.js +5 -10
  64. package/node_modules/encodeurl/package.json +10 -6
  65. package/node_modules/on-finished/index.js +14 -2
  66. package/node_modules/on-finished/package.json +11 -7
  67. package/node_modules/qs/.editorconfig +1 -1
  68. package/node_modules/qs/CHANGELOG.md +87 -22
  69. package/node_modules/qs/README.md +63 -10
  70. package/node_modules/qs/dist/qs.js +17 -17
  71. package/node_modules/qs/eslint.config.mjs +61 -50
  72. package/node_modules/qs/lib/formats.js +7 -2
  73. package/node_modules/qs/lib/parse.js +29 -10
  74. package/node_modules/qs/lib/stringify.js +5 -3
  75. package/node_modules/qs/lib/utils.js +28 -9
  76. package/node_modules/qs/package.json +6 -6
  77. package/node_modules/qs/test/parse.js +335 -2
  78. package/node_modules/qs/test/stringify.js +222 -0
  79. package/node_modules/qs/test/utils.js +61 -3
  80. package/node_modules/semver/classes/range.js +2 -2
  81. package/node_modules/semver/internal/constants.js +30 -0
  82. package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
  83. package/node_modules/semver/package.json +1 -1
  84. package/node_modules/ws/README.md +21 -29
  85. package/node_modules/ws/lib/buffer-util.js +13 -2
  86. package/node_modules/ws/lib/sender.js +4 -1
  87. package/node_modules/ws/lib/websocket.js +8 -3
  88. package/node_modules/ws/package.json +1 -1
  89. package/package.json +1 -1
  90. package/node_modules/on-finished/HISTORY.md +0 -98
@@ -0,0 +1,85 @@
1
+ const messages = require('./message')
2
+
3
+ function validateAsyncAPI(content) {
4
+ if ('channels' in content && 'components' in content && 'messages' in content.components) {
5
+ return
6
+ }
7
+
8
+ throw new Error(messages.INVALID_ASYNCAPI_FILE)
9
+ }
10
+
11
+ /**
12
+ * Accepted scenarios:
13
+ * * with version info : <namespace>.<businessObject>.<operation>.<version>
14
+ * * without version info : <namespace>.<businessObject>.<operation>
15
+ *
16
+ * ? Is it mandatory for <namespace> to always follow small case notation?
17
+ * ? Is it mandatory for <businessObject> to follow Camel Case or Upper Came Case notation?
18
+ *
19
+ * ToDo: Can we support `-` in operations?
20
+ */
21
+ const regExp = new RegExp(
22
+ '^(?<namespace>(?:[a-z][a-z0-9]*)(?:[.][a-z][a-z0-9]*)+)[.](?<businessObject>[a-zA-Z0-9]+)[.](?<operation>[a-zA-Z0-9]+)[.]?(?<version>v(?:[0-9]|[1-9][0-9]*))?$'
23
+ )
24
+
25
+ /**
26
+ * Validates the messages' `key` with the regExp and returns the regEx groups.
27
+ * @returns regexGroups
28
+ */
29
+ function getRegExpGroups(key) {
30
+ /**
31
+ * Result format:
32
+ * index 0: key
33
+ * index 1: <namespace> value
34
+ * index 2: <businessObject> value
35
+ * index 3: <operation> value
36
+ * index 4: <version> value; undefined if <version> is optional
37
+ */
38
+ const regExpGroups = regExp.exec(key)
39
+
40
+ if (regExpGroups == null) {
41
+ throw new Error(messages.INVALID_MESSAGE_NAME)
42
+ }
43
+ return regExpGroups
44
+ }
45
+
46
+ /**
47
+ * Parses a message name into a [fullName, serviceName] pair.
48
+ *
49
+ * Two formats are supported:
50
+ *
51
+ * 1. SAP event-catalog format: `<namespace>.<BusinessObject>.<Operation>[.v<n>]`
52
+ * where <namespace> is ≥2 lowercase dot-separated segments (e.g. `sap.s4.beh`).
53
+ * Detected by matching the strict regex; service key = namespace + '.' + businessObject.
54
+ *
55
+ * 2. CAP-produced format: `<ServiceName>.<EventName>` (optionally nested), where the
56
+ * first segment is always the PascalCase CDS service name.
57
+ * Detected by the presence of `x-sap-application-namespace` in the AsyncAPI document.
58
+ *
59
+ * @param {string} key - The message name to parse
60
+ * @param {string|undefined} applicationNamespace - Value of `x-sap-application-namespace`
61
+ * from the AsyncAPI document, or undefined if absent
62
+ * @returns {Array} [fullName, serviceName]
63
+ */
64
+ function parseMessageName(key, applicationNamespace) {
65
+ const regExpGroups = regExp.exec(key)
66
+ if (regExpGroups != null) {
67
+ const serviceKey = regExpGroups[1] + '.' + regExpGroups[2]
68
+ return [regExpGroups[0], serviceKey]
69
+ }
70
+
71
+ if (applicationNamespace) {
72
+ const segments = key.split('.')
73
+ if (segments.length >= 2) {
74
+ return [key, segments[0]]
75
+ }
76
+ }
77
+
78
+ throw new Error(messages.INVALID_MESSAGE_NAME)
79
+ }
80
+
81
+ module.exports = {
82
+ validateAsyncAPI,
83
+ getRegExpGroups,
84
+ parseMessageName
85
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/asyncapi",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "CAP tool for AsyncAPI",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,9 +16,14 @@
16
16
  "author": "SAP SE (https://www.sap.com)",
17
17
  "license": "Apache-2.0",
18
18
  "main": "index.js",
19
+ "exports": {
20
+ ".": "./index.js",
21
+ "./cds-plugin": "./cds-plugin.js"
22
+ },
19
23
  "files": [
20
24
  "lib/",
21
25
  "index.js",
26
+ "cds-plugin.js",
22
27
  "LICENSE"
23
28
  ],
24
29
  "scripts": {
@@ -29,6 +34,89 @@
29
34
  "@sap/cds": ">=7.6"
30
35
  },
31
36
  "devDependencies": {
37
+ "@sap/cds-dk": "^10",
32
38
  "eslint": "^10.0.0"
39
+ },
40
+ "cds": {
41
+ "schema": {
42
+ "export": {
43
+ "asyncapi": {
44
+ "type": "object",
45
+ "description": "AsyncAPI settings for cds compile",
46
+ "additionalProperties": false,
47
+ "properties": {
48
+ "application_namespace": {
49
+ "type": "string",
50
+ "description": "The registered namespace of the application"
51
+ },
52
+ "merged": {
53
+ "type": "object",
54
+ "description": "properties that needs to be there when merged option is used",
55
+ "additionalProperties": false,
56
+ "properties": {
57
+ "title": {
58
+ "type": "string",
59
+ "description": "Title to be used for merged doc"
60
+ },
61
+ "version": {
62
+ "type": "string",
63
+ "description": "Version to be used for merged doc"
64
+ },
65
+ "short_text": {
66
+ "type": "string",
67
+ "description": "Short description of the AsyncAPIs"
68
+ },
69
+ "description": {
70
+ "type": "string",
71
+ "description": "Description to be used for the merged doc"
72
+ }
73
+ }
74
+ },
75
+ "event_spec_version": {
76
+ "type": "string",
77
+ "description": "Mentions the version of AsyncAPI spec"
78
+ },
79
+ "event_source": {
80
+ "type": "string",
81
+ "description": "Event source information",
82
+ "default": "{region}/{applicationNamespace}/{instanceId}"
83
+ },
84
+ "event_source_params": {
85
+ "type": "object",
86
+ "description": "Parameters related to the source of event",
87
+ "default": {
88
+ "region": "the region",
89
+ "applicationNamespace": "the namespace",
90
+ "instanceId": "the instance id"
91
+ },
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "region": {
95
+ "type": "string",
96
+ "description": "The regional context of the application"
97
+ },
98
+ "applicationNamespace": {
99
+ "type": "string",
100
+ "description": "The registered namespace of the application"
101
+ },
102
+ "instanceId": {
103
+ "type": "string",
104
+ "description": "The instance id (tenant, installation, ...) of the application."
105
+ }
106
+ }
107
+ },
108
+ "event_characteristics": {
109
+ "type": "object",
110
+ "description": "The characteristics defined for events",
111
+ "additionalProperties": true
112
+ }
113
+ }
114
+ }
115
+ },
116
+ "importFrom": {
117
+ "const": "asyncapi",
118
+ "description": "AsyncAPI specification"
119
+ }
120
+ }
33
121
  }
34
122
  }
@@ -4,6 +4,16 @@
4
4
  - The format is based on [Keep a Changelog](https://keepachangelog.com/).
5
5
  - This project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## Version 10.2.2 - 2026-10-28
8
+
9
+ ### Fixed
10
+
11
+ - App index page no longer generates links with a double slash (e.g. `//bookshop`) for app links that already start with `/`
12
+ - Prevent more than one concurrent `AMQP` client reconnect loop
13
+ - Translation of messages in `sap-messages` header
14
+ - Legacy event queue: `TimeoutOverflowWarning` no longer emitted when tasks are scheduled with a retention period exceeding 24.8 days (e.g. `cds.env.agents.retention = '31d'`)
15
+ - CDS config JSON schema: corrected invalid/misplaced `additionalProperties` keywords so extra properties are validated as intended
16
+
7
17
  ## Version 10.2.1 - 2026-10-20
8
18
 
9
19
  ### Fixed
@@ -38,7 +38,7 @@ const app_info = {
38
38
  return find (folder, ['*.html', '*/*.html', '*/*/*.html'])
39
39
  .map (file => path.relative(folder,file).replace(/\\/g,'/'))
40
40
  .concat (cds.app._app_links||[]) // concatenate additional app links
41
- .map (href => ({ href: '/'+href, label: href.replace('/index.html','') }))
41
+ .map (href => ({ href: href[0]==='/' ? href : '/'+href, label: href.replace('/index.html','') }))
42
42
  },
43
43
 
44
44
  get agents() {
@@ -17,8 +17,6 @@ module.exports = {
17
17
  type: 'string'
18
18
  },
19
19
  cds: {
20
- type: 'object',
21
- additionalProperties: true,
22
20
  $ref: 'cdsjsonschema://schemas/cds-rc.json#/$defs/cdsRoot',
23
21
  description: 'CDS configuration root',
24
22
  default: {}
@@ -29,7 +29,7 @@ module.exports = {
29
29
 
30
30
  $defs: {
31
31
  cdsRoot: {
32
- allowAdditionalProperties: true,
32
+ additionalProperties: true,
33
33
  patternProperties: {
34
34
  '\\[.+\\]': {
35
35
  default: {},
@@ -489,12 +489,12 @@ module.exports = {
489
489
  create: {
490
490
  type: 'object',
491
491
  description: 'HDI container provisioning parameters.',
492
+ additionalProperties: true,
492
493
  properties: {
493
494
  database_id: {
494
495
  type: 'string',
495
496
  description: 'HANA Cloud instance ID.'
496
- },
497
- additionalProperties: true
497
+ }
498
498
  }
499
499
  },
500
500
  bind: {
@@ -1,12 +1,12 @@
1
- const { join } = require('path')
1
+ const { join, parse } = require('node:path')
2
2
 
3
3
  module.exports = new class {
4
4
  async default4(name) {
5
- const file = join(__dirname, name.replace(/\.\w+$/, ''))
5
+ const file = join(__dirname, parse(name).name)
6
6
  try {
7
7
  return structuredClone(require(file))
8
- } catch {
9
- throw new Error(`ENOENT: Could not load schema '${name}' from ${file}`)
8
+ } catch (e) {
9
+ throw new Error(`ENOENT: Could not load schema '${name}'`, { cause: e })
10
10
  }
11
11
  }
12
12
  }
@@ -203,20 +203,33 @@ exports.write = function write (file, data, o) {
203
203
  }
204
204
 
205
205
  const { setTimeout: sleep } = require('node:timers/promises')
206
- exports.lock = async function (file, retries = 10, delay = 20) {
206
+ exports.lock = async function (file, retries = 10, delay = 20, stale = 5_000) {
207
207
  file = resolve (cds.root, file)
208
- const { open, unlink } = fs.promises
209
- for (let i = 0; i < retries; i++) try {
210
- const handle = await open (file+'.lock', 'wx')
208
+ const lockfile = file + '.lock'
209
+ const { open, unlink, stat } = fs.promises
210
+ // `retries` bounds waits on a live lock and, independently, reclaim attempts:
211
+ // a stale reclaim or a vanished-lock race retries the acquire at once — so the
212
+ // final reclaim still gets a matching attempt — without spinning unbounded.
213
+ for (let waited = 0, reclaims = 0; ;) try {
214
+ const handle = await open (lockfile, 'wx')
211
215
  await handle.close()
212
- return {release:() => unlink (file+'.lock') .catch (err => {
216
+ return {release:() => unlink (lockfile) .catch (err => {
213
217
  if (err.code !== 'ENOENT') throw err // ignore if already deleted
214
218
  })}
215
219
  } catch (err) {
216
- if (err.code === 'EEXIST') await sleep (delay)
217
- else throw err
220
+ if (err.code !== 'EEXIST') throw err
221
+ // Config edits normally complete quickly, so a lock older than `stale`
222
+ // likely belongs to a process that crashed mid-edit. A negative age means
223
+ // the lock vanished between the failed open and the stat — retry at once.
224
+ const age = await stat (lockfile) .then (s => Date.now() - s.mtimeMs, () => -1)
225
+ if (age < 0 || age > stale) { // vanished, or stale → reclaim and retry now
226
+ if (age > stale) await unlink (lockfile) .catch (() => {})
227
+ if (reclaims++ < retries) continue
228
+ } else if (waited++ < retries) { // held by a live process → wait and retry
229
+ await sleep (delay); continue
230
+ }
231
+ throw new Error (`Could not acquire lock on ${file}`, { cause: err })
218
232
  }
219
- throw new Error('Could not acquire lock: connection timed out')
220
233
  }
221
234
 
222
235
 
@@ -10,45 +10,24 @@ const connect = (client, LOG, keepAlive) => {
10
10
  return new Promise((resolve, reject) => {
11
11
  _rmHandlers(client)
12
12
 
13
- client
14
- .once('connected', function () {
15
- _rmHandlers(client)
16
-
17
- // It's important to _always_ keep an error listener,
18
- // otherwise it will throw and crash the server
19
- client.on('error', err => LOG(err?.message))
20
-
21
- if (keepAlive) {
22
- client.on('disconnected', () => {
23
- let connected = false
24
- client.once('connected', () => {
25
- LOG('Reconnected')
26
- connected = true
27
- })
28
- let x = 1
29
- const _untilConnected = async () => {
30
- if (!connected) {
31
- LOG('Reconnecting')
32
- try {
33
- client.connect()
34
- } catch {
35
- // we try again...
36
- }
37
- setTimeout(_untilConnected, waitingTime(x++)).unref()
38
- }
39
- }
40
- _untilConnected()
41
- })
42
- }
43
-
44
- resolve(client)
45
- })
46
- .once('error', err => {
47
- _rmHandlers(client)
48
- const e = new Error('Connection error')
49
- e.cause = err
50
- reject(e)
51
- })
13
+ client.once('connected', function () {
14
+ _rmHandlers(client)
15
+
16
+ // It's important to _always_ keep an error listener,
17
+ // otherwise it will throw and crash the server
18
+ client.on('error', err => LOG(err?.message))
19
+
20
+ if (keepAlive) client.once('disconnected', reconnect(client, LOG))
21
+
22
+ resolve(client)
23
+ })
24
+
25
+ client.once('error', err => {
26
+ _rmHandlers(client)
27
+ const e = new Error('Connection error')
28
+ e.cause = err
29
+ reject(e)
30
+ })
52
31
 
53
32
  try {
54
33
  client.connect()
@@ -58,6 +37,33 @@ const connect = (client, LOG, keepAlive) => {
58
37
  })
59
38
  }
60
39
 
40
+ const reconnect = (client, LOG) => () => {
41
+ let connected = false
42
+
43
+ client.once('connected', () => {
44
+ LOG('Reconnected')
45
+ connected = true
46
+ client.once('disconnected', reconnect(client, LOG))
47
+ })
48
+
49
+ let retries = 1
50
+ const _untilConnected = async () => {
51
+ if (!connected) {
52
+ LOG('Reconnecting')
53
+
54
+ try {
55
+ client.connect()
56
+ } catch {
57
+ // we try again ...
58
+ }
59
+
60
+ setTimeout(_untilConnected, waitingTime(retries++)).unref()
61
+ }
62
+ }
63
+
64
+ _untilConnected()
65
+ }
66
+
61
67
  const disconnect = client => {
62
68
  return new Promise((resolve, reject) => {
63
69
  _rmHandlers(client)
@@ -111,7 +111,7 @@ class ODataAdapter extends HttpAdapter {
111
111
  if (res.headersSent || !cdsReq.messages?.length) return
112
112
  const messages = cdsReq.messages.map(err => {
113
113
  if (!err.code) err.code = '' // default to empty string if not provided
114
- const { code, message, numericSeverity, longtextUrl, target, additionalTargets } = this.normalized(err)
114
+ const { code, message, numericSeverity, longtextUrl, target, additionalTargets } = this.localized(this.normalized(err))
115
115
  // only return allowed subset of properties
116
116
  return { code, message, numericSeverity, longtextUrl, target, additionalTargets }
117
117
  })
@@ -4,6 +4,26 @@ const QUEUED = 'queued'
4
4
  const PLANNED = 'planned'
5
5
  const CALLBACKS = 'callbacks'
6
6
 
7
+ // Node's setTimeout delay is a 32-bit signed int; delays > MAX_TIMEOUT_MS emit a
8
+ // TimeoutOverflowWarning and fire almost immediately. Chain segments so the callback
9
+ // fires at the correct time. Returns a handle with cancel() that always clears the
10
+ // currently-active segment — not just the first — so re-plans work at any point in the chain.
11
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1
12
+ const _setLongTimeout = (cb, delay) => {
13
+ const deadline = Date.now() + delay
14
+ const handle = {}
15
+ const schedule = () => {
16
+ // Re-derive the step from the absolute deadline each segment, so lateness in one
17
+ // segment (process suspension, blocked event loop) doesn't compound across the chain.
18
+ const remaining = deadline - Date.now()
19
+ const step = Math.min(Math.max(remaining, 0), MAX_TIMEOUT_MS)
20
+ const t = setTimeout(remaining > MAX_TIMEOUT_MS ? schedule : cb, step).unref()
21
+ handle.cancel = () => clearTimeout(t)
22
+ }
23
+ schedule()
24
+ return handle
25
+ }
26
+
7
27
  module.exports = class TaskRunner {
8
28
  constructor() {
9
29
  this.states = new Map()
@@ -49,11 +69,8 @@ module.exports = class TaskRunner {
49
69
  const newDate = Date.now() + waitingTime
50
70
  const alreadyPlanned = this._getStateProp(PLANNED, { name, tenant })
51
71
  if (!alreadyPlanned || alreadyPlanned.date > newDate) {
52
- if (alreadyPlanned) clearInterval(alreadyPlanned.timer)
53
- const timer = setTimeout(
54
- () => (this._setStateProp(PLANNED, undefined, { name, tenant }), cb()),
55
- waitingTime
56
- ).unref()
72
+ if (alreadyPlanned) alreadyPlanned.timer.cancel()
73
+ const timer = _setLongTimeout(() => (this._setStateProp(PLANNED, undefined, { name, tenant }), cb()), waitingTime)
57
74
  this._setStateProp(PLANNED, { date: newDate, timer }, { name, tenant })
58
75
  }
59
76
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "10.2.1",
3
+ "version": "10.2.2",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [
@@ -6,6 +6,14 @@ 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.2.1 - 2026-10-28
10
+
11
+ ### Fixed
12
+
13
+ - Extensibility login now falls back to XSUAA when required IAS login metadata is unavailable, instead of using provider IAS metadata for an unresolved subscriber.
14
+ - `cds.xt.DeploymentService` now reuses extracted HANA deployment resources across tenant subscriptions when extensibility is disabled.
15
+ - Database and CSN compilation errors during HANA tenant upgrades now fail only the affected tenant instead of shutting down the server and stopping the entire upgrade job.
16
+
9
17
  ## Version 4.2.0 - 2026-10-21
10
18
 
11
19
  ### Added
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds-mtxs",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
4
4
  "description": "SAP Cloud Application Programming Model - Multitenancy library",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [
@@ -443,12 +443,15 @@ module.exports = class ExtensibilityService extends cds.ApplicationService {
443
443
  cds.on('served', () => {
444
444
  cds.app?.post('/-/cds/login/grant', express.urlencoded({ extended: false }), async (req, res) => {
445
445
  try {
446
- const cred = cds.env.requires.auth.credentials || {}
447
- if (!cred.clientid) throw cds.error('missing authentication service binding', { status: 500 })
448
-
449
446
  const query = req.body || {}
447
+ const meta = _isIas() ? await _iasMetadata(query) : undefined
448
+ if (!meta && _isIas() && typeof query.authorization_code === 'string') {
449
+ throw cds.error('IAS login metadata is unavailable', { status: 503 })
450
+ }
451
+ const cred = meta ? cds.env.requires.auth.credentials : _xsuaaCredentials()
452
+ if (!cred.clientid) throw cds.error('missing authentication service binding', { status: 500 })
450
453
 
451
- const raw = _isIas() ? await _iasGrant(cred, query) : await _xsuaaGrant(cred, query)
454
+ const raw = meta ? await _iasGrant(cred, query, meta) : await _xsuaaGrant(cred, query)
452
455
  const data = JSON.parse(raw)
453
456
  if (data.error) return res.status(401).json(data)
454
457
  res.json(data)
@@ -542,6 +545,13 @@ function _isIas() {
542
545
  return cds.env.requires?.auth?.kind === 'ias'
543
546
  }
544
547
 
548
+ function _xsuaaCredentials() {
549
+ const auth = cds.env.requires?.auth
550
+ const cred = _isIas() ? cds.env.requires[auth.xsuaa || 'xsuaa']?.credentials : auth?.credentials
551
+ if (!cred) throw cds.error('missing XSUAA service binding', { status: 500 })
552
+ return cred
553
+ }
554
+
545
555
  // XSUAA login: exchange passcode or refresh_token for a token with the
546
556
  // `<xsappname>.cds.ExtensionDeveloper` scope.
547
557
  async function _xsuaaGrant(cred, query) {
@@ -556,11 +566,12 @@ async function _xsuaaGrant(cred, query) {
556
566
  return authToken(cred, { form, subdomain: query.subdomain })
557
567
  }
558
568
 
559
- // Resolves the subscriber's IAS login target: `app_tid` (request → subscriber by subdomain
560
- // → provider binding) and the subscriber's IAS `tenantHost` (undefined → provider host).
569
+ // A requested subscriber must have its own IAS tenant; never substitute the provider.
570
+ // Without a subdomain, use the explicit app_tid or provider binding.
561
571
  async function _iasLoginTarget(cred, query = {}) {
562
572
  const subdomain = typeof query.subdomain === 'string' ? query.subdomain : undefined
563
573
  const tenant = subdomain ? await _subscriberTenant(subdomain, cred) : undefined
574
+ if (subdomain && (!tenant?.app_tid || !tenant?.tenantHost)) return {}
564
575
  const app_tid =
565
576
  (typeof query.app_tid === 'string' && query.app_tid) ||
566
577
  tenant?.app_tid ||
@@ -621,14 +632,13 @@ async function _subscriberTenant(subdomain, cred) {
621
632
 
622
633
  // IAS login: exchange the authorization_code (browser PKCE flow) or a refresh_token
623
634
  // for a token. Authorization as `cds.ExtensionDeveloper` comes from AMS policies, not scopes.
624
- async function _iasGrant(cred, query) {
635
+ async function _iasGrant(cred, query, meta) {
625
636
  const refresh_token = typeof query.refresh_token === 'string' ? query.refresh_token : undefined
626
637
  const code = typeof query.authorization_code === 'string' ? query.authorization_code : undefined
627
638
  if (!refresh_token && !code)
628
639
  throw cds.error('missing authorization_code or refresh_token', { status: 400 })
629
640
 
630
- // Redeem the code at the (consumer) tenant that issued it; fall back to the provider host.
631
- const { app_tid, tenantHost } = await _iasLoginTarget(cred, query)
641
+ const { app_tid, token_endpoint } = meta
632
642
 
633
643
  const form = refresh_token
634
644
  ? { grant_type: 'refresh_token', refresh_token }
@@ -640,42 +650,49 @@ async function _iasGrant(cred, query) {
640
650
  }
641
651
  if (app_tid) form.app_tid = app_tid
642
652
 
643
- // mTLS via the provider's X509 client; host is the consumer tenant when resolved, else
644
- // the fixed provider host (no `subdomain`).
645
- return authToken(cred, tenantHost
646
- ? { form, endpoint: `https://${tenantHost}${IAS_TOKEN_PATH}` }
647
- : { form, path: IAS_TOKEN_PATH })
653
+ return authToken(cred, { form, endpoint: token_endpoint })
648
654
  }
649
655
 
650
- async function authMeta(req, res) {
656
+ async function _iasMetadata(query = {}) {
651
657
  const cred = cds.env.requires?.auth?.credentials || {}
652
658
  try {
653
- if (_isIas()) {
654
- // Redirect the browser to the subscriber's own IAS tenant; fall back to the provider host.
655
- const { app_tid, tenantHost } = await _iasLoginTarget(cred, req.query || {})
656
- const meta = {
657
- flow: 'authorization_code',
658
- pkce: true,
659
- client_id: cred.clientid,
660
- scope: 'openid',
661
- authorization_endpoint: tenantHost ? `https://${tenantHost}${IAS_AUTHORIZE_PATH}` : iasAuthorizeUrl(cred),
662
- token_endpoint: tenantHost ? `https://${tenantHost}${IAS_TOKEN_PATH}` : iasTokenUrl(cred),
663
- ...(app_tid && { app_tid }) // sent on both authorize and token
664
- }
665
- DEBUG?.('IAS login metadata:', {
666
- client_id: meta.client_id,
667
- requested_subdomain: req.query?.subdomain,
668
- app_tid: meta.app_tid,
669
- binding_app_tid: cred.app_tid || cred.appTid || cred.zone_uuid,
670
- tenant_host: tenantHost,
671
- authorization_endpoint: meta.authorization_endpoint,
672
- token_endpoint: meta.token_endpoint,
673
- binding_url: cred.url,
674
- binding_certurl: cred.certurl
675
- })
676
- return res.send(meta)
659
+ const { app_tid, tenantHost } = await _iasLoginTarget(cred, query)
660
+ if (typeof cred.clientid !== 'string' || !cred.clientid.trim() ||
661
+ typeof app_tid !== 'string' || !app_tid.trim()) return undefined
662
+ const meta = {
663
+ flow: 'authorization_code',
664
+ pkce: true,
665
+ client_id: cred.clientid,
666
+ scope: 'openid',
667
+ authorization_endpoint: tenantHost ? `https://${tenantHost}${IAS_AUTHORIZE_PATH}` : iasAuthorizeUrl(cred),
668
+ token_endpoint: tenantHost ? `https://${tenantHost}${IAS_TOKEN_PATH}` : iasTokenUrl(cred),
669
+ app_tid // sent on both authorize and token
677
670
  }
678
- res.send({ passcode_url: _passcodeUrl(cred, req.query?.subdomain) })
671
+ for (const endpoint of [meta.authorization_endpoint, meta.token_endpoint]) {
672
+ if (new URL(endpoint).protocol !== 'https:') return undefined
673
+ }
674
+ DEBUG?.('IAS login metadata:', {
675
+ client_id: meta.client_id,
676
+ requested_subdomain: query.subdomain,
677
+ app_tid: meta.app_tid,
678
+ binding_app_tid: cred.app_tid || cred.appTid || cred.zone_uuid,
679
+ tenant_host: tenantHost,
680
+ authorization_endpoint: meta.authorization_endpoint,
681
+ token_endpoint: meta.token_endpoint,
682
+ binding_url: cred.url,
683
+ binding_certurl: cred.certurl
684
+ })
685
+ return meta
686
+ } catch (err) {
687
+ DEBUG?.('Failed to resolve IAS login metadata; falling back to XSUAA:', err.message)
688
+ return undefined
689
+ }
690
+ }
691
+
692
+ async function authMeta(req, res) {
693
+ try {
694
+ const meta = _isIas() ? await _iasMetadata(req.query || {}) : undefined
695
+ res.send(meta || { passcode_url: _passcodeUrl(_xsuaaCredentials(), req.query?.subdomain) })
679
696
  } catch (err) {
680
697
  res.status(err.status || err.statusCode || 500).json({ error: err.message })
681
698
  }
@@ -715,4 +732,3 @@ const _decodeError = (activationError) => {
715
732
  return value
716
733
  })
717
734
  }
718
-