@sdods/core 0.2.0 → 0.2.2

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.
@@ -199,20 +199,52 @@ export function detectModules(report, opts = {}) {
199
199
  // S2 workspace (0.9)
200
200
  const ws = longestWorkspaceMatch(r.file, workspaces);
201
201
  if (ws) {
202
- cast({ target: r.path, kind, module: ws, weight: SIGNAL_WEIGHTS.workspace, signal: 'workspace', layer, evidence: ev });
202
+ cast({
203
+ target: r.path,
204
+ kind,
205
+ module: ws,
206
+ weight: SIGNAL_WEIGHTS.workspace,
207
+ signal: 'workspace',
208
+ layer,
209
+ evidence: ev,
210
+ });
203
211
  }
204
212
  // S3 domain-dir (0.8)
205
213
  const dd = DOMAIN_DIR_RE.exec(r.file.replace(/\\/g, '/'));
206
214
  if (dd?.[1] && !GENERIC_DIRS.has(dd[1].toLowerCase())) {
207
- cast({ target: r.path, kind, module: slugify(dd[1]), weight: SIGNAL_WEIGHTS['domain-dir'], signal: 'domain-dir', layer, evidence: ev });
215
+ cast({
216
+ target: r.path,
217
+ kind,
218
+ module: slugify(dd[1]),
219
+ weight: SIGNAL_WEIGHTS['domain-dir'],
220
+ signal: 'domain-dir',
221
+ layer,
222
+ evidence: ev,
223
+ });
208
224
  }
209
225
  // S4 source-file (0.7) - the primary signal
210
226
  const mf = moduleOfFile(r.file);
211
227
  if (mf) {
212
- cast({ target: r.path, kind, module: mf, weight: SIGNAL_WEIGHTS['source-file'], signal: 'source-file', layer, evidence: ev });
228
+ cast({
229
+ target: r.path,
230
+ kind,
231
+ module: mf,
232
+ weight: SIGNAL_WEIGHTS['source-file'],
233
+ signal: 'source-file',
234
+ layer,
235
+ evidence: ev,
236
+ });
213
237
  }
214
238
  // S5 route-segment (0.3) - today's rule, kept as the floor so nothing goes unassigned
215
- cast({ target: r.path, kind, module: moduleOfPath(r.path), weight: SIGNAL_WEIGHTS['route-segment'], signal: 'route-segment', layer, evidence: ev });
239
+ cast({
240
+ target: r.path,
241
+ kind,
242
+ module: moduleOfPath(r.path),
243
+ weight: SIGNAL_WEIGHTS['route-segment'],
244
+ signal: 'route-segment',
245
+ layer,
246
+ evidence: ev,
247
+ });
216
248
  }
217
249
  // resolve: strongest vote per target
218
250
  const winners = new Map();
@@ -224,8 +256,15 @@ export function detectModules(report, opts = {}) {
224
256
  // build modules
225
257
  const acc = new Map();
226
258
  for (const v of winners.values()) {
227
- const m = acc.get(v.module) ??
228
- { routes: new Set(), endpoints: new Set(), layers: new Set(), signals: new Set(), evidence: [], weights: [], contract: false };
259
+ const m = acc.get(v.module) ?? {
260
+ routes: new Set(),
261
+ endpoints: new Set(),
262
+ layers: new Set(),
263
+ signals: new Set(),
264
+ evidence: [],
265
+ weights: [],
266
+ contract: false,
267
+ };
229
268
  if (v.kind === 'endpoint')
230
269
  m.endpoints.add(v.target);
231
270
  else
@@ -148,7 +148,8 @@ export async function captureAuth(opts) {
148
148
  const token = await strategy.token({ config, user });
149
149
  if (token) {
150
150
  const file = tokenFileFor(config, user);
151
- writeFileSync(file, JSON.stringify({ token, capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2));
151
+ // Owner-only: this holds a usable API token for the application under test.
152
+ writeFileSync(file, JSON.stringify({ token, capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2), { mode: 0o600 });
152
153
  result.tokenFile = file;
153
154
  }
154
155
  }
@@ -1,3 +1,5 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  /**
2
4
  * Define the project's auth strategy. `form` uses `auth.form` selectors from the project yaml
3
5
  * unless a custom `login` is provided.
@@ -71,16 +73,52 @@ export async function formLogin(page, config, user) {
71
73
  if (!form) {
72
74
  throw new Error('auth.strategy is "form" but auth.form selectors are missing in sdods.project.yaml.');
73
75
  }
74
- await page.goto(form.loginPath);
75
- await page.locator(form.usernameSelector).fill(user.username);
76
- await page.locator(form.passwordSelector).fill(user.password);
77
- await page.locator(form.submitSelector).click();
78
- if (form.readySelector)
79
- await page.locator(form.readySelector).first().waitFor({ state: 'visible' });
80
- else if (form.readyUrl)
81
- await page.waitForURL(`**${form.readyUrl}*`);
82
- else
83
- await page.waitForLoadState('domcontentloaded');
76
+ try {
77
+ await page.goto(form.loginPath);
78
+ await page.locator(form.usernameSelector).fill(user.username);
79
+ await page.locator(form.passwordSelector).fill(user.password);
80
+ await page.locator(form.submitSelector).click();
81
+ if (form.readySelector)
82
+ await page.locator(form.readySelector).first().waitFor({ state: 'visible' });
83
+ else if (form.readyUrl)
84
+ await page.waitForURL(`**${form.readyUrl}*`);
85
+ else
86
+ await page.waitForLoadState('domcontentloaded');
87
+ }
88
+ catch (cause) {
89
+ throw await describeLoginFailure(page, config, user, cause);
90
+ }
91
+ }
92
+ /**
93
+ * The form login runs in its own context (see `defineAuth`), so Playwright's video/trace/screenshot
94
+ * settings never attach to it — a failure here would otherwise surface as a bare locator timeout
95
+ * with no artifact. Name the page we actually landed on, and leave a screenshot behind.
96
+ */
97
+ async function describeLoginFailure(page, config, user, cause) {
98
+ let where = '';
99
+ let shot = '';
100
+ try {
101
+ const url = page.url();
102
+ const title = await page.title();
103
+ where = ` at ${url}${title ? ` (title: ${JSON.stringify(title)})` : ''}`;
104
+ }
105
+ catch {
106
+ // page already closed or crashed — the original error is still worth reporting
107
+ }
108
+ try {
109
+ const dir = join(config.runtime.runDir, 'auth');
110
+ mkdirSync(dir, { recursive: true });
111
+ const file = join(dir, `login-failed-${user.role}-${user.username}.png`.replace(/\s+/g, '_'));
112
+ await page.screenshot({ path: file, fullPage: true });
113
+ shot = `\nScreenshot: ${file}`;
114
+ }
115
+ catch {
116
+ // screenshotting is best effort
117
+ }
118
+ const reason = cause instanceof Error ? cause.message : String(cause);
119
+ return new Error(`Form login for ${user.username} (${user.role}) failed${where}.\n${reason}` +
120
+ `\nIs the application under test running at ${config.env.ui.baseUrl}, and does that page ` +
121
+ `use the auth.form selectors in sdods.project.yaml?${shot}`, { cause });
84
122
  }
85
123
  export const noopAuth = defineAuth({ strategy: 'none' });
86
124
  //# sourceMappingURL=index.js.map
@@ -236,6 +236,9 @@ export function buildRunnerConfig(registry, sel = {}) {
236
236
  trace: 'on-first-retry',
237
237
  actionTimeout: timeouts.action,
238
238
  navigationTimeout: timeouts.navigation,
239
+ // Playwright's own `--headed` flag still wins on the CLI; this is what makes the documented
240
+ // SDODS_HEADED env var and the --headed CliOverride reach the browser at all.
241
+ headless: !first?.runtime.headed,
239
242
  },
240
243
  projects,
241
244
  metadata: {
@@ -39,7 +39,15 @@ export declare class AuthStateCache {
39
39
  private readState;
40
40
  /** Return a fresh storageState path for the user, capturing it with the strategy when needed. */
41
41
  ensure(user: PoolUserLike, auth: AuthStrategy, browser: Browser): Promise<string | undefined>;
42
- /** Atomic save: temp file + rename, sidecar written last. */
42
+ /**
43
+ * Atomic save: temp file + rename, sidecar written last.
44
+ *
45
+ * Written owner-only. This file is a live session for the application under test — cookies and
46
+ * localStorage, replayable as-is — and it used to land world-readable (0644 in a 0755
47
+ * directory), so any other account on the machine could lift it. Encryption is not the control
48
+ * here: the state has to be decryptable to be replayed, so a key would have to sit on the same
49
+ * disk. Restricting access is what actually protects it.
50
+ */
43
51
  save(user: PoolUserLike, state: Record<string, unknown>): string;
44
52
  /** Apply a cached state to a live context/page (step path: cookies + localStorage). */
45
53
  apply(args: {
@@ -92,15 +92,23 @@ export class AuthStateCache {
92
92
  return this.save(user, state);
93
93
  });
94
94
  }
95
- /** Atomic save: temp file + rename, sidecar written last. */
95
+ /**
96
+ * Atomic save: temp file + rename, sidecar written last.
97
+ *
98
+ * Written owner-only. This file is a live session for the application under test — cookies and
99
+ * localStorage, replayable as-is — and it used to land world-readable (0644 in a 0755
100
+ * directory), so any other account on the machine could lift it. Encryption is not the control
101
+ * here: the state has to be decryptable to be replayed, so a key would have to sit on the same
102
+ * disk. Restricting access is what actually protects it.
103
+ */
96
104
  save(user, state) {
97
- mkdirSync(this.dir, { recursive: true });
105
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
98
106
  const file = this.fileFor(user);
99
107
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
100
- writeFileSync(tmp, JSON.stringify(state, null, 2));
108
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
101
109
  renameSync(tmp, file);
102
110
  const sideTmp = `${this.sidecarFor(user)}.${process.pid}.tmp`;
103
- writeFileSync(sideTmp, JSON.stringify({ capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2));
111
+ writeFileSync(sideTmp, JSON.stringify({ capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2), { mode: 0o600 });
104
112
  renameSync(sideTmp, this.sidecarFor(user));
105
113
  return file;
106
114
  }
@@ -147,7 +155,7 @@ export class AuthStateCache {
147
155
  * LOCK_STALE_MS is treated as abandoned by a crashed worker.
148
156
  */
149
157
  async withLock(user, fn) {
150
- mkdirSync(this.dir, { recursive: true });
158
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
151
159
  const lock = this.lockFor(user);
152
160
  const deadline = Date.now() + LOCK_STALE_MS * 2;
153
161
  for (;;) {
@@ -63,6 +63,7 @@ export interface HarFile {
63
63
  entries: SdodsHarEntry[];
64
64
  };
65
65
  }
66
+ export declare const SECRET_HEADER: RegExp;
66
67
  export declare function normalizeUrl(url: string): string;
67
68
  export declare function bodyHash(body: unknown): string;
68
69
  export declare function harKey(method: string, url: string, body?: unknown): string;
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { redact } from '../logger.js';
5
5
  import { VERSION } from '../version.js';
6
- const SECRET_HEADER = /^(authorization|cookie|set-cookie|x-api-key|proxy-authorization)$/i;
6
+ export const SECRET_HEADER = /^(authorization|cookie|set-cookie|x-api-key|proxy-authorization)$/i;
7
7
  const VOLATILE_QUERY = /^(_|t|ts|timestamp|nonce|cb|cache)$/i;
8
8
  export function normalizeUrl(url) {
9
9
  let u;
@@ -1,4 +1,5 @@
1
1
  export * from './paths.js';
2
2
  export * from './api-har.js';
3
3
  export * from './hooks.js';
4
+ export * from './scrub.js';
4
5
  //# sourceMappingURL=index.d.ts.map
package/dist/har/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './paths.js';
2
2
  export * from './api-har.js';
3
3
  export * from './hooks.js';
4
+ export * from './scrub.js';
4
5
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Strip credentials out of a HAR recorded by the browser.
3
+ *
4
+ * The API layer builds its own HAR and redacts secret headers as it goes. The browser layer does
5
+ * not: Playwright's `routeFromHAR({ update: true })` writes the file itself, verbatim, including
6
+ * the `Cookie`, `Set-Cookie` and `Authorization` headers of the application under test. HARs are
7
+ * meant to be committed — the demo project's are in git — so recording against a real application
8
+ * while signed in wrote a live session into a tracked file.
9
+ *
10
+ * Values are replaced rather than the headers removed, so replay still matches on their presence.
11
+ */
12
+ export declare function scrubHar(har: unknown): {
13
+ changed: number;
14
+ };
15
+ /** Scrub a HAR file in place. Returns how many values were replaced, or -1 if it is not readable. */
16
+ export declare function scrubHarFile(file: string): number;
17
+ //# sourceMappingURL=scrub.d.ts.map
@@ -0,0 +1,60 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { SECRET_HEADER } from './api-har.js';
3
+ /**
4
+ * Strip credentials out of a HAR recorded by the browser.
5
+ *
6
+ * The API layer builds its own HAR and redacts secret headers as it goes. The browser layer does
7
+ * not: Playwright's `routeFromHAR({ update: true })` writes the file itself, verbatim, including
8
+ * the `Cookie`, `Set-Cookie` and `Authorization` headers of the application under test. HARs are
9
+ * meant to be committed — the demo project's are in git — so recording against a real application
10
+ * while signed in wrote a live session into a tracked file.
11
+ *
12
+ * Values are replaced rather than the headers removed, so replay still matches on their presence.
13
+ */
14
+ export function scrubHar(har) {
15
+ let changed = 0;
16
+ const entries = har?.log?.entries;
17
+ if (!Array.isArray(entries))
18
+ return { changed };
19
+ for (const entry of entries) {
20
+ for (const side of ['request', 'response']) {
21
+ const headers = entry[side]?.headers;
22
+ if (!Array.isArray(headers))
23
+ continue;
24
+ for (const h of headers) {
25
+ if (h && typeof h.name === 'string' && SECRET_HEADER.test(h.name) && h.value !== '***') {
26
+ h.value = '***';
27
+ changed++;
28
+ }
29
+ }
30
+ }
31
+ // Playwright also records cookies as structured arrays alongside the headers.
32
+ for (const side of ['request', 'response']) {
33
+ const cookies = entry[side]?.cookies;
34
+ if (!Array.isArray(cookies))
35
+ continue;
36
+ for (const c of cookies) {
37
+ if (c && typeof c.value === 'string' && c.value !== '***') {
38
+ c.value = '***';
39
+ changed++;
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return { changed };
45
+ }
46
+ /** Scrub a HAR file in place. Returns how many values were replaced, or -1 if it is not readable. */
47
+ export function scrubHarFile(file) {
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
51
+ }
52
+ catch {
53
+ return -1;
54
+ }
55
+ const { changed } = scrubHar(parsed);
56
+ if (changed > 0)
57
+ writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`);
58
+ return changed;
59
+ }
60
+ //# sourceMappingURL=scrub.js.map
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.2.0";
1
+ export declare const VERSION = "0.2.2";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.2.0';
1
+ export const VERSION = '0.2.2';
2
2
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "SDODS runtime: configuration, project registry, fixtures, step libraries, data providers, screenshot narratives and self-healing locators.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SDODS <admin@sdods.com>",
@@ -42,7 +42,7 @@
42
42
  "playwright-bdd": ">=9"
43
43
  },
44
44
  "dependencies": {
45
- "@sdods/contracts": "0.2.0",
45
+ "@sdods/contracts": "0.2.2",
46
46
  "@cucumber/gherkin": "^42.0.1",
47
47
  "@cucumber/messages": "^34.2.1",
48
48
  "@faker-js/faker": "^10.6.0",
@@ -59,7 +59,7 @@
59
59
  "ts-morph": "^28.0.0",
60
60
  "yaml": "^2.9.0",
61
61
  "zod": "^4.5.4",
62
- "@sdods/db": "0.2.0"
62
+ "@sdods/db": "0.2.2"
63
63
  },
64
64
  "homepage": "https://sdods.com",
65
65
  "bugs": {