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.
- package/LICENSE +134 -0
- package/README.md +190 -0
- package/dist/cli/src/api.js +192 -0
- package/dist/cli/src/auth.js +106 -0
- package/dist/cli/src/commands/_shared.js +78 -0
- package/dist/cli/src/commands/alerts.js +85 -0
- package/dist/cli/src/commands/auth.js +92 -0
- package/dist/cli/src/commands/declaration.js +45 -0
- package/dist/cli/src/commands/diff.js +26 -0
- package/dist/cli/src/commands/domains.js +44 -0
- package/dist/cli/src/commands/export.js +136 -0
- package/dist/cli/src/commands/init.js +134 -0
- package/dist/cli/src/commands/install.js +77 -0
- package/dist/cli/src/commands/issues.js +61 -0
- package/dist/cli/src/commands/link.js +41 -0
- package/dist/cli/src/commands/logs.js +98 -0
- package/dist/cli/src/commands/open.js +45 -0
- package/dist/cli/src/commands/pull.js +89 -0
- package/dist/cli/src/commands/push.js +110 -0
- package/dist/cli/src/commands/scan.js +94 -0
- package/dist/cli/src/commands/schedule.js +97 -0
- package/dist/cli/src/commands/services.js +143 -0
- package/dist/cli/src/commands/sites.js +111 -0
- package/dist/cli/src/commands/status.js +90 -0
- package/dist/cli/src/commands/templates.js +133 -0
- package/dist/cli/src/commands/tokens.js +50 -0
- package/dist/cli/src/commands/usage.js +41 -0
- package/dist/cli/src/commands/versions.js +95 -0
- package/dist/cli/src/commands/webhooks.js +164 -0
- package/dist/cli/src/configpkg.js +10 -0
- package/dist/cli/src/diff.js +63 -0
- package/dist/cli/src/errors.js +20 -0
- package/dist/cli/src/frameworks.js +141 -0
- package/dist/cli/src/index.js +100 -0
- package/dist/cli/src/jobs.js +59 -0
- package/dist/cli/src/merge.js +38 -0
- package/dist/cli/src/output.js +112 -0
- package/dist/cli/src/project.js +269 -0
- package/dist/cli/src/util.js +122 -0
- package/dist/config/rules_reference.json +569 -0
- package/dist/config/src/canon.js +36 -0
- package/dist/config/src/declaration.js +38 -0
- package/dist/config/src/defaults.js +804 -0
- package/dist/config/src/export.js +130 -0
- package/dist/config/src/index.js +16 -0
- package/dist/config/src/lint.js +139 -0
- package/dist/config/src/regimes.js +62 -0
- package/dist/config/src/rules.js +90 -0
- package/dist/config/src/schema.js +323 -0
- package/dist/config/src/theme.js +147 -0
- package/dist/config/src/verify.js +51 -0
- package/dist/config/src/webhooks.js +309 -0
- 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
|
+
}
|