logwork-cli 1.1.1 → 1.2.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 (64) hide show
  1. package/README.md +23 -7
  2. package/RELEASE.md +1 -0
  3. package/commands/commits.mjs +57 -6
  4. package/commands/history.mjs +0 -4
  5. package/commands/quick.mjs +38 -23
  6. package/commands/reminder.mjs +41 -2
  7. package/commands/setup.mjs +50 -16
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +56 -14
  11. package/docs/operations-guide.md +35 -2
  12. package/docs/recipes.md +27 -0
  13. package/docs/security.md +3 -1
  14. package/examples/mcp/antigravity.mcp_config.json +1 -1
  15. package/examples/mcp/codex.config.toml +1 -1
  16. package/examples/mcp/copilot.mcp.json +1 -1
  17. package/lib/api-auth-forms.mjs +393 -0
  18. package/lib/api-auth-http.mjs +184 -0
  19. package/lib/api-auth-support.mjs +101 -0
  20. package/lib/api-auth-tokens.mjs +129 -0
  21. package/lib/api-auth.mjs +39 -813
  22. package/lib/api.mjs +38 -1422
  23. package/lib/apply-ledger.mjs +1 -1
  24. package/lib/batch-workflow.mjs +187 -2
  25. package/lib/commit-workflow.mjs +188 -21
  26. package/lib/config.mjs +7 -0
  27. package/lib/diagnostics.mjs +53 -4
  28. package/lib/jira-api.mjs +25 -3
  29. package/lib/jira-workflow.mjs +36 -6
  30. package/lib/logwork-config.mjs +64 -13
  31. package/lib/manual-apply-state.mjs +15 -0
  32. package/lib/manual-commands.mjs +2 -2
  33. package/lib/manual-ink-app.mjs +108 -1281
  34. package/lib/manual-ink-draft-actions.mjs +227 -0
  35. package/lib/manual-ink-interaction.mjs +306 -0
  36. package/lib/manual-ink-session.mjs +41 -0
  37. package/lib/manual-ink-task-editor.mjs +338 -0
  38. package/lib/manual-ink-wizard.mjs +520 -0
  39. package/lib/manual-mcp.mjs +27 -15
  40. package/lib/manual-repl.mjs +39 -3
  41. package/lib/mcp-server.mjs +51 -20
  42. package/lib/node-runtime.mjs +115 -0
  43. package/lib/paths.mjs +7 -0
  44. package/lib/project-mapping-workflow.mjs +30 -7
  45. package/lib/query-workflow.mjs +0 -1
  46. package/lib/reconciliation-workflow.mjs +1 -1
  47. package/lib/reminder-config.mjs +38 -2
  48. package/lib/reminder-scheduler.mjs +54 -3
  49. package/lib/reminder-service.mjs +28 -3
  50. package/lib/reminder-workflow.mjs +118 -22
  51. package/lib/ro-api-diagnostics.mjs +109 -0
  52. package/lib/ro-api-entries.mjs +359 -0
  53. package/lib/ro-api-fields.mjs +290 -0
  54. package/lib/ro-api-http.mjs +68 -0
  55. package/lib/ro-api-projects.mjs +137 -0
  56. package/lib/ro-api-tasks.mjs +50 -0
  57. package/lib/ro-api-timesheet.mjs +413 -0
  58. package/lib/ro-delete-workflow.mjs +2 -1
  59. package/lib/ro-entry-reader.mjs +11 -6
  60. package/lib/ro-resubmit-workflow.mjs +2 -1
  61. package/lib/terminal-auth-prompts.mjs +7 -4
  62. package/lib/ui.mjs +80 -59
  63. package/package.json +11 -4
  64. package/skills/logwork/SKILL.md +5 -4
@@ -0,0 +1,393 @@
1
+ // Keycloak HTML form parsing (login, password update, device choice, OTP) and
2
+ // form submission.
3
+ import { safeApiAuthError } from './api-auth-support.mjs';
4
+
5
+ export function findKeycloakAuthUrl(html, baseUrl) {
6
+ const matches = String(html || '').match(/https:\/\/keycloak\.vinova\.sg\/auth\/realms\/resource\/protocol\/openid-connect\/auth[^"' <]+/i);
7
+ if (matches?.[0]) {
8
+ return decodeHtml(matches[0]);
9
+ }
10
+
11
+ const href = findAttributeValue(html, 'href', /keycloak\.vinova\.sg\/auth\/realms\/resource\/protocol\/openid-connect\/auth/i);
12
+ if (href) {
13
+ return new URL(decodeHtml(href), baseUrl).toString();
14
+ }
15
+
16
+ return null;
17
+ }
18
+
19
+ export function selectNextKeycloakForm(html, baseUrl, { preferDevice = false } = {}) {
20
+ const forms = parseHtmlForms(html, baseUrl);
21
+ const passwordUpdate = forms.find((form) => form.kind === 'password-update');
22
+ const credentials = forms.find((form) => form.kind === 'credentials');
23
+ const otp = forms.find((form) => form.kind === 'otp');
24
+ const device = forms.find((form) => form.kind === 'device' || form.devices.length > 0);
25
+
26
+ if (passwordUpdate) {
27
+ return passwordUpdate;
28
+ }
29
+ if (credentials) {
30
+ return credentials;
31
+ }
32
+ if (otp) {
33
+ return otp;
34
+ }
35
+ if (preferDevice && device) {
36
+ return {
37
+ ...device,
38
+ kind: 'device'
39
+ };
40
+ }
41
+ if (device) {
42
+ return device;
43
+ }
44
+
45
+ return forms.find((form) => form.kind === 'credentials')
46
+ || null;
47
+ }
48
+
49
+ export function parseHtmlForms(html, baseUrl) {
50
+ const forms = [];
51
+ const source = String(html || '');
52
+ const regex = /<form\b([^>]*)>([\s\S]*?)<\/form>/gi;
53
+ let match;
54
+ while ((match = regex.exec(source))) {
55
+ const attrs = match[1];
56
+ const inner = match[2];
57
+ const action = resolveActionUrl(decodeHtml(readAttr(attrs, 'action') || ''), baseUrl);
58
+ const method = (readAttr(attrs, 'method') || 'GET').toUpperCase();
59
+ const fields = readInputFields(inner);
60
+ const devices = readDeviceOptions(inner);
61
+ const kind = classifyForm(fields, devices);
62
+ forms.push({ action, method, fields, devices, kind });
63
+ }
64
+ return forms;
65
+ }
66
+
67
+ export async function submitCredentialsForm(session, form, { email, password }) {
68
+ const body = bodyFromForm(form);
69
+ body.set(usernameFieldName(form) || 'username', email);
70
+ body.set(passwordFieldName(form) || 'password', password);
71
+ return postForm(session, form.action, body);
72
+ }
73
+
74
+ export async function submitPasswordUpdateForm(session, form, {
75
+ newPassword,
76
+ confirmPassword
77
+ }) {
78
+ const fieldNames = passwordUpdateFieldNames(form);
79
+ if (!fieldNames) {
80
+ throw safeApiAuthError('Keycloak password update form did not contain the expected password fields.');
81
+ }
82
+ if (!newPassword || newPassword !== confirmPassword) {
83
+ throw safeApiAuthError('New Resource Optimiser passwords must be non-empty and match.');
84
+ }
85
+
86
+ const body = bodyFromForm(form);
87
+ body.set(fieldNames.newPassword, newPassword);
88
+ body.set(fieldNames.confirmPassword, confirmPassword);
89
+ return postForm(session, form.action, body);
90
+ }
91
+
92
+ export async function submitDeviceForm(session, form, device) {
93
+ const body = bodyFromForm(form);
94
+ body.set(device.name, device.value);
95
+ return postForm(session, form.action, body);
96
+ }
97
+
98
+ export async function submitOtpForm(session, form, otp, selectedDevice = null) {
99
+ const body = bodyFromForm(form);
100
+ if (selectedDevice?.name && selectedDevice.value !== undefined) {
101
+ body.set(selectedDevice.name, selectedDevice.value);
102
+ }
103
+ body.set(otpFieldName(form) || 'otp', otp);
104
+ return postForm(session, form.action, body);
105
+ }
106
+
107
+ function postForm(session, action, body) {
108
+ return session.request(action, {
109
+ method: 'POST',
110
+ headers: {
111
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
112
+ 'Content-Type': 'application/x-www-form-urlencoded'
113
+ },
114
+ body
115
+ });
116
+ }
117
+
118
+ export function readTitle(html) {
119
+ const match = String(html || '').match(/<title[^>]*>([\s\S]*?)<\/title>/i);
120
+ return match ? stripTags(decodeHtml(match[1])).slice(0, 120) : null;
121
+ }
122
+
123
+ export function readBodyHint(html) {
124
+ const bodyMatch = String(html || '').match(/<body[^>]*>([\s\S]*?)<\/body>/i);
125
+ const body = String(bodyMatch ? bodyMatch[1] : html)
126
+ .replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
127
+ .replace(/<style\b[\s\S]*?<\/style>/gi, ' ');
128
+ const hint = stripTags(decodeHtml(body)).slice(0, 160);
129
+ return hint || null;
130
+ }
131
+
132
+ function resolveActionUrl(action, baseUrl) {
133
+ if (!action) {
134
+ return baseUrl ? new URL(baseUrl).toString() : '';
135
+ }
136
+ if (!baseUrl) {
137
+ return action;
138
+ }
139
+ return new URL(action, baseUrl).toString();
140
+ }
141
+
142
+ function bodyFromForm(form) {
143
+ const body = new URLSearchParams();
144
+ for (const field of form.fields) {
145
+ if (field.name && field.value !== undefined) {
146
+ body.set(field.name, field.value);
147
+ }
148
+ }
149
+ return body;
150
+ }
151
+
152
+ function classifyForm(fields, devices) {
153
+ if (passwordUpdateFieldNames({ fields })) {
154
+ return 'password-update';
155
+ }
156
+ if (passwordFieldName({ fields })) {
157
+ return 'credentials';
158
+ }
159
+ if (otpFieldName({ fields })) {
160
+ return 'otp';
161
+ }
162
+ if (devices.length > 0) {
163
+ return 'device';
164
+ }
165
+ return 'unknown';
166
+ }
167
+
168
+ function readInputFields(html) {
169
+ const fields = [];
170
+ const regex = /<input\b([^>]*)>/gi;
171
+ let match;
172
+ while ((match = regex.exec(String(html || '')))) {
173
+ const attrs = match[1];
174
+ fields.push({
175
+ name: decodeHtml(readAttr(attrs, 'name') || ''),
176
+ id: decodeHtml(readAttr(attrs, 'id') || ''),
177
+ type: decodeHtml(readAttr(attrs, 'type') || 'text'),
178
+ value: decodeHtml(readAttr(attrs, 'value') || ''),
179
+ autocomplete: decodeHtml(readAttr(attrs, 'autocomplete') || ''),
180
+ inputmode: decodeHtml(readAttr(attrs, 'inputmode') || ''),
181
+ placeholder: decodeHtml(readAttr(attrs, 'placeholder') || ''),
182
+ ariaLabel: decodeHtml(readAttr(attrs, 'aria-label') || '')
183
+ });
184
+ }
185
+ return fields.filter((field) => field.name);
186
+ }
187
+
188
+ function otpFieldName(form) {
189
+ return form.fields.find(isOtpInputField)?.name;
190
+ }
191
+
192
+ function usernameFieldName(form) {
193
+ return fieldName(form, ['username', 'email', 'login', 'user', 'userid', 'user_id']) ||
194
+ form.fields.find((field) => isTextInputField(field) && fieldHint(field, /user|email|login|account|identifier/))?.name ||
195
+ form.fields.find((field) => isTextInputField(field) && !isOtpInputField(field))?.name;
196
+ }
197
+
198
+ function passwordFieldName(form) {
199
+ return fieldName(form, ['password', 'passwd', 'pass', 'pwd']) ||
200
+ form.fields.find((field) => String(field.type || '').toLowerCase() === 'password')?.name;
201
+ }
202
+
203
+ function passwordUpdateFieldNames(form) {
204
+ const passwordFields = form.fields.filter(
205
+ (field) => String(field.type || '').toLowerCase() === 'password'
206
+ );
207
+ if (passwordFields.length < 2) {
208
+ return null;
209
+ }
210
+
211
+ const newPassword = fieldName(form, [
212
+ 'password-new',
213
+ 'new-password',
214
+ 'newPassword',
215
+ 'passwordNew'
216
+ ]) || passwordFields.find((field) => fieldHint(field, /(?:new.*password|password.*new)/i))?.name;
217
+ const confirmPassword = fieldName(form, [
218
+ 'password-confirm',
219
+ 'confirm-password',
220
+ 'confirmPassword',
221
+ 'passwordConfirm',
222
+ 'password-repeat',
223
+ 'repeat-password'
224
+ ]) || passwordFields.find((field) => fieldHint(field, /confirm|repeat/i))?.name;
225
+
226
+ if (newPassword && confirmPassword && newPassword !== confirmPassword) {
227
+ return { newPassword, confirmPassword };
228
+ }
229
+
230
+ return {
231
+ newPassword: passwordFields[0].name,
232
+ confirmPassword: passwordFields[1].name
233
+ };
234
+ }
235
+
236
+ function fieldName(form, candidates) {
237
+ const lower = new Set(candidates.map((candidate) => candidate.toLowerCase()));
238
+ return form.fields.find((field) => lower.has(String(field.name || '').toLowerCase()))?.name;
239
+ }
240
+
241
+ function isTextInputField(field) {
242
+ const type = String(field.type || 'text').toLowerCase();
243
+ return ['text', 'email', 'search', 'tel', 'url', ''].includes(type);
244
+ }
245
+
246
+ function isOtpInputField(field) {
247
+ const name = String(field.name || '').toLowerCase();
248
+ const type = String(field.type || 'text').toLowerCase();
249
+ if (['hidden', 'radio', 'checkbox', 'submit', 'button'].includes(type)) {
250
+ return false;
251
+ }
252
+
253
+ if (/^(otp|totp|mfa|mfa_code|two_factor|twofactor|authenticator_code|authenticatorCode)$/i.test(name)) {
254
+ return true;
255
+ }
256
+
257
+ if (/(otp|totp|mfa|2fa|two[_-]?factor|authenticator|verification)/i.test(name)) {
258
+ return true;
259
+ }
260
+
261
+ if (name !== 'code') {
262
+ return false;
263
+ }
264
+
265
+ return fieldHint(field, /otp|totp|one-time|one time|verification|authenticator|2fa|mfa|sms|pin|numeric|number/);
266
+ }
267
+
268
+ function fieldHint(field, pattern) {
269
+ const hint = [
270
+ field.id,
271
+ field.autocomplete,
272
+ field.inputmode,
273
+ field.placeholder,
274
+ field.ariaLabel
275
+ ].map((value) => String(value || '').toLowerCase()).join(' ');
276
+
277
+ return pattern.test(hint);
278
+ }
279
+
280
+ function readDeviceOptions(html) {
281
+ const devices = [];
282
+ const radioRegex = /<input\b([^>]*type=["']?radio["']?[^>]*)>/gi;
283
+ let radioMatch;
284
+ while ((radioMatch = radioRegex.exec(String(html || '')))) {
285
+ const attrs = radioMatch[1];
286
+ const name = decodeHtml(readAttr(attrs, 'name') || '');
287
+ const value = decodeHtml(readAttr(attrs, 'value') || '');
288
+ const id = decodeHtml(readAttr(attrs, 'id') || '');
289
+ if (!name || !value) {
290
+ continue;
291
+ }
292
+ devices.push({
293
+ type: 'radio',
294
+ name,
295
+ value,
296
+ label: readLabelFor(html, id) || readWrappingLabelForInput(html, attrs) || value
297
+ });
298
+ }
299
+
300
+ const selectRegex = /<select\b([^>]*)>([\s\S]*?)<\/select>/gi;
301
+ let selectMatch;
302
+ while ((selectMatch = selectRegex.exec(String(html || '')))) {
303
+ const name = decodeHtml(readAttr(selectMatch[1], 'name') || '');
304
+ if (!name) {
305
+ continue;
306
+ }
307
+ const optionRegex = /<option\b([^>]*)>([\s\S]*?)<\/option>/gi;
308
+ let optionMatch;
309
+ while ((optionMatch = optionRegex.exec(selectMatch[2]))) {
310
+ const value = decodeHtml(readAttr(optionMatch[1], 'value') || '');
311
+ const label = stripTags(decodeHtml(optionMatch[2]));
312
+ if (value) {
313
+ devices.push({ type: 'select', name, value, label: label || value });
314
+ }
315
+ }
316
+ }
317
+
318
+ return devices;
319
+ }
320
+
321
+ function readLabelFor(html, id) {
322
+ if (!id) {
323
+ return null;
324
+ }
325
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
326
+ const match = String(html || '').match(new RegExp(`<label\\b[^>]*for=["']${escapedId}["'][^>]*>([\\s\\S]*?)<\\/label>`, 'i'));
327
+ return match ? stripTags(decodeHtml(match[1])) : null;
328
+ }
329
+
330
+ function readWrappingLabelForInput(html, inputAttrs) {
331
+ const inputValue = readAttr(inputAttrs, 'value');
332
+ const labels = String(html || '').match(/<label\b[^>]*>[\s\S]*?<\/label>/gi) || [];
333
+ for (const label of labels) {
334
+ const inputMatch = label.match(/<input\b([^>]*)>/i);
335
+ if (!inputMatch) {
336
+ continue;
337
+ }
338
+ const labelInputValue = readAttr(inputMatch[1], 'value');
339
+ const labelInputName = readAttr(inputMatch[1], 'name');
340
+ if (
341
+ inputValue !== null &&
342
+ labelInputValue === inputValue &&
343
+ labelInputName === readAttr(inputAttrs, 'name')
344
+ ) {
345
+ return stripTags(decodeHtml(label));
346
+ }
347
+ }
348
+ return null;
349
+ }
350
+
351
+ export function summarizeFormShapes(forms) {
352
+ return forms
353
+ .slice(0, 3)
354
+ .map((form, index) => {
355
+ const fields = form.fields
356
+ .slice(0, 8)
357
+ .map((field) => `${field.name || '(unnamed)'}:${field.type || 'text'}`)
358
+ .join(',');
359
+ const devices = form.devices.length ? ` devices=${form.devices.length}` : '';
360
+ return `form${index + 1}{kind=${form.kind || 'unknown'} fields=[${fields}]${devices}}`;
361
+ })
362
+ .join(' ');
363
+ }
364
+
365
+ function readAttr(attrs, name) {
366
+ const match = String(attrs || '').match(new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
367
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
368
+ }
369
+
370
+ function findAttributeValue(html, attr, pattern) {
371
+ const regex = new RegExp(`${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'gi');
372
+ let match;
373
+ while ((match = regex.exec(String(html || '')))) {
374
+ const value = match[1] ?? match[2] ?? match[3] ?? '';
375
+ if (pattern.test(value)) {
376
+ return value;
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+
382
+ function decodeHtml(value) {
383
+ return String(value || '')
384
+ .replace(/&amp;/g, '&')
385
+ .replace(/&quot;/g, '"')
386
+ .replace(/&#39;/g, "'")
387
+ .replace(/&lt;/g, '<')
388
+ .replace(/&gt;/g, '>');
389
+ }
390
+
391
+ function stripTags(value) {
392
+ return String(value || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
393
+ }
@@ -0,0 +1,184 @@
1
+ // Cookie-carrying HTTP session for the Keycloak login. Redirects are followed by
2
+ // hand so every hop is checked against the allowed origins and the code callback.
3
+ import { CONFIG } from './config.mjs';
4
+ import { fetchWithPolicy } from './http.mjs';
5
+ import {
6
+ assertSecureUrl,
7
+ readAuthorizationCode,
8
+ safeApiAuthError,
9
+ sameRedirectTarget,
10
+ stripHash
11
+ } from './api-auth-support.mjs';
12
+
13
+ export function createHttpSession({
14
+ fetchImpl = fetch,
15
+ allowedHosts = [],
16
+ allowedOrigins = [],
17
+ authorizationRedirectUri = null
18
+ } = {}) {
19
+ const jar = new CookieJar();
20
+
21
+ return {
22
+ jar,
23
+ async request(url, options = {}) {
24
+ return requestWithRedirects({
25
+ fetchImpl,
26
+ jar,
27
+ url,
28
+ options,
29
+ allowedHosts,
30
+ allowedOrigins,
31
+ authorizationRedirectUri
32
+ });
33
+ }
34
+ };
35
+ }
36
+
37
+ export async function requestWithRedirects({
38
+ fetchImpl,
39
+ jar,
40
+ url,
41
+ options = {},
42
+ maxRedirects = 10,
43
+ allowedHosts = [],
44
+ allowedOrigins = [],
45
+ authorizationRedirectUri = null,
46
+ timeoutMs = CONFIG.httpTimeoutMs,
47
+ retryDelayMs = CONFIG.httpRetryDelayMs,
48
+ retries = CONFIG.httpReadRetries
49
+ }) {
50
+ let currentUrl = new URL(url).toString();
51
+ let method = options.method || 'GET';
52
+ let body = options.body;
53
+ const headers = new Headers(options.headers || {});
54
+ const allowedHostSet = new Set(allowedHosts.filter(Boolean).map((host) => String(host).toLowerCase()));
55
+ const allowedOriginSet = new Set(allowedOrigins.filter(Boolean).map((origin) => String(origin).toLowerCase()));
56
+
57
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
58
+ assertSecureUrl(currentUrl, 'Auth request');
59
+ const cookie = jar.header(currentUrl);
60
+ if (cookie) {
61
+ headers.set('Cookie', cookie);
62
+ }
63
+ const response = await fetchWithPolicy(currentUrl, {
64
+ ...options,
65
+ fetchImpl,
66
+ timeoutMs,
67
+ retryDelayMs,
68
+ retries,
69
+ idempotent: isIdempotentMethod(method),
70
+ method,
71
+ body,
72
+ headers,
73
+ redirect: 'manual'
74
+ });
75
+ jar.store(currentUrl, response.headers);
76
+
77
+ if (isRedirect(response.status)) {
78
+ const location = response.headers.get('location');
79
+ if (!location) {
80
+ throw safeApiAuthError('Redirect response did not include a location header.');
81
+ }
82
+ const nextUrl = new URL(location, currentUrl).toString();
83
+ assertAllowedRedirect(nextUrl, {
84
+ allowedHosts: allowedHostSet,
85
+ allowedOrigins: allowedOriginSet,
86
+ authorizationRedirectUri
87
+ });
88
+ const redirectCode = readAuthorizationCode(nextUrl);
89
+ if (redirectCode) {
90
+ return {
91
+ url: nextUrl,
92
+ status: response.status,
93
+ headers: response.headers,
94
+ body: '',
95
+ redirectCode,
96
+ redirectUri: stripHash(nextUrl)
97
+ };
98
+ }
99
+ currentUrl = nextUrl;
100
+ method = 'GET';
101
+ body = undefined;
102
+ headers.delete('Content-Type');
103
+ continue;
104
+ }
105
+
106
+ const text = await response.text();
107
+ if (!response.ok) {
108
+ throw safeApiAuthError(`API auth request failed with HTTP ${response.status}.`);
109
+ }
110
+ return {
111
+ url: currentUrl,
112
+ status: response.status,
113
+ headers: response.headers,
114
+ body: text
115
+ };
116
+ }
117
+
118
+ throw safeApiAuthError('Too many redirects during API auth.');
119
+ }
120
+
121
+ function assertAllowedRedirect(rawUrl, { allowedHosts, allowedOrigins, authorizationRedirectUri }) {
122
+ const url = assertSecureUrl(rawUrl, 'Auth redirect');
123
+ const origin = url.origin.toLowerCase();
124
+ const host = url.host.toLowerCase();
125
+ const originAllowed = allowedOrigins?.size
126
+ ? allowedOrigins.has(origin)
127
+ : allowedHosts.has(host);
128
+ if (!originAllowed) {
129
+ throw safeApiAuthError(`Blocked auth redirect to unexpected origin: ${url.origin || 'unknown-origin'}.`);
130
+ }
131
+
132
+ if (readAuthorizationCode(rawUrl) && !sameRedirectTarget(rawUrl, authorizationRedirectUri)) {
133
+ throw safeApiAuthError('Blocked authorization code redirect to unexpected Resource Optimiser callback.');
134
+ }
135
+ }
136
+
137
+ function isRedirect(status) {
138
+ return [301, 302, 303, 307, 308].includes(status);
139
+ }
140
+
141
+ function isIdempotentMethod(method) {
142
+ return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase());
143
+ }
144
+
145
+ class CookieJar {
146
+ constructor() {
147
+ this.cookies = new Map();
148
+ }
149
+
150
+ store(rawUrl, headers) {
151
+ const url = new URL(rawUrl);
152
+ const values = getSetCookieValues(headers);
153
+ for (const value of values) {
154
+ const [pair] = value.split(';');
155
+ const [name, cookieValue] = pair.split('=');
156
+ if (name && cookieValue !== undefined) {
157
+ this.cookies.set(`${url.hostname}:${name.trim()}`, `${name.trim()}=${cookieValue.trim()}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ header(rawUrl) {
163
+ const url = new URL(rawUrl);
164
+ return [...this.cookies.entries()]
165
+ .filter(([key]) => key.startsWith(`${url.hostname}:`))
166
+ .map(([, value]) => value)
167
+ .join('; ');
168
+ }
169
+ }
170
+
171
+ function getSetCookieValues(headers) {
172
+ if (typeof headers.getSetCookie === 'function') {
173
+ return headers.getSetCookie();
174
+ }
175
+ const value = headers.get?.('set-cookie');
176
+ return value ? splitSetCookie(value) : [];
177
+ }
178
+
179
+ function splitSetCookie(value) {
180
+ return String(value)
181
+ .split(/,(?=\s*[^;,\s]+=)/)
182
+ .map((item) => item.trim())
183
+ .filter(Boolean);
184
+ }
@@ -0,0 +1,101 @@
1
+ // Shared helpers for the API-only Keycloak login: safe errors, URL and origin
2
+ // checks, authorization-response URL readers and best-effort auth diagnostics.
3
+ import { redactText } from './auth-redaction.mjs';
4
+
5
+ export function assertSecureUrl(rawUrl, label) {
6
+ let url;
7
+ try {
8
+ url = new URL(rawUrl);
9
+ } catch {
10
+ throw safeApiAuthError(`${label} must be a valid URL.`);
11
+ }
12
+
13
+ if (!['http:', 'https:'].includes(url.protocol)) {
14
+ throw safeApiAuthError(`${label} must use HTTP or HTTPS.`);
15
+ }
16
+
17
+ if (url.protocol === 'http:' && !isLocalHttpHost(url.hostname)) {
18
+ throw safeApiAuthError(`${label} must use HTTPS unless it points to localhost or loopback.`);
19
+ }
20
+
21
+ return url;
22
+ }
23
+
24
+ export function originFromUrl(rawUrl) {
25
+ return assertSecureUrl(rawUrl, 'Origin URL').origin;
26
+ }
27
+
28
+ export function recordAuthEvent(recorder, name, details = {}) {
29
+ try {
30
+ recorder?.event?.(name, details);
31
+ } catch {
32
+ // Diagnostics must never break auth.
33
+ }
34
+ }
35
+
36
+ export function safeUrlParts(rawUrl) {
37
+ try {
38
+ const url = new URL(rawUrl);
39
+ return {
40
+ host: url.host,
41
+ path: url.pathname
42
+ };
43
+ } catch {
44
+ return {
45
+ host: 'unknown-host',
46
+ path: '/unknown-path'
47
+ };
48
+ }
49
+ }
50
+
51
+ export function readAuthorizationCode(rawUrl) {
52
+ const url = new URL(rawUrl);
53
+ return url.searchParams.get('code') || new URLSearchParams(url.hash.replace(/^#/, '')).get('code');
54
+ }
55
+
56
+ export function readAuthorizationState(rawUrl) {
57
+ const url = new URL(rawUrl);
58
+ return url.searchParams.get('state') || new URLSearchParams(url.hash.replace(/^#/, '')).get('state');
59
+ }
60
+
61
+ export function sameRedirectTarget(rawUrl, expectedRawUrl) {
62
+ if (!expectedRawUrl) {
63
+ return false;
64
+ }
65
+
66
+ try {
67
+ const url = new URL(rawUrl);
68
+ const expected = new URL(expectedRawUrl);
69
+ return url.origin === expected.origin && url.pathname === expected.pathname;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function isLocalHttpHost(hostname) {
76
+ const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
77
+ return host === 'localhost' ||
78
+ host === '127.0.0.1' ||
79
+ host === '::1' ||
80
+ host.startsWith('127.');
81
+ }
82
+
83
+ export function stripHash(rawUrl) {
84
+ const url = new URL(rawUrl);
85
+ url.hash = '';
86
+ return url.toString();
87
+ }
88
+
89
+ export function safeJson(text) {
90
+ try {
91
+ return JSON.parse(text);
92
+ } catch {
93
+ throw safeApiAuthError('Auth endpoint returned non-JSON response.');
94
+ }
95
+ }
96
+
97
+ export function safeApiAuthError(message) {
98
+ const error = new Error(redactText(message));
99
+ error.code = 'API_AUTH_FAILED';
100
+ return error;
101
+ }