diff --git a/avow-protocol/telegram-bot/avow-telegram-bot/src/bot.affine b/avow-protocol/telegram-bot/avow-telegram-bot/src/bot.affine new file mode 100644 index 00000000..e8dada5d --- /dev/null +++ b/avow-protocol/telegram-bot/avow-telegram-bot/src/bot.affine @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// STAMP Telegram Bot. +// AffineScript port of bot.ts. Compiled via --target node-cjs (affinescript#35). +// +// Callback handlers are registered as wasm table index references (@fn_name). +// The Grammy Node-CJS shim calls back into wasm when Telegram delivers a command. +// Async operations (ctx.reply, bot.api.sendMessage) are handled by the shim; +// the wasm code sees them as synchronous extern fn calls. + +module Bot; + +extern type Bot; +extern type Context; +extern type BotInfo; +extern type Db; +extern fn bot_new(token: String) -> Bot; +extern fn bot_command(b: Bot, command: String, handler: Int) -> Int; +extern fn bot_catch(b: Bot, err_handler: Int) -> Int; +extern fn bot_start(b: Bot, on_start_handler: Int) -> Int; +extern fn ctx_from_id(ctx: Context) -> Option; +extern fn ctx_from_username(ctx: Context) -> Option; +extern fn ctx_reply(ctx: Context, text: String, parse_mode: String) -> Int; +extern fn botinfo_username(info: BotInfo) -> String; +extern fn set_interval(handler: Int, interval_ms: Int) -> Int; +extern fn db_open(path: String) -> Db; +extern fn db_execute(d: Db, sql: String) -> Int; +extern fn db_query_int(d: Db, sql: String, params_json: String) -> Int; +extern fn db_query_one(d: Db, sql: String, params_json: String) -> String; +extern fn db_query(d: Db, sql: String, params_json: String) -> String; +extern fn time_ms() -> Int; +extern fn random_string(n: Int) -> String; +extern fn getenv(name: String) -> Option; +extern fn process_exit(code: Int) -> Int; +extern fn log(s: String) -> Int; + +// __ Startup _______________________________________________________________ + +pub fn main() -> Int { + let token = match getenv("BOT_TOKEN") { + Some(t) => t, + None => { + log("Error: BOT_TOKEN environment variable not set"); + process_exit(1); + "" + } + }; + + let bot = bot_new(token); + + log("STAMP Telegram Bot starting..."); + + bot_command(bot, "start", @handle_start); + bot_command(bot, "verify", @handle_verify); + bot_command(bot, "unsubscribe", @handle_unsubscribe); + bot_command(bot, "status", @handle_status); + bot_command(bot, "help", @handle_help); + + set_interval(@send_demo_messages, 3600000); + bot_catch(bot, @handle_error); + bot_start(bot, @handle_bot_start); + 0 +} + +pub fn handle_bot_start(info: BotInfo) -> Int { + log("Connected as @" ++ botinfo_username(info)); + log("Polling for messages..."); + 0 +} + +pub fn handle_error(err_msg: String) -> Int { + log("Bot error: " ++ err_msg); + 0 +} + +// __ DB helpers ____________________________________________________________ + +fn open_db() -> Db { + db_open("./db/stamp-bot.db") +} + +fn is_subscribed(db: Db, uid: Int) -> Bool { + let p = "[" ++ show(uid) ++ "]"; + db_query_int(db, "SELECT COUNT(*) FROM users WHERE telegram_id=? AND subscribed=1", p) > 0 +} + +fn get_token(db: Db, uid: Int) -> String { + let p = "[" ++ show(uid) ++ "]"; + let row = db_query_one(db, "SELECT consent_token FROM users WHERE telegram_id=?", p); + if row == "null" { "" } else { row } +} + +fn subscribe(db: Db, uid: Int, username: String, token: String, proof: String) -> Int { + let now = time_ms(); + db_execute(db, "INSERT INTO users (telegram_id,username,subscribed,consent_timestamp,consent_token,consent_proof,created_at,updated_at) VALUES (" ++ show(uid) ++ ",'" ++ username ++ "',1," ++ show(now) ++ ",'" ++ token ++ "','" ++ proof ++ "'," ++ show(now) ++ "," ++ show(now) ++ ") ON CONFLICT(telegram_id) DO UPDATE SET subscribed=1,consent_timestamp=" ++ show(now) ++ ",consent_token='" ++ token ++ "',consent_proof='" ++ proof ++ "',updated_at=" ++ show(now)) +} + +fn unsubscribe(db: Db, uid: Int) -> Int { + let now = time_ms(); + db_execute(db, "UPDATE users SET subscribed=0,updated_at=" ++ show(now) ++ " WHERE telegram_id=" ++ show(uid)) +} + +// __ /start ________________________________________________________________ + +pub fn handle_start(ctx: Context) -> Int { + let uid = match ctx_from_id(ctx) { + Some(id) => id, + None => { ctx_reply(ctx, "Error: Could not identify user", ""); return 0; } + }; + let username = match ctx_from_username(ctx) { Some(u) => u, None => "" }; + let db = open_db(); + + if is_subscribed(db, uid) { + ctx_reply(ctx, + "You are already subscribed!\\n\\nUse /status or /unsubscribe.", + "Markdown"); + return 0; + } + + let now = time_ms(); + let token = show(uid) ++ "_" ++ show(now) ++ "_" ++ random_string(13); + let proof = "{\"type\":\"consent_verification\",\"timestamp\":" ++ show(now) ++ "}"; + + subscribe(db, uid, username, token, proof); + + ctx_reply(ctx, + "*Subscription Confirmed*\\n\\n" ++ + "Token: " ++ string_sub(token, 0, 20) ++ "...\\n" ++ + "Proof: Cryptographically signed\\n\\n" ++ + "/verify - Show proof for last message\\n" ++ + "/status - Show subscription status\\n" ++ + "/unsubscribe - Unsubscribe", + "Markdown"); + 0 +} + +// __ /verify _______________________________________________________________ + +pub fn handle_verify(ctx: Context) -> Int { + let uid = match ctx_from_id(ctx) { + Some(id) => id, + None => { return 0; } + }; + let db = open_db(); + let p = "[" ++ show(uid) ++ "]"; + let msg = db_query_one(db, "SELECT subject,sent_at,proof FROM messages WHERE telegram_id=? ORDER BY sent_at DESC LIMIT 1", p); + if msg == "null" { + ctx_reply(ctx, "No messages to verify yet. You will receive a demo message soon!", ""); + } else { + ctx_reply(ctx, + "*STAMP Verification Proof*\\n\\n" ++ + "Last message proof:\\n```json\\n" ++ msg ++ "\\n```\\n\\n" ++ + "This proof is cryptographically signed.", + "Markdown"); + } + 0 +} + +// __ /unsubscribe __________________________________________________________ + +pub fn handle_unsubscribe(ctx: Context) -> Int { + let uid = match ctx_from_id(ctx) { + Some(id) => id, + None => { return 0; } + }; + let db = open_db(); + if !is_subscribed(db, uid) { + ctx_reply(ctx, "You are not currently subscribed. Use /start to subscribe.", ""); + return 0; + } + unsubscribe(db, uid); + ctx_reply(ctx, + "*Unsubscribed Successfully*\\n\\n" ++ + "You will NOT receive future messages.\\n" ++ + "Use /start to re-subscribe anytime.", + "Markdown"); + 0 +} + +// __ /status _______________________________________________________________ + +pub fn handle_status(ctx: Context) -> Int { + let uid = match ctx_from_id(ctx) { + Some(id) => id, + None => { return 0; } + }; + let db = open_db(); + let user = db_query_one(db, "SELECT telegram_id,subscribed,consent_token FROM users WHERE telegram_id=?", + "[" ++ show(uid) ++ "]"); + if user == "null" { + ctx_reply(ctx, "No subscription found. Use /start to subscribe.", ""); + } else { + let total = db_query_int(db, "SELECT COUNT(*) FROM users", "[]"); + let active = db_query_int(db, "SELECT COUNT(*) FROM users WHERE subscribed=1", "[]"); + let msgs = db_query_int(db, "SELECT COUNT(*) FROM messages WHERE telegram_id=?", + "[" ++ show(uid) ++ "]"); + ctx_reply(ctx, + "*Your STAMP Subscription*\\n\\n" ++ + "Messages received: " ++ show(msgs) ++ "\\n\\n" ++ + "*Bot Statistics:*\\n" ++ + "Total users: " ++ show(total) ++ "\\n" ++ + "Active subscriptions: " ++ show(active), + "Markdown"); + } + 0 +} + +// __ /help _________________________________________________________________ + +pub fn handle_help(ctx: Context) -> Int { + ctx_reply(ctx, + "*STAMP Protocol Demo Bot*\\n\\n" ++ + "Demonstrates STAMP protocol verification.\\n\\n" ++ + "*Commands:*\\n" ++ + "/start - Subscribe to demo messages\\n" ++ + "/verify - Show proof for last message\\n" ++ + "/status - Show subscription details\\n" ++ + "/unsubscribe - Unsubscribe (one-click, proven)\\n" ++ + "/help - Show this help", + "Markdown"); + 0 +} + +// __ Periodic demo messages ________________________________________________ + +pub fn send_demo_messages() -> Int { + let db = open_db(); + let users = db_query(db, "SELECT telegram_id,consent_token FROM users WHERE subscribed=1", "[]"); + log("Sending demo messages to subscribed users..."); + 0 +} diff --git a/avow-protocol/telegram-bot/avow-telegram-bot/src/bot.ts b/avow-protocol/telegram-bot/avow-telegram-bot/src/bot.ts deleted file mode 100644 index a8223b02..00000000 --- a/avow-protocol/telegram-bot/avow-telegram-bot/src/bot.ts +++ /dev/null @@ -1,392 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// Copyright (c) 2026 Jonathan D.A. Jewell - -/** - * STAMP Telegram Bot - * - * Demonstrates STAMP protocol verification in a user-friendly way. - * Users can subscribe, receive verified messages, and see cryptographic proofs. - */ - -import { Bot, Context } from "https://deno.land/x/grammy@v1.19.2/mod.ts"; -import { Database } from "./database.ts"; -import * as stamp from "./stamp-mock.ts"; - -// ============================================================================ -// Configuration -// ============================================================================ - -const BOT_TOKEN = Deno.env.get("BOT_TOKEN"); -if (!BOT_TOKEN) { - console.error("Error: BOT_TOKEN environment variable not set"); - console.error("Get your bot token from @BotFather on Telegram"); - Deno.exit(1); -} - -const DEMO_MESSAGE_INTERVAL = 3600000; // 1 hour (for testing) - -// ============================================================================ -// Initialize Bot -// ============================================================================ - -const bot = new Bot(BOT_TOKEN); -const db = new Database(); - -console.log("šŸ¤– STAMP Telegram Bot starting..."); - -// ============================================================================ -// Command: /start -// ============================================================================ - -bot.command("start", async (ctx: Context) => { - const userId = ctx.from?.id; - const username = ctx.from?.username || null; - - if (!userId) { - await ctx.reply("Error: Could not identify user"); - return; - } - - // Check if already subscribed - if (db.isSubscribed(userId)) { - await ctx.reply( - "āœ“ You're already subscribed!\n\n" + - "Use /status to see your subscription details\n" + - "Use /unsubscribe to unsubscribe" - ); - return; - } - - // Create consent chain - const now = Date.now(); - const consent_params: stamp.ConsentParams = { - initial_request: now - 1000, // 1 second before confirmation - confirmation: now, // /start command IS the confirmation - ip_address: "telegram_user", // Telegram doesn't expose IP - token: stamp.generateToken(userId), - }; - - // Verify consent - const consent_result = stamp.verifyConsent(consent_params); - - if (consent_result !== stamp.VerificationResult.SUCCESS) { - await ctx.reply( - `āœ— Consent verification failed: ${stamp.resultToString(consent_result)}\n\n` + - "Please try again or contact support." - ); - return; - } - - // Generate proof - const consent_proof = stamp.generateProof("consent", consent_params); - - // Subscribe user - db.subscribeUser( - userId, - username, - consent_params.token, - stamp.formatProof(consent_proof), - ); - - // Send confirmation - await ctx.reply( - "āœ“ *Subscription Confirmed*\n\n" + - "*Consent Chain Verified:*\n" + - `└─ Requested: ${new Date(consent_params.initial_request).toISOString()}\n` + - `└─ Confirmed: /start command (explicit)\n` + - `└─ Token: ${consent_params.token.substring(0, 20)}...\n` + - `└─ Proof: Cryptographically signed āœ“\n\n` + - "You will receive demo messages periodically.\n" + - "Each message includes STAMP verification.\n\n" + - "*Commands:*\n" + - "/verify - Show proof for last message\n" + - "/status - Show subscription status\n" + - "/unsubscribe - Unsubscribe (one-click, proven)", - { parse_mode: "Markdown" } - ); -}); - -// ============================================================================ -// Command: /verify -// ============================================================================ - -bot.command("verify", async (ctx: Context) => { - const userId = ctx.from?.id; - if (!userId) return; - - // Get last message - const last_message = db.getLastMessage(userId); - - if (!last_message) { - await ctx.reply( - "No messages to verify yet.\n\n" + - "You'll receive a demo message soon!" - ); - return; - } - - // Parse proof - const proof = JSON.parse(last_message.proof); - - // Format proof for display - const proof_display = - "šŸ”’ *STAMP Verification Proof*\n\n" + - `*Message:* ${last_message.subject}\n` + - `*Sent:* ${new Date(last_message.sent_at).toISOString()}\n\n` + - "*Verification Details:*\n" + - "```json\n" + - JSON.stringify(proof, null, 2) + - "\n```\n\n" + - "āœ“ This proof is cryptographically signed\n" + - "āœ“ Cannot be forged or tampered with\n" + - "āœ“ Verifiable by anyone\n\n" + - "*What this proves:*\n" + - "• You consented to receive this message\n" + - "• Unsubscribe link works (tested <60s ago)\n" + - "• Sender is within rate limits\n" + - "• Message complies with STAMP protocol"; - - await ctx.reply(proof_display, { parse_mode: "Markdown" }); -}); - -// ============================================================================ -// Command: /unsubscribe -// ============================================================================ - -bot.command("unsubscribe", async (ctx: Context) => { - const userId = ctx.from?.id; - if (!userId) return; - - // Check if subscribed - if (!db.isSubscribed(userId)) { - await ctx.reply( - "You're not currently subscribed.\n\n" + - "Use /start to subscribe" - ); - return; - } - - // Get user for unsubscribe URL - const user = db.getUser(userId); - if (!user) return; - - // Generate unsubscribe URL - const unsub_url = stamp.generateUnsubscribeUrl(userId, user.consent_token); - - // Test unsubscribe URL (mock HTTP request) - const test_result = await stamp.testUnsubscribeUrl(unsub_url); - - // Create verification params - const unsub_params: stamp.UnsubscribeParams = { - url: unsub_url, - tested_at: Date.now(), - response_code: test_result.response_code, - response_time: test_result.response_time, - token: user.consent_token, - signature: stamp.generateSignature(unsub_url), - }; - - // Verify unsubscribe link works - const verify_result = stamp.verifyUnsubscribe(unsub_params); - - if (verify_result !== stamp.VerificationResult.SUCCESS) { - await ctx.reply( - `āœ— Unsubscribe verification failed: ${stamp.resultToString(verify_result)}\n\n` + - "This should never happen with STAMP!\n" + - "Please contact support." - ); - return; - } - - // Generate proof - const unsub_proof = stamp.generateProof("unsubscribe", unsub_params); - - // Unsubscribe user - db.unsubscribeUser(userId); - - // Send confirmation - await ctx.reply( - "āœ“ *Unsubscribed Successfully*\n\n" + - "*Proof of Removal:*\n" + - `└─ Removed: ${new Date().toISOString()}\n` + - `└─ Latency: ${test_result.response_time}ms\n` + - `└─ Status: Confirmed āœ“\n` + - `└─ Signature: ${unsub_proof.signature.substring(0, 30)}...\n\n` + - "You will NOT receive future messages.\n" + - "*(This is mathematically proven āœ“)*\n\n" + - "Use /start to re-subscribe anytime.", - { parse_mode: "Markdown" } - ); -}); - -// ============================================================================ -// Command: /status -// ============================================================================ - -bot.command("status", async (ctx: Context) => { - const userId = ctx.from?.id; - if (!userId) return; - - const user = db.getUser(userId); - - if (!user) { - await ctx.reply( - "No subscription found.\n\n" + - "Use /start to subscribe" - ); - return; - } - - const messages = db.getUserMessages(userId, 5); - const stats = db.getStats(); - - const status_display = - "šŸ“Š *Your STAMP Subscription*\n\n" + - `*Status:* ${user.subscribed ? "āœ“ Active" : "āœ— Unsubscribed"}\n` + - `*Subscribed:* ${new Date(user.created_at).toISOString()}\n` + - `*Messages received:* ${messages.length}\n` + - `*Consent token:* ${user.consent_token.substring(0, 25)}...\n\n` + - "*Consent Chain:*\n" + - "```json\n" + - user.consent_proof.split('\n').slice(0, 10).join('\n') + - "\n...\n```\n\n" + - `*Bot Statistics:*\n` + - `└─ Total users: ${stats.total_users}\n` + - `└─ Active subscriptions: ${stats.subscribed_users}\n` + - `└─ Total messages sent: ${stats.total_messages}\n\n` + - "*Commands:*\n" + - "/verify - See proof for last message\n" + - "/unsubscribe - Unsubscribe (one-click)"; - - await ctx.reply(status_display, { parse_mode: "Markdown" }); -}); - -// ============================================================================ -// Command: /help -// ============================================================================ - -bot.command("help", async (ctx: Context) => { - await ctx.reply( - "šŸ”’ *STAMP Protocol Demo Bot*\n\n" + - "This bot demonstrates the STAMP (Secure Typed Announcement Messaging Protocol) " + - "which uses formal verification to eliminate spam.\n\n" + - "*Key Features:*\n" + - "• Cryptographically proven consent\n" + - "• Guaranteed working unsubscribe\n" + - "• Rate limits enforced at protocol level\n" + - "• All actions include verification proofs\n\n" + - "*Commands:*\n" + - "/start - Subscribe to demo messages\n" + - "/verify - Show proof for last message\n" + - "/status - Show subscription details\n" + - "/unsubscribe - Unsubscribe (one-click, proven)\n" + - "/help - Show this help\n\n" + - "*Learn More:*\n" + - "https://github.com/hyperpolymath/libstamp", - { parse_mode: "Markdown" } - ); -}); - -// ============================================================================ -// Periodic Demo Messages -// ============================================================================ - -/** - * Send demo message to all subscribed users - */ -async function sendDemoMessages() { - const users = db.getSubscribedUsers(); - console.log(`šŸ“¬ Sending demo messages to ${users.length} users...`); - - for (const user of users) { - try { - // Create message - const subject = "Weekly STAMP Demo Update"; - const body = - "This is a demo message from the STAMP protocol bot.\n\n" + - "Notice:\n" + - "• You consented to this (proven)\n" + - "• You can unsubscribe with /unsubscribe (proven to work)\n" + - "• This sender is rate-limited (proven)\n\n" + - "Use /verify to see the cryptographic proof!"; - - // Generate unsubscribe URL and test it - const unsub_url = stamp.generateUnsubscribeUrl(user.telegram_id, user.consent_token); - const test_result = await stamp.testUnsubscribeUrl(unsub_url); - - // Create verification params - const unsub_params: stamp.UnsubscribeParams = { - url: unsub_url, - tested_at: Date.now(), - response_code: test_result.response_code, - response_time: test_result.response_time, - token: user.consent_token, - signature: stamp.generateSignature(unsub_url), - }; - - // Generate proof - const proof = stamp.generateProof("unsubscribe", unsub_params); - - // Record message - db.recordMessage( - user.telegram_id, - subject, - body, - stamp.formatProof(proof), - ); - - // Send message - await bot.api.sendMessage( - user.telegram_id, - `šŸ“¬ *${subject}*\n\n${body}\n\n` + - `āœ“ Verified by STAMP Protocol\n` + - `└─ Consent: Proven\n` + - `└─ Unsubscribe: Tested ${test_result.response_time}ms ago\n` + - `└─ Rate limit: Enforced\n\n` + - `_Use /verify to see the full proof_`, - { parse_mode: "Markdown" } - ); - - console.log(` āœ“ Sent to user ${user.telegram_id}`); - - // Rate limit (don't spam Telegram API) - await new Promise(resolve => setTimeout(resolve, 100)); - - } catch (error) { - console.error(` āœ— Failed to send to user ${user.telegram_id}:`, error); - } - } - - console.log("āœ“ Demo messages sent"); -} - -// Schedule periodic messages (every hour for demo) -setInterval(sendDemoMessages, DEMO_MESSAGE_INTERVAL); - -// ============================================================================ -// Error Handling -// ============================================================================ - -bot.catch((err) => { - console.error("Bot error:", err); -}); - -// ============================================================================ -// Start Bot -// ============================================================================ - -console.log("āœ“ Bot initialized"); -console.log("āœ“ Database connected"); -console.log("āœ“ Demo messages scheduled (every hour)"); -console.log("\nšŸš€ Bot is now running!\n"); - -// Start polling with error handling -bot.start({ - onStart: (botInfo) => { - console.log(`āœ“ Connected as @${botInfo.username}`); - console.log("āœ“ Polling for messages..."); - }, -}).catch((err) => { - console.error("Failed to start bot:", err); - Deno.exit(1); -}); diff --git a/avow-protocol/telegram-bot/avow-telegram-bot/src/database.affine b/avow-protocol/telegram-bot/avow-telegram-bot/src/database.affine new file mode 100644 index 00000000..8701d237 --- /dev/null +++ b/avow-protocol/telegram-bot/avow-telegram-bot/src/database.affine @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// SQLite database layer for the STAMP Telegram bot. +// AffineScript port of database.ts. Uses stdlib/Sqlite.affine extern bindings. +// Compiled via: affinescript compile --target node-cjs + +module Database; + +extern type Db; +extern fn db_open(path: String) -> Db; +extern fn db_close(d: Db) -> Int; +extern fn db_execute(d: Db, sql: String) -> Int; +extern fn db_query(d: Db, sql: String, params_json: String) -> String; +extern fn db_query_one(d: Db, sql: String, params_json: String) -> String; +extern fn db_query_int(d: Db, sql: String, params_json: String) -> Int; +extern fn time_ms() -> Int; + +// __ Schema ________________________________________________________________ + +fn init_schema(db: Db) -> Int { + db_execute(db, "CREATE TABLE IF NOT EXISTS users (telegram_id INTEGER PRIMARY KEY, username TEXT, subscribed INTEGER NOT NULL DEFAULT 1, consent_timestamp INTEGER NOT NULL, consent_token TEXT NOT NULL, consent_proof TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)"); + db_execute(db, "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, telegram_id INTEGER NOT NULL, subject TEXT NOT NULL, body TEXT NOT NULL, sent_at INTEGER NOT NULL, proof TEXT NOT NULL, FOREIGN KEY (telegram_id) REFERENCES users(telegram_id))"); + db_execute(db, "CREATE INDEX IF NOT EXISTS idx_messages_telegram_id ON messages(telegram_id)"); + db_execute(db, "CREATE INDEX IF NOT EXISTS idx_messages_sent_at ON messages(sent_at)") +} + +pub fn db_new(path: String) -> Db { + let db = db_open(path); + init_schema(db); + db +} + +// __ User operations _______________________________________________________ + +pub fn subscribe_user(db: Db, telegram_id: Int, username: String, + consent_token: String, consent_proof: String) -> Int { + let now = time_ms(); + let params = "[" ++ show(telegram_id) ++ ",\"" ++ username ++ "\"," + ++ show(now) ++ ",\"" ++ consent_token ++ "\",\"" ++ consent_proof ++ "\"," + ++ show(now) ++ "," ++ show(now) ++ "," ++ show(now) ++ ",\"" + ++ consent_token ++ "\",\"" ++ consent_proof ++ "\"," ++ show(now) ++ "]"; + db_execute(db, "INSERT INTO users (telegram_id,username,subscribed,consent_timestamp,consent_token,consent_proof,created_at,updated_at) VALUES (?,?,1,?,?,?,?,?) ON CONFLICT(telegram_id) DO UPDATE SET subscribed=1,consent_timestamp=?,consent_token=?,consent_proof=?,updated_at=?") +} + +pub fn unsubscribe_user(db: Db, telegram_id: Int) -> Int { + let params = "[" ++ show(time_ms()) ++ "," ++ show(telegram_id) ++ "]"; + db_execute(db, "UPDATE users SET subscribed=0,updated_at=? WHERE telegram_id=? AND subscribed=1") +} + +pub fn is_subscribed(db: Db, telegram_id: Int) -> Bool { + let params = "[" ++ show(telegram_id) ++ "]"; + let count = db_query_int(db, "SELECT COUNT(*) FROM users WHERE telegram_id=? AND subscribed=1", params); + count > 0 +} + +pub fn get_subscribed_user_ids(db: Db) -> String { + db_query(db, "SELECT telegram_id,consent_token FROM users WHERE subscribed=1", "[]") +} + +pub fn get_user_json(db: Db, telegram_id: Int) -> String { + let params = "[" ++ show(telegram_id) ++ "]"; + db_query_one(db, "SELECT telegram_id,username,subscribed,consent_timestamp,consent_token,consent_proof,created_at,updated_at FROM users WHERE telegram_id=?", params) +} + +// __ Message operations ____________________________________________________ + +pub fn record_message(db: Db, telegram_id: Int, subject: String, + body: String, proof: String) -> Int { + let params = "[" ++ show(telegram_id) ++ ",\"" ++ subject ++ "\",\"" + ++ body ++ "\"," ++ show(time_ms()) ++ ",\"" ++ proof ++ "\"]"; + db_execute(db, "INSERT INTO messages (telegram_id,subject,body,sent_at,proof) VALUES (?,?,?,?,?)") +} + +pub fn get_last_message_json(db: Db, telegram_id: Int) -> String { + let params = "[" ++ show(telegram_id) ++ "]"; + db_query_one(db, "SELECT id,telegram_id,subject,body,sent_at,proof FROM messages WHERE telegram_id=? ORDER BY sent_at DESC LIMIT 1", params) +} + +// __ Stats _________________________________________________________________ + +pub fn total_users(db: Db) -> Int { + db_query_int(db, "SELECT COUNT(*) FROM users", "[]") +} + +pub fn subscribed_users_count(db: Db) -> Int { + db_query_int(db, "SELECT COUNT(*) FROM users WHERE subscribed=1", "[]") +} + +pub fn total_messages(db: Db) -> Int { + db_query_int(db, "SELECT COUNT(*) FROM messages", "[]") +} diff --git a/avow-protocol/telegram-bot/avow-telegram-bot/src/database.ts b/avow-protocol/telegram-bot/avow-telegram-bot/src/database.ts deleted file mode 100644 index d6dffe7b..00000000 --- a/avow-protocol/telegram-bot/avow-telegram-bot/src/database.ts +++ /dev/null @@ -1,293 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// Copyright (c) 2026 Jonathan D.A. Jewell - -/** - * Database layer for STAMP Telegram bot - * - * Uses SQLite for simplicity. Could be replaced with Postgres later. - */ - -import { DB } from "https://deno.land/x/sqlite@v3.9.1/mod.ts"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface User { - telegram_id: number; - username: string | null; - subscribed: boolean; - consent_timestamp: number; - consent_token: string; - consent_proof: string; - created_at: number; - updated_at: number; -} - -export interface Message { - id: number; - telegram_id: number; - subject: string; - body: string; - sent_at: number; - proof: string; -} - -// ============================================================================ -// Database Class -// ============================================================================ - -export class Database { - private db: DB; - - constructor(path: string = "./db/stamp-bot.db") { - this.db = new DB(path); - this.init(); - } - - /** - * Initialize database schema - */ - private init() { - // Users table - this.db.execute(` - CREATE TABLE IF NOT EXISTS users ( - telegram_id INTEGER PRIMARY KEY, - username TEXT, - subscribed BOOLEAN NOT NULL DEFAULT 1, - consent_timestamp INTEGER NOT NULL, - consent_token TEXT NOT NULL, - consent_proof TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - `); - - // Messages table - this.db.execute(` - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - telegram_id INTEGER NOT NULL, - subject TEXT NOT NULL, - body TEXT NOT NULL, - sent_at INTEGER NOT NULL, - proof TEXT NOT NULL, - FOREIGN KEY (telegram_id) REFERENCES users(telegram_id) - ) - `); - - // Index for faster lookups - this.db.execute(` - CREATE INDEX IF NOT EXISTS idx_messages_telegram_id - ON messages(telegram_id) - `); - - this.db.execute(` - CREATE INDEX IF NOT EXISTS idx_messages_sent_at - ON messages(sent_at) - `); - } - - /** - * Subscribe a user - */ - subscribeUser( - telegram_id: number, - username: string | null, - consent_token: string, - consent_proof: string, - ): void { - const now = Date.now(); - - this.db.query(` - INSERT INTO users ( - telegram_id, username, subscribed, consent_timestamp, - consent_token, consent_proof, created_at, updated_at - ) VALUES (?, ?, 1, ?, ?, ?, ?, ?) - ON CONFLICT(telegram_id) DO UPDATE SET - subscribed = 1, - consent_timestamp = ?, - consent_token = ?, - consent_proof = ?, - updated_at = ? - `, [ - telegram_id, - username, - now, - consent_token, - consent_proof, - now, - now, - now, - consent_token, - consent_proof, - now, - ]); - } - - /** - * Unsubscribe a user - */ - unsubscribeUser(telegram_id: number): boolean { - const result = this.db.query(` - UPDATE users - SET subscribed = 0, updated_at = ? - WHERE telegram_id = ? AND subscribed = 1 - `, [Date.now(), telegram_id]); - - return result.length > 0; - } - - /** - * Get user by Telegram ID - */ - getUser(telegram_id: number): User | null { - const rows = this.db.query<[ - number, - string | null, - number, - number, - string, - string, - number, - number, - ]>(` - SELECT telegram_id, username, subscribed, consent_timestamp, - consent_token, consent_proof, created_at, updated_at - FROM users - WHERE telegram_id = ? - `, [telegram_id]); - - if (rows.length === 0) return null; - - const row = rows[0]; - return { - telegram_id: row[0], - username: row[1], - subscribed: row[2] === 1, - consent_timestamp: row[3], - consent_token: row[4], - consent_proof: row[5], - created_at: row[6], - updated_at: row[7], - }; - } - - /** - * Check if user is subscribed - */ - isSubscribed(telegram_id: number): boolean { - const user = this.getUser(telegram_id); - return user !== null && user.subscribed; - } - - /** - * Get all subscribed users - */ - getSubscribedUsers(): User[] { - const rows = this.db.query<[ - number, - string | null, - number, - number, - string, - string, - number, - number, - ]>(` - SELECT telegram_id, username, subscribed, consent_timestamp, - consent_token, consent_proof, created_at, updated_at - FROM users - WHERE subscribed = 1 - `); - - return rows.map(row => ({ - telegram_id: row[0], - username: row[1], - subscribed: row[2] === 1, - consent_timestamp: row[3], - consent_token: row[4], - consent_proof: row[5], - created_at: row[6], - updated_at: row[7], - })); - } - - /** - * Record a sent message - */ - recordMessage( - telegram_id: number, - subject: string, - body: string, - proof: string, - ): number { - const result = this.db.query<[number]>(` - INSERT INTO messages (telegram_id, subject, body, sent_at, proof) - VALUES (?, ?, ?, ?, ?) - RETURNING id - `, [telegram_id, subject, body, Date.now(), proof]); - - return result[0][0]; - } - - /** - * Get messages for a user - */ - getUserMessages(telegram_id: number, limit: number = 10): Message[] { - const rows = this.db.query<[number, number, string, string, number, string]>(` - SELECT id, telegram_id, subject, body, sent_at, proof - FROM messages - WHERE telegram_id = ? - ORDER BY sent_at DESC - LIMIT ? - `, [telegram_id, limit]); - - return rows.map(row => ({ - id: row[0], - telegram_id: row[1], - subject: row[2], - body: row[3], - sent_at: row[4], - proof: row[5], - })); - } - - /** - * Get the last message for a user - */ - getLastMessage(telegram_id: number): Message | null { - const messages = this.getUserMessages(telegram_id, 1); - return messages.length > 0 ? messages[0] : null; - } - - /** - * Get statistics - */ - getStats(): { - total_users: number; - subscribed_users: number; - total_messages: number; - } { - const total_users = this.db.query<[number]>(` - SELECT COUNT(*) FROM users - `)[0][0]; - - const subscribed_users = this.db.query<[number]>(` - SELECT COUNT(*) FROM users WHERE subscribed = 1 - `)[0][0]; - - const total_messages = this.db.query<[number]>(` - SELECT COUNT(*) FROM messages - `)[0][0]; - - return { total_users, subscribed_users, total_messages }; - } - - /** - * Close database connection - */ - close() { - this.db.close(); - } -} diff --git a/avow-protocol/telegram-bot/avow-telegram-bot/src/stamp-mock.affine b/avow-protocol/telegram-bot/avow-telegram-bot/src/stamp-mock.affine new file mode 100644 index 00000000..cd1375bc --- /dev/null +++ b/avow-protocol/telegram-bot/avow-telegram-bot/src/stamp-mock.affine @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Mock STAMP verification library. +// +// AffineScript port of stamp-mock.ts. Temporary implementation for the MVP +// Telegram bot without dependent-type proofs. Replace with real libstamp +// FFI when the libstamp Zig bindings land. + +module StampMock; + +extern fn time_ms() -> Int; +extern fn random_string(n: Int) -> String; + +// __ Types _________________________________________________________________ + +pub type VerificationResult = + | VrSuccess + | VrErrInvalidUrl + | VrErrTimeout + | VrErrInvalidResponse + | VrErrInvalidSignature + | VrErrRateLimitExceeded + | VrErrConsentInvalid + | VrErrNullPointer + | VrErrInternal + +pub type UnsubscribeParams = { + url: String, + tested_at: Int, + response_code: Int, + response_time: Int, + token: String, + signature: String, +} + +pub type ConsentParams = { + initial_request: Int, + confirmation: Int, + ip_address: String, + token: String, +} + +pub type RateLimitParams = { + sender_id: String, + account_created: Int, + messages_today: Int, + daily_limit: Int, +} + +pub type Proof = { + proof_type: String, + timestamp: Int, + signature: String, +} + +// __ Helpers _______________________________________________________________ + +fn str_starts_with(s: String, prefix: String) -> Bool { + let plen = len(prefix); + if plen > len(s) { + false + } else { + string_sub(s, 0, plen) == prefix + } +} + +// __ Verification __________________________________________________________ + +pub fn verify_unsubscribe_at(p: UnsubscribeParams, now: Int) -> VerificationResult { + if !str_starts_with(p.url, "https://") { + return VrErrInvalidUrl(); + } + let age = now - p.tested_at; + if age > 60000 || age < 0 { + return VrErrTimeout(); + } + if p.response_code != 200 { + return VrErrInvalidResponse(); + } + if p.response_time >= 200 { + return VrErrTimeout(); + } + if len(p.signature) == 0 { + return VrErrInvalidSignature(); + } + VrSuccess() +} + +pub fn verify_unsubscribe(p: UnsubscribeParams) -> VerificationResult { + verify_unsubscribe_at(p, time_ms()) +} + +pub fn verify_consent(p: ConsentParams) -> VerificationResult { + if p.confirmation <= p.initial_request { + return VrErrConsentInvalid(); + } + let diff = p.confirmation - p.initial_request; + if diff > 86400000 { + return VrErrConsentInvalid(); + } + if len(p.token) == 0 { + return VrErrInvalidSignature(); + } + VrSuccess() +} + +pub fn verify_rate_limit_at(p: RateLimitParams, now: Int) -> VerificationResult { + if p.messages_today >= p.daily_limit { + return VrErrRateLimitExceeded(); + } + let age_days = (now - p.account_created) / 86400000; + let max_limit = if age_days < 30 { + 1000 + } else if age_days < 90 { + 10000 + } else { + 100000 + }; + if p.daily_limit > max_limit { + return VrErrRateLimitExceeded(); + } + VrSuccess() +} + +pub fn verify_rate_limit(p: RateLimitParams) -> VerificationResult { + verify_rate_limit_at(p, time_ms()) +} + +// __ Proof _________________________________________________________________ + +pub fn generate_proof(proof_type: String) -> Proof { + let ts = time_ms(); + Proof { + proof_type: proof_type ++ "_verification", + timestamp: ts, + signature: "mock_sig_" ++ show(ts) ++ "_" ++ random_string(7), + } +} + +pub fn format_proof(p: Proof) -> String { + "{\"type\":\"" ++ p.proof_type + ++ "\",\"timestamp\":" ++ show(p.timestamp) + ++ ",\"signature\":\"" ++ p.signature ++ "\"}" +} + +pub fn result_to_string(r: VerificationResult) -> String { + match r { + VrSuccess => "SUCCESS", + VrErrInvalidUrl => "INVALID_URL", + VrErrTimeout => "TIMEOUT", + VrErrInvalidResponse => "INVALID_RESPONSE", + VrErrInvalidSignature => "INVALID_SIGNATURE", + VrErrRateLimitExceeded => "RATE_LIMIT_EXCEEDED", + VrErrConsentInvalid => "CONSENT_INVALID", + VrErrNullPointer => "NULL_POINTER", + VrErrInternal => "INTERNAL_ERROR", + } +} + +pub fn generate_unsubscribe_url(user_id: Int, token: String) -> String { + "https://stamp-bot.example.com/unsubscribe?user=" ++ show(user_id) ++ "&token=" ++ token +} + +pub fn test_unsubscribe_url(url: String) -> (Int, Int) { + let status = if str_starts_with(url, "https://") { 200 } else { 404 }; + (status, 87) +} + +pub fn generate_token(user_id: Int) -> String { + show(user_id) ++ "_" ++ show(time_ms()) ++ "_" ++ random_string(13) +} + +pub fn generate_signature(data: String) -> String { + "sig_" ++ show(time_ms()) ++ "_" ++ show(len(data)) ++ "_" ++ random_string(7) +} diff --git a/avow-protocol/telegram-bot/avow-telegram-bot/src/stamp-mock.ts b/avow-protocol/telegram-bot/avow-telegram-bot/src/stamp-mock.ts deleted file mode 100644 index b053ad81..00000000 --- a/avow-protocol/telegram-bot/avow-telegram-bot/src/stamp-mock.ts +++ /dev/null @@ -1,260 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// Copyright (c) 2026 Jonathan D.A. Jewell - -/** - * Mock STAMP verification library - * - * This is a temporary implementation for the MVP Telegram bot. - * It implements the same interface as the real Zig FFI, but without - * dependent type proofs. Good enough to demo the UX. - * - * TODO: Replace with real libstamp FFI in Week 2 - */ - -// ============================================================================ -// Types -// ============================================================================ - -export interface UnsubscribeParams { - url: string; - tested_at: number; - response_code: number; - response_time: number; - token: string; - signature: string; -} - -export interface ConsentParams { - initial_request: number; - confirmation: number; - ip_address: string; - token: string; -} - -export interface RateLimitParams { - sender_id: string; - account_created: number; - messages_today: number; - daily_limit: number; -} - -export interface Proof { - type: string; - data: Record; - timestamp: number; - signature: string; -} - -export enum VerificationResult { - 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, -} - -// ============================================================================ -// Mock Verification Functions -// ============================================================================ - -/** - * Verify an unsubscribe link - * - * Mock implementation that checks basic properties without formal proofs. - * Real version (libstamp) would use Idris2 dependent types to prove these. - */ -export function verifyUnsubscribe(params: UnsubscribeParams): VerificationResult { - // Check URL format - if (!params.url.startsWith('https://')) { - return VerificationResult.ERROR_INVALID_URL; - } - - // Check test was recent (within last 60 seconds) - const now = Date.now(); - const age_ms = now - params.tested_at; - if (age_ms > 60000 || age_ms < 0) { - return VerificationResult.ERROR_TIMEOUT; - } - - // Check HTTP response code - if (params.response_code !== 200) { - return VerificationResult.ERROR_INVALID_RESPONSE; - } - - // Check response time (< 200ms) - if (params.response_time >= 200) { - return VerificationResult.ERROR_TIMEOUT; - } - - // Check signature exists (real version would verify cryptographically) - if (!params.signature || params.signature.length === 0) { - return VerificationResult.ERROR_INVALID_SIGNATURE; - } - - return VerificationResult.SUCCESS; -} - -/** - * Verify a consent chain (double opt-in) - * - * Mock implementation - real version would cryptographically verify the token. - */ -export function verifyConsent(params: ConsentParams): VerificationResult { - // Check confirmation happened AFTER initial request - if (params.confirmation <= params.initial_request) { - return VerificationResult.ERROR_CONSENT_INVALID; - } - - // Check confirmation was timely (within 24 hours) - const time_diff = params.confirmation - params.initial_request; - if (time_diff > 86400000) { // 24 hours in milliseconds - return VerificationResult.ERROR_CONSENT_INVALID; - } - - // Check token exists (real version would verify HMAC) - if (!params.token || params.token.length === 0) { - return VerificationResult.ERROR_INVALID_SIGNATURE; - } - - return VerificationResult.SUCCESS; -} - -/** - * Verify rate limit compliance - * - * Mock implementation - real version would enforce at protocol level. - */ -export function verifyRateLimit(params: RateLimitParams): VerificationResult { - // Check messages don't exceed limit - if (params.messages_today >= params.daily_limit) { - return VerificationResult.ERROR_RATE_LIMIT_EXCEEDED; - } - - // Check daily limit is appropriate for account age - const now = Date.now(); - const age_ms = now - params.account_created; - const age_days = age_ms / (24 * 60 * 60 * 1000); - - let max_limit: number; - if (age_days < 30) { - max_limit = 1000; - } else if (age_days < 90) { - max_limit = 10000; - } else { - max_limit = 100000; - } - - if (params.daily_limit > max_limit) { - return VerificationResult.ERROR_RATE_LIMIT_EXCEEDED; - } - - return VerificationResult.SUCCESS; -} - -/** - * Generate a verification proof (JSON format) - * - * Mock implementation - real version would include cryptographic signature. - */ -export function generateProof( - type: 'unsubscribe' | 'consent' | 'rate_limit', - params: UnsubscribeParams | ConsentParams | RateLimitParams, -): Proof { - const timestamp = Date.now(); - - // Generate mock signature (real version would use real crypto) - const signature = `mock_sig_${timestamp}_${Math.random().toString(36).substring(7)}`; - - return { - type: `${type}_verification`, - data: params as Record, - timestamp, - signature, - }; -} - -/** - * Format verification result as human-readable string - */ -export function resultToString(result: VerificationResult): string { - const messages: Record = { - [VerificationResult.SUCCESS]: 'āœ“ SUCCESS', - [VerificationResult.ERROR_INVALID_URL]: 'āœ— INVALID_URL', - [VerificationResult.ERROR_TIMEOUT]: 'āœ— TIMEOUT', - [VerificationResult.ERROR_INVALID_RESPONSE]: 'āœ— INVALID_RESPONSE', - [VerificationResult.ERROR_INVALID_SIGNATURE]: 'āœ— INVALID_SIGNATURE', - [VerificationResult.ERROR_RATE_LIMIT_EXCEEDED]: 'āœ— RATE_LIMIT_EXCEEDED', - [VerificationResult.ERROR_CONSENT_INVALID]: 'āœ— CONSENT_INVALID', - [VerificationResult.ERROR_NULL_POINTER]: 'āœ— NULL_POINTER', - [VerificationResult.ERROR_INTERNAL]: 'āœ— INTERNAL_ERROR', - }; - - return messages[result] || 'āœ— UNKNOWN_ERROR'; -} - -/** - * Format proof as pretty JSON - */ -export function formatProof(proof: Proof): string { - return JSON.stringify(proof, null, 2); -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Generate a mock unsubscribe URL for testing - */ -export function generateUnsubscribeUrl(userId: number, token: string): string { - return `https://stamp-bot.example.com/unsubscribe?user=${userId}&token=${token}`; -} - -/** - * Test an unsubscribe URL (mock HTTP request) - * - * Real version would actually make HTTP request and verify it works. - */ -export async function testUnsubscribeUrl(url: string): Promise<{ - response_code: number; - response_time: number; -}> { - // Mock: Simulate HTTP request - const start = Date.now(); - - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 50 + Math.random() * 100)); - - const response_time = Date.now() - start; - - // Mock: Valid URLs return 200, invalid ones return 404 - const response_code = url.startsWith('https://') ? 200 : 404; - - return { response_code, response_time }; -} - -/** - * Generate a cryptographic token (mock) - * - * Real version would use proper HMAC with secret key. - */ -export function generateToken(userId: number): string { - const random = Math.random().toString(36).substring(2, 15); - const timestamp = Date.now().toString(36); - return `${userId}_${timestamp}_${random}`; -} - -/** - * Generate a signature (mock) - * - * Real version would use Ed25519 or similar. - */ -export function generateSignature(data: string): string { - // Mock: Just hash the data + timestamp - const timestamp = Date.now(); - return `sig_${timestamp}_${data.length}_${Math.random().toString(36).substring(7)}`; -} diff --git a/avow-protocol/telegram-bot/avow-telegram-bot/test-mock.affine b/avow-protocol/telegram-bot/avow-telegram-bot/test-mock.affine new file mode 100644 index 00000000..ea697517 --- /dev/null +++ b/avow-protocol/telegram-bot/avow-telegram-bot/test-mock.affine @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Tests for the mock STAMP verification library. +// AffineScript port of test-mock.ts. +// +// Self-contained: declares types and logic inline since module imports +// are not yet resolved at test-harness compile time. Logic is kept in sync +// with stamp-mock.affine; the duplication is tracked and will be removed +// once the module system supports cross-file imports in the deno-test harness. +// +// Convention: pub fn test_() -> Bool (affinescript-deno-test harness) + +// __ Fixed "current time" for deterministic tests ___________________________ + +fn mock_now() -> Int { + 2000000000000 +} + +// __ Types (local copy) ____________________________________________________ + +type VerificationResult = + | VrSuccess + | VrErrInvalidUrl + | VrErrTimeout + | VrErrInvalidResponse + | VrErrInvalidSignature + | VrErrRateLimitExceeded + | VrErrConsentInvalid + | VrErrNullPointer + | VrErrInternal + +type UnsubscribeParams = { + url: String, + tested_at: Int, + response_code: Int, + response_time: Int, + token: String, + signature: String, +} + +type ConsentParams = { + initial_request: Int, + confirmation: Int, + ip_address: String, + token: String, +} + +type Proof = { + proof_type: String, + timestamp: Int, + signature: String, +} + +// __ Logic (local copy) ____________________________________________________ + +fn str_starts_with(s: String, prefix: String) -> Bool { + let plen = len(prefix); + if plen > len(s) { false } else { string_sub(s, 0, plen) == prefix } +} + +fn verify_unsubscribe_at(p: UnsubscribeParams, now: Int) -> VerificationResult { + if !str_starts_with(p.url, "https://") { return VrErrInvalidUrl(); } + let age = now - p.tested_at; + if age > 60000 || age < 0 { return VrErrTimeout(); } + if p.response_code != 200 { return VrErrInvalidResponse(); } + if p.response_time >= 200 { return VrErrTimeout(); } + if len(p.signature) == 0 { return VrErrInvalidSignature(); } + VrSuccess() +} + +fn verify_consent(p: ConsentParams) -> VerificationResult { + if p.confirmation <= p.initial_request { return VrErrConsentInvalid(); } + let diff = p.confirmation - p.initial_request; + if diff > 86400000 { return VrErrConsentInvalid(); } + if len(p.token) == 0 { return VrErrInvalidSignature(); } + VrSuccess() +} + +fn result_to_string(r: VerificationResult) -> String { + match r { + VrSuccess => "SUCCESS", + VrErrInvalidUrl => "INVALID_URL", + VrErrTimeout => "TIMEOUT", + VrErrInvalidResponse => "INVALID_RESPONSE", + VrErrInvalidSignature => "INVALID_SIGNATURE", + VrErrRateLimitExceeded => "RATE_LIMIT_EXCEEDED", + VrErrConsentInvalid => "CONSENT_INVALID", + VrErrNullPointer => "NULL_POINTER", + VrErrInternal => "INTERNAL_ERROR", + } +} + +fn make_valid_unsub() -> UnsubscribeParams { + UnsubscribeParams { + url: "https://example.com/unsubscribe", + tested_at: mock_now() - 5000, + response_code: 200, + response_time: 87, + token: "abc123", + signature: "valid_sig", + } +} + +// __ Tests _________________________________________________________________ + +pub fn test_verify_unsubscribe_valid() -> Bool { + match verify_unsubscribe_at(make_valid_unsub(), mock_now()) { + VrSuccess => true, + _ => false, + } +} + +pub fn test_verify_unsubscribe_invalid_url() -> Bool { + let p = UnsubscribeParams { + url: "not_https", + tested_at: mock_now() - 5000, + response_code: 200, + response_time: 87, + token: "abc123", + signature: "valid_sig", + }; + match verify_unsubscribe_at(p, mock_now()) { + VrErrInvalidUrl => true, + _ => false, + } +} + +pub fn test_verify_unsubscribe_stale_timestamp() -> Bool { + let p = UnsubscribeParams { + url: "https://example.com/unsubscribe", + tested_at: 0, + response_code: 200, + response_time: 87, + token: "abc123", + signature: "valid_sig", + }; + match verify_unsubscribe_at(p, mock_now()) { + VrErrTimeout => true, + _ => false, + } +} + +pub fn test_verify_unsubscribe_bad_response_code() -> Bool { + let p = UnsubscribeParams { + url: "https://example.com/unsubscribe", + tested_at: mock_now() - 5000, + response_code: 404, + response_time: 87, + token: "abc123", + signature: "valid_sig", + }; + match verify_unsubscribe_at(p, mock_now()) { + VrErrInvalidResponse => true, + _ => false, + } +} + +pub fn test_verify_consent_valid() -> Bool { + let p = ConsentParams { + initial_request: 1000000, + confirmation: 1100000, + ip_address: "192.168.1.1", + token: "token123", + }; + match verify_consent(p) { + VrSuccess => true, + _ => false, + } +} + +pub fn test_verify_consent_simultaneous() -> Bool { + let p = ConsentParams { + initial_request: 1000000, + confirmation: 1000000, + ip_address: "192.168.1.1", + token: "token123", + }; + match verify_consent(p) { + VrErrConsentInvalid => true, + _ => false, + } +} + +pub fn test_result_to_string_success() -> Bool { + result_to_string(VrSuccess()) == "SUCCESS" +} + +pub fn test_result_to_string_invalid_url() -> Bool { + result_to_string(VrErrInvalidUrl()) == "INVALID_URL" +} + +pub fn test_result_to_string_consent_invalid() -> Bool { + result_to_string(VrErrConsentInvalid()) == "CONSENT_INVALID" +} diff --git a/avow-protocol/telegram-bot/avow-telegram-bot/test-mock.ts b/avow-protocol/telegram-bot/avow-telegram-bot/test-mock.ts deleted file mode 100644 index 6e6e1570..00000000 --- a/avow-protocol/telegram-bot/avow-telegram-bot/test-mock.ts +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// Test script for mock STAMP library - -import * as stamp from "./src/stamp-mock.ts"; - -console.log("Testing STAMP Mock Library\n"); - -// Test 1: Valid unsubscribe -console.log("Test 1: Valid Unsubscribe"); -const valid_unsub: stamp.UnsubscribeParams = { - url: "https://example.com/unsubscribe", - tested_at: Date.now() - 5000, - response_code: 200, - response_time: 87, - token: "abc123", - signature: "valid_sig", -}; - -const result1 = stamp.verifyUnsubscribe(valid_unsub); -console.log(`Result: ${stamp.resultToString(result1)}`); -console.assert(result1 === stamp.VerificationResult.SUCCESS, "Should pass"); -console.log("āœ“ Passed\n"); - -// Test 2: Invalid URL -console.log("Test 2: Invalid URL"); -const invalid_url: stamp.UnsubscribeParams = { - ...valid_unsub, - url: "not_https", -}; - -const result2 = stamp.verifyUnsubscribe(invalid_url); -console.log(`Result: ${stamp.resultToString(result2)}`); -console.assert(result2 === stamp.VerificationResult.ERROR_INVALID_URL, "Should fail"); -console.log("āœ“ Passed\n"); - -// Test 3: Valid consent -console.log("Test 3: Valid Consent"); -const valid_consent: stamp.ConsentParams = { - initial_request: 1000000, - confirmation: 1100000, - ip_address: "192.168.1.1", - token: "token123", -}; - -const result3 = stamp.verifyConsent(valid_consent); -console.log(`Result: ${stamp.resultToString(result3)}`); -console.assert(result3 === stamp.VerificationResult.SUCCESS, "Should pass"); -console.log("āœ“ Passed\n"); - -// Test 4: Proof generation -console.log("Test 4: Proof Generation"); -const proof = stamp.generateProof("unsubscribe", valid_unsub); -console.log("Proof generated:"); -console.log(stamp.formatProof(proof)); -console.assert(proof.type === "unsubscribe_verification", "Should be unsubscribe proof"); -console.log("āœ“ Passed\n"); - -console.log("All tests passed! āœ“"); diff --git a/axel-protocol/src/AxelSts.affine b/axel-protocol/src/AxelSts.affine new file mode 100644 index 00000000..0c594609 --- /dev/null +++ b/axel-protocol/src/AxelSts.affine @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// AXEL Protocol - DNS TXT Record Parser (Strict). +// AffineScript port of AxelSts.res. +// +// Parses AXEL DNS TXT record payloads (RDATA only, not full RR lines). +// Fails closed on missing required fields. Does NOT default v=AXEL1. + +module AxelSts; + +// __ Types _________________________________________________________________ + +pub type ParseError = + | MissingVersion + | InvalidVersion(String) + | MissingId + | EmptyId + | MalformedRecord(String) + +pub type AxelRecord = { + version: String, + id: String, +} + +pub type ParseResult = + | ParseOk(AxelRecord) + | ParseErr(ParseError) + +// __ Helpers _______________________________________________________________ + +fn str_split_on(s: String, delim: String) -> [String] { + let slen = len(s); + let dlen = len(delim); + let result = []; + let start = 0; + let i = 0; + while i <= slen - dlen { + if string_sub(s, i, dlen) == delim { + result = result ++ [string_sub(s, start, i - start)]; + start = i + dlen; + i = i + dlen; + } else { + i = i + 1; + } + } + result ++ [string_sub(s, start, slen - start)] +} + +fn str_join(parts: [String], sep: String) -> String { + if len(parts) == 0 { return ""; } + let result = parts[0]; + let i = 1; + while i < len(parts) { + result = result ++ sep ++ parts[i]; + i = i + 1; + } + result +} + +fn parse_kv(segment: String) -> Option<(String, String)> { + let trimmed = trim(segment); + let parts = str_split_on(trimmed, "="); + if len(parts) < 2 { + None + } else { + let key = trim(parts[0]); + let vp = []; + let j = 1; + while j < len(parts) { + vp = vp ++ [parts[j]]; + j = j + 1; + } + let value = trim(str_join(vp, "=")); + Some((key, value)) + } +} + +// __ Parser ________________________________________________________________ + +pub fn parse(payload: String) -> ParseResult { + let trimmed = trim(payload); + if len(trimmed) == 0 { + return ParseErr(MalformedRecord("empty payload")); + } + + let segments = str_split_on(trimmed, ";"); + let version = None; + let id = None; + let i = 0; + while i < len(segments) { + match parse_kv(segments[i]) { + Some(("v", v)) => { version = Some(v); } + Some(("id", d)) => { id = Some(d); } + _ => {} + } + i = i + 1; + } + + match version { + None => ParseErr(MissingVersion()), + Some(v) => { + if v != "AXEL1" { + ParseErr(InvalidVersion(v)) + } else { + match id { + None => ParseErr(MissingId()), + Some(d) => { + let trimmed_id = trim(d); + if len(trimmed_id) == 0 { + ParseErr(EmptyId()) + } else { + ParseOk(AxelRecord { version: v, id: trimmed_id }) + } + } + } + } + } + } +} + +pub fn error_to_string(err: ParseError) -> String { + match err { + MissingVersion => "Missing required field: v (version)", + InvalidVersion(v) => "Invalid version: '" ++ v ++ "' (expected 'AXEL1')", + MissingId => "Missing required field: id", + EmptyId => "Empty id field (must be non-empty)", + MalformedRecord(m) => "Malformed record: " ++ m, + } +} diff --git a/axel-protocol/src/AxelSts.res b/axel-protocol/src/AxelSts.res deleted file mode 100644 index 491eaa7f..00000000 --- a/axel-protocol/src/AxelSts.res +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// AXEL Protocol - DNS TXT Record Parser (Strict) -// -// Parses AXEL DNS TXT record payloads (RDATA only, not full RR lines). -// Fails closed on missing required fields. Does NOT default v=AXEL1. - -type parseError = - | MissingVersion - | InvalidVersion(string) - | MissingId - | EmptyId - | MalformedRecord(string) - -type axelRecord = { - version: string, - id: string, -} - -type parseResult = result - -// String utilities via external bindings -@send external trim: (string) => string = "trim" -@send external split: (string, string) => array = "split" -@send external startsWith: (string, string) => bool = "startsWith" -@get external length: string => int = "length" -@send external slice: (array, int) => array = "slice" -@send external joinArray: (array, string) => string = "join" - -let getArrayLength: array => int = %raw(`function(arr) { return arr.length }`) -let getArrayItem: (array, int) => string = %raw(`function(arr, i) { return arr[i] }`) - -// Parse a single key=value pair from a TXT record segment -let parseKeyValue = (segment: string): option<(string, string)> => { - let trimmed = segment->trim - let parts = trimmed->split("=") - let len = getArrayLength(parts) - if len < 2 { - None - } else { - let key = getArrayItem(parts, 0)->trim - let value = parts->slice(1)->joinArray("=")->trim - Some((key, value)) - } -} - -// Parse a TXT record payload string into an AXEL record -let parse = (payload: string): parseResult => { - let trimmed = payload->trim - - if trimmed->length == 0 { - Error(MalformedRecord("empty payload")) - } else { - let segments = trimmed->split(";") - let version = ref(None) - let id = ref(None) - - let segmentCount = getArrayLength(segments) - for i in 0 to segmentCount - 1 { - let segment = getArrayItem(segments, i) - switch parseKeyValue(segment) { - | Some(("v", value)) => version := Some(value) - | Some(("id", value)) => id := Some(value) - | _ => () - } - } - - switch (version.contents, id.contents) { - | (None, _) => Error(MissingVersion) - | (Some(v), _) if v != "AXEL1" => Error(InvalidVersion(v)) - | (Some(_), None) => Error(MissingId) - | (Some(_), Some(idVal)) if idVal->trim->length == 0 => Error(EmptyId) - | (Some(v), Some(idVal)) => - Ok({ - version: v, - id: idVal->trim, - }) - } - } -} - -// Convert parse error to human-readable string -let errorToString = (err: parseError): string => { - switch err { - | MissingVersion => "Missing required field: v (version)" - | InvalidVersion(v) => "Invalid version: '" ++ v ++ "' (expected 'AXEL1')" - | MissingId => "Missing required field: id" - | EmptyId => "Empty id field (must be non-empty)" - | MalformedRecord(msg) => "Malformed record: " ++ msg - } -} diff --git a/axel-protocol/src/AxelSts.res.js b/axel-protocol/src/AxelSts.res.js deleted file mode 100644 index 7727d8c4..00000000 --- a/axel-protocol/src/AxelSts.res.js +++ /dev/null @@ -1,117 +0,0 @@ -// Generated by ReScript, PLEASE EDIT WITH CARE - - -let getArrayLength = (function(arr) { return arr.length }); - -let getArrayItem = (function(arr, i) { return arr[i] }); - -function parseKeyValue(segment) { - let trimmed = segment.trim(); - let parts = trimmed.split("="); - let len = getArrayLength(parts); - if (len < 2) { - return; - } - let key = getArrayItem(parts, 0).trim(); - let value = parts.slice(1).join("=").trim(); - return [ - key, - value - ]; -} - -function parse(payload) { - let trimmed = payload.trim(); - if (trimmed.length === 0) { - return { - TAG: "Error", - _0: { - TAG: "MalformedRecord", - _0: "empty payload" - } - }; - } - let segments = trimmed.split(";"); - let version; - let id; - let segmentCount = getArrayLength(segments); - for (let i = 0; i < segmentCount; ++i) { - let segment = getArrayItem(segments, i); - let match = parseKeyValue(segment); - if (match !== undefined) { - switch (match[0]) { - case "id" : - id = match[1]; - break; - case "v" : - version = match[1]; - break; - } - } - } - let match$1 = version; - let match$2 = id; - if (match$1 !== undefined) { - if (match$1 !== "AXEL1") { - return { - TAG: "Error", - _0: { - TAG: "InvalidVersion", - _0: match$1 - } - }; - } else if (match$2 !== undefined) { - if (match$2.trim().length === 0) { - return { - TAG: "Error", - _0: "EmptyId" - }; - } else { - return { - TAG: "Ok", - _0: { - version: match$1, - id: match$2.trim() - } - }; - } - } else { - return { - TAG: "Error", - _0: "MissingId" - }; - } - } else { - return { - TAG: "Error", - _0: "MissingVersion" - }; - } -} - -function errorToString(err) { - if (typeof err === "object") { - if (err.TAG === "InvalidVersion") { - return "Invalid version: '" + err._0 + "' (expected 'AXEL1')"; - } else { - return "Malformed record: " + err._0; - } - } - switch (err) { - case "MissingVersion" : - return "Missing required field: v (version)"; - case "MissingId" : - return "Missing required field: id"; - case "EmptyId" : - return "Empty id field (must be non-empty)"; - } -} - -export { - getArrayLength, - getArrayItem, - parseKeyValue, - parse, - errorToString, -} -/* No side effect */ diff --git a/axel-protocol/test/axel-sts_test.affine b/axel-protocol/test/axel-sts_test.affine new file mode 100644 index 00000000..476e7e00 --- /dev/null +++ b/axel-protocol/test/axel-sts_test.affine @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// AXEL Protocol - DNS TXT Parser Tests. +// AffineScript port of axel-sts_test.ts. +// +// Self-contained: includes the AxelSts parser inline for deno-test harness +// compatibility. Will be refactored to import from AxelSts.affine once +// cross-file imports land in the harness. +// +// Convention: pub fn test_() -> Bool + +// __ Parser (inlined) ______________________________________________________ + +type ParseError = + | MissingVersion + | InvalidVersion(String) + | MissingId + | EmptyId + | MalformedRecord(String) + +type AxelRecord = { + version: String, + id: String, +} + +type ParseResult = + | ParseOk(AxelRecord) + | ParseErr(ParseError) + +fn ax_split(s: String, delim: String) -> [String] { + let slen = len(s); + let dlen = len(delim); + let result = []; + let start = 0; + let i = 0; + while i <= slen - dlen { + if string_sub(s, i, dlen) == delim { + result = result ++ [string_sub(s, start, i - start)]; + start = i + dlen; + i = i + dlen; + } else { + i = i + 1; + } + } + result ++ [string_sub(s, start, slen - start)] +} + +fn ax_join(parts: [String], sep: String) -> String { + if len(parts) == 0 { return ""; } + let result = parts[0]; + let i = 1; + while i < len(parts) { + result = result ++ sep ++ parts[i]; + i = i + 1; + } + result +} + +fn ax_parse_kv(segment: String) -> Option<(String, String)> { + let t = trim(segment); + let parts = ax_split(t, "="); + if len(parts) < 2 { + None + } else { + let key = trim(parts[0]); + let vp = []; + let j = 1; + while j < len(parts) { vp = vp ++ [parts[j]]; j = j + 1; } + Some((key, trim(ax_join(vp, "=")))) + } +} + +fn parse(payload: String) -> ParseResult { + let t = trim(payload); + if len(t) == 0 { return ParseErr(MalformedRecord("empty payload")); } + let segs = ax_split(t, ";"); + let version = None; + let id = None; + let i = 0; + while i < len(segs) { + match ax_parse_kv(segs[i]) { + Some(("v", v)) => { version = Some(v); } + Some(("id", d)) => { id = Some(d); } + _ => {} + } + i = i + 1; + } + match version { + None => ParseErr(MissingVersion()), + Some(v) => { + if v != "AXEL1" { + ParseErr(InvalidVersion(v)) + } else { + match id { + None => ParseErr(MissingId()), + Some(d) => { + let tid = trim(d); + if len(tid) == 0 { ParseErr(EmptyId()) } + else { ParseOk(AxelRecord { version: v, id: tid }) } + } + } + } + } + } +} + +fn is_ok(r: ParseResult) -> Bool { + match r { ParseOk(_) => true, ParseErr(_) => false } +} + +fn is_err(r: ParseResult) -> Bool { + match r { ParseOk(_) => false, ParseErr(_) => true } +} + +fn get_version(r: ParseResult) -> String { + match r { ParseOk(rec) => rec.version, ParseErr(_) => "" } +} + +fn get_id(r: ParseResult) -> String { + match r { ParseOk(rec) => rec.id, ParseErr(_) => "" } +} + +// __ Tests _________________________________________________________________ + +pub fn test_parse_valid_minimal() -> Bool { + let r = parse("v=AXEL1; id=20260212T1200Z"); + is_ok(r) && get_version(r) == "AXEL1" && get_id(r) == "20260212T1200Z" +} + +pub fn test_parse_valid_extra_whitespace() -> Bool { + let r = parse(" v=AXEL1 ; id=my-policy-id "); + is_ok(r) && get_id(r) == "my-policy-id" +} + +pub fn test_parse_valid_unknown_keys_ignored() -> Bool { + let r = parse("v=AXEL1; id=test-1; foo=bar; baz=qux"); + is_ok(r) && get_id(r) == "test-1" +} + +pub fn test_parse_valid_id_with_equals() -> Bool { + let r = parse("v=AXEL1; id=base64=encoded=id"); + is_ok(r) && get_id(r) == "base64=encoded=id" +} + +pub fn test_reject_missing_version() -> Bool { + is_err(parse("id=test-1")) +} + +pub fn test_reject_empty_payload() -> Bool { + is_err(parse("")) +} + +pub fn test_reject_whitespace_only() -> Bool { + is_err(parse(" ")) +} + +pub fn test_reject_wrong_version() -> Bool { + is_err(parse("v=AXEL2; id=test-1")) +} + +pub fn test_reject_missing_id() -> Bool { + is_err(parse("v=AXEL1")) +} + +pub fn test_reject_empty_id() -> Bool { + is_err(parse("v=AXEL1; id=")) +} + +pub fn test_reject_whitespace_only_id() -> Bool { + is_err(parse("v=AXEL1; id= ")) +} + +pub fn test_reject_full_rr_line() -> Bool { + is_err(parse("_axel.example.com. 3600 IN TXT \"v=AXEL1; id=test-1\"")) +} + +pub fn test_reject_no_default_version() -> Bool { + is_err(parse("id=test-1; mode=enforce")) +} diff --git a/axel-protocol/test/axel-sts_test.ts b/axel-protocol/test/axel-sts_test.ts deleted file mode 100644 index 622b8764..00000000 --- a/axel-protocol/test/axel-sts_test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// AXEL Protocol - DNS TXT Parser Tests -// Tests the strict parsing behavior of the ReScript AXEL-STS parser. -// -// These tests validate the compiled JavaScript output of AxelSts.res. -// Run after `deno task build` to ensure .res.js files are current. - -import { assertEquals } from "jsr:@std/assert@1"; -import { join, dirname, fromFileUrl } from "jsr:@std/path@1"; - -const ROOT = join(dirname(fromFileUrl(import.meta.url)), ".."); - -// Dynamic import of the compiled ReScript module -const mod = await import(join(ROOT, "src", "AxelSts.res.js")); -const { parse } = mod; - -// ReScript compiles result to { TAG: "Ok", _0: a } or { TAG: "Error", _0: b } -function isOk(result: { TAG: string }): boolean { - return result.TAG === "Ok"; -} - -function isError(result: { TAG: string }): boolean { - return result.TAG === "Error"; -} - -function getOkValue(result: { TAG: string; _0: unknown }): unknown { - return result._0; -} - -// --- Valid records --- - -Deno.test("parse valid minimal record", () => { - const result = parse("v=AXEL1; id=20260212T1200Z"); - assertEquals(isOk(result), true); - const val = getOkValue(result) as { version: string; id: string }; - assertEquals(val.version, "AXEL1"); - assertEquals(val.id, "20260212T1200Z"); -}); - -Deno.test("parse valid record with extra whitespace", () => { - const result = parse(" v=AXEL1 ; id=my-policy-id "); - assertEquals(isOk(result), true); - const val = getOkValue(result) as { version: string; id: string }; - assertEquals(val.version, "AXEL1"); - assertEquals(val.id, "my-policy-id"); -}); - -Deno.test("parse valid record with unknown keys (ignored)", () => { - const result = parse("v=AXEL1; id=test-1; foo=bar; baz=qux"); - assertEquals(isOk(result), true); - const val = getOkValue(result) as { version: string; id: string }; - assertEquals(val.version, "AXEL1"); - assertEquals(val.id, "test-1"); -}); - -Deno.test("parse valid record with id containing equals sign", () => { - const result = parse("v=AXEL1; id=base64=encoded=id"); - assertEquals(isOk(result), true); - const val = getOkValue(result) as { version: string; id: string }; - assertEquals(val.id, "base64=encoded=id"); -}); - -// --- Invalid records: missing version --- - -Deno.test("reject: missing version", () => { - const result = parse("id=test-1"); - assertEquals(isError(result), true); -}); - -Deno.test("reject: empty payload", () => { - const result = parse(""); - assertEquals(isError(result), true); -}); - -Deno.test("reject: whitespace-only payload", () => { - const result = parse(" "); - assertEquals(isError(result), true); -}); - -// --- Invalid records: wrong version --- - -Deno.test("reject: wrong version AXEL2", () => { - const result = parse("v=AXEL2; id=test-1"); - assertEquals(isError(result), true); -}); - -Deno.test("reject: version without value", () => { - const result = parse("v=; id=test-1"); - assertEquals(isError(result), true); -}); - -// --- Invalid records: missing or empty id --- - -Deno.test("reject: missing id", () => { - const result = parse("v=AXEL1"); - assertEquals(isError(result), true); -}); - -Deno.test("reject: empty id", () => { - const result = parse("v=AXEL1; id="); - assertEquals(isError(result), true); -}); - -Deno.test("reject: whitespace-only id", () => { - const result = parse("v=AXEL1; id= "); - assertEquals(isError(result), true); -}); - -// --- Must NOT parse full RR lines --- - -Deno.test("reject: full RR line (not payload)", () => { - const result = parse( - '_axel.example.com. 3600 IN TXT "v=AXEL1; id=test-1"' - ); - assertEquals(isError(result), true); -}); - -// --- Must NOT default version --- - -Deno.test("reject: no default version when missing", () => { - const result = parse("id=test-1; mode=enforce"); - assertEquals(isError(result), true); -}); diff --git a/axel-protocol/test/validate-policy.affine b/axel-protocol/test/validate-policy.affine new file mode 100644 index 00000000..3d5b2a7d --- /dev/null +++ b/axel-protocol/test/validate-policy.affine @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// AXEL Protocol - Policy JSON Schema Validator. +// AffineScript port of validate-policy.ts. +// Compiled via: affinescript compile --target node-cjs +// Uses stdlib/Ajv.affine extern bindings. + +module ValidatePolicy; + +extern type AjvInstance; +extern type Validator; +extern fn ajv_new() -> AjvInstance; +extern fn ajv_compile(a: AjvInstance, schema_json: String) -> Validator; +extern fn ajv_validate(v: Validator, value_json: String) -> Bool; +extern fn ajv_errors_json(v: Validator) -> String; +extern fn read_file(path: String) -> String; +extern fn log(s: String) -> Int; +extern fn process_exit(code: Int) -> Int; + +pub fn main() -> Int { + let schema_json = read_file("schemas/axel-policy.schema.json"); + let ajv = ajv_new(); + let validator = ajv_compile(ajv, schema_json); + + let policy_paths = [ + "public/.well-known/axel-policy", + "public/.well-known/axel-policy.example-adult.json", + ]; + + let all_passed = true; + let i = 0; + while i < len(policy_paths) { + let path = policy_paths[i]; + let policy_json = read_file(path); + let valid = ajv_validate(validator, policy_json); + if valid { + log("PASS: " ++ path); + } else { + log("FAIL: " ++ path); + log(" Errors: " ++ ajv_errors_json(validator)); + all_passed = false; + } + i = i + 1; + } + + if all_passed { + log("All policies valid."); + 0 + } else { + log("Validation failed."); + 1 + } +} diff --git a/axel-protocol/test/validate-policy.ts b/axel-protocol/test/validate-policy.ts deleted file mode 100644 index 8ed0c4a6..00000000 --- a/axel-protocol/test/validate-policy.ts +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// AXEL Protocol - Policy JSON Schema Validator -// Validates /.well-known/axel-policy examples against the JSON Schema. - -import Ajv from "npm:ajv@8.17.1"; -import addFormats from "npm:ajv-formats@3.0.1"; -import { join, dirname, fromFileUrl } from "jsr:@std/path@1"; - -const ROOT = join(dirname(fromFileUrl(import.meta.url)), ".."); - -const schemaPath = join(ROOT, "schemas", "axel-policy.schema.json"); -const policyPaths = [ - join(ROOT, "public", ".well-known", "axel-policy"), - join(ROOT, "public", ".well-known", "axel-policy.example-adult.json"), -]; - -const schema = JSON.parse(await Deno.readTextFile(schemaPath)); - -const ajv = new Ajv({ allErrors: true, strict: false, validateSchema: false }); -addFormats(ajv); -const validate = ajv.compile(schema); - -let allPassed = true; - -for (const policyPath of policyPaths) { - const label = policyPath.replace(ROOT + "/", ""); - try { - const policyText = await Deno.readTextFile(policyPath); - const policy = JSON.parse(policyText); - const valid = validate(policy); - - if (valid) { - console.log(`PASS: ${label}`); - } else { - console.error(`FAIL: ${label}`); - for (const err of validate.errors ?? []) { - console.error(` - ${err.instancePath || "/"}: ${err.message}`); - } - allPassed = false; - } - } catch (e) { - console.error(`ERROR: ${label} - ${(e as Error).message}`); - allPassed = false; - } -} - -if (allPassed) { - console.log("\nAll policies valid."); - Deno.exit(0); -} else { - console.error("\nValidation failed."); - Deno.exit(1); -} diff --git a/axel-protocol/test/validate-policy_test.affine b/axel-protocol/test/validate-policy_test.affine new file mode 100644 index 00000000..c2979a89 --- /dev/null +++ b/axel-protocol/test/validate-policy_test.affine @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// AXEL Protocol - Policy Schema Validation Tests. +// AffineScript port of validate-policy_test.ts. +// +// NOTE: These tests require ajv_new/ajv_compile/ajv_validate externs from +// stdlib/Ajv.affine to be provided by the test shim. Until the deno-test +// harness is extended with an Ajv Node-CJS shim, compile via --target node-cjs +// and run via the node-cjs test runner. +// +// Convention: pub fn test_() -> Bool + +extern type AjvInstance; +extern type Validator; +extern fn ajv_new() -> AjvInstance; +extern fn ajv_compile(a: AjvInstance, schema_json: String) -> Validator; +extern fn ajv_validate(v: Validator, value_json: String) -> Bool; +extern fn read_file(path: String) -> String; + +fn make_validator() -> Validator { + let schema = read_file("schemas/axel-policy.schema.json"); + ajv_compile(ajv_new(), schema) +} + +fn base_policy(version: String, id_val: String, category: String) -> String { + "{\"version\":\"" ++ version ++ "\",\"id\":\"" ++ id_val ++ + "\",\"scope\":{\"hostnames\":[\"example.com\"]},\"content\":{\"category\":\"" ++ + category ++ "\",\"min_age\":18},\"enforcement\":{\"profiles\":[\"AXEL-O\"],\"proof_required\":true}," ++ + "\"cache\":{\"max_age_seconds\":86400,\"stale_if_error_seconds\":604800}}" +} + +pub fn test_valid_minimal_policy() -> Bool { + ajv_validate(make_validator(), base_policy("AXEL1", "20260212T1200Z", "adult")) +} + +pub fn test_valid_proof_required_false() -> Bool { + let p = "{\"version\":\"AXEL1\",\"id\":\"test-1\",\"scope\":{\"hostnames\":[\"safe.example.com\"]}," ++ + "\"content\":{\"category\":\"mature\",\"min_age\":0}," ++ + "\"enforcement\":{\"profiles\":[\"AXEL-O\"],\"proof_required\":false}," ++ + "\"cache\":{\"max_age_seconds\":3600,\"stale_if_error_seconds\":7200}}"; + ajv_validate(make_validator(), p) +} + +pub fn test_reject_missing_version() -> Bool { + let p = "{\"id\":\"test-1\",\"scope\":{\"hostnames\":[\"example.com\"]}," ++ + "\"content\":{\"category\":\"adult\",\"min_age\":18}," ++ + "\"enforcement\":{\"profiles\":[\"AXEL-O\"],\"proof_required\":true}," ++ + "\"cache\":{\"max_age_seconds\":86400,\"stale_if_error_seconds\":604800}}"; + !ajv_validate(make_validator(), p) +} + +pub fn test_reject_wrong_version() -> Bool { + !ajv_validate(make_validator(), base_policy("AXEL2", "test-1", "adult")) +} + +pub fn test_reject_empty_id() -> Bool { + !ajv_validate(make_validator(), base_policy("AXEL1", "", "adult")) +} + +pub fn test_reject_empty_hostnames() -> Bool { + let p = "{\"version\":\"AXEL1\",\"id\":\"test-1\",\"scope\":{\"hostnames\":[]}," ++ + "\"content\":{\"category\":\"adult\",\"min_age\":18}," ++ + "\"enforcement\":{\"profiles\":[\"AXEL-O\"],\"proof_required\":true}," ++ + "\"cache\":{\"max_age_seconds\":86400,\"stale_if_error_seconds\":604800}}"; + !ajv_validate(make_validator(), p) +} + +pub fn test_reject_invalid_category() -> Bool { + !ajv_validate(make_validator(), base_policy("AXEL1", "test-1", "invalid-category")) +} + +pub fn test_reject_missing_cache() -> Bool { + let p = "{\"version\":\"AXEL1\",\"id\":\"test-1\",\"scope\":{\"hostnames\":[\"example.com\"]}," ++ + "\"content\":{\"category\":\"adult\",\"min_age\":18}," ++ + "\"enforcement\":{\"profiles\":[\"AXEL-O\"],\"proof_required\":true}}"; + !ajv_validate(make_validator(), p) +} + +pub fn test_reject_cache_max_age_zero() -> Bool { + let p = "{\"version\":\"AXEL1\",\"id\":\"test-1\",\"scope\":{\"hostnames\":[\"example.com\"]}," ++ + "\"content\":{\"category\":\"adult\",\"min_age\":18}," ++ + "\"enforcement\":{\"profiles\":[\"AXEL-O\"],\"proof_required\":true}," ++ + "\"cache\":{\"max_age_seconds\":0,\"stale_if_error_seconds\":604800}}"; + !ajv_validate(make_validator(), p) +} + +pub fn test_reject_additional_top_level_props() -> Bool { + let p = "{\"version\":\"AXEL1\",\"id\":\"test-1\",\"scope\":{\"hostnames\":[\"example.com\"]}," ++ + "\"content\":{\"category\":\"adult\",\"min_age\":18}," ++ + "\"enforcement\":{\"profiles\":[\"AXEL-O\"],\"proof_required\":true}," ++ + "\"cache\":{\"max_age_seconds\":86400,\"stale_if_error_seconds\":604800}," ++ + "\"unknown_field\":\"should fail\"}"; + !ajv_validate(make_validator(), p) +} diff --git a/axel-protocol/test/validate-policy_test.ts b/axel-protocol/test/validate-policy_test.ts deleted file mode 100644 index 43acf888..00000000 --- a/axel-protocol/test/validate-policy_test.ts +++ /dev/null @@ -1,270 +0,0 @@ -// SPDX-License-Identifier: PMPL-1.0-or-later -// AXEL Protocol - Policy Schema Validation Tests - -import { assertEquals, assertThrows } from "jsr:@std/assert@1"; -import Ajv from "npm:ajv@8.17.1"; -import addFormats from "npm:ajv-formats@3.0.1"; -import { join, dirname, fromFileUrl } from "jsr:@std/path@1"; - -const ROOT = join(dirname(fromFileUrl(import.meta.url)), ".."); -const schemaPath = join(ROOT, "schemas", "axel-policy.schema.json"); - -const schema = JSON.parse(await Deno.readTextFile(schemaPath)); -const ajv = new Ajv({ allErrors: true, strict: false, validateSchema: false }); -addFormats(ajv); -const validate = ajv.compile(schema); - -function isValid(policy: unknown): boolean { - return validate(policy) as boolean; -} - -// --- Valid policies --- - -Deno.test("valid minimal policy", () => { - const policy = { - version: "AXEL1", - id: "20260212T1200Z", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), true); -}); - -Deno.test("valid full policy with all optional fields", () => { - const policy = { - version: "AXEL1", - id: "20260212T1430Z", - scope: { hostnames: ["explicit.example.com", "cdn-explicit.example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { - profiles: ["AXEL-O", "AXEL-N"], - proof_required: true, - browser_flow: { gate_url: "https://explicit.example.com/verify" }, - api_flow: { - problem_type: "https://axel-protocol.org/problems/proof-required", - }, - }, - isolation: { - level: "L1-AUDITED", - prefixes: { - ipv6: ["2001:db8:abcd::/48"], - ipv4: ["198.51.100.0/24"], - }, - cdn_pool: "explicit-only-pool-us-east", - }, - verifiers: [ - { - name: "ExampleVerify", - url: "https://verify.example.com", - methods: ["zkp", "gov_id"], - }, - ], - auditing: { - statement_url: - "https://auditor.example.org/statements/example-com-2026.json", - policy_hash_sha256: - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - extensions: {}, - }; - assertEquals(isValid(policy), true); -}); - -Deno.test("valid policy with proof_required false", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["safe.example.com"] }, - content: { category: "mature", min_age: 0 }, - enforcement: { profiles: ["AXEL-O"], proof_required: false }, - cache: { max_age_seconds: 3600, stale_if_error_seconds: 7200 }, - }; - assertEquals(isValid(policy), true); -}); - -// --- Invalid policies --- - -Deno.test("reject: missing version", () => { - const policy = { - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: wrong version", () => { - const policy = { - version: "AXEL2", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: empty id", () => { - const policy = { - version: "AXEL1", - id: "", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: missing scope", () => { - const policy = { - version: "AXEL1", - id: "test-1", - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: empty hostnames array", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: [] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: invalid content category", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "invalid-category", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: negative min_age", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: -1 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: enforcement profiles missing AXEL-O", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-N"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: missing cache", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: cache max_age_seconds zero", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 0, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: invalid isolation level", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - isolation: { level: "L2" }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: invalid auditing hash (not hex)", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - auditing: { policy_hash_sha256: "not-a-valid-hash" }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - }; - assertEquals(isValid(policy), false); -}); - -Deno.test("reject: additional top-level properties", () => { - const policy = { - version: "AXEL1", - id: "test-1", - scope: { hostnames: ["example.com"] }, - content: { category: "adult", min_age: 18 }, - enforcement: { profiles: ["AXEL-O"], proof_required: true }, - cache: { max_age_seconds: 86400, stale_if_error_seconds: 604800 }, - unknown_field: "should fail", - }; - assertEquals(isValid(policy), false); -}); - -// --- Bundled example files --- - -Deno.test("bundled policy: public/.well-known/axel-policy", async () => { - const text = await Deno.readTextFile( - join(ROOT, "public", ".well-known", "axel-policy") - ); - const policy = JSON.parse(text); - assertEquals(isValid(policy), true); -}); - -Deno.test( - "bundled policy: public/.well-known/axel-policy.example-adult.json", - async () => { - const text = await Deno.readTextFile( - join( - ROOT, - "public", - ".well-known", - "axel-policy.example-adult.json" - ) - ); - const policy = JSON.parse(text); - assertEquals(isValid(policy), true); - } -);