cookiecrumbs 0.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 (53) hide show
  1. package/LICENSE +134 -0
  2. package/README.md +190 -0
  3. package/dist/cli/src/api.js +192 -0
  4. package/dist/cli/src/auth.js +106 -0
  5. package/dist/cli/src/commands/_shared.js +78 -0
  6. package/dist/cli/src/commands/alerts.js +85 -0
  7. package/dist/cli/src/commands/auth.js +92 -0
  8. package/dist/cli/src/commands/declaration.js +45 -0
  9. package/dist/cli/src/commands/diff.js +26 -0
  10. package/dist/cli/src/commands/domains.js +44 -0
  11. package/dist/cli/src/commands/export.js +136 -0
  12. package/dist/cli/src/commands/init.js +134 -0
  13. package/dist/cli/src/commands/install.js +77 -0
  14. package/dist/cli/src/commands/issues.js +61 -0
  15. package/dist/cli/src/commands/link.js +41 -0
  16. package/dist/cli/src/commands/logs.js +98 -0
  17. package/dist/cli/src/commands/open.js +45 -0
  18. package/dist/cli/src/commands/pull.js +89 -0
  19. package/dist/cli/src/commands/push.js +110 -0
  20. package/dist/cli/src/commands/scan.js +94 -0
  21. package/dist/cli/src/commands/schedule.js +97 -0
  22. package/dist/cli/src/commands/services.js +143 -0
  23. package/dist/cli/src/commands/sites.js +111 -0
  24. package/dist/cli/src/commands/status.js +90 -0
  25. package/dist/cli/src/commands/templates.js +133 -0
  26. package/dist/cli/src/commands/tokens.js +50 -0
  27. package/dist/cli/src/commands/usage.js +41 -0
  28. package/dist/cli/src/commands/versions.js +95 -0
  29. package/dist/cli/src/commands/webhooks.js +164 -0
  30. package/dist/cli/src/configpkg.js +10 -0
  31. package/dist/cli/src/diff.js +63 -0
  32. package/dist/cli/src/errors.js +20 -0
  33. package/dist/cli/src/frameworks.js +141 -0
  34. package/dist/cli/src/index.js +100 -0
  35. package/dist/cli/src/jobs.js +59 -0
  36. package/dist/cli/src/merge.js +38 -0
  37. package/dist/cli/src/output.js +112 -0
  38. package/dist/cli/src/project.js +269 -0
  39. package/dist/cli/src/util.js +122 -0
  40. package/dist/config/rules_reference.json +569 -0
  41. package/dist/config/src/canon.js +36 -0
  42. package/dist/config/src/declaration.js +38 -0
  43. package/dist/config/src/defaults.js +804 -0
  44. package/dist/config/src/export.js +130 -0
  45. package/dist/config/src/index.js +16 -0
  46. package/dist/config/src/lint.js +139 -0
  47. package/dist/config/src/regimes.js +62 -0
  48. package/dist/config/src/rules.js +90 -0
  49. package/dist/config/src/schema.js +323 -0
  50. package/dist/config/src/theme.js +147 -0
  51. package/dist/config/src/verify.js +51 -0
  52. package/dist/config/src/webhooks.js +309 -0
  53. package/package.json +62 -0
package/LICENSE ADDED
@@ -0,0 +1,134 @@
1
+ CookieCrumbs Source-Available Licence
2
+ Version 1.0, 2 September 2026
3
+
4
+ Copyright (c) 2026 CookieCrumbs (cookiecrumbs.eu). All rights reserved.
5
+
6
+ This licence covers the CookieCrumbs software published by CookieCrumbs under it
7
+ (the "Software"): the `cookiecrumbs` command line, the `@cookiecrumbs/*` packages
8
+ (MCP server, framework bindings, configuration schema), the `cc.js` banner runtime
9
+ and the source files that accompany them. It also covers the CookieCrumbs brand
10
+ assets in the `brand/` directory (the "Brand Assets"), with the additional rules in
11
+ section 5.
12
+
13
+ The Software exists to be used with the CookieCrumbs service at https://cookiecrumbs.eu
14
+ (the "Service"). That is what this licence lets you do, including in commercial
15
+ projects, without letting anyone take the project and turn it into something else.
16
+
17
+ 1. Definitions
18
+
19
+ "You" means the person or organisation exercising rights under this licence.
20
+ "Service Terms" means the terms of service, privacy policy and marketing
21
+ guidelines published at https://cookiecrumbs.eu, as amended from time to time.
22
+ "Modification" means any change to the Software or any work derived from it.
23
+ "Competing Service" means any product or service that offers consent management,
24
+ cookie banners, tracker scanning or consent-record keeping to third parties,
25
+ whether hosted, on-premise or embedded, other than the Service itself.
26
+
27
+ 2. Permitted use
28
+
29
+ Subject to the conditions in section 3, CookieCrumbs grants You a worldwide,
30
+ royalty-free, non-exclusive, non-transferable licence to:
31
+
32
+ a) install, run and use the Software, including for commercial purposes, on any
33
+ number of sites and machines, in order to use, configure, automate, integrate
34
+ with or extend the Service;
35
+ b) copy the Software into Your own repositories, build pipelines, containers and
36
+ developer tooling for the purpose in (a);
37
+ c) make Modifications for Your own use with the Service, and contribute them back
38
+ to CookieCrumbs;
39
+ d) redistribute unmodified copies of the Software, and of Your Modifications under
40
+ (c), provided this licence and all copyright, licence and attribution notices
41
+ stay attached and unaltered.
42
+
43
+ 3. Conditions
44
+
45
+ a) The Software may only be used together with the Service under an active
46
+ CookieCrumbs account, in accordance with the Service Terms.
47
+ b) You may not use the Software, any part of it or any Modification to build,
48
+ offer, host or operate a Competing Service, nor to help anyone else do so.
49
+ c) You may not remove, hide, alter or circumvent any licence notice, copyright
50
+ notice, attribution, plan entitlement check, telemetry the Service Terms
51
+ disclose, or the "Made & Hosted in Europe by CookieCrumbs" line where the
52
+ Service Terms require it.
53
+ d) You may not use the Software in a way that circumvents the limits of Your plan
54
+ or gains access to data that Your account is not entitled to.
55
+ e) You may not sublicense, sell, rent or lease the Software, or represent that
56
+ You own it or that CookieCrumbs endorses Your product.
57
+ f) You may not register, or attempt to register, any trademark, domain name or
58
+ package name that contains "CookieCrumbs" or is confusingly similar to it.
59
+
60
+ Any use outside these conditions requires a separate written agreement with
61
+ CookieCrumbs (hello@cookiecrumbs.eu).
62
+
63
+ 4. Contributions
64
+
65
+ If You send a Modification to CookieCrumbs (for example as a pull request), You
66
+ grant CookieCrumbs a perpetual, worldwide, irrevocable, royalty-free licence to
67
+ use, modify, sublicense and distribute that Modification as part of the Software
68
+ and the Service under any terms. You confirm that You have the right to grant
69
+ this licence.
70
+
71
+ 5. Brand Assets
72
+
73
+ The CookieCrumbs name, the cookie-and-crumbs mark, the wordmark and the logo
74
+ lockups are trademarks of CookieCrumbs and are not licensed under sections 2 to
75
+ 4. You may use the Brand Assets, unaltered and as provided in the `brand/`
76
+ directory, only:
77
+
78
+ a) to state truthfully that a site, product or integration uses or works with
79
+ CookieCrumbs (for example "Made with CookieCrumbs", "Consent by CookieCrumbs"
80
+ or the attribution line the banner renders);
81
+ b) in articles, talks, documentation and comparisons that refer to CookieCrumbs;
82
+ c) in the ways the marketing guidelines at https://cookiecrumbs.eu expressly
83
+ allow.
84
+
85
+ You may not change the colours, proportions or shapes of the Brand Assets, put
86
+ them inside other marks, use them as or in Your own logo or app icon, or use them
87
+ in a way that suggests that CookieCrumbs made, endorses or is responsible for
88
+ Your product. The Brand Assets must always be recognisable as belonging to
89
+ CookieCrumbs.
90
+
91
+ 6. Third-party components
92
+
93
+ The Software depends on open-source packages that keep their own licences (see
94
+ each package's `node_modules` and `package-lock.json`). The wordmark is set in
95
+ Space Grotesk, which is licensed under the SIL Open Font License 1.1. Nothing in
96
+ this licence restricts those components beyond their own terms.
97
+
98
+ 7. No warranty
99
+
100
+ THE SOFTWARE AND THE BRAND ASSETS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
101
+ KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
102
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. The
103
+ Software helps You operate a consent banner; it does not make Your site
104
+ compliant by itself and nothing it outputs is legal advice.
105
+
106
+ 8. Limitation of liability
107
+
108
+ TO THE EXTENT PERMITTED BY LAW, COOKIECRUMBS IS NOT LIABLE FOR ANY INDIRECT,
109
+ INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR FOR LOSS OF PROFITS,
110
+ DATA OR GOODWILL, ARISING FROM THE SOFTWARE OR THIS LICENCE, EVEN IF ADVISED OF
111
+ THE POSSIBILITY. Liability for intent, gross negligence, injury to life, body or
112
+ health, and under mandatory product-liability law is not limited.
113
+
114
+ 9. Termination
115
+
116
+ This licence ends automatically if You breach section 3 or section 5 and do not
117
+ cure the breach within 30 days of being notified, or immediately on a breach of
118
+ section 3(b) or 3(f). It also ends when Your CookieCrumbs account is closed. On
119
+ termination You must stop using the Software and the Brand Assets and delete
120
+ Your copies, except that copies of the consent records and configurations that
121
+ belong to You remain Yours. Sections 4, 7, 8 and 10 survive termination.
122
+
123
+ 10. General
124
+
125
+ This licence is governed by the laws of the Federal Republic of Germany,
126
+ excluding its conflict-of-law rules and the UN Convention on Contracts for the
127
+ International Sale of Goods. If any provision is unenforceable, the rest remains
128
+ in force. CookieCrumbs may publish new versions of this licence; new versions
129
+ apply to releases published under them, never retroactively. This is the whole
130
+ agreement about the Software and the Brand Assets, together with the Service
131
+ Terms for Your use of the Service.
132
+
133
+ Questions about this licence, or a request for rights it does not grant:
134
+ hello@cookiecrumbs.eu
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # cookiecrumbs — the CookieCrumbs CLI
2
+
3
+ Push and pull your banner configuration as code, publish versions, run hosted scans in CI, manage
4
+ the site itself (name, retention, scan schedule, services, issues, domains, install checks,
5
+ templates), and export consent logs, declarations and audit bundles. Talks only to the REST API v1
6
+ (`{api}/v1/...`) with a `cc_*` token.
7
+
8
+ ```bash
9
+ npm i -D cookiecrumbs # or: npx cookiecrumbs <command>
10
+ npx cookiecrumbs login # device flow: approve in the dashboard
11
+ npx cookiecrumbs init # pick/create a site, write cookiecrumbs.config.ts + texts, print the install snippet
12
+ npx cookiecrumbs push --note "first version"
13
+ ```
14
+
15
+ Requires Node 22+.
16
+
17
+ ## Configuration
18
+
19
+ | What | Where |
20
+ |---|---|
21
+ | API base | `--api <url>` or `COOKIECRUMBS_API` (default `https://api.cookiecrumbs.eu`; paths are `{base}/v1/...`) |
22
+ | Token | `COOKIECRUMBS_TOKEN` (CI/MCP) wins, else the credentials file |
23
+ | Credentials file | `~/.config/cookiecrumbs/credentials.json` (mode 0600) — on Windows `%APPDATA%\cookiecrumbs\credentials.json`; `{api, token, org, name, …}` |
24
+ | Project state | `.cookiecrumbs/state.json` `{site_id, env, last_pulled_hash, last_pulled_updated_at}` plus `.cookiecrumbs/last-pulled.json` (the last synced config, used for diffs) |
25
+ | Config file | `cookiecrumbs.config.ts` (`export default defineConfig({...})` from `@cookiecrumbs/config`) or `cookiecrumbs.config.json` |
26
+ | Texts | `cookiecrumbs/texts/<lang>.json` — `{texts, category_texts}` per language, merged into the config on push (the config file may keep `texts: {}`) |
27
+ | Dashboard URL | `COOKIECRUMBS_APP` (default `https://app.cookiecrumbs.eu`), used by `open` and the install snippet (`COOKIECRUMBS_RUNTIME_URL` overrides the `cc.js` location) |
28
+
29
+ Every command accepts `--json` (prints exactly one JSON object to stdout, nothing else; errors become
30
+ `{ok:false, error:{code, detail, exit, …}}`) and `--api <url>`. Exit codes: `0` ok, `1` failure,
31
+ `2` usage, conflict or "needs a prompt". When stdin is not a terminal the CLI never prompts: it exits 2
32
+ and names the flag to pass instead. Every request carries `X-CookieCrumbs-Client: cli/<version>`, so the
33
+ audit trail reads "via CLI · <token name>".
34
+
35
+ ## Commands
36
+
37
+ ### Account
38
+
39
+ | Command | What it does |
40
+ |---|---|
41
+ | `login [--token [cc_…]] [--scopes a,b] [--no-open]` | Device flow (RFC 8628): prints the URL and code, opens the browser, polls until approved; `--token` stores an existing token after checking it with `/v1/me` (reads stdin when no value is given: `echo $TOK \| cookiecrumbs login --token`). |
42
+ | `logout` | Deletes the credentials file. |
43
+ | `whoami` | Token name, kind, scopes, restrictions, expiry, workspace, source (env or file). |
44
+
45
+ ### Project
46
+
47
+ | Command | What it does |
48
+ |---|---|
49
+ | `init [--site <id> \| --name <n> --domain <d>] [--env preview] [--format ts\|json] [--publish] [--yes]` | Detects Next.js / React / Astro / Nuxt / SvelteKit / plain from `package.json` and file hints, links or creates the site, writes the config + texts from the current draft (or the defaults), prints the install snippet with the environment's public key. `--publish` pushes straight away. Non-interactive runs need `--site` (or `--name` + `--domain`) and `--yes` to overwrite. |
50
+ | `link [--site <id>] [--env <env>]` | Re-point the folder without touching the config. |
51
+ | `push [--env] [--note] [--material] [--dry-run]` | Lints locally with `@cookiecrumbs/config`, prints the diff vs the last pulled config, `PUT` the draft (with `If-Match` from the last pull → exit 2 `draft_conflict` when someone else edited it), then `POST /versions`. Server lint errors (422) are printed; plan gates (402) fail with the feature name. |
52
+ | `pull [--env] [--force]` | Three-way on hashes of the canonical JSON: local unchanged since the last pull → overwrite; remote unchanged → keep; both changed → writes `cookiecrumbs.config.remote.json` and exits 2 (`--force` takes the remote version). |
53
+ | `diff [--env]` | Flat path diff local vs remote draft (`~ changed`, `+ added locally`, `- missing locally`). |
54
+ | `status` | Site, environment, framework, sync state, latest versions. |
55
+ | `open [overview\|banner\|versions\|services\|inbox\|declaration\|scans\|logs\|exports\|settings\|developers] [--no-open]` | Opens the dashboard page for the linked site. |
56
+
57
+ ### Versions
58
+
59
+ ```
60
+ cookiecrumbs versions list [--env]
61
+ cookiecrumbs versions show <n>
62
+ cookiecrumbs versions rollback <n> [--env] # publishes a new version copied from n
63
+ cookiecrumbs versions promote <n> # existing preview version → production
64
+ ```
65
+
66
+ ### Scans
67
+
68
+ ```
69
+ cookiecrumbs scan [--env] [--states no_interaction,reject_all,accept_all] [--pages <n>]
70
+ [--wait] [--timeout <min>] [--fail-on-unknown] [--fail-on-preconsent]
71
+ [--sarif <file>] [--scan-id <id>] [--json]
72
+ ```
73
+
74
+ Without `--wait` the scan is queued and its id printed (exit 0). With `--wait` (implied by `--sarif` and the
75
+ `--fail-on-*` flags) the CLI polls `GET /v1/scans/:id` every 5 s and prints the summary; `--fail-on-unknown`
76
+ exits 1 when `summary.unclassified > 0`, `--fail-on-preconsent` when `summary.preconsent_violations > 0`;
77
+ `--sarif` downloads `findings?format=sarif` (SARIF 2.1.0). `--scan-id <id>` re-attaches to an existing scan
78
+ instead of starting one (extension). `--local` prints "not yet available" and exits 2.
79
+
80
+ ### Logs, declaration, full export
81
+
82
+ ```
83
+ cookiecrumbs logs export [--from YYYY-MM-DD] [--to …] [--env] [--format jsonl|csv] [--out <file>] [--job <id>] [--timeout <s>]
84
+ cookiecrumbs declaration export [--format html|md|json] [--lang <lang>] [--out <file>]
85
+ cookiecrumbs export --all [--out ./cookiecrumbs-export] [--env] [--from] [--to] [--skip-logs]
86
+ ```
87
+
88
+ `logs export` creates an export job (`consent_jsonl` / `consent_csv`), waits for a **running cc-worker**
89
+ to finish it (polling every 3 s, 10 min by default), downloads the signed file and checks the
90
+ `X-Content-SHA256` header against the bytes. One export job per site runs at a time; `--job <id>`
91
+ re-attaches to a queued job (extension).
92
+
93
+ `export --all` writes `config.json`, `texts/<lang>.json`, `declaration.<lang>.{html,md,json}`,
94
+ `versions.json`, `consents.jsonl`, `audit-bundle.zip` and a `README.txt` that explains the files and how to
95
+ verify hashes, signatures and the consent hash chain (`node bundle/verify.mjs`). Files that could not be
96
+ produced (e.g. no worker running) are listed under "Missing" and the command exits 1.
97
+
98
+ ### The site itself
99
+
100
+ Everything the dashboard's Settings, Scans → Schedule, Services, Issues, Domains and Install pages
101
+ can change. Every command takes `--site <id>`; without it the linked site is used.
102
+
103
+ ```
104
+ cookiecrumbs sites list
105
+ cookiecrumbs sites show [id]
106
+ cookiecrumbs sites create --name <n> --domain <host> [--languages en,de]
107
+ cookiecrumbs sites update [id] [--name <n>] [--retention <months>] [--public-versions-feed on|off]
108
+ [--consent-cookie-name <name>|none] [--reask-months <n>|none]
109
+
110
+ cookiecrumbs schedule [--site <id>] # the scan schedule
111
+ cookiecrumbs schedule set [--cadence monthly|weekly|daily] [--pages <n>] [--states a,b]
112
+ [--start-urls <list>|none] [--include <list>|none] [--exclude <list>|none]
113
+ [--robots on|off] [--pause | --resume]
114
+
115
+ cookiecrumbs services list [--status discovered|managed|ignored]
116
+ cookiecrumbs services add <name> --category <key> [--provider] [--domain] [--hosts] [--scripts]
117
+ [--cookies] [--storage] [--iframes] [--basis] [--justification]
118
+ [--privacy-url] [--first-party on|off] [--status] [--description]
119
+ cookiecrumbs services update <id> [same options] [--name]
120
+ cookiecrumbs services remove <id> [--yes]
121
+
122
+ cookiecrumbs issues list [--status open|resolved|suppressed|all]
123
+ cookiecrumbs issues suppress <id> --reason "<why>"
124
+ cookiecrumbs issues unsuppress <id>
125
+
126
+ cookiecrumbs install status [--limit <n>]
127
+ cookiecrumbs install check [--url <page>] [--wait] [--timeout <s>] # --wait exits 1 when cc.js is missing
128
+
129
+ cookiecrumbs domains list
130
+ cookiecrumbs domains verify <id> [--method dns_txt|meta] # prints the record / tag to publish
131
+
132
+ cookiecrumbs templates show <id> [--config-only]
133
+ cookiecrumbs templates save <name> [--from-site <id>] [--env production|preview] [--config <file.json>] [--id <template>]
134
+ cookiecrumbs templates delete <id> [--yes]
135
+ ```
136
+
137
+ `schedule set` and `services update` send only the options you pass. A cadence or page cap the plan
138
+ does not allow is clamped by the server and **reported** (`clamped` in `--json`), never hidden.
139
+ `issues suppress` requires a reason: it is kept with the issue and shown in the audit trail.
140
+ `install check --wait` polls `GET /sites/:id/install-checks` for a result newer than the request
141
+ (a worker must be running) and is meant as a post-deploy gate.
142
+
143
+ ### Tokens
144
+
145
+ ```
146
+ cookiecrumbs tokens list [--all]
147
+ cookiecrumbs tokens revoke <id> [--note <text>]
148
+ cookiecrumbs tokens create [--scopes a,b] [--use] [--no-open]
149
+ ```
150
+
151
+ There is no `POST /v1/tokens` route: `tokens create` runs the device flow and prints the new token once
152
+ (the approver picks workspace, optional site/environment and scopes in the dashboard); `--use` also stores it
153
+ as this machine's credentials.
154
+
155
+ ## CI
156
+
157
+ Set `COOKIECRUMBS_TOKEN` (a machine token with the scopes you need) and commit `.cookiecrumbs/state.json`,
158
+ or `link --site` first. Everything works without a TTY; add `--json` to parse results.
159
+
160
+ ```bash
161
+ # fail the pipeline on unclassified trackers, upload SARIF
162
+ npx cookiecrumbs scan --wait --fail-on-unknown --fail-on-preconsent --sarif results.sarif
163
+
164
+ # publish the config from the repository
165
+ npx cookiecrumbs push --env production --note "release $GITHUB_SHA"
166
+ ```
167
+
168
+ GitHub Actions example: `docs/platform/ci/cookiecrumbs-scan.yml` (scheduled + after deployments, uploads the
169
+ SARIF to Code scanning).
170
+
171
+ ```yaml
172
+ - uses: actions/setup-node@v4
173
+ with: { node-version: 22 }
174
+ - run: npx --yes cookiecrumbs scan --wait --fail-on-unknown --sarif results.sarif
175
+ env: { COOKIECRUMBS_TOKEN: ${{ secrets.COOKIECRUMBS_TOKEN }} }
176
+ - uses: github/codeql-action/upload-sarif@v3
177
+ if: always()
178
+ with: { sarif_file: results.sarif }
179
+ ```
180
+
181
+ ## Development
182
+
183
+ ```bash
184
+ npm run build -w cookiecrumbs # tsc → dist/ (the config package is compiled in, dist/config/)
185
+ npm test -w cookiecrumbs # unit tests + end-to-end against a fake API server (test/fake-api.ts)
186
+ node packages/cli/dist/cli/src/index.js --help
187
+ ```
188
+
189
+ `COOKIECRUMBS_POLL_MS` shortens the scan/export/device polling interval (tests), `COOKIECRUMBS_CREDENTIALS`
190
+ overrides the credentials path, `DEBUG=1` prints stack traces.
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CliError } from "./errors.js";
3
+ import { normaliseApi } from "./util.js";
4
+ const exitFor = (status) => (status === 409 || status === 412 || status === 400 ? 2 : 1);
5
+ function codeFromProblem(problem, status) {
6
+ if (typeof problem.code === 'string' && problem.code)
7
+ return problem.code;
8
+ if (typeof problem.type === 'string' && problem.type && problem.type !== 'about:blank') {
9
+ const tail = problem.type.split(/[/#:]/).filter(Boolean).pop();
10
+ if (tail)
11
+ return tail;
12
+ }
13
+ if (typeof problem.title === 'string' && problem.title)
14
+ return problem.title.toLowerCase().replace(/[^a-z0-9]+/g, '_');
15
+ return `http_${status}`;
16
+ }
17
+ /** Map a non-2xx response (RFC 9457 problem+json when available) to a CliError. */
18
+ export function problemToError(status, body, fallbackText = '') {
19
+ const problem = (typeof body === 'object' && body !== null ? body : {});
20
+ const code = codeFromProblem(problem, status);
21
+ const detail = typeof problem.detail === 'string' && problem.detail ? problem.detail : typeof problem.title === 'string' && problem.title ? problem.title : fallbackText || `HTTP ${status}`;
22
+ const extra = {};
23
+ for (const k of ['errors', 'lint', 'issues', 'instance', 'type', 'title'])
24
+ if (problem[k] !== undefined)
25
+ extra[k] = problem[k];
26
+ let exit = exitFor(status);
27
+ if (status === 401)
28
+ exit = 1;
29
+ if (status === 403)
30
+ exit = 1;
31
+ return new CliError(code, detail, exit, { status, extra });
32
+ }
33
+ export class Api {
34
+ base;
35
+ token;
36
+ client;
37
+ constructor(base, token, version) {
38
+ this.base = normaliseApi(base);
39
+ this.token = token;
40
+ this.client = `cli/${version}`;
41
+ }
42
+ url(path, query) {
43
+ const u = new URL(`${this.base}/v1${path.startsWith('/') ? path : '/' + path}`);
44
+ if (query)
45
+ for (const [k, v] of Object.entries(query))
46
+ if (v !== undefined && v !== '')
47
+ u.searchParams.set(k, String(v));
48
+ return u.toString();
49
+ }
50
+ headers(opts, hasBody) {
51
+ const h = {
52
+ 'X-CookieCrumbs-Client': this.client,
53
+ 'X-Request-Id': randomUUID(),
54
+ Accept: 'application/json, application/problem+json;q=0.9, */*;q=0.1',
55
+ ...(opts.headers ?? {}),
56
+ };
57
+ if (hasBody)
58
+ h['Content-Type'] = 'application/json';
59
+ if (!opts.anonymous) {
60
+ if (!this.token)
61
+ throw new CliError('not_logged_in', 'No API token. Run `cookiecrumbs login` or set COOKIECRUMBS_TOKEN.', 2);
62
+ h.Authorization = `Bearer ${this.token}`;
63
+ }
64
+ return h;
65
+ }
66
+ async send(method, path, opts) {
67
+ const hasBody = opts.body !== undefined;
68
+ const url = this.url(path, opts.query);
69
+ let res;
70
+ try {
71
+ res = await fetch(url, { method, headers: this.headers(opts, hasBody), body: hasBody ? JSON.stringify(opts.body) : undefined });
72
+ }
73
+ catch (e) {
74
+ const msg = e instanceof Error ? (e.cause instanceof Error ? `${e.message}: ${e.cause.message}` : e.message) : String(e);
75
+ throw new CliError('network', `Could not reach ${url}: ${msg}`, 1);
76
+ }
77
+ if (res.ok || opts.allowStatus?.includes(res.status))
78
+ return res;
79
+ const text = await res.text();
80
+ let body = null;
81
+ try {
82
+ body = text ? JSON.parse(text) : null;
83
+ }
84
+ catch {
85
+ body = null;
86
+ }
87
+ throw problemToError(res.status, body, text.slice(0, 300));
88
+ }
89
+ async request(method, path, opts = {}) {
90
+ const res = await this.send(method, path, opts);
91
+ if (res.status === 204)
92
+ return undefined;
93
+ const text = await res.text();
94
+ if (!text)
95
+ return undefined;
96
+ try {
97
+ return JSON.parse(text);
98
+ }
99
+ catch {
100
+ throw new CliError('bad_response', `Expected JSON from ${method} ${path}, got: ${text.slice(0, 120)}`, 1);
101
+ }
102
+ }
103
+ async requestText(method, path, opts = {}) {
104
+ const res = await this.send(method, path, opts);
105
+ return res.text();
106
+ }
107
+ async download(path, opts = {}) {
108
+ const res = await this.send('GET', path, { ...opts, headers: { Accept: '*/*', ...(opts.headers ?? {}) } });
109
+ const headers = {};
110
+ res.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
111
+ const cd = headers['content-disposition'] ?? '';
112
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(cd);
113
+ return { bytes: Buffer.from(await res.arrayBuffer()), headers, filename: m ? decodeURIComponent(m[1]) : null };
114
+ }
115
+ // ---- typed routes ------------------------------------------------------
116
+ health = () => this.request('GET', '/health', { anonymous: true });
117
+ me = () => this.request('GET', '/me');
118
+ sites = () => this.request('GET', '/sites').then(unwrapList('sites'));
119
+ site = (id) => this.request('GET', `/sites/${id}`);
120
+ createSite = (body) => this.request('POST', '/sites', { body });
121
+ updateSite = (id, body) => this.request('PATCH', `/sites/${id}`, { body });
122
+ // ---- site configuration (0063) -----------------------------------------
123
+ scanSchedule = (site) => this.request('GET', `/sites/${site}/scan-schedule`);
124
+ updateScanSchedule = (site, body) => this.request('PATCH', `/sites/${site}/scan-schedule`, { body });
125
+ installChecks = (site, limit) => this.request('GET', `/sites/${site}/install-checks`, { query: { limit } }).then((r) => r.items ?? []);
126
+ requestInstallCheck = (site, url) => this.request('POST', `/sites/${site}/install-checks`, { body: url ? { url } : {} });
127
+ domains = (site) => this.request('GET', `/sites/${site}/domains`).then((r) => r.items ?? []);
128
+ verifyDomain = (id, method) => this.request('POST', `/domains/${id}/verify`, { body: { method } });
129
+ createService = (site, body) => this.request('POST', `/sites/${site}/services`, { body });
130
+ updateService = (id, body) => this.request('PATCH', `/services/${id}`, { body });
131
+ deleteService = (id) => this.request('DELETE', `/services/${id}`);
132
+ issuesOf = (site, status = 'open') => this.request('GET', `/sites/${site}/issues`, { query: { status } }).then((r) => r.items ?? []);
133
+ suppressIssue = (id, reason) => this.request('POST', `/issues/${id}/suppress`, { body: { reason } });
134
+ unsuppressIssue = (id) => this.request('POST', `/issues/${id}/unsuppress`, { body: {} });
135
+ template = (id) => this.request('GET', `/templates/${id}`);
136
+ createTemplate = (body) => this.request('POST', '/templates', { body });
137
+ updateTemplate = (id, body) => this.request('PUT', `/templates/${id}`, { body });
138
+ deleteTemplate = (id) => this.request('DELETE', `/templates/${id}`);
139
+ getDraft = (site, env) => this.request('GET', `/sites/${site}/config/draft`, { query: { env } });
140
+ putDraft = (site, env, config, ifMatch) => this.request('PUT', `/sites/${site}/config/draft`, { query: { env }, body: config, headers: ifMatch ? { 'If-Match': ifMatch } : undefined });
141
+ validate = (site, config) => this.request('POST', `/sites/${site}/config/validate`, { body: config });
142
+ publish = (site, body) => this.request('POST', `/sites/${site}/versions`, { body });
143
+ versions = (site) => this.request('GET', `/sites/${site}/versions`).then(unwrapList('versions'));
144
+ version = (id) => this.request('GET', `/versions/${id}`);
145
+ rollback = (id, env) => this.request('POST', `/versions/${id}/rollback`, { body: { env } });
146
+ promote = (id) => this.request('POST', `/versions/${id}/promote`, { body: {} });
147
+ services = (site, status) => this.request('GET', `/sites/${site}/services`, { query: { status } }).then(unwrapList('items'));
148
+ issues = (site) => this.request('GET', `/sites/${site}/issues`);
149
+ createScan = (site, body) => this.request('POST', `/sites/${site}/scans`, { body });
150
+ scan = (id) => this.request('GET', `/scans/${id}`);
151
+ findings = (id, format, tab = 'all') => this.requestText('GET', `/scans/${id}/findings`, { query: { format, tab }, headers: { Accept: '*/*' } });
152
+ scanDiff = (id) => this.request('GET', `/scans/${id}/diff`);
153
+ declaration = (site, format, lang) => this.requestText('GET', `/sites/${site}/declaration.${format}`, { query: { lang }, headers: { Accept: '*/*' } });
154
+ consents = (site, q) => this.request('GET', `/sites/${site}/consents`, { query: q });
155
+ createExport = (site, body) => this.request('POST', `/sites/${site}/exports`, { body });
156
+ exportJob = (id) => this.request('GET', `/exports/${id}`);
157
+ exportDownload = (id) => this.download(`/exports/${id}/download`);
158
+ tokens = () => this.request('GET', '/tokens').then(unwrapList('tokens'));
159
+ revokeToken = (id, note) => this.request('DELETE', `/tokens/${id}`, { body: note ? { note } : undefined });
160
+ // ---- phase 5 ----------------------------------------------------------
161
+ alerts = (q) => this.request('GET', '/alerts', { query: q }).then((v) => Array.isArray(v) ? { items: v } : { items: v.items ?? [], counts: v.counts });
162
+ acknowledgeAlert = (id) => this.request('POST', `/alerts/${id}/acknowledge`, { body: {} });
163
+ resolveAlert = (id) => this.request('POST', `/alerts/${id}/resolve`, { body: {} });
164
+ webhooks = (siteId) => this.request('GET', '/webhooks', { query: { site_id: siteId } }).then(unwrapList('items'));
165
+ createWebhook = (body) => this.request('POST', '/webhooks', { body });
166
+ rotateWebhook = (id) => this.request('POST', `/webhooks/${id}/rotate`, { body: {} });
167
+ deleteWebhook = (id) => this.request('DELETE', `/webhooks/${id}`);
168
+ webhookDeliveries = (id, limit) => this.request('GET', `/webhooks/${id}/deliveries`, { query: { limit } }).then(unwrapList('items'));
169
+ redeliverWebhook = (id, delivery) => this.request('POST', `/webhooks/${id}/deliveries/${delivery}/redeliver`, { body: {} });
170
+ usage = (period) => this.request('GET', '/usage', { query: { period } });
171
+ templates = () => this.request('GET', '/templates').then(unwrapList('items'));
172
+ applyTemplate = (id, body) => this.request('POST', `/templates/${id}/apply`, { body }).then(unwrapList('results'));
173
+ deviceCode = (body) => this.request('POST', '/device/code', { body, anonymous: true });
174
+ deviceToken = (device_code) => this.request('POST', '/device/token', { body: { device_code }, anonymous: true, allowStatus: [400, 401, 403, 428] });
175
+ }
176
+ /** Some list routes may wrap rows (`{sites:[…]}`); accept both. */
177
+ function unwrapList(key) {
178
+ return (v) => {
179
+ if (Array.isArray(v))
180
+ return v;
181
+ if (v && typeof v === 'object') {
182
+ const o = v;
183
+ for (const k of [key, 'rows', 'data', 'items'])
184
+ if (Array.isArray(o[k]))
185
+ return o[k];
186
+ }
187
+ return [];
188
+ };
189
+ }
190
+ export function isDeviceTokenOk(r) {
191
+ return typeof r.token === 'string' && r.token.length > 0;
192
+ }
@@ -0,0 +1,106 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { isDeviceTokenOk } from "./api.js";
5
+ import { CliError } from "./errors.js";
6
+ import { human, spinner } from "./output.js";
7
+ import { openBrowser, sleep, pollMs } from "./util.js";
8
+ /** `%APPDATA%/cookiecrumbs/credentials.json` on Windows, `~/.config/cookiecrumbs/credentials.json` elsewhere. */
9
+ export function credentialsPath() {
10
+ if (process.env.COOKIECRUMBS_CREDENTIALS)
11
+ return process.env.COOKIECRUMBS_CREDENTIALS;
12
+ if (process.platform === 'win32') {
13
+ const base = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
14
+ return join(base, 'cookiecrumbs', 'credentials.json');
15
+ }
16
+ const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
17
+ return join(base, 'cookiecrumbs', 'credentials.json');
18
+ }
19
+ export function readCredentials() {
20
+ const file = credentialsPath();
21
+ if (!existsSync(file))
22
+ return null;
23
+ try {
24
+ const c = JSON.parse(readFileSync(file, 'utf8'));
25
+ return c && typeof c.token === 'string' ? c : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function writeCredentials(c) {
32
+ const file = credentialsPath();
33
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
34
+ writeFileSync(file, JSON.stringify({ ...c, saved_at: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 });
35
+ try {
36
+ chmodSync(file, 0o600);
37
+ }
38
+ catch {
39
+ /* Windows ignores POSIX modes; the file sits under the user's profile */
40
+ }
41
+ return file;
42
+ }
43
+ export function clearCredentials() {
44
+ const file = credentialsPath();
45
+ if (!existsSync(file))
46
+ return false;
47
+ rmSync(file);
48
+ return true;
49
+ }
50
+ /** COOKIECRUMBS_TOKEN wins over the credentials file. */
51
+ export function resolveToken() {
52
+ const env = process.env.COOKIECRUMBS_TOKEN?.trim();
53
+ if (env)
54
+ return { token: env, source: 'env', creds: null };
55
+ const creds = readCredentials();
56
+ if (creds)
57
+ return { token: creds.token, source: 'file', creds };
58
+ return { token: null, source: null, creds: null };
59
+ }
60
+ export const DEFAULT_SCOPES = ['banner:read', 'banner:write', 'banner:publish', 'scans:read', 'scans:run', 'logs:read', 'logs:export', 'sites:read', 'sites:write'];
61
+ /** RFC 8628 device flow: print the URL + code, open the browser unless told not to, poll until a token arrives. */
62
+ export async function deviceFlow(api, opts = {}) {
63
+ const code = await api.deviceCode({ scopes: opts.scopes ?? DEFAULT_SCOPES, client: api.client });
64
+ if (!code?.device_code || !code.user_code)
65
+ throw new CliError('bad_response', 'The device code response is missing device_code/user_code.', 1);
66
+ opts.onCode?.(code);
67
+ const url = code.verification_uri_complete || code.verification_uri;
68
+ human.note(`Open ${url}\nand confirm the code ${code.user_code}`, 'Approve this device');
69
+ if (opts.open !== false) {
70
+ if (openBrowser(url))
71
+ human.info('Opening your browser… (pass --no-open to skip)');
72
+ }
73
+ let interval = Math.max(1, Number(code.interval) || 5);
74
+ const deadline = Date.now() + Math.max(30, Number(code.expires_in) || 900) * 1000;
75
+ const s = spinner('Waiting for approval in the dashboard');
76
+ while (Date.now() < deadline) {
77
+ await sleep(pollMs(interval * 1000));
78
+ const r = await api.deviceToken(code.device_code);
79
+ if (isDeviceTokenOk(r)) {
80
+ s.stop('Approved');
81
+ return { token: r, code };
82
+ }
83
+ const err = r?.error ?? 'authorization_pending';
84
+ if (err === 'authorization_pending')
85
+ continue;
86
+ if (err === 'slow_down') {
87
+ interval += 5;
88
+ continue;
89
+ }
90
+ s.fail(err === 'access_denied' ? 'Denied in the dashboard' : `Device code ${err}`);
91
+ if (err === 'access_denied')
92
+ throw new CliError('access_denied', 'The request was denied in the dashboard.', 1);
93
+ if (err === 'expired_token')
94
+ throw new CliError('expired_token', 'The device code expired before it was approved. Run `cookiecrumbs login` again.', 1);
95
+ throw new CliError(err, r.error_description ?? `Device flow failed: ${err}`, 1);
96
+ }
97
+ s.fail('Timed out');
98
+ throw new CliError('expired_token', 'Timed out waiting for approval. Run `cookiecrumbs login` again.', 1);
99
+ }
100
+ export function orgLabel(org) {
101
+ if (!org)
102
+ return '—';
103
+ if (typeof org === 'string')
104
+ return org;
105
+ return org.slug ? `${org.name ?? org.slug} (${org.slug})` : (org.name ?? org.id);
106
+ }