diff --git a/a2ml/docs/CONTRACTILES-A2ML-V1.adoc b/a2ml/docs/CONTRACTILES-A2ML-V1.adoc index 7ba6c082..ad665267 100644 --- a/a2ml/docs/CONTRACTILES-A2ML-V1.adoc +++ b/a2ml/docs/CONTRACTILES-A2ML-V1.adoc @@ -109,6 +109,6 @@ The validator and emitter support explicit types: [source,bash] ---- -python3 scripts/contractiles-a2ml-tool.py validate --type mustfile tests/contractiles/fixtures/invalid-mustfile.a2ml -python3 scripts/contractiles-a2ml-tool.py emit --type trustfile contractiles/trust/Trustfile.a2ml /tmp/trustfile.json +deno run --allow-read --allow-write scripts/contractiles-a2ml-tool.js validate --type mustfile tests/contractiles/fixtures/invalid-mustfile.a2ml +deno run --allow-read --allow-write scripts/contractiles-a2ml-tool.js emit --type trustfile contractiles/trust/Trustfile.a2ml /tmp/trustfile.json ---- diff --git a/a2ml/docs/RELEASE-CHECKLIST-CONTRACTILES-A2ML-V1.adoc b/a2ml/docs/RELEASE-CHECKLIST-CONTRACTILES-A2ML-V1.adoc index 624f5110..883c89e7 100644 --- a/a2ml/docs/RELEASE-CHECKLIST-CONTRACTILES-A2ML-V1.adoc +++ b/a2ml/docs/RELEASE-CHECKLIST-CONTRACTILES-A2ML-V1.adoc @@ -5,7 +5,7 @@ == Pre-Release - [ ] Specs updated: `docs/CONTRACTILES-A2ML-V1.adoc` and `contractiles/spec/contractiles-v1.json` -- [ ] Validator/emitter tool updated: `scripts/contractiles-a2ml-tool.py` +- [ ] Validator/emitter tool updated: `scripts/contractiles-a2ml-tool.js` - [ ] Fixtures updated: `tests/contractiles/fixtures/*` - [ ] Expected outputs updated: `tests/contractiles/expected/*` - [ ] CI workflow present: `.github/workflows/contractiles-a2ml.yml` diff --git a/a2ml/docs/RELEASE-NOTES-CONTRACTILES-A2ML-V1.adoc b/a2ml/docs/RELEASE-NOTES-CONTRACTILES-A2ML-V1.adoc index 26c65ceb..a5ee8f75 100644 --- a/a2ml/docs/RELEASE-NOTES-CONTRACTILES-A2ML-V1.adoc +++ b/a2ml/docs/RELEASE-NOTES-CONTRACTILES-A2ML-V1.adoc @@ -30,7 +30,7 @@ just contractiles-k9-validate * `docs/CONTRACTILES-A2ML-V1.adoc` * `contractiles/spec/contractiles-v1.json` -* `scripts/contractiles-a2ml-tool.py` +* `scripts/contractiles-a2ml-tool.js` * `tests/contractiles/fixtures/*` * `.github/workflows/contractiles-a2ml.yml` diff --git a/a2ml/scripts/contractiles-a2ml-emit.sh b/a2ml/scripts/contractiles-a2ml-emit.sh index 58ca296d..afb85435 100755 --- a/a2ml/scripts/contractiles-a2ml-emit.sh +++ b/a2ml/scripts/contractiles-a2ml-emit.sh @@ -6,12 +6,13 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) out_dir="${1:-$root/build/contractiles}" mkdir -p "$out_dir" -tool="$root/scripts/contractiles-a2ml-tool.py" +tool="$root/scripts/contractiles-a2ml-tool.js" +runtool() { deno run --quiet --allow-read --allow-write "$tool" "$@"; } -python3 "$tool" emit "$root/contractiles/must/Mustfile.a2ml" "$out_dir/mustfile.json" -python3 "$tool" emit "$root/contractiles/trust/Trustfile.a2ml" "$out_dir/trustfile.json" -python3 "$tool" emit "$root/contractiles/dust/Dustfile.a2ml" "$out_dir/dustfile.json" -python3 "$tool" emit "$root/contractiles/lust/Intentfile.a2ml" "$out_dir/intentfile.json" +runtool emit "$root/contractiles/must/Mustfile.a2ml" "$out_dir/mustfile.json" +runtool emit "$root/contractiles/trust/Trustfile.a2ml" "$out_dir/trustfile.json" +runtool emit "$root/contractiles/dust/Dustfile.a2ml" "$out_dir/dustfile.json" +runtool emit "$root/contractiles/lust/Intentfile.a2ml" "$out_dir/intentfile.json" echo "Wrote: $out_dir/mustfile.json" echo "Wrote: $out_dir/trustfile.json" diff --git a/a2ml/scripts/contractiles-a2ml-test.sh b/a2ml/scripts/contractiles-a2ml-test.sh index 7e521934..7bb4840c 100755 --- a/a2ml/scripts/contractiles-a2ml-test.sh +++ b/a2ml/scripts/contractiles-a2ml-test.sh @@ -3,20 +3,22 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -tool="$root/scripts/contractiles-a2ml-tool.py" +tool="$root/scripts/contractiles-a2ml-tool.js" fixtures="$root/tests/contractiles/fixtures" expected="$root/tests/contractiles/expected" -python3 "$tool" validate \ +runtool() { deno run --quiet --allow-read --allow-write "$tool" "$@"; } + +runtool validate \ "$fixtures/Mustfile.a2ml" \ "$fixtures/Trustfile.a2ml" \ "$fixtures/Dustfile.a2ml" \ "$fixtures/Intentfile.a2ml" -python3 "$tool" emit "$fixtures/Mustfile.a2ml" "$expected/mustfile.json.tmp" -python3 "$tool" emit "$fixtures/Trustfile.a2ml" "$expected/trustfile.json.tmp" -python3 "$tool" emit "$fixtures/Dustfile.a2ml" "$expected/dustfile.json.tmp" -python3 "$tool" emit "$fixtures/Intentfile.a2ml" "$expected/intentfile.json.tmp" +runtool emit "$fixtures/Mustfile.a2ml" "$expected/mustfile.json.tmp" +runtool emit "$fixtures/Trustfile.a2ml" "$expected/trustfile.json.tmp" +runtool emit "$fixtures/Dustfile.a2ml" "$expected/dustfile.json.tmp" +runtool emit "$fixtures/Intentfile.a2ml" "$expected/intentfile.json.tmp" diff -u "$expected/mustfile.json" "$expected/mustfile.json.tmp" diff -u "$expected/trustfile.json" "$expected/trustfile.json.tmp" @@ -25,7 +27,7 @@ diff -u "$expected/intentfile.json" "$expected/intentfile.json.tmp" expect_fail() { local file="$1" - if python3 "$tool" validate "$file"; then + if runtool validate "$file"; then echo "Expected validation failure: $file" >&2 exit 1 fi @@ -34,7 +36,7 @@ expect_fail() { expect_fail_type() { local file="$1" local type="$2" - if python3 "$tool" validate --type "$type" "$file"; then + if runtool validate --type "$type" "$file"; then echo "Expected validation failure: $file ($type)" >&2 exit 1 fi diff --git a/a2ml/scripts/contractiles-a2ml-tool.js b/a2ml/scripts/contractiles-a2ml-tool.js new file mode 100644 index 00000000..46a68450 --- /dev/null +++ b/a2ml/scripts/contractiles-a2ml-tool.js @@ -0,0 +1,355 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Contractiles A2ML validator/emitter (Deno port of the former Python tool; +// Python is banned estate-wide). JSON output is byte-compatible with the +// previous `json.dumps(obj, indent=2, sort_keys=True) + "\n"` form. + +const SPEC_VERSION = "1.0.0"; + +const REQUIRED = { + mustfile: { + filename: "Mustfile.a2ml", + sections: ["Parameters", "Checks"], + parameters: ["gateway_port", "schema_version"], + item_fields: ["description", "run"], + }, + trustfile: { + filename: "Trustfile.a2ml", + sections: ["Inputs", "Verifications"], + inputs: [ + "policy_path", + "policy_hash_path", + "schema_path", + "schema_sig_path", + "schema_pub_path", + "driver_paths", + "migrations_path", + "migrations_sig_path", + "migrations_pub_path", + ], + item_fields: ["description", "command"], + }, + dustfile: { + filename: "Dustfile.a2ml", + sections: ["Logs", "Policy", "Gateway", "Dust-Events"], + fields_by_section: { + "Logs": ["path", "reversible", "handler"], + "Policy": ["path", "rollback"], + "Gateway": ["event", "undo"], + "Dust-Events": ["source", "transform"], + }, + }, + intentfile: { + filename: "Intentfile.a2ml", + sections: ["Trust-Engine", "Control-Plane", "Pipeline", "Introspection"], + }, +}; + +const NAME_TO_TYPE = Object.fromEntries( + Object.entries(REQUIRED).map(([k, v]) => [v.filename, k]), +); + +function baseName(p) { + const parts = p.split("/"); + return parts[parts.length - 1]; +} + +function parseA2ml(path) { + let section = null; + let item = null; + const data = { sections: {}, items: {}, bullets: {} }; + + const flushItem = () => { + if (item === null) return; + (data.items[section] ??= []).push(item); + item = null; + }; + + const text = Deno.readTextFileSync(path); + for (const raw of text.split("\n")) { + const line = raw.replace(/\s+$/, ""); + if (line.startsWith("## ")) { + flushItem(); + section = line.slice(3).trim(); + if (!(section in data.sections)) data.sections[section] = true; + continue; + } + if (line.startsWith("### ")) { + flushItem(); + item = { name: line.slice(4).trim() }; + continue; + } + if (!line.startsWith("- ")) continue; + + const content = line.slice(2).trim(); + if (content.includes(":")) { + const idx = content.indexOf(":"); + const key = content.slice(0, idx).trim(); + const value = content.slice(idx + 1).trim(); + if (item !== null) { + item[key] = value; + } else { + (data.bullets[section] ??= []).push({ [key]: value }); + } + } else { + (data.bullets[section] ??= []).push(content); + } + } + + flushItem(); + return data; +} + +function resolveType(path, explicit) { + if (explicit) { + if (!(explicit in REQUIRED)) return null; + return explicit; + } + return NAME_TO_TYPE[baseName(path)] ?? null; +} + +function validate(path, explicitType) { + const docType = resolveType(path, explicitType); + if (!docType) return [`Unsupported file: ${baseName(path)}`]; + + const req = REQUIRED[docType]; + const parsed = parseA2ml(path); + const errors = []; + + for (const section of req.sections ?? []) { + if (!(section in parsed.sections)) errors.push(`Missing section: ${section}`); + } + + if (docType === "mustfile") { + const params = {}; + for (const entry of parsed.bullets["Parameters"] ?? []) { + if (entry && typeof entry === "object") Object.assign(params, entry); + } + for (const key of req.parameters ?? []) { + if (!(key in params) || params[key] === "") errors.push(`Missing parameter: ${key}`); + } + + const checks = parsed.items["Checks"] ?? []; + const seen = new Set(); + for (const check of checks) { + const cname = check.name ?? ""; + if (!cname) { + errors.push("Check missing name"); + continue; + } + if (seen.has(cname)) errors.push(`Duplicate check name: ${cname}`); + seen.add(cname); + for (const field of req.item_fields ?? []) { + if (!(field in check) || check[field] === "") { + errors.push(`Check '${cname}' missing field: ${field}`); + } + } + } + } else if (docType === "trustfile") { + const inputs = {}; + for (const entry of parsed.bullets["Inputs"] ?? []) { + if (entry && typeof entry === "object") Object.assign(inputs, entry); + } + for (const key of req.inputs ?? []) { + if (!(key in inputs) || inputs[key] === "") errors.push(`Missing input: ${key}`); + } + + const verifications = parsed.items["Verifications"] ?? []; + for (const verification of verifications) { + const vname = verification.name ?? ""; + if (!vname) { + errors.push("Verification missing name"); + continue; + } + for (const field of req.item_fields ?? []) { + if (!(field in verification) || verification[field] === "") { + errors.push(`Verification '${vname}' missing field: ${field}`); + } + } + } + } else if (docType === "dustfile") { + for (const [section, fields] of Object.entries(req.fields_by_section ?? {})) { + const items = parsed.items[section] ?? []; + if (items.length === 0) { + errors.push(`Section '${section}' has no entries`); + continue; + } + for (const entry of items) { + const ename = entry.name ?? ""; + if (!ename) { + errors.push(`${section} entry missing name`); + continue; + } + for (const field of fields) { + if (!(field in entry) || entry[field] === "") { + errors.push(`${section} '${ename}' missing field: ${field}`); + } + } + } + } + } else if (docType === "intentfile") { + for (const section of req.sections ?? []) { + const bullets = parsed.bullets[section] ?? []; + if (bullets.length === 0) { + errors.push(`Section '${section}' must include at least one item`); + } + } + } + + return errors; +} + +function emit(path, explicitType) { + const docType = resolveType(path, explicitType); + if (!docType) { + console.error(`Unsupported file: ${baseName(path)}`); + Deno.exit(1); + } + + const parsed = parseA2ml(path); + + if (docType === "mustfile") { + const parameters = {}; + for (const entry of parsed.bullets["Parameters"] ?? []) { + if (entry && typeof entry === "object") Object.assign(parameters, entry); + } + const checks = parsed.items["Checks"] ?? []; + return { type: "mustfile", spec_version: SPEC_VERSION, parameters, checks }; + } + + if (docType === "trustfile") { + const inputs = {}; + for (const entry of parsed.bullets["Inputs"] ?? []) { + if (entry && typeof entry === "object") Object.assign(inputs, entry); + } + const verifications = parsed.items["Verifications"] ?? []; + return { type: "trustfile", spec_version: SPEC_VERSION, inputs, verifications }; + } + + if (docType === "dustfile") { + const sections = {}; + for (const section of REQUIRED[docType].sections) { + sections[section] = parsed.items[section] ?? []; + } + return { type: "dustfile", spec_version: SPEC_VERSION, sections }; + } + + if (docType === "intentfile") { + const future = {}; + for (const section of REQUIRED[docType].sections) { + const items = parsed.bullets[section] ?? []; + future[section] = items.map((it) => + typeof it === "string" ? it : Object.values(it)[0] + ); + } + return { type: "intentfile", spec_version: SPEC_VERSION, future }; + } + + console.error(`Unsupported file: ${baseName(path)}`); + Deno.exit(1); +} + +// Recursively sort object keys, then serialise to match Python's +// json.dumps(obj, indent=2, sort_keys=True) including ensure_ascii=True. +function sortDeep(value) { + if (Array.isArray(value)) return value.map(sortDeep); + if (value && typeof value === "object") { + const out = {}; + for (const k of Object.keys(value).sort()) out[k] = sortDeep(value[k]); + return out; + } + return value; +} + +function asciiEscape(s) { + let out = ""; + for (const ch of s) { + const cp = ch.codePointAt(0); + if (cp > 0x7f) { + if (cp > 0xffff) { + const u = cp - 0x10000; + const hi = 0xd800 + (u >> 10); + const lo = 0xdc00 + (u & 0x3ff); + out += "\\u" + hi.toString(16).padStart(4, "0") + + "\\u" + lo.toString(16).padStart(4, "0"); + } else { + out += "\\u" + cp.toString(16).padStart(4, "0"); + } + } else { + out += ch; + } + } + return out; +} + +function pyJson(obj) { + return asciiEscape(JSON.stringify(sortDeep(obj), null, 2)) + "\n"; +} + +function parseArgs(argv) { + const cmd = argv[0]; + if (cmd === "validate") { + const files = []; + let type = null; + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--type") { + type = argv[++i]; + } else { + files.push(argv[i]); + } + } + if (files.length === 0) { + console.error("usage: contractiles-a2ml-tool validate FILES... [--type TYPE]"); + Deno.exit(2); + } + return { cmd, files, type }; + } + if (cmd === "emit") { + const positional = []; + let type = null; + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--type") { + type = argv[++i]; + } else { + positional.push(argv[i]); + } + } + if (positional.length < 2) { + console.error("usage: contractiles-a2ml-tool emit INPUT OUTPUT [--type TYPE]"); + Deno.exit(2); + } + return { cmd, input: positional[0], output: positional[1], type }; + } + console.error("usage: contractiles-a2ml-tool {validate,emit} ..."); + Deno.exit(2); +} + +function main() { + const args = parseArgs(Deno.args); + + if (args.cmd === "validate") { + const allErrors = []; + for (const f of args.files) { + for (const err of validate(f, args.type)) { + allErrors.push(`${baseName(f)}: ${err}`); + } + } + if (allErrors.length > 0) { + for (const err of allErrors) console.error(err); + return 1; + } + return 0; + } + + if (args.cmd === "emit") { + const output = emit(args.input, args.type); + Deno.writeTextFileSync(args.output, pyJson(output)); + return 0; + } + + return 1; +} + +Deno.exit(main()); diff --git a/a2ml/scripts/contractiles-a2ml-tool.py b/a2ml/scripts/contractiles-a2ml-tool.py deleted file mode 100755 index 2e5402c4..00000000 --- a/a2ml/scripts/contractiles-a2ml-tool.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import sys -from pathlib import Path - -SPEC_VERSION = "1.0.0" - -REQUIRED = { - "mustfile": { - "filename": "Mustfile.a2ml", - "sections": ["Parameters", "Checks"], - "parameters": ["gateway_port", "schema_version"], - "item_fields": ["description", "run"], - }, - "trustfile": { - "filename": "Trustfile.a2ml", - "sections": ["Inputs", "Verifications"], - "inputs": [ - "policy_path", - "policy_hash_path", - "schema_path", - "schema_sig_path", - "schema_pub_path", - "driver_paths", - "migrations_path", - "migrations_sig_path", - "migrations_pub_path", - ], - "item_fields": ["description", "command"], - }, - "dustfile": { - "filename": "Dustfile.a2ml", - "sections": ["Logs", "Policy", "Gateway", "Dust-Events"], - "fields_by_section": { - "Logs": ["path", "reversible", "handler"], - "Policy": ["path", "rollback"], - "Gateway": ["event", "undo"], - "Dust-Events": ["source", "transform"], - }, - }, - "intentfile": { - "filename": "Intentfile.a2ml", - "sections": ["Trust-Engine", "Control-Plane", "Pipeline", "Introspection"], - }, -} - -NAME_TO_TYPE = {v["filename"]: k for k, v in REQUIRED.items()} - - -def parse_a2ml(path: Path): - section = None - item = None - data = { - "sections": {}, - "items": {}, - "bullets": {}, - } - - def flush_item(): - nonlocal item - if item is None: - return - data["items"].setdefault(section, []).append(item) - item = None - - for raw in path.read_text(encoding="utf-8").splitlines(): - line = raw.rstrip() - if line.startswith("## "): - flush_item() - section = line[3:].strip() - data["sections"].setdefault(section, True) - continue - if line.startswith("### "): - flush_item() - item = {"name": line[4:].strip()} - continue - if not line.startswith("- "): - continue - - content = line[2:].strip() - if ":" in content: - key, value = content.split(":", 1) - key = key.strip() - value = value.strip() - if item is not None: - item[key] = value - else: - data["bullets"].setdefault(section, []).append({key: value}) - else: - data["bullets"].setdefault(section, []).append(content) - - flush_item() - return data - - -def resolve_type(path: Path, explicit: str | None): - if explicit: - if explicit not in REQUIRED: - return None - return explicit - return NAME_TO_TYPE.get(path.name) - - -def validate(path: Path, explicit_type: str | None): - doc_type = resolve_type(path, explicit_type) - if not doc_type: - return [f"Unsupported file: {path.name}"] - - req = REQUIRED[doc_type] - parsed = parse_a2ml(path) - errors = [] - - for section in req.get("sections", []): - if section not in parsed["sections"]: - errors.append(f"Missing section: {section}") - - if doc_type == "mustfile": - params = {} - for entry in parsed["bullets"].get("Parameters", []): - if isinstance(entry, dict): - params.update(entry) - for key in req.get("parameters", []): - if key not in params or params[key] == "": - errors.append(f"Missing parameter: {key}") - - checks = parsed["items"].get("Checks", []) - seen = set() - for check in checks: - cname = check.get("name", "") - if not cname: - errors.append("Check missing name") - continue - if cname in seen: - errors.append(f"Duplicate check name: {cname}") - seen.add(cname) - for field in req.get("item_fields", []): - if field not in check or check[field] == "": - errors.append(f"Check '{cname}' missing field: {field}") - - elif doc_type == "trustfile": - inputs = {} - for entry in parsed["bullets"].get("Inputs", []): - if isinstance(entry, dict): - inputs.update(entry) - for key in req.get("inputs", []): - if key not in inputs or inputs[key] == "": - errors.append(f"Missing input: {key}") - - verifications = parsed["items"].get("Verifications", []) - for verification in verifications: - vname = verification.get("name", "") - if not vname: - errors.append("Verification missing name") - continue - for field in req.get("item_fields", []): - if field not in verification or verification[field] == "": - errors.append(f"Verification '{vname}' missing field: {field}") - - elif doc_type == "dustfile": - for section, fields in req.get("fields_by_section", {}).items(): - items = parsed["items"].get(section, []) - if not items: - errors.append(f"Section '{section}' has no entries") - continue - for entry in items: - ename = entry.get("name", "") - if not ename: - errors.append(f"{section} entry missing name") - continue - for field in fields: - if field not in entry or entry[field] == "": - errors.append(f"{section} '{ename}' missing field: {field}") - - elif doc_type == "intentfile": - for section in req.get("sections", []): - bullets = parsed["bullets"].get(section, []) - if not bullets: - errors.append(f"Section '{section}' must include at least one item") - - return errors - - -def emit(path: Path, explicit_type: str | None): - doc_type = resolve_type(path, explicit_type) - if not doc_type: - raise SystemExit(f"Unsupported file: {path.name}") - - parsed = parse_a2ml(path) - - if doc_type == "mustfile": - parameters = {} - for entry in parsed["bullets"].get("Parameters", []): - if isinstance(entry, dict): - parameters.update(entry) - checks = parsed["items"].get("Checks", []) - return { - "type": "mustfile", - "spec_version": SPEC_VERSION, - "parameters": parameters, - "checks": checks, - } - - if doc_type == "trustfile": - inputs = {} - for entry in parsed["bullets"].get("Inputs", []): - if isinstance(entry, dict): - inputs.update(entry) - verifications = parsed["items"].get("Verifications", []) - return { - "type": "trustfile", - "spec_version": SPEC_VERSION, - "inputs": inputs, - "verifications": verifications, - } - - if doc_type == "dustfile": - sections = {} - for section in REQUIRED[doc_type]["sections"]: - sections[section] = parsed["items"].get(section, []) - return { - "type": "dustfile", - "spec_version": SPEC_VERSION, - "sections": sections, - } - - if doc_type == "intentfile": - future = {} - for section in REQUIRED[doc_type]["sections"]: - items = parsed["bullets"].get(section, []) - future[section] = [item if isinstance(item, str) else list(item.values())[0] for item in items] - return { - "type": "intentfile", - "spec_version": SPEC_VERSION, - "future": future, - } - - raise SystemExit(f"Unsupported file: {path.name}") - - -def main(): - parser = argparse.ArgumentParser() - sub = parser.add_subparsers(dest="cmd", required=True) - - vcmd = sub.add_parser("validate") - vcmd.add_argument("files", nargs="+") - vcmd.add_argument("--type", choices=sorted(REQUIRED.keys())) - - ecmd = sub.add_parser("emit") - ecmd.add_argument("input") - ecmd.add_argument("output") - ecmd.add_argument("--type", choices=sorted(REQUIRED.keys())) - - args = parser.parse_args() - - if args.cmd == "validate": - all_errors = [] - for f in args.files: - errors = validate(Path(f), args.type) - for err in errors: - all_errors.append(f"{Path(f).name}: {err}") - if all_errors: - for err in all_errors: - print(err, file=sys.stderr) - return 1 - return 0 - - if args.cmd == "emit": - output = emit(Path(args.input), args.type) - Path(args.output).write_text(json.dumps(output, indent=2, sort_keys=True) + "\n", encoding="utf-8") - return 0 - - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/a2ml/scripts/contractiles-a2ml-validate.sh b/a2ml/scripts/contractiles-a2ml-validate.sh index a01fc093..b00b1518 100755 --- a/a2ml/scripts/contractiles-a2ml-validate.sh +++ b/a2ml/scripts/contractiles-a2ml-validate.sh @@ -3,7 +3,8 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -tool="$root/scripts/contractiles-a2ml-tool.py" +tool="$root/scripts/contractiles-a2ml-tool.js" +runtool() { deno run --quiet --allow-read --allow-write "$tool" "$@"; } if ! command -v a2ml >/dev/null 2>&1; then echo "a2ml CLI not found. Install it or add bin/a2ml to PATH." >&2 @@ -21,4 +22,4 @@ for file in "${files[@]}"; do a2ml validate "$file" done -python3 "$tool" validate "${files[@]}" +runtool validate "${files[@]}" diff --git a/avow-protocol/avow-lib/examples/deno/stamp_example.js b/avow-protocol/avow-lib/examples/deno/stamp_example.js new file mode 100644 index 00000000..494c75ec --- /dev/null +++ b/avow-protocol/avow-lib/examples/deno/stamp_example.js @@ -0,0 +1,300 @@ +#!/usr/bin/env -S deno run --allow-read --allow-ffi +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Deno example using libstamp via Deno FFI (port of the former Python +// ctypes example; Python is banned estate-wide). + +import { dirname, fromFileUrl, join } from "jsr:@std/path@^1"; + +// ============================================================================ +// Load Library +// ============================================================================ + +function loadLibstamp() { + let libName; + if (Deno.build.os === "darwin") libName = "libstamp.dylib"; + else if (Deno.build.os === "windows") libName = "stamp.dll"; + else libName = "libstamp.so"; + + const here = dirname(fromFileUrl(import.meta.url)); + const searchPaths = [ + join(here, "..", "..", "ffi", "zig", "zig-out", "lib", libName), + join("/usr/local/lib", libName), + join("/usr/lib", libName), + ]; + + for (const path of searchPaths) { + try { + Deno.statSync(path); + return Deno.dlopen(path, SYMBOLS); + } catch { + // try next + } + } + throw new Error( + `Could not find ${libName}. Build with: cd ffi/zig && zig build`, + ); +} + +const SYMBOLS = { + stamp_verify_unsubscribe: { parameters: ["pointer"], result: "i32" }, + stamp_verify_consent: { parameters: ["pointer"], result: "i32" }, + stamp_verify_rate_limit: { parameters: ["pointer"], result: "i32" }, + stamp_generate_proof: { parameters: ["pointer"], result: "pointer" }, + stamp_free_proof: { parameters: ["pointer"], result: "void" }, + stamp_version: { parameters: [], result: "pointer" }, +}; + +const libstamp = loadLibstamp(); + +// ============================================================================ +// Result codes +// ============================================================================ + +const StampResult = { + SUCCESS: 0, + ERROR_INVALID_URL: -1, + ERROR_TIMEOUT: -2, + ERROR_INVALID_RESPONSE: -3, + ERROR_INVALID_SIGNATURE: -4, + ERROR_RATE_LIMIT_EXCEEDED: -5, + ERROR_CONSENT_INVALID: -6, + ERROR_NULL_POINTER: -7, + ERROR_INTERNAL: -99, + toString(code) { + return ({ + 0: "✓ SUCCESS", + [-1]: "✗ INVALID_URL", + [-2]: "✗ TIMEOUT", + [-3]: "✗ INVALID_RESPONSE", + [-4]: "✗ INVALID_SIGNATURE", + [-5]: "✗ RATE_LIMIT_EXCEEDED", + [-6]: "✗ CONSENT_INVALID", + [-7]: "✗ NULL_POINTER", + [-99]: "✗ INTERNAL_ERROR", + })[code] ?? "✗ UNKNOWN_ERROR"; + }, +}; + +// ============================================================================ +// C struct marshalling (x86-64 SysV natural alignment) +// ============================================================================ + +const enc = new TextEncoder(); + +function cstring(s) { + const bytes = enc.encode(s); + const buf = new Uint8Array(bytes.length + 1); + buf.set(bytes); + return buf; // NUL-terminated; keep a reference so it isn't GC'd +} + +function ptrBig(buf) { + return BigInt(Deno.UnsafePointer.value(Deno.UnsafePointer.of(buf))); +} + +// StampUnsubscribeParams { char* url; u64 tested_at; u16 response_code; +// u32 response_time; char* token; char* signature; } size 40 +function unsubscribeParams({ url, testedAt, responseCode, responseTime, token, signature }) { + const urlB = cstring(url), tokB = cstring(token), sigB = cstring(signature); + const buf = new ArrayBuffer(40); + const dv = new DataView(buf); + dv.setBigUint64(0, ptrBig(urlB), true); + dv.setBigUint64(8, BigInt(testedAt), true); + dv.setUint16(16, responseCode, true); + dv.setUint32(20, responseTime, true); + dv.setBigUint64(24, ptrBig(tokB), true); + dv.setBigUint64(32, ptrBig(sigB), true); + return { buf: new Uint8Array(buf), keep: [urlB, tokB, sigB] }; +} + +// StampConsentParams { u64 initial_request; u64 confirmation; +// char* ip_address; char* token; } size 32 +function consentParams({ initialRequest, confirmation, ipAddress, token }) { + const ipB = cstring(ipAddress), tokB = cstring(token); + const buf = new ArrayBuffer(32); + const dv = new DataView(buf); + dv.setBigUint64(0, BigInt(initialRequest), true); + dv.setBigUint64(8, BigInt(confirmation), true); + dv.setBigUint64(16, ptrBig(ipB), true); + dv.setBigUint64(24, ptrBig(tokB), true); + return { buf: new Uint8Array(buf), keep: [ipB, tokB] }; +} + +// StampRateLimitParams { char* sender_id; u64 account_created; +// u32 messages_today; u32 daily_limit; } size 24 +function rateLimitParams({ senderId, accountCreated, messagesToday, dailyLimit }) { + const idB = cstring(senderId); + const buf = new ArrayBuffer(24); + const dv = new DataView(buf); + dv.setBigUint64(0, ptrBig(idB), true); + dv.setBigUint64(8, BigInt(accountCreated), true); + dv.setUint32(16, messagesToday, true); + dv.setUint32(20, dailyLimit, true); + return { buf: new Uint8Array(buf), keep: [idB] }; +} + +// StampProof { char* data; size_t length; char* signature; } size 24 +function readProof(ptr) { + if (ptr === null) return null; + const view = new Deno.UnsafePointerView(ptr); + const dataPtrVal = view.getBigUint64(0); + const dataPtr = Deno.UnsafePointer.create(dataPtrVal); + return new Deno.UnsafePointerView(dataPtr).getCString(); +} + +// ============================================================================ +// Wrappers +// ============================================================================ + +function getCurrentTimestamp() { + return Date.now(); +} + +function verifyUnsubscribe(args) { + const p = unsubscribeParams(args); + return libstamp.symbols.stamp_verify_unsubscribe(Deno.UnsafePointer.of(p.buf)); +} + +function verifyConsent(args) { + const p = consentParams(args); + return libstamp.symbols.stamp_verify_consent(Deno.UnsafePointer.of(p.buf)); +} + +function verifyRateLimit(args) { + const p = rateLimitParams(args); + return libstamp.symbols.stamp_verify_rate_limit(Deno.UnsafePointer.of(p.buf)); +} + +function generateProof(args) { + const p = unsubscribeParams(args); + const proofPtr = libstamp.symbols.stamp_generate_proof(Deno.UnsafePointer.of(p.buf)); + if (proofPtr === null) return null; + const data = readProof(proofPtr); + libstamp.symbols.stamp_free_proof(proofPtr); + return data; +} + +function getVersion() { + const ptr = libstamp.symbols.stamp_version(); + return new Deno.UnsafePointerView(ptr).getCString(); +} + +// ============================================================================ +// Examples +// ============================================================================ + +function assert(cond, msg) { + if (!cond) throw new Error(`Assertion failed: ${msg}`); +} + +function main() { + console.log("=== STAMP Deno Example ===\n"); + console.log(`Version: ${getVersion()}\n`); + + const now = getCurrentTimestamp(); + + console.log("Example 1: Valid Unsubscribe Link"); + console.log("=================================="); + let result = verifyUnsubscribe({ + url: "https://example.com/unsubscribe", + testedAt: now - 5000, + responseCode: 200, + responseTime: 87, + token: "EXAMPLE-UNSUBSCRIBE-TOKEN", + signature: "EXAMPLE-SIGNATURE", + }); + console.log(`Result: ${StampResult.toString(result)}`); + assert(result === StampResult.SUCCESS, "example 1"); + console.log(); + + const proof = generateProof({ + url: "https://example.com/unsubscribe", + testedAt: now - 5000, + responseCode: 200, + responseTime: 87, + token: "EXAMPLE-UNSUBSCRIBE-TOKEN", + signature: "EXAMPLE-SIGNATURE", + }); + if (proof) { + console.log("Proof generated:"); + console.log(proof); + console.log(); + } + + console.log("Example 2: Invalid URL"); + console.log("======================"); + result = verifyUnsubscribe({ + url: "not_a_url", + testedAt: now, + responseCode: 200, + responseTime: 87, + token: "EXAMPLE-TOKEN", + signature: "EXAMPLE-SIG", + }); + console.log(`Result: ${StampResult.toString(result)}`); + assert(result === StampResult.ERROR_INVALID_URL, "example 2"); + console.log(); + + console.log("Example 3: Valid Consent Chain"); + console.log("==============================="); + const initial = 1706630400000; + const confirmation = initial + 100000; + result = verifyConsent({ + initialRequest: initial, + confirmation, + ipAddress: "192.168.1.100", + token: "EXAMPLE-CONSENT-TOKEN", + }); + console.log(`Initial: ${initial}`); + console.log(`Confirmation: ${confirmation} (+${Math.floor((confirmation - initial) / 1000)} seconds)`); + console.log(`Result: ${StampResult.toString(result)}`); + assert(result === StampResult.SUCCESS, "example 3"); + console.log(); + + console.log("Example 4: Invalid Consent Order"); + console.log("================================="); + result = verifyConsent({ + initialRequest: confirmation, + confirmation: initial, + ipAddress: "192.168.1.100", + token: "EXAMPLE-CONSENT-TOKEN", + }); + console.log(`Result: ${StampResult.toString(result)}`); + assert(result === StampResult.ERROR_CONSENT_INVALID, "example 4"); + console.log(); + + console.log("Example 5: Rate Limit Check"); + console.log("============================"); + const accountAge = now - (45 * 24 * 60 * 60 * 1000); + result = verifyRateLimit({ + senderId: "user_12345", + accountCreated: accountAge, + messagesToday: 150, + dailyLimit: 10000, + }); + const ageDays = Math.floor((now - accountAge) / (24 * 60 * 60 * 1000)); + console.log(`Account age: ${ageDays} days`); + console.log("Messages: 150/10000"); + console.log(`Result: ${StampResult.toString(result)}`); + assert(result === StampResult.SUCCESS, "example 5"); + console.log(); + + console.log("Example 6: Rate Limit Exceeded"); + console.log("==============================="); + result = verifyRateLimit({ + senderId: "spammer", + accountCreated: accountAge, + messagesToday: 10000, + dailyLimit: 10000, + }); + console.log("Messages: 10000/10000"); + console.log(`Result: ${StampResult.toString(result)}`); + assert(result === StampResult.ERROR_RATE_LIMIT_EXCEEDED, "example 6"); + console.log(); + + console.log("All examples passed! ✓"); +} + +main(); diff --git a/avow-protocol/avow-lib/examples/python/stamp_example.py b/avow-protocol/avow-lib/examples/python/stamp_example.py deleted file mode 100644 index bc5eaad6..00000000 --- a/avow-protocol/avow-lib/examples/python/stamp_example.py +++ /dev/null @@ -1,350 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: PMPL-1.0-or-later -# Copyright (c) 2026 Jonathan D.A. Jewell - -""" -Python example using libstamp via ctypes FFI -""" - -import ctypes -import platform -import time -from pathlib import Path -from typing import Optional - -# ============================================================================ -# Load Library -# ============================================================================ - -def load_libstamp(): - """Load libstamp shared library""" - # Determine library extension based on platform - if platform.system() == "Darwin": - lib_name = "libstamp.dylib" - elif platform.system() == "Windows": - lib_name = "stamp.dll" - else: - lib_name = "libstamp.so" - - # Try to find library in standard locations - search_paths = [ - Path(__file__).parent.parent.parent / "ffi" / "zig" / "zig-out" / "lib" / lib_name, - Path("/usr/local/lib") / lib_name, - Path("/usr/lib") / lib_name, - ] - - for path in search_paths: - if path.exists(): - return ctypes.CDLL(str(path)) - - raise FileNotFoundError(f"Could not find {lib_name}. Build with: cd ffi/zig && zig build") - -libstamp = load_libstamp() - -# ============================================================================ -# Type Definitions -# ============================================================================ - -class StampResult: - SUCCESS = 0 - ERROR_INVALID_URL = -1 - ERROR_TIMEOUT = -2 - ERROR_INVALID_RESPONSE = -3 - ERROR_INVALID_SIGNATURE = -4 - ERROR_RATE_LIMIT_EXCEEDED = -5 - ERROR_CONSENT_INVALID = -6 - ERROR_NULL_POINTER = -7 - ERROR_INTERNAL = -99 - - @staticmethod - def to_string(code: int) -> str: - mapping = { - 0: "✓ SUCCESS", - -1: "✗ INVALID_URL", - -2: "✗ TIMEOUT", - -3: "✗ INVALID_RESPONSE", - -4: "✗ INVALID_SIGNATURE", - -5: "✗ RATE_LIMIT_EXCEEDED", - -6: "✗ CONSENT_INVALID", - -7: "✗ NULL_POINTER", - -99: "✗ INTERNAL_ERROR", - } - return mapping.get(code, "✗ UNKNOWN_ERROR") - -class StampUnsubscribeParams(ctypes.Structure): - _fields_ = [ - ("url", ctypes.c_char_p), - ("tested_at", ctypes.c_uint64), - ("response_code", ctypes.c_uint16), - ("response_time", ctypes.c_uint32), - ("token", ctypes.c_char_p), - ("signature", ctypes.c_char_p), - ] - -class StampConsentParams(ctypes.Structure): - _fields_ = [ - ("initial_request", ctypes.c_uint64), - ("confirmation", ctypes.c_uint64), - ("ip_address", ctypes.c_char_p), - ("token", ctypes.c_char_p), - ] - -class StampRateLimitParams(ctypes.Structure): - _fields_ = [ - ("sender_id", ctypes.c_char_p), - ("account_created", ctypes.c_uint64), - ("messages_today", ctypes.c_uint32), - ("daily_limit", ctypes.c_uint32), - ] - -class StampProof(ctypes.Structure): - _fields_ = [ - ("data", ctypes.c_char_p), - ("length", ctypes.c_size_t), - ("signature", ctypes.c_char_p), - ] - -# ============================================================================ -# Function Signatures -# ============================================================================ - -libstamp.stamp_verify_unsubscribe.argtypes = [ctypes.POINTER(StampUnsubscribeParams)] -libstamp.stamp_verify_unsubscribe.restype = ctypes.c_int - -libstamp.stamp_verify_consent.argtypes = [ctypes.POINTER(StampConsentParams)] -libstamp.stamp_verify_consent.restype = ctypes.c_int - -libstamp.stamp_verify_rate_limit.argtypes = [ctypes.POINTER(StampRateLimitParams)] -libstamp.stamp_verify_rate_limit.restype = ctypes.c_int - -libstamp.stamp_generate_proof.argtypes = [ctypes.POINTER(StampUnsubscribeParams)] -libstamp.stamp_generate_proof.restype = ctypes.POINTER(StampProof) - -libstamp.stamp_free_proof.argtypes = [ctypes.POINTER(StampProof)] -libstamp.stamp_free_proof.restype = None - -libstamp.stamp_version.argtypes = [] -libstamp.stamp_version.restype = ctypes.c_char_p - -# ============================================================================ -# Python Wrappers -# ============================================================================ - -def get_current_timestamp() -> int: - """Get current Unix timestamp in milliseconds""" - return int(time.time() * 1000) - -def verify_unsubscribe( - url: str, - tested_at: int, - response_code: int, - response_time: int, - token: str, - signature: str, -) -> int: - """Verify an unsubscribe link""" - params = StampUnsubscribeParams( - url=url.encode('utf-8'), - tested_at=tested_at, - response_code=response_code, - response_time=response_time, - token=token.encode('utf-8'), - signature=signature.encode('utf-8'), - ) - return libstamp.stamp_verify_unsubscribe(ctypes.byref(params)) - -def verify_consent( - initial_request: int, - confirmation: int, - ip_address: str, - token: str, -) -> int: - """Verify a consent chain""" - params = StampConsentParams( - initial_request=initial_request, - confirmation=confirmation, - ip_address=ip_address.encode('utf-8'), - token=token.encode('utf-8'), - ) - return libstamp.stamp_verify_consent(ctypes.byref(params)) - -def verify_rate_limit( - sender_id: str, - account_created: int, - messages_today: int, - daily_limit: int, -) -> int: - """Verify rate limit compliance""" - params = StampRateLimitParams( - sender_id=sender_id.encode('utf-8'), - account_created=account_created, - messages_today=messages_today, - daily_limit=daily_limit, - ) - return libstamp.stamp_verify_rate_limit(ctypes.byref(params)) - -def generate_proof( - url: str, - tested_at: int, - response_code: int, - response_time: int, - token: str, - signature: str, -) -> Optional[str]: - """Generate a verification proof""" - params = StampUnsubscribeParams( - url=url.encode('utf-8'), - tested_at=tested_at, - response_code=response_code, - response_time=response_time, - token=token.encode('utf-8'), - signature=signature.encode('utf-8'), - ) - - proof_ptr = libstamp.stamp_generate_proof(ctypes.byref(params)) - if not proof_ptr: - return None - - proof = proof_ptr.contents - proof_data = proof.data.decode('utf-8') - - libstamp.stamp_free_proof(proof_ptr) - return proof_data - -def get_version() -> str: - """Get library version""" - return libstamp.stamp_version().decode('utf-8') - -# ============================================================================ -# Examples -# ============================================================================ - -def main(): - print("=== STAMP Python Example ===\n") - print(f"Version: {get_version()}\n") - - now = get_current_timestamp() - - # Example 1: Valid unsubscribe link - print("Example 1: Valid Unsubscribe Link") - print("==================================") - - result = verify_unsubscribe( - url="https://example.com/unsubscribe", - tested_at=now - 5000, # 5 seconds ago - response_code=200, - response_time=87, - token="abc123def456", - signature="valid_signature", - ) - - print(f"Result: {StampResult.to_string(result)}") - assert result == StampResult.SUCCESS - print() - - # Generate proof - proof = generate_proof( - url="https://example.com/unsubscribe", - tested_at=now - 5000, - response_code=200, - response_time=87, - token="abc123def456", - signature="valid_signature", - ) - if proof: - print("Proof generated:") - print(proof) - print() - - # Example 2: Invalid URL - print("Example 2: Invalid URL") - print("======================") - - result = verify_unsubscribe( - url="not_a_url", - tested_at=now, - response_code=200, - response_time=87, - token="token", - signature="sig", - ) - - print(f"Result: {StampResult.to_string(result)}") - assert result == StampResult.ERROR_INVALID_URL - print() - - # Example 3: Valid consent chain - print("Example 3: Valid Consent Chain") - print("===============================") - - initial = 1706630400000 - confirmation = initial + 100000 # 100 seconds later - - result = verify_consent( - initial_request=initial, - confirmation=confirmation, - ip_address="192.168.1.100", - token="consent_token", - ) - - print(f"Initial: {initial}") - print(f"Confirmation: {confirmation} (+{(confirmation - initial) // 1000} seconds)") - print(f"Result: {StampResult.to_string(result)}") - assert result == StampResult.SUCCESS - print() - - # Example 4: Invalid consent order - print("Example 4: Invalid Consent Order") - print("=================================") - - result = verify_consent( - initial_request=confirmation, # Swapped! - confirmation=initial, - ip_address="192.168.1.100", - token="consent_token", - ) - - print(f"Result: {StampResult.to_string(result)}") - assert result == StampResult.ERROR_CONSENT_INVALID - print() - - # Example 5: Rate limit check - print("Example 5: Rate Limit Check") - print("============================") - - account_age = now - (45 * 24 * 60 * 60 * 1000) # 45 days ago - - result = verify_rate_limit( - sender_id="user_12345", - account_created=account_age, - messages_today=150, - daily_limit=10000, - ) - - age_days = (now - account_age) // (24 * 60 * 60 * 1000) - print(f"Account age: {age_days} days") - print(f"Messages: 150/10000") - print(f"Result: {StampResult.to_string(result)}") - assert result == StampResult.SUCCESS - print() - - # Example 6: Rate limit exceeded - print("Example 6: Rate Limit Exceeded") - print("===============================") - - result = verify_rate_limit( - sender_id="spammer", - account_created=account_age, - messages_today=10000, # At limit - daily_limit=10000, - ) - - print(f"Messages: 10000/10000") - print(f"Result: {StampResult.to_string(result)}") - assert result == StampResult.ERROR_RATE_LIMIT_EXCEEDED - print() - - print("All examples passed! ✓") - -if __name__ == "__main__": - main() diff --git a/avow-protocol/avow-lib/ffi/zig/README.adoc b/avow-protocol/avow-lib/ffi/zig/README.adoc index 33a34889..a3de982f 100644 --- a/avow-protocol/avow-lib/ffi/zig/README.adoc +++ b/avow-protocol/avow-lib/ffi/zig/README.adoc @@ -121,49 +121,10 @@ cargo build cargo run ---- -=== From Python - -See `examples/python/` for complete example. - -[source,python] ----- -import ctypes - -libstamp = ctypes.CDLL('./zig-out/lib/libstamp.so') - -# Define structures -class StampUnsubscribeParams(ctypes.Structure): - _fields_ = [ - ("url", ctypes.c_char_p), - ("tested_at", ctypes.c_uint64), - ("response_code", ctypes.c_uint16), - ("response_time", ctypes.c_uint32), - ("token", ctypes.c_char_p), - ("signature", ctypes.c_char_p), - ] - -# Call function -params = StampUnsubscribeParams( - url=b"https://example.com/unsub", - tested_at=1706630400000, - response_code=200, - response_time=87, - token=b"abc123...", - signature=b"sig...", -) - -result = libstamp.stamp_verify_unsubscribe(ctypes.byref(params)) -print(f"Result: {result}") ----- - -**Run:** -[source,bash] ----- -python3 examples/python/stamp_example.py ----- - === From JavaScript/Deno +See `examples/deno/` for the complete, runnable example. + [source,javascript] ---- // Deno FFI @@ -179,6 +140,12 @@ const result = libstamp.symbols.stamp_verify_unsubscribe(params); console.log(`Result: ${result}`); ---- +**Run:** +[source,bash] +---- +deno run --allow-read --allow-ffi examples/deno/stamp_example.js +---- + == API Reference === Result Codes diff --git a/consent-aware-http/examples/reference-implementations/deno/README.md b/consent-aware-http/examples/reference-implementations/deno/README.md new file mode 100644 index 00000000..b100df68 --- /dev/null +++ b/consent-aware-http/examples/reference-implementations/deno/README.md @@ -0,0 +1,193 @@ +# AIBDP + HTTP 430 Middleware for Deno + +Reference implementation of the AI Boundary Declaration Protocol (AIBDP) with HTTP 430 (Consent Required) enforcement for Deno servers. This is a port of the former Python/Flask reference implementation — Python is banned across the Hyperpolymath estate; Deno is the standard runtime. + +## Features + +- **AIBDP Manifest Parsing**: Load and cache `.well-known/aibdp.json` +- **AI System Detection**: Identify AI user-agents (GPTBot, Claude-Web, etc.) +- **Policy Enforcement**: Block or allow based on declared boundaries +- **HTTP 430 Responses**: Standards-compliant consent violation responses +- **Path Scoping**: Glob-pattern matching for granular control +- **Conditional Policies**: Check for consent headers and conditions +- **Automatic Caching**: Manifest caching with configurable TTL +- **ES modules**: Importable, framework-free (`Deno.serve`) +- **Wrapper Support**: `aibdpRequired()` for route-specific protection + +## Installation + +No installation step — Deno fetches dependencies on first run. Requires [Deno](https://deno.land/) 1.40+. + +## Quick Start + +### Basic Usage + +```javascript +import { AIBDPMiddleware, serveManifest } from "./aibdp_middleware.js"; + +const middleware = new AIBDPMiddleware({ + manifestPath: ".well-known/aibdp.json", +}); + +const manifest = serveManifest(); + +const handler = middleware.wrap((req) => { + const { pathname } = new URL(req.url); + if (pathname === "/.well-known/aibdp.json") return manifest(); + return new Response("Hello, consent-aware world!"); +}); + +Deno.serve({ port: 5000 }, handler); +``` + +### Run Example Server + +```bash +deno run --allow-read --allow-net example_server.js +``` + +Then test with: + +```bash +# Normal browser access (allowed) +curl http://localhost:5000/ + +# AI bot access (may be blocked based on manifest) +curl http://localhost:5000/article -H "User-Agent: GPTBot/1.0" + +# View AIBDP manifest +curl http://localhost:5000/.well-known/aibdp.json +``` + +## API Reference + +### `AIBDPMiddleware` + +Middleware class for AIBDP enforcement. + +**Constructor:** + +```javascript +new AIBDPMiddleware({ + manifestPath = ".well-known/aibdp.json", + enforceForAll = false, + onViolation = null, +}) +``` + +- `manifestPath` (string): Path to AIBDP manifest file +- `enforceForAll` (boolean): Enforce for all requests, not just AI bots +- `onViolation` (function): Callback `(req, policy, purpose) => void` when a violation is detected + +`middleware.wrap(handler)` returns a `Deno.serve` handler that returns an HTTP 430 `Response` on violation, otherwise delegates to `handler`. + +### `serveManifest(manifestPath)` + +Returns a handler that serves the AIBDP manifest (`application/aibdp+json`, cached, CORS-open), or a 404 JSON response if the manifest is missing. + +### `aibdpRequired(handler, { manifestPath, purpose })` + +Wrap a single route handler with AIBDP enforcement for a given `purpose` (e.g. `"training"`). Returns HTTP 430 if the purpose is refused or conditional requirements are not met. + +### Utility Functions + +- `isAiUserAgent(userAgent)` → boolean +- `extractAiPurpose(headers)` → string (`headers` may be a `Headers` instance or a plain object) +- `pathMatches(requestPath, pattern)` → boolean (glob: `**` = any, `*` = non-slash, `?` = one char) +- `getApplicablePolicy(manifest, purpose, requestPath)` → policy object or `null` +- `checkPolicyConditions(policy, headers)` → `[satisfied, missing[]]` +- `create430Response(manifest, policy, purpose, extra?)` → `Response` + +## Manifest Format + +Example `.well-known/aibdp.json`: + +```json +{ + "aibdp_version": "0.2", + "contact": "mailto:policy@example.org", + "policies": { + "training": { + "status": "conditional", + "conditions": ["Attribution required", "Non-commercial use only"], + "scope": ["/articles/**"] + }, + "indexing": { "status": "allowed", "scope": "all" }, + "generation": { + "status": "refused", + "rationale": "Content should not be synthetically replicated" + } + } +} +``` + +### Policy Status Values + +- `allowed`: Usage permitted without conditions +- `refused`: Usage explicitly prohibited +- `conditional`: Usage permitted if conditions met +- `encouraged`: Usage actively encouraged + +## HTTP 430 Response Format + +```http +HTTP/1.1 430 Consent Required +Content-Type: application/json +Link: ; rel="blocked-by-consent" +Retry-After: 86400 + +{ + "error": "AI usage boundaries declared in AIBDP manifest not satisfied", + "manifest": "https://example.org/.well-known/aibdp.json", + "violated_policy": "training", + "policy_status": "refused", + "required_conditions": [], + "rationale": "Content should not be used for training", + "contact": "mailto:policy@example.org" +} +``` + +## AI Consent Headers + +AI systems can indicate compliance by sending: + +```http +GET /article HTTP/1.1 +Host: example.org +User-Agent: ResearchBot/1.0 +AI-Purpose: indexing +AI-Consent-Reviewed: https://example.org/.well-known/aibdp.json +AI-Consent-Conditions: attribution,non-commercial +``` + +The middleware checks for these headers when enforcing conditional policies. + +## Deployment Considerations + +- ✅ Create AIBDP manifest at `.well-known/aibdp.json` +- ✅ Set appropriate `expires` field in manifest (30–90 days recommended) +- ✅ Provide contact information for policy questions +- ✅ Monitor logs for violations +- ✅ Enable HTTPS for manifest integrity +- ✅ Manifest is cached in memory (default: 1 hour) +- ✅ Failed manifest loads fail open (don't break the site) + +## Standards Compliance + +- [draft-jewell-aibdp-00](https://github.com/Hyperpolymath/consent-aware-http/blob/main/drafts/draft-jewell-aibdp-00.xml) — AIBDP specification +- [draft-jewell-http-430-consent-required-00](https://github.com/Hyperpolymath/consent-aware-http/blob/main/draft-jewell-http-430-consent-required-00.xml) — HTTP 430 status code +- [RFC 8615](https://www.rfc-editor.org/info/rfc8615) — Well-Known URIs +- [RFC 8259](https://www.rfc-editor.org/info/rfc8259) — JSON format + +## License + +MIT License — see LICENSE file for details. + +## Related Projects + +- [AIBDP Specification](https://github.com/Hyperpolymath/consent-aware-http) +- [Node.js Implementation](../nodejs/) + +--- + +_"Without refusal, permission is meaningless."_ diff --git a/consent-aware-http/examples/reference-implementations/deno/aibdp_middleware.js b/consent-aware-http/examples/reference-implementations/deno/aibdp_middleware.js new file mode 100644 index 00000000..7246f3aa --- /dev/null +++ b/consent-aware-http/examples/reference-implementations/deno/aibdp_middleware.js @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT OR GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// AIBDP + HTTP 430 middleware for Deno (port of the former Python/Flask +// reference implementation; Python is banned estate-wide). +// +// Implements AI Boundary Declaration Protocol (AIBDP) enforcement with +// HTTP 430 (Consent Required) responses for `Deno.serve` handlers. + +// AI User-Agent patterns for detection +const AI_USER_AGENTS = [ + /GPTBot/i, + /ChatGPT-User/i, + /Claude-Web/i, + /anthropic-ai/i, + /Google-Extended/i, + /CCBot/i, + /Googlebot/i, + /Bingbot/i, + /Slurp/i, + /DuckDuckBot/i, + /Baiduspider/i, + /YandexBot/i, + /PerplexityBot/i, + /Diffbot/i, +]; + +// AIBDP manifest loader with TTL cache. +export class AIBDPManifest { + constructor(manifestPath, cacheDurationSeconds = 3600) { + this.manifestPath = manifestPath; + this.cacheDuration = cacheDurationSeconds; + this._manifest = null; + this._loadTime = null; + } + + load() { + const now = Date.now(); + if (this._manifest && this._loadTime !== null) { + if ((now - this._loadTime) / 1000 < this.cacheDuration) { + return this._manifest; + } + } + try { + const text = Deno.readTextFileSync(this.manifestPath); + this._manifest = JSON.parse(text); + this._loadTime = now; + return this._manifest; + } catch (e) { + if (e instanceof Deno.errors.NotFound) return null; + console.log(`Warning: Failed to load AIBDP manifest: ${e}`); + return null; + } + } + + get manifest() { + return this.load(); + } +} + +export function isAiUserAgent(userAgent) { + if (!userAgent) return false; + return AI_USER_AGENTS.some((p) => p.test(userAgent)); +} + +// `headers` may be a Headers instance or a plain object. +function headerGet(headers, name) { + if (headers instanceof Headers) return headers.get(name); + if (name in headers) return headers[name]; + // case-insensitive fallback for plain objects + const lower = name.toLowerCase(); + for (const k of Object.keys(headers)) { + if (k.toLowerCase() === lower) return headers[k]; + } + return null; +} + +function headerHas(headers, name) { + return headerGet(headers, name) !== null && headerGet(headers, name) !== undefined; +} + +export function extractAiPurpose(headers) { + const explicit = headerGet(headers, "AI-Purpose"); + if (explicit) return String(explicit).toLowerCase(); + + const ua = headerGet(headers, "User-Agent") || ""; + if (/GPTBot/i.test(ua)) return "training"; + if (/Claude-Web/i.test(ua)) return "indexing"; + if (/Google-Extended/i.test(ua)) return "training"; + if (/Googlebot/i.test(ua)) return "indexing"; + return "unknown"; +} + +export function pathMatches(requestPath, pattern) { + if (pattern === "all") return true; + // Match the Python sequential-replace glob translation exactly. + const regexPattern = pattern + .replaceAll(".", "\\.") + .replaceAll("**", ".*") + .replaceAll("*", "[^/]*") + .replaceAll("?", "."); + return new RegExp(`^${regexPattern}$`).test(requestPath); +} + +export function getApplicablePolicy(manifest, purpose, requestPath) { + if (!manifest || !("policies" in manifest)) return null; + const policy = manifest.policies[purpose]; + if (!policy) return null; + + const scope = policy.scope; + if (scope) { + if (Array.isArray(scope)) { + if (!scope.some((pat) => pathMatches(requestPath, pat))) return null; + } + // scope: "all" always matches + } + + for (const exception of policy.exceptions ?? []) { + if (pathMatches(requestPath, exception.path)) return exception; + } + + return policy; +} + +export function checkPolicyConditions(policy, headers) { + if (policy.status !== "conditional") return [true, []]; + const conditions = policy.conditions ?? []; + if (conditions.length === 0) return [true, []]; + + const missing = []; + if (!headerHas(headers, "AI-Consent-Reviewed")) { + missing.push("AI-Consent-Reviewed header required"); + } + if (!headerHas(headers, "AI-Consent-Conditions")) { + missing.push("AI-Consent-Conditions header required"); + } + return [missing.length === 0, missing]; +} + +export function create430Response(manifest, policy, purpose, extra = {}) { + const manifestUri = manifest.canonical_uri ?? "/.well-known/aibdp.json"; + const body = { + error: "AI usage boundaries declared in AIBDP manifest not satisfied", + manifest: manifestUri, + violated_policy: purpose, + policy_status: policy.status, + required_conditions: policy.conditions ?? [], + rationale: policy.rationale ?? "No additional information provided", + contact: manifest.contact ?? null, + ...extra, + }; + return new Response(JSON.stringify(body), { + status: 430, + headers: { + "Content-Type": "application/json", + "Link": `<${manifestUri}>; rel="blocked-by-consent"`, + "Retry-After": "86400", + }, + }); +} + +// Flask-style middleware: wrap a `Deno.serve` handler. +export class AIBDPMiddleware { + constructor({ + manifestPath = ".well-known/aibdp.json", + enforceForAll = false, + onViolation = null, + } = {}) { + this.manifestLoader = new AIBDPManifest(manifestPath); + this.enforceForAll = enforceForAll; + this.onViolation = onViolation; + } + + // Returns a Response (HTTP 430) when a violation is detected, else null. + beforeRequest(req) { + try { + const manifest = this.manifestLoader.manifest; + if (!manifest) return null; + + const userAgent = req.headers.get("User-Agent") || ""; + const isAi = this.enforceForAll || isAiUserAgent(userAgent); + if (!isAi) return null; + + const purpose = extractAiPurpose(req.headers); + const path = new URL(req.url).pathname; + const policy = getApplicablePolicy(manifest, purpose, path); + if (!policy) return null; + + if (policy.status === "refused") { + this.onViolation?.(req, policy, purpose); + return create430Response(manifest, policy, purpose); + } + + if (policy.status === "conditional") { + const [satisfied, missing] = checkPolicyConditions(policy, req.headers); + if (!satisfied) { + this.onViolation?.(req, policy, purpose); + return create430Response(manifest, policy, purpose, { + missing_conditions: missing, + }); + } + } + return null; + } catch (e) { + console.log(`AIBDP middleware error: ${e}`); + return null; // fail open — don't break the site + } + } + + wrap(handler) { + return (req, info) => { + const blocked = this.beforeRequest(req); + if (blocked) return blocked; + return handler(req, info); + }; + } +} + +export function serveManifest(manifestPath = ".well-known/aibdp.json") { + const loader = new AIBDPManifest(manifestPath); + return () => { + const manifest = loader.manifest; + if (!manifest) { + return new Response(JSON.stringify({ error: "Manifest not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify(manifest), { + headers: { + "Content-Type": "application/aibdp+json", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*", + }, + }); + }; +} + +export function aibdpRequired( + handler, + { manifestPath = ".well-known/aibdp.json", purpose = "unknown" } = {}, +) { + const loader = new AIBDPManifest(manifestPath); + return (req, info) => { + const manifest = loader.manifest; + if (!manifest) return handler(req, info); + + const userAgent = req.headers.get("User-Agent") || ""; + if (!isAiUserAgent(userAgent)) return handler(req, info); + + const path = new URL(req.url).pathname; + const policy = getApplicablePolicy(manifest, purpose, path); + if (!policy) return handler(req, info); + + if (policy.status === "refused") { + return create430Response(manifest, policy, purpose); + } + if (policy.status === "conditional") { + const [satisfied] = checkPolicyConditions(policy, req.headers); + if (!satisfied) return create430Response(manifest, policy, purpose); + } + return handler(req, info); + }; +} diff --git a/consent-aware-http/examples/reference-implementations/python/example-aibdp.json b/consent-aware-http/examples/reference-implementations/deno/example-aibdp.json similarity index 100% rename from consent-aware-http/examples/reference-implementations/python/example-aibdp.json rename to consent-aware-http/examples/reference-implementations/deno/example-aibdp.json diff --git a/consent-aware-http/examples/reference-implementations/deno/example_server.js b/consent-aware-http/examples/reference-implementations/deno/example_server.js new file mode 100644 index 00000000..ed1a3643 --- /dev/null +++ b/consent-aware-http/examples/reference-implementations/deno/example_server.js @@ -0,0 +1,152 @@ +#!/usr/bin/env -S deno run --allow-read --allow-net +// SPDX-License-Identifier: MIT OR GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Example Deno server demonstrating AIBDP + HTTP 430 middleware (port of +// the former Python/Flask example; Python is banned estate-wide). +// +// Usage: +// deno run --allow-read --allow-net example_server.js +// +// Test with: +// curl http://localhost:5000/ +// curl http://localhost:5000/article -H "User-Agent: GPTBot/1.0" +// curl http://localhost:5000/.well-known/aibdp.json + +import { + aibdpRequired, + AIBDPMiddleware, + serveManifest, +} from "./aibdp_middleware.js"; + +const MANIFEST = "example-aibdp.json"; + +const middleware = new AIBDPMiddleware({ + manifestPath: MANIFEST, + enforceForAll: false, // only enforce for detected AI systems + onViolation: (req, policy, purpose) => { + const url = new URL(req.url); + console.log( + `[AIBDP] Blocked: ${req.method} ${url.pathname}\n` + + ` User-Agent: ${req.headers.get("User-Agent")}\n` + + ` Purpose: ${purpose}\n` + + ` Policy: ${policy.status}`, + ); + }, +}); + +const manifestHandler = serveManifest(MANIFEST); + +function html(body) { + return new Response(body, { headers: { "Content-Type": "text/html" } }); +} + +const indexPage = html(` + + + Consent-Aware HTTP Example (Deno) + + +

Welcome to Consent-Aware HTTP

+

This Deno server implements HTTP 430 + AIBDP.

+ +

Try these requests:

+
    +
  • curl http://localhost:5000/ - Normal access (allowed)
  • +
  • curl http://localhost:5000/article -H "User-Agent: GPTBot/1.0" - AI bot (may be blocked)
  • +
  • curl http://localhost:5000/.well-known/aibdp.json - View AIBDP manifest
  • +
+ +

Resources:

+ + + + `); + +const articlePage = () => + html(` + + + Protected Article + + +

Protected Article

+

This content has AI usage boundaries declared via AIBDP.

+

Training: Conditional (requires attribution)

+

Generation: Refused

+

Indexing: Allowed

+ + + `); + +const publicPage = html(` + + + Public Content + + +

Public Content

+

This content is available for all purposes, including AI training.

+ + + `); + +const protectedHandler = aibdpRequired( + () => + html(` + + + Protected Route + + +

Protected Route (Decorator)

+

This route uses the aibdpRequired wrapper.

+

AI training is not permitted on this content.

+ + + `), + { manifestPath: MANIFEST, purpose: "training" }, +); + +function route(req) { + const { pathname } = new URL(req.url); + switch (pathname) { + case "/": + return indexPage; + case "/article": + return articlePage(); + case "/public": + return publicPage; + case "/protected": + return protectedHandler(req); + case "/health": + return Response.json({ + status: "healthy", + aibdp_enabled: true, + http_430_enabled: true, + }); + case "/.well-known/aibdp.json": + return manifestHandler(); + default: + return new Response("Not Found", { status: 404 }); + } +} + +const handler = middleware.wrap(route); + +console.log("🚀 Consent-Aware HTTP server (Deno) running on http://localhost:5000"); +console.log("📄 AIBDP manifest: http://localhost:5000/.well-known/aibdp.json"); +console.log("🛡️ HTTP 430 enforcement: ENABLED"); +console.log(""); +console.log("Try these commands:"); +console.log(" curl http://localhost:5000/"); +console.log(' curl http://localhost:5000/article -H "User-Agent: GPTBot/1.0"'); +console.log(" curl http://localhost:5000/.well-known/aibdp.json"); +console.log(""); + +Deno.serve({ port: 5000 }, handler); diff --git a/consent-aware-http/examples/reference-implementations/nodejs/README.md b/consent-aware-http/examples/reference-implementations/nodejs/README.md index a9c0e2d1..99511892 100644 --- a/consent-aware-http/examples/reference-implementations/nodejs/README.md +++ b/consent-aware-http/examples/reference-implementations/nodejs/README.md @@ -288,8 +288,7 @@ See [CONTRIBUTING.md](../../../.github/CONTRIBUTING.md) in the main repository. ## Related Projects - [AIBDP Specification](https://github.com/Hyperpolymath/consent-aware-http) -- [Python Implementation](../python/) -- [Rust Implementation](../rust/) +- [Deno Implementation](../deno/) --- diff --git a/consent-aware-http/examples/reference-implementations/python/README.md b/consent-aware-http/examples/reference-implementations/python/README.md deleted file mode 100644 index e814ab34..00000000 --- a/consent-aware-http/examples/reference-implementations/python/README.md +++ /dev/null @@ -1,380 +0,0 @@ -# AIBDP + HTTP 430 Middleware for Flask - -Reference implementation of the AI Boundary Declaration Protocol (AIBDP) with HTTP 430 (Consent Required) enforcement for Python/Flask applications. - -## Features - -- **AIBDP Manifest Parsing**: Load and cache `.well-known/aibdp.json` -- **AI System Detection**: Identify AI user-agents (GPTBot, Claude-Web, etc.) -- **Policy Enforcement**: Block or allow based on declared boundaries -- **HTTP 430 Responses**: Standards-compliant consent violation responses -- **Path Scoping**: Glob-pattern matching for granular control -- **Conditional Policies**: Check for consent headers and conditions -- **Automatic Caching**: Manifest caching with configurable TTL -- **Type Hints**: Full type annotations for IDE support -- **Decorator Support**: `@aibdp_required` for route-specific protection - -## Installation - -```bash -pip install -r requirements.txt -``` - -## Quick Start - -### Basic Usage - -```python -from flask import Flask -from aibdp_middleware import AIBDPMiddleware, serve_manifest - -app = Flask(__name__) - -# Initialize AIBDP middleware -middleware = AIBDPMiddleware( - app, - manifest_path='.well-known/aibdp.json' -) - -# Serve AIBDP manifest -app.route('/.well-known/aibdp.json')(serve_manifest()) - -# Your routes -@app.route('/') -def index(): - return 'Hello, consent-aware world!' - -if __name__ == '__main__': - app.run() -``` - -### Run Example Server - -```bash -python example_server.py -``` - -Then test with: - -```bash -# Normal browser access (allowed) -curl http://localhost:5000/ - -# AI bot access (may be blocked based on manifest) -curl http://localhost:5000/article -H "User-Agent: GPTBot/1.0" - -# View AIBDP manifest -curl http://localhost:5000/.well-known/aibdp.json -``` - -## API Reference - -### `AIBDPMiddleware` - -Flask middleware class for AIBDP enforcement. - -**Constructor:** - -```python -AIBDPMiddleware( - app=None, - manifest_path: str = '.well-known/aibdp.json', - enforce_for_all: bool = False, - on_violation: Optional[Callable] = None -) -``` - -**Arguments:** - -- `app` (Flask, optional): Flask application (can use `init_app` later) -- `manifest_path` (str): Path to AIBDP manifest file -- `enforce_for_all` (bool): Enforce for all requests, not just AI bots -- `on_violation` (callable): Callback when violation detected. Signature: `(request, policy, purpose) -> None` - -**Example:** - -```python -def log_violation(request, policy, purpose): - print(f"Blocked {purpose} from {request.remote_addr}") - -middleware = AIBDPMiddleware( - app, - manifest_path='./my-aibdp.json', - enforce_for_all=False, - on_violation=log_violation -) -``` - -### `serve_manifest(manifest_path)` - -Create route handler to serve AIBDP manifest at `/.well-known/aibdp.json`. - -**Arguments:** - -- `manifest_path` (str): Path to manifest file - -**Returns:** Flask route handler function - -**Example:** - -```python -app.route('/.well-known/aibdp.json')(serve_manifest('./aibdp.json')) -``` - -### `@aibdp_required` Decorator - -Protect specific routes with AIBDP enforcement. - -**Arguments:** - -- `manifest_path` (str): Path to AIBDP manifest (default: `.well-known/aibdp.json`) -- `purpose` (str): AI purpose to check against (training, indexing, etc.) - -**Example:** - -```python -from aibdp_middleware import aibdp_required - -@app.route('/article') -@aibdp_required(purpose='training') -def article(): - return 'Protected content' -``` - -This decorator will return HTTP 430 if AI training is refused or conditional requirements are not met. - -### Utility Functions - -#### `is_ai_user_agent(user_agent: str) -> bool` - -Check if User-Agent indicates an AI system. - -```python -from aibdp_middleware import is_ai_user_agent - -if is_ai_user_agent('GPTBot/1.0'): - print('AI system detected') -``` - -#### `extract_ai_purpose(headers: Dict[str, str]) -> str` - -Extract AI purpose from request headers. - -```python -from aibdp_middleware import extract_ai_purpose - -purpose = extract_ai_purpose({ - 'User-Agent': 'GPTBot/1.0', - 'AI-Purpose': 'training' -}) -print(purpose) # 'training' -``` - -#### `path_matches(request_path: str, pattern: str) -> bool` - -Check if request path matches glob pattern. - -```python -from aibdp_middleware import path_matches - -path_matches('/docs/guide.html', '/docs/**') # True -path_matches('/article.pdf', '*.pdf') # True -path_matches('/blog/post.html', '/docs/**') # False -``` - -#### `get_applicable_policy(manifest: Dict, purpose: str, request_path: str) -> Optional[Dict]` - -Get applicable policy for request. - -```python -from aibdp_middleware import get_applicable_policy - -manifest = {...} # Loaded AIBDP manifest -policy = get_applicable_policy(manifest, 'training', '/article.html') -``` - -## Manifest Format - -Example `.well-known/aibdp.json`: - -```json -{ - "aibdp_version": "0.2", - "contact": "mailto:policy@example.org", - "policies": { - "training": { - "status": "conditional", - "conditions": ["Attribution required", "Non-commercial use only"], - "scope": ["/articles/**"] - }, - "indexing": { - "status": "allowed", - "scope": "all" - }, - "generation": { - "status": "refused", - "rationale": "Content should not be synthetically replicated" - } - } -} -``` - -### Policy Status Values - -- `allowed`: Usage permitted without conditions -- `refused`: Usage explicitly prohibited -- `conditional`: Usage permitted if conditions met -- `encouraged`: Usage actively encouraged - -## HTTP 430 Response Format - -When a policy is violated, the middleware responds with HTTP 430: - -```http -HTTP/1.1 430 Consent Required -Content-Type: application/json -Link: ; rel="blocked-by-consent" -Retry-After: 86400 - -{ - "error": "AI usage boundaries declared in AIBDP manifest not satisfied", - "manifest": "https://example.org/.well-known/aibdp.json", - "violated_policy": "training", - "policy_status": "refused", - "required_conditions": [], - "rationale": "Content should not be used for training", - "contact": "mailto:policy@example.org" -} -``` - -## AI Consent Headers - -AI systems can indicate compliance by sending: - -```http -GET /article HTTP/1.1 -Host: example.org -User-Agent: ResearchBot/1.0 -AI-Purpose: indexing -AI-Consent-Reviewed: https://example.org/.well-known/aibdp.json -AI-Consent-Conditions: attribution,non-commercial -``` - -The middleware checks for these headers when enforcing conditional policies. - -## Type Safety - -This implementation includes full type hints for IDE support and type checking with mypy: - -```bash -pip install mypy -mypy aibdp_middleware.py -``` - -## Testing - -Create a test file `test_middleware.py`: - -```python -from aibdp_middleware import is_ai_user_agent, path_matches, extract_ai_purpose - -def test_ai_detection(): - assert is_ai_user_agent('GPTBot/1.0') == True - assert is_ai_user_agent('Mozilla/5.0') == False - -def test_path_matching(): - assert path_matches('/docs/guide.html', '/docs/**') == True - assert path_matches('/blog/post.html', '/docs/**') == False - -def test_purpose_extraction(): - headers = {'User-Agent': 'GPTBot/1.0'} - assert extract_ai_purpose(headers) == 'training' - -if __name__ == '__main__': - test_ai_detection() - test_path_matching() - test_purpose_extraction() - print('All tests passed!') -``` - -Run with: - -```bash -python test_middleware.py -``` - -## Deployment Considerations - -### Production Checklist - -- ✅ Create AIBDP manifest at `.well-known/aibdp.json` -- ✅ Set appropriate `expires` field in manifest (30-90 days recommended) -- ✅ Provide contact information for policy questions -- ✅ Monitor logs for violations -- ✅ Set up manifest validation in CI/CD -- ✅ Use production WSGI server (gunicorn, uWSGI) -- ✅ Enable HTTPS for manifest integrity -- ✅ Document rationale in human-readable policy page - -### Production Deployment - -Use a production WSGI server: - -```bash -pip install gunicorn -gunicorn -w 4 -b 0.0.0.0:5000 example_server:app -``` - -Or with uWSGI: - -```bash -pip install uwsgi -uwsgi --http :5000 --wsgi-file example_server.py --callable app --processes 4 -``` - -### Performance - -- Manifest is cached in memory (default: 1 hour) -- Minimal latency impact for non-AI requests -- Failed manifest loads fail open (don't break site) -- Regex compilation is efficient - -### Security - -- Manifest served over HTTPS prevents tampering -- JSON parsing errors fail gracefully -- No sensitive information in manifest -- Type-safe implementation reduces bugs - -## Standards Compliance - -This implementation follows: - -- [draft-jewell-aibdp-00](https://github.com/Hyperpolymath/consent-aware-http/blob/main/drafts/draft-jewell-aibdp-00.xml) - AIBDP specification -- [draft-jewell-http-430-consent-required-00](https://github.com/Hyperpolymath/consent-aware-http/blob/main/draft-jewell-http-430-consent-required-00.xml) - HTTP 430 status code -- [RFC 8615](https://www.rfc-editor.org/info/rfc8615) - Well-Known URIs -- [RFC 8259](https://www.rfc-editor.org/info/rfc8259) - JSON format - -## License - -MIT License - see LICENSE file for details - -## Contributing - -See [CONTRIBUTING.md](../../../.github/CONTRIBUTING.md) in the main repository. - -## Support - -- **Issues**: https://github.com/Hyperpolymath/consent-aware-http/issues -- **Discussions**: https://github.com/Hyperpolymath/consent-aware-http/discussions -- **Email**: jonathan@metadatastician.art - -## Related Projects - -- [AIBDP Specification](https://github.com/Hyperpolymath/consent-aware-http) -- [Node.js Implementation](../nodejs/) -- [Rust Implementation](../rust/) - ---- - -_"Without refusal, permission is meaningless."_ diff --git a/consent-aware-http/examples/reference-implementations/python/aibdp_middleware.py b/consent-aware-http/examples/reference-implementations/python/aibdp_middleware.py deleted file mode 100644 index 6482393d..00000000 --- a/consent-aware-http/examples/reference-implementations/python/aibdp_middleware.py +++ /dev/null @@ -1,436 +0,0 @@ -# SPDX-License-Identifier: MIT OR GPL-3.0-or-later -# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell - -""" -AIBDP + HTTP 430 Middleware for Flask - -Implements AI Boundary Declaration Protocol (AIBDP) enforcement -with HTTP 430 (Consent Required) responses for Flask applications. - -License: MIT -Author: Jonathan D.A. Jewell -""" - -import json -import re -from pathlib import Path -from datetime import datetime, timedelta -from typing import Dict, List, Optional, Tuple, Callable -from functools import wraps - -from flask import request, jsonify, Response, make_response - -# AI User-Agent patterns for detection -AI_USER_AGENTS = [ - re.compile(r'GPTBot', re.IGNORECASE), - re.compile(r'ChatGPT-User', re.IGNORECASE), - re.compile(r'Claude-Web', re.IGNORECASE), - re.compile(r'anthropic-ai', re.IGNORECASE), - re.compile(r'Google-Extended', re.IGNORECASE), - re.compile(r'CCBot', re.IGNORECASE), - re.compile(r'Googlebot', re.IGNORECASE), - re.compile(r'Bingbot', re.IGNORECASE), - re.compile(r'Slurp', re.IGNORECASE), - re.compile(r'DuckDuckBot', re.IGNORECASE), - re.compile(r'Baiduspider', re.IGNORECASE), - re.compile(r'YandexBot', re.IGNORECASE), - re.compile(r'PerplexityBot', re.IGNORECASE), - re.compile(r'Diffbot', re.IGNORECASE), -] - - -class AIBDPManifest: - """ - AIBDP Manifest loader and cache manager. - """ - - def __init__(self, manifest_path: str, cache_duration: int = 3600): - """ - Initialize manifest loader. - - Args: - manifest_path: Path to aibdp.json file - cache_duration: Cache duration in seconds (default: 1 hour) - """ - self.manifest_path = Path(manifest_path) - self.cache_duration = cache_duration - self._manifest: Optional[Dict] = None - self._load_time: Optional[datetime] = None - - def load(self) -> Optional[Dict]: - """ - Load manifest from file with caching. - - Returns: - Parsed manifest dict or None if not found/invalid - """ - now = datetime.now() - - # Return cached if still valid - if self._manifest and self._load_time: - if (now - self._load_time).total_seconds() < self.cache_duration: - return self._manifest - - # Load from file - try: - if not self.manifest_path.exists(): - return None - - with open(self.manifest_path, 'r', encoding='utf-8') as f: - self._manifest = json.load(f) - self._load_time = now - return self._manifest - - except (json.JSONDecodeError, OSError) as e: - print(f"Warning: Failed to load AIBDP manifest: {e}") - return None - - @property - def manifest(self) -> Optional[Dict]: - """Get current manifest (cached or fresh).""" - return self.load() - - -def is_ai_user_agent(user_agent: str) -> bool: - """ - Check if User-Agent indicates an AI system. - - Args: - user_agent: User-Agent header value - - Returns: - True if AI system detected - """ - if not user_agent: - return False - - return any(pattern.search(user_agent) for pattern in AI_USER_AGENTS) - - -def extract_ai_purpose(headers: Dict[str, str]) -> str: - """ - Extract AI purpose from request headers. - - Args: - headers: Request headers dict - - Returns: - Detected purpose (training, indexing, etc.) or 'unknown' - """ - # Check custom AI-Purpose header - if 'AI-Purpose' in headers: - return headers['AI-Purpose'].lower() - - # Infer from User-Agent - ua = headers.get('User-Agent', '') - - if re.search(r'GPTBot', ua, re.IGNORECASE): - return 'training' - if re.search(r'Claude-Web', ua, re.IGNORECASE): - return 'indexing' - if re.search(r'Google-Extended', ua, re.IGNORECASE): - return 'training' - if re.search(r'Googlebot', ua, re.IGNORECASE): - return 'indexing' - - return 'unknown' - - -def path_matches(request_path: str, pattern: str) -> bool: - """ - Check if request path matches glob-style pattern. - - Args: - request_path: Request path (e.g., '/docs/guide.html') - pattern: Pattern from manifest (e.g., '/docs/**', '*.pdf') - - Returns: - True if path matches pattern - """ - if pattern == 'all': - return True - - # Convert glob pattern to regex - regex_pattern = ( - pattern - .replace('.', r'\.') - .replace('**', '.*') - .replace('*', '[^/]*') - .replace('?', '.') - ) - - return bool(re.match(f'^{regex_pattern}$', request_path)) - - -def get_applicable_policy( - manifest: Dict, - purpose: str, - request_path: str -) -> Optional[Dict]: - """ - Get applicable policy for request path and purpose. - - Args: - manifest: AIBDP manifest dict - purpose: AI purpose (training, indexing, etc.) - request_path: Request path - - Returns: - Applicable policy dict or None - """ - if not manifest or 'policies' not in manifest: - return None - - policy = manifest['policies'].get(purpose) - if not policy: - return None - - # Check scope - scope = policy.get('scope') - if scope: - if isinstance(scope, list): - if not any(path_matches(request_path, pattern) for pattern in scope): - return None - # scope: "all" always matches - - # Check exceptions - exceptions = policy.get('exceptions', []) - for exception in exceptions: - if path_matches(request_path, exception['path']): - return exception # Exception takes precedence - - return policy - - -def check_policy_conditions(policy: Dict, headers: Dict[str, str]) -> Tuple[bool, List[str]]: - """ - Check if request satisfies policy conditions. - - Args: - policy: Policy dict from manifest - headers: Request headers - - Returns: - Tuple of (satisfied: bool, missing: List[str]) - """ - if policy.get('status') != 'conditional': - return True, [] - - conditions = policy.get('conditions', []) - if not conditions: - return True, [] - - missing = [] - - # Check for consent headers - if 'AI-Consent-Reviewed' not in headers: - missing.append('AI-Consent-Reviewed header required') - - if 'AI-Consent-Conditions' not in headers: - missing.append('AI-Consent-Conditions header required') - - # TODO: More sophisticated condition checking - - return len(missing) == 0, missing - - -def create_430_response( - manifest: Dict, - policy: Dict, - purpose: str -) -> Response: - """ - Create HTTP 430 Consent Required response. - - Args: - manifest: AIBDP manifest - policy: Violated policy - purpose: AI purpose - - Returns: - Flask Response object with HTTP 430 - """ - manifest_uri = manifest.get('canonical_uri', '/.well-known/aibdp.json') - - response_data = { - 'error': 'AI usage boundaries declared in AIBDP manifest not satisfied', - 'manifest': manifest_uri, - 'violated_policy': purpose, - 'policy_status': policy['status'], - 'required_conditions': policy.get('conditions', []), - 'rationale': policy.get('rationale', 'No additional information provided'), - 'contact': manifest.get('contact') - } - - response = make_response(jsonify(response_data), 430) - response.headers['Link'] = f'<{manifest_uri}>; rel="blocked-by-consent"' - response.headers['Retry-After'] = '86400' # 24 hours - - return response - - -class AIBDPMiddleware: - """ - Flask middleware for AIBDP enforcement. - """ - - def __init__( - self, - app=None, - manifest_path: str = '.well-known/aibdp.json', - enforce_for_all: bool = False, - on_violation: Optional[Callable] = None - ): - """ - Initialize AIBDP middleware. - - Args: - app: Flask application (optional, can use init_app later) - manifest_path: Path to AIBDP manifest - enforce_for_all: Enforce for all requests, not just AI bots - on_violation: Callback when violation detected - """ - self.manifest_loader = AIBDPManifest(manifest_path) - self.enforce_for_all = enforce_for_all - self.on_violation = on_violation - - if app: - self.init_app(app) - - def init_app(self, app): - """ - Initialize middleware with Flask application. - - Args: - app: Flask application - """ - app.before_request(self.before_request) - - def before_request(self): - """ - Flask before_request handler for AIBDP enforcement. - - Returns: - Response object (HTTP 430) if violation detected, None otherwise - """ - try: - manifest = self.manifest_loader.manifest - if not manifest: - return None # No manifest, no enforcement - - # Detect AI systems - user_agent = request.headers.get('User-Agent', '') - is_ai = self.enforce_for_all or is_ai_user_agent(user_agent) - - if not is_ai: - return None # Not an AI system, allow through - - # Extract purpose - purpose = extract_ai_purpose(request.headers) - - # Get applicable policy - policy = get_applicable_policy(manifest, purpose, request.path) - - if not policy: - return None # No policy for this purpose/path - - # Check policy status - if policy['status'] == 'refused': - if self.on_violation: - self.on_violation(request, policy, purpose) - return create_430_response(manifest, policy, purpose) - - if policy['status'] == 'conditional': - satisfied, missing = check_policy_conditions(policy, request.headers) - if not satisfied: - if self.on_violation: - self.on_violation(request, policy, purpose) - - response = create_430_response(manifest, policy, purpose) - response_data = response.get_json() - response_data['missing_conditions'] = missing - return make_response(jsonify(response_data), 430) - - # Policy satisfied or allowed - return None - - except Exception as e: - print(f"AIBDP middleware error: {e}") - return None # Fail open - don't break the site - - -def serve_manifest(manifest_path: str = '.well-known/aibdp.json'): - """ - Create Flask route handler to serve AIBDP manifest. - - Args: - manifest_path: Path to manifest file - - Returns: - Route handler function - """ - manifest_loader = AIBDPManifest(manifest_path) - - def handler(): - manifest = manifest_loader.manifest - if not manifest: - return jsonify({'error': 'Manifest not found'}), 404 - - response = make_response(jsonify(manifest)) - response.headers['Content-Type'] = 'application/aibdp+json' - response.headers['Cache-Control'] = 'public, max-age=3600' # 1 hour - response.headers['Access-Control-Allow-Origin'] = '*' - return response - - return handler - - -def aibdp_required( - manifest_path: str = '.well-known/aibdp.json', - purpose: str = 'unknown' -): - """ - Decorator to protect specific Flask routes with AIBDP enforcement. - - Args: - manifest_path: Path to AIBDP manifest - purpose: AI purpose to check against (training, indexing, etc.) - - Returns: - Decorator function - - Example: - @app.route('/article') - @aibdp_required(purpose='training') - def article(): - return 'Protected content' - """ - manifest_loader = AIBDPManifest(manifest_path) - - def decorator(f): - @wraps(f) - def wrapper(*args, **kwargs): - manifest = manifest_loader.manifest - if not manifest: - return f(*args, **kwargs) # No manifest, allow through - - user_agent = request.headers.get('User-Agent', '') - if not is_ai_user_agent(user_agent): - return f(*args, **kwargs) # Not AI, allow through - - policy = get_applicable_policy(manifest, purpose, request.path) - if not policy: - return f(*args, **kwargs) # No policy, allow through - - if policy['status'] == 'refused': - return create_430_response(manifest, policy, purpose) - - if policy['status'] == 'conditional': - satisfied, _ = check_policy_conditions(policy, request.headers) - if not satisfied: - return create_430_response(manifest, policy, purpose) - - return f(*args, **kwargs) - - return wrapper - return decorator diff --git a/consent-aware-http/examples/reference-implementations/python/example_server.py b/consent-aware-http/examples/reference-implementations/python/example_server.py deleted file mode 100644 index 5b19dd8d..00000000 --- a/consent-aware-http/examples/reference-implementations/python/example_server.py +++ /dev/null @@ -1,156 +0,0 @@ -# SPDX-License-Identifier: MIT OR GPL-3.0-or-later -# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell - -""" -Example Flask server demonstrating AIBDP + HTTP 430 middleware - -This example shows how to integrate consent-aware HTTP infrastructure -into a Flask application. - -Usage: - pip install -r requirements.txt - python example_server.py - -Test with: - curl http://localhost:5000/ - curl http://localhost:5000/article -H "User-Agent: GPTBot/1.0" - curl http://localhost:5000/.well-known/aibdp.json -""" - -from flask import Flask, render_template_string -from aibdp_middleware import AIBDPMiddleware, serve_manifest, aibdp_required - -app = Flask(__name__) - -# Initialize AIBDP middleware -middleware = AIBDPMiddleware( - app, - manifest_path='example-aibdp.json', - enforce_for_all=False, # Only enforce for detected AI systems - on_violation=lambda req, policy, purpose: print( - f"[AIBDP] Blocked: {req.method} {req.path}\n" - f" User-Agent: {req.headers.get('User-Agent')}\n" - f" Purpose: {purpose}\n" - f" Policy: {policy['status']}" - ) -) - -# Serve AIBDP manifest -app.route('/.well-known/aibdp.json')(serve_manifest('example-aibdp.json')) - - -# Routes -@app.route('/') -def index(): - return render_template_string(''' - - - Consent-Aware HTTP Example (Python/Flask) - - -

Welcome to Consent-Aware HTTP

-

This Flask server implements HTTP 430 + AIBDP.

- -

Try these requests:

-
    -
  • curl http://localhost:5000/ - Normal access (allowed)
  • -
  • curl http://localhost:5000/article -H "User-Agent: GPTBot/1.0" - AI bot (may be blocked)
  • -
  • curl http://localhost:5000/.well-known/aibdp.json - View AIBDP manifest
  • -
- -

Resources:

- - - - ''') - - -@app.route('/article') -def article(): - return render_template_string(''' - - - Protected Article - - -

Protected Article

-

This content has AI usage boundaries declared via AIBDP.

-

Training: Conditional (requires attribution)

-

Generation: Refused

-

Indexing: Allowed

- - - ''') - - -@app.route('/public') -def public(): - return render_template_string(''' - - - Public Content - - -

Public Content

-

This content is available for all purposes, including AI training.

- - - ''') - - -@app.route('/protected') -@aibdp_required(purpose='training') -def protected(): - """ - Example of using @aibdp_required decorator for route-specific protection. - """ - return render_template_string(''' - - - Protected Route - - -

Protected Route (Decorator)

-

This route uses the @aibdp_required decorator.

-

AI training is not permitted on this content.

- - - ''') - - -@app.route('/health') -def health(): - return { - 'status': 'healthy', - 'aibdp_enabled': True, - 'http_430_enabled': True - } - - -@app.errorhandler(404) -def not_found(e): - return 'Not Found', 404 - - -@app.errorhandler(500) -def server_error(e): - return 'Internal Server Error', 500 - - -if __name__ == '__main__': - print('🚀 Consent-Aware HTTP server (Python/Flask) running on http://localhost:5000') - print('📄 AIBDP manifest: http://localhost:5000/.well-known/aibdp.json') - print('🛡️ HTTP 430 enforcement: ENABLED') - print('') - print('Try these commands:') - print(' curl http://localhost:5000/') - print(' curl http://localhost:5000/article -H "User-Agent: GPTBot/1.0"') - print(' curl http://localhost:5000/.well-known/aibdp.json') - print('') - - app.run(debug=True, port=5000) diff --git a/consent-aware-http/examples/reference-implementations/python/requirements.txt b/consent-aware-http/examples/reference-implementations/python/requirements.txt deleted file mode 100644 index 7fa6f555..00000000 --- a/consent-aware-http/examples/reference-implementations/python/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask>=3.0.0 -jsonschema>=4.19.0