@cookiecrumbs-eu/mcp 0.7.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 (62) hide show
  1. package/LICENSE +134 -0
  2. package/README.md +186 -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/dist/mcp/src/auth.js +40 -0
  54. package/dist/mcp/src/client.js +44 -0
  55. package/dist/mcp/src/diff.js +134 -0
  56. package/dist/mcp/src/index.js +25 -0
  57. package/dist/mcp/src/matrix.js +106 -0
  58. package/dist/mcp/src/server.js +171 -0
  59. package/dist/mcp/src/shared.js +147 -0
  60. package/dist/mcp/src/tools-config.js +943 -0
  61. package/dist/mcp/src/tools.js +650 -0
  62. package/package.json +66 -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,186 @@
1
+ # @cookiecrumbs-eu/mcp
2
+
3
+ The CookieCrumbs [Model Context Protocol](https://modelcontextprotocol.io) server. It gives a coding
4
+ agent read access to your consent banner, your hosted scans and your compliance issues — and, when the
5
+ token carries a write scope, **two-step** writes that land in the audit trail as *via MCP*.
6
+
7
+ stdio only. Node 22+.
8
+
9
+ ```bash
10
+ claude mcp add cookiecrumbs -- npx -y @cookiecrumbs-eu/mcp
11
+ ```
12
+
13
+ ```jsonc
14
+ // .cursor/mcp.json
15
+ {
16
+ "mcpServers": {
17
+ "cookiecrumbs": {
18
+ "command": "npx",
19
+ "args": ["-y", "@cookiecrumbs-eu/mcp"],
20
+ "env": { "COOKIECRUMBS_TOKEN": "cc_live_…" }
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## The token
27
+
28
+ The server reads the token from, in order:
29
+
30
+ 1. `COOKIECRUMBS_TOKEN`;
31
+ 2. the credentials file the CLI writes (`%APPDATA%\cookiecrumbs\credentials.json` on Windows,
32
+ `~/.config/cookiecrumbs/credentials.json` elsewhere) — one credential store, shared with
33
+ `npx cookiecrumbs`.
34
+
35
+ If neither exists the server still starts, and the first tool call answers with the two ways to fix it.
36
+ It does **not** run a device flow: a stdio server has no terminal to print a user code on and no browser
37
+ to open. Run `npx cookiecrumbs login` once, or create a machine token in the dashboard under
38
+ **Developers → MCP**, then restart the server.
39
+
40
+ Machine tokens can be restricted to one site and one environment. The server picks that up from
41
+ `GET /v1/me` and every tool then defaults to that site.
42
+
43
+ | Variable | Meaning |
44
+ |---|---|
45
+ | `COOKIECRUMBS_TOKEN` | `cc_live_…` bearer token |
46
+ | `COOKIECRUMBS_API` | gateway base (default: the hosted gateway) |
47
+ | `MCP_CLIENT` | overrides the client name in `X-CookieCrumbs-Client: mcp/<client>` |
48
+ | `COOKIECRUMBS_CREDENTIALS` | path to the CLI credentials file |
49
+
50
+ ## Tools, and the scopes they need
51
+
52
+ A tool is **advertised only when the token carries one of its scopes**. Hand an agent a read-only
53
+ token and the write tools are not in its tool list at all — there is nothing for it to be tempted by.
54
+
55
+ | Tool | Kind | Scope |
56
+ |---|---|---|
57
+ | `list_sites` | read | `sites:read` |
58
+ | `get_site` | read | `sites:read` |
59
+ | `get_banner` | read | `banner:read` |
60
+ | `get_compliance_status` | read | `banner:read` |
61
+ | `scan_site` | read | `scans:run` |
62
+ | `get_scan` | read | `scans:read` |
63
+ | `list_findings` | read | `scans:read` |
64
+ | `explain_classification` | read | `scans:read` |
65
+ | `check_first_layer` | read | `banner:read` |
66
+ | `rules_reference` | read | — |
67
+ | `get_declaration` | read | `banner:read` |
68
+ | `list_versions` | read | `banner:read` |
69
+ | `logs_summary` | read | `analytics:read` |
70
+ | `logs_export` | read | `logs:export` |
71
+ | `classify_tracker` | **write, two-step** | `banner:write` |
72
+ | `update_banner` | **write, two-step** | `banner:write` |
73
+ | `push_config` | **write, two-step** | `banner:publish` or `banner:write` |
74
+ | `rollback_version` | **write, two-step** | `banner:publish` |
75
+
76
+ ### The whole site (`src/tools-config.ts`)
77
+
78
+ Everything the dashboard can change, an agent can change too — and nothing more (team, billing and
79
+ tokens stay in the dashboard). Same rules: scope-gated, two-step, audited.
80
+
81
+ | Tool | Kind | Scope |
82
+ |---|---|---|
83
+ | `create_site` | **write, two-step** | `sites:write` |
84
+ | `update_site` — name, retention, settings | **write, two-step** | `sites:write` |
85
+ | `list_domains` | read | `sites:read` |
86
+ | `verify_domain` — re-issues the DNS TXT / meta token | **write, two-step** | `sites:write` |
87
+ | `list_scans` · `get_scan_diff` · `get_scan_schedule` · `get_install_status` | read | `scans:read` |
88
+ | `set_scan_schedule` — cadence, pages, states, URLs, patterns, robots, pause | **write, two-step** | `scans:run` |
89
+ | `check_install` — queue an install check, optionally wait | read | `scans:run` |
90
+ | `list_alerts` | read | `scans:read` |
91
+ | `acknowledge_alert` · `resolve_alert` | **write, two-step** | `scans:run` |
92
+ | `list_services` | read | `banner:read` |
93
+ | `add_service` · `update_service` · `delete_service` (destructive) | **write, two-step** | `banner:write` |
94
+ | `suppress_issue` (reason required) · `unsuppress_issue` | **write, two-step** | `banner:write` |
95
+ | `promote_version` — preview → production, diff first | **write, two-step** | `banner:publish` |
96
+ | `list_templates` · `get_template` | read | `banner:read` |
97
+ | `apply_template` · `save_template` · `delete_template` (destructive) | **write, two-step** | `banner:write` |
98
+ | `list_webhooks` · `list_alert_channels` · `get_usage` · `list_export_destinations` | read | `sites:read` |
99
+ | `create_webhook` (secret shown once) · `update_webhook` · `delete_webhook` (destructive) | **write, two-step** | `sites:write` |
100
+ | `create_alert_channel` · `update_alert_channel` · `delete_alert_channel` (destructive) | **write, two-step** | `sites:write` |
101
+ | `list_export_schedules` | read | `logs:export` |
102
+ | `create_export_schedule` · `update_export_schedule` · `delete_export_schedule` (destructive) | **write, two-step** | `logs:export` |
103
+
104
+ Site, service, template, webhook and channel arguments accept an id **or a name** (a domain for
105
+ sites, a URL for webhooks); the tool says what it matched. Deletes carry `destructiveHint` so an
106
+ agent host can ask first.
107
+
108
+ The table lives in [`src/matrix.ts`](src/matrix.ts). `src/tools.ts` and `src/tools-config.ts` build
109
+ their tool definitions from it and the dashboard renders it, so the three can never disagree.
110
+
111
+ ## Resources
112
+
113
+ * `cookiecrumbs://sites/<id>/config` — the draft configuration with its legal lint
114
+ * `cookiecrumbs://sites/<id>/declaration` — the current cookie declaration as Markdown
115
+ * `cookiecrumbs://sites/<id>/issues` — open compliance issues with their rules
116
+
117
+ `<id>` accepts a site id, a primary domain or a site name.
118
+
119
+ ## The rules the server keeps
120
+
121
+ **Every write is two-step.** The first call — `confirm: false`, which is the default — returns a
122
+ unified diff of what would change (plus the legal lint where a banner config is involved), and
123
+ changes nothing. Only `confirm: true` writes. A confirmed `push_config` also requires a `note`, which
124
+ goes into the version and into the audit trail. Plan limits answer with the feature they need (402),
125
+ and a value the plan clamped (a daily scan cadence on a monthly plan) is reported in the answer,
126
+ never silently applied.
127
+
128
+ **Nothing subject-level leaves the API.** `logs_export` returns an export id and the signed download
129
+ URL. It never streams consent records into a conversation. `logs_summary` reads daily roll-ups only.
130
+
131
+ **Classifications are quoted, not guessed.** `explain_classification` answers with the
132
+ `tracker_patterns` row that matched, the `tracker_db` entry behind it — including its **source** and
133
+ its **licence** — and the regime rule that follows from the category. If the finding is unclassified it
134
+ says exactly that, and offers nothing else.
135
+
136
+ **Citations are real or absent.** The rules reference (`@cookiecrumbs/config/rules`) carries verbatim
137
+ wording only where it was verified against the primary source; everything else carries a summary in our
138
+ own words and `quote: null`. Nothing the server returns is a legal assessment, and every citation block
139
+ says so.
140
+
141
+ **Every request is attributable.** `X-CookieCrumbs-Client: mcp/<client>` goes out on every call — the
142
+ client name comes from `MCP_CLIENT` or from the MCP `initialize` params — so the gateway records
143
+ `channel = 'mcp'` and the audit row reads *via MCP · &lt;token name&gt;*.
144
+
145
+ ## Example
146
+
147
+ ```
148
+ > why is _fbp classified as Marketing on e2e.cookiecrumbs.test?
149
+
150
+ # http_cookie `_fbp` on e2e.cookiecrumbs.test (first party)
151
+ ## Category: marketing
152
+ Decided by: service (classification_source `service`, confidence 1)
153
+ ## The tracker database row this came from
154
+ provider: Meta Pixel (facebook.com)
155
+ source: cookiecrumbs
156
+ licence: CC0
157
+ Consent Mode: ad_storage, ad_user_data, ad_personalization
158
+ ## The pattern that matched
159
+ tracker_patterns row …: kind=cookie_name, pattern=`_fbp`, match_type=exact
160
+ ## The regime rule applied
161
+ Under an opt-in regime … nothing in category "marketing" may be stored or read before
162
+ the visitor gives consent …
163
+
164
+ Citations:
165
+ • Directive 2002/58/EC (ePrivacy Directive), Article 5(3) — “Member States shall ensure that …”
166
+ • EDPB, Cookie Banner Taskforce report (para. 24) — “… the legal basis for the placement/reading
167
+ of cookies pursuant to Article 5 (3) cannot be the legitimate interests of the controller.”
168
+
169
+ This is not a legal assessment.
170
+ ```
171
+
172
+ ## Known gaps
173
+
174
+ * `classify_tracker` with `confirm: true` calls `PATCH /v1/services/:id`. Where a gateway does not
175
+ expose that route yet, the dry run still works and the confirmed call answers with what to do
176
+ instead of a raw error.
177
+ * `logs_summary` calls `GET /v1/sites/:id/analytics/daily` and needs the `analytics:read` scope. Where
178
+ the route or the scope is not deployed, the tool is simply not advertised, or answers that nothing
179
+ was read.
180
+
181
+ ## Development
182
+
183
+ ```bash
184
+ npm run build -w @cookiecrumbs-eu/mcp # tsc → dist/mcp/src/index.js (the bin)
185
+ npm test -w @cookiecrumbs-eu/mcp # tool registration by scope, diff rendering, error mapping
186
+ ```
@@ -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
+ }