Reference: Data-plane receivers (Node + C#/.NET)

The runnable reference backends for Quickstart: Agent Data Plane — complete source, published here so an integrator outside the repo can copy, run, and port them. Two implementations of the same wire contract:

  • Node (below): the quickstart's worked example — one server on Node built-ins + @pouchy_ai/backend-kit, all four receiver legs.
  • C#/.NET (further down): a dependency-free port of the kit's verification surface with a vector self-test — for teams whose receiver is .NET (the classic on-prem ERP shape).

Both demonstrate the two habits every port must keep: raw bytes (every signature covers the exact wire bytes — no body-parser re-serialization) and uniform refusal (any verification failure answers one bare 404 — distinguishing reasons builds an oracle).


Node receiver

One server, e-commerce flavored, all four legs:

Leg Route Contract
Order View GET /pouchy/order signed GET, declared filters in the query string, flat JSON out (Pouchy curates to declared fields — internalCost below never reaches the agent)
refund_order Action POST /pouchy/refund {"actionId","action","args"}, at-most-once per (actionId, intent), refusals via ActionRejected = stored+replayed 4xx
reconciliation GET /pouchy/refund-status?actionId= three-state committed | rejected | not_found, answered from the idempotency store
platform webhooks POST /webhooks/pouchy X-Pouchy-Signature verify (agent.event_reply makes Events wake instead of defer)

The "database" is an in-memory Map on purpose: the Data plane's integration boundary is plain signed HTTPS — swap the Map for your real system and nothing else changes. Works for any domain; the e-commerce flavor is just the worked example.

Run it

  1. Follow steps 1–2 of the quickstart (provision the signing secret FIRST, then publish the three declarations against your audience hostname).

  2. Save the two files below as server.mjs + package.json, then:

    npm install
    POUCHY_KID=k… POUCHY_SIGNING_SECRET=pcsk_… \
    POUCHY_WEBHOOK_SECRET=whsec… POUCHY_AUDIENCE=api.shop.example.com \
    npm start
    
  3. Put it behind HTTPS at the declared hostname (a tunnel is fine in dev), then prove it headlessly — testReadCapability / testActionCapability from @pouchy_ai/admin-sdk, per quickstart step 4 — before enabling the agent's Data flags.

server.mjs

// Pouchy Data-plane receiver — the reference backend for
// docs/data-plane-quickstart.md (published at pouchy.ai/docs/data-plane-quickstart).
//
// One Node server, e-commerce flavored, all four receiver legs:
//
//   GET  /pouchy/order          the `Order` View       (signed GET, filters in query)
//   POST /pouchy/refund         the `refund_order` Action (idempotent, ActionRejected)
//   GET  /pouchy/refund-status  reconciliation by actionId (three-state)
//   POST /webhooks/pouchy       platform webhooks (agent.event_reply, …)
//
// The "database" is an in-memory Map — the point of the exercise: the Data
// plane's integration boundary is HTTPS, and ANY store (or none) works
// behind it. Swap `db`/`store` for your real system; nothing else changes.
//
//   POUCHY_KID=k… POUCHY_SIGNING_SECRET=pcsk_… POUCHY_WEBHOOK_SECRET=… \
//   POUCHY_AUDIENCE=api.shop.example.com npm start
//
// (For local runs the audience just has to match what you declared; put the
// server behind a tunnel/proxy that serves that hostname over HTTPS.)

import { createServer } from 'node:http';
import {
	verifyPouchyRequest,
	idempotentAction,
	reconcileFrom,
	ActionRejected,
	verifyWebhookSignature
} from '@pouchy_ai/backend-kit';

const KID = process.env.POUCHY_KID || '';
const SIGNING_SECRET = process.env.POUCHY_SIGNING_SECRET || '';
const WEBHOOK_SECRET = process.env.POUCHY_WEBHOOK_SECRET || '';
const AUDIENCE = process.env.POUCHY_AUDIENCE || 'api.shop.example.com';
const PORT = Number(process.env.PORT || 8788);

if (!KID || !SIGNING_SECRET || !WEBHOOK_SECRET) {
	// WEBHOOK_SECRET is NOT optional: this server mounts /webhooks/pouchy, and
	// an empty webhook secret is an HMAC key anyone can compute (the kit
	// refuses it as 'missing_secret' — every delivery would 404).
	console.error(
		'Set POUCHY_KID + POUCHY_SIGNING_SECRET (dashboard → Capabilities → signing card) ' +
			'+ POUCHY_WEBHOOK_SECRET (dashboard → Webhooks).'
	);
	process.exit(1);
}

// ── "your system" ──────────────────────────────────────────────────────────
// Orders by customer. In production this is your real database/service.
const db = new Map([
	['cust_42', [{ orderId: 'o_1001', status: 'paid', total: '129.00', eta: '2026-08-05', internalCost: '61.30' }]]
]);

// The Action idempotency store — the durable (actionId → outcome) memory the
// wire contract requires. Any `{ get, put }` backing satisfies the kit.
const rows = new Map();
const store = { get: (id) => rows.get(id) ?? null, put: (id, row) => void rows.set(id, row) };

// ── the Action handler ─────────────────────────────────────────────────────
// Runs AT MOST once per (actionId, intent). Refusals are thrown as
// ActionRejected — a stored, replayed 4xx that Pouchy classifies `rejected`;
// an unexpected crash is deliberately left to become a 500 = `unknown`.
const refund = idempotentAction(store, async (args, ctx) => {
	// WHO the refund is for comes from the VERIFIED principal (ctx.who below),
	// never from args — the model can't name whose data to touch.
	const order = (db.get(ctx.who?.sub ?? '') ?? []).find((o) => o.orderId === args.orderId);
	if (!order) throw new ActionRejected({ error: 'no such order' }, 422);
	if (order.status !== 'paid') throw new ActionRejected({ error: `not refundable in status ${order.status}` }, 409);
	order.status = 'refunded'; // your real mutation (payment API, ledger, …)
	return { refunded: order.orderId, amount: order.total, at: new Date().toISOString(), by: ctx.actionId };
});
const refundStatus = reconcileFrom(store);

// ── the HTTP shell ─────────────────────────────────────────────────────────
createServer(async (req, res) => {
	// RAW BYTES: every signature covers the exact wire bytes. Collect them
	// yourself (or use your framework's raw-body mode) — never re-serialize.
	const chunks = [];
	for await (const c of req) chunks.push(c);
	const rawBody = Buffer.concat(chunks).toString('utf8');
	const url = new URL(req.url, `https://${AUDIENCE}`);
	const send = (status, body) => {
		res.writeHead(status, { 'content-type': 'application/json' });
		res.end(JSON.stringify(body));
	};

	// Platform webhooks use their OWN signature scheme (X-Pouchy-Signature).
	if (url.pathname === '/webhooks/pouchy' && req.method === 'POST') {
		const w = verifyWebhookSignature({ body: rawBody, header: req.headers['x-pouchy-signature'], secret: WEBHOOK_SECRET });
		if (!w.ok) return send(404, {}); // uniform refusal — no oracle
		const event = JSON.parse(rawBody);
		console.log('[webhook]', event.type, event.id); // dedupe on event.id — retries keep it stable
		return send(200, { ok: true });
	}

	// Every capability leg: verify the FULL signed request FIRST. Claims from
	// an unverified request are forgeable noise.
	const v = verifyPouchyRequest(
		{ method: req.method, url, headers: req.headers, body: rawBody },
		{ secrets: { [KID]: SIGNING_SECRET }, audience: AUDIENCE }
	);
	if (!v.ok) return send(404, {}); // ONE uniform failure, whatever the reason

	if (url.pathname === '/pouchy/order' && req.method === 'GET') {
		// The View. `v.principal.sub` is the verified subject (your external
		// user id); declared filters arrive as query params — already signed.
		const orders = db.get(v.principal.sub ?? '') ?? [];
		const orderId = url.searchParams.get('orderId');
		const hit = orderId ? orders.filter((o) => o.orderId === orderId) : orders;
		// Return your business shape freely: Pouchy curates to the DECLARED
		// fields (internalCost below never reaches the agent).
		return send(200, hit);
	}

	if (url.pathname === '/pouchy/refund' && req.method === 'POST') {
		// v.principal.kind: 'end_user' = a human approved the confirm card;
		// 'automation' = a pre-authorized event-driven run — same contract,
		// distinct trust tier if your policy cares.
		const envelope = JSON.parse(rawBody); // {"actionId","action","args"}
		const { status, body } = await refund(envelope, { who: v.principal });
		return send(status, body);
	}

	if (url.pathname === '/pouchy/refund-status' && req.method === 'GET') {
		const { status, body } = await refundStatus(url.searchParams.get('actionId'));
		return send(status, body);
	}

	send(404, {});
}).listen(PORT, () => console.log(`data-plane receiver on :${PORT} (audience ${AUDIENCE})`));

package.json

{
	"name": "pouchy-data-plane-receiver",
	"private": true,
	"type": "module",
	"description": "Reference Data-plane receiver: a Views/Actions/Events e-commerce backend on Node built-ins + @pouchy_ai/backend-kit.",
	"engines": { "node": ">=18" },
	"scripts": {
		"start": "node server.mjs"
	},
	"dependencies": {
		"@pouchy_ai/backend-kit": "^0.3.4"
	}
}

C#/.NET receiver (verification + vector self-test)

PouchyVerify.cs is a single-file, dependency-free port of @pouchy_ai/backend-kit's verification surface —

Surface Method Direction
POUCHY-ACTOR-V1 VerifyPouchyRequest Pouchy → your View/Action endpoints
Platform webhooks (X-Pouchy-Signature) VerifyWebhookSignature Pouchy → your webhook receiver
POUCHY-SOURCE-V1 BuildEventSourceSignature your Events → Pouchy
Idempotency identity IntentHashOf (actionId, intentHash) on your side

Two spots deliberately diverge from .NET defaults to stay byte-identical with the production signer: query encoding uses JavaScript's encodeURIComponent character set (not Uri.EscapeDataString), and IntentHashOf serializes like JSON.stringify (minimal escaping, literal non-ASCII). Don't "simplify" those — the self-test will tell you why not.

Prove it before you trust it

Save the three files below into one directory, npm install @pouchy_ai/backend-kit next to it (for the public test vectors the kit ships under vectors/), then:

dotnet run -- ./node_modules/@pouchy_ai/backend-kit/vectors

The program verifies the golden vector, refuses all seven adversarial mutations with the documented reasons, reproduces the event-source golden signature byte-for-byte, checks the path-relative req.url shape (kit ≥0.3.1 parity), and round-trips a webhook signature. Exit 0 / "ALL GREEN" is your license to deploy; any FAIL means drift from the wire contract.

Using it in a receiver

// ASP.NET Core: read the RAW body — model binding re-serializes and breaks signatures.
app.MapPost("/pouchy/refund", async (HttpRequest req) =>
{
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);
    var raw = ms.ToArray();

    var (ok, _) = PouchyVerify.VerifyPouchyRequest(
        req.Method, req.GetEncodedPathAndQuery(), n => req.Headers[n].FirstOrDefault(), raw,
        secrets: new Dictionary<string, string> { [kid] = pcskSecret },
        audience: "api.your-erp.example.com");
    if (ok is null) return Results.NotFound();   // ONE uniform refusal — no oracle

    // ok.PrincipalSub = the verified external user; ok.PrincipalKind
    // 'end_user' = a human approved; 'automation' = pre-authorized event run.
    // Then: (actionId, IntentHashOf(args)) idempotency against a SQL table
    // keyed by actionId — 2xx commits, throw→500 stays honest `unknown`,
    // chosen 4xx = rejected. See the quickstart for the full contract.
    …
});

PouchyVerify.cs

// Pouchy Data-plane receiver verification — C#/.NET reference implementation.
//
// A faithful port of @pouchy_ai/backend-kit's verification surface for teams
// whose receiver is .NET (the classic ERP shape). Covers:
//
//   - POUCHY-ACTOR-V1  (VerifyPouchyRequest — Views + Actions, Pouchy → you)
//   - platform webhooks (VerifyWebhookSignature — agent.event_reply etc.)
//   - POUCHY-SOURCE-V1 (BuildEventSourceSignature — your Events → Pouchy)
//   - IntentHashOf      (the (actionId, intentHash) idempotency identity)
//
// Byte-parity is the whole game, and two places diverge from .NET defaults
// on purpose:
//
//   1. Query values are encoded with JavaScript's encodeURIComponent set
//      (Uri.EscapeDataString additionally escapes ! * ' ( ) — that would
//      change the canonical string bytes).
//   2. IntentHashOf serializes like JSON.stringify (minimal escaping,
//      non-ASCII kept literal) — System.Text.Json's encoders don't match.
//
// Run `dotnet run` in this directory: Program.cs proves this file against
// the SAME test vectors that pin the production signer (shipped inside the
// @pouchy_ai/backend-kit npm package under vectors/).

using System.Security.Cryptography;
using System.Text;

namespace Pouchy.Reference;

public sealed record VerifiedRequest(
	System.Text.Json.JsonElement Claims,
	string PrincipalKind,
	string? PrincipalSub,
	string CallId,
	string Kid);

public static class PouchyVerify
{
	public const string Scheme = "POUCHY-ACTOR-V1";
	public const string ActorHeader = "x-pouchy-actor";
	public const string ActorSignatureHeader = "x-pouchy-actor-signature";
	public const string WebhookSignatureHeader = "x-pouchy-signature";
	public const string SourceSignatureHeader = "x-pouchy-source-signature";
	public const long MaxSkewMs = 5 * 60_000;

	// ── canonical request path ────────────────────────────────────────────
	/// Pathname verbatim + query pairs sorted by key then value (ordinal,
	/// UTF-16 code units — matching JS string `<`), each side encoded with the
	/// JS encodeURIComponent set, no `?` when empty. Accepts an absolute URL
	/// or a path-relative one (`/pouchy/order?x=1`) — parity with kit ≥0.3.1.
	public static string CanonicalRequestPath(string url)
	{
		var u = Uri.TryCreate(url, UriKind.Absolute, out var abs)
			? abs
			: new Uri(new Uri("http://relative.invalid"), url);
		var pairs = ParseQueryPairs(u.Query);
		if (pairs.Count == 0) return u.AbsolutePath;
		pairs.Sort((a, b) =>
		{
			var k = string.CompareOrdinal(a.Key, b.Key);
			return k != 0 ? k : string.CompareOrdinal(a.Value, b.Value);
		});
		var joined = string.Join("&", pairs.Select(p =>
			$"{EncodeUriComponent(p.Key)}={EncodeUriComponent(p.Value)}"));
		return $"{u.AbsolutePath}?{joined}";
	}

	/// Decoded (key, value) pairs in wire order — the sort runs on DECODED
	/// strings, exactly like URLSearchParams iteration in the JS kit.
	private static List<KeyValuePair<string, string>> ParseQueryPairs(string query)
	{
		var pairs = new List<KeyValuePair<string, string>>();
		var q = query.StartsWith('?') ? query[1..] : query;
		if (q.Length == 0) return pairs;
		foreach (var seg in q.Split('&'))
		{
			if (seg.Length == 0) continue;
			var i = seg.IndexOf('=');
			var k = i < 0 ? seg : seg[..i];
			var v = i < 0 ? "" : seg[(i + 1)..];
			pairs.Add(new(DecodeUriComponent(k), DecodeUriComponent(v)));
		}
		return pairs;
	}

	private static string DecodeUriComponent(string s) =>
		Uri.UnescapeDataString(s.Replace("+", "%2B")); // '+' is literal in URLSearchParams paths we emit

	/// JavaScript encodeURIComponent: unreserved = A-Z a-z 0-9 - _ . ! ~ * ' ( )
	public static string EncodeUriComponent(string s)
	{
		var sb = new StringBuilder(s.Length);
		foreach (var b in Encoding.UTF8.GetBytes(s))
		{
			char c = (char)b;
			bool keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
				|| c is '-' or '_' or '.' or '!' or '~' or '*' or '\'' or '(' or ')';
			if (keep) sb.Append(c);
			else sb.Append('%').Append(b.ToString("X2"));
		}
		return sb.ToString();
	}

	// ── canonical signing string ──────────────────────────────────────────
	/// The exact 8-line string of the public contract. `bodyUtf8` is the EXACT
	/// raw request bytes (null/empty → the literal `-` hash slot).
	public static string CanonicalSigningString(
		string claimsB64, string audience, string method, string url, byte[]? bodyUtf8, string callId, long tSec)
	{
		return string.Join("\n", new[]
		{
			Scheme,
			tSec.ToString(),
			callId,
			audience.ToLowerInvariant(),
			method.ToUpperInvariant(),
			CanonicalRequestPath(url),
			bodyUtf8 is null || bodyUtf8.Length == 0 ? "-" : Sha256Hex(bodyUtf8),
			claimsB64
		});
	}

	// ── POUCHY-ACTOR-V1 verification ──────────────────────────────────────
	/// Verify one signed Pouchy request. Claims come back ONLY on full
	/// success — never act on claims from a request that failed any check.
	/// On failure, answer ONE uniform response (the reference receivers use a
	/// bare 404); the reason is for YOUR logs, not the caller.
	public static (VerifiedRequest? Ok, string? Reason) VerifyPouchyRequest(
		string method, string url, Func<string, string?> header, byte[]? rawBody,
		IReadOnlyDictionary<string, string> secrets, string audience, long? nowMs = null)
	{
		var claimsB64 = header(ActorHeader);
		var sig = header(ActorSignatureHeader);
		if (string.IsNullOrEmpty(claimsB64) || string.IsNullOrEmpty(sig)) return (null, "missing");

		var parts = ParseKvHeader(sig);
		if (!parts.TryGetValue("t", out var tRaw) || !parts.TryGetValue("kid", out var kid) ||
			!parts.TryGetValue("v1", out var v1) || !tRaw.All(char.IsAsciiDigit) ||
			v1.Length != 64 || !v1.All(c => char.IsAsciiDigit(c) || (c >= 'a' && c <= 'f')))
			return (null, "malformed");

		// An entry whose VALUE is empty is as unusable as an absent kid — and an
		// empty HMAC key is one anyone can compute. Parity with the JS kit's
		// `typeof secret !== 'string' || !secret` guard.
		if (!secrets.TryGetValue(kid, out var secret) || string.IsNullOrEmpty(secret))
			return (null, "unknown_key");

		var tSec = long.Parse(tRaw);
		var now = nowMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
		if (Math.Abs(now - tSec * 1000) > MaxSkewMs) return (null, "stale");

		System.Text.Json.JsonElement claims;
		string callId;
		try
		{
			claims = System.Text.Json.JsonDocument.Parse(Base64UrlDecode(claimsB64)).RootElement;
			callId = claims.GetProperty("cid").GetString() ?? "";
		}
		catch { return (null, "malformed"); }
		if (callId.Length == 0) return (null, "malformed");

		var canonical = CanonicalSigningString(claimsB64, audience, method, url, rawBody, callId, tSec);
		var expected = HmacHex(secret, canonical);
		if (!CryptographicOperations.FixedTimeEquals(
			Convert.FromHexString(expected), Convert.FromHexString(v1)))
			return (null, "bad_signature");

		var aud = claims.TryGetProperty("aud", out var a) ? a.GetString() : null;
		if (aud != audience.ToLowerInvariant()) return (null, "audience_mismatch");

		var kind = claims.TryGetProperty("kind", out var k) ? k.GetString() ?? "" : "";
		string? sub = claims.TryGetProperty("sub", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.String
			? s.GetString() : null;
		return (new VerifiedRequest(claims, kind, sub, callId, kid), null);
	}

	// ── intent hash ───────────────────────────────────────────────────────
	/// sha256 hex of JSON.stringify({sorted keys, String(values)}) — must be
	/// byte-identical to the runtime's canonical intent, so the serializer
	/// below mirrors JSON.stringify, not a .NET encoder.
	public static string IntentHashOf(IEnumerable<KeyValuePair<string, string>> args)
	{
		var sb = new StringBuilder("{");
		bool first = true;
		foreach (var kv in args.OrderBy(kv => kv.Key, StringComparer.Ordinal))
		{
			if (!first) sb.Append(',');
			first = false;
			AppendJsonString(sb, kv.Key);
			sb.Append(':');
			AppendJsonString(sb, kv.Value);
		}
		sb.Append('}');
		return Sha256Hex(Encoding.UTF8.GetBytes(sb.ToString()));
	}

	/// JSON.stringify string escaping: " \ and control chars only; non-ASCII
	/// stays literal (the hash runs over UTF-8 bytes of the literal).
	private static void AppendJsonString(StringBuilder sb, string s)
	{
		sb.Append('"');
		foreach (var c in s)
		{
			switch (c)
			{
				case '"': sb.Append("\\\""); break;
				case '\\': sb.Append("\\\\"); break;
				case '\b': sb.Append("\\b"); break;
				case '\f': sb.Append("\\f"); break;
				case '\n': sb.Append("\\n"); break;
				case '\r': sb.Append("\\r"); break;
				case '\t': sb.Append("\\t"); break;
				default:
					if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4"));
					else sb.Append(c);
					break;
			}
		}
		sb.Append('"');
	}

	// ── platform webhooks ────────────────────────────────────────────────
	/// X-Pouchy-Signature: t=<unix>,v1=<hex HMAC-SHA256("<t>.<body>", secret)>
	/// (Stripe-style). `body` = EXACT raw bytes as a UTF-8 string.
	public static (bool Ok, long TSec, string? Reason) VerifyWebhookSignature(
		string body, string? headerValue, string? secret, long? nowMs = null, long maxSkewMs = MaxSkewMs)
	{
		if (string.IsNullOrEmpty(headerValue)) return (false, 0, "missing");
		var parts = ParseKvHeader(headerValue);
		if (!parts.TryGetValue("t", out var tRaw) || !parts.TryGetValue("v1", out var v1) ||
			!tRaw.All(char.IsAsciiDigit) || v1.Length != 64 ||
			!v1.All(c => char.IsAsciiDigit(c) || (c >= 'a' && c <= 'f')))
			return (false, 0, "malformed");
		// Kit ≥0.3.4 parity (same reason ORDER as the JS kit: header checks
		// first): an unset/empty secret is refused, never verified — an empty
		// HMAC key would authenticate forgeable deliveries.
		if (string.IsNullOrEmpty(secret)) return (false, 0, "missing_secret");
		var tSec = long.Parse(tRaw);
		var now = nowMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
		if (Math.Abs(now - tSec * 1000) > maxSkewMs) return (false, 0, "stale");
		var expected = HmacHex(secret, $"{tSec}.{body}");
		if (!CryptographicOperations.FixedTimeEquals(
			Convert.FromHexString(expected), Convert.FromHexString(v1)))
			return (false, 0, "bad_signature");
		return (true, tSec, null);
	}

	// ── POUCHY-SOURCE-V1 (your Events → Pouchy) ──────────────────────────
	/// Sign AT SEND TIME on every attempt — a retried eventId carries a fresh
	/// t; duplicate suppression stays Pouchy's eventId dedupe.
	public static string BuildEventSourceSignature(
		string source, string eventId, byte[]? bodyUtf8, string keyId, string secret, long? nowMs = null)
	{
		var tSec = (nowMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) / 1000;
		var canonical = string.Join("\n", new[]
		{
			"POUCHY-SOURCE-V1",
			tSec.ToString(),
			source,
			eventId,
			bodyUtf8 is null || bodyUtf8.Length == 0 ? "-" : Sha256Hex(bodyUtf8)
		});
		return $"t={tSec},kid={keyId},v1={HmacHex(secret, canonical)}";
	}

	// ── helpers ──────────────────────────────────────────────────────────
	private static Dictionary<string, string> ParseKvHeader(string value)
	{
		var parts = new Dictionary<string, string>();
		foreach (var seg in value.Split(','))
		{
			var i = seg.IndexOf('=');
			if (i > 0) parts[seg[..i].Trim()] = seg[(i + 1)..].Trim();
		}
		return parts;
	}

	public static string Sha256Hex(byte[] bytes) =>
		Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();

	public static string HmacHex(string secret, string message) =>
		Convert.ToHexString(HMACSHA256.HashData(
			Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(message))).ToLowerInvariant();

	public static byte[] Base64UrlDecode(string s)
	{
		var b64 = s.Replace('-', '+').Replace('_', '/');
		return Convert.FromBase64String(b64.PadRight(b64.Length + (4 - b64.Length % 4) % 4, '='));
	}
}

Program.cs (the self-test)

// Self-test: prove PouchyVerify.cs against the SAME public vectors that pin
// the production signer (shipped in @pouchy_ai/backend-kit under vectors/,
// mirrored in this repo at packages/backend-kit/vectors/).
//
//   dotnet run                          # vectors from ../../packages/backend-kit/vectors
//   dotnet run -- /path/to/vectors      # or point anywhere (e.g. node_modules/@pouchy_ai/backend-kit/vectors)
//
// Exit code 0 = every leg green. Any FAIL means this port has drifted from
// the wire contract — do not put it in front of real traffic.

using System.Text;
using System.Text.Json;
using Pouchy.Reference;

var vectorsDir = args.Length > 0 ? args[0] : Path.Combine("..", "..", "packages", "backend-kit", "vectors");
var failures = 0;

void Check(string name, bool ok, string? detail = null)
{
	Console.WriteLine($"{(ok ? "PASS" : "FAIL")}  {name}{(ok || detail is null ? "" : $"  — {detail}")}");
	if (!ok) failures++;
}

// ── POUCHY-ACTOR-V1 golden vector ─────────────────────────────────────────
var actor = JsonDocument.Parse(File.ReadAllText(Path.Combine(vectorsDir, "capability-signing-vectors.json"))).RootElement;
var g = actor.GetProperty("golden");
string secret = g.GetProperty("secret").GetString()!;
string kid = g.GetProperty("kid").GetString()!;
long tSec = g.GetProperty("timestampSec").GetInt64();
string claimsB64 = g.GetProperty("claimsB64").GetString()!;
string audience = g.GetProperty("audience").GetString()!;
string method = g.GetProperty("method").GetString()!;
string url = g.GetProperty("url").GetString()!;
string body = g.GetProperty("body").GetString()!;
string sigHeader = g.GetProperty("signatureHeader").GetString()!;
var bodyBytes = Encoding.UTF8.GetBytes(body);
var secrets = new Dictionary<string, string> { [kid] = secret };
long nowMs = tSec * 1000;

Func<string, string?> Headers(string? actorB64, string? sig) => name => name switch
{
	PouchyVerify.ActorHeader => actorB64,
	PouchyVerify.ActorSignatureHeader => sig,
	_ => null
};

// Byte-level intermediates first — these fail loudly on any encoder-parity
// bug before the HMAC comparison can obscure WHERE the drift is.
Check("canonical path (sorted query, JS encode set)",
	PouchyVerify.CanonicalRequestPath(url) == "/pouchy/grant?a=1&b=2",
	PouchyVerify.CanonicalRequestPath(url));
var canonical = PouchyVerify.CanonicalSigningString(claimsB64, audience, method, url, bodyBytes, g.GetProperty("claims").GetProperty("cid").GetString()!, tSec);
Check("canonical signing string bytes",
	PouchyVerify.Sha256Hex(Encoding.UTF8.GetBytes(canonical)) == "6828660b5658f8b5bdbf2675038e5a6652c98e38f8cc685e2ab9592ea79ee183",
	PouchyVerify.Sha256Hex(Encoding.UTF8.GetBytes(canonical)));
Check("intent hash parity (JSON.stringify escaping)",
	PouchyVerify.IntentHashOf(new Dictionary<string, string> { ["qty"] = "1", ["item"] = "starter_sword" })
		== "f96b62b7ed86ce405dd738892438572d58fb414bc4b3ad999c55374fe7c0fabb");

var (ok, _) = PouchyVerify.VerifyPouchyRequest(method, url, Headers(claimsB64, sigHeader), bodyBytes, secrets, audience, nowMs);
Check("golden vector verifies", ok is not null);
Check("verified principal", ok is { PrincipalKind: "end_user", PrincipalSub: "user_7" });

// Relative req.url parity (kit ≥0.3.1): same request, path-relative URL.
var rel = new Uri(url).PathAndQuery;
var (okRel, _) = PouchyVerify.VerifyPouchyRequest(method, rel, Headers(claimsB64, sigHeader), bodyBytes, secrets, audience, nowMs);
Check("path-relative req.url verifies (0.3.1 parity)", okRel is not null);

// The seven adversarial mutations (constructed exactly like the JS compat test).
string Reason(string m, string u, string? aB64, string? sig, byte[]? b, Dictionary<string, string> sec, string aud, long now)
	=> PouchyVerify.VerifyPouchyRequest(m, u, Headers(aB64, sig), b, sec, aud, now).Reason ?? "VERIFIED";
Check("changed_body → bad_signature",
	Reason(method, url, claimsB64, sigHeader, Encoding.UTF8.GetBytes(body.Replace("\"1\"", "\"999\"")), secrets, audience, nowMs) == "bad_signature");
Check("changed_query → bad_signature",
	Reason(method, url.Replace("a=1", "a=2"), claimsB64, sigHeader, bodyBytes, secrets, audience, nowMs) == "bad_signature");
Check("changed_path → bad_signature",
	Reason(method, url.Replace("/pouchy/grant", "/pouchy/steal"), claimsB64, sigHeader, bodyBytes, secrets, audience, nowMs) == "bad_signature");
Check("changed_audience → bad_signature",
	Reason(method, url, claimsB64, sigHeader, bodyBytes, secrets, "evil.example.com", nowMs) == "bad_signature");
Check("stale_timestamp → stale",
	Reason(method, url, claimsB64, sigHeader, bodyBytes, secrets, audience, (tSec + 301) * 1000) == "stale");
Check("wrong_key → bad_signature",
	Reason(method, url, claimsB64, sigHeader, bodyBytes, new() { [kid] = "pcsk_wrong" }, audience, nowMs) == "bad_signature");
Check("unknown_kid → unknown_key",
	Reason(method, url, claimsB64, sigHeader, bodyBytes, new() { ["k00000000"] = secret }, audience, nowMs) == "unknown_key");
Check("empty secret entry → unknown_key (never an HMAC over an empty key)",
	Reason(method, url, claimsB64, sigHeader, bodyBytes, new() { [kid] = "" }, audience, nowMs) == "unknown_key");
Check("missing headers → missing",
	Reason(method, url, null, null, bodyBytes, secrets, audience, nowMs) == "missing");

// ── POUCHY-SOURCE-V1 golden vector (byte-for-byte reproduction) ──────────
var src = JsonDocument.Parse(File.ReadAllText(Path.Combine(vectorsDir, "event-source-signing-vectors.json"))).RootElement;
var sg = src.GetProperty("golden");
var produced = PouchyVerify.BuildEventSourceSignature(
	sg.GetProperty("source").GetString()!,
	sg.GetProperty("eventId").GetString()!,
	Encoding.UTF8.GetBytes(sg.GetProperty("body").GetString()!),
	sg.GetProperty("kid").GetString()!,
	sg.GetProperty("secret").GetString()!,
	sg.GetProperty("timestampSec").GetInt64() * 1000);
Check("source signature reproduces the golden header", produced == sg.GetProperty("signatureHeader").GetString(), produced);

// ── platform webhook (self-contained: sign like the server, then verify) ──
const string whSecret = "whsec_selftest";
const string whBody = "{\"id\":\"evt_1\",\"type\":\"agent.event_reply\"}";
const long whT = 1_700_000_000;
var whHeader = $"t={whT},v1={PouchyVerify.HmacHex(whSecret, $"{whT}.{whBody}")}";
Check("webhook verify accepts", PouchyVerify.VerifyWebhookSignature(whBody, whHeader, whSecret, whT * 1000).Ok);
Check("webhook tamper refused", !PouchyVerify.VerifyWebhookSignature(whBody.Replace("evt_1", "evt_2"), whHeader, whSecret, whT * 1000).Ok);
Check("webhook stale refused", PouchyVerify.VerifyWebhookSignature(whBody, whHeader, whSecret, (whT + 301) * 1000).Reason == "stale");
// Kit ≥0.3.4 parity: an empty webhook secret must refuse — an empty HMAC key
// is one an attacker can compute, so "verifying" with it authenticates forgeries.
var whForged = $"t={whT},v1={PouchyVerify.HmacHex("", $"{whT}.{whBody}")}";
Check("empty webhook secret refused (forgery with empty-key HMAC)",
	PouchyVerify.VerifyWebhookSignature(whBody, whForged, "", whT * 1000).Reason == "missing_secret");
Check("null webhook secret refused", PouchyVerify.VerifyWebhookSignature(whBody, whHeader, null, whT * 1000).Reason == "missing_secret");

Console.WriteLine(failures == 0 ? "\nALL GREEN — this port matches the production wire contract." : $"\n{failures} FAILURES — do not deploy this port.");
return failures == 0 ? 0 : 1;

PouchyReference.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>Pouchy.Reference</RootNamespace>
  </PropertyGroup>
</Project>

Full walkthrough (declare → provision-first → test → go live): Quickstart: Agent Data Plane (中文版: 数据面快速上手); on-prem deployment shapes: Integration FAQ §9.