@cspeach/cli 1.1.21 → 1.1.23

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 (54) hide show
  1. package/dist/agent/tool-dispatch.js +4 -2
  2. package/dist/cli.js +13 -0
  3. package/dist/commands/plan-audit-evidence.js +61 -4
  4. package/dist/commands/plan-audit.js +13 -1
  5. package/dist/commands/sap-link.js +52 -0
  6. package/dist/lib/shutdown-hooks.js +87 -0
  7. package/dist/one-shot.js +4 -0
  8. package/dist/projects/plan-run.js +5 -1
  9. package/dist/projects/plan-schema.js +2 -1
  10. package/dist/renderer/tool-labels.js +5 -1
  11. package/dist/repl/credential-handover-gate.js +12 -6
  12. package/dist/repl.js +4 -0
  13. package/dist/sap/connection-manager.js +30 -24
  14. package/dist/sap/standalone-profile.js +1 -1
  15. package/dist/sap/system-info.js +50 -27
  16. package/dist/sap-link/policy.js +185 -0
  17. package/dist/sap-link/project.js +163 -0
  18. package/dist/sap-link/registry.js +259 -0
  19. package/dist/sap-link/server.js +351 -0
  20. package/dist/sap-link/upstream.js +367 -0
  21. package/dist/sap-link/write-log.js +60 -0
  22. package/dist/skills/bundled-skills.js +118 -118
  23. package/dist/tools/cap/_fetch-errors.js +20 -0
  24. package/dist/tools/cap/link-smoke.js +703 -0
  25. package/dist/tools/cap/metadata-import.js +325 -0
  26. package/dist/tools/cap/sap-link-tool.js +133 -0
  27. package/dist/tools/cap/service-catalog.js +248 -0
  28. package/dist/tools/fiori/preview/registry.js +30 -24
  29. package/dist/tools/local-build.js +11 -0
  30. package/dist/tools/subagent/_background-shared.js +59 -37
  31. package/dist/tools/subagent/background_run.js +9 -5
  32. package/dist/upgrade.js +4 -2
  33. package/package.json +1 -1
  34. package/dist/commands/project.js +0 -301
  35. package/dist/tools/local-files.js +0 -31
  36. package/dist/tools/subagent/schedule_create.js +0 -131
  37. package/dist/ui/error-emitter.js +0 -21
  38. package/dist/ui/header.js +0 -41
  39. package/dist/ui/ink-classifier-route.js +0 -19
  40. package/dist/ui/rich-error-box.js +0 -9
  41. package/dist/ui/sidebar.js +0 -12
  42. package/dist/ui/status-emitter.js +0 -19
  43. package/dist/ui/status-line.js +0 -62
  44. package/dist/ui/status-row.js +0 -38
  45. package/dist/ui/todo-panel.js +0 -85
  46. package/dist/ui/widgets/ask-form.js +0 -250
  47. package/dist/ui/widgets/ask-question-modal.js +0 -178
  48. package/fiori/zlas-inspection-lr/package.json +0 -26
  49. package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
  50. package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
  51. package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
  52. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
  53. package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
  54. package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
@@ -0,0 +1,259 @@
1
+ // cspeach-cli/src/sap-link/registry.ts
2
+ /**
3
+ * CAP phase 2 — the SAP link registry (Task 8). One in-process link per CAP
4
+ * project. The link and its UpstreamSession live side by side, so stopping a
5
+ * link also drops the pooled authenticated sockets, the cookie jar and the
6
+ * CSRF token (UpstreamSession.close()).
7
+ *
8
+ * Security rules held here:
9
+ * - Never prompt for a password: the keychain lookup runs headless; a miss is
10
+ * a fast plain error.
11
+ * - TLS posture comes only from resolveSapTls(sys). A CA path is read and
12
+ * trusted; an alias the user already marked insecure is reported loudly.
13
+ * No new insecure path is introduced.
14
+ * - Every error thrown from here is a SapLinkError (or the project's
15
+ * LinkConfigError) with a fixed plain message. Raw keychain / fs / TLS text
16
+ * never passes through.
17
+ * - All links are closed when the process exits (best effort, installed once).
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { readFileSync } from 'node:fs';
21
+ import * as os from 'node:os';
22
+ import * as path from 'node:path';
23
+ import { loadConfig as realLoadConfig, resolveSapTls } from '../config/loader.js';
24
+ import { resolveSapPassword } from '../sap/connection-manager.js';
25
+ import { readLinkTargets } from './project.js';
26
+ import { startSapLink, SapLinkStartError } from './server.js';
27
+ import { UpstreamSession } from './upstream.js';
28
+ import { registerShutdownHook } from '../lib/shutdown-hooks.js';
29
+ /** A registry error. Its message is always a fixed, plain, secret-free sentence. */
30
+ export class SapLinkError extends Error {
31
+ }
32
+ const links = new Map();
33
+ /** Registry key: the resolved path, lower-cased on Windows (case-insensitive file system). */
34
+ function key(dir) {
35
+ const abs = path.resolve(dir);
36
+ return process.platform === 'win32' ? abs.toLowerCase() : abs;
37
+ }
38
+ export function writeLogFileFor(projectDir) {
39
+ const id = createHash('sha1').update(key(projectDir)).digest('hex').slice(0, 12);
40
+ return path.join(os.homedir(), '.cspeach', 'sap-link', id, 'write-log.jsonl');
41
+ }
42
+ export function findSapLink(projectDir) {
43
+ return links.get(key(projectDir))?.handle;
44
+ }
45
+ export function listSapLinks() {
46
+ return [...links.values()].map((e) => ({ projectDir: e.projectDir, url: e.handle.url, alias: e.alias }));
47
+ }
48
+ /** Same wording as server.ts's port-taken error, so the developer sees one message. */
49
+ function portTakenMessage(port) {
50
+ return `Port ${port} is in use. Stop the program using it, or change the port in package.json ([s4] credentials.url) and start the SAP link again.`;
51
+ }
52
+ export function roleNoteFor(alias, role) {
53
+ return role === 'dev' ? undefined : `Writes are blocked: ${alias} has no role dev (reads only).`;
54
+ }
55
+ function httpNoteFor(alias, sys) {
56
+ return sys.useSsl
57
+ ? undefined
58
+ : `HTTPS is off for ${alias}. Your SAP login crosses the network unencrypted. Turn on HTTPS (useSsl) for this system.`;
59
+ }
60
+ /** Everything that shapes the upstream connection. A change means a restart, never a reuse. */
61
+ function fingerprintFor(sys) {
62
+ const tls = resolveSapTls(sys);
63
+ return JSON.stringify([sys.host, sys.port, sys.client, sys.username, sys.authType, sys.useSsl === true, tls.mode, tls.caCertPath ?? '']);
64
+ }
65
+ function postureNotesFor(alias, sys) {
66
+ const tlsNote = tlsNoteFor(alias, sys);
67
+ const httpNote = httpNoteFor(alias, sys);
68
+ return { ...(tlsNote ? { tlsNote } : {}), ...(httpNote ? { httpNote } : {}) };
69
+ }
70
+ function tlsNoteFor(alias, sys) {
71
+ return resolveSapTls(sys).mode === 'insecure'
72
+ ? `TLS verification is OFF for ${alias} (insecure in config). Set sap.${alias}.ca_cert_path instead.`
73
+ : undefined;
74
+ }
75
+ function baseUrlFor(sys) {
76
+ const host = sys.host.includes(':') && !sys.host.startsWith('[') ? `[${sys.host}]` : sys.host;
77
+ return `${sys.useSsl ? 'https' : 'http'}://${host}:${sys.port}`;
78
+ }
79
+ // One registry operation at a time: two starts for one project must not race.
80
+ let chain = Promise.resolve();
81
+ function serial(fn) {
82
+ const run = chain.then(fn, fn);
83
+ chain = run.catch(() => undefined);
84
+ return run;
85
+ }
86
+ async function closeEntry(e) {
87
+ try {
88
+ e.upstream.close();
89
+ }
90
+ catch { /* best effort */ }
91
+ try {
92
+ await e.handle.close();
93
+ }
94
+ catch { /* best effort */ }
95
+ }
96
+ async function stopUnlocked(projectDir) {
97
+ const k = key(projectDir);
98
+ const e = links.get(k);
99
+ if (!e)
100
+ return false;
101
+ links.delete(k);
102
+ await closeEntry(e);
103
+ return true;
104
+ }
105
+ const sameList = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
106
+ export function ensureSapLink(projectDir, alias, partial = {}) {
107
+ return serial(() => ensureUnlocked(projectDir, alias, partial));
108
+ }
109
+ async function ensureUnlocked(projectDir, alias, partial) {
110
+ const deps = {
111
+ loadConfig: realLoadConfig,
112
+ // Never prompt (spec §6.3, same rule as the Fiori preview): a missing keychain entry is a fast error.
113
+ password: (a, s) => resolveSapPassword(a, s, { headless: () => true }),
114
+ readCa: (p) => readFileSync(p),
115
+ start: startSapLink,
116
+ ...partial,
117
+ };
118
+ // package.json first: its errors are plain LinkConfigErrors and need no SAP settings.
119
+ const targets = await readLinkTargets(projectDir);
120
+ const services = targets.services.map((s) => s.name);
121
+ const prefixes = targets.services.map((s) => s.prefix);
122
+ let cfg;
123
+ try {
124
+ cfg = await deps.loadConfig();
125
+ }
126
+ catch {
127
+ throw new SapLinkError('Could not read the SAP system settings.');
128
+ }
129
+ const k = key(projectDir);
130
+ const sys = cfg.sap?.[alias];
131
+ if (!sys) {
132
+ // The alias is gone from config: a link still running on it goes too.
133
+ if (links.get(k)?.alias === alias)
134
+ await stopUnlocked(projectDir);
135
+ throw new SapLinkError(`No SAP system named ${alias}. Add it with: cspeach config add-sap`);
136
+ }
137
+ const roleNote = roleNoteFor(alias, sys.role);
138
+ const fingerprint = fingerprintFor(sys);
139
+ const existing = links.get(k);
140
+ if (existing && existing.alias === alias && existing.fingerprint === fingerprint &&
141
+ existing.requestedPort === targets.port && sameList(existing.prefixes, prefixes) &&
142
+ // A link whose upstream got a 401 refuses every send: start means restart (new session, new password).
143
+ !existing.upstream.isLoginRejected()) {
144
+ // TLS/http notes describe the running link; the role is read fresh (the server does too).
145
+ return { handle: existing.handle, reused: true, services, ...existing.postureNotes, ...(roleNote ? { roleNote } : {}) };
146
+ }
147
+ // Another alias, connection, auth type, TLS posture, port or service list, or a rejected login: the old link goes first.
148
+ if (existing)
149
+ await stopUnlocked(projectDir);
150
+ if (targets.port !== 0) {
151
+ for (const [otherKey, other] of links) {
152
+ if (otherKey !== k && other.handle.listenPort === targets.port)
153
+ throw new SapLinkError(portTakenMessage(targets.port));
154
+ }
155
+ }
156
+ if (sys.authType !== 'basic')
157
+ throw new SapLinkError(`The SAP link needs password login. ${alias} uses a certificate.`);
158
+ let password;
159
+ try {
160
+ password = await deps.password(alias, sys);
161
+ }
162
+ catch {
163
+ throw new SapLinkError(`No stored password for ${alias}. Store it with: cspeach config add-sap`);
164
+ }
165
+ if (!password)
166
+ throw new SapLinkError(`No stored password for ${alias}. Store it with: cspeach config add-sap`);
167
+ const tls = resolveSapTls(sys);
168
+ let ca;
169
+ if (tls.caCertPath) {
170
+ try {
171
+ ca = deps.readCa(tls.caCertPath);
172
+ }
173
+ catch {
174
+ throw new SapLinkError(`Could not read the CA certificate for ${alias}. Check ca_cert_path in your config.`);
175
+ }
176
+ }
177
+ let upstream;
178
+ try {
179
+ upstream = new UpstreamSession({
180
+ baseUrl: baseUrlFor(sys),
181
+ client: sys.client,
182
+ username: sys.username,
183
+ password,
184
+ ...(ca ? { ca } : {}),
185
+ rejectUnauthorized: !tls.insecureSkipTlsVerify,
186
+ });
187
+ }
188
+ catch {
189
+ throw new SapLinkError(`The SAP settings for ${alias} are not valid. Check host and port.`);
190
+ }
191
+ let handle;
192
+ try {
193
+ handle = await deps.start({
194
+ alias, host: sys.host, port: sys.port,
195
+ listenPort: targets.port,
196
+ allowedPrefixes: prefixes,
197
+ // Fresh per request (Review Focus 3): a role change in config applies at once.
198
+ getRole: async () => (await deps.loadConfig()).sap?.[alias]?.role,
199
+ upstream,
200
+ secret: { password, username: sys.username },
201
+ writeLogFile: writeLogFileFor(projectDir),
202
+ });
203
+ }
204
+ catch (e) {
205
+ try {
206
+ upstream.close();
207
+ }
208
+ catch { /* best effort */ }
209
+ if (e instanceof SapLinkStartError && e.code === 'port-taken')
210
+ throw new SapLinkError(portTakenMessage(targets.port));
211
+ throw new SapLinkError('The SAP link could not start.');
212
+ }
213
+ const postureNotes = postureNotesFor(alias, sys);
214
+ links.set(k, { projectDir: path.resolve(projectDir), alias, prefixes, requestedPort: targets.port, fingerprint, postureNotes, handle, upstream });
215
+ installSapLinkExitHandler();
216
+ return { handle, reused: false, services, ...postureNotes, ...(roleNote ? { roleNote } : {}) };
217
+ }
218
+ export function stopSapLink(projectDir) {
219
+ return serial(() => stopUnlocked(projectDir));
220
+ }
221
+ export function __resetSapLinksForTests() {
222
+ return serial(async () => {
223
+ for (const k of [...links.keys()]) {
224
+ const e = links.get(k);
225
+ links.delete(k);
226
+ await closeEntry(e);
227
+ }
228
+ });
229
+ }
230
+ // ── Process exit ────────────────────────────────────────────────────────────
231
+ let exitHandlerInstalled = false;
232
+ /** Synchronous best effort: drop upstream sessions and stop listening. */
233
+ function closeAllSapLinksSync() {
234
+ for (const e of links.values()) {
235
+ try {
236
+ e.upstream.close();
237
+ }
238
+ catch { /* best effort */ }
239
+ try {
240
+ void e.handle.close().catch(() => undefined);
241
+ }
242
+ catch { /* best effort */ }
243
+ }
244
+ links.clear();
245
+ }
246
+ /** Close every SAP link on CLI exit or a terminating signal. Idempotent. */
247
+ export function installSapLinkExitHandler() {
248
+ if (exitHandlerInstalled)
249
+ return;
250
+ exitHandlerInstalled = true;
251
+ // The shared handler (lib/shutdown-hooks.ts) runs this on exit and on
252
+ // SIGINT/SIGTERM/SIGHUP, alongside the preview registry's cleanup, and
253
+ // re-raises the signal when nothing else is waiting for it.
254
+ registerShutdownHook(closeAllSapLinksSync);
255
+ }
256
+ /** Test-only — observe whether installSapLinkExitHandler has fired. */
257
+ export function __isSapLinkExitHandlerInstalledForTests() {
258
+ return exitHandlerInstalled;
259
+ }
@@ -0,0 +1,351 @@
1
+ // cspeach-cli/src/sap-link/server.ts
2
+ /**
3
+ * CAP phase 2 — the SAP link (spec §6.3): a loopback forwarder INSIDE the CSPeach
4
+ * process. The generated app's [s4] profile points at it; it adds the keychain
5
+ * login (UpstreamSession), refuses anything outside the imported services, and
6
+ * passes writes only while the alias role is exactly 'dev' — read fresh per
7
+ * request, so a config change takes effect at once.
8
+ *
9
+ * Hardening choices (Task 7, ruling R1 + review carry-overs):
10
+ * - Browser / DNS-rebinding defence, checked BEFORE the role, the policy and the body:
11
+ * exactly one Host header, equal to 127.0.0.1:<listenPort> or localhost:<listenPort>
12
+ * (case-insensitive); an Origin header (if any) must be http://127.0.0.1[:port] or
13
+ * http://localhost[:port] (`null` refused); Sec-Fetch-Site (if any) must be
14
+ * same-origin or none. CAP's server-side calls send none of these browser headers.
15
+ * - Request body capped at 10 MiB: a larger content-length is refused up front, a
16
+ * streamed body is dropped the moment it passes the cap (413, connection closed).
17
+ * - Only the policy's output is forwarded (method, normalised path, service prefix,
18
+ * write flag), never req.method / req.url. GET and HEAD never carry a body upstream.
19
+ * - The role is read fresh on every request. If it cannot be read: 503, nothing sent.
20
+ * - Errors are mapped to fixed plain messages. No error text, stack, header or body
21
+ * from upstream ever reaches the app. Upstream 401 → the login message; after it the
22
+ * upstream refuses every send (ELOGINREJECTED) until the link restarts, same message.
23
+ * - Write log: time, method, path without query, status. A failed log append does not
24
+ * break the response (the write already happened upstream).
25
+ * - Server timeouts are set; close() drops all connections and is idempotent.
26
+ * - Nothing here logs requests, bodies, headers or the password.
27
+ *
28
+ * Fix round 1 (security review):
29
+ * - R16: a write whose upstream call throws is still logged, with status 0
30
+ * ("outcome unknown"): SAP may already have applied it.
31
+ * - For a write the role is read AGAIN after the body arrives; anything but exactly
32
+ * 'dev' is a 403, so a role flip during a slow upload still stops the write.
33
+ * - getRole() is raced against a timeout (5 s default, `roleTimeoutMs`); on timeout 503.
34
+ * - Every answer carries `X-Content-Type-Options: nosniff`; pass-through answers also
35
+ * carry `Content-Security-Policy: sandbox; default-src 'none'` (upstream values for
36
+ * both are replaced), so SAP HTML can never run as a page on this origin.
37
+ * - Only upstream statuses 200-599 are passed through; anything else is the generic 502.
38
+ * - plain() resets statusMessage and any half-set headers, so a fallback 502 after a
39
+ * failed pass-through never says "OK".
40
+ */
41
+ import * as http from 'node:http';
42
+ import { decideRequest, writesBlockedMessage } from './policy.js';
43
+ import { appendWriteRow } from './write-log.js';
44
+ export class SapLinkStartError extends Error {
45
+ code;
46
+ constructor(code, message) {
47
+ super(message);
48
+ this.code = code;
49
+ }
50
+ }
51
+ const REACH_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH', 'ECONNRESET', 'EAI_AGAIN']);
52
+ export const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
53
+ const DISCARD_BUDGET_BYTES = 4 * 1024 * 1024;
54
+ const DISCARD_GRACE_MS = 2_000;
55
+ const HEADERS_TIMEOUT_MS = 30_000;
56
+ const REQUEST_TIMEOUT_MS = 120_000;
57
+ const DEFAULT_ROLE_TIMEOUT_MS = 5_000;
58
+ const LOOPBACK_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost)(:\d{1,5})?$/i;
59
+ /** Framing headers we set ourselves; never copied from the upstream answer. */
60
+ const FRAMING_OUT = new Set(['content-length', 'transfer-encoding', 'connection', 'keep-alive']);
61
+ /** Security headers we set ourselves on pass-through answers; upstream values are replaced. */
62
+ const OWN_OUT = new Set(['x-content-type-options', 'content-security-policy']);
63
+ const SETTINGS_UNREADABLE = 'Could not read the SAP system settings.';
64
+ class RoleUnavailable extends Error {
65
+ }
66
+ /** getRole() with a deadline. Any throw or timeout becomes RoleUnavailable. */
67
+ async function readRole(getRole, timeoutMs) {
68
+ let timer;
69
+ try {
70
+ return await Promise.race([
71
+ Promise.resolve().then(getRole),
72
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new RoleUnavailable()), timeoutMs); }),
73
+ ]);
74
+ }
75
+ catch {
76
+ throw new RoleUnavailable();
77
+ }
78
+ finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+ export function reachMessage(alias, host, port) {
83
+ return `Can't reach ${alias} (${host}:${port}) from this machine. Check network access to the SAP system.`;
84
+ }
85
+ class BodyTooLarge extends Error {
86
+ }
87
+ class BodyAborted extends Error {
88
+ }
89
+ function plain(res, status, text, close = false) {
90
+ if (res.headersSent) {
91
+ res.destroy();
92
+ return;
93
+ }
94
+ // A failed pass-through writeHead may have left a status text ("OK") or headers behind.
95
+ for (const name of res.getHeaderNames())
96
+ res.removeHeader(name);
97
+ res.statusMessage = http.STATUS_CODES[status] ?? 'Error';
98
+ const headers = {
99
+ 'content-type': 'text/plain; charset=utf-8',
100
+ 'content-length': Buffer.byteLength(text),
101
+ 'x-content-type-options': 'nosniff',
102
+ };
103
+ if (close)
104
+ headers.connection = 'close';
105
+ res.writeHead(status, res.statusMessage, headers);
106
+ res.end(text);
107
+ }
108
+ /** All values of one header from rawHeaders (case-insensitive), duplicates kept. */
109
+ function rawValues(req, name) {
110
+ const out = [];
111
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) {
112
+ if (req.rawHeaders[i].toLowerCase() === name)
113
+ out.push(req.rawHeaders[i + 1]);
114
+ }
115
+ return out;
116
+ }
117
+ /** R1: null when the request may go on, else the refusal. */
118
+ function browserGuard(req, listenPort) {
119
+ const hosts = rawValues(req, 'host');
120
+ const want = [`127.0.0.1:${listenPort}`, `localhost:${listenPort}`];
121
+ if (hosts.length !== 1 || !want.includes(hosts[0].trim().toLowerCase())) {
122
+ return { status: 403, message: `The SAP link only answers requests addressed to 127.0.0.1:${listenPort}.` };
123
+ }
124
+ const origins = rawValues(req, 'origin');
125
+ if (origins.length > 1 || (origins.length === 1 && !LOOPBACK_ORIGIN.test(origins[0].trim()))) {
126
+ return { status: 403, message: 'The SAP link does not answer browser requests from other sites.' };
127
+ }
128
+ const sites = rawValues(req, 'sec-fetch-site');
129
+ if (sites.length > 1 || (sites.length === 1 && !['same-origin', 'none'].includes(sites[0].trim().toLowerCase()))) {
130
+ return { status: 403, message: 'The SAP link does not answer browser requests from other sites.' };
131
+ }
132
+ return null;
133
+ }
134
+ /** Reads the body up to `limit` bytes. Past the limit it stops reading and rejects. */
135
+ function readBody(req, limit) {
136
+ return new Promise((resolve, reject) => {
137
+ const chunks = [];
138
+ let total = 0;
139
+ let done = false;
140
+ const finish = (err) => {
141
+ if (done)
142
+ return;
143
+ done = true;
144
+ req.off('data', onData);
145
+ req.off('end', onEnd);
146
+ req.off('error', onError);
147
+ req.off('close', onClose);
148
+ if (err) {
149
+ chunks.length = 0;
150
+ reject(err);
151
+ }
152
+ else
153
+ resolve(Buffer.concat(chunks));
154
+ };
155
+ const onData = (c) => {
156
+ total += c.length;
157
+ if (total > limit)
158
+ return finish(new BodyTooLarge());
159
+ chunks.push(c);
160
+ };
161
+ const onEnd = () => finish();
162
+ const onError = () => finish(new BodyAborted());
163
+ const onClose = () => { if (!req.complete)
164
+ finish(new BodyAborted()); };
165
+ req.on('data', onData);
166
+ req.on('end', onEnd);
167
+ req.on('error', onError);
168
+ req.on('close', onClose);
169
+ });
170
+ }
171
+ /**
172
+ * 413 and stop. Nothing more is buffered. So the client can still read the 413, up to
173
+ * DISCARD_BUDGET_BYTES more are thrown away for at most DISCARD_GRACE_MS; past either
174
+ * limit the socket is dropped (the client may then see a reset instead of the 413).
175
+ */
176
+ function tooLarge(req, res) {
177
+ plain(res, 413, `The request body is larger than the SAP link accepts (${MAX_REQUEST_BODY_BYTES / (1024 * 1024)} MiB).`);
178
+ const socket = req.socket;
179
+ let discarded = 0;
180
+ const drop = () => { clearTimeout(timer); req.off('data', onData); socket.destroy(); };
181
+ const onData = (c) => { discarded += c.length; if (discarded > DISCARD_BUDGET_BYTES)
182
+ drop(); };
183
+ const timer = setTimeout(drop, DISCARD_GRACE_MS);
184
+ timer.unref();
185
+ socket.once('close', () => clearTimeout(timer));
186
+ req.on('data', onData);
187
+ req.resume();
188
+ }
189
+ export async function startSapLink(opts) {
190
+ let listenPort = opts.listenPort;
191
+ const roleTimeoutMs = opts.roleTimeoutMs ?? DEFAULT_ROLE_TIMEOUT_MS;
192
+ const handle = async (req, res) => {
193
+ // 1. R1 — browser / DNS-rebinding defence, before anything else.
194
+ const refused = browserGuard(req, listenPort);
195
+ if (refused)
196
+ return plain(res, refused.status, refused.message, true);
197
+ const declared = req.headers['content-length'];
198
+ if (declared !== undefined && !(Number(declared) <= MAX_REQUEST_BODY_BYTES))
199
+ return tooLarge(req, res);
200
+ // 2. Role, fresh on every request. Fail closed if it cannot be read.
201
+ let role;
202
+ try {
203
+ role = await readRole(opts.getRole, roleTimeoutMs);
204
+ }
205
+ catch {
206
+ return plain(res, 503, SETTINGS_UNREADABLE);
207
+ }
208
+ // 3. Policy. Only its output is forwarded from here on.
209
+ const d = decideRequest({
210
+ method: req.method ?? 'GET', rawUrl: req.url ?? '/',
211
+ headers: req.headers,
212
+ allowedPrefixes: opts.allowedPrefixes, alias: opts.alias, role,
213
+ });
214
+ if (!d.allow)
215
+ return plain(res, d.status, d.message);
216
+ // 4. Body (capped). GET and HEAD never carry one upstream.
217
+ let body;
218
+ try {
219
+ body = await readBody(req, MAX_REQUEST_BODY_BYTES);
220
+ }
221
+ catch (e) {
222
+ if (e instanceof BodyTooLarge)
223
+ return tooLarge(req, res);
224
+ res.destroy();
225
+ return;
226
+ }
227
+ // A write: read the role again now the body is in. A flip during the upload wins.
228
+ if (d.write) {
229
+ let again;
230
+ try {
231
+ again = await readRole(opts.getRole, roleTimeoutMs);
232
+ }
233
+ catch {
234
+ return plain(res, 503, SETTINGS_UNREADABLE);
235
+ }
236
+ if (again !== 'dev')
237
+ return plain(res, 403, writesBlockedMessage(opts.alias, again));
238
+ }
239
+ const noBody = d.method === 'GET' || d.method === 'HEAD';
240
+ if (noBody)
241
+ body = Buffer.alloc(0);
242
+ const logWrite = (status) => {
243
+ try {
244
+ appendWriteRow(opts.writeLogFile, { at: new Date().toISOString(), method: d.method, path: d.upstreamPath.split('?')[0], status }, opts.secret);
245
+ }
246
+ catch {
247
+ /* intentionally silent: no logging of anything request-related */
248
+ }
249
+ };
250
+ const flat = {};
251
+ for (const [k, v] of Object.entries(req.headers))
252
+ if (v !== undefined)
253
+ flat[k] = Array.isArray(v) ? v.join(', ') : v;
254
+ // 5. Upstream. Any throw maps to a fixed plain message. The first 401 and the
255
+ // upstream's latched refusal (ELOGINREJECTED, nothing sent) share one message.
256
+ const loginRejected = () => `${opts.alias} rejected the stored login for ${opts.secret.username}. Update the stored SAP password for this alias, then start the SAP link again (sap_link start).`;
257
+ let up;
258
+ try {
259
+ up = await opts.upstream.send({ method: d.method, path: d.upstreamPath, servicePrefix: d.servicePrefix, headers: flat, body, write: d.write });
260
+ }
261
+ catch (e) {
262
+ // R16: the write may have reached SAP. Log it as status 0 = outcome unknown.
263
+ if (d.write)
264
+ logWrite(0);
265
+ const code = e && typeof e === 'object' && typeof e.code === 'string' ? e.code : '';
266
+ if (REACH_CODES.has(code))
267
+ return plain(res, 502, reachMessage(opts.alias, opts.host, opts.port));
268
+ if (code === 'ELOGINREJECTED')
269
+ return plain(res, 502, loginRejected());
270
+ if (code === 'ERESPONSETOOLARGE')
271
+ return plain(res, 502, `The response from ${opts.alias} was too large.`);
272
+ return plain(res, 502, `The SAP link could not complete the request to ${opts.alias}.`);
273
+ }
274
+ // 6. Write log. The write already happened: a failed append must not hide the answer.
275
+ if (d.write)
276
+ logWrite(up.status);
277
+ // 7. Answer.
278
+ if (!Number.isInteger(up.status) || up.status < 200 || up.status > 599) {
279
+ return plain(res, 502, `The SAP link could not complete the request to ${opts.alias}.`);
280
+ }
281
+ if (up.status === 401)
282
+ return plain(res, 502, loginRejected());
283
+ const headers = {};
284
+ for (const [k, v] of Object.entries(up.headers ?? {})) {
285
+ const lower = k.toLowerCase();
286
+ if (!FRAMING_OUT.has(lower) && !OWN_OUT.has(lower))
287
+ headers[k] = v;
288
+ }
289
+ headers['x-content-type-options'] = 'nosniff';
290
+ headers['content-security-policy'] = "sandbox; default-src 'none'";
291
+ const isHead = d.method === 'HEAD' || req.method === 'HEAD';
292
+ const bodyless = isHead || up.status === 204 || up.status === 304;
293
+ if (!bodyless)
294
+ headers['content-length'] = up.body.length;
295
+ try {
296
+ res.writeHead(up.status, headers);
297
+ }
298
+ catch {
299
+ // Invalid status or header value from upstream: answer plainly instead.
300
+ return plain(res, 502, `The SAP link could not complete the request to ${opts.alias}.`);
301
+ }
302
+ if (bodyless)
303
+ res.end();
304
+ else
305
+ res.end(up.body);
306
+ };
307
+ const server = http.createServer({ requireHostHeader: false }, (req, res) => {
308
+ handle(req, res).catch(() => {
309
+ try {
310
+ plain(res, 502, `The SAP link could not complete the request to ${opts.alias}.`);
311
+ }
312
+ catch {
313
+ res.destroy();
314
+ }
315
+ });
316
+ });
317
+ server.headersTimeout = HEADERS_TIMEOUT_MS;
318
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
319
+ await new Promise((resolve, reject) => {
320
+ const onError = (e) => {
321
+ reject(e.code === 'EADDRINUSE'
322
+ ? new SapLinkStartError('port-taken', `Port ${opts.listenPort} is in use. Stop the program using it, or change the port in package.json ([s4] credentials.url) and start the SAP link again.`)
323
+ : new SapLinkStartError('other', `The SAP link could not start: ${e.message}`));
324
+ };
325
+ server.once('error', onError);
326
+ try {
327
+ server.listen(opts.listenPort, '127.0.0.1', () => { server.off('error', onError); resolve(); });
328
+ }
329
+ catch (e) {
330
+ server.off('error', onError);
331
+ onError(e);
332
+ }
333
+ });
334
+ // After start, a server error must never crash CSPeach. Nothing is logged.
335
+ server.on('error', () => { });
336
+ listenPort = server.address().port;
337
+ let closing;
338
+ return {
339
+ url: `http://127.0.0.1:${listenPort}`,
340
+ listenPort,
341
+ alias: opts.alias,
342
+ writeLogFile: opts.writeLogFile,
343
+ close: () => {
344
+ closing ??= new Promise((r) => {
345
+ server.close(() => r());
346
+ server.closeAllConnections();
347
+ });
348
+ return closing;
349
+ },
350
+ };
351
+ }