@cap-js/agents 0.0.0 → 0.9.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 (56) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +155 -0
  3. package/_i18n/messages.properties +31 -0
  4. package/cds-plugin.js +140 -0
  5. package/index.cds +105 -0
  6. package/index.js +0 -0
  7. package/lib/agents/markdown/backends/mime-utils.js +37 -0
  8. package/lib/agents/markdown/backends/outputs-backend.js +152 -0
  9. package/lib/agents/markdown/backends/uploads-backend.js +143 -0
  10. package/lib/agents/markdown/deep-agent.js +93 -0
  11. package/lib/agents/middleware/agent-actions.js +18 -0
  12. package/lib/agents/middleware/content-filter.js +191 -0
  13. package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
  14. package/lib/agents/middleware/hitl.js +20 -0
  15. package/lib/agents/middleware/index.js +19 -0
  16. package/lib/agents/middleware/patch-tool-calls.js +51 -0
  17. package/lib/agents/middleware/quota-enforcer.js +94 -0
  18. package/lib/agents/middleware/status-update.js +153 -0
  19. package/lib/agents/middleware/tool-selection.js +22 -0
  20. package/lib/agents/quota-enforcer-at-start.js +198 -0
  21. package/lib/agents/summarize-on-timeout.js +91 -0
  22. package/lib/compile.js +55 -0
  23. package/lib/index.cjs +1 -0
  24. package/lib/index.js +422 -0
  25. package/lib/models/aicore.js +441 -0
  26. package/lib/models/anthropic.js +77 -0
  27. package/lib/models/mock.js +88 -0
  28. package/lib/preview/chat.html +875 -0
  29. package/lib/preview/preview.js +46 -0
  30. package/lib/protocol/agent-card.js +297 -0
  31. package/lib/protocol/persistence/checkpoint-saver.js +317 -0
  32. package/lib/protocol/persistence/file-store.js +209 -0
  33. package/lib/protocol/persistence/push-notification-store.js +59 -0
  34. package/lib/protocol/persistence/task-store.js +47 -0
  35. package/lib/protocol/push-notification-sender.js +57 -0
  36. package/lib/sidecar.js +162 -0
  37. package/lib/telemetry/active-users.js +106 -0
  38. package/lib/telemetry/chat-tracing.js +342 -0
  39. package/lib/telemetry/metrics.js +85 -0
  40. package/lib/telemetry/mlflow.js +290 -0
  41. package/lib/telemetry/tool-tracing.js +164 -0
  42. package/lib/telemetry/tracing.js +150 -0
  43. package/lib/utils/inner-auth.js +33 -0
  44. package/lib/utils/markdown.js +199 -0
  45. package/lib/utils/message-handling.js +155 -0
  46. package/lib/utils/utils.js +168 -0
  47. package/package.json +225 -2
  48. package/srv/graph-cache.js +82 -0
  49. package/srv/handlers/graph-executor.js +1369 -0
  50. package/srv/handlers/index.js +173 -0
  51. package/srv/handlers/mcp-tools.js +159 -0
  52. package/srv/handlers/sub-agent-tools.js +314 -0
  53. package/srv/handlers/system-prompt.js +25 -0
  54. package/srv/handlers/tools.js +366 -0
  55. package/srv/langgraph-executor-srv.js +70 -0
  56. package/srv/push-notification-srv.js +149 -0
@@ -0,0 +1,366 @@
1
+ import cds from "@sap/cds"
2
+ import { DynamicStructuredTool, tool } from "@langchain/core/tools"
3
+ import z from "zod"
4
+ import {
5
+ createGenericReadToolDefinition,
6
+ createDescribeToolDefinition,
7
+ createCallActionToolDefinition,
8
+ createPerActionToolDefinition,
9
+ executeGenericReadTool,
10
+ executeDescribe,
11
+ executeCallActionTool,
12
+ executePerActionTool,
13
+ } from "@cap-js/mcp/lib/tools.js"
14
+ import { getFilteredEntities, getFilteredActions } from "../../lib/utils/utils.js"
15
+ import { isTextMime } from "../../lib/agents/markdown/backends/mime-utils.js"
16
+ import { checkAuthorization } from "@cap-js/mcp/lib/auth.js"
17
+
18
+ const LOG = cds.log("agents")
19
+
20
+ const unwrap = (result) => {
21
+ const text = result.content?.[0]?.text ?? ""
22
+ return [text, { isError: result.isError === true }]
23
+ }
24
+
25
+ // we need the checkAuthorization result many times in the same cds context
26
+ function cachedAuth(srv) {
27
+ if (!cds.context) return checkAuthorization(srv)
28
+ const cache = (cds.context.__agentAuth ??= {})
29
+ return (cache[srv.name] ??= checkAuthorization(srv))
30
+ }
31
+
32
+ class GenericReadTool extends DynamicStructuredTool {
33
+ constructor(srv, entities) {
34
+ const def = createGenericReadToolDefinition(Object.keys(entities), srv.name, "")
35
+ super({
36
+ name: def.name,
37
+ description: def.description,
38
+ schema: def.inputSchema,
39
+ responseFormat: "content_and_artifact",
40
+ func: async (args) => {
41
+ return unwrap(await executeGenericReadTool(srv, entities, args, { log: LOG }))
42
+ },
43
+ })
44
+ this.srv = srv
45
+ this._allEntities = entities
46
+ delete this.description
47
+ delete this.schema
48
+ }
49
+
50
+ get description() {
51
+ const { entities, error } = cachedAuth(this.srv)
52
+ if (error) return "Not available due to " + error?.reason
53
+ return createGenericReadToolDefinition(Object.keys(entities), this.srv.name, "").description
54
+ }
55
+
56
+ get schema() {
57
+ const { entities, error } = cachedAuth(this.srv)
58
+ if (error) return z.undefined
59
+ return createGenericReadToolDefinition(Object.keys(entities), this.srv.name, "").inputSchema
60
+ }
61
+
62
+ isAllowed() {
63
+ const { entities, error } = cachedAuth(this.srv)
64
+ return !error && Object.keys(entities).length > 0
65
+ }
66
+ }
67
+
68
+ class DescribeTool extends DynamicStructuredTool {
69
+ constructor(srv, entities, actions) {
70
+ const def = createDescribeToolDefinition(
71
+ Object.keys(entities),
72
+ Object.keys(actions),
73
+ srv.name,
74
+ "",
75
+ )
76
+ super({
77
+ name: def.name,
78
+ description: def.description,
79
+ schema: def.inputSchema,
80
+ responseFormat: "content_and_artifact",
81
+ func: async (args) => {
82
+ return unwrap(await executeDescribe(srv, entities, actions, args, { log: LOG }))
83
+ },
84
+ })
85
+ this.srv = srv
86
+ this._allEntities = entities
87
+ this._allActions = actions
88
+ delete this.description
89
+ delete this.schema
90
+ }
91
+
92
+ get description() {
93
+ const { entities, actions, error } = cachedAuth(this.srv)
94
+ if (error) return "Not available due to " + error?.reason
95
+ return createDescribeToolDefinition(
96
+ Object.keys(entities),
97
+ Object.keys(actions),
98
+ this.srv.name,
99
+ "",
100
+ ).description
101
+ }
102
+
103
+ get schema() {
104
+ const { entities, actions, error } = cachedAuth(this.srv)
105
+ if (error) return z.undefined
106
+ return createDescribeToolDefinition(
107
+ Object.keys(entities),
108
+ Object.keys(actions),
109
+ this.srv.name,
110
+ "",
111
+ ).inputSchema
112
+ }
113
+
114
+ isAllowed() {
115
+ const { entities, actions, error } = cachedAuth(this.srv)
116
+ return !error && (Object.keys(entities).length > 0 || Object.keys(actions).length > 0)
117
+ }
118
+ }
119
+
120
+ class PerActionTool extends DynamicStructuredTool {
121
+ constructor(srv, actionName, action) {
122
+ const def = createPerActionToolDefinition(actionName, action, srv.name, srv.model, "")
123
+ super({
124
+ name: def.name,
125
+ description: def.description,
126
+ schema: def.inputSchema,
127
+ responseFormat: "content_and_artifact",
128
+ func: async (args) => {
129
+ return unwrap(await executePerActionTool(srv, actionName, action, args, { log: LOG }))
130
+ },
131
+ })
132
+ this.srv = srv
133
+ this.actionName = actionName
134
+ this.action = action
135
+ }
136
+
137
+ isAllowed() {
138
+ const { actions, error } = cachedAuth(this.srv)
139
+ return !error && Object.prototype.hasOwnProperty.call(actions, this.actionName)
140
+ }
141
+ }
142
+
143
+ class CallActionTool extends DynamicStructuredTool {
144
+ constructor(srv, actions) {
145
+ const def = createCallActionToolDefinition(Object.keys(actions), srv.name, "")
146
+ super({
147
+ name: def.name,
148
+ description: def.description,
149
+ schema: def.inputSchema,
150
+ responseFormat: "content_and_artifact",
151
+ func: async (args) => {
152
+ return unwrap(await executeCallActionTool(srv, actions, args, { log: LOG }))
153
+ },
154
+ })
155
+ this.srv = srv
156
+ this._allActions = actions
157
+ delete this.description
158
+ delete this.schema
159
+ }
160
+
161
+ get description() {
162
+ const { actions, error } = cachedAuth(this.srv)
163
+ if (error) return "Not available due to " + error?.reason
164
+ return createCallActionToolDefinition(Object.keys(actions), this.srv.name, "").description
165
+ }
166
+
167
+ get schema() {
168
+ const { actions, error } = cachedAuth(this.srv)
169
+ if (error) return z.undefined
170
+ return createCallActionToolDefinition(Object.keys(actions), this.srv.name, "").inputSchema
171
+ }
172
+
173
+ isAllowed() {
174
+ const { actions, error } = cachedAuth(this.srv)
175
+ return !error && Object.keys(actions).length > 0
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Reuses tool definitions and execution logic from @cap-js/mcp.
181
+ *
182
+ * @param {object} srv - CDS ApplicationService
183
+ */
184
+ export function generateTools(srv) {
185
+ const entities = getFilteredEntities(srv)
186
+ const actions = getFilteredActions(srv)
187
+
188
+ const tools = []
189
+
190
+ // Query tool — one tool for reading all entities
191
+ const entityNames = Object.keys(entities)
192
+ if (entityNames.length > 0) {
193
+ tools.push(new GenericReadTool(srv, entities))
194
+ }
195
+
196
+ // Describe tool — introspect service model
197
+ const actionNames = Object.keys(actions)
198
+ if (entityNames.length > 0 || actionNames.length > 0) {
199
+ tools.push(new DescribeTool(srv, entities, actions))
200
+ }
201
+
202
+ // Action/function tools — per-action (default) or combined call action
203
+ const usePerActionTools = cds.env.agents?.per_action_tool !== false
204
+ if (actionNames.length > 0) {
205
+ if (usePerActionTools) {
206
+ for (const [name, action] of Object.entries(actions)) {
207
+ tools.push(new PerActionTool(srv, name, action))
208
+ }
209
+ } else {
210
+ tools.push(new CallActionTool(srv, actions))
211
+ }
212
+ }
213
+
214
+ // File tools — only when fileIO is enabled
215
+ // emit_file_part: stateless protocol emitter; safe to tools.push once at startup.
216
+ // read_file: per-request (needs contextId) — created on-demand via createReadFileTool().
217
+ if (cds.env.agents?.fileIO?.enabled) {
218
+ tools.push(createEmitFilePartTool())
219
+ }
220
+
221
+ return tools
222
+ }
223
+
224
+ // ── File tools ────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * Render a byte count as a human-readable string ("123 B" / "12.3 KB" / "1.2 MB").
228
+ * Exported so callers (e.g. GraphExecutor's file manifest) format sizes identically.
229
+ */
230
+ export function formatFileSize(bytes) {
231
+ if (bytes < 1024) return `${bytes} B`
232
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
233
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
234
+ }
235
+
236
+ /**
237
+ * Sanitize a user-supplied filename so it is safe to use as a stable key,
238
+ * a /uploads/<name> path component, and an artifactId fragment.
239
+ *
240
+ * - Strips any directory components (path.basename) — defeats `../../etc/passwd`
241
+ * tricks that would otherwise produce confusing artifactIds and DB keys.
242
+ * - Replaces characters that are not letters, digits, dot, dash, underscore, or
243
+ * space with `_`. Leaves Unicode letters (CJK/etc.) intact.
244
+ * - Collapses leading dots so the name cannot start with `.` (avoids hidden-file
245
+ * surprises and `..` artifacts).
246
+ * - Falls back to "unnamed" for empty input.
247
+ */
248
+ export function sanitizeFilename(raw) {
249
+ const name = String(raw ?? "").trim()
250
+ if (!name) return "unnamed"
251
+ // path.basename equivalent without pulling in node:path: drop everything up to
252
+ // the last forward- or back-slash. Also drop any pure ".."/"." segments.
253
+ const base = name.replace(/^.*[\\/]/, "")
254
+ const cleaned = base.replace(/[^\p{L}\p{N}._\-\s]/gu, "_").replace(/^\.+/, "")
255
+ return cleaned || "unnamed"
256
+ }
257
+
258
+ /**
259
+ * Create a tool that emits a file as part of the A2A response.
260
+ * Pure protocol emitter — caller provides the bytes, no generation, no placeholders.
261
+ * The executor's toolResults collection loop parses `kind:'file'` JSON from this tool.
262
+ */
263
+ export function createEmitFilePartTool() {
264
+ return tool(
265
+ async ({ filename, mimeType, content, encoding = "utf-8" }) => {
266
+ const bytes =
267
+ encoding === "base64" ? content : Buffer.from(content, "utf-8").toString("base64")
268
+ const decodedSize =
269
+ encoding === "base64"
270
+ ? Buffer.byteLength(content, "base64")
271
+ : Buffer.byteLength(content, "utf-8")
272
+ LOG.info("emit_file_part", { filename, mimeType, encoding, bytes: decodedSize })
273
+ return JSON.stringify({ kind: "file", file: { name: filename, mimeType, bytes } })
274
+ },
275
+ {
276
+ name: "emit_file_part",
277
+ description:
278
+ 'Emit a file as part of the A2A response. For text formats (CSV, JSON, plain text) provide raw text content. For binary formats provide base64-encoded content and set encoding to "base64". You are responsible for obtaining or generating the file content before calling this tool.',
279
+ schema: z.object({
280
+ filename: z.string().describe("Filename with extension, e.g. report.csv"),
281
+ mimeType: z.string().describe("MIME type, e.g. text/csv, application/json, image/png"),
282
+ content: z
283
+ .string()
284
+ .describe("File content: raw text for text formats, base64 string for binary formats"),
285
+ encoding: z.enum(["utf-8", "base64"]).optional().default("utf-8"),
286
+ }),
287
+ },
288
+ )
289
+ }
290
+
291
+ /**
292
+ * Create a read_file tool scoped to the current conversation.
293
+ * For the default LangGraph path only — deepagents tools.pushs its own read_file
294
+ * tool via FilesystemMiddleware, which routes through UploadsBackend.
295
+ *
296
+ * When contextId is omitted, the tool resolves it from cds.context at invocation
297
+ * time (set by GraphExecutor before graph.invoke). This allows a single tool
298
+ * instance to be reused across requests in createAgent-based graphs.
299
+ *
300
+ * @param {import('../../lib/protocol/persistence/file-store.js').CdsFileStore} fileStore
301
+ * @param {string} [contextId] - If omitted, read from cds.context["agent.context.id"]
302
+ * @param {string} [userId] - If omitted, read from cds.context.user.id
303
+ */
304
+ export function createReadFileTool(fileStore, contextId, userId) {
305
+ return tool(
306
+ async ({ path: filePath }) => {
307
+ try {
308
+ const resolvedContextId = contextId || cds.context?.["agent.context.id"]
309
+ const resolvedUserId = userId || cds.context?.user?.id
310
+ const name = filePath.replace(/^\/uploads\//, "")
311
+ if (!fileStore) {
312
+ return `read_file is not available in this context (no file store configured).`
313
+ }
314
+ const file = await fileStore.getInputFile(resolvedContextId, name, resolvedUserId)
315
+ if (!file) {
316
+ const available =
317
+ (await fileStore.listInputFiles(resolvedContextId, resolvedUserId))
318
+ .map((f) => `/uploads/${f.name}`)
319
+ .join(", ") || "none"
320
+ LOG.info("read_file (not found)", {
321
+ contextId: resolvedContextId,
322
+ path: filePath,
323
+ available,
324
+ })
325
+ return `File not found: ${filePath}. Available files: ${available}`
326
+ }
327
+ if (file.mimeType?.startsWith("image/")) {
328
+ LOG.info("read_file (image, not returned as text)", {
329
+ contextId: resolvedContextId,
330
+ path: filePath,
331
+ mimeType: file.mimeType,
332
+ size: file.size,
333
+ })
334
+ return `"${name}" is an image (${formatFileSize(file.size)}). Use an image analysis tool to process it.`
335
+ }
336
+ if (!isTextMime(file.mimeType)) {
337
+ LOG.info("read_file (binary, not returned as text)", {
338
+ contextId: resolvedContextId,
339
+ path: filePath,
340
+ mimeType: file.mimeType,
341
+ size: file.size,
342
+ })
343
+ return `"${name}" is a binary file (${file.mimeType}, ${formatFileSize(file.size)}). Cannot be read as text.`
344
+ }
345
+ LOG.info("read_file", {
346
+ contextId: resolvedContextId,
347
+ path: filePath,
348
+ mimeType: file.mimeType,
349
+ size: file.size,
350
+ })
351
+ return file.bytes.toString("utf-8")
352
+ } catch (err) {
353
+ LOG.error("read_file failed", { filePath, error: err.message })
354
+ return `Error reading file: ${err.message}`
355
+ }
356
+ },
357
+ {
358
+ name: "read_file",
359
+ description:
360
+ "Read the contents of an uploaded file. Use the /uploads/<filename> path from the file manifest. Returns file content for text-based formats.",
361
+ schema: z.object({
362
+ path: z.string().describe("File path, e.g. /uploads/report.csv"),
363
+ }),
364
+ },
365
+ )
366
+ }
@@ -0,0 +1,70 @@
1
+ import cds from "@sap/cds"
2
+ import { GraphExecutor } from "./handlers/graph-executor.js"
3
+ import { GraphCache, hashFeatures } from "./graph-cache.js"
4
+
5
+ /**
6
+ * LangGraph-based executor service for @agent annotated services.
7
+ *
8
+ * Dispatches `buildGraph` event on the target ApplicationService.
9
+ * Graphs cached per feature vector (cds.context.features) in a FIFO cache.
10
+ * Lazy init: graph built on first request (features not available at startup).
11
+ */
12
+ export class LangGraphExecutor {
13
+ static _instance
14
+ _caches = new Map()
15
+
16
+ static for(srv) {
17
+ this._instance ??= new LangGraphExecutor()
18
+ return this._instance.for(srv)
19
+ }
20
+
21
+ for(srv) {
22
+ return {
23
+ execute: (requestContext, eventBus) => this._execute(srv, requestContext, eventBus),
24
+ cancelTask: async (taskId, eventBus) => {
25
+ const executor = await this._ensureExecutor(srv)
26
+ return executor.cancelTask(taskId, eventBus)
27
+ },
28
+ abort: async (taskId) => {
29
+ const executor = await this._ensureExecutor(srv)
30
+ return executor.abort(taskId)
31
+ },
32
+ }
33
+ }
34
+
35
+ _getCache(srv) {
36
+ if (!this._caches.has(srv.name)) {
37
+ this._caches.set(srv.name, new GraphCache())
38
+ }
39
+ return this._caches.get(srv.name)
40
+ }
41
+
42
+ async _ensureExecutor(srv) {
43
+ const features = cds.context?.features || {}
44
+ const key = hashFeatures(features)
45
+ const cache = this._getCache(srv)
46
+
47
+ return cache.getOrBuild(key, () => this._buildExecutor(srv))
48
+ }
49
+
50
+ async _buildExecutor(srv) {
51
+ const result = await srv.send("buildGraph", {})
52
+
53
+ if (result && typeof result.invoke === "function") {
54
+ return new GraphExecutor(result, srv)
55
+ }
56
+ if (result && typeof result.execute === "function") {
57
+ return result
58
+ }
59
+
60
+ throw new Error(
61
+ `buildGraph handler for service "${srv.name}" must return a compiled LangGraph (with invoke()) ` +
62
+ `or a GraphExecutor (with execute()). Got: ${typeof result}`,
63
+ )
64
+ }
65
+
66
+ async _execute(srv, requestContext, eventBus) {
67
+ const executor = await this._ensureExecutor(srv)
68
+ return executor.execute(requestContext, eventBus)
69
+ }
70
+ }
@@ -0,0 +1,149 @@
1
+ import cds from "@sap/cds"
2
+ import { isAllowedDomain } from "../lib/utils/utils.js"
3
+
4
+ const LOG = cds.log("agents")
5
+
6
+ // Hop limit for callback redirects. Real webhooks rarely need more than a
7
+ // single hop (protocol upgrade or CDN edge routing); 3 leaves headroom
8
+ // without opening a redirect loop as an amplifier.
9
+ const MAX_REDIRECTS = 3
10
+
11
+ /**
12
+ * CDS service that handles push notification delivery.
13
+ * Registered as `agent-push-notifications` and wrapped in CAP outbox
14
+ * by CdsPushNotificationSender for at-least-once delivery with retry.
15
+ */
16
+ export default class AgentPushNotificationService extends cds.Service {
17
+ async init() {
18
+ this.on("pushNotification", async (msg) => {
19
+ const { task, url } = msg.data
20
+ await pushNotification(url, task)
21
+ })
22
+ return super.init()
23
+ }
24
+ }
25
+
26
+ async function pushNotification(url, task) {
27
+ const headers = { "Content-Type": "application/json" }
28
+
29
+ const iasResource = cds.env.agents?.pushNotifications?.ias?.resource
30
+ if (iasResource) {
31
+ const bearerToken = await fetchIasToken(iasResource)
32
+ if (bearerToken) {
33
+ headers["Authorization"] = `Bearer ${bearerToken}`
34
+ }
35
+ }
36
+
37
+ const allowedDomains = cds.env.agents?.pushNotifications?.allowedDomains
38
+ const res = await fetchWithRedirects(
39
+ url,
40
+ {
41
+ method: "POST",
42
+ headers,
43
+ body: JSON.stringify(task),
44
+ signal: AbortSignal.timeout(10_000),
45
+ },
46
+ allowedDomains,
47
+ )
48
+
49
+ if (!res.ok) {
50
+ // Throw so outbox retries on transient failures (5xx, network)
51
+ const body = await res.text().catch(() => "")
52
+ const msg = `Push notification failed: HTTP ${res.status} ${res.statusText}`
53
+ if (res.status >= 500 || res.status === 429) {
54
+ throw cds.error({ message: msg, response: body }) // retryable
55
+ }
56
+ // 4xx = permanent failure, log and don't retry
57
+ LOG.error(msg, { taskId: task.id, url, response: body })
58
+ } else {
59
+ LOG.debug("Push notification delivered", { taskId: task.id, url, status: res.status })
60
+ }
61
+ }
62
+
63
+ /**
64
+ * POST to `url` with manual redirect handling. Every hop (including the
65
+ * initial URL) is re-validated against `allowedDomains` so a compromised or
66
+ * open-redirect endpoint on an allow-listed domain cannot exfiltrate the
67
+ * request body + Authorization header to an off-allowlist host.
68
+ *
69
+ * Policy:
70
+ * - Non-3xx responses are returned as-is.
71
+ * - 307 / 308 preserve method + body and follow if the new URL is allowlisted.
72
+ * - 301 / 302 / 303 would change POST semantics (spec allows GET downgrade)
73
+ * so they are refused - legitimate push webhooks never rely on that.
74
+ * - The redirect chain is capped at MAX_REDIRECTS. Absolute and relative
75
+ * Location values are both resolved against the previous URL.
76
+ */
77
+ async function fetchWithRedirects(url, options, allowedDomains, maxRedirects = MAX_REDIRECTS) {
78
+ let currentUrl = url
79
+ for (let hop = 0; hop <= maxRedirects; hop++) {
80
+ if (!isAllowedDomain(currentUrl, allowedDomains)) {
81
+ throw new Error(
82
+ `Push callback URL not on allowlist: ${currentUrl}` +
83
+ (hop > 0 ? ` (reached via ${hop} redirect${hop === 1 ? "" : "s"})` : ""),
84
+ )
85
+ }
86
+
87
+ // Never let fetch follow silently - we validate each hop explicitly.
88
+ // Sequential await is required
89
+ // eslint-disable-next-line no-await-in-loop
90
+ const res = await fetch(currentUrl, { ...options, redirect: "manual" })
91
+
92
+ if (res.status < 300 || res.status >= 400) {
93
+ return res
94
+ }
95
+
96
+ // 3xx: only 307 / 308 preserve method + body. 301 / 302 / 303 semantically
97
+ // downgrade POST to GET - a push callback that relies on that is misconfigured.
98
+ if (res.status !== 307 && res.status !== 308) {
99
+ throw new Error(
100
+ `Push callback redirect ${res.status} would change method or drop body; refusing`,
101
+ )
102
+ }
103
+
104
+ if (hop === maxRedirects) {
105
+ throw new Error(`Push callback exceeded ${maxRedirects} redirects`)
106
+ }
107
+
108
+ const location = res.headers.get("location")
109
+ if (!location) {
110
+ // 3xx without Location - nothing to follow, return as-is
111
+ return res
112
+ }
113
+
114
+ // Resolve relative Locations against the previous URL.
115
+ currentUrl = new URL(location, currentUrl).toString()
116
+ }
117
+ }
118
+
119
+ async function fetchIasToken(resource) {
120
+ let getIasToken
121
+ try {
122
+ ;({ getIasToken } = await import("@sap-cloud-sdk/connectivity"))
123
+ } catch {
124
+ LOG.debug("@sap-cloud-sdk/connectivity not available, skipping IAS token")
125
+ return null
126
+ }
127
+
128
+ const tenant = cds.context?.tenant
129
+ const options = {
130
+ resource: { name: resource },
131
+ ...(tenant && { appTid: tenant }),
132
+ }
133
+
134
+ try {
135
+ const result = await getIasToken(options)
136
+ return result?.access_token || result?.token || result
137
+ } catch (err) {
138
+ // No identity binding → expected in dev/test
139
+ if (
140
+ err.message?.includes("identity") ||
141
+ err.message?.includes("service binding") ||
142
+ err.message?.includes("not found")
143
+ ) {
144
+ LOG.debug("No identity service binding, push without auth", { resource })
145
+ return null
146
+ }
147
+ throw err
148
+ }
149
+ }