@openmobilehub/credentagent-gate
v0.4.0
Published
The consent layer for AI agents — require a verifiable credential from the user's wallet before a consequential MCP tool completes. Identity leads; payments is one application.
Readme
@openmobilehub/credentagent-gate
The consent layer for AI agents. An AI agent must prove a verifiable credential
from the user's phone wallet before a consequential MCP tool completes. Identity
leads; payments is one application — age.over(21), a loyalty membership, a
prescription, and payment.in("usd") are all just credentials in the same policy.
Design preview / v0.1. This package is real and tested, but the broader CredentAgent SDK is still being extracted from the reference server (mcp-apps-shopping-demo). See the repo's
ROADMAP.mdfor what's shipping vs. next.
Install
npm install @openmobilehub/credentagent-gateApache-2.0, ESM, ships its own types. Pairs with
@openmobilehub/credentagent-storefront, but stands alone on any
Express-shaped host.
Quickstart
The whole flow in ≤ 10 lines — a credential-gated agentic storefront. createStorefront()
publishes the ceremony seams; new CredentAgent().mount(store.app) wires the real /credentagent/*
ceremony rails; store.gate() resolves your policy on every checkout call (copied from
examples/storefront.mjs /
storefront-gate.test.ts):
import { createStorefront } from "@openmobilehub/credentagent-storefront/server";
import { CredentAgent, age, membership, payment, required, optional } from "@openmobilehub/credentagent-gate";
const store = createStorefront(); // the storefront — one line
const credentagent = new CredentAgent(); // zero-config (defaults to http://localhost:3000)
credentagent.mount(store.app); // wires the real /credentagent/* ceremony rails
store.gate((order) => // resolved on every checkout (payment settles LAST)
credentagent.requirements(order, [
required(age.over(21).when((order) => order.lines.some((l) => l.minimumAge != null))),
optional(membership.discount(10)), // 10% off if a loyalty credential is presented
required(payment.in("usd")), // amount derived from the order; settles last
]),
);
const { url } = await store.listen(3005); // → add http://localhost:3005/mcp to Claude / ChatGPT / GooseAdd the whiskey (21+) to the cart and check out → the tool returns the checkout link plus
a requires manifest → the buyer proves age (and optionally membership) → authorizes payment →
the widget shows the confirmation. Add the headphones instead and the age gate drops — the
.when() predicate receives the order and is false.
.when((order) => …)takes the wholeGateOrder(id, total, currency, lines), so a predicate keys off the cart's lines — e.g.order.lines.some((l) => l.minimumAge != null). For a deployment pass your public origin:new CredentAgent({ walletOrigin: "https://shop.example" }).
Branding the ceremony pages
The buyer proves their credential on pages the gate serves during your checkout, so those
pages can carry your brand. Set branding once on the constructor and every ceremony page
— the checkout hub and the age / payment / credential gate pages — picks it up. No per-page wiring:
const credentagent = new CredentAgent({
walletOrigin: "https://shop.acme.example",
branding: {
wordmark: "ACME", // replaces the "CREDENTAGENT" wordmark
accent: "#7c3aed", // primary colour (button, active step, ✓); hover derived
logo: "data:image/svg+xml;base64,…", // optional; shown instead of the wordmark
demoPill: false, // hide the DEMO pill (e.g. in production)
},
});
credentagent.mount(store.app); // every /credentagent/* page now carries ACME's brand- Zero-config default — omit
brandingand the pages render exactly as before. - Chrome only, by design. Branding never touches the honesty trust footer: the
presence-only-demodisclosure (see Honest status) is the same on every page, branded or not. Nothing you pass can alter or remove it. - Safe by construction. Every field is sanitized where it's used — the wordmark is HTML-escaped,
the
accentmust be a hex orrgb()/hsl()colour (anything else — including a bare word liketeal— is ignored, keeping the built-in teal), andlogoaccepts only adata:image/…URI, anhttps:/http:URL, or a root-relative/path. A host-supplied string can't inject markup or CSS onto a consent screen.
Orders — a checkout without a storefront
Don't have (or want) the MCP storefront? Drive the checkout yourself with credentagent.orders.
Two things happen at startup (wire the checkout once, subscribe to completion once); the third,
orders.create(), happens per purchase — inside a request handler, each time an agent wants to buy.
The comments below mark which is which:
import express from "express";
import { CredentAgent, age, payment, required } from "@openmobilehub/credentagent-gate";
const app = express();
app.use(express.json());
const credentagent = new CredentAgent({ walletOrigin: "http://localhost:4000" });
// ── once, at startup ──────────────────────────────────────────────
credentagent.orders.serve(app); // wire the whole checkout onto your app
credentagent.on("order.settled", ({ id }) => fulfill(id)); // subscribe once — fires when ANY order is paid
// ── per purchase — inside a request handler (runs every time) ──────
app.post("/buy-wine", async (_req, res) => {
const { id, approveUrl } = await credentagent.orders.create({ // → { id, approveUrl, manifest }
order: { id: "", total: 21, currency: "USD", lines: [{ id: "wine", name: "Bottle of wine", quantity: 1, unitPrice: 21, minimumAge: 21 }] },
policy: [required(age.over(21)), required(payment.in("usd"))],
});
res.json({ id, approveUrl }); // hand approveUrl to the human
});
// read status here (durable, works across instances). In a single-process server the
// in-process order.settled listener above is enough; this is the cross-instance signal.
app.get("/orders/:id", async (req, res) => res.json(await credentagent.orders.retrieve(req.params.id)));
on("order.settled")is an in-process event, not a webhook — it fires synchronously in the one long-lived Node process that completed the order. On serverless (Vercel, Lambda) the instance can be frozen the moment the response is sent, so async work started in the listener may never finish — don't fulfill from it there. Instead, inject shared stores (orderStore,completedOrderStore) and readorders.retrieve(id)as the durable, cross-instance signal — or register a webhook (next section) so a different service gets the signed HTTPPOST.
orders.retrieve(id) is the one result door: { ok: true, completion } once paid, { ok: false,
pending: true, approveUrl } while it's open, or { ok: false, code } for an unknown id. The amount and
the age threshold are re-derived from the order you stored server-side — never trusted from the link
(invariant 2), and a gated order can only complete through the wallet ceremony, never a shortcut
(invariant 1). Runnable: examples/orders-checkout/.
Preflight — did I configure this right? (doctor())
Those deployment knobs — a stable gateSecret, a public walletOrigin, shared stores — are easy to
forget, and a missing one fails at the worst time (a buyer mid-checkout, on the one instance that never
saw the proof). Call credentagent.doctor() once at startup for a config check. It returns typed plain
data, and never throws or touches the network — it reads your config plus process.env for
deployment signals:
const report = credentagent.doctor();
if (!report.ok) { // ok === no error-level findings
for (const f of report.findings) console.error(`[${f.level}] ${f.message}\n fix: ${f.fix}`);
process.exit(1); // fail the boot rather than serve a broken deploy
}
// …or one line that prints a human-readable summary AND returns the same report:
credentagent.doctor({ print: true });Each finding is { level: "error" | "warn", code, message, fix }. It checks the config you passed to
new CredentAgent(...):
| code | fires when | fix |
| --- | --- | --- |
| ephemeral-gate-secret | no gateSecret on a deployment (serverless ⇒ error, else warn) | set GATE_SECRET — openssl rand -hex 32 — and pass { gateSecret } |
| localhost-wallet-origin | walletOrigin is localhost on a deployment | pass your public https origin |
| in-memory-verification-store | the default in-memory store on a deployment | inject a shared { store } (Redis/Upstash) |
| in-memory-order-store | the default in-memory order stores on a deployment | inject { orderStore, completedOrderStore } |
In plain local dev — no deployment env signals (VERCEL, AWS_LAMBDA_*, NODE_ENV=production, …) — it
reports nothing, so the zero-config quickstart stays quiet.
Webhooks — tell a different service when an order settles
on("order.settled", …) only fires in the process that settled the order. When fulfillment runs
elsewhere, register a webhook: the gate sends a signed HTTP POST and the other service
verifies it — the Stripe idiom (constructEvent). Real HMAC signature, replay-protected.
// SENDING — configure once; a settled order is POSTed to each endpoint (signed, retried, non-blocking):
new CredentAgent({ webhooks: { endpoints: [{ url: "https://fulfillment.example/hooks", secret: process.env.WHSEC }] } });
// RECEIVING — a different service; only the shared secret. Verify the RAW body:
import { constructEvent } from "@openmobilehub/credentagent-gate";
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try { event = constructEvent(req.body, req.get("CredentAgent-Signature"), process.env.WHSEC); }
catch (err) { return res.status(400).send(err.message); } // forged / tampered / replayed → rejected
if (event.type === "order.settled") fulfill(event.data.object.orderId); // dedupe on event.id
res.json({ received: true });
});Signature: CredentAgent-Signature: t=…,v1=<hex HMAC-SHA256> over `${t}.${rawBody}`, secret
whsec_…. Delivery is at-least-once with retry (dedupe on event.id) — it never blocks a settled
order. Endpoint URLs must be https (http only for localhost dev — enforced where endpoints enter);
redirects are never followed, and each attempt is bounded by a timeout (timeoutMs, default 10s).
verifyEvent(...) is the never-throws verdict door if you prefer a result to a try/catch. Runnable:
examples/order-webhooks/.
Bring your own host — mount on YOUR MCP server
createStorefront() is one host; the product promise is "mount the gate on any app." If you
have your own Express + MCP server, your own catalog/pricing, and your own order store, wire the
gate over them with defineHost(...) — you should never call the low-level completeOrder by
hand or reach into app.locals yourself.
Give defineHost three things — how you price an order, how you read a created order, and
where completed orders go — and it builds the shared completion for you, owns the per-order
verification store, and publishes every seam. Then mount(app) serves the proof pages over them:
import { CredentAgent, defineHost, age, payment, required } from "@openmobilehub/credentagent-gate";
const host = defineHost({
catalog: { createOrder: (items, orderId) => priceFromMyCatalog(items, orderId) }, // amount source of truth
orderStore: { read: (orderId) => myOrders.get(orderId) ?? null }, // your created order
records: { read: (id) => myCompleted.get(id), write: (rec) => myCompleted.set(rec.orderId, rec) },
signingKey: process.env.GATE_SECRET, // stable across instances; or { allowEphemeralKey: true } for dev
returnUrl: (id) => `/checkout/${id}`, // your checkout route — where a rail returns the buyer after a proof
});
host.publish(app); // publish the seams onto your app
new CredentAgent({ walletOrigin }).mount(app); // serve the /credentagent/* proof pages over them
// Your OWN place-order / MCP tool calls host.complete(...), so the gates run server-side on YOUR
// completion path too (not just in a rendered page — Security invariant 1). Typed plain data back:
app.post("/checkout/:id", async (req, res) => {
const order = priceFromMyCatalog([{ productId: "wine", quantity: 1 }], req.params.id);
const result = await host.complete({ order, mandateId: `demo-${order.id}`, amount: order.total, currency: "USD", method: "demo", gates: [{ gate: "demo", pass: true, detail: "" }] });
res.status(result.completed ? 200 : 402).json(result); // { completed:false, reason:"age" } until proven
});host.complete(input)is the same shared completion the rails use — one choke point, no second weaker path. It re-prices from your catalog (never the token), runs the age + any customgate()credentials, settles, and records idempotently, returning{ completed, reason? }.returnUrlis where a rail sends the buyer back after they prove. Set it to your own checkout route — otherwise the rails default to/checkout?order=<id>, which a non-storefront host does not serve, and the buyer lands on a dead link.host.verificationStoreis the per-order proof store (default in-memory; inject a shared store — e.g. Redis — for a multi-instance deploy). The rails write it when the buyer proves; your completion reads it.- Fail-closed like
mount():defineHostthrows at construction on an incomplete or contradictory seam set (missingcatalog/orderStore, bothrecordsand acompletion, or neither asigningKeynorallowEphemeralKey). - Advanced: pass your own
completionseam instead ofrecordsif you'd rather bindcompleteOrderyourself. Runnable end-to-end:examples/bring-your-own-host.mjs.
Still storefront-only (not yet in
defineHost): the catalog-injected MCP shopping tools, the widget bundle, and the?cart=stateless-order transport wiring —createStorefront()remains the batteries- included host.defineHostcovers the seam contract (pricing, orders, completion, verification), which is what a custom host actually needs.
The three execution contexts
The split is load-bearing — conflating them is the documented root cause of confusion (spec §0). v0.1 is consolidated Mode A:
- Tool — mints the link + reports requirements. Your
checkouthandler runs once when checkout is requested. There is no phone in the loop, so it does not run a ceremony — it callscredentagent.requirements(order, policy)and surfaces the resultingrequiresmanifest. - Page — runs the gates. The buyer opens the link once and completes every verification and
payment in a single browser session, on the
/credentagent/*routesmount()serves. - Poll — reports completion. The agent polls (MCP has no server→client push) and reports the result. It never performs the ceremony.
requirements() is the code→data boundary (Principle VI): it runs your .when() / appliesTo
predicates server-side, sorts payment last, and emits a flat, JSON-safe manifest — no functions
cross the wire. The manifest's requires[] is exactly what the agent and the widget receive.
The credential model
Built-ins, custom credentials, and effects are one shape (Credential + Effect):
| Builder | Effect | Verifies |
| :-- | :-- | :-- |
| age.over(n) | gate() | the explicit positive age_over_${n} === true (an 18+ proof never satisfies a 21+ gate) |
| membership.discount(n) | discount({ percent: n }) | a non-empty membership_number; applies the discount once |
| payment.in(cur) | authorize() | authorized === true; settles last, amount derived from the order |
Wrap each in required(c) or optional(c) to build the ordered policy array. Attach a call-site
conditional with .when((order) => boolean) — it returns a fresh Credential (non-mutating) whose
predicate is AND-ed onto any existing appliesTo.
Gate any credential with defineCredential — no registration step, usable by object
(from specs/001-attesto-sdk/quickstart.md):
import { defineCredential, dcql, gate } from "@openmobilehub/credentagent-gate";
const prescription = defineCredential({
id: "prescription",
request: dcql({ docType: "org.hl7.prescription.1", claims: ["rx_valid"] }),
verify: (c) => c.rx_valid === true,
effect: gate(), // or discount({ percent }) / authorize()
appliesTo: (order) => order.lines.some((l) => l.requiresRx), // definition-time conditional
ui: { label: "Prescription", action: "Verify prescription" },
});
// …then drop required(prescription) into the same policy array.dcql({ docType, claims }) is concise sugar for a single-mdoc DCQL query (selective disclosure,
never-retain by default). The credential id defaults to a stable, unique derivation from the full
doctype (org.openwallet.payment.1 → org_openwallet_payment_1); pass dcql({ docType, claims, id })
to name it yourself. The three effect builders — gate(), discount({ percent }), authorize() —
are the only effects the resolver interprets.
A custom credential is served by the mounted ceremony and enforced end-to-end — no new code
path: requirements() registers it by id, the credential-gate rail builds the wallet request from
its own request/verify, and completeOrder enforces every applicable gate() on the shared
completion path (a hard block, independent of required/optional). Worked pack:
examples/professional-license.mjs.
Multi-instance / serverless: register-on-resolve is enough for one long-lived process, but where checkout and completion can land on different instances (serverless, multiple workers) an instance that never ran
requirements()has a cold registry — its completion sweep would no-op and an applicablegate()could complete unproven. Declare your custom credentials up front so every instance enforces them from boot:new CredentAgent({ credentials: [prescription] }).
Honest status
Honesty is carried in the types, not prose (Principle VII):
enforcedAt: "checkout"— v0.1 is consolidated Mode A: every gate runs on the checkout page (Context 2) and is enforced server-side on the completion path. ("tool"is the Mode-B blocking shape — roadmap.)trust_level: "presence-only-demo"— the gate enforces disclosure (an explicit positive claim, not token-presence) and binding (nonce / ephemeral key), but not trust (mdoc issuer / device signatures). A self-crafted mdoc would pass. This is a flow demo, not a real safety control — never present it as one. Issuer-trust verification (Multipaz /@auth0/mdl,trust_level: "issuer-verified") is roadmap.
The three rails mount() serves differ in how much crypto is real today:
| Rail (/credentagent/*) | What it proves | Trust today |
| :-- | :-- | :-- |
| passkey (same-device + cross-device caBLE) | WebAuthn assertion verified against this server's origin / RP-ID, user-verification required, nonce/replay-bound — real cryptography (@simplewebauthn) | real WebAuthn crypto |
| credential (age / membership) | OpenID4VP presentation; the explicit positive claim is checked, but the mdoc's issuer/device signatures are not verified | presence-only-demo |
| dc-payment (Digital Credentials API) | amount-bound mdoc presentation; the JWE vp_token + device signature are taken at face value, not cryptographically verified | presence-only-demo |
| delegated (opt-in — mount({ verifier })) | the same policy, verified + settled by an external verifier/processor; the gate re-derives the binding and re-runs your policy, and relays the verdict's trust | the verifier's — issuer-verified with a real anchor |
The built-in OpenID4VP plumbing is scaffolded; cryptographic mdoc trust is the integration step, not
new cryptography. The mandate is AP2-shaped and dev-signed (integrity hash), not key-signed.
trust_level: "issuer-verified" is reachable today through the verifier seam (below) — the gate
relays a level a real anchor produced; it does not verify issuer signatures itself.
Presenting a stable reader identity (optional)
By default the OpenID4VP rails self-sign an ephemeral reader certificate per request, so a wallet has no reason to trust the verifier and shows an "unknown verifier" warning. Pass a stable reader identity and the rails present it instead — a wallet that trusts it (via an imported RICAL) shows the verifier as trusted:
new CredentAgent({
walletOrigin: "https://shop.example",
readerIdentity: {
key: readFileSync("reader.key", "utf8"), // PEM, EC P-256
cert: readFileSync("reader.pem", "utf8"), // PEM leaf → rides in the request's `x5c`
},
});The cert's SubjectAltName must cover the walletOrigin host or the wallet rejects the request
(origin binding); the client warns at construction on a mismatch.
This is verifier trust, not issuer trust — they point in opposite directions. It changes whether the wallet trusts us to ask. It does not verify the mdoc the wallet presents back, so
trust_levelstayspresence-only-demoeither way.
Real trust: delegate to an external verifier (verifier)
The built-in rails lack an issuer/device trust anchor — that is what keeps them
presence-only-demo. Pass a verifier seam and the gate serves a delegated ceremony: your same
gate() policy runs a real, issuer-trust-verified, amount-bound payment through an external
verifier/processor (e.g. a Multipaz verifier + a UPay-style processor), inside the mounted
ceremony instead of around it. Your policy and storefront are unchanged — only the backend moves in.
1. The adapter you write. A plain object with three methods, each a thin wrapper over a verifier/processor you already have — in plain words:
interface DelegatedVerifier {
// "Tell the checker: verify these credentials, bound to exactly $124 payable to me."
buildRequest(input: { order, dcql, binding, origin }): DelegatedHandoff;
// "Fetch the checker's verdict, server-to-server, by reference — no money moves here."
consume(input: { reference, order }): DelegatedVerdict;
// "Charge. The gate calls this ONLY after its own re-checks pass."
settle?(input: { reference, order, amount, currency }): SettlementRecordLike;
}Type your adapter with import type { DelegatedVerifier } from "@openmobilehub/credentagent-gate" and
let the compiler guide you through each method's exact input/output shape.
settle is optional: an identity-only gate (age, a licence, membership) completes without it —
there is nothing to charge.
2. Plugging it in. One option, your policy untouched — either path works:
// with the storefront
const store = createStorefront({ verifier });
new CredentAgent().mount(store.app); // zero-arg — picks the verifier off app.locals
// or storefront-less
credentagent.mount(app, { ...seams, verifier });3. What happens at runtime. Checkout → one delegated approve link → the wallet ceremony runs
with the checker → the browser returns only a sealed, order-bound reference (never the result, so
it cannot forge an approval) → the gate re-prices from the catalog, re-runs your policy over the
disclosed claims, then authorizes settle → the order is recorded with the checker's trust_level.
The one rule that makes delegation safe: trust is delegable, binding is not.
- The verifier brings what the gate lacks: issuer/device signature verification against a real
anchor. Its verdict reports
trust_level: "issuer-verified", which the gate relays — it never synthesizes a level it did not receive. - The gate keeps what it must never outsource: it re-derives the amount/payee from the catalog and
re-checks the verdict against it, re-runs your policy over the disclosed claims (an 18+ verifier
check never satisfies
age.over(21)), and only then authorizessettle. A verifier that approves the wrong amount — or a stricter-than-the-merchant age — is refused before any money moves.
The concrete verifier is a host-side adapter — no processor-specific symbol lives in this package.
No real adapter ships yet. This package defines the interface; the first real adapter lives host-side in
openwallet-foundation/multipaz-utopia(S6, tracked in multipaz-utopia#16). Today the only way to run the delegated rail is a stand-in like the scripted verifier inexamples/delegated-verifier/— a test double, never shipped, and deliberately kept out of the runnablerun-storefrontexample. Stating this plainly is the honesty fence working, not a gap.
A refused tool call is a protocol, not a wall. For a page-less tool,
gated()returns a typedverification_requiredenvelope the agent drives (which credential, a per-order approve link, the tool to poll) instead of completing — the retained blocking Mode B primitive.
Grants — approve once, the agent spends while you're away
You approve one spending limit — "up to $100 at this store, max $30 per purchase, Beverages
only" — and your agent buys against it unattended, every rule re-checked server-side on every
spend. This is the human-NOT-present resource, credentagent.grants (spec 009):
import express from "express";
import { CredentAgent } from "@openmobilehub/credentagent-gate";
const app = express();
app.use(express.json());
const credentagent = new CredentAgent({
walletOrigin: "https://shop.example",
catalog: { coffee: { price: 18, category: "Beverages" }, wine: { price: 21, minAge: 21, category: "Beverages" } },
});
// ── once, at startup ─────────────────────────────────────────────
credentagent.grants.serve(app); // serves each grant's approveUrl (approve/deny page)
// ── the human approves ONCE ──────────────────────────────────────
const grant = await credentagent.grants.create({
merchant: "utopia", budget: 100, perSpend: 30,
allow: { categories: ["Beverages"] }, // bound WHAT may be bought, not just how much
});
sendToUser(grant.approveUrl); // grant.status: "pending" → "authorized" | "denied"
// ── later, human AWAY — rehydrate and spend within the sealed bounds ──
const g = await credentagent.grants.retrieve(grant.id);
if (g.status === "authorized") {
const s = await g.spend({ idempotencyKey: "order-1", items: [{ sku: "coffee" }] });
// → { ok: true, amount: 18, remaining: 82, authorization: "delegated" }
// or { ok: false, code: "per-spend-exceeded" | "budget-exceeded" | "not-allowed" | "step-up" | "revoked" | … }
await g.revoke(); // kill-switch — the very next spend refuses
}The refusal code is a typed union (GrantDoorCode) — a typo fails to compile. A retried
idempotencyKey replays the ORIGINAL outcome, refusal included, so a key can never be repurposed.
The sealed bounds are immutable after create. Age is non-delegable — an age-restricted item
refuses step-up no matter the budget: buying wine always needs a live human. Try all of it
clickable in examples/demo-hub/
(Section 3) or the two-pane examples/grants-proto/.
Under the hood — the delegated-draw seams (005)
grants wraps DelegatedGate (preApprove/spend/revoke), which remains exported for
direct use:
const gate = new DelegatedGate({ catalog: { coffee: 18 } });
const grant = await gate.preApprove({ merchant: "blue-bottle", perOrder: 30, total: 100 });
const result = await grant.spend({ idempotencyKey: "order-1", item: "coffee" });
await grant.revoke();Under that facade are signer-agnostic seams for redeeming a user-sealed
Intent Mandate (a bounded, revocable delegation) with no live human — sealIntent / checkDraw
(pure, total, typed refusals), a RevocationStore (per-intent + subject kill-switch, atomic
single-use consume), and an additive, fail-closed draw branch in completeOrder that re-runs
every bounds + revocation check server-side, writes a delegationId, and suppresses settlement.
Age is non-delegable — an age-restricted cart always steps up to a live ceremony.
Honesty (Principle VII, constitution v1.1.0): draws carry a presence axis ("delegated" /
"delegated-demo") — when consent happened — separate from trust_level — how strongly it's
bound. The wire crypto is real (ES256 over the canonical draw; content-addressed intentId), but
v0.1 has no issuer/DeviceKey trust anchor and no per-draw proof-of-possession — the grant is
effectively a bearer instrument, fenced as a demo. A real HNP control requires presence:
"delegated" and trust_level: "issuer-verified"; the HTTP intent rail + the wallet server that
provide those are later increments.
API surface (v0.1)
// Client (configure once, then declarative calls)
class CredentAgent {
constructor(opts?: { walletOrigin?: string; store?: VerificationStore; credentials?: Credential[]; branding?: Branding });
requirements(order: GateOrder, policy: Step[]): VerificationManifestEntry[]; // Context 1
mount(app: ExpressApp, ceremony?: MountCeremony): void; // Context 2
doctor(opts?: { print?: boolean }): DoctorReport; // config preflight (#25)
}
// Policy builders + extensibility
age.over(n) · membership.discount(n) · payment.in(currency)
required(c) · optional(c) · .when((order) => boolean)
defineCredential({ id, request, verify, effect, appliesTo?, ui })
dcql({ docType, claims }) · gate() · discount({ percent?, amount? }) · authorize()
// Stores + host-side composition seam
MemoryVerificationStore · completeOrder(input, ctx)
// Bring your own host — the typed seam contract (builds completion + publishes the seams)
defineHost({ catalog, orderStore, records | completion, signingKey | allowEphemeralKey })
→ { verificationStore, publish(app), complete(input) → { completed, reason? } }
// Delegated draws (HNP, 005 preview) — the Stripe-grade facade + the underlying seams
DelegatedGate · gate.preApprove(bounds) → DelegatedGrant · grant.spend(purchase) → SpendResult · grant.revoke()
sealIntent · checkDraw · signDraw · MemoryRevocationStore · Draw / IntentBounds / CommittedDraw / Refusal
// Cart Mandate (ap2.CartMandate) — signed, tamper-evident cart integrity; the
// signingKey-gated check in completeOrder + the opt-in `statelessOrders` transport
issueCartMandate(args, secret) · verifyCartMandate(mandate, orderId, secret) · DEFAULT_CART_MANDATE_TTL_MS
// Retained Mode-B / roadmap blocking primitive
gated() · buildVerificationRequired() · isVerificationRequired() · envelopeInstruction()
ageDcql() · ENVELOPE_VERSION · ENVELOPE_SENTINEL
// Types: CredentAgentOptions, GateOrder, OrderLine, Credential, Step, Effect,
// VerificationManifestEntry, VerificationStore, VerificationRecord,
// TrustLevel, DcqlQuery, DcqlClaim, DcqlCredentialOption, ExpressApp,
// CompletionSeam / SettlementSeam / CeremonyOrder (host composition)
// DelegatedVerifier / DelegatedVerdict / DelegatedHandoff / SettlementRecordLike (delegated seam)Full, compiler-checked contract: specs/001-attesto-sdk/ (the
quickstart, spec.md,
and the mount contract).
Apache-2.0 · part of Open Mobile Hub (Linux Foundation).
