kaloko 0.21.4 → 0.22.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.21.4",
3
+ "version": "0.22.0",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
@@ -442,6 +442,11 @@
442
442
  ],
443
443
  "description": "Version of the application under test (git tag on HEAD or --app-version); documentation captures carry it."
444
444
  },
445
+ "packs_off": {
446
+ "type": "object",
447
+ "description": "Packs the steps list but this run left out, and why: design (a code-only pack on a design environment that the scenario did not keep with health.design_packs).",
448
+ "additionalProperties": { "enum": ["design"] }
449
+ },
445
450
  "site": {
446
451
  "type": "object",
447
452
  "description": "Site checks (#152, scenario health.site), once per site and run: robots.txt, sitemaps, llms.txt, http→https and www redirects. Advisory: health shows a finding per failed check (pack site, step site:<host>); they never decide a verdict.",
@@ -95,6 +95,7 @@
95
95
  "description": "Health (CS Kondice): the checks of packs are advisory, shown apart from acceptance and never part of a verdict. acceptance lists the packs whose checks still count for acceptance in this scenario, as they did before health was split off (the design pack always does). To make one check count, adopt it: kaloko health adopt <step>:<check> (e.g. home:HC103) adds the criterion AC103 with from: HC103.",
96
96
  "properties": {
97
97
  "acceptance": { "type": "array", "uniqueItems": true, "items": { "enum": ["seo", "usability", "a11y", "perf", "console", "errors", "app", "design", "aeo", "locale"] } },
98
+ "design_packs": { "type": "array", "uniqueItems": true, "items": { "enum": ["seo", "aeo", "perf", "console", "errors"] }, "description": "Code-only packs (seo, aeo, perf, console, errors) are off when the scenario is walked on a design environment, because a designer's local HTML has no real meta tags, server or error statuses. List the ones that should still run on design versions. a11y, usability and locale always run." },
98
99
  "site": {
99
100
  "description": "Site checks (#152), once per site and run: robots.txt, the sitemaps it lists, llms.txt, and http→https and www redirects in one hop. Advisory (health, pack site, HC11xx); off unless set, because they make requests of their own. true for the defaults, or an object.",
100
101
  "oneOf": [
@@ -107,6 +107,7 @@ design reference or baseline, merging a design branch, keeping your version of
107
107
  a step in a sync conflict (`--mine`), turning on `html_view`, turning on recordings for
108
108
  a whole project (`capture.record` in the config: storage), a real clean-up
109
109
  (`kaloko prune --yes`), trashing someone else's run, archiving a scenario,
110
+ moving a scenario to another project (`kaloko scenario move`),
110
111
  renaming a project or restricting it (`kaloko projects rename`, a `kaloko
111
112
  projects sync` that would rename), picking one site of a repository that
112
113
  holds several (ask which one before `kaloko init` or a walk),
@@ -62,6 +62,14 @@ criterion with the same id wins and counts for acceptance (that is what
62
62
  `--coverage`; the plan lists only what the brief asks for. Use packs for
63
63
  public pages and landings; task flows keep their own criteria.
64
64
 
65
+ On a design environment (`kind: design`) the code-only packs `seo`, `aeo`,
66
+ `perf`, `console` and `errors` are off: the designer's HTML has no real server,
67
+ meta tags or error statuses. `health: { design_packs: [console] }` on the
68
+ scenario keeps one on there; `a11y`, `usability` and `locale` always run. The
69
+ run manifest names what was turned off in `packs_off`. Every check of every
70
+ pack, with why it matters and its severity, is in the help page Health checks
71
+ (`reference/health-checks` of Kaloko's docs).
72
+
65
73
  Set `page` on every step (or once on the scenario) so the usability pack
66
74
  asks the right questions:
67
75
 
@@ -215,7 +223,7 @@ When the work has a repository and an issue number, make them visible:
215
223
 
216
224
  - scenario `id` carries the ticket (`TASK-1273-club-activation`, `WEB-12-checkout`); the CLI derives the issue number from it or from the branch (`feat/1273-…`) and links it to the repository's origin (`acme/shop#1273`);
217
225
  - set `issue: { number, repo?, url? }` explicitly when the id or branch cannot carry it (for example a Plane item);
218
- - the project is the repository name unless `project:` in `kaloko.config.yml` says otherwise; a single flow may sort itself elsewhere with `project: <slug>` in its scenario (optional). Projects are records of the organization: `kaloko projects` or MCP `list_projects` show the slugs to use; admins create them in Settings → Projects or assign a scenario in the overview, which wins over the scenario file;
226
+ - the project is the repository name unless `project:` in `kaloko.config.yml` says otherwise; a single flow may sort itself elsewhere with `project: <slug>` in its scenario (optional). Projects are records of the organization: `kaloko projects` or MCP `list_projects` show the slugs to use; admins create them in Settings → Projects and move a scenario on its page in the app (Scenario settings), people with the Developer / Tester role with `kaloko scenario move <id> --project <slug>`; a move wins over the scenario file and the config;
219
227
  - the canvas header, the organization overview and `kaloko feedback` show `repo#number · title`, so name the scenario `title` for humans, not for the ticket;
220
228
  - requirement IDs from the team's tracker or QMS go into `refs:` on the scenario, a step or a criterion (`refs: [URS-12, FS-3.4]`, free text, never invented); they flow into the acceptance record and the trace matrix ([evidence.md](evidence.md)).
221
229
 
@@ -231,13 +231,16 @@ npx kaloko prune --scenario TASK-1273-cart --keep 3 # dry run: lists
231
231
  npx kaloko prune --scenario TASK-1273-cart --keep 3 --yes # ask first
232
232
  npx kaloko keep <run-id> # never expires, never pruned (--off to undo)
233
233
  npx kaloko archive TASK-1273-klub # retired scenario, ask first (admin); --undo
234
+ npx kaloko scenario move TASK-1273-klub --project eco-web # ask first: runs and drafts follow
234
235
  ```
235
236
 
236
237
  - **Without asking:** trash runs you uploaded in this session that were
237
238
  superseded before anyone looked at them (debug rounds without comments or a
238
239
  verdict), and a dry-run `prune`.
239
240
  - **Ask first:** `prune --yes`, trashing someone else's run, archiving a
240
- scenario.
241
+ scenario, moving a scenario to another project (`kaloko scenario move`: its
242
+ runs and drafts follow, people who see only the old project lose them, and
243
+ the move wins over `project:` in the config for later shares).
241
244
  - **Never:** trash an accepted run, a baseline, a run with review comments, or a
242
245
  run linked from an open PR or issue comment. `prune` already keeps kept,
243
246
  accepted, baseline, design-reference and commented runs, and without the admin
@@ -18,7 +18,12 @@ environments:
18
18
  `kaloko start --scenario qa/flows/checkout.yml --env design` starts a run of it. The scenario is the one the
19
19
  implementation uses: same step ids, same criteria. Design steps meet the acceptance criteria too (headings,
20
20
  labels, semantic questions), so a design is accepted before anyone builds it. Checks against the design
21
- reference (`design_pixels`, `design_structure`) are left out on a design environment.
21
+ reference (`design_pixels`, `design_structure`) are left out on a design environment, and so are the code-only
22
+ packs `seo`, `aeo`, `perf`, `console` and `errors` (a local folder has no real server, meta tags or error
23
+ statuses). `health: { design_packs: [console] }` on the scenario keeps one on; `a11y`, `usability` and `locale`
24
+ run on designs too. Kaloko writes no code from a design: Live runs the designer's own files, and Dev mode is the
25
+ handoff (measurements, tokens, states, a prompt to copy for an agent) that developers build from in their code.
26
+ Published versions are listed under Design drafts in the app, not among the runs.
22
27
 
23
28
  ## The bundle
24
29
 
@@ -28,6 +28,16 @@ npx kaloko browser install --browsers webkit,firefox # once per machine or CI
28
28
  npx kaloko devices # the preset catalogue
29
29
  ```
30
30
 
31
+ ## Default capture setup
32
+
33
+ When the person names no sizes, a new scenario uses `viewports: { desktop: [1280, 800], mobile: [360, 800] }`
34
+ (what `kaloko init`, `kaloko draft` and the template write) and captures whole pages (`full_page` is on unless a
35
+ step or stack turns it off). Add a tablet only when the task is about one, as `tablet: [744, 1133]`; a width of
36
+ 900 or more is a small desktop, not a tablet. Do not pick sizes per step to fit a small element, and leave
37
+ `capture.annotate` off unless the task is about marked areas of the page. Never change the `viewports:` of an
38
+ existing scenario to these defaults: its baselines were taken at its own sizes (a scenario without `viewports:`
39
+ still captures at 1280 × 800 and 390 × 844).
40
+
31
41
  Every combination is one capture: `checkout × 3 viewports × 2 browsers × 2 schemes` is 12 screenshots per step and
32
42
  locale. Keep the matrix to what the task is about. A Safari bug report needs `browsers: [chromium, webkit]` on the
33
43
  affected steps, not every dimension on every step. A run refuses more than 400 combinations.
@@ -268,6 +268,11 @@ A product shows in the app (Products in the main menu, `/<org>/products`) once i
268
268
  their newest version and docs, and names those of the config that are not there yet. After adding or renaming a
269
269
  product in the config, run `kaloko products sync` and say so in your summary.
270
270
 
271
+ A product that is no longer shipped is archived with `kaloko products remove <product>` (an admin of its projects):
272
+ it leaves Products, its changelog and docs answer 404, its releases and docs are kept. `products sync` then reports
273
+ it as archived instead of creating it again; delete it from `products:` too. Archive only when the person asks;
274
+ `kaloko products restore <product>` undoes it.
275
+
271
276
  ## Readers
272
277
 
273
278
  The changelog page (`/<org>/products/<product>/changelog`, or `/<org>/projects/<project>/changelog`) and the docs
@@ -78,7 +78,9 @@ Rules:
78
78
  Choose names that fit the project; ask the person for the values.
79
79
  - The gate is sent only to `access.origins`, never to third parties (analytics,
80
80
  CDNs, payment providers). List an API on another host explicitly;
81
- `https://*.example.com` covers subdomains.
81
+ `https://*.example.com` covers subdomains. Every redirect hop is checked on
82
+ its own: one within the gate keeps it, one to another host (audio on S3, a
83
+ sign-in at an identity provider) is followed without it.
82
84
  - The CLI applies the gate and the readonly policy to every browser context it
83
85
  creates: the shared browser, `walk` sessions and one-off captures. A context
84
86
  you create yourself over CDP does not get them; work in the shared browser's
package/src/cli.mjs CHANGED
@@ -14,7 +14,7 @@ import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral
14
14
  import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
15
15
  import { observeConsole, pageTarget } from './lib/console.mjs'
16
16
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
17
- import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateForRequests } from './lib/access.mjs'
17
+ import { newContext, accountFor, gateForRequests, basicCredentials, gateRouting, continueThroughGate } from './lib/access.mjs'
18
18
  import { configureGate, disposeShared } from './lib/linkcheck.mjs'
19
19
  import { rememberEnvironment } from './lib/secrets.mjs'
20
20
  import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
@@ -36,7 +36,7 @@ import { runFindings, findingsText } from './lib/findings.mjs'
36
36
  import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, rotationDate, sourceLocaleOf, stackReader, readsOutside } from './lib/stack.mjs'
37
37
  import { walkSiteChecks, siteChecksOn } from './lib/site.mjs'
38
38
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
39
- import { expandPacks } from './lib/packs.mjs'
39
+ import { expandPacks, forEnvironment } from './lib/packs.mjs'
40
40
  import { upsertPrComment } from './lib/pr.mjs'
41
41
  import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
42
42
  import { healthCmd } from './lib/health.mjs'
@@ -304,6 +304,12 @@ Results
304
304
  keep <run-id> [--off] A kept run never expires and is never pruned
305
305
  archive <scenario-id> [--off] Hide a retired scenario from the scenario list (admin); its runs stay reachable;
306
306
  --off shows it again
307
+ scenario move <scenario-id> --project <slug> [--json]
308
+ Move a scenario to another project with all its runs, design drafts and screens
309
+ (also on kaloko.app: the scenario’s page, Scenario settings). The move wins over
310
+ project: in the config and the scenario file, so later shares of the scenario land in
311
+ the new project too. Needs the Developer / Tester role in both projects (their
312
+ admin for a restricted one); the project must exist (kaloko projects)
307
313
  inbox [--all] [--read] [--json] Your notifications on kaloko.app (mentions, returned runs, new runs, expiry); --read marks them read
308
314
  calibrate [--golden labels.yml] [--scenario id [--limit 30]] [--target 0.95] [--json]
309
315
  Semantic evaluator vs human labels: precision/recall per criterion at the
@@ -385,6 +391,12 @@ Products (products: in kaloko.config.yml; without it the project is the product)
385
391
  publish: is applied only for an admin of the product's projects
386
392
  products list [--json] The products on Kaloko you read: newest release, docs, who reads them, and
387
393
  the products of the config that are not on Kaloko yet
394
+ products remove <product> [--json]
395
+ Archive a product: it leaves Products, its changelog and docs pages answer 404
396
+ and products sync does not create it again. Releases and docs are kept
397
+ (an admin of the product's projects)
398
+ products restore <product> [--json]
399
+ Bring an archived product back with its releases and docs
388
400
 
389
401
  Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
390
402
  changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
@@ -579,8 +591,8 @@ async function runContext (args) {
579
591
  const run = await resolveRun({ root: ctx.root, output: ctx.config.output, run: args.run })
580
592
  const scenario = await loadScenario(resolve(ctx.root, run.meta.scenario_file))
581
593
  const env = environment(ctx.config, run.meta.environment.name)
582
- // a design is not checked against itself: the design reference checks apply to implementation runs only (#42)
583
- if (env.kind === 'design') for (const n of scenario.nodes) if (n.criteria) n.criteria = n.criteria.filter((c) => !['design_pixels', 'design_structure'].includes(c.check.assert))
594
+ // a design is not checked against itself (#42), and code-only packs are off on a design environment (#205)
595
+ forEnvironment(scenario, env)
584
596
  rememberEnvironment(env)
585
597
  return { ...ctx, run, scenario, env }
586
598
  }
@@ -749,14 +761,15 @@ async function captureMail ({ config, root, run, env, scenario = null, node, loc
749
761
  // context keeps it on; the e-mail's own scripts are stripped and every
750
762
  // non-image request is blocked instead. Images hosted behind the environment's
751
763
  // gate get its headers, nobody else does.
752
- const page = await (await browser.newContext()).newPage()
753
- const gate = gateHeaders(env)
754
- const toGate = Object.keys(gate).length ? originMatcher(gateOrigins(env)) : () => false
764
+ const credentials = basicCredentials(env)
765
+ const page = await (await browser.newContext(credentials ? { httpCredentials: credentials } : {})).newPage()
766
+ const { headers: gate, toGate } = gateRouting(env)
755
767
  await page.route('**/*', (route) => {
756
768
  const r = route.request()
757
769
  if (r.url().startsWith('data:')) return route.continue()
758
770
  if (r.resourceType() !== 'image') return route.abort()
759
- return toGate(r.url()) ? route.continue({ headers: { ...r.headers(), ...gate } }) : route.continue()
771
+ // an image that redirects to a CDN does not take the gate along
772
+ return toGate(r.url()) ? continueThroughGate(route, gate, toGate) : route.continue()
760
773
  })
761
774
  const html = (message.html || `<pre>${(message.text || '').replace(/</g, '&lt;')}</pre>`).replace(/<script[\s\S]*?<\/script>/gi, '')
762
775
  // Remote images are allowed for the screenshot but must not hold the capture hostage.
@@ -1891,7 +1904,7 @@ const commands = {
1891
1904
  await commands.start({ scenario: scenarioFile, env: envName(), locales: args.locales, viewports: args.viewports, executor: args.executor || `import:${source.tool}`, config: args.config, _: [] })
1892
1905
  run = await resolveRun({ root, output: config.output })
1893
1906
  } else say(`Importing into run ${run.meta.id} (${run.meta.scenario.id}); --adhoc or --scenario starts a new one`)
1894
- const scenario = await loadScenario(resolve(root, run.meta.scenario_file))
1907
+ const scenario = forEnvironment(await loadScenario(resolve(root, run.meta.scenario_file)), run.meta.environment)
1895
1908
  const map = Object.fromEntries((list(args.map) || []).map((p) => p.split('=')).filter((p) => p.length === 2 && p[0] && p[1]).map(([a, b]) => [a.trim(), b.trim()]))
1896
1909
  const locale = typeof args.locale === 'string' && run.meta.scope.locales.includes(args.locale) ? args.locale : run.meta.scope.locales[0]
1897
1910
  const enc = await launchEphemeral(config).catch(() => null)
@@ -2612,6 +2625,27 @@ const commands = {
2612
2625
  say(off ? `✓ ${args._[0]} is active again` : `✓ ${args._[0]} archived: hidden from the scenario list, its runs stay reachable (kaloko archive ${args._[0]} --off)`)
2613
2626
  },
2614
2627
 
2628
+ /** kaloko scenario move: the scenario's project on Kaloko, its runs and drafts follow (POST …/scenarios/<id>/project). */
2629
+ async scenario (args) {
2630
+ const usage = 'Usage: kaloko scenario move <scenario-id> --project <slug> [--json]'
2631
+ const [sub, id, ...rest] = args._
2632
+ if (sub !== 'move') throw new UsageError(sub ? `kaloko scenario ${sub}: there is no such sub-command.\n${usage}` : usage)
2633
+ if (!id || rest.length) throw new UsageError(`kaloko scenario move needs one scenario id.\n${usage}`)
2634
+ if (args.project === undefined) throw new UsageError(`kaloko scenario move needs --project <slug>: the project it goes to (kaloko projects lists them).\n${usage}`)
2635
+ const slug = checkSlug(args.project === true ? '' : String(args.project), '--project slug')
2636
+ const { config } = await context(args)
2637
+ if (!config.org) throw new UserError(NO_ORG)
2638
+ const client = service(config)
2639
+ // only into a project that exists: the service would create a new one from a typo
2640
+ const projects = await client.request('GET', `/orgs/${config.org}/projects`)
2641
+ const target = projects.find((p) => p.slug === slug)
2642
+ if (!target) throw new UserError(`There is no project ${slug} that you see in ${config.org}. kaloko projects lists them; kaloko projects create ${slug} makes one.`)
2643
+ const r = await client.request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(id)}/project`, { project: slug })
2644
+ if (args.json) { say(JSON.stringify(r, null, 2)); return }
2645
+ say(`✓ ${r.scenario} is in ${r.project} (${target.name}) now, with all its runs, design drafts and screens. Later shares of it land there too.`)
2646
+ if (config.project && config.project !== r.project) say(`Note: ${CONFIG_FILE} says project: ${config.project}; for ${r.scenario} the move wins. kaloko scenario move ${r.scenario} --project ${config.project} takes it back.`)
2647
+ },
2648
+
2615
2649
  async login (args) {
2616
2650
  // a project is optional: it only names the service URL, the organization and the token variable
2617
2651
  let config = {}
@@ -2881,12 +2915,19 @@ const commands = {
2881
2915
  if (!r.ok || (args.strict && r.warnings)) process.exitCode = 1
2882
2916
  },
2883
2917
 
2884
- /** `kaloko products sync|list`: the products of the config on Kaloko, without a release; what the service has. */
2918
+ /** `kaloko products sync|list|remove|restore`: the products of the config on Kaloko, without a release; what the service has. */
2885
2919
  async products (args) {
2886
2920
  const sub = args._[0]
2887
- if (!['sync', 'list'].includes(sub)) throw new UsageError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
2921
+ if (!['sync', 'list', 'remove', 'restore'].includes(sub)) throw new UsageError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]\n kaloko products remove <product> [--json]\n kaloko products restore <product> [--json]')
2888
2922
  const { config, root } = await context(args)
2889
2923
  const lib = await import('./lib/changelog.mjs')
2924
+ if (sub === 'remove' || sub === 'restore') {
2925
+ if (!args._[1]) throw new UsageError(`Usage: kaloko products ${sub} <product> [--json]`)
2926
+ if (!config.org) throw new UserError(NO_ORG)
2927
+ const r = sub === 'remove' ? await lib.removeProduct({ config, client: service(config), slug: args._[1] }) : await lib.restoreProduct({ config, client: service(config), slug: args._[1] })
2928
+ say(args.json ? JSON.stringify(r, null, 2) : sub === 'remove' ? lib.formatRemove(r) : lib.formatRestore(r))
2929
+ return
2930
+ }
2890
2931
  if (sub === 'list') {
2891
2932
  if (!config.org) throw new UserError(NO_ORG)
2892
2933
  const r = await lib.listProducts({ root, config, client: service(config) })
@@ -11,6 +11,7 @@ import { join, resolve } from 'node:path'
11
11
  import { policyViolation, baseOrigins } from './config.mjs'
12
12
  import { secret, remember } from './secrets.mjs'
13
13
  import { UserError } from './util.mjs'
14
+ import { gateHeadersPerHop } from './gate-cdp.mjs'
14
15
 
15
16
  /** The environment's gate: `access`, with the older `http_auth` read as `access.basic`. */
16
17
  export function gateOf (env) {
@@ -78,15 +79,107 @@ export function clientCertificates (env, root = process.cwd()) {
78
79
  }))
79
80
  }
80
81
 
82
+ /**
83
+ * HTTP Basic of the gate as Playwright `httpCredentials`, or null: only for a gate on one exact origin (Playwright
84
+ * scopes them to a single origin). The browser answers a 401 challenge of that origin with them and nowhere else.
85
+ */
86
+ export function basicCredentials (env) {
87
+ const gate = gateOf(env); const origins = gateOrigins(env)
88
+ if (!gate.basic || origins.length !== 1 || String(origins[0]).includes('*')) return null
89
+ return { username: secret(gate.basic.user_env, `HTTP Basic user for ${env.name}`), password: secret(gate.basic.password_env, `HTTP Basic password for ${env.name}`), origin: new URL(origins[0]).origin }
90
+ }
91
+
92
+ /**
93
+ * How a browser context adds the gate: `headers` for requests to the gate's origins that `toGate` matches. A gate of
94
+ * HTTP Basic alone on one origin is left to the browser, which answers the challenge with httpCredentials (to that
95
+ * origin only, across redirects as well), so nothing is added for it; once there are headers to add (or several
96
+ * origins), Basic goes up front with them.
97
+ */
98
+ export function gateRouting (env, vars = process.env) {
99
+ const headers = gateHeaders(env, vars)
100
+ const own = Object.keys(headers).filter((n) => !(n === 'Authorization' && basicCredentials(env)))
101
+ return { headers, toGate: own.length ? originMatcher(gateOrigins(env)) : NOTHING }
102
+ }
103
+ const NOTHING = () => false
104
+
105
+ const REDIRECTS = new Set([301, 302, 303, 307, 308])
106
+ /** Largest piece of a media file one request through the gate asks for: the player asks for the next one itself. */
107
+ export const MEDIA_CHUNK = 4 * 1024 * 1024
108
+
109
+ /**
110
+ * The request to the gate's origins with the gate's headers, so that no redirect carries them anywhere else: the
111
+ * fallback where the headers cannot be added per redirect hop (gate-cdp.mjs, Chromium only). Playwright applies headers given to route.continue() to every redirect that follows (and does not route the
112
+ * redirected requests), so the request is made here instead, one hop at a time: a redirect that leaves the gate's
113
+ * origins goes back to the browser, which follows it without the headers. A subresource's redirect within the gate
114
+ * is followed here with the headers; a navigation's goes back to the browser too, so the address bar stays right
115
+ * (the next page then gets in with the gate's cookie, as Cloudflare Access sets one and Vercel does with
116
+ * x-vercel-set-bypass-cookie, or with the Basic challenge). `blocked(method, url)` is the readonly policy for the
117
+ * hops followed here. Media is asked for in pieces of at most MEDIA_CHUNK bytes, so a long recording never sits in
118
+ * memory whole. Headers the page set itself win, so an application's own Authorization header is left alone.
119
+ */
120
+ export async function continueThroughGate (route, gateHeadersToAdd, toGate, blocked = () => null) {
121
+ const request = route.request()
122
+ const current = request.headers()
123
+ const extra = Object.keys(gateHeadersToAdd).filter((n) => !(n.toLowerCase() in current))
124
+ if (!extra.length) return route.continue()
125
+ // an event stream never ends, so it cannot be fetched first; it keeps the headers on its (rare) redirects
126
+ if (request.resourceType() === 'eventsource' || /text\/event-stream/i.test(current.accept || '')) {
127
+ return route.continue({ headers: { ...current, ...Object.fromEntries(extra.map((n) => [n, gateHeadersToAdd[n]])) } })
128
+ }
129
+ let headers = { ...current, ...Object.fromEntries(extra.map((n) => [n, gateHeadersToAdd[n]])) }
130
+ if (request.resourceType() === 'media') headers = boundedRange(headers)
131
+ let url = request.url(); let method = request.method(); let postData = request.postDataBuffer() ?? undefined
132
+ try {
133
+ for (let hop = 0; hop < 20; hop++) {
134
+ const response = await route.fetch({ url, method, headers, postData, maxRedirects: 0, timeout: 0 })
135
+ const location = REDIRECTS.has(response.status()) ? response.headers().location : null
136
+ if (!location) return await route.fulfill({ response })
137
+ const next = new URL(location, url).href
138
+ if (request.isNavigationRequest() || !toGate(next) || new URL(next).protocol !== new URL(request.url()).protocol) {
139
+ return await route.fulfill({ response, headers: { ...response.headers(), location: next } })
140
+ }
141
+ if (response.status() === 303 || (method === 'POST' && response.status() !== 307 && response.status() !== 308)) {
142
+ method = 'GET'; postData = undefined
143
+ headers = Object.fromEntries(Object.entries(headers).filter(([n]) => !/^content-(type|length|encoding|language|location)$/i.test(n)))
144
+ }
145
+ url = next
146
+ if (blocked(method, url)) return await route.abort('blockedbyclient')
147
+ }
148
+ return await route.abort('failed') // a redirect loop, as the browser would end it
149
+ } catch (error) {
150
+ if (/closed|disposed/i.test(String(error?.message))) return
151
+ return route.abort('failed').catch(() => {})
152
+ }
153
+ }
154
+
155
+ /** A media Range header with an open end (`bytes=N-`, what players send) limited to MEDIA_CHUNK bytes. */
156
+ export function boundedRange (headers) {
157
+ const name = Object.keys(headers).find((n) => n.toLowerCase() === 'range')
158
+ const m = name && String(headers[name]).match(/^bytes=(\d+)-$/)
159
+ if (!m) return headers
160
+ const start = Number(m[1])
161
+ return { ...headers, [name]: `bytes=${start}-${start + MEDIA_CHUNK - 1}` }
162
+ }
163
+
81
164
  /**
82
165
  * Enforces the environment on a browser context: the readonly policy for every request and the
83
- * gate headers for the gate's origins only (third parties never see them). Headers the page set
84
- * itself win, so an application's own Authorization header is left alone.
166
+ * gate headers for the gate's origins only (third parties never see them, not even through a redirect).
167
+ * The context is expected to carry contextOptions' httpCredentials, which answer HTTP Basic.
168
+ *
169
+ * On Chromium the headers are added per request and per redirect hop below Playwright's routing (gate-cdp.mjs), so
170
+ * a redirect within the gate keeps them and one that leaves it never gets them. Elsewhere, or when that cannot be
171
+ * set up (or with `perHop: false`), continueThroughGate makes the requests itself.
85
172
  */
86
- export async function prepareContext (context, env, { log } = {}) {
87
- const headers = gateHeaders(env)
88
- const names = Object.keys(headers)
89
- const toGate = names.length ? originMatcher(gateOrigins(env)) : () => false
173
+ export async function prepareContext (context, env, { log, perHop: allowPerHop = true } = {}) {
174
+ const { headers, toGate } = gateRouting(env)
175
+ const perHop = toGate !== NOTHING && allowPerHop && await gateHeadersPerHop(context, { headers, origins: gateOrigins(env), toGate })
176
+ if (toGate !== NOTHING && !perHop) {
177
+ // a page answered through the gate (continueThroughGate) counts as public to Chrome, which then blocks its
178
+ // requests to local and private addresses (an intranet API, a staging host on a VPN); the gate's own origins
179
+ // may reach them, as the page could when it came straight from the network
180
+ const exact = [...new Set([...gateOrigins(env), ...baseOrigins(env)].filter((o) => !String(o).includes('*') && toGate(o)).map((o) => new URL(o).origin))]
181
+ for (const origin of exact) await context.grantPermissions(['local-network-access'], { origin }).catch(() => {})
182
+ }
90
183
  await context.route('**/*', (route) => {
91
184
  const request = route.request()
92
185
  const reason = policyViolation(env, { method: request.method(), url: request.url() })
@@ -94,10 +187,12 @@ export async function prepareContext (context, env, { log } = {}) {
94
187
  log?.({ type: 'blocked', url: request.url(), text: reason }, request)
95
188
  return route.abort('blockedbyclient')
96
189
  }
97
- if (toGate(request.url())) {
98
- const current = request.headers()
99
- const extra = names.filter((n) => !(n.toLowerCase() in current))
100
- if (extra.length) return route.continue({ headers: { ...current, ...Object.fromEntries(extra.map((n) => [n, headers[n]])) } })
190
+ if (toGate(request.url()) && !perHop) {
191
+ return continueThroughGate(route, headers, toGate, (method, url) => {
192
+ const hop = policyViolation(env, { method, url })
193
+ if (hop) log?.({ type: 'blocked', url, text: hop }, request)
194
+ return hop
195
+ })
101
196
  }
102
197
  return route.continue()
103
198
  })
@@ -109,12 +204,10 @@ export async function contextOptions (env, { root, config, account } = {}) {
109
204
  const options = {}
110
205
  const certs = clientCertificates(env, root)
111
206
  if (certs.length) options.clientCertificates = certs
112
- // Basic auth also for navigations that arrive through a redirect (#56): route headers do not apply to redirected
113
- // requests, so answer the 401 challenge with httpCredentials — only for one exact gate origin, never elsewhere
114
- const gate = gateOf(env); const origins = gateOrigins(env)
115
- if (gate.basic && origins.length === 1 && !String(origins[0]).includes('*')) {
116
- options.httpCredentials = { username: secret(gate.basic.user_env, `HTTP Basic user for ${env.name}`), password: secret(gate.basic.password_env, `HTTP Basic password for ${env.name}`), origin: new URL(origins[0]).origin }
117
- }
207
+ // Basic auth through the browser's own 401 challenge, for the one exact gate origin only (#56, #224): it reaches
208
+ // navigations that arrive through a redirect, and a redirect to another host never carries it
209
+ const credentials = basicCredentials(env)
210
+ if (credentials) options.httpCredentials = credentials
118
211
  if (account?.storage_state && await exists(account.storage_state).then(() => true, () => false)) options.storageState = account.storage_state
119
212
  return options
120
213
  }
@@ -4,18 +4,21 @@
4
4
  // Secrets are resolved here from the inherited environment, never passed in argv.
5
5
  import { chromium } from 'playwright'
6
6
  import { writeJson } from './util.mjs'
7
- import { prepareContext, clientCertificates } from './access.mjs'
7
+ import { prepareContext, clientCertificates, basicCredentials } from './access.mjs'
8
8
  import { launchPersistentWithRetry } from './browser.mjs'
9
9
  import { consoleWriter, pageTarget } from './console.mjs'
10
10
 
11
11
  const opts = JSON.parse(process.argv[2])
12
12
  const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
13
+ // HTTP Basic of a one-origin gate answers the 401 challenge of that origin only (#224)
14
+ const credentials = opts.env ? basicCredentials(opts.env) : null
13
15
  // a time limit and one more try (KALOKO_LAUNCH_TIMEOUT): a start that stalls on a busy machine does not hang here
14
16
  const context = await launchPersistentWithRetry(chromium, opts.profile, {
15
17
  headless: !opts.headed,
16
18
  channel: opts.channel || undefined,
17
19
  viewport: { width: 1280, height: 800 },
18
20
  ...(certs.length ? { clientCertificates: certs } : {}),
21
+ ...(credentials ? { httpCredentials: credentials } : {}),
19
22
  args: [`--remote-debugging-port=${opts.port}`]
20
23
  })
21
24
 
package/src/lib/build.mjs CHANGED
@@ -98,6 +98,8 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
98
98
  // "personal data hidden" (#64): the canvas says so, so a blank is not taken for a bug
99
99
  ...(runPii(meta, nodes) ? { pii: runPii(meta, nodes) } : {}),
100
100
  ...(site ? { site } : {}),
101
+ // packs a design environment turned off (#205; packs.mjs forEnvironment): the app says why they are missing
102
+ ...(scenario.packs_off ? { packs_off: scenario.packs_off } : {}),
101
103
  coverage: cov,
102
104
  history,
103
105
  catalog,
@@ -279,7 +279,8 @@ export async function productPayload (root, product, src) {
279
279
  * `kaloko products sync`: every product of the config (or the one asked for) on Kaloko with its name, projects,
280
280
  * modules, version source, reference environment, default scenario and who should read it (publish:, which the
281
281
  * service applies only for someone who manages the product), without recording a release. A product the service
282
- * refuses (a project it does not know or the person may not map) is reported and the others still go.
282
+ * refuses (a project it does not know or the person may not map) is reported and the others still go. An archived
283
+ * product (kaloko products remove) is not created again: it is reported with the way back (kaloko products restore).
283
284
  */
284
285
  export async function syncProducts ({ root, config, client = null, only = null, dryRun = false }) {
285
286
  const all = productsOf(config, root)
@@ -293,7 +294,10 @@ export async function syncProducts ({ root, config, client = null, only = null,
293
294
  try {
294
295
  const saved = await client.request('POST', `/orgs/${config.org}/products`, payload)
295
296
  out.push({ ...item, ok: true, created: saved.created ?? null, publish: saved.product?.publish || null, url: saved.page || saved.url || null, changelog: saved.url || null, notes: saved.notes || [] })
296
- } catch (e) { out.push({ ...item, ok: false, error: String(e.message || e).split('\n')[0] }) }
297
+ } catch (e) {
298
+ if (e.code === 'product_archived') { out.push({ ...item, ok: null, archived: true, restore: `kaloko products restore ${product.slug}` }); continue }
299
+ out.push({ ...item, ok: false, error: String(e.message || e).split('\n')[0] })
300
+ }
297
301
  }
298
302
  return { dry_run: dryRun, from_config: !!Object.keys(config.products || {}).length, products: out }
299
303
  }
@@ -303,15 +307,18 @@ export function formatSync (r) {
303
307
  const lines = []
304
308
  const n = r.products.length
305
309
  lines.push(r.dry_run ? `${n} product(s) would go to Kaloko; dry run, nothing sent.` : `${r.products.filter((p) => p.ok).length} of ${n} product(s) on Kaloko; no release recorded.`)
310
+ const archived = r.products.filter((p) => p.archived)
306
311
  if (!r.from_config) lines.push(' No products: in kaloko.config.yml, so the project is the product (its own changelog and docs).')
307
312
  for (const p of r.products) {
308
313
  const what = [`${p.projects.length ? `projects ${p.projects.join(', ')}` : 'the whole organization'}`, p.modules.length ? `modules ${p.modules.join(', ')}` : null, `version from ${p.version_source.source}${p.version ? ` (${p.version})` : ''}`].filter(Boolean).join(' · ')
309
314
  const name = typeof p.name === 'object' && p.name ? Object.values(p.name)[0] : p.name
315
+ if (p.archived) { lines.push(` − ${p.slug}${name && name !== p.slug ? ` (${name})` : ''}: archived on Kaloko, left as it is`); continue }
310
316
  lines.push(` ${p.ok === false ? '✗' : r.dry_run ? '·' : p.created ? '+' : '✓'} ${p.slug}${name && name !== p.slug ? ` (${name})` : ''}: ${what}`)
311
317
  if (p.url) lines.push(` ${p.url}`)
312
318
  if (p.error) lines.push(` ! ${p.error}`)
313
319
  for (const x of p.notes || []) lines.push(` ! ${x}`)
314
320
  }
321
+ if (archived.length) lines.push(`Archived products stay archived. To bring one back with its releases and docs: ${archived.map((p) => p.restore).join('; ')}. Or delete it from products: in kaloko.config.yml.`)
315
322
  return lines.join('\n')
316
323
  }
317
324
 
@@ -321,9 +328,43 @@ export function formatSync (r) {
321
328
  */
322
329
  export async function listProducts ({ root, config, client }) {
323
330
  const service = await client.request('GET', `/orgs/${config.org}/products`)
331
+ // archived products the person reads (kaloko products remove); an older service answers the plain list: none
332
+ const archived = (await client.request('GET', `/orgs/${config.org}/products?archived=1`).catch(() => [])).filter((p) => p?.archived_at)
324
333
  const local = Object.keys(config.products || {}).length ? productsOf(config, root) : []
325
- const known = new Set(service.map((p) => p.slug))
326
- return { products: service, missing: local.filter((p) => !known.has(p.slug)).map((p) => p.slug) }
334
+ const known = new Set([...service, ...archived].map((p) => p.slug))
335
+ return { products: service, archived, missing: local.filter((p) => !known.has(p.slug)).map((p) => p.slug) }
336
+ }
337
+
338
+ const productArg = (slug) => {
339
+ const s = String(slug || '').trim().toLowerCase()
340
+ if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(s)) throw new UserError('Name the product: kaloko products remove <product> (its slug, as kaloko products list shows it).')
341
+ return s
342
+ }
343
+
344
+ /**
345
+ * `kaloko products remove <product>`: archives it on Kaloko (#211). It leaves the lists, its changelog and docs pages
346
+ * answer 404, kaloko products sync does not create it again; its releases and docs stay for kaloko products restore.
347
+ */
348
+ export async function removeProduct ({ config, client, slug }) {
349
+ const s = productArg(slug)
350
+ const r = await client.request('DELETE', `/orgs/${config.org}/products/${encodeURIComponent(s)}`)
351
+ return { slug: s, archived: true, releases: r.releases ?? null, restore: `kaloko products restore ${s}` }
352
+ }
353
+
354
+ /** `kaloko products restore <product>`: an archived product back as it was, with its releases and docs. */
355
+ export async function restoreProduct ({ config, client, slug }) {
356
+ const s = productArg(slug)
357
+ const r = await client.request('POST', `/orgs/${config.org}/products/${encodeURIComponent(s)}/restore`, {})
358
+ return { slug: s, restored: !!r.restored, url: r.url || null }
359
+ }
360
+
361
+ export function formatRemove (r) {
362
+ const kept = r.releases ? ` Its ${r.releases} release(s) and its docs are kept.` : ' Its releases and docs are kept.'
363
+ return `${r.slug} is archived: it is gone from Products, and its changelog and docs pages answer 404.${kept}\n Undo: ${r.restore}\n kaloko products sync will not create it again; delete it from products: in kaloko.config.yml too.`
364
+ }
365
+
366
+ export function formatRestore (r) {
367
+ return r.restored ? `${r.slug} is back, with its releases and docs.${r.url ? `\n ${r.url}` : ''}` : `${r.slug} was not archived; nothing changed.${r.url ? `\n ${r.url}` : ''}`
327
368
  }
328
369
 
329
370
  /** The list for people. */
@@ -339,6 +380,7 @@ export function formatList (r) {
339
380
  if (p.projects?.length) lines.push(` projects: ${p.projects.join(', ')}`)
340
381
  if (p.url) lines.push(` ${p.url}`)
341
382
  }
383
+ if (r.archived?.length) lines.push(`Archived: ${r.archived.map((p) => p.slug).join(', ')}. kaloko products restore <product> brings one back.`)
342
384
  if (r.missing.length) lines.push(`In kaloko.config.yml but not on Kaloko yet: ${r.missing.join(', ')}. Run kaloko products sync.`)
343
385
  return lines.join('\n')
344
386
  }
@@ -48,7 +48,7 @@ export const HIDDEN = {
48
48
  * Commands whose unknown options stop the command instead of a warning: they delete, publish or change access. A
49
49
  * "command sub" entry makes only that sub-command strict (kaloko projects lists, kaloko projects create writes).
50
50
  */
51
- export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync'])
51
+ export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync', 'scenario move'])
52
52
 
53
53
  /**
54
54
  * Options a command takes more than once (kaloko start --ref A --ref B): their value is the list of every value given.
@@ -181,7 +181,7 @@ export function syncScope (scope, locales) {
181
181
  /** Scenario snapshot the service keeps with the draft (so it can compose versions without the CLI). */
182
182
  export function snapshotOf (scenario, run) {
183
183
  const pos = Object.fromEntries(layout(scenario).map((p) => [p.id, { col: p.col, lane: p.lane }]))
184
- return { title: scenario.title, kind: scenario.kind || 'task', source: scenario.source || null, file: run.meta.scenario_file, project: run.meta.project || null, nodes: scenario.nodes, edges: scenario.edges, lanes: scenario.lanes || [], sessions: scenario.sessions || null, layout: pos }
184
+ return { title: scenario.title, kind: scenario.kind || 'task', source: scenario.source || null, file: run.meta.scenario_file, project: run.meta.project || null, nodes: scenario.nodes, edges: scenario.edges, lanes: scenario.lanes || [], sessions: scenario.sessions || null, layout: pos, ...(scenario.packs_off ? { packs_off: scenario.packs_off } : {}) }
185
185
  }
186
186
 
187
187
  export async function uploadBlobs ({ client, org, files, say }) {