@snagset/server 0.0.1

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 (105) hide show
  1. package/README.md +83 -0
  2. package/dist/app.js +453 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/bin.js +951 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/boot.js +321 -0
  7. package/dist/boot.js.map +1 -0
  8. package/dist/config.generated.js +116 -0
  9. package/dist/config.generated.js.map +1 -0
  10. package/dist/config.js +180 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/crypto.js +26 -0
  13. package/dist/crypto.js.map +1 -0
  14. package/dist/db/connect.js +49 -0
  15. package/dist/db/connect.js.map +1 -0
  16. package/dist/db/migrate.js +210 -0
  17. package/dist/db/migrate.js.map +1 -0
  18. package/dist/db/migrations/0001_init.sql +1060 -0
  19. package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
  20. package/dist/db/migrations/0003_email_provider.sql +35 -0
  21. package/dist/db/migrations/0004_unsubscribe.sql +24 -0
  22. package/dist/db/rls.js +58 -0
  23. package/dist/db/rls.js.map +1 -0
  24. package/dist/jobs/index.js +171 -0
  25. package/dist/jobs/index.js.map +1 -0
  26. package/dist/jobs/reaper.js +182 -0
  27. package/dist/jobs/reaper.js.map +1 -0
  28. package/dist/keys.js +77 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/mail/index.js +75 -0
  31. package/dist/mail/index.js.map +1 -0
  32. package/dist/mail/mailer.js +27 -0
  33. package/dist/mail/mailer.js.map +1 -0
  34. package/dist/mail/notify.js +405 -0
  35. package/dist/mail/notify.js.map +1 -0
  36. package/dist/mail/resend.js +95 -0
  37. package/dist/mail/resend.js.map +1 -0
  38. package/dist/mail/smtp.js +315 -0
  39. package/dist/mail/smtp.js.map +1 -0
  40. package/dist/mail/unsubscribe.js +134 -0
  41. package/dist/mail/unsubscribe.js.map +1 -0
  42. package/dist/projectCache.js +50 -0
  43. package/dist/projectCache.js.map +1 -0
  44. package/dist/projects.js +101 -0
  45. package/dist/projects.js.map +1 -0
  46. package/dist/routes/attachments.js +159 -0
  47. package/dist/routes/attachments.js.map +1 -0
  48. package/dist/routes/auth.js +55 -0
  49. package/dist/routes/auth.js.map +1 -0
  50. package/dist/routes/commentEdit.js +201 -0
  51. package/dist/routes/commentEdit.js.map +1 -0
  52. package/dist/routes/comments.js +313 -0
  53. package/dist/routes/comments.js.map +1 -0
  54. package/dist/routes/cors.js +174 -0
  55. package/dist/routes/cors.js.map +1 -0
  56. package/dist/routes/jobs.js +56 -0
  57. package/dist/routes/jobs.js.map +1 -0
  58. package/dist/routes/mcpAuth.js +187 -0
  59. package/dist/routes/mcpAuth.js.map +1 -0
  60. package/dist/routes/mcpToken.js +94 -0
  61. package/dist/routes/mcpToken.js.map +1 -0
  62. package/dist/routes/owner.js +288 -0
  63. package/dist/routes/owner.js.map +1 -0
  64. package/dist/routes/reviewLinks.js +169 -0
  65. package/dist/routes/reviewLinks.js.map +1 -0
  66. package/dist/routes/rounds.js +137 -0
  67. package/dist/routes/rounds.js.map +1 -0
  68. package/dist/routes/sessions.js +349 -0
  69. package/dist/routes/sessions.js.map +1 -0
  70. package/dist/routes/sites.js +192 -0
  71. package/dist/routes/sites.js.map +1 -0
  72. package/dist/routes/threads.js +1080 -0
  73. package/dist/routes/threads.js.map +1 -0
  74. package/dist/routes/widget.js +132 -0
  75. package/dist/routes/widget.js.map +1 -0
  76. package/dist/serverless.js +97 -0
  77. package/dist/serverless.js.map +1 -0
  78. package/dist/session.js +152 -0
  79. package/dist/session.js.map +1 -0
  80. package/dist/storage/bucket.js +116 -0
  81. package/dist/storage/bucket.js.map +1 -0
  82. package/dist/storage/settings.js +132 -0
  83. package/dist/storage/settings.js.map +1 -0
  84. package/dist/vendor/adapters/index.js +1 -0
  85. package/dist/vendor/adapters/outbound/fence.js +44 -0
  86. package/dist/vendor/adapters/outbound/sanitise.js +233 -0
  87. package/dist/vendor/types/index.js +4 -0
  88. package/dist/vendor/types/names.js +254 -0
  89. package/dist/vendor/widget/object.js +38 -0
  90. package/dist/vendor/widget/predicates.js +377 -0
  91. package/package.json +53 -0
  92. package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
  93. package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
  94. package/static/dashboard/index.html +14 -0
  95. package/static/widget/snag.anchor-ext.js +1 -0
  96. package/static/widget/snag.anchor-fuzzy.js +1 -0
  97. package/static/widget/snag.capture.js +4 -0
  98. package/static/widget/snag.crumbs.js +4 -0
  99. package/static/widget/snag.dock.js +5 -0
  100. package/static/widget/snag.js +5 -0
  101. package/static/widget/snag.panels.js +2 -0
  102. package/static/widget/snag.runtime.js +2 -0
  103. package/static/widget/snag.screenshot.js +1 -0
  104. package/static/widget/snag.text.js +2 -0
  105. package/vercel.json +6 -0
@@ -0,0 +1,254 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // GENERATED FILE — DO NOT EDIT BY HAND.
3
+ // Source of truth: packages/types/registry.json
4
+ // Regenerate: pnpm gen:names (CI runs `pnpm gen:names:check`)
5
+ //
6
+ // The Name Registry (specs/00-foundations.md §2) made executable. Every token
7
+ // below is DERIVED from registry.json's `brand` block — the product name lives
8
+ // in exactly one place. test/rename.test.ts proves it: flip the brand, and no
9
+ // token of the old one survives. SCHEMA_VERSION is the one deliberate literal.
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ /** The one place the product name exists. Everything else derives from this. */
12
+ export const BRAND = {
13
+ $comment: "The ONLY place the product name appears. `long` = display/global name. `short` = the terse prefix that goes on attributes, classes, params, storage keys and artefact filenames (a client's DOM should not carry a long word). `pkg` = the npm/lowercase form used for env vars, headers, metrics, DOM events and symbols.",
14
+ long: "Snagset",
15
+ short: "snag",
16
+ pkg: "snagset",
17
+ };
18
+ export const PRODUCT = "Snagset";
19
+ export const NPM_PACKAGE = "snagset";
20
+ export const NPM_SCOPE = "@snagset";
21
+ /** Frozen once v1 stores real data — stamped into exports, issue bodies and the MCP contract, so it must survive a rename (registry.frozen). */
22
+ export const SCHEMA_VERSION = "snagset.v1";
23
+ export const CSS_VAR_PREFIX = "--snag-";
24
+ export const HTTP_HEADER_PREFIX = "x-snagset-";
25
+ export const ENV_PREFIX = "SNAGSET_";
26
+ export const PG_TYPE_PREFIX = "snag_";
27
+ export const METRIC_PREFIX = "snagset_";
28
+ export const DOM_EVENT_PREFIX = "snagset:";
29
+ export const SYMBOL_PREFIX = "snagset.";
30
+ export const MENTION_SCHEME = "snag:user/";
31
+ export const SKIP_HEADER = "x-snagset-skip";
32
+ export const THREAD_REF_PREFIX = "SN-";
33
+ export const TERM_SET = "Snagset";
34
+ export const TERM_SET_PLURAL = "Snagsets";
35
+ export const TERM_ITEM = "Snag";
36
+ export const TERM_ITEM_PLURAL = "Snags";
37
+ export const RESERVED_CUSTOM_PREFIX = "cr.";
38
+ /** @returns e.g. `x-snagset-idempotency-key` */
39
+ export const header = (name) => HTTP_HEADER_PREFIX + name;
40
+ /** @returns e.g. `SNAGSET_SESSION_TTL_S` */
41
+ export const env = (name) => ENV_PREFIX + name;
42
+ /** @returns e.g. `snag_attachment_kind` */
43
+ export const pgType = (name) => PG_TYPE_PREFIX + name;
44
+ /** @returns e.g. `snagset_http_requests_total` */
45
+ export const metric = (name) => METRIC_PREFIX + name;
46
+ /** window.* names (§2.2). Dunders are internal/test affordances, not public API. */
47
+ export const GLOBALS = {
48
+ api: "Snagset",
49
+ config: "SnagsetConfig",
50
+ disabled: "__SNAGSET_DISABLED__",
51
+ assetBase: "__SNAGSET_ASSET_BASE__",
52
+ collisionFallback: "__snagset__",
53
+ };
54
+ const scriptAttrToken = {
55
+ site: "data-snag-site",
56
+ host: "data-snag-host",
57
+ disabled: "data-snag-disabled",
58
+ env: "data-snag-env",
59
+ mode: "data-snag-mode",
60
+ badge: "data-snag-badge",
61
+ lang: "data-snag-lang",
62
+ theme: "data-snag-theme",
63
+ position: "data-snag-position",
64
+ offset: "data-snag-offset",
65
+ assetBase: "data-snag-asset-base",
66
+ branch: "data-snag-branch",
67
+ build: "data-snag-build",
68
+ storage: "data-snag-storage",
69
+ hotkey: "data-snag-hotkey",
70
+ allowAutomation: "data-snag-allow-automation",
71
+ screenshots: "data-snag-screenshots",
72
+ maskSelectors: "data-snag-mask-selectors",
73
+ nonce: "data-snag-nonce",
74
+ autoMount: "data-snag-auto-mount",
75
+ };
76
+ export const scriptAttr = (name) => scriptAttrToken[name];
77
+ const widgetAttrToken = {
78
+ root: "data-snag-root",
79
+ };
80
+ export const widgetAttr = (name) => widgetAttrToken[name];
81
+ const hostAttrToken = {
82
+ anchor: "data-snag-anchor",
83
+ ignore: "data-snag-ignore",
84
+ mask: "data-snag-mask",
85
+ nomask: "data-snag-nomask",
86
+ src: "data-snag-src",
87
+ component: "data-snag-component",
88
+ };
89
+ export const hostAttr = (name) => hostAttrToken[name];
90
+ const cssClassToken = {
91
+ mask: "snag-mask",
92
+ block: "snag-block",
93
+ };
94
+ export const cssClass = (name) => cssClassToken[name];
95
+ /** Custom properties (§2.1's prefix, enumerated). 06 §14 makes every one of these public SemVer surface, so they are registered rather than minted at the point of use. Values are NOT here — the registry governs names, never design. */
96
+ export const CSS_VAR = {
97
+ font: "--snag-font",
98
+ accent: "--snag-accent",
99
+ onAccent: "--snag-onAccent",
100
+ bg: "--snag-bg",
101
+ fg: "--snag-fg",
102
+ muted: "--snag-muted",
103
+ border: "--snag-border",
104
+ radius: "--snag-radius",
105
+ shadow: "--snag-shadow",
106
+ z: "--snag-z",
107
+ motion: "--snag-motion",
108
+ surface: "--snag-surface",
109
+ sunken: "--snag-sunken",
110
+ hover: "--snag-hover",
111
+ focus: "--snag-focus",
112
+ disabled: "--snag-disabled",
113
+ statusOpen: "--snag-status-open",
114
+ statusProgress: "--snag-status-progress",
115
+ statusReview: "--snag-status-review",
116
+ statusResolved: "--snag-status-resolved",
117
+ anchorOrphan: "--snag-anchor-orphan",
118
+ tbH: "--snag-tb-h",
119
+ dockW: "--snag-dock-w",
120
+ kb: "--snag-kb",
121
+ thumbX: "--snag-thumb-x",
122
+ thumbW: "--snag-thumb-w",
123
+ };
124
+ /** Which tokens belong to which role — the dark block must override theme members only. */
125
+ export const CSS_VAR_GROUPS = {
126
+ theme: ["font", "accent", "onAccent", "bg", "fg", "muted", "border", "radius", "shadow", "z", "motion"],
127
+ derived: ["surface", "sunken", "hover", "focus", "disabled"],
128
+ status: ["statusOpen", "statusProgress", "statusReview", "statusResolved"],
129
+ anchor: ["anchorOrphan"],
130
+ geometry: ["tbH", "dockW", "kb", "thumbX", "thumbW"],
131
+ };
132
+ /** @returns e.g. `--snag-bg` */
133
+ export const cssVar = (name) => CSS_VAR[name];
134
+ /** @returns e.g. `var(--snag-bg)` — what stylesheets actually interpolate. */
135
+ export const cssRef = (name) => `var(${CSS_VAR[name]})`;
136
+ /** @returns e.g. `snag_t`. The review-link secret is a FRAGMENT param (§2.5). */
137
+ export const param = (name) => "snag_" + name;
138
+ export const RESERVED_URL_PARAMS = {
139
+ query: ["snag_t", "snag_off", "snag_debug", "snag_theme"],
140
+ fragment: ["snag_k"],
141
+ };
142
+ /** Point consts for hot paths: a chunk needing ONE key imports it and
143
+ * drops the whole table — object properties cannot tree-shake, and the
144
+ * panels chunk was carrying ten keys to read two (M2-13). */
145
+ export const DISCLOSURE_KEY = "snag:disclosure:v1";
146
+ export const IDENTITY_KEY = "snag:identity";
147
+ export const THEME_ATTR = "data-snag-theme";
148
+ /** Web-storage keys (§2.6). Parameterised keys are functions. */
149
+ export const storageKey = {
150
+ session: (uid) => `snag:session:${uid}`,
151
+ identity: IDENTITY_KEY,
152
+ threads: (uid, env) => `snag:threads:${uid}:${env}`,
153
+ counts: (origin) => `snag:counts:${origin}`,
154
+ draft: (route, draftId) => `snag:draft:${route}:${draftId}`,
155
+ outbox: (uid) => `snag:outbox:${uid}`,
156
+ mode: "snag:mode",
157
+ handoff: "snag:handoff",
158
+ seenCoach: "snag:seen-coach",
159
+ disclosure: DISCLOSURE_KEY,
160
+ };
161
+ /** The v0.1 IndexedDB blob store (§2.6, 05 §10.1) — one database, one
162
+ * object store, shared by screenshots, reviewer images and the v2 outbox. */
163
+ export const IDB = {
164
+ database: "snag:blobs",
165
+ store: "blobs",
166
+ };
167
+ /** Anchored regex sources matching every registered key — AC-22's storage diff. */
168
+ export const STORAGE_KEY_PATTERNS = [
169
+ "^snag:session:.+$",
170
+ "^snag:identity$",
171
+ "^snag:threads:.+:.+$",
172
+ "^snag:counts:.+$",
173
+ "^snag:draft:.+:.+$",
174
+ "^snag:outbox:.+$",
175
+ "^snag:mode$",
176
+ "^snag:handoff$",
177
+ "^snag:seen-coach$",
178
+ "^snag:disclosure:v1$",
179
+ ];
180
+ /** The WIDGET's two (§2.7). Neither carries a credential, and no cookie authenticates the widget API — it runs on somebody else's origin. */
181
+ export const COOKIE = {
182
+ off: "snag_off",
183
+ on: "snag_on",
184
+ };
185
+ /** The dashboard's session + CSRF pair (05 §4.7) — first-party, on the server's own origin, owner audience. */
186
+ export const DASHBOARD_COOKIE = {
187
+ session: "snag_session",
188
+ csrf: "snag_csrf",
189
+ };
190
+ /** 05 §4.7 names these two constants directly; both spellings resolve to the same string. */
191
+ export const DASHBOARD_SESSION_COOKIE = DASHBOARD_COOKIE.session;
192
+ export const DASHBOARD_CSRF_COOKIE = DASHBOARD_COOKIE.csrf;
193
+ /** Built widget chunks (§2.8). Preact lives only in `panels`. */
194
+ export const ARTIFACT = {
195
+ stub: "snag.js",
196
+ runtime: "snag.runtime.js",
197
+ panels: "snag.panels.js",
198
+ crumbs: "snag.crumbs.js",
199
+ capture: "snag.capture.js",
200
+ screenshot: "snag.screenshot.js",
201
+ anchorExt: "snag.anchor-ext.js",
202
+ anchorFuzzy: "snag.anchor-fuzzy.js",
203
+ dock: "snag.dock.js",
204
+ text: "snag.text.js",
205
+ };
206
+ /** Wire-form entity id prefixes (§2.10). Not brand-derived. */
207
+ export const ID_PREFIX = {
208
+ project: "prj_",
209
+ thread: "thr_",
210
+ comment: "cmt_",
211
+ reviewer: "rev_",
212
+ attachment: "att_",
213
+ link: "lnk_",
214
+ round: "rnd_",
215
+ };
216
+ /** Credential literals (07 §8.2). Brand-DERIVED, and deliberately not the three-letter row shape: the literal is what gets registered with secret scanning, so a token pasted into a public repo is reported. */
217
+ export const CREDENTIAL_PREFIX = {
218
+ mcp: "snagmcp_",
219
+ };
220
+ /** CustomEvents dispatched on document (§2.11). */
221
+ export const DOM_EVENT = {
222
+ ready: "snagset:ready",
223
+ activate: "snagset:activate",
224
+ deactivate: "snagset:deactivate",
225
+ threadCreated: "snagset:thread-created",
226
+ threadResolved: "snagset:thread-resolved",
227
+ };
228
+ /** Request/response header names inside §2.1's prefix (05 §2.3). */
229
+ export const HTTP_HEADER = {
230
+ client: "x-snagset-client",
231
+ schema: "x-snagset-schema",
232
+ idempotencyKey: "x-snagset-idempotency-key",
233
+ identity: "x-snagset-identity",
234
+ csrf: "x-snagset-csrf",
235
+ attachmentKind: "x-snagset-attachment-kind",
236
+ attachmentWidth: "x-snagset-attachment-width",
237
+ attachmentHeight: "x-snagset-attachment-height",
238
+ server: "x-snagset-server",
239
+ minClient: "x-snagset-min-client",
240
+ time: "x-snagset-time",
241
+ configVersion: "x-snagset-config-version",
242
+ idempotentReplay: "x-snagset-idempotent-replay",
243
+ requestId: "x-snagset-request-id",
244
+ };
245
+ /** Host-authored `<meta name>` keys we READ and never mint (§2.11). 03 §5.3: `route` is the only one. */
246
+ export const META_NAME = {
247
+ route: "snagset:route",
248
+ };
249
+ /** Keys for Symbol.for() patch bookkeeping (§2.11). */
250
+ export const SYMBOL_KEY = {
251
+ original: "snagset.original",
252
+ wrapped: "snagset.wrapped",
253
+ instance: "snagset.instance",
254
+ };
@@ -0,0 +1,38 @@
1
+ import { isSensitiveKey, redactText, redactUrl } from "./predicates.js";
2
+ /**
3
+ * `redactObject` — 04 §4.5's rule for `Breadcrumb.data` (v0.1) and, later,
4
+ * `CaptureContext.custom` (v1): **key-first, then `redactText` on every leaf,
5
+ * URL-shaped leaves via `redactUrl`**.
6
+ *
7
+ * Key-first matters because the value under a bad key is often innocent-LOOKING
8
+ * — `{ password: "hunter2" }` has a value no shape rule would flag, and the key
9
+ * is the only evidence. When the key condemns it, the whole value goes,
10
+ * whatever its type: an object under `credentials:` is not walked for salvage.
11
+ *
12
+ * A SEPARATE FILE from predicates.ts, deliberately: the runtime re-exports the
13
+ * three predicate functions as resident surface (D48 residency), and keeping
14
+ * the walker out of that module means its bytes can never ride into the
15
+ * activation envelope on the strength of a re-export. Chunks that need it —
16
+ * crumbs, capture — bundle it from this one source.
17
+ *
18
+ * Depth and size discipline belongs to the SERIALIZER (04 §3.6b) — by the time
19
+ * data reaches here it is already bounded. The walker still carries its own
20
+ * depth stop so a caller who skips the serializer cannot make it spin.
21
+ */
22
+ const URLISH = /^https?:\/\//i;
23
+ const MARK = "[redacted]";
24
+ export function redactObject(v, depth = 4) {
25
+ if (typeof v === "string")
26
+ return URLISH.test(v) ? redactUrl(v) : redactText(v);
27
+ if (v === null || typeof v !== "object")
28
+ return v; // numbers, booleans, undefined pass
29
+ if (depth <= 0)
30
+ return MARK; // deeper than the serializer allows — refuse, don't trust
31
+ if (Array.isArray(v))
32
+ return v.map((item) => redactObject(item, depth - 1));
33
+ const out = {};
34
+ for (const [k, val] of Object.entries(v)) {
35
+ out[k] = isSensitiveKey(k) ? MARK : redactObject(val, depth - 1);
36
+ }
37
+ return out;
38
+ }
@@ -0,0 +1,377 @@
1
+ import { RESERVED_URL_PARAMS } from "../types/names.js";
2
+ /**
3
+ * The redaction module (04 §4).
4
+ *
5
+ * 04 §4.1: "Redaction is not optional and cannot be disabled — only *extended*.
6
+ * There is no `redact: false`." v0 ships `redactText`, `redactUrl`, every shape
7
+ * rule in §4.3 and `isSensitiveValue` — because `CaptureContext.url` needs the
8
+ * first two and 03's selector generation needs the third. `redactObject` and the
9
+ * record-time pipeline arrive with breadcrumbs in v0.1.
10
+ *
11
+ * ── Why this file lives in the RUNTIME chunk ──
12
+ *
13
+ * 04 §1 says the module lives in `snag.capture.js`. It cannot at v0: 06 §1.1
14
+ * tags that artefact v0.1 so it does not exist, AC-P15 forbids activation
15
+ * fetching any capture chunk, and 03's `createElementAnchor` calls
16
+ * `isSensitiveValue` SYNCHRONOUSLY at pin time so it cannot await an import.
17
+ * plan/PLAN.md §1.4 rules on it: "One copy, in snag.runtime.js; snag.capture.js
18
+ * imports from it." The spec edit that records the inversion is still owed.
19
+ *
20
+ * ── The one thing to know before editing ──
21
+ *
22
+ * Every constant below carries `/g`, and `RegExp.prototype.test` on a global
23
+ * regex is STATEFUL — it advances `lastIndex` and the next call starts from
24
+ * there. `isSensitiveValue` is called once per candidate attribute per element,
25
+ * so a naive `EMAIL.test(v)` returns true, then false, then true on identical
26
+ * input. `hits()` exists for exactly that reason.
27
+ */
28
+ /**
29
+ * The key vocabulary, byte-for-byte from 00 §9.
30
+ *
31
+ * 04 §4.3: "if the two ever differ, 00 wins." `pin` and `sig` are DELIBERATELY
32
+ * absent — 00 §9 gives the reason, and adding them is not a fix.
33
+ */
34
+ const KEY = `pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|auth(?:orization)?|` +
35
+ `credential|session|sid|cookie|jwt|bearer|otp|mfa|totp|cvv|cvc|ssn|nino|iban|` +
36
+ `card(?:[_-]?number)?|account[_-]?number|private[_-]?key|signature|` +
37
+ `client[_-]?secret|refresh[_-]?token|access[_-]?token`;
38
+ /**
39
+ * key = value, in any of the shapes real payloads use. 00 §9, verbatim.
40
+ *
41
+ * THE LEADING BOUNDARY IS A LOOKBEHIND, NOT `\b`. `_` is a word character, so
42
+ * `\b` finds no boundary between the `B` and the `P` of `DB_PASSWORD` and the
43
+ * whole snake_case convention was invisible to this rule: `db_password=…`,
44
+ * `oauth_token=…`, `csrf_token=…`, `app_secret=…` and every SCREAMING_SNAKE
45
+ * environment variable came back byte-identical, while the bare `password=…`
46
+ * and the hyphenated `X-API-KEY=…` were redacted correctly. `refresh_token`,
47
+ * `access_token`, `client_secret`, `api_key`, `private_key`, `card_number` and
48
+ * `account_number` escaped only because KEY happens to spell those exact
49
+ * compounds out. `(?<![A-Za-z0-9])` allows a preceding `_`, `-`, `.`, quote,
50
+ * space or start-of-string and still refuses a letter or digit, so `mypassword`
51
+ * stays unmatched exactly as `\b` intended.
52
+ *
53
+ * Group 1 is the optional quote BEFORE the key; group 2 is the value's opening
54
+ * quote; group 3 the quoted value body; group 4 the unquoted value. The only
55
+ * backreference in the whole rule set is this one's `\2`, which is deliberate:
56
+ * 04 §4.3 makes catastrophic backtracking a hard review criterion, because it
57
+ * is a denial of service on the reviewer's own main thread.
58
+ */
59
+ const KV = new RegExp(`(["'\`]?)(?<![A-Za-z0-9])(?:${KEY})[\\w-]*\\b\\1?\\s*[:=]\\s*` +
60
+ `(?:(["'\`])((?:\\\\.|(?!\\2)[^\\\\])*)\\2|([^\\s,;&}\\])]{3,}))`, "gi");
61
+ /** The auth schemes AUTH_SCHEME knows. Used by the carve-out below. */
62
+ const SCHEME_WORD = /^(?:Bearer|Basic|Digest|Negotiate|Token)$/i;
63
+ /**
64
+ * Replace the VALUE and keep everything else — the key, the separator, the
65
+ * whitespace and the quotes. A redaction that ate the key name would destroy
66
+ * the one part of the pair that is diagnostic rather than secret.
67
+ *
68
+ * THE CARVE-OUT, and the leak that forced it. KV's unquoted-value branch stops
69
+ * at whitespace, so on the single most canonical secret in existence —
70
+ *
71
+ * Authorization: Bearer abcdefgh12345
72
+ *
73
+ * — it matched the key `Authorization`, took `Bearer` as the whole value, and
74
+ * emitted `Authorization: [redacted] abcdefgh12345`. The word was redacted and
75
+ * THE TOKEN WAS PUBLISHED. AUTH_SCHEME could no longer help, because the scheme
76
+ * it keys on had just been replaced. Found by running the rules rather than
77
+ * reading them, which is what 00 §9's own preamble asks for.
78
+ *
79
+ * So AUTH_SCHEME runs first (see redactText), and KV then leaves a scheme word
80
+ * alone *only when the credential after it is already gone*. Both halves are
81
+ * needed: skipping unconditionally would let `Bearer abc` — too short for
82
+ * AUTH_SCHEME's 8-char floor — survive untouched, which is worse than the bug.
83
+ */
84
+ const kvReplace = (m, _q0, q, qv, uv, offset, whole) => {
85
+ if (!q && SCHEME_WORD.test(uv) && whole.slice(offset + m.length).startsWith(" [redacted]")) {
86
+ return m;
87
+ }
88
+ /**
89
+ * IDEMPOTENCE. The unquoted-value class `[^\s,;&}\])]{3,}` excludes `]`, so
90
+ * against an already-redacted `token=[redacted]` it matches `[redacted`,
91
+ * substitutes `[redacted]`, and strands the original bracket — producing
92
+ * `token=[redacted]]`. Found end-to-end: `redactUrl` redacts structurally and
93
+ * then runs the text rules over its own output, so this fired on every
94
+ * captured URL with a query string. A redaction that corrupts its own marker
95
+ * reads as a bug in the export rather than as a redaction.
96
+ */
97
+ if ((q ? qv : uv).startsWith("[redacted"))
98
+ return m;
99
+ const val = q ? `${q}${qv}${q}` : uv;
100
+ const head = m.slice(0, m.length - val.length);
101
+ return head + (q ? `${q}[redacted]${q}` : "[redacted]");
102
+ };
103
+ // The shape rules (04 §4.3), verbatim.
104
+ const DATA_URL = /data:[a-z0-9.+-]*\/?[a-z0-9.+-]*;base64,[A-Za-z0-9+/=]{16,}/gi;
105
+ const JWT = /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{0,}/g;
106
+ const KEY_PREFIX = /\b(sk-[A-Za-z0-9]+-[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|(sk|pk|rk)_(live|test)_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|xapp-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|ya29\.[A-Za-z0-9_-]{20,}|SG\.[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{30,}|dop_v1_[a-f0-9]{40,}|shpat_[a-f0-9]{30,}|sbp_[a-f0-9]{30,}|hf_[A-Za-z0-9]{30,}|nvapi-[A-Za-z0-9_-]{20,})\b/g;
107
+ const AUTH_SCHEME = /\b(Bearer|Basic|Digest|Negotiate|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
108
+ const EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z]{2,24})+\b/g;
109
+ const CARD_CAND = /\b(?:\d[ -]?){12,18}\d\b/g;
110
+ const B64 = /\b(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[A-Z])(?=[A-Za-z0-9+/]*\d)[A-Za-z0-9+/]{40,}={0,2}\b/g;
111
+ const HEX = /\b[0-9a-f]{32,}\b/gi;
112
+ /**
113
+ * EXTENSIONS. 04 §4.1: redaction "cannot be disabled — only *extended*", which
114
+ * makes adding a rule the sanctioned move and rewriting a spec-verbatim one the
115
+ * unsanctioned one. Every constant above is copied from 00 §9 / 04 §4.3 and
116
+ * stays untouched; each rule below closes a hole an adversarial pass found by
117
+ * running the module, with the shape that got through named in its comment.
118
+ */
119
+ /**
120
+ * Base64URL. B64's class is `[A-Za-z0-9+/]`, verbatim from 00 §9 — so a single
121
+ * `_` or `-` chops a token into runs under the 40-char floor and B64 never
122
+ * fires at all. A Cloudflare API token is exactly 40 characters of
123
+ * `[A-Za-z0-9_-]` with no vendor prefix for KEY_PREFIX to key on, and walked
124
+ * through untouched.
125
+ */
126
+ const B64URL = /\b(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{40,}\b/g;
127
+ /**
128
+ * `vendor_prefix_PAYLOAD` — the shape KEY_PREFIX cannot enumerate.
129
+ *
130
+ * KEY_PREFIX lists vendors one by one, so every provider not on the list ships
131
+ * whole: Docker Hub PATs, Stripe webhook signing secrets, Supabase service
132
+ * keys, Netlify and RubyGems tokens. Their payloads are short enough to sit
133
+ * under B64's 40-char floor, so nothing else catches them either. The two
134
+ * lookaheads — payload must contain BOTH an uppercase letter and a digit — are
135
+ * what keep this off ordinary snake_case identifiers.
136
+ */
137
+ const VENDOR_TOKEN = /\b[a-z][a-z0-9]{1,15}(?:_[a-z0-9]{1,15}){0,2}_(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{24,}\b/g;
138
+ /**
139
+ * Credentials in a URL authority: `scheme://user:pass@host`.
140
+ *
141
+ * EMAIL does not cover it — it requires a dotted TLD after the `@`, so a bare
142
+ * hostname, an IP or a `+` in the scheme all slip past. `redactUrl` strips
143
+ * these structurally, but a URL sitting inside free text never reaches that
144
+ * path, and neither does `isSensitiveValue`.
145
+ */
146
+ const URL_CRED = /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/gi;
147
+ /**
148
+ * A whole value that IS one unbroken high-entropy token, whatever vendor minted
149
+ * it (used by `isSensitiveValue` only).
150
+ *
151
+ * ANCHORED, deliberately: it must fire on a value that is a credential and
152
+ * never on prose that merely contains one, or 04 §4.8's negative corpus —
153
+ * `/orders/2024`, `4 items`, `cardNumber` — starts failing and 03 loses
154
+ * selectors it should have kept. Mixed case AND a digit AND 24+ characters is
155
+ * what separates an Azure client secret from `product-card-item-12345678`.
156
+ */
157
+ const OPAQUE = /^(?=[A-Za-z0-9_~.+/=-]*[a-z])(?=[A-Za-z0-9_~.+/=-]*[A-Z])(?=[A-Za-z0-9_~.+/=-]*\d)[A-Za-z0-9_~.+/=-]{24,}$/;
158
+ /**
159
+ * Does this string contain a match, without the caller inheriting `lastIndex`?
160
+ *
161
+ * `test()` on a `/g` regex mutates the regex. Resetting before each use is the
162
+ * cheap fix and keeps a single copy of every pattern, which AC C1c cares about:
163
+ * it greps for a SECOND definition of any of these shapes outside this
164
+ * directory, so cloning them per call would be the wrong kind of clever.
165
+ */
166
+ function hits(re, s) {
167
+ re.lastIndex = 0;
168
+ const found = re.test(s);
169
+ re.lastIndex = 0;
170
+ return found;
171
+ }
172
+ /**
173
+ * The Luhn checksum. 04 §4.3: "The Luhn gate is not optional and not forkable."
174
+ *
175
+ * Three mechanics the spec asserts without specifying, decided here and written
176
+ * down rather than left as folklore:
177
+ * · separators are stripped before the checksum, because CARD_CAND matches
178
+ * digits interleaved with single spaces or hyphens and a checksum over
179
+ * "4111 1111" is meaningless;
180
+ * · the length window is 13–19, the range of real PANs;
181
+ * · a candidate that FAILS is left entirely untouched — no retry on a shorter
182
+ * window inside the same digit run. A 16-digit order number that happens to
183
+ * sit next to a date should survive as itself.
184
+ */
185
+ function luhn(raw) {
186
+ const d = raw.replace(/[ -]/g, "");
187
+ if (d.length < 13 || d.length > 19)
188
+ return false;
189
+ let sum = 0;
190
+ let alt = false;
191
+ for (let i = d.length - 1; i >= 0; i--) {
192
+ let n = d.charCodeAt(i) - 48;
193
+ if (alt) {
194
+ n *= 2;
195
+ if (n > 9)
196
+ n -= 9;
197
+ }
198
+ sum += n;
199
+ alt = !alt;
200
+ }
201
+ return sum % 10 === 0;
202
+ }
203
+ /** CARD_CAND ∧ Luhn — the only card test anywhere. */
204
+ function cardHit(s) {
205
+ CARD_CAND.lastIndex = 0;
206
+ for (const m of s.matchAll(CARD_CAND))
207
+ if (luhn(m[0]))
208
+ return true;
209
+ return false;
210
+ }
211
+ /**
212
+ * Free text — messages, serialised data, accessible names (04 §4.2).
213
+ *
214
+ * ORDER IS NORMATIVE and the reason is stated in 04 §4.2: "broader patterns run
215
+ * later so they cannot swallow the input a narrower pattern would have labelled
216
+ * precisely." A JWT is base64-ish and hex-ish; if B64 ran first every token
217
+ * would come out as `[redacted:b64:210]` and the reviewer would lose the one
218
+ * word that told them what leaked. KV runs at position 2, before every shape
219
+ * rule, and both always run.
220
+ *
221
+ * No caps are applied here. §4.6's pre/post caps are PER FIELD and this
222
+ * signature carries no field identity — the caller applies them, which at v0
223
+ * means exactly one call site (`CaptureContext.url`).
224
+ */
225
+ export function redactText(s) {
226
+ let out = s;
227
+ out = out.replace(DATA_URL, "data:[redacted]"); // 1
228
+ // AUTH_SCHEME is MOVED AHEAD OF KV — 04 §4.2 numbers it 5. The letter of the
229
+ // table leaks (see kvReplace); its stated principle — "broader patterns run
230
+ // later so they cannot swallow the input a narrower pattern would have
231
+ // labelled precisely" — is exactly why it has to move, because KV is the
232
+ // broader of the two. Following the rule rather than the numbering.
233
+ //
234
+ // `$1 [redacted]`, NOT the literal "Bearer [redacted]" the table cell shows:
235
+ // the pattern captures the scheme, so `Basic …` must produce `Basic
236
+ // [redacted]`. The cell is an example.
237
+ out = out.replace(AUTH_SCHEME, "$1 [redacted]");
238
+ out = out.replace(KV, kvReplace); // 2
239
+ out = out.replace(JWT, "[redacted:jwt]"); // 3
240
+ out = out.replace(KEY_PREFIX, "[redacted:key]"); // 4
241
+ out = out.replace(VENDOR_TOKEN, "[redacted:key]"); // 4b — see EXTENSIONS
242
+ out = out.replace(URL_CRED, "[redacted:url-cred]@"); // 4c
243
+ out = out.replace(EMAIL, "[redacted:email]"); // 6
244
+ out = out.replace(CARD_CAND, (m) => (luhn(m) ? "[redacted:card]" : m)); // 7
245
+ // 8 and 9 carry the MATCHED length, which is the only length computable
246
+ // without decoding. It is fixture-visible output, so it is stated here.
247
+ out = out.replace(B64URL, (m) => `[redacted:b64:${m.length}]`); // 8b
248
+ out = out.replace(B64, (m) => `[redacted:b64:${m.length}]`);
249
+ out = out.replace(HEX, (m) => `[redacted:hex:${m.length}]`);
250
+ // 10 (UUID) is gated on `redact.redactUuids`, v0.1, default false.
251
+ // 11 (server-supplied patterns) is v1. Neither is reachable at v0.
252
+ return out;
253
+ }
254
+ /** Our own registered query parameters. Values are KEPT — see redactUrl. */
255
+ const OURS = RESERVED_URL_PARAMS.query;
256
+ /** The review-link secret (00 §2.5). It lives in the FRAGMENT and is redacted
257
+ * wherever it appears — the one parameter whose value is never displayable. */
258
+ const SECRET_PARAMS = RESERVED_URL_PARAMS.fragment;
259
+ /**
260
+ * A URL: a structured pass first, then `redactText` over the serialisation
261
+ * (04 §4.4).
262
+ *
263
+ * ALLOW-LIST, NOT DENY-LIST. Parameter *names* survive because the name alone is
264
+ * diagnostic; values do not. The default is empty and stays empty — 04 §4.4 is
265
+ * explicit that "the failure mode of a too-loose default (leaking a magic-link
266
+ * token in every captured URL) is catastrophic". A starter list worth
267
+ * documenting, never defaulting: page, tab, view, sort, filter, locale, lang.
268
+ *
269
+ * Our own parameters keep their values, because a report about the wrong
270
+ * permalink is unreadable without the permalink — except `snag_k`, which is the
271
+ * review-link secret and is always redacted.
272
+ *
273
+ * URL PATHS ARE NOT FULLY REDACTED (04 §6 item 4). Path segments get no
274
+ * structural treatment; they are covered only by the trailing `redactText`.
275
+ * That is deliberate and documented, not an oversight.
276
+ */
277
+ export function redactUrl(raw, allow = []) {
278
+ let u;
279
+ try {
280
+ // `globalThis.location`, reached through a cast, rather than the bare
281
+ // `location` global. The runtime guard was always right; the TYPE was
282
+ // browser-only, and 07 §2.6.2 requires this module to be usable
283
+ // server-side ("publish that module as an isomorphic export usable
284
+ // server-side, not only inside widget-core"). Under Node's lib the bare
285
+ // global does not exist to name, so the file did not compile there at all.
286
+ const here = globalThis.location?.href;
287
+ u = new URL(raw, here);
288
+ }
289
+ catch {
290
+ // Not parseable as a URL — it still goes through the text rules rather than
291
+ // through untouched. §4.7: nothing reaches a surface un-redacted.
292
+ return redactText(raw);
293
+ }
294
+ u.username = "";
295
+ u.password = "";
296
+ for (const k of [...u.searchParams.keys()]) {
297
+ // From the registry, not typed: `snag_k` is a registered name (00 §2.5) and
298
+ // a rename would have left this literal behind — so the ONE parameter that
299
+ // must always be redacted would quietly stop being.
300
+ if (SECRET_PARAMS.includes(k)) {
301
+ u.searchParams.set(k, "[redacted]");
302
+ continue;
303
+ }
304
+ if (OURS.includes(k))
305
+ continue;
306
+ if (!allow.includes(k)) {
307
+ u.searchParams.set(k, "[redacted]");
308
+ continue;
309
+ }
310
+ // AN ALLOWED PARAMETER IS NOT A LICENCE. The trailing text pass runs over
311
+ // the SERIALISED url, where the value is still percent-encoded — so
312
+ // `?filter=password%3DSuperSecret123` defeated KV outright, because `%3D`
313
+ // is not `=`. `searchParams.get` decodes, so checking here is the only
314
+ // place the rules see what the value actually says. If they recognise
315
+ // anything in it, the whole value goes: an operator allow-listing a
316
+ // parameter is saying its values are ordinarily safe, not promising it.
317
+ const decoded = u.searchParams.get(k) ?? "";
318
+ if (redactText(decoded) !== decoded)
319
+ u.searchParams.set(k, "[redacted]");
320
+ }
321
+ // The fragment is redacted WHOLE, not per key: it is where the review-link
322
+ // secret lives, and a per-key pass would leave its shape visible.
323
+ if (u.hash && u.hash.length > 1)
324
+ u.hash = allow.includes("#") ? u.hash : "#[redacted]";
325
+ // `URLSearchParams` serialises form-urlencoded, which escapes [ and ] — so
326
+ // `set(k, "[redacted]")` comes back out as `token=%5Bredacted%5D` and 04's own
327
+ // illustration (`?utm_source=[redacted]&token=[redacted]`) is unreachable.
328
+ // Un-escaping just our own marker is narrower than hand-building the query
329
+ // string and does not touch any value the host page owns.
330
+ return redactText(u.toString().replace(/%5Bredacted%5D/gi, "[redacted]"));
331
+ }
332
+ /**
333
+ * Should this attribute value stay out of a generated selector? (04 §4.8)
334
+ *
335
+ * THE SINGLE NORMATIVE DEFINITION — 03 imports this symbol rather than carrying
336
+ * its own copy, and AC C1c greps for a second one. The 120-char clause is 04's
337
+ * value; 03's 100 is deleted. The hex threshold is 32; 03's 24 is deleted.
338
+ *
339
+ * Composed from exactly seven shapes plus the length clause — deliberately NOT
340
+ * from KV, AUTH_SCHEME or UUID, which describe text that contains a secret
341
+ * rather than a value that IS one.
342
+ *
343
+ * 04 §4.8 is careful about what this does not do: "This does not make selectors
344
+ * private" — `a[href="/users/jane.doe/invoices"]` still passes the guard. It
345
+ * keeps credentials out of selectors, not personal data.
346
+ */
347
+ /**
348
+ * The key test for OBJECT keys — 04 §4.5's "key-first" step for
349
+ * `Breadcrumb.data` and (v1) `custom`. Same vocabulary, same boundary
350
+ * semantics as KV: the lookbehind is what lets `DB_PASSWORD` and the
351
+ * `[\w-]*` tail is what lets `refreshToken`/`token_count` match, exactly as
352
+ * the string form `db_password=…` would. One list, two syntaxes — a separate
353
+ * key list here is the fork 00 §14.2 forbids.
354
+ */
355
+ const KEY_NAME = new RegExp(`(?<![A-Za-z0-9])(?:${KEY})[\\w-]*$`, "i");
356
+ export function isSensitiveKey(k) {
357
+ return KEY_NAME.test(k);
358
+ }
359
+ export function isSensitiveValue(v) {
360
+ if (v.length > 120)
361
+ return true;
362
+ return (hits(EMAIL, v) ||
363
+ hits(JWT, v) ||
364
+ hits(KEY_PREFIX, v) ||
365
+ hits(HEX, v) ||
366
+ hits(B64, v) ||
367
+ hits(DATA_URL, v) ||
368
+ // The extensions. This predicate is the ENTIRE defence for AnchorRecord
369
+ // .label — 04 §4.5 gives that field redaction "none" precisely because this
370
+ // gate stands in front of it — so a shape that gets past here goes verbatim
371
+ // into storage, the dock, the Markdown export and any issue built from it.
372
+ hits(B64URL, v) ||
373
+ hits(VENDOR_TOKEN, v) ||
374
+ hits(URL_CRED, v) ||
375
+ OPAQUE.test(v) ||
376
+ cardHit(v));
377
+ }