@blamejs/core 0.8.28 → 0.8.30

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/CHANGELOG.md CHANGED
Binary file
package/index.js CHANGED
@@ -105,6 +105,7 @@ var secCyber = require("./lib/sec-cyber");
105
105
  var iabTcf = require("./lib/iab-tcf");
106
106
  var fapi2 = require("./lib/fapi2");
107
107
  var contentCredentials = require("./lib/content-credentials");
108
+ var aiPref = require("./lib/ai-pref");
108
109
  var safeUrl = require("./lib/safe-url");
109
110
  var safeRedirect = require("./lib/safe-redirect");
110
111
  var pick = require("./lib/pick");
@@ -297,6 +298,7 @@ module.exports = {
297
298
  iabTcf: iabTcf,
298
299
  fapi2: fapi2,
299
300
  contentCredentials: contentCredentials,
301
+ aiPref: aiPref,
300
302
  safeUrl: safeUrl,
301
303
  safeRedirect: safeRedirect,
302
304
  pick: pick,
package/lib/ai-pref.js ADDED
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ /**
3
+ * b.aiPref — IETF AIPREF Working Group Content-Usage HTTP response
4
+ * header + robots.txt grammar + Cloudflare Content Signals Policy +
5
+ * Pay-Per-Crawl (HTTP 402) coordination.
6
+ *
7
+ * IETF AIPREF (Authors / Information Providers' Preference for AI
8
+ * Use) draft-ietf-aipref-attach-04 (deadline ⏰ 2026-08) defines a
9
+ * machine-readable Content-Usage HTTP response header that signals
10
+ * the operator's AI-training / AI-inference / AI-snippet preferences
11
+ * to crawlers. Cloudflare's Content Signals Policy + Pay-Per-Crawl
12
+ * (HTTP 402) is the de-facto baseline that Cloudflare adopted ahead
13
+ * of the IETF spec finalizing.
14
+ *
15
+ * Public API:
16
+ *
17
+ * b.aiPref.middleware(opts) -> middleware(req, res, next)
18
+ * opts:
19
+ * train: "allow" | "deny" | "paid" — default "deny"
20
+ * infer: "allow" | "deny" | "paid" — default "allow"
21
+ * snippet: "allow" | "deny" — default "allow"
22
+ * price: { amountUsd, perTokens? } when any of
23
+ * train/infer is "paid".
24
+ * cloudflareSignals: bool, default true — emit the Cloudflare
25
+ * Content-Signals header alongside Content-Usage.
26
+ * robotsContext: "default" | "<user-agent>" — emit
27
+ * per-user-agent rules in robots.txt rather
28
+ * than the catch-all default.
29
+ *
30
+ * b.aiPref.robotsBlock(opts) -> string
31
+ * Returns a robots.txt block per AIPREF §3 grammar:
32
+ *
33
+ * User-agent: GPTBot
34
+ * Content-Usage: train=deny, infer=allow, snippet=allow
35
+ *
36
+ * b.aiPref.serializeHeader(opts) -> string
37
+ * Returns the Content-Usage HTTP response header value.
38
+ *
39
+ * b.aiPref.parseHeader(value) -> { train, infer, snippet, price? }
40
+ * Parses an inbound Content-Usage header (used when the framework
41
+ * plays the role of crawler: respect declared preferences).
42
+ *
43
+ * b.aiPref.refusePaidCrawl(req, res, opts)
44
+ * Convenience: emits HTTP 402 Payment Required with the price
45
+ * manifest in the Cloudflare-compatible JSON body.
46
+ */
47
+
48
+ var audit = require("./audit");
49
+ var requestHelpers = require("./request-helpers");
50
+ var { defineClass } = require("./framework-error");
51
+ var AiPrefError = defineClass("AiPrefError", { alwaysPermanent: true });
52
+
53
+ var TRAIN_VALUES = ["allow", "deny", "paid"];
54
+ var INFER_VALUES = ["allow", "deny", "paid"];
55
+ var SNIPPET_VALUES = ["allow", "deny"];
56
+
57
+ function _validate(opts) {
58
+ if (!opts || typeof opts !== "object") {
59
+ throw AiPrefError.factory("BAD_OPTS",
60
+ "aiPref: opts required");
61
+ }
62
+ var train = opts.train || "deny";
63
+ var infer = opts.infer || "allow";
64
+ var snippet = opts.snippet || "allow";
65
+ if (TRAIN_VALUES.indexOf(train) === -1) {
66
+ throw AiPrefError.factory("BAD_TRAIN", "aiPref: train must be one of " + TRAIN_VALUES.join(", "));
67
+ }
68
+ if (INFER_VALUES.indexOf(infer) === -1) {
69
+ throw AiPrefError.factory("BAD_INFER", "aiPref: infer must be one of " + INFER_VALUES.join(", "));
70
+ }
71
+ if (SNIPPET_VALUES.indexOf(snippet) === -1) {
72
+ throw AiPrefError.factory("BAD_SNIPPET", "aiPref: snippet must be one of " + SNIPPET_VALUES.join(", "));
73
+ }
74
+ if ((train === "paid" || infer === "paid") &&
75
+ (!opts.price || typeof opts.price.amountUsd !== "number" ||
76
+ !isFinite(opts.price.amountUsd) || opts.price.amountUsd <= 0)) {
77
+ throw AiPrefError.factory("BAD_PRICE",
78
+ "aiPref: price.amountUsd (positive finite number) required when train or infer is 'paid'");
79
+ }
80
+ return { train: train, infer: infer, snippet: snippet, price: opts.price || null };
81
+ }
82
+
83
+ function serializeHeader(opts) {
84
+ var v = _validate(opts);
85
+ // RFC 8941 structured-fields list of token=token pairs. AIPREF §4.2.
86
+ var parts = [
87
+ "train=" + v.train,
88
+ "infer=" + v.infer,
89
+ "snippet=" + v.snippet,
90
+ ];
91
+ if (v.price) {
92
+ parts.push('price-usd=' + v.price.amountUsd.toFixed(6));
93
+ if (typeof v.price.perTokens === "number" && isFinite(v.price.perTokens) && v.price.perTokens > 0) {
94
+ parts.push("per-tokens=" + Math.floor(v.price.perTokens));
95
+ }
96
+ }
97
+ return parts.join(", ");
98
+ }
99
+
100
+ function parseHeader(value) {
101
+ if (typeof value !== "string" || value.length === 0) {
102
+ throw AiPrefError.factory("BAD_HEADER", "aiPref.parseHeader: value required");
103
+ }
104
+ if (value.length > 1024) { // allow:raw-byte-literal — header value cap, not bytes
105
+ throw AiPrefError.factory("HEADER_TOO_LARGE",
106
+ "aiPref.parseHeader: value exceeds 1024 chars");
107
+ }
108
+ var out = { train: null, infer: null, snippet: null, price: null };
109
+ var pairs = value.split(",");
110
+ for (var i = 0; i < pairs.length; i += 1) {
111
+ var p = pairs[i].trim();
112
+ var eq = p.indexOf("=");
113
+ if (eq === -1) continue;
114
+ var k = p.slice(0, eq).trim().toLowerCase();
115
+ var val = p.slice(eq + 1).trim();
116
+ if (k === "train" && TRAIN_VALUES.indexOf(val) !== -1) out.train = val;
117
+ else if (k === "infer" && INFER_VALUES.indexOf(val) !== -1) out.infer = val;
118
+ else if (k === "snippet" && SNIPPET_VALUES.indexOf(val) !== -1) out.snippet = val;
119
+ else if (k === "price-usd") {
120
+ var amt = parseFloat(val);
121
+ if (isFinite(amt) && amt > 0) out.price = Object.assign({ amountUsd: amt }, out.price || {});
122
+ } else if (k === "per-tokens") {
123
+ var pt = parseInt(val, 10);
124
+ if (isFinite(pt) && pt > 0) out.price = Object.assign({ perTokens: pt }, out.price || {});
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ function robotsBlock(opts) {
131
+ var v = _validate(opts);
132
+ var ua = opts.userAgent || "*";
133
+ if (typeof ua !== "string" || ua.length === 0 || ua.length > 256) { // allow:raw-byte-literal — UA-string cap, not bytes
134
+ throw AiPrefError.factory("BAD_USER_AGENT",
135
+ "aiPref.robotsBlock: userAgent must be 1-256 char string (or omit for *)");
136
+ }
137
+ return "User-agent: " + ua + "\n" +
138
+ "Content-Usage: " + serializeHeader(v) + "\n";
139
+ }
140
+
141
+ function _cfSignalsHeader(v) {
142
+ // Cloudflare Content Signals Policy emits a header named
143
+ // `cf-content-signals` with a similar grammar. As of Cloudflare's
144
+ // 2025-12 beta the canonical key names are: `ai-training`,
145
+ // `ai-inference`, `ai-snippet`. Keep close to that vocabulary.
146
+ var parts = [
147
+ "ai-training=" + v.train,
148
+ "ai-inference=" + v.infer,
149
+ "ai-snippet=" + v.snippet,
150
+ ];
151
+ if (v.price) parts.push("price-usd=" + v.price.amountUsd.toFixed(6));
152
+ return parts.join("; ");
153
+ }
154
+
155
+ function middleware(opts) {
156
+ var v = _validate(opts);
157
+ var emitCf = opts.cloudflareSignals !== false;
158
+ var header = serializeHeader(v);
159
+ var cfHeader = emitCf ? _cfSignalsHeader(v) : null;
160
+
161
+ return function aiPrefMw(req, res, next) {
162
+ if (typeof res.setHeader === "function") {
163
+ res.setHeader("Content-Usage", header);
164
+ if (cfHeader) res.setHeader("CF-Content-Signals", cfHeader);
165
+ }
166
+ if (typeof next === "function") next();
167
+ };
168
+ }
169
+
170
+ function refusePaidCrawl(req, res, opts) {
171
+ if (!opts || !opts.price || typeof opts.price.amountUsd !== "number") {
172
+ throw AiPrefError.factory("BAD_PRICE",
173
+ "aiPref.refusePaidCrawl: opts.price.amountUsd required");
174
+ }
175
+ var body = JSON.stringify({
176
+ error: "payment_required",
177
+ pricingModel: "pay-per-crawl",
178
+ price: {
179
+ amountUsd: opts.price.amountUsd,
180
+ perTokens: opts.price.perTokens || null,
181
+ },
182
+ contact: opts.contact || null,
183
+ });
184
+ if (typeof res.setHeader === "function") {
185
+ res.setHeader("Content-Type", "application/json");
186
+ res.setHeader("Cache-Control", "no-store");
187
+ }
188
+ res.statusCode = 402; // allow:raw-byte-literal — HTTP 402 Payment Required (RFC 9110)
189
+ res.end(body);
190
+ audit.safeEmit({
191
+ action: "aipref.paid_crawl_refused",
192
+ outcome: "denied",
193
+ metadata: {
194
+ ip: requestHelpers.clientIp(req),
195
+ userAgent: req && req.headers && req.headers["user-agent"],
196
+ amountUsd: opts.price.amountUsd,
197
+ },
198
+ });
199
+ }
200
+
201
+ module.exports = {
202
+ middleware: middleware,
203
+ serializeHeader: serializeHeader,
204
+ parseHeader: parseHeader,
205
+ robotsBlock: robotsBlock,
206
+ refusePaidCrawl: refusePaidCrawl,
207
+ TRAIN_VALUES: TRAIN_VALUES.slice(),
208
+ INFER_VALUES: INFER_VALUES.slice(),
209
+ SNIPPET_VALUES: SNIPPET_VALUES.slice(),
210
+ AiPrefError: AiPrefError,
211
+ };
package/lib/audit.js CHANGED
@@ -243,6 +243,7 @@ var FRAMEWORK_NAMESPACES = [
243
243
  "iabtcf", // b.iabTcf (iabtcf.refused / iabtcf.accepted)
244
244
  "fapi2", // b.fapi2 (fapi2.posture_asserted)
245
245
  "contentcredentials", // b.contentCredentials (contentcredentials.signed / verified)
246
+ "aipref", // b.aiPref (aipref.paid_crawl_refused)
246
247
  ];
247
248
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
248
249
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.28",
3
+ "version": "0.8.30",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:0b8cc5a2-bd56-46a7-a9e6-2508bf3da424",
5
+ "serialNumber": "urn:uuid:e537dde5-9052-47ef-83fb-163bf2d7433c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T14:00:16.533Z",
8
+ "timestamp": "2026-05-07T14:27:38.990Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.8.28",
22
+ "bom-ref": "@blamejs/core@0.8.30",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.28",
25
+ "version": "0.8.30",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.8.28",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.30",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.8.28",
57
+ "ref": "@blamejs/core@0.8.30",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]