@sap/cds 9.6.4 → 9.7.0

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 (47) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/bin/serve.js +38 -26
  3. package/lib/compile/for/flows.js +92 -19
  4. package/lib/compile/for/lean_drafts.js +0 -47
  5. package/lib/compile/for/nodejs.js +47 -14
  6. package/lib/compile/for/odata.js +20 -0
  7. package/lib/compile/load.js +22 -25
  8. package/lib/compile/minify.js +29 -11
  9. package/lib/compile/parse.js +1 -1
  10. package/lib/compile/resolve.js +133 -76
  11. package/lib/compile/to/csn.js +2 -2
  12. package/lib/dbs/cds-deploy.js +48 -43
  13. package/lib/env/cds-env.js +6 -0
  14. package/lib/env/cds-requires.js +9 -3
  15. package/lib/index.js +3 -1
  16. package/lib/plugins.js +1 -1
  17. package/lib/req/request.js +2 -2
  18. package/lib/srv/bindings.js +10 -5
  19. package/lib/srv/middlewares/auth/index.js +7 -5
  20. package/lib/srv/protocols/hcql.js +8 -3
  21. package/lib/srv/protocols/index.js +1 -0
  22. package/lib/utils/cds-utils.js +28 -1
  23. package/lib/utils/colors.js +1 -1
  24. package/libx/_runtime/common/generic/assert.js +1 -7
  25. package/libx/_runtime/common/generic/flows.js +14 -4
  26. package/libx/_runtime/common/utils/resolveView.js +4 -0
  27. package/libx/_runtime/fiori/lean-draft.js +8 -3
  28. package/libx/_runtime/messaging/common-utils/authorizedRequest.js +4 -0
  29. package/libx/_runtime/messaging/enterprise-messaging-utils/EMManagement.js +12 -12
  30. package/libx/_runtime/messaging/enterprise-messaging.js +1 -1
  31. package/libx/_runtime/messaging/http-utils/token.js +18 -3
  32. package/libx/_runtime/messaging/message-queuing.js +7 -7
  33. package/libx/_runtime/remote/Service.js +3 -1
  34. package/libx/_runtime/remote/utils/client.js +1 -0
  35. package/libx/_runtime/remote/utils/query.js +0 -1
  36. package/libx/odata/middleware/batch.js +128 -112
  37. package/libx/odata/middleware/error.js +7 -3
  38. package/libx/odata/parse/afterburner.js +10 -11
  39. package/libx/odata/parse/grammar.peggy +4 -2
  40. package/libx/odata/parse/parser.js +1 -1
  41. package/libx/odata/utils/odataBind.js +8 -2
  42. package/libx/queue/index.js +1 -0
  43. package/package.json +4 -7
  44. package/srv/outbox.cds +1 -1
  45. package/srv/ucl-service.cds +3 -5
  46. package/bin/colors.js +0 -2
  47. package/libx/_runtime/.eslintrc +0 -14
@@ -332,13 +332,40 @@ exports.redacted = function _redacted(cred) {
332
332
  if (Array.isArray(cred)) return cred.map(c => typeof c === 'string' ? '...' : _redacted(c))
333
333
  if (typeof cred === 'object') {
334
334
  const newCred = Object.assign({}, cred)
335
- Object.keys(newCred).forEach(k => (typeof newCred[k] === 'string' && SECRETS.test(k)) ? (newCred[k] = '...') : (newCred[k] = _redacted(newCred[k])))
335
+ Object.keys(newCred).forEach(k => {
336
+ if (typeof newCred[k] === 'string' && SECRETS.test(k)) {
337
+ newCred[k] = '...'
338
+ } else if (k === 'uri' && typeof newCred[k] === 'string') {
339
+ // redact secrets in URIs, e.g., s3://access_key_id:secret_access_key@...
340
+ newCred[k] = newCred[k].replace(/\/\/(.*?):(.*?)@/g, '//...:...@')
341
+ } else {
342
+ newCred[k] = _redacted(newCred[k])
343
+ }
344
+ })
336
345
  return newCred
337
346
  }
338
347
  return cred
339
348
  }
340
349
 
341
350
 
351
+ /**
352
+ * A variant of child_process.exec that returns a promise,
353
+ * which resolves with the command's stdout split into lines.
354
+ * @example
355
+ * await cds.utils.sh `npm ls -lp --depth=0`
356
+ * @param {string|TemplateStringsArray} cmd - the command to execute, may be a tagged template
357
+ * @returns {string[]} array of stdout lines
358
+ */
359
+ exports.sh = function shell (cmd,..._) {
360
+ if (cmd.raw) cmd = String.raw (cmd,..._) // the cmd may be a tagged template
361
+ let debug = shell.debug ??= cds.debug('shell'); debug?.(cmd)
362
+ let exec = shell.exec ??= require('node:child_process').exec
363
+ return new Promise ((resolve, reject) => exec (cmd, (e, stdout, stderr) => {
364
+ if (e) reject (Object.assign (e, { stdout, stderr }))
365
+ else resolve (stdout?.trim().split('\n'))
366
+ }))
367
+ }
368
+
342
369
  /**
343
370
  * Converts a time span with a unit into milliseconds. @example
344
371
  * ms4(5,'s') //> 5000
@@ -3,7 +3,7 @@ module.exports = Object.assign (_colors4 (process.stdout), {
3
3
  })
4
4
 
5
5
  function _colors4 (stdout_or_stderr = process.stdout) {
6
- const enabled = stdout_or_stderr.isTTY && !process.env.NO_COLOR || process.env.FORCE_COLOR
6
+ const enabled = (process.env.GITHUB_ACTIONS || stdout_or_stderr.isTTY) && !process.env.NO_COLOR || process.env.FORCE_COLOR
7
7
  const color = enabled ? ttl => ttl[0] : ()=>''
8
8
  return {
9
9
  enabled,
@@ -127,13 +127,7 @@ module.exports = cds.service.impl(async function () {
127
127
  if (failedColumns.length === 0) continue
128
128
 
129
129
  const failedAsserts = failedColumns.map(([element, message]) => {
130
- const error = {
131
- status: 400,
132
- code: 'ASSERT',
133
- target: element,
134
- numericSeverity: 4,
135
- '@Common.numericSeverity': 4
136
- }
130
+ const error = { status: 400, code: 'ASSERT', target: element }
137
131
 
138
132
  // if error function was used in @assert expression -> use its output
139
133
  try {
@@ -1,7 +1,5 @@
1
1
  const cds = require('../../cds')
2
2
 
3
- const { getFrom } = require('../../../../lib/compile/for/flows')
4
-
5
3
  const FLOW_STATUS = '@flow.status'
6
4
  const FROM = '@from'
7
5
  const TO = '@to'
@@ -9,6 +7,11 @@ const FLOW_PREVIOUS = '$flow.previous'
9
7
 
10
8
  const $transitions_ = Symbol.for('transitions_')
11
9
 
10
+ const getFrom = action => {
11
+ let from = action[FROM]
12
+ return Array.isArray(from) ? from : [from]
13
+ }
14
+
12
15
  function isCurrentStatusInFrom(result, action, statusElementName, statusEnum) {
13
16
  const fromList = getFrom(action)
14
17
  const allowed = fromList.filter(from => {
@@ -162,10 +165,17 @@ module.exports = cds.service.impl(function () {
162
165
  entity[$transitions_] = base.compositions.transitions_
163
166
  // track changes on db level
164
167
  cds.connect.to('db').then(db => {
168
+ db.before('UPSERT', entity, async req => {
169
+ const status = req.data[statusInfo.statusElementName]
170
+ if (status === undefined) req._upsert_exists = await SELECT.one.from(req.subject).columns([1])
171
+ })
165
172
  db.after(['CREATE', 'UPDATE', 'UPSERT'], entity, async (res, req) => {
166
173
  if ((res.affectedRows ?? res) !== 1) return
167
- if (!(statusInfo.statusElementName in req.data)) return
168
- const status = req.data[statusInfo.statusElementName]
174
+ let status = req.data[statusInfo.statusElementName]
175
+ if (status === undefined && (req.event === 'CREATE' || (req.event === 'UPSERT' && !req._upsert_exists))) {
176
+ status = entity.elements[statusInfo.statusElementName]?.default?.val
177
+ }
178
+ if (!status) return
169
179
  const upKeys = await buildUpKeys(entity, req.data, req.subject)
170
180
  const last = await SELECT.one.from(entity[$transitions_].target).orderBy('timestamp desc').where(upKeys)
171
181
  if (last?.status !== status) await UPSERT.into(entity[$transitions_].target).entries({ ...upKeys, status })
@@ -639,6 +639,10 @@ const _entityTransitionsForTarget = (from, model, service, options) => {
639
639
  )
640
640
  }
641
641
 
642
+ if (typeof from === 'object' && from.SELECT) {
643
+ return _entityTransitionsForTarget(from.SELECT.from, model, service, options)
644
+ }
645
+
642
646
  return from.ref.map((f, i) => {
643
647
  const element = f.id || f
644
648
  if (element === options.alias) return
@@ -463,12 +463,13 @@ const compileUpdatedDraftMessages = (newMessages, persistedMessages, requestData
463
463
  if (!message.target) return acc //> silently ignore messages without target
464
464
 
465
465
  message.numericSeverity ??= message['@Common.numericSeverity'] ?? 4
466
+ delete message['@Common.numericSeverity']
466
467
  if (message['@Common.additionalTargets']) message.additionalTargets ??= message['@Common.additionalTargets']
467
- message.additionalTargets = message.additionalTargets?.map(t => (t.startsWith('in/') ? t.slice(3) : t))
468
+ message.additionalTargets = message.additionalTargets?.map(t => t.replace(/^in\//, ''))
468
469
  delete message['@Common.additionalTargets']
469
470
 
470
471
  // Remove prefix 'in/' in favor of fully qualified path to the target
471
- const messageTarget = message.target.startsWith('in/') ? message.target.slice(3) : message.target
472
+ const messageTarget = message.target.replace(/^in\//, '')
472
473
  if (message.code) delete message.code // > Expect _only_ message to be set and contain the code
473
474
 
474
475
  // Process the message target produced by validation
@@ -966,8 +967,12 @@ const draftHandle = async function (req) {
966
967
 
967
968
  if (IS_PERSISTED_DRAFT_MESSAGES_ENABLED) {
968
969
  _req.on('failed', async error => {
970
+ if (!error && !_req.errors?.length) {
971
+ LOG.debug('Draft activation failed without error information: Skip update of DraftMessages.')
972
+ return
973
+ }
969
974
  const errors = []
970
- if (_req.errors) {
975
+ if (_req.errors?.length) {
971
976
  // REVISIT: e._message hack for draft validation messages
972
977
  // Errors procesed during 'failed' will have undergone error._normalize at this point
973
978
  // > We need to revert the code - message swap _normalize includes
@@ -4,6 +4,10 @@ const cds = require('../../cds')
4
4
  const LOG = cds.log('http-messaging') // not public
5
5
 
6
6
  const authorizedRequest = ({ method, uri, path, oa2, tenant, dataObj, headers, tokenStore }) => {
7
+ if (tenant) {
8
+ if (!tokenStore[tenant]) tokenStore[tenant] = {}
9
+ tokenStore = tokenStore[tenant]
10
+ }
7
11
  return new Promise((resolve, reject) => {
8
12
  if (LOG._debug) LOG.debug({ method, uri, path, oa2, tenant, dataObj, headers, tokenStore })
9
13
  ;((tokenStore.token && Promise.resolve(tokenStore.token)) || requestToken(oa2, tenant, tokenStore))
@@ -46,7 +46,7 @@ class EMManagement {
46
46
  uri: this.options.uri,
47
47
  path: `/hub/rest/api/v1/management/messaging/queues/${encodeURIComponent(queueName)}`,
48
48
  oa2: this.options.oa2,
49
- tokenStore: this
49
+ tokenStore: (this._tokenStore ??= {})
50
50
  })
51
51
  return res.body
52
52
  } catch (e) {
@@ -67,7 +67,7 @@ class EMManagement {
67
67
  uri: this.options.uri,
68
68
  path: `/hub/rest/api/v1/management/messaging/queues`,
69
69
  oa2: this.options.oa2,
70
- tokenStore: this
70
+ tokenStore: (this._tokenStore ??= {})
71
71
  })
72
72
  return res.body
73
73
  } catch (e) {
@@ -96,7 +96,7 @@ class EMManagement {
96
96
  path: `/hub/rest/api/v1/management/messaging/queues/${encodeURIComponent(queueName)}`,
97
97
  oa2: this.options.oa2,
98
98
  dataObj: queueConfig,
99
- tokenStore: this
99
+ tokenStore: (this._tokenStore ??= {})
100
100
  })
101
101
  if (res.statusCode === 201) return true
102
102
  } catch (e) {
@@ -121,7 +121,7 @@ class EMManagement {
121
121
  uri: this.options.uri,
122
122
  path: `/hub/rest/api/v1/management/messaging/queues/${encodeURIComponent(queueName)}`,
123
123
  oa2: this.options.oa2,
124
- tokenStore: this
124
+ tokenStore: (this._tokenStore ??= {})
125
125
  })
126
126
  } catch (e) {
127
127
  const error = new Error(`Queue "${queueName}" could not be deleted ${this.subdomainInfo}`)
@@ -146,7 +146,7 @@ class EMManagement {
146
146
  path: `/hub/rest/api/v1/management/messaging/queues/${encodeURIComponent(queueName)}/subscriptions`,
147
147
  oa2: this.options.oa2,
148
148
  target: { kind: 'SUBSCRIPTION', queue: queueName },
149
- tokenStore: this
149
+ tokenStore: (this._tokenStore ??= {})
150
150
  })
151
151
  return res.body
152
152
  } catch (e) {
@@ -175,7 +175,7 @@ class EMManagement {
175
175
  queueName
176
176
  )}/subscriptions/${encodeURIComponent(topicPattern)}`,
177
177
  oa2: this.options.oa2,
178
- tokenStore: this
178
+ tokenStore: (this._tokenStore ??= {})
179
179
  })
180
180
  if (res.statusCode === 201) return true
181
181
  } catch (e) {
@@ -206,7 +206,7 @@ class EMManagement {
206
206
  queueName
207
207
  )}/subscriptions/${encodeURIComponent(topicPattern)}`,
208
208
  oa2: this.options.oa2,
209
- tokenStore: this
209
+ tokenStore: (this._tokenStore ??= {})
210
210
  })
211
211
  } catch (e) {
212
212
  const error = new Error(
@@ -235,7 +235,7 @@ class EMManagement {
235
235
  uri: this.optionsMessagingREST.uri,
236
236
  path: `/messagingrest/v1/subscriptions/${encodeURIComponent(webhookName)}`,
237
237
  oa2: this.optionsMessagingREST.oa2,
238
- tokenStore: this
238
+ tokenStore: (this._tokenStore ??= {})
239
239
  })
240
240
  return res.body
241
241
  } catch (e) {
@@ -263,7 +263,7 @@ class EMManagement {
263
263
  uri: this.optionsMessagingREST.uri,
264
264
  path: `/messagingrest/v1/subscriptions/${encodeURIComponent(webhookName)}`,
265
265
  oa2: this.optionsMessagingREST.oa2,
266
- tokenStore: this
266
+ tokenStore: (this._tokenStore ??= {})
267
267
  })
268
268
  } catch (e) {
269
269
  const error = new Error(`Webhook "${webhookName}" could not be deleted ${this.subdomainInfo}`)
@@ -331,7 +331,7 @@ class EMManagement {
331
331
  path: '/messagingrest/v1/subscriptions',
332
332
  oa2: this.optionsMessagingREST.oa2,
333
333
  dataObj,
334
- tokenStore: this
334
+ tokenStore: (this._tokenStore ??= {})
335
335
  })
336
336
  if (res.statusCode === 201) return true
337
337
  } catch (e) {
@@ -360,7 +360,7 @@ class EMManagement {
360
360
  uri: this.optionsMessagingREST.uri,
361
361
  path: `/messagingrest/v1/subscriptions/${encodeURIComponent(webhookName)}`,
362
362
  oa2: this.optionsMessagingREST.oa2,
363
- tokenStore: this
363
+ tokenStore: (this._tokenStore ??= {})
364
364
  })
365
365
  } catch (e) {
366
366
  const error = new Error(`Webhook "${webhookName}" could not be deleted ${this.subdomainInfo}`)
@@ -420,7 +420,7 @@ class EMManagement {
420
420
  uri: this.options.uri,
421
421
  path: `/hub/rest/api/v1/management/messaging/readinessCheck`,
422
422
  oa2: this.options.oa2,
423
- tokenStore: this
423
+ tokenStore: (this._tokenStore ??= {})
424
424
  })
425
425
  } catch (e) {
426
426
  const error = new Error(`Readiness Check failed ${this.subdomainInfo}`)
@@ -182,7 +182,7 @@ class EnterpriseMessaging extends AMQPWebhookMessaging {
182
182
  'Content-Type': contentType,
183
183
  'x-qos': 1
184
184
  },
185
- tokenStore: {}
185
+ tokenStore: (this._tokenStore ??= {})
186
186
  }
187
187
  if (tenant) params.tenant = tenant
188
188
  await authorizedRequest(params)
@@ -7,8 +7,8 @@ const _errorObj = result => {
7
7
  return errorObj
8
8
  }
9
9
 
10
- const requestToken = ({ client, secret, endpoint, mTLS }, tenant, tokenStore) =>
11
- new Promise((resolve, reject) => {
10
+ const requestToken = ({ client, secret, endpoint, mTLS }, tenant, tokenStore) => {
11
+ const promise = new Promise((resolve, reject) => {
12
12
  const options = {
13
13
  host: endpoint.replace('/oauth/token', '').replace('https://', ''),
14
14
  headers: {}
@@ -44,7 +44,18 @@ const requestToken = ({ client, secret, endpoint, mTLS }, tenant, tokenStore) =>
44
44
  reject(_errorObj(result))
45
45
  return
46
46
  }
47
- // store token on tokenStore
47
+ // set timeout to delete token from cache 5 minutes (300 seconds) before it expires
48
+ const expiresIn = json.expires_in || 3600
49
+ const timeout = Math.max(0, (expiresIn - 300) * 1000)
50
+ if (tokenStore.expirationTimeout) clearTimeout(tokenStore.expirationTimeout)
51
+ tokenStore.expires_in = json.expires_in
52
+ tokenStore.expirationTimeout = setTimeout(() => {
53
+ delete tokenStore.token
54
+ delete tokenStore.expires_in
55
+ delete tokenStore.expirationTimeout
56
+ }, timeout)
57
+
58
+ // store token value on tokenStore
48
59
  tokenStore.token = json.access_token
49
60
  resolve(json.access_token)
50
61
  } catch {
@@ -56,4 +67,8 @@ const requestToken = ({ client, secret, endpoint, mTLS }, tenant, tokenStore) =>
56
67
  req.end()
57
68
  })
58
69
 
70
+ tokenStore.token = promise
71
+ return promise
72
+ }
73
+
59
74
  module.exports = requestToken
@@ -23,7 +23,7 @@ class MQManagement {
23
23
  path: `/v1/management/queues/${encodeURIComponent(queueName)}`,
24
24
  oa2: this.options.auth.oauth2,
25
25
  target: { kind: 'QUEUE', queue: queueName },
26
- tokenStore: this
26
+ tokenStore: (this._tokenStore ??= {})
27
27
  })
28
28
  return res.body
29
29
  } catch (e) {
@@ -45,7 +45,7 @@ class MQManagement {
45
45
  path: `/v1/management/queues`,
46
46
  oa2: this.options.auth.oauth2,
47
47
  target: { kind: 'QUEUE' },
48
- tokenStore: this
48
+ tokenStore: (this._tokenStore ??= {})
49
49
  })
50
50
  return res.body && res.body.results
51
51
  } catch (e) {
@@ -70,7 +70,7 @@ class MQManagement {
70
70
  path: `/v1/management/queues/${encodeURIComponent(queueName)}`,
71
71
  oa2: this.options.auth.oauth2,
72
72
  dataObj: queueConfig,
73
- tokenStore: this
73
+ tokenStore: (this._tokenStore ??= {})
74
74
  })
75
75
  if (res.statusCode === 201) return true
76
76
  } catch (e) {
@@ -91,7 +91,7 @@ class MQManagement {
91
91
  uri: this.options.url,
92
92
  path: `/v1/management/queues/${encodeURIComponent(queueName)}`,
93
93
  oa2: this.options.auth.oauth2,
94
- tokenStore: this
94
+ tokenStore: (this._tokenStore ??= {})
95
95
  })
96
96
  } catch (e) {
97
97
  const error = new Error(`Queue "${queueName}" could not be deleted`)
@@ -111,7 +111,7 @@ class MQManagement {
111
111
  uri: this.options.url,
112
112
  path: `/v1/management/queues/${encodeURIComponent(queueName)}/subscriptions/topics`,
113
113
  oa2: this.options.auth.oauth2,
114
- tokenStore: this
114
+ tokenStore: (this._tokenStore ??= {})
115
115
  })
116
116
  return res.body
117
117
  } catch (e) {
@@ -134,7 +134,7 @@ class MQManagement {
134
134
  topicPattern
135
135
  )}`,
136
136
  oa2: this.options.auth.oauth2,
137
- tokenStore: this
137
+ tokenStore: (this._tokenStore ??= {})
138
138
  })
139
139
  if (res.statusCode === 201) return true
140
140
  } catch (e) {
@@ -159,7 +159,7 @@ class MQManagement {
159
159
  oa2: this.options.auth.oauth2,
160
160
 
161
161
  target: { kind: 'SUBSCRIPTION', queue: queueName, topic: topicPattern },
162
- tokenStore: this
162
+ tokenStore: (this._tokenStore ??= {})
163
163
  })
164
164
  } catch (e) {
165
165
  const error = new Error(`Subscription "${topicPattern}" could not be deleted from queue "${queueName}"`)
@@ -260,8 +260,10 @@ class RemoteService extends cds.Service {
260
260
  returnType: req._returnType
261
261
  }
262
262
 
263
+ // REVISIT: use xssec's getIdToken() for full list of audiences?
264
+ let jwt = cds.context?.user?.authInfo?.token?.jwt
263
265
  // REVISIT: i don't believe req.context.headers is an official API
264
- let jwt = req?.context?.headers?.authorization?.split(/^bearer /i)[1]
266
+ if (!jwt) jwt = req?.context?.headers?.authorization?.split(/^bearer /i)[1]
265
267
  if (!jwt) jwt = req?.context?.http?.req?.headers?.authorization?.split(/^bearer /i)[1]
266
268
  if (jwt) additionalOptions.jwt = jwt
267
269
 
@@ -250,6 +250,7 @@ module.exports.run = async (requestConfig, options) => {
250
250
  }
251
251
 
252
252
  const { kind, resolvedTarget, returnType } = options
253
+ if (kind === 'hcql') return response.data.data
253
254
  if (kind === 'odata-v4') return _purgeODataV4(response.data)
254
255
  if (kind === 'odata-v2') return _purgeODataV2(response.data, resolvedTarget, returnType)
255
256
  if (kind === 'odata') {
@@ -103,7 +103,6 @@ const KINDS_SUPPORTING_BATCH = { odata: true, 'odata-v2': true, 'odata-v4': true
103
103
 
104
104
  const _extractRequestConfig = (req, query, service) => {
105
105
  if (service.kind === 'hcql' && typeof query === 'object') return _cqnToHcqlRequestConfig(query)
106
- if (service.kind === 'hcql') throw new Error('The request has no query and cannot be served by HCQL remote services!')
107
106
  if (typeof query === 'object') return _cqnToRequestConfig(query, service, req)
108
107
  if (typeof query === 'string') return _stringToRequestConfig(query, req.data, req.target)
109
108
  //> no model, no service.definition