From ac19a9409ec61653dd255263d70d603f643b9fba Mon Sep 17 00:00:00 2001 From: Blockchain-Oracle Date: Fri, 17 Jul 2026 13:22:05 +0200 Subject: [PATCH] feat(leash): complete Phase 7 control plane --- apps/agent/src/eip3009-authorization.test.ts | 60 + apps/agent/src/eip3009-authorization.ts | 5 + .../src/fetch-wrapper.integration.test.ts | 2 + apps/agent/src/fetch-wrapper.ts | 12 +- apps/agent/src/origin-context.ts | 9 + apps/agent/src/proxy.integration.test.ts | 6 +- apps/agent/src/proxy.ts | 11 +- apps/agent/src/remote-signer.test.ts | 15 +- apps/agent/src/remote-signer.ts | 10 +- apps/agent/src/resource-url.test.ts | 45 + apps/agent/src/resource-url.ts | 39 + .../app/(leash)/auth/leash-auth-api.test.ts | 57 + apps/web/app/(leash)/auth/leash-auth-api.ts | 100 + apps/web/app/(leash)/auth/leash-auth-card.tsx | 74 + .../(leash)/auth/leash-auth-email-panel.tsx | 70 + .../app/(leash)/auth/leash-auth-entry.test.ts | 80 + apps/web/app/(leash)/auth/leash-auth-entry.ts | 26 + apps/web/app/(leash)/auth/leash-auth-page.tsx | 51 + apps/web/app/(leash)/auth/use-leash-auth.ts | 186 + .../leash/(control)/cap-panel.module.css | 299 ++ .../leash/(control)/cap-panel.test.tsx | 97 + .../app/(leash)/leash/(control)/cap-panel.tsx | 251 ++ .../app/(leash)/leash/(control)/cap/page.tsx | 46 + .../leash/(control)/connect-agent.module.css | 154 + .../leash/(control)/connect-agent.test.tsx | 156 + .../(leash)/leash/(control)/connect-agent.tsx | 83 + .../(control)/connect-config-panel.module.css | 97 + .../leash/(control)/connect-config-panel.tsx | 62 + .../leash/(control)/connect-config.test.ts | 78 + .../(leash)/leash/(control)/connect-config.ts | 75 + .../(leash)/leash/(control)/connect/page.tsx | 58 + .../leash/(control)/control-page.module.css | 111 + .../(leash)/leash/(control)/control-page.tsx | 39 + .../leash/(control)/key-lifecycle.module.css | 184 + .../leash/(control)/key-lifecycle.test.tsx | 88 + .../(leash)/leash/(control)/key-lifecycle.tsx | 139 + .../app/(leash)/leash/(control)/layout.tsx | 19 + .../leash/(control)/leash-account-menu.tsx | 48 + .../leash/(control)/leash-overview.module.css | 250 ++ .../leash/(control)/leash-overview.test.tsx | 75 + .../leash/(control)/leash-overview.tsx | 178 + .../leash/(control)/leash-shell.module.css | 279 ++ .../leash/(control)/leash-shell.test.tsx | 55 + .../(leash)/leash/(control)/leash-shell.tsx | 70 + .../notification-center-actions.test.ts | 81 + .../(control)/notification-center-actions.ts | 87 + .../(control)/notification-center.module.css | 291 ++ .../(control)/notification-center.test.tsx | 80 + .../leash/(control)/notification-center.tsx | 252 ++ .../leash/(control)/notifications/page.tsx | 53 + .../overview-notifications.module.css | 113 + .../(control)/overview-notifications.test.tsx | 28 + .../(control)/overview-notifications.tsx | 72 + apps/web/app/(leash)/leash/(control)/page.tsx | 84 + apps/web/app/(leash)/leash/login/page.tsx | 9 + .../api/agent/sign/route.integration.test.ts | 20 +- .../auth/precheck/route.integration.test.ts | 13 + .../route.owner-upgrade.integration.test.ts | 97 + .../app/api/leash/auth/precheck/route.test.ts | 102 + apps/web/app/api/leash/auth/precheck/route.ts | 49 + .../auth/verify/route.integration.test.ts | 187 + apps/web/app/api/leash/auth/verify/route.ts | 111 + .../api/leash/caps/route.integration.test.ts | 161 + apps/web/app/api/leash/caps/route.ts | 88 + apps/web/app/api/leash/cycles/reset/route.ts | 46 + .../api/leash/keys/route.integration.test.ts | 186 + apps/web/app/api/leash/keys/route.ts | 125 + .../notifications/route.integration.test.ts | 291 ++ ...e.pagination-precision.integration.test.ts | 108 + apps/web/app/api/leash/notifications/route.ts | 100 + apps/web/drizzle/0017_fine_black_tom.sql | 75 + apps/web/drizzle/0018_lucky_tusk.sql | 36 + .../drizzle/0019_aspiring_frightful_four.sql | 2 + apps/web/drizzle/meta/0017_snapshot.json | 3284 ++++++++++++++++ apps/web/drizzle/meta/0018_snapshot.json | 3290 +++++++++++++++++ apps/web/drizzle/meta/0019_snapshot.json | 3290 +++++++++++++++++ apps/web/drizzle/meta/_journal.json | 21 + apps/web/lib/auth/current-owner.test.ts | 72 + apps/web/lib/auth/current-owner.ts | 32 + apps/web/lib/auth/leash-key.ts | 59 +- apps/web/lib/auth/magic-admin.ts | 9 + .../auth/merchant-session.integration.test.ts | 33 + apps/web/lib/auth/owner-request.ts | 25 + .../auth/owner-session.integration.test.ts | 157 + apps/web/lib/auth/owner-session.ts | 43 + apps/web/lib/auth/session-principal.ts | 14 +- apps/web/lib/auth/session.test.ts | 27 + apps/web/lib/auth/session.ts | 42 +- ...tion-migration-upgrade.integration.test.ts | 149 + ...sion-migration-upgrade.integration.test.ts | 85 + ...sh-notification-schema.integration.test.ts | 245 ++ apps/web/lib/db/leash-notification-schema.ts | 155 + apps/web/lib/db/leash-receipt-schema.ts | 15 + apps/web/lib/db/leash-schema.ts | 1 + .../merchant-provisioning.integration.test.ts | 49 + .../lib/db/owner-identity.integration.test.ts | 123 + apps/web/lib/db/owner-identity.ts | 48 + apps/web/lib/db/provision-merchant.ts | 31 +- apps/web/lib/leash/cap-cycle-policy-store.ts | 85 + apps/web/lib/leash/cap-display.test.ts | 107 + apps/web/lib/leash/cap-display.ts | 76 + apps/web/lib/leash/cap-halt-store.ts | 21 + .../lib/leash/cap-halt.integration.test.ts | 219 ++ apps/web/lib/leash/cap-input.test.ts | 43 + apps/web/lib/leash/cap-input.ts | 65 + apps/web/lib/leash/cap-policy-errors.ts | 13 + apps/web/lib/leash/cap-policy-summary.ts | 86 + .../lib/leash/cap-policy.integration.test.ts | 285 ++ apps/web/lib/leash/cap-policy.ts | 154 + apps/web/lib/leash/cap-view.ts | 45 + apps/web/lib/leash/cycles.integration.test.ts | 266 ++ apps/web/lib/leash/cycles.test.ts | 65 + apps/web/lib/leash/cycles.ts | 122 + .../float-empty-notifier.integration.test.ts | 118 + apps/web/lib/leash/key-input.ts | 36 + apps/web/lib/leash/leash-format.test.ts | 44 + apps/web/lib/leash/leash-format.ts | 53 + apps/web/lib/leash/leash-http.ts | 35 + apps/web/lib/leash/leash-key-view.ts | 18 + apps/web/lib/leash/notification-input.test.ts | 95 + apps/web/lib/leash/notification-input.ts | 141 + ...tification-owner-count.integration.test.ts | 92 + apps/web/lib/leash/notification-store.ts | 205 + apps/web/lib/leash/notification-view.ts | 29 + .../lib/leash/notifier.integration.test.ts | 294 ++ apps/web/lib/leash/notifier.ts | 291 ++ .../leash/owner-agents.integration.test.ts | 79 + apps/web/lib/leash/owner-agents.ts | 51 + .../pay-result-store.integration.test.ts | 76 +- apps/web/lib/leash/pay-result-store.ts | 58 +- apps/web/lib/leash/resource-identity.test.ts | 35 + apps/web/lib/leash/resource-identity.ts | 34 + .../resource-novelty.integration.test.ts | 220 ++ .../resource-provenance.integration.test.ts | 149 + .../sign-notifications.integration.test.ts | 242 ++ .../sign-preflight-store.integration.test.ts | 108 +- apps/web/lib/leash/sign-preflight-store.ts | 99 +- apps/web/lib/leash/sign-request.test.ts | 51 + apps/web/lib/leash/sign-request.ts | 46 +- .../lib/leash/sign-store.integration.test.ts | 1 + apps/web/lib/leash/sign-store.ts | 107 +- 141 files changed, 22458 insertions(+), 96 deletions(-) create mode 100644 apps/agent/src/eip3009-authorization.test.ts create mode 100644 apps/agent/src/resource-url.test.ts create mode 100644 apps/agent/src/resource-url.ts create mode 100644 apps/web/app/(leash)/auth/leash-auth-api.test.ts create mode 100644 apps/web/app/(leash)/auth/leash-auth-api.ts create mode 100644 apps/web/app/(leash)/auth/leash-auth-card.tsx create mode 100644 apps/web/app/(leash)/auth/leash-auth-email-panel.tsx create mode 100644 apps/web/app/(leash)/auth/leash-auth-entry.test.ts create mode 100644 apps/web/app/(leash)/auth/leash-auth-entry.ts create mode 100644 apps/web/app/(leash)/auth/leash-auth-page.tsx create mode 100644 apps/web/app/(leash)/auth/use-leash-auth.ts create mode 100644 apps/web/app/(leash)/leash/(control)/cap-panel.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/cap-panel.test.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/cap-panel.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/cap/page.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/connect-agent.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/connect-agent.test.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/connect-agent.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/connect-config-panel.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/connect-config-panel.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/connect-config.test.ts create mode 100644 apps/web/app/(leash)/leash/(control)/connect-config.ts create mode 100644 apps/web/app/(leash)/leash/(control)/connect/page.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/control-page.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/control-page.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/key-lifecycle.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/key-lifecycle.test.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/key-lifecycle.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/layout.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/leash-account-menu.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/leash-overview.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/leash-overview.test.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/leash-overview.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/leash-shell.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/leash-shell.test.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/leash-shell.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/notification-center-actions.test.ts create mode 100644 apps/web/app/(leash)/leash/(control)/notification-center-actions.ts create mode 100644 apps/web/app/(leash)/leash/(control)/notification-center.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/notification-center.test.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/notification-center.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/notifications/page.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/overview-notifications.module.css create mode 100644 apps/web/app/(leash)/leash/(control)/overview-notifications.test.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/overview-notifications.tsx create mode 100644 apps/web/app/(leash)/leash/(control)/page.tsx create mode 100644 apps/web/app/(leash)/leash/login/page.tsx create mode 100644 apps/web/app/api/auth/verify/route.owner-upgrade.integration.test.ts create mode 100644 apps/web/app/api/leash/auth/precheck/route.test.ts create mode 100644 apps/web/app/api/leash/auth/precheck/route.ts create mode 100644 apps/web/app/api/leash/auth/verify/route.integration.test.ts create mode 100644 apps/web/app/api/leash/auth/verify/route.ts create mode 100644 apps/web/app/api/leash/caps/route.integration.test.ts create mode 100644 apps/web/app/api/leash/caps/route.ts create mode 100644 apps/web/app/api/leash/cycles/reset/route.ts create mode 100644 apps/web/app/api/leash/keys/route.integration.test.ts create mode 100644 apps/web/app/api/leash/keys/route.ts create mode 100644 apps/web/app/api/leash/notifications/route.integration.test.ts create mode 100644 apps/web/app/api/leash/notifications/route.pagination-precision.integration.test.ts create mode 100644 apps/web/app/api/leash/notifications/route.ts create mode 100644 apps/web/drizzle/0017_fine_black_tom.sql create mode 100644 apps/web/drizzle/0018_lucky_tusk.sql create mode 100644 apps/web/drizzle/0019_aspiring_frightful_four.sql create mode 100644 apps/web/drizzle/meta/0017_snapshot.json create mode 100644 apps/web/drizzle/meta/0018_snapshot.json create mode 100644 apps/web/drizzle/meta/0019_snapshot.json create mode 100644 apps/web/lib/auth/current-owner.test.ts create mode 100644 apps/web/lib/auth/current-owner.ts create mode 100644 apps/web/lib/auth/owner-request.ts create mode 100644 apps/web/lib/auth/owner-session.integration.test.ts create mode 100644 apps/web/lib/auth/owner-session.ts create mode 100644 apps/web/lib/db/leash-notification-migration-upgrade.integration.test.ts create mode 100644 apps/web/lib/db/leash-notification-precision-migration-upgrade.integration.test.ts create mode 100644 apps/web/lib/db/leash-notification-schema.integration.test.ts create mode 100644 apps/web/lib/db/leash-notification-schema.ts create mode 100644 apps/web/lib/db/owner-identity.integration.test.ts create mode 100644 apps/web/lib/db/owner-identity.ts create mode 100644 apps/web/lib/leash/cap-cycle-policy-store.ts create mode 100644 apps/web/lib/leash/cap-display.test.ts create mode 100644 apps/web/lib/leash/cap-display.ts create mode 100644 apps/web/lib/leash/cap-halt-store.ts create mode 100644 apps/web/lib/leash/cap-halt.integration.test.ts create mode 100644 apps/web/lib/leash/cap-input.test.ts create mode 100644 apps/web/lib/leash/cap-input.ts create mode 100644 apps/web/lib/leash/cap-policy-errors.ts create mode 100644 apps/web/lib/leash/cap-policy-summary.ts create mode 100644 apps/web/lib/leash/cap-policy.integration.test.ts create mode 100644 apps/web/lib/leash/cap-policy.ts create mode 100644 apps/web/lib/leash/cap-view.ts create mode 100644 apps/web/lib/leash/cycles.integration.test.ts create mode 100644 apps/web/lib/leash/cycles.test.ts create mode 100644 apps/web/lib/leash/cycles.ts create mode 100644 apps/web/lib/leash/float-empty-notifier.integration.test.ts create mode 100644 apps/web/lib/leash/key-input.ts create mode 100644 apps/web/lib/leash/leash-format.test.ts create mode 100644 apps/web/lib/leash/leash-format.ts create mode 100644 apps/web/lib/leash/leash-http.ts create mode 100644 apps/web/lib/leash/leash-key-view.ts create mode 100644 apps/web/lib/leash/notification-input.test.ts create mode 100644 apps/web/lib/leash/notification-input.ts create mode 100644 apps/web/lib/leash/notification-owner-count.integration.test.ts create mode 100644 apps/web/lib/leash/notification-store.ts create mode 100644 apps/web/lib/leash/notification-view.ts create mode 100644 apps/web/lib/leash/notifier.integration.test.ts create mode 100644 apps/web/lib/leash/notifier.ts create mode 100644 apps/web/lib/leash/owner-agents.integration.test.ts create mode 100644 apps/web/lib/leash/owner-agents.ts create mode 100644 apps/web/lib/leash/resource-identity.test.ts create mode 100644 apps/web/lib/leash/resource-identity.ts create mode 100644 apps/web/lib/leash/resource-novelty.integration.test.ts create mode 100644 apps/web/lib/leash/resource-provenance.integration.test.ts create mode 100644 apps/web/lib/leash/sign-notifications.integration.test.ts diff --git a/apps/agent/src/eip3009-authorization.test.ts b/apps/agent/src/eip3009-authorization.test.ts new file mode 100644 index 0000000..aacc7a1 --- /dev/null +++ b/apps/agent/src/eip3009-authorization.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + InvalidEip3009AuthorizationError, + parseExactEip3009Authorization, +} from "./eip3009-authorization.js"; + +const agentAddress = "0x2222222222222222222222222222222222222222"; +const nowSeconds = 1_784_271_300; +const maxAtomicUsdcAmount = 999_999_999_999_999_999_990_000n; + +function authorization(value: bigint) { + return { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + version: "2", + }, + message: { + from: agentAddress, + nonce: `0x${"12".repeat(32)}`, + to: "0x1111111111111111111111111111111111111111", + validAfter: 0n, + validBefore: BigInt(nowSeconds + 60), + value, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }; +} + +describe("EIP-3009 USDC amount bound", () => { + it("accepts the largest amount representable by the product cap domain", () => { + expect( + parseExactEip3009Authorization(authorization(maxAtomicUsdcAmount), { + address: agentAddress, + nowSeconds, + }).amount, + ).toBe(maxAtomicUsdcAmount.toString()); + }); + + it("rejects one atomic unit above the product cap domain", () => { + expect(() => + parseExactEip3009Authorization(authorization(maxAtomicUsdcAmount + 1n), { + address: agentAddress, + nowSeconds, + }), + ).toThrow(InvalidEip3009AuthorizationError); + }); +}); diff --git a/apps/agent/src/eip3009-authorization.ts b/apps/agent/src/eip3009-authorization.ts index cea2282..961292e 100644 --- a/apps/agent/src/eip3009-authorization.ts +++ b/apps/agent/src/eip3009-authorization.ts @@ -3,6 +3,10 @@ import { getAddress, type TypedData } from "viem"; import { ARBITRUM_NETWORK, ARBITRUM_USDC, BASE_NETWORK, BASE_USDC } from "./routing.js"; const MAX_AUTHORIZATION_LIFETIME_SECONDS = 600; +// Mirrors numeric(20,0) cap cents at 10,000 atomic USDC units per cent. +const MAX_CAP_USD_CENTS = 10n ** 20n - 1n; +const ATOMIC_UNITS_PER_CENT = 10_000n; +export const MAX_USDC_AMOUNT_ATOMIC = MAX_CAP_USD_CENTS * ATOMIC_UNITS_PER_CENT; const AUTHORIZATION_TYPES = [ { name: "from", type: "address" }, { name: "to", type: "address" }, @@ -103,6 +107,7 @@ export function parseExactEip3009Authorization( if ( from !== getAddress(options.address) || amount === "0" || + BigInt(amount) > MAX_USDC_AMOUNT_ATOMIC || validAfter !== "0" || typeof nonce !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(nonce) || diff --git a/apps/agent/src/fetch-wrapper.integration.test.ts b/apps/agent/src/fetch-wrapper.integration.test.ts index b87501d..c6c3871 100644 --- a/apps/agent/src/fetch-wrapper.integration.test.ts +++ b/apps/agent/src/fetch-wrapper.integration.test.ts @@ -138,6 +138,7 @@ describe("Leash fetch wrapper with real x402 and HTTP wires", () => { toolName: `GET ${origin}/protected`, transport: "http", }, + resourceUrl: `${origin}/protected`, }); expect(JSON.stringify(signRequests[0])).not.toMatch(/receipt-secret|client-fragment/); await expect @@ -186,6 +187,7 @@ describe("Leash fetch wrapper with real x402 and HTTP wires", () => { expect(signRequests).toHaveLength(1); expect(signRequests[0]).toMatchObject({ origin: { toolName: `POST ${origin}/protected` }, + resourceUrl: `${origin}/protected`, }); }); }); diff --git a/apps/agent/src/fetch-wrapper.ts b/apps/agent/src/fetch-wrapper.ts index c54efd8..aa55c67 100644 --- a/apps/agent/src/fetch-wrapper.ts +++ b/apps/agent/src/fetch-wrapper.ts @@ -1,6 +1,10 @@ import { wrapFetchWithPayment } from "@x402/fetch"; -import { currentPaymentOrigin, withPaymentOrigin } from "./origin-context.js"; +import { + currentPaymentOrigin, + withPaymentOrigin, + withPaymentResourceUrl, +} from "./origin-context.js"; import { createLeashPaymentClient } from "./payment-client.js"; import { LeashRemoteSigner } from "./remote-signer.js"; @@ -14,6 +18,10 @@ interface LeashFetchOptions { type FetchInput = Request | string | URL; +function requestUrl(input: FetchInput) { + return input instanceof Request ? input.url : input.toString(); +} + function requestName(input: FetchInput, init?: RequestInit) { const method = init?.method ?? (input instanceof Request ? input.method : "GET"); const rawUrl = input instanceof Request ? input.url : input.toString(); @@ -49,6 +57,6 @@ export function createLeashFetch(options: LeashFetchOptions) { toolName: requestName(input, init), transport: "http", }, - () => paidFetch(input, init), + () => withPaymentResourceUrl(requestUrl(input), () => paidFetch(input, init)), ); } diff --git a/apps/agent/src/origin-context.ts b/apps/agent/src/origin-context.ts index ff99f57..7677e8d 100644 --- a/apps/agent/src/origin-context.ts +++ b/apps/agent/src/origin-context.ts @@ -3,6 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import type { PaymentOrigin } from "./remote-signer.js"; const paymentOrigin = new AsyncLocalStorage(); +const paymentResourceUrl = new AsyncLocalStorage(); export function currentPaymentOrigin() { return paymentOrigin.getStore(); @@ -11,3 +12,11 @@ export function currentPaymentOrigin() { export function withPaymentOrigin(origin: PaymentOrigin, action: () => T) { return paymentOrigin.run(origin, action); } + +export function currentPaymentResourceUrl() { + return paymentResourceUrl.getStore(); +} + +export function withPaymentResourceUrl(resourceUrl: string, action: () => T) { + return paymentResourceUrl.run(resourceUrl, action); +} diff --git a/apps/agent/src/proxy.integration.test.ts b/apps/agent/src/proxy.integration.test.ts index 9bf5520..7abe449 100644 --- a/apps/agent/src/proxy.integration.test.ts +++ b/apps/agent/src/proxy.integration.test.ts @@ -26,7 +26,9 @@ const paymentRequired = { scheme: "exact", }, ], - resource: { url: "mcp://tool/search" }, + resource: { + url: "mcp://wire-user:wire-pass@Search.API.EXAMPLE.TEST:8443/tool/search?token=wire-secret#fragment", + }, x402Version: 2, } satisfies PaymentRequired; @@ -142,7 +144,9 @@ describe("Leash MCP proxy with real SDK transports", () => { expect(paidMetadata[0]).toMatchObject({ traceId: "trace-1" }); expect(signBodies[0]).toMatchObject({ origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + resourceUrl: "mcp://search.api.example.test:8443/tool/search", }); + expect(JSON.stringify(signBodies[0])).not.toMatch(/wire-user|wire-pass|wire-secret|fragment/); expect(resultBodies).toEqual([ { outcome: "observed", diff --git a/apps/agent/src/proxy.ts b/apps/agent/src/proxy.ts index 04b65e7..b3f3501 100644 --- a/apps/agent/src/proxy.ts +++ b/apps/agent/src/proxy.ts @@ -7,7 +7,7 @@ import { MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY } from "@x402/mcp"; import { detectMcpPaymentRequired } from "./detect.js"; import { SignerNotConfiguredError } from "./errors.js"; -import { withPaymentOrigin } from "./origin-context.js"; +import { withPaymentOrigin, withPaymentResourceUrl } from "./origin-context.js"; interface LeashProxyOptions { paymentClient?: x402Client; @@ -65,9 +65,12 @@ export function createLeashProxyServer(options: LeashProxyOptions) { if (!initialResult) throw new Error("Upstream tool returned no result"); return initialResult; } - if (!options.paymentClient) throw new SignerNotConfiguredError(); + const paymentClient = options.paymentClient; + if (!paymentClient) throw new SignerNotConfiguredError(); - const paymentPayload = await options.paymentClient.createPaymentPayload(challenge); + const paymentPayload = await withPaymentResourceUrl(challenge.resource.url, () => + paymentClient.createPaymentPayload(challenge), + ); const paidResult = await options.upstream.callTool( { ...request.params, @@ -81,7 +84,7 @@ export function createLeashProxyServer(options: LeashProxyOptions) { ); const settlement = settlementFromResult(paidResult); if (settlement) { - await options.paymentClient.handlePaymentResponse({ + await paymentClient.handlePaymentResponse({ paymentPayload, requirements: paymentPayload.accepted, settleResponse: settlement, diff --git a/apps/agent/src/remote-signer.test.ts b/apps/agent/src/remote-signer.test.ts index b374ce9..e2963ec 100644 --- a/apps/agent/src/remote-signer.test.ts +++ b/apps/agent/src/remote-signer.test.ts @@ -83,11 +83,21 @@ function signerWithFetch(fetch: typeof globalThis.fetch) { apiKey: "leash_sk_secret", fetch, nowSeconds: () => nowSeconds, + resourceUrl: credentialedResourceUrl, reportRetryDelayMs: 1, reportTimeoutMs: 10, }); } +function credentialedResourceUrl() { + const url = new URL("https://TOOL.EXAMPLE.TEST:8443/search"); + url.username = "wire-user"; + url.password = "wire-pass"; + url.searchParams.set("token", "wire-secret"); + url.hash = "fragment"; + return url.toString(); +} + describe("Leash remote signer authorization gate", () => { it("posts only an exact EIP-3009 native-USDC authority and verifies its signature", async () => { const signerRequest = validSignerRequest(); @@ -99,6 +109,7 @@ describe("Leash remote signer authorization gate", () => { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", network: "eip155:8453", payTo: "0x1111111111111111111111111111111111111111", + resourceUrl: "https://tool.example.test:8443/search", signerRequest: { ...signerRequest, message: { @@ -112,10 +123,8 @@ describe("Leash remote signer authorization gate", () => { return Response.json({ receiptId: "receipt-1", signature }); }); const signer = signerWithFetch(fetch); - await expect(signer.signTypedData(signerRequest)).resolves.toBe(signature); expect(signer.receiptIdForSignature(signature)).toBe("receipt-1"); - expect(signer.receiptIdForSignature(signature)).toBe("receipt-1"); }); it.each([ @@ -171,7 +180,6 @@ describe("Leash remote signer authorization gate", () => { mutate(request); const fetch = vi.fn(async () => Response.json({})); const signer = signerWithFetch(fetch); - await expect(signer.signTypedData(request)).rejects.toMatchObject({ code: "INVALID_SIGNER_REQUEST", }); @@ -184,7 +192,6 @@ describe("Leash remote signer authorization gate", () => { const signer = signerWithFetch(async () => Response.json({ receiptId: "receipt-forged", signature: forgedSignature }), ); - await expect(signer.signTypedData(signerRequest)).rejects.toMatchObject({ code: "INVALID_SIGNER_RESPONSE", status: 502, diff --git a/apps/agent/src/remote-signer.ts b/apps/agent/src/remote-signer.ts index 007ad73..762c4db 100644 --- a/apps/agent/src/remote-signer.ts +++ b/apps/agent/src/remote-signer.ts @@ -7,7 +7,8 @@ import { parseExactEip3009Authorization, type SignerRequest, } from "./eip3009-authorization.js"; -import { currentPaymentOrigin } from "./origin-context.js"; +import { currentPaymentOrigin, currentPaymentResourceUrl } from "./origin-context.js"; +import { redactPaymentResourceUrl } from "./resource-url.js"; export interface PaymentOrigin { clientName: string; @@ -22,6 +23,7 @@ interface RemoteSignerOptions { fetch?: typeof globalThis.fetch; nowSeconds?: () => number; origin?: () => PaymentOrigin | undefined; + resourceUrl?: () => string | undefined; reportAttempts?: number; reportRetryDelayMs?: number; reportTimeoutMs?: number; @@ -71,6 +73,7 @@ export class LeashRemoteSigner implements ClientEvmSigner { readonly #reportAttempts: number; readonly #reportRetryDelayMs: number; readonly #reportTimeoutMs: number; + readonly #resourceUrl: (() => string | undefined) | undefined; readonly #resultEndpoint: URL; readonly #receiptBySignature = new Map(); @@ -83,6 +86,7 @@ export class LeashRemoteSigner implements ClientEvmSigner { this.#fetch = options.fetch ?? globalThis.fetch; this.#nowSeconds = options.nowSeconds ?? (() => Math.floor(Date.now() / 1_000)); this.#origin = options.origin ?? currentPaymentOrigin; + this.#resourceUrl = options.resourceUrl ?? currentPaymentResourceUrl; this.#reportAttempts = options.reportAttempts ?? 3; this.#reportRetryDelayMs = options.reportRetryDelayMs ?? 250; this.#reportTimeoutMs = options.reportTimeoutMs ?? 2_000; @@ -111,6 +115,9 @@ export class LeashRemoteSigner implements ClientEvmSigner { throw new RemoteSignerError("INVALID_SIGNER_REQUEST", "The signer request is invalid.", 400); } const origin = this.#origin?.(); + const rawResourceUrl = this.#resourceUrl?.(); + const resourceUrl = + rawResourceUrl === undefined ? undefined : redactPaymentResourceUrl(rawResourceUrl); const response = await this.#fetch(this.#endpoint, { body: jsonBody({ amount: authorization.amount, @@ -118,6 +125,7 @@ export class LeashRemoteSigner implements ClientEvmSigner { network: authorization.network, ...(origin ? { origin } : {}), payTo: authorization.payTo, + ...(resourceUrl ? { resourceUrl } : {}), signerRequest: authorization.typedData, }), headers: { authorization: `Bearer ${this.#apiKey}`, "content-type": "application/json" }, diff --git a/apps/agent/src/resource-url.test.ts b/apps/agent/src/resource-url.test.ts new file mode 100644 index 0000000..33ddffe --- /dev/null +++ b/apps/agent/src/resource-url.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { InvalidPaymentResourceUrlError, redactPaymentResourceUrl } from "./resource-url.js"; + +function credentialedResourceUrl(value: string) { + const url = new URL(value); + url.username = "wire-user"; + url.password = "wire-pass"; + url.searchParams.set("token", "secret"); + url.hash = "fragment"; + return url.toString(); +} + +describe("payment resource URL redaction", () => { + it("keeps the canonical HTTP path and meaningful port but removes secrets", () => { + expect( + redactPaymentResourceUrl( + credentialedResourceUrl("https://PAYMENTS.EXAMPLE.TEST:8443/tool/pay"), + ), + ).toBe("https://payments.example.test:8443/tool/pay"); + }); + + it("canonicalizes an MCP resource identifier without leaking its secrets", () => { + expect( + redactPaymentResourceUrl(credentialedResourceUrl("mcp://TOOL.EXAMPLE.TEST:8443/tool/pay")), + ).toBe("mcp://tool.example.test:8443/tool/pay"); + }); + + it.each([ + "", + "/relative", + "not a URL", + "ftp://tool/pay", + "file:///tmp/payment", + ])("rejects unsupported or non-absolute resource provenance: %s", (value) => { + expect(() => redactPaymentResourceUrl(value)).toThrow(InvalidPaymentResourceUrlError); + }); + + it.each([ + ["host", `https://${"a".repeat(254)}/pay`], + ["URL", `https://tool.example/${"a".repeat(2_048)}`], + ])("rejects an overlong resource %s", (_label, value) => { + expect(() => redactPaymentResourceUrl(value)).toThrow(InvalidPaymentResourceUrlError); + }); +}); diff --git a/apps/agent/src/resource-url.ts b/apps/agent/src/resource-url.ts new file mode 100644 index 0000000..80f3b64 --- /dev/null +++ b/apps/agent/src/resource-url.ts @@ -0,0 +1,39 @@ +const MAX_RESOURCE_HOST_LENGTH = 253; +const MAX_RESOURCE_URL_LENGTH = 2_048; +const SUPPORTED_RESOURCE_PROTOCOLS = new Set(["http:", "https:", "mcp:"]); + +export class InvalidPaymentResourceUrlError extends Error { + readonly code = "INVALID_PAYMENT_RESOURCE_URL"; + + constructor() { + super("Payment resource URL must be a supported absolute URL."); + this.name = "InvalidPaymentResourceUrlError"; + } +} + +export function redactPaymentResourceUrl(value: string) { + if (value.length < 1 || value.length > MAX_RESOURCE_URL_LENGTH) { + throw new InvalidPaymentResourceUrlError(); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new InvalidPaymentResourceUrlError(); + } + if ( + !SUPPORTED_RESOURCE_PROTOCOLS.has(url.protocol) || + url.hostname.length < 1 || + url.hostname.length > MAX_RESOURCE_HOST_LENGTH + ) { + throw new InvalidPaymentResourceUrlError(); + } + url.hostname = url.hostname.toLowerCase(); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + const redacted = url.toString(); + if (redacted.length > MAX_RESOURCE_URL_LENGTH) throw new InvalidPaymentResourceUrlError(); + return redacted; +} diff --git a/apps/web/app/(leash)/auth/leash-auth-api.test.ts b/apps/web/app/(leash)/auth/leash-auth-api.test.ts new file mode 100644 index 0000000..9cc623b --- /dev/null +++ b/apps/web/app/(leash)/auth/leash-auth-api.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +import { AuthRequestError } from "../../(auth)/auth-api"; +import { precheckLeashEmail, verifyLeashDidToken } from "./leash-auth-api"; + +describe("Leash owner auth API client", () => { + it("prechecks the email through the dedicated owner endpoint", async () => { + const request = vi.fn(async () => Response.json({ allowed: true })); + + await precheckLeashEmail("Owner@Example.com", { request }); + + expect(request).toHaveBeenCalledWith("/api/leash/auth/precheck", { + body: JSON.stringify({ email: "Owner@Example.com" }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + }); + + it("sends the DID to the owner verifier and accepts only a local redirect", async () => { + const goodRequest = vi.fn(async () => Response.json({ redirectTo: "/leash" })); + const badRequest = vi.fn(async () => Response.json({ redirectTo: "//outside.example" })); + + await expect( + verifyLeashDidToken("persisted.did.token", "owner@example.com", { request: goodRequest }), + ).resolves.toBe("/leash"); + expect(goodRequest).toHaveBeenCalledWith("/api/leash/auth/verify", { + body: JSON.stringify({ didToken: "persisted.did.token", email: "owner@example.com" }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + await expect( + verifyLeashDidToken("persisted.did.token", "owner@example.com", { request: badRequest }), + ).rejects.toMatchObject({ code: "INVALID_AUTH_RESPONSE" }); + }); + + it("preserves a structured server rejection for truthful UI copy", async () => { + const request = vi.fn(async () => + Response.json( + { + error: { + code: "MAGIC_EMAIL_MISMATCH", + message: "This browser belongs to a different email.", + }, + }, + { status: 409 }, + ), + ); + + await expect(precheckLeashEmail("owner@example.com", { request })).rejects.toEqual( + new AuthRequestError( + "MAGIC_EMAIL_MISMATCH", + "This browser belongs to a different email.", + 409, + ), + ); + }); +}); diff --git a/apps/web/app/(leash)/auth/leash-auth-api.ts b/apps/web/app/(leash)/auth/leash-auth-api.ts new file mode 100644 index 0000000..dbf72b3 --- /dev/null +++ b/apps/web/app/(leash)/auth/leash-auth-api.ts @@ -0,0 +1,100 @@ +import { AuthRequestError } from "../../(auth)/auth-api"; + +type AuthRequester = (input: string, init: RequestInit) => Promise; + +type AuthRequestOptions = { + request?: AuthRequester; + signal?: AbortSignal; +}; + +type ErrorBody = { + error?: { code?: unknown; message?: unknown }; +}; + +const browserRequest: AuthRequester = (input, init) => fetch(input, init); + +async function postJson(path: string, body: Record, options: AuthRequestOptions) { + const init: RequestInit = { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }; + if (options.signal) init.signal = options.signal; + + let response: Response; + try { + response = await (options.request ?? browserRequest)(path, init); + } catch (error) { + throw new AuthRequestError( + "NETWORK_ERROR", + "Tab could not reach the authentication service.", + 0, + { cause: error }, + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new AuthRequestError( + "INVALID_AUTH_RESPONSE", + "Tab received an invalid authentication response.", + response.status, + ); + } + + if (!response.ok) { + const apiError = (payload as ErrorBody).error; + throw new AuthRequestError( + typeof apiError?.code === "string" ? apiError.code : "AUTH_REQUEST_FAILED", + typeof apiError?.message === "string" + ? apiError.message + : "Authentication could not be completed.", + response.status, + ); + } + + return payload; +} + +export async function precheckLeashEmail(email: string, options: AuthRequestOptions = {}) { + const payload = await postJson("/api/leash/auth/precheck", { email }, options); + if ( + typeof payload !== "object" || + payload === null || + !("allowed" in payload) || + payload.allowed !== true + ) { + throw new AuthRequestError( + "INVALID_AUTH_RESPONSE", + "Tab received an invalid authentication response.", + 200, + ); + } +} + +export async function verifyLeashDidToken( + didToken: string, + email: string, + options: AuthRequestOptions = {}, +) { + const payload = await postJson("/api/leash/auth/verify", { didToken, email }, options); + const redirectTo = + typeof payload === "object" && + payload !== null && + "redirectTo" in payload && + typeof payload.redirectTo === "string" + ? payload.redirectTo + : ""; + + if (!redirectTo.startsWith("/") || redirectTo.startsWith("//")) { + throw new AuthRequestError( + "INVALID_AUTH_RESPONSE", + "Tab received an invalid authentication response.", + 200, + ); + } + + return redirectTo; +} diff --git a/apps/web/app/(leash)/auth/leash-auth-card.tsx b/apps/web/app/(leash)/auth/leash-auth-card.tsx new file mode 100644 index 0000000..e2afe9a --- /dev/null +++ b/apps/web/app/(leash)/auth/leash-auth-card.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { AuthChallengePanel } from "../../(auth)/auth-challenge-panel"; +import { AuthStatusPanel } from "../../(auth)/auth-status-panel"; +import { LeashAuthEmailPanel } from "./leash-auth-email-panel"; +import { useLeashAuth } from "./use-leash-auth"; + +type LeashAuthCardProps = { + configured: boolean; + configurationMessage: string; + publishableKey: string; +}; + +export function LeashAuthCard(props: LeashAuthCardProps) { + const auth = useLeashAuth(props); + const { state } = auth; + + if (state.stage === "device") { + return ( + + ); + } + + if (state.stage === "success") { + return ( + + ); + } + + if (state.stage === "error") { + return ( + + ); + } + + if (["otp", "wrong", "expired", "limited", "verifying"].includes(state.stage)) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/app/(leash)/auth/leash-auth-email-panel.tsx b/apps/web/app/(leash)/auth/leash-auth-email-panel.tsx new file mode 100644 index 0000000..f596bbb --- /dev/null +++ b/apps/web/app/(leash)/auth/leash-auth-email-panel.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { FormEvent } from "react"; + +import styles from "../../(auth)/auth.module.css"; + +type LeashAuthEmailPanelProps = { + configured: boolean; + configurationMessage: string; + email: string; + errorMessage?: string | undefined; + onEmailChange: (value: string) => void; + onSubmit: (event: FormEvent) => void; + sending: boolean; +}; + +export function LeashAuthEmailPanel(props: LeashAuthEmailPanelProps) { + const hasValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(props.email.trim()); + + return ( +
+

Log in to Leash

+

+ A trusted browser signs in instantly. Otherwise, Magic emails a one-time code. +

+ + {!props.configured ? ( +
+ {props.configurationMessage} +
+ ) : null} + +
+ + props.onEmailChange(event.currentTarget.value)} + placeholder="you@example.com" + required + type="email" + value={props.email} + /> + {props.errorMessage ? ( + + ) : null} + +
+ +

+ New here? Your owner profile is created after verification. +

+
+ ); +} diff --git a/apps/web/app/(leash)/auth/leash-auth-entry.test.ts b/apps/web/app/(leash)/auth/leash-auth-entry.test.ts new file mode 100644 index 0000000..0fd02a0 --- /dev/null +++ b/apps/web/app/(leash)/auth/leash-auth-entry.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runLeashAuthEntry } from "./leash-auth-entry"; + +describe("Leash persistent Magic auth entry", () => { + it("prechecks, restores the Magic DID, then verifies without starting OTP", async () => { + const order: string[] = []; + const onAuthenticated = vi.fn(); + const onChallenge = vi.fn(); + + await runLeashAuthEntry({ + isCurrent: () => true, + onAuthenticated, + onChallenge, + onPrecheckRejected: vi.fn(), + precheck: async () => { + order.push("precheck"); + }, + user: { + getIdToken: async () => { + order.push("getIdToken"); + return "persisted.did.token"; + }, + isLoggedIn: async () => { + order.push("isLoggedIn"); + return true; + }, + }, + verifyDidToken: async (didToken) => { + order.push(`verify:${didToken}`); + return "/leash"; + }, + }); + + expect(order).toEqual(["precheck", "isLoggedIn", "getIdToken", "verify:persisted.did.token"]); + expect(onAuthenticated).toHaveBeenCalledWith("/leash"); + expect(onChallenge).not.toHaveBeenCalled(); + }); + + it("starts OTP only when Magic has no persisted login", async () => { + const getIdToken = vi.fn(); + const onChallenge = vi.fn(); + + await runLeashAuthEntry({ + isCurrent: () => true, + onAuthenticated: vi.fn(), + onChallenge, + onPrecheckRejected: vi.fn(), + precheck: async () => undefined, + user: { + getIdToken, + isLoggedIn: vi.fn().mockResolvedValue(false), + }, + verifyDidToken: vi.fn(), + }); + + expect(getIdToken).not.toHaveBeenCalled(); + expect(onChallenge).toHaveBeenCalledOnce(); + }); + + it("does not contact Magic when the server precheck rejects", async () => { + const isLoggedIn = vi.fn(); + const onPrecheckRejected = vi.fn(); + + await runLeashAuthEntry({ + isCurrent: () => true, + onAuthenticated: vi.fn(), + onChallenge: vi.fn(), + onPrecheckRejected, + precheck: async () => { + throw new Error("not configured"); + }, + user: { getIdToken: vi.fn(), isLoggedIn }, + verifyDidToken: vi.fn(), + }); + + expect(onPrecheckRejected).toHaveBeenCalledOnce(); + expect(isLoggedIn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(leash)/auth/leash-auth-entry.ts b/apps/web/app/(leash)/auth/leash-auth-entry.ts new file mode 100644 index 0000000..e84c773 --- /dev/null +++ b/apps/web/app/(leash)/auth/leash-auth-entry.ts @@ -0,0 +1,26 @@ +import { runAuthEntry } from "../../(auth)/auth-entry"; +import { getPersistedMagicDidToken } from "../../(auth)/magic-session"; + +type MagicUser = Parameters[0]; + +type LeashAuthEntryOptions = { + isCurrent: () => boolean; + onAuthenticated: (redirectTo: string) => void; + onChallenge: () => void; + onPrecheckRejected: (error: unknown) => void; + precheck: () => Promise; + user: MagicUser; + verifyDidToken: (didToken: string) => Promise; +}; + +export function runLeashAuthEntry(options: LeashAuthEntryOptions) { + return runAuthEntry({ + isCurrent: options.isCurrent, + onAuthenticated: options.onAuthenticated, + onChallenge: options.onChallenge, + onPrecheckRejected: options.onPrecheckRejected, + precheck: options.precheck, + readDidToken: () => getPersistedMagicDidToken(options.user), + verifyDidToken: options.verifyDidToken, + }); +} diff --git a/apps/web/app/(leash)/auth/leash-auth-page.tsx b/apps/web/app/(leash)/auth/leash-auth-page.tsx new file mode 100644 index 0000000..e3b9aca --- /dev/null +++ b/apps/web/app/(leash)/auth/leash-auth-page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { getCurrentOwner } from "../../../lib/auth/current-owner"; +import styles from "../../(auth)/auth-page.module.css"; +import { LeashAuthCard } from "./leash-auth-card"; + +function authConfiguration() { + const publishableKey = process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY?.trim() ?? ""; + const missing = [ + !publishableKey ? "NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY" : undefined, + !process.env.MAGIC_SECRET_KEY?.trim() ? "MAGIC_SECRET_KEY" : undefined, + !process.env.SESSION_SECRET?.trim() ? "SESSION_SECRET" : undefined, + !process.env.DATABASE_URL?.trim() ? "DATABASE_URL" : undefined, + ].filter((name): name is string => Boolean(name)); + + return { + configured: missing.length === 0, + configurationMessage: + process.env.NODE_ENV === "development" + ? `[Authentication blocked — set ${missing.join(" and ")}]` + : "Authentication is temporarily unavailable.", + publishableKey, + }; +} + +export async function LeashAuthPage() { + await connection(); + const owner = await getCurrentOwner(); + if (owner) redirect("/leash"); + + return ( +
+
+ + + Tab · Leash + +
+
+
+ +
+

One owner session · No wallet extension required

+
+
+ ); +} diff --git a/apps/web/app/(leash)/auth/use-leash-auth.ts b/apps/web/app/(leash)/auth/use-leash-auth.ts new file mode 100644 index 0000000..ade21c4 --- /dev/null +++ b/apps/web/app/(leash)/auth/use-leash-auth.ts @@ -0,0 +1,186 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { type FormEvent, useEffect, useReducer, useRef, useState } from "react"; + +import { type EmailOtpFlow, getMagicClient } from "../../../lib/auth/magic-client"; +import { AuthRequestError } from "../../(auth)/auth-api"; +import { AuthAttemptGate } from "../../(auth)/auth-attempt"; +import { authReducer, initialAuthState } from "../../(auth)/auth-state"; +import { + type MagicEmailOtpChallenge, + startMagicEmailOtpChallenge, +} from "../../(auth)/magic-challenge"; +import { precheckLeashEmail, verifyLeashDidToken } from "./leash-auth-api"; +import { runLeashAuthEntry } from "./leash-auth-entry"; + +type LeashAuthOptions = { + configured: boolean; + publishableKey: string; +}; + +const genericError = "Couldn’t log you in to Leash. Try again."; + +function failureMessage(error: unknown) { + return error instanceof AuthRequestError ? error.message : genericError; +} + +export function useLeashAuth({ configured, publishableKey }: LeashAuthOptions) { + const router = useRouter(); + const [state, dispatch] = useReducer(authReducer, initialAuthState); + const [email, setEmail] = useState(""); + const [emailError, setEmailError] = useState(); + const [flowError, setFlowError] = useState(genericError); + const [deviceMessage, setDeviceMessage] = useState( + "Magic needs you to approve this device. Follow the instructions in your email.", + ); + const [notice, setNotice] = useState(); + const activeChallenge = useRef(undefined); + const submittedOtp = useRef<{ flow: EmailOtpFlow; otp: string } | undefined>(undefined); + const resendRequested = useRef(false); + const attemptGate = useRef(new AuthAttemptGate()); + + function cancelActiveFlow() { + const active = activeChallenge.current; + activeChallenge.current = undefined; + submittedOtp.current = undefined; + active?.cancel(); + } + + useEffect( + () => () => { + const active = activeChallenge.current; + attemptGate.current.cancel(); + activeChallenge.current = undefined; + submittedOtp.current = undefined; + active?.cancel(); + }, + [], + ); + + function failFlow(challenge: EmailOtpFlow, message: string) { + if (activeChallenge.current?.flow !== challenge) return; + cancelActiveFlow(); + setFlowError(message); + dispatch({ type: "auth-failed" }); + } + + function startMagicChallenge(normalizedEmail: string) { + activeChallenge.current = startMagicEmailOtpChallenge({ + dispatch, + email: normalizedEmail, + isCurrent: (challenge) => activeChallenge.current?.flow === challenge, + isResend: () => resendRequested.current, + onAuthenticated(challenge, redirectTo) { + if (activeChallenge.current?.flow !== challenge) return; + activeChallenge.current = undefined; + dispatch({ type: "auth-succeeded" }); + router.replace(redirectTo); + router.refresh(); + }, + onFailure(challenge, error) { + if (challenge && activeChallenge.current?.flow !== challenge) return; + setFlowError(failureMessage(error)); + activeChallenge.current = undefined; + submittedOtp.current = undefined; + dispatch({ type: "auth-failed" }); + }, + onFatal: failFlow, + publishableKey, + resetResend: () => { + resendRequested.current = false; + }, + resetSubmittedOtp: () => { + submittedOtp.current = undefined; + }, + setDeviceMessage, + setNotice, + verifyDidToken: (didToken, signal) => + verifyLeashDidToken(didToken, normalizedEmail, { signal }), + }); + } + + async function beginChallenge(normalizedEmail: string, resend = false) { + if (!configured) return; + cancelActiveFlow(); + const attempt = attemptGate.current.begin(); + setEmailError(undefined); + setNotice(undefined); + resendRequested.current = resend; + dispatch({ type: "email-submitted" }); + + try { + await runLeashAuthEntry({ + isCurrent: attempt.isCurrent, + onAuthenticated(redirectTo) { + attempt.finish(); + dispatch({ type: "auth-succeeded" }); + router.replace(redirectTo); + router.refresh(); + }, + onChallenge() { + attempt.finish(); + startMagicChallenge(normalizedEmail); + }, + onPrecheckRejected(error) { + attempt.finish(); + resendRequested.current = false; + setEmailError(failureMessage(error)); + dispatch({ type: "return-to-email" }); + }, + precheck: () => precheckLeashEmail(normalizedEmail, { signal: attempt.signal }), + user: getMagicClient(publishableKey).user, + verifyDidToken: (didToken) => + verifyLeashDidToken(didToken, normalizedEmail, { signal: attempt.signal }), + }); + } catch (error) { + if (!attempt.isCurrent()) return; + attempt.finish(); + setFlowError(failureMessage(error)); + dispatch({ type: "auth-failed" }); + } + } + + useEffect(() => { + if (state.stage !== "otp" || state.otp.length !== 6) return; + const challenge = activeChallenge.current?.flow; + if (!challenge) return; + if (submittedOtp.current?.flow === challenge && submittedOtp.current.otp === state.otp) return; + submittedOtp.current = { flow: challenge, otp: state.otp }; + dispatch({ type: "otp-submitted" }); + challenge.emit("verify-email-otp", state.otp); + }, [state.otp, state.stage]); + + function returnToEmail() { + attemptGate.current.cancel(); + cancelActiveFlow(); + setNotice(undefined); + dispatch({ type: "return-to-email" }); + } + + return { + changeEmail(value: string) { + setEmail(value); + setEmailError(undefined); + }, + changeOtp(otp: string) { + dispatch({ otp, type: "otp-changed" }); + }, + deviceMessage, + email, + emailError, + flowError, + notice, + resendCode() { + void beginChallenge(email, true); + }, + returnToEmail, + state, + submitEmail(event: FormEvent) { + event.preventDefault(); + const normalizedEmail = email.trim().toLowerCase(); + setEmail(normalizedEmail); + void beginChallenge(normalizedEmail); + }, + }; +} diff --git a/apps/web/app/(leash)/leash/(control)/cap-panel.module.css b/apps/web/app/(leash)/leash/(control)/cap-panel.module.css new file mode 100644 index 0000000..a1c6cad --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/cap-panel.module.css @@ -0,0 +1,299 @@ +.panel { + padding: 28px 32px 48px; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.eyebrow, +.metricLabel { + margin: 0; + color: #8c8a80; + font-family: var(--font-geist), monospace; + font-size: 10.5px; + font-weight: 600; +} + +.header h1 { + margin: 6px 0 0; + font-size: 22px; + letter-spacing: -0.02em; +} + +.header p:not(.eyebrow) { + max-width: 610px; + margin: 6px 0 0; + color: #5f5d55; + font-size: 12.5px; + line-height: 1.55; +} + +.haltedChip { + flex: none; + padding: 5px 11px; + border: 1px solid #f0d5d6; + border-radius: 999px; + background: #faecec; + color: #8e2f34; + font-size: 11.5px; + font-weight: 600; +} + +.warning { + display: grid; + gap: 3px; + margin-top: 18px; + padding: 13px 16px; + border: 1px solid #f2e3c2; + border-radius: 11px; + background: #fbf1dc; + color: #7a5410; + font-size: 12.5px; +} + +.spendCard, +.form { + margin-top: 18px; + border: 1px solid #e8e6df; + border-radius: 12px; + background: #fff; +} + +.spendCard { + padding: 20px 22px; +} + +.spendHeading, +.resetLine, +.legend { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.amountLine { + display: flex; + align-items: baseline; + gap: 8px; + margin: 9px 0 0; + font-variant-numeric: tabular-nums; +} + +.amountLine strong { + font-size: 32px; + letter-spacing: -0.03em; +} + +.amountLine span { + color: #5f5d55; + font-size: 14px; +} + +.amountLine b { + color: #206b4e; + font-size: 13px; +} + +.frequency { + color: #5f5d55; + font-size: 12px; +} + +.track { + position: relative; + display: flex; + height: 12px; + margin-top: 14px; + overflow: hidden; + border-radius: 999px; + background: #f1efe9; +} + +.settledFill { + background: #206b4e; +} + +.pendingFill { + background: repeating-linear-gradient(135deg, #73a38f 0 4px, #a8c7ba 4px 8px); +} + +.overageFill { + position: absolute; + inset: 0 0 0 auto; + background: repeating-linear-gradient(135deg, #b4383d 0 3px, transparent 3px 6px); + box-shadow: -1px 0 0 #fff; + pointer-events: none; +} + +.legend { + justify-content: flex-start; + margin-top: 10px; + color: #8c8a80; + font-size: 11px; +} + +.legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.legend i { + width: 8px; + height: 8px; + border-radius: 2px; +} + +.settledKey { + background: #206b4e; +} + +.pendingKey { + background: #a8c7ba; +} + +.legend strong { + margin-left: auto; + color: #5f5d55; + font-weight: 500; +} + +.resetLine { + align-items: flex-start; + margin-top: 15px; + color: #8c8a80; + font-size: 11.5px; + line-height: 1.45; +} + +.resetLine span:last-child { + text-align: right; +} + +.overageNote { + margin: 14px 0 0; + padding: 10px 12px; + border: 1px solid #f0d5d6; + border-radius: 8px; + background: #faecec; + color: #8e2f34; + font-size: 12px; +} + +.form { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(180px, 0.65fr) auto; + align-items: end; + gap: 14px; + padding: 18px 20px; +} + +.form label { + display: grid; + gap: 7px; + color: #5f5d55; + font-size: 11.5px; + font-weight: 600; +} + +.form input, +.form select { + width: 100%; + height: 38px; + padding: 0 11px; + border: 1px solid #dbd9d0; + border-radius: 8px; + background: #fff; + color: #1c1b18; +} + +.actions { + display: flex; + gap: 8px; +} + +.actions button { + height: 38px; + padding: 0 14px; + border: 0; + border-radius: 8px; + background: #206b4e; + color: #fff; + font-size: 12px; + font-weight: 600; +} + +.actions .secondaryButton { + border: 1px solid #dbd9d0; + background: #fff; + color: #5f5d55; +} + +.actions button:disabled { + opacity: 0.55; +} + +.success, +.error { + margin: 12px 0 0; + font-size: 12px; +} + +.success { + color: #17503a; +} + +.error { + color: #b4383d; +} + +.form input:focus-visible, +.form select:focus-visible, +.actions button:focus-visible { + outline: 2px solid #206b4e; + outline-offset: 2px; +} + +@media (max-width: 900px) { + .form { + grid-template-columns: 1fr 1fr; + } + + .actions { + grid-column: 1 / -1; + } +} + +@media (max-width: 620px) { + .panel { + padding: 22px 18px 36px; + } + + .header, + .spendHeading, + .resetLine { + align-items: flex-start; + flex-direction: column; + } + + .amountLine { + flex-wrap: wrap; + } + + .form { + grid-template-columns: 1fr; + } + + .actions { + grid-column: auto; + flex-wrap: wrap; + } + + .resetLine span:last-child { + text-align: left; + } +} diff --git a/apps/web/app/(leash)/leash/(control)/cap-panel.test.tsx b/apps/web/app/(leash)/leash/(control)/cap-panel.test.tsx new file mode 100644 index 0000000..872c1cf --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/cap-panel.test.tsx @@ -0,0 +1,97 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { CapPolicyView } from "../../../../lib/leash/cap-view"; +import { CapPanel } from "./cap-panel"; + +const agentId = "11111111-1111-4111-8111-111111111111"; + +function policy(overrides: Partial = {}): CapPolicyView { + return { + agentId, + cap: { + amountUsdCents: "1000", + frequency: "daily", + updatedAt: "2026-07-17T10:00:00.000Z", + }, + cycle: { + id: "22222222-2222-4222-8222-222222222222", + nextResetAt: "2026-07-18T00:00:00.000Z", + startedAt: "2026-07-17T00:00:00.000Z", + }, + halted: false, + spend: { + approaching: true, + atOrAboveLimit: false, + blockedReceiptCount: 0, + capFillBasisPoints: "10000", + capAtomic: "10000000", + committedAtomic: "7600000", + committedBasisPoints: "7600", + overageAtomic: "0", + overageFillBasisPoints: "0", + pendingAtomic: "1600000", + pendingFillBasisPoints: "1600", + settledAtomic: "6000000", + settledFillBasisPoints: "6000", + }, + ...overrides, + }; +} + +describe("Leash cap control reality states", () => { + it("shows the real no-cap hard gate instead of an uncapped active state", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Set a cap to enable payments"); + expect(html).toContain("signer refuses every payment"); + expect(html).toContain("Create cap"); + expect(html).not.toContain("Unlimited"); + }); + + it("renders settled and pending as distinct exact ledger segments", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("$7.60"); + expect(html).toContain("$10.00"); + expect(html).toContain("incl. $1.60 pending"); + expect(html).toContain("76%"); + expect(html).toContain("Settled"); + expect(html).toContain("Pending"); + expect(html).toContain("no overage"); + expect(html).toContain("No cap-blocked attempts this cycle"); + expect(html).toContain('dateTime="2026-07-18T00:00:00.000Z"'); + }); + + it("scales a lowered-cap overage and exposes value-rich ledger evidence", () => { + const base = policy(); + if (base.spend.capAtomic === null) throw new Error("Expected a configured cap fixture"); + const overage = policy({ + halted: true, + spend: { + ...base.spend, + atOrAboveLimit: true, + blockedReceiptCount: 2, + capFillBasisPoints: "7143", + committedAtomic: "14000000", + committedBasisPoints: "14000", + overageAtomic: "4000000", + overageFillBasisPoints: "2857", + pendingAtomic: "6000000", + pendingFillBasisPoints: "4286", + settledAtomic: "8000000", + settledFillBasisPoints: "5714", + }, + }); + const html = renderToStaticMarkup(); + + expect(html).toContain("Payments halted"); + expect(html).toContain("140%"); + expect(html).toContain("$4.00 over the current cap"); + expect(html).toContain("2 cap-blocked attempts this cycle"); + expect(html).toContain("$8.00 settled; $6.00 pending; $10.00 cap; $4.00 over cap"); + expect(html).toContain('style="width:57.14%"'); + expect(html).toContain('style="width:42.86%"'); + expect(html).toContain('style="width:28.57%"'); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/cap-panel.tsx b/apps/web/app/(leash)/leash/(control)/cap-panel.tsx new file mode 100644 index 0000000..9479d78 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/cap-panel.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + blockedReceiptEvidence, + type CapPolicyView, + capUsageDescription, +} from "../../../../lib/leash/cap-view"; +import { + capFillWidth, + formatBasisPoints, + formatCycleFrequency, + formatRemaining, + formatUsdAtomic, + formatUsdCents, +} from "../../../../lib/leash/leash-format"; +import styles from "./cap-panel.module.css"; + +type CapPanelProps = { agentId: string; initialPolicy: CapPolicyView | null }; +type ResponseBody = { error?: { message?: string }; policy?: CapPolicyView }; + +function amountInput(cents: string | undefined) { + if (!cents) return ""; + const value = BigInt(cents); + return `${value / BigInt(100)}.${(value % BigInt(100)).toString().padStart(2, "0")}`; +} + +function resetLabel(nextResetAt: string | null, now: Date | null) { + if (!nextResetAt) return "No scheduled reset"; + return now ? formatRemaining(new Date(nextResetAt), now) : "Countdown starting…"; +} + +export function CapPanel({ agentId, initialPolicy }: CapPanelProps) { + const [amount, setAmount] = useState(() => amountInput(initialPolicy?.cap.amountUsdCents)); + const [error, setError] = useState(); + const [frequency, setFrequency] = useState( + initialPolicy?.cap.frequency ?? "daily", + ); + const [now, setNow] = useState(null); + const [policy, setPolicy] = useState(initialPolicy); + const [status, setStatus] = useState<"idle" | "resetting" | "saving">("idle"); + const [success, setSuccess] = useState(); + + useEffect(() => { + setNow(new Date()); + const interval = window.setInterval(() => setNow(new Date()), 60_000); + return () => window.clearInterval(interval); + }, []); + + async function mutate(method: "PATCH" | "POST", body: Record) { + const response = await fetch( + method === "POST" && body.action === "reset" ? "/api/leash/cycles/reset" : "/api/leash/caps", + { + body: JSON.stringify( + body.action === "reset" ? { agentId } : { agentId, amount, frequency }, + ), + headers: { "content-type": "application/json" }, + method, + }, + ); + const result = (await response.json()) as ResponseBody; + if (!response.ok || !result.policy) { + throw new Error(result.error?.message ?? "The cap change was not saved."); + } + setPolicy(result.policy); + } + + async function save() { + if (status !== "idle") return; + setError(undefined); + setSuccess(undefined); + setStatus("saving"); + try { + await mutate(policy ? "PATCH" : "POST", {}); + setSuccess(policy ? "Cap updated." : "Cap created. Payments can now pass the cap gate."); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "The cap change was not saved."); + } finally { + setStatus("idle"); + } + } + + async function reset() { + if (!policy || status !== "idle") return; + setError(undefined); + setSuccess(undefined); + setStatus("resetting"); + try { + await mutate("POST", { action: "reset" }); + setSuccess("Cycle reset. Current-cycle spend is now zero."); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "The cycle was not reset."); + } finally { + setStatus("idle"); + } + } + + const committedBasisPoints = policy?.spend.committedBasisPoints ?? "0"; + const hasPending = policy ? BigInt(policy.spend.pendingAtomic) > BigInt(0) : false; + const hasOverage = policy ? BigInt(policy.spend.overageAtomic) > BigInt(0) : false; + + return ( +
+
+
+

THE LEASH ITSELF

+

Cap & limits

+

Changes take effect at the hosted signer immediately. No agent restart is required.

+
+ {policy?.halted ? Payments halted : null} +
+ + {!policy ? ( +
+ Set a cap to enable payments. + Until a cap exists, the signer refuses every payment. +
+ ) : null} + + {policy ? ( +
+
+
+ SPEND THIS CYCLE +

+ {formatUsdAtomic(policy.spend.committedAtomic)} + of {formatUsdCents(policy.cap.amountUsdCents)} + {formatBasisPoints(committedBasisPoints)} +

+
+ {formatCycleFrequency(policy.cap.frequency)} +
+
+ + + {hasOverage ? ( + + ) : null} +
+
+ + + Settled + + + + Pending + + {hasPending ? ( + incl. {formatUsdAtomic(policy.spend.pendingAtomic)} pending + ) : null} +
+
+ {policy.cycle.nextResetAt ? ( + + Next reset{" "} + {" "} + · {resetLabel(policy.cycle.nextResetAt, now)} + + ) : ( + No automatic reset. Use Reset cycle when you want a clean window. + )} + {blockedReceiptEvidence(policy.spend.blockedReceiptCount)} +
+ {hasOverage ? ( +

+ {formatUsdAtomic(policy.spend.overageAtomic)} over the current cap. Raise the cap or + reset this cycle to resume payments. +

+ ) : null} +
+ ) : null} + +
{ + event.preventDefault(); + void save(); + }} + > + + +
+ + {policy ? ( + + ) : null} +
+
+ {success ? ( +

+ {success} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/cap/page.tsx b/apps/web/app/(leash)/leash/(control)/cap/page.tsx new file mode 100644 index 0000000..682e19c --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/cap/page.tsx @@ -0,0 +1,46 @@ +import { notFound } from "next/navigation"; + +import { requireCurrentOwner } from "../../../../../lib/auth/current-owner"; +import { getServerDatabase } from "../../../../../lib/db/server"; +import { readOwnerCap } from "../../../../../lib/leash/cap-policy"; +import { capPolicyView } from "../../../../../lib/leash/cap-view"; +import { + LeashAgentSelectionError, + readOwnerAgentSelection, +} from "../../../../../lib/leash/owner-agents"; +import { CapPanel } from "../cap-panel"; +import { AgentPicker, NoAgentState } from "../control-page"; + +type CapPageProps = { searchParams: Promise<{ agentId?: string | string[] }> }; + +export default async function CapPage({ searchParams }: CapPageProps) { + const [owner, query] = await Promise.all([requireCurrentOwner(), searchParams]); + const requestedAgentId = typeof query.agentId === "string" ? query.agentId : undefined; + + let selection: Awaited>; + try { + selection = await readOwnerAgentSelection(getServerDatabase().db, { + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + ownerId: owner.userId, + }); + } catch (error) { + if (error instanceof LeashAgentSelectionError) notFound(); + throw error; + } + if (!selection.selected) return ; + + const policy = await readOwnerCap(getServerDatabase().db, { + agentId: selection.selected.id, + ownerId: owner.userId, + }); + + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/connect-agent.module.css b/apps/web/app/(leash)/leash/(control)/connect-agent.module.css new file mode 100644 index 0000000..929877a --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/connect-agent.module.css @@ -0,0 +1,154 @@ +.page { + display: grid; + width: min(100%, 850px); + gap: 14px; + padding: 26px 32px 48px; +} + +.header, +.steps { + display: flex; + align-items: center; +} + +.header { + justify-content: space-between; +} + +.eyebrow { + margin: 0; + color: #8c8a80; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; +} + +.header h1 { + margin: 5px 0 0; + font-size: 22px; + letter-spacing: -0.02em; +} + +.subtitle { + display: block; + max-width: 650px; + margin-top: 5px; + color: #5f5d55; + font-size: 12.5px; +} + +.activeChip, +.waitingChip { + flex: none; + padding: 5px 10px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; +} + +.activeChip { + border: 1px solid #bee0d6; + background: #e7f3ef; + color: #17503a; +} + +.waitingChip { + border: 1px solid #dbd9d0; + background: #fff; + color: #8c8a80; +} + +.steps { + gap: 0; + padding: 14px 18px; + border: 1px solid #e8e6df; + border-radius: 11px; + background: #fff; +} + +.steps > div { + display: flex; + flex: 1; + align-items: center; + gap: 8px; + color: #8c8a80; + font-size: 11.5px; + font-weight: 600; +} + +.steps b { + display: grid; + width: 23px; + height: 23px; + place-items: center; + border: 1px solid #dbd9d0; + border-radius: 50%; + font-size: 10.5px; +} + +.steps .doneStep { + color: #17503a; +} + +.doneStep b { + border-color: #bee0d6; + background: #e7f3ef; +} + +.steps .currentStep { + color: #96650f; +} + +.currentStep b { + border-color: #f2e3c2; + background: #fbf1dc; +} + +.connectionCard { + border: 1px solid #e8e6df; + border-radius: 12px; + background: #fff; +} + +.connectionCard p { + color: #5f5d55; + font-size: 12px; + line-height: 1.5; +} + +.connectionCard { + display: grid; + gap: 4px; + padding: 16px 18px; +} + +.connectionCard strong { + margin-top: 4px; + font-size: 14px; +} + +.connectionCard p { + margin: 0; +} + +.connectionCard small { + color: #8c8a80; + font-size: 10.5px; +} + +@media (max-width: 620px) { + .page { + padding: 22px 18px 36px; + } + + .header { + align-items: flex-start; + flex-direction: column; + } + + .steps { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } +} diff --git a/apps/web/app/(leash)/leash/(control)/connect-agent.test.tsx b/apps/web/app/(leash)/leash/(control)/connect-agent.test.tsx new file mode 100644 index 0000000..62d063f --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/connect-agent.test.tsx @@ -0,0 +1,156 @@ +/** @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { LeashKeyView } from "../../../../lib/leash/leash-key-view"; +import { ConnectAgent } from "./connect-agent"; + +const agentId = "11111111-1111-4111-8111-111111111111"; +const key: LeashKeyView = { + agentId, + createdAt: "2026-07-17T10:00:00.000Z", + id: "22222222-2222-4222-8222-222222222222", + last4: "a1B2", + lastUsedAt: "2026-07-17T11:00:00.000Z", + prefix: "leash_sk_", + revokedAt: null, + rotatedFromId: null, +}; + +describe("Leash agent connection setup", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("starts from a real no-key state and labels package publication honestly", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Generate Leash key"); + expect(html).toContain("Package publish pending"); + expect(html).toContain("LEASH_API_BASE_URL"); + expect(html).toContain("Standalone paid_fetch"); + expect(html).toContain("Proxy an existing MCP server"); + expect(html).toContain("No key exists yet"); + expect(html).not.toContain("leash_sk_••••••••"); + }); + + it("derives the mask and connection state from stored rows", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("leash_sk_••••••••a1B2"); + expect(html).toContain("Claude Code"); + expect(html).toContain("2 recorded connections"); + expect(html).toContain("Rotate key"); + expect(html).not.toContain("a fake key"); + }); + + it("blocks the config honestly when the deployed origin is unavailable", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Configuration blocked"); + expect(html).toContain("NEXT_PUBLIC_APP_URL is not configured."); + expect(html).not.toContain("leash-mcp"); + }); + + it("advances key progress immediately after the real create response and stays current after save", async () => { + const createdKey = { ...key, last4: "z9Y8" }; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ key: createdKey, secret: `leash_sk_${"z".repeat(43)}` }), { + status: 201, + }), + ), + ); + await act(async () => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Not connected"); + const generate = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Generate Leash key", + ); + if (!generate) throw new Error("Generate button not found"); + await act(async () => { + generate.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(container.textContent).toContain("Awaiting initialize"); + + const saved = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "I’ve saved my key", + ); + if (!saved) throw new Error("Save confirmation not found"); + await act(async () => saved.click()); + + expect(container.textContent).toContain("Awaiting initialize"); + expect(container.textContent).toContain("leash_sk_••••••••z9Y8"); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/connect-agent.tsx b/apps/web/app/(leash)/leash/(control)/connect-agent.tsx new file mode 100644 index 0000000..17b5675 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/connect-agent.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; + +import type { LeashKeyView } from "../../../../lib/leash/leash-key-view"; +import styles from "./connect-agent.module.css"; +import { ConnectConfigPanel } from "./connect-config-panel"; +import { KeyLifecycle } from "./key-lifecycle"; + +type AgentView = { + clientName: string | null; + connectionCount: number; + id: string; + lastSeenAt: string | null; + transport: string | null; +}; + +export function ConnectAgent({ + agent, + apiBaseUrl, + configurationIssue, + initialKey, +}: { + agent: AgentView; + apiBaseUrl: string | null; + configurationIssue?: string | null; + initialKey: LeashKeyView | null; +}) { + const connected = agent.connectionCount > 0; + const [currentKey, setCurrentKey] = useState(initialKey); + + return ( +
+
+
+

CONNECT YOUR AGENT

+

One credential for x402 payments

+ + Leash has no model. It only pays requests your own agent makes, within your cap. + +
+ + {connected ? "Connected" : currentKey ? "Awaiting initialize" : "Not connected"} + +
+ +
+
+ 1 + Key created +
+
+ 2 + Agent initialized +
+
+ 3 + Listening for x402 +
+
+ + + + + +
+ REAL CONNECTION STATE + {agent.clientName ?? "No initialize call recorded"} +

+ {agent.transport ?? + "Client and transport appear after the first authenticated MCP initialize call."} +

+ + {agent.connectionCount} recorded connection{agent.connectionCount === 1 ? "" : "s"} + +
+
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/connect-config-panel.module.css b/apps/web/app/(leash)/leash/(control)/connect-config-panel.module.css new file mode 100644 index 0000000..16bc4a5 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/connect-config-panel.module.css @@ -0,0 +1,97 @@ +.card { + padding: 18px 20px; + border: 1px solid #e8e6df; + border-radius: 12px; + background: #fff; +} + +.heading { + display: flex; + align-items: center; + gap: 8px; +} + +.heading > span { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border-radius: 50%; + background: #e7f3ef; + color: #17503a; + font-size: 10.5px; + font-weight: 700; +} + +.heading h2, +.modes h3 { + margin: 0; +} + +.heading h2 { + font-size: 13px; +} + +.packageNote, +.configBlocked { + display: grid; + gap: 3px; + margin-top: 12px; + padding: 9px 11px; + border-radius: 8px; + font-size: 11px; +} + +.packageNote { + border: 1px solid #f2e3c2; + background: #fbf1dc; + color: #7a5410; +} + +.configBlocked { + border: 1px solid #f0d5d6; + background: #faecec; + color: #8e2f34; +} + +.packageNote span, +.configBlocked span, +.modes p { + color: #5f5d55; + line-height: 1.5; +} + +.modes { + display: grid; + gap: 14px; + margin-top: 14px; +} + +.modes article { + min-width: 0; +} + +.modes h3 { + font-size: 12px; +} + +.modes p { + margin: 4px 0 0; + font-size: 11.5px; +} + +.modes p code { + font-size: 10.5px; +} + +.modes pre { + margin: 9px 0 0; + padding: 13px; + overflow-x: auto; + border-radius: 9px; + background: #26251f; + color: #e4e2d8; + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: 11px; + line-height: 1.6; +} diff --git a/apps/web/app/(leash)/leash/(control)/connect-config-panel.tsx b/apps/web/app/(leash)/leash/(control)/connect-config-panel.tsx new file mode 100644 index 0000000..e58e10b --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/connect-config-panel.tsx @@ -0,0 +1,62 @@ +import { buildLeashMcpConfiguration, LEASH_UPSTREAM_PLACEHOLDER } from "./connect-config"; +import styles from "./connect-config-panel.module.css"; + +function snippet(apiBaseUrl: string, upstreamUrl?: string) { + return `// .mcp.json\n${JSON.stringify( + buildLeashMcpConfiguration(apiBaseUrl, upstreamUrl), + null, + 2, + )}`; +} + +export function ConnectConfigPanel({ + apiBaseUrl, + configurationIssue, +}: { + apiBaseUrl: string | null; + configurationIssue?: string | null | undefined; +}) { + return ( +
+
+ 2 +

Point your agent at Leash

+
+
+ Package publish pending + + The CLI is real in this workspace, but the public npm package is not released yet. + +
+ {!apiBaseUrl ? ( +
+ Configuration blocked + {configurationIssue ?? "The deployed Tab origin is unavailable."} +
+ ) : ( +
+
+

Standalone paid_fetch

+

+ With no arguments, Leash exposes one paid_fetch tool for direct HTTP + requests. +

+
+              {snippet(apiBaseUrl)}
+            
+
+
+

Proxy an existing MCP server

+

+ Pass --upstream with your existing absolute Streamable HTTP MCP URL. + Replace the explicit placeholder below before use. +

+
+              {snippet(apiBaseUrl, LEASH_UPSTREAM_PLACEHOLDER)}
+            
+
+
+ )} +
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/connect-config.test.ts b/apps/web/app/(leash)/leash/(control)/connect-config.test.ts new file mode 100644 index 0000000..d916669 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/connect-config.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { parseLeashCliConfig } from "../../../../../agent/src/cli-config.js"; +import { + buildLeashMcpConfiguration, + LEASH_KEY_PLACEHOLDER, + resolveLeashApiOrigin, +} from "./connect-config"; + +const realKey = `leash_sk_${"a".repeat(43)}`; + +function parseRenderedConfiguration(configuration: ReturnType) { + const rendered = configuration.mcpServers.leash; + return parseLeashCliConfig(rendered.args ?? [], { + ...rendered.env, + LEASH_API_KEY: + rendered.env.LEASH_API_KEY === LEASH_KEY_PLACEHOLDER ? realKey : rendered.env.LEASH_API_KEY, + }); +} + +describe("Connect MCP configuration", () => { + it("renders both required environment keys for the real standalone CLI mode", () => { + const configuration = buildLeashMcpConfiguration("https://tab.example.test"); + + expect(configuration.mcpServers.leash).toEqual({ + command: "leash-mcp", + env: { + LEASH_API_BASE_URL: "https://tab.example.test", + LEASH_API_KEY: LEASH_KEY_PLACEHOLDER, + }, + }); + expect(parseRenderedConfiguration(configuration)).toEqual({ + apiBaseUrl: "https://tab.example.test/", + apiKey: realKey, + upstreamUrl: null, + }); + }); + + it("renders the real proxy arguments accepted by the CLI parser", () => { + const configuration = buildLeashMcpConfiguration( + "https://tab.example.test", + "https://existing-mcp.example.test/rpc", + ); + + expect(configuration.mcpServers.leash.args).toEqual([ + "--upstream", + "https://existing-mcp.example.test/rpc", + ]); + expect(parseRenderedConfiguration(configuration)).toMatchObject({ + upstreamUrl: "https://existing-mcp.example.test/rpc", + }); + }); + + it("canonicalizes the configured deployment origin and blocks absent or unsafe values", () => { + expect(resolveLeashApiOrigin("https://tab.example.test/")).toEqual({ + apiBaseUrl: "https://tab.example.test", + issue: null, + }); + expect(resolveLeashApiOrigin(undefined)).toMatchObject({ apiBaseUrl: null }); + expect(resolveLeashApiOrigin("https://tab.example.test/path")).toMatchObject({ + apiBaseUrl: null, + }); + expect(resolveLeashApiOrigin("https://user:secret@tab.example.test")).toMatchObject({ + apiBaseUrl: null, + }); + expect(resolveLeashApiOrigin("http://tab.example.test")).toMatchObject({ + apiBaseUrl: null, + }); + expect(resolveLeashApiOrigin("http://localhost:3000")).toEqual({ + apiBaseUrl: "http://localhost:3000", + issue: null, + }); + expect(resolveLeashApiOrigin("http://127.0.0.1:3000")).toEqual({ + apiBaseUrl: "http://127.0.0.1:3000", + issue: null, + }); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/connect-config.ts b/apps/web/app/(leash)/leash/(control)/connect-config.ts new file mode 100644 index 0000000..3c02084 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/connect-config.ts @@ -0,0 +1,75 @@ +export const LEASH_KEY_PLACEHOLDER = ""; +export const LEASH_UPSTREAM_PLACEHOLDER = ""; + +type LeashMcpConfiguration = { + mcpServers: { + leash: { + args?: ["--upstream", string]; + command: "leash-mcp"; + env: { + LEASH_API_BASE_URL: string; + LEASH_API_KEY: string; + }; + }; + }; +}; + +export function buildLeashMcpConfiguration( + apiBaseUrl: string, + upstreamUrl?: string, +): LeashMcpConfiguration { + return { + mcpServers: { + leash: { + ...(upstreamUrl ? { args: ["--upstream", upstreamUrl] as ["--upstream", string] } : {}), + command: "leash-mcp", + env: { + LEASH_API_BASE_URL: apiBaseUrl, + LEASH_API_KEY: LEASH_KEY_PLACEHOLDER, + }, + }, + }, + }; +} + +export function resolveLeashApiOrigin(value: string | undefined): { + apiBaseUrl: string | null; + issue: string | null; +} { + if (!value) { + return { + apiBaseUrl: null, + issue: + "NEXT_PUBLIC_APP_URL is not configured. Set it to this deployed app's HTTPS origin before connecting an agent.", + }; + } + let url: URL; + try { + url = new URL(value); + } catch { + return invalidOrigin(); + } + const loopbackHttp = + url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"); + if ( + value.trim() !== value || + (url.protocol !== "https:" && !loopbackHttp) || + url.username.length > 0 || + url.password.length > 0 || + url.pathname !== "/" || + url.search.length > 0 || + url.hash.length > 0 + ) { + return invalidOrigin(); + } + return { apiBaseUrl: url.origin, issue: null }; +} + +function invalidOrigin() { + return { + apiBaseUrl: null, + issue: + "NEXT_PUBLIC_APP_URL must be an absolute HTTPS origin without credentials, a path, query, or fragment. HTTP is allowed only for explicit loopback development.", + }; +} diff --git a/apps/web/app/(leash)/leash/(control)/connect/page.tsx b/apps/web/app/(leash)/leash/(control)/connect/page.tsx new file mode 100644 index 0000000..e278164 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/connect/page.tsx @@ -0,0 +1,58 @@ +import { notFound } from "next/navigation"; + +import { requireCurrentOwner } from "../../../../../lib/auth/current-owner"; +import { readOwnerLeashKey } from "../../../../../lib/auth/leash-key"; +import { getServerDatabase } from "../../../../../lib/db/server"; +import { leashKeyView } from "../../../../../lib/leash/leash-key-view"; +import { + LeashAgentSelectionError, + readOwnerAgentSelection, +} from "../../../../../lib/leash/owner-agents"; +import { ConnectAgent } from "../connect-agent"; +import { resolveLeashApiOrigin } from "../connect-config"; +import { AgentPicker, NoAgentState } from "../control-page"; + +type ConnectPageProps = { searchParams: Promise<{ agentId?: string | string[] }> }; + +export default async function ConnectPage({ searchParams }: ConnectPageProps) { + const [owner, query] = await Promise.all([requireCurrentOwner(), searchParams]); + const requestedAgentId = typeof query.agentId === "string" ? query.agentId : undefined; + const database = getServerDatabase().db; + + let selection: Awaited>; + try { + selection = await readOwnerAgentSelection(database, { + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + ownerId: owner.userId, + }); + } catch (error) { + if (error instanceof LeashAgentSelectionError) notFound(); + throw error; + } + if (!selection.selected) return ; + + const key = await readOwnerLeashKey(database, { + agentId: selection.selected.id, + ownerId: owner.userId, + }); + const configuration = resolveLeashApiOrigin(process.env.NEXT_PUBLIC_APP_URL); + + return ( + <> + + + + ); +} diff --git a/apps/web/app/(leash)/leash/(control)/control-page.module.css b/apps/web/app/(leash)/leash/(control)/control-page.module.css new file mode 100644 index 0000000..a382507 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/control-page.module.css @@ -0,0 +1,111 @@ +.emptyPage { + display: grid; + min-height: 100dvh; + place-items: center; + padding: 28px; +} + +.emptyCard { + width: min(100%, 620px); + padding: 34px; + border: 1px solid #e8e6df; + border-radius: 14px; + background: #fff; + box-shadow: 0 1px 3px rgb(28 27 24 / 6%); + text-align: center; +} + +.blockedLabel { + display: inline-flex; + padding: 4px 8px; + border-radius: 6px; + background: #fbf1dc; + color: #7a5410; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.05em; +} + +.emptyCard h1 { + margin: 18px 0 0; + font-size: 22px; + letter-spacing: -0.02em; +} + +.emptyCard p { + max-width: 510px; + margin: 8px auto 0; + color: #5f5d55; + font-size: 13px; + line-height: 1.6; +} + +.emptyCard button { + height: 36px; + margin-top: 18px; + padding: 0 15px; + border: 1px solid #dbd9d0; + border-radius: 9px; + background: #f4f3ee; + color: #8c8a80; + font-size: 12px; + font-weight: 600; +} + +.agentPicker { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 24px 32px 0; +} + +.agentPicker label { + display: grid; + gap: 5px; + color: #8c8a80; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.agentPicker select, +.agentPicker button { + height: 34px; + border: 1px solid #dbd9d0; + border-radius: 8px; + background: #fff; + color: #1c1b18; + font-size: 12px; +} + +.agentPicker select { + min-width: 220px; + padding: 0 10px; +} + +.agentPicker button { + padding: 0 12px; + font-weight: 600; +} + +.agentPicker select:focus-visible, +.agentPicker button:focus-visible { + outline: 2px solid #206b4e; + outline-offset: 2px; +} + +@media (max-width: 620px) { + .emptyPage { + min-height: 70dvh; + padding: 18px; + } + + .emptyCard { + padding: 28px 20px; + } + + .agentPicker { + padding: 18px 18px 0; + } +} diff --git a/apps/web/app/(leash)/leash/(control)/control-page.tsx b/apps/web/app/(leash)/leash/(control)/control-page.tsx new file mode 100644 index 0000000..8b9eae3 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/control-page.tsx @@ -0,0 +1,39 @@ +import type { OwnerAgent } from "../../../../lib/leash/owner-agents"; +import styles from "./control-page.module.css"; + +export function AgentPicker({ agents, selectedId }: { agents: OwnerAgent[]; selectedId: string }) { + if (agents.length < 2) return null; + return ( +
+ + +
+ ); +} + +export function NoAgentState() { + return ( +
+
+ BLOCKED · SIGNER PROVISIONING +

No Leash agent has been created

+

+ Agent provisioning stays disabled until the funded Magic OIDC signing spike is verified. + No wallet, balance, cap, or connection has been fabricated for this account. +

+ +
+
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/key-lifecycle.module.css b/apps/web/app/(leash)/leash/(control)/key-lifecycle.module.css new file mode 100644 index 0000000..139613f --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/key-lifecycle.module.css @@ -0,0 +1,184 @@ +.card { + padding: 18px 20px; + border: 1px solid #e8e6df; + border-radius: 12px; + background: #fff; +} + +.heading, +.heading div, +.reveal > div, +.confirm > div { + display: flex; + align-items: center; +} + +.heading { + justify-content: space-between; +} + +.heading div { + gap: 8px; +} + +.heading div > span { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border-radius: 50%; + background: #e7f3ef; + color: #17503a; + font-size: 10.5px; + font-weight: 700; +} + +.heading h2 { + margin: 0; + font-size: 13px; +} + +.heading button, +.empty button, +.reveal button, +.confirm button { + border: 1px solid #dbd9d0; + border-radius: 8px; + background: #fff; + color: #1c1b18; + font-size: 11.5px; + font-weight: 600; +} + +.heading button { + height: 30px; + padding: 0 11px; +} + +.empty p, +.maskedHelp, +.confirm p { + color: #5f5d55; + font-size: 12px; + line-height: 1.5; +} + +.empty p { + margin: 10px 0 0; +} + +.empty button { + height: 36px; + margin-top: 12px; + padding: 0 14px; + border-color: #206b4e; + background: #206b4e; + color: #fff; +} + +.masked { + display: grid; + gap: 5px; + margin-top: 11px; +} + +.masked code, +.reveal code { + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; +} + +.masked code { + color: #5f5d55; + font-size: 12.5px; +} + +.reveal { + margin-top: 12px; + padding: 12px; + border: 1px solid #f2e3c2; + border-radius: 9px; + background: #fbf1dc; +} + +.reveal p { + margin: 0; + color: #7a5410; + font-size: 12px; +} + +.reveal > div { + gap: 8px; + margin-top: 10px; + padding: 11px; + border-radius: 8px; + background: #26251f; +} + +.reveal code { + min-width: 0; + flex: 1; + overflow-wrap: anywhere; + color: #e4e2d8; + font-size: 11.5px; +} + +.reveal button, +.confirm button { + padding: 6px 9px; +} + +.reveal .savedButton { + margin-top: 10px; + border-color: #1c1b18; + background: #1c1b18; + color: #fff; +} + +.confirm { + margin-top: 12px; + padding: 12px; + border: 1px solid #f0d5d6; + border-radius: 9px; + background: #faecec; +} + +.confirm h3, +.confirm p { + margin: 0; +} + +.confirm h3 { + color: #8e2f34; + font-size: 13px; +} + +.confirm p { + margin-top: 4px; +} + +.confirm > div { + justify-content: flex-end; + gap: 7px; + margin-top: 10px; +} + +.confirm button:last-child { + border-color: #b4383d; + background: #b4383d; + color: #fff; +} + +.error { + margin: 10px 0 0; + color: #b4383d; + font-size: 12px; +} + +.card button:focus-visible { + outline: 2px solid #206b4e; + outline-offset: 2px; +} + +.card button:disabled { + opacity: 0.55; +} diff --git a/apps/web/app/(leash)/leash/(control)/key-lifecycle.test.tsx b/apps/web/app/(leash)/leash/(control)/key-lifecycle.test.tsx new file mode 100644 index 0000000..6666f2e --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/key-lifecycle.test.tsx @@ -0,0 +1,88 @@ +/** @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { LeashKeyView } from "../../../../lib/leash/leash-key-view"; +import { KeyLifecycle } from "./key-lifecycle"; + +const agentId = "11111111-1111-4111-8111-111111111111"; +const oldKey: LeashKeyView = { + agentId, + createdAt: "2026-07-17T10:00:00.000Z", + id: "22222222-2222-4222-8222-222222222222", + last4: "a1B2", + lastUsedAt: null, + prefix: "leash_sk_", + revokedAt: null, + rotatedFromId: null, +}; + +function button(container: ParentNode, label: string) { + const found = [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent === label, + ); + if (!found) throw new Error(`Button not found: ${label}`); + return found; +} + +describe("Leash key rotation", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("rotates the selected stored key and reveals the replacement only until it is saved", async () => { + const replacement = { + ...oldKey, + id: "33333333-3333-4333-8333-333333333333", + last4: "z9Y8", + rotatedFromId: oldKey.id, + }; + const oneTimeSecret = `leash_sk_${"z".repeat(43)}`; + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ key: replacement, secret: oneTimeSecret }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + await act(async () => root.render()); + + await act(async () => button(container, "Rotate key").click()); + const dialog = container.querySelector('[role="alertdialog"]'); + if (!dialog) throw new Error("Rotation confirmation not found"); + await act(async () => { + button(dialog, "Rotate key").click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/leash/keys", + expect.objectContaining({ + body: JSON.stringify({ agentId, keyId: oldKey.id }), + method: "PATCH", + }), + ); + expect(container.textContent).toContain(oneTimeSecret); + expect(container.textContent).not.toContain("leash_sk_••••••••z9Y8"); + + await act(async () => button(container, "I’ve saved my key").click()); + expect(container.textContent).not.toContain(oneTimeSecret); + expect(container.textContent).toContain("leash_sk_••••••••z9Y8"); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/key-lifecycle.tsx b/apps/web/app/(leash)/leash/(control)/key-lifecycle.tsx new file mode 100644 index 0000000..6241aa8 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/key-lifecycle.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; + +import type { LeashKeyView } from "../../../../lib/leash/leash-key-view"; +import styles from "./key-lifecycle.module.css"; + +type KeyResponse = { error?: { message?: string }; key?: LeashKeyView; secret?: string }; + +function maskedKey(key: LeashKeyView) { + return `${key.prefix}••••••••${key.last4}`; +} + +export function KeyLifecycle({ + agentId, + initialKey, + onKeyChange, +}: { + agentId: string; + initialKey: LeashKeyView | null; + onKeyChange?: (key: LeashKeyView) => void; +}) { + const [busy, setBusy] = useState(false); + const [confirmingRotation, setConfirmingRotation] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(); + const [key, setKey] = useState(initialKey); + const [secret, setSecret] = useState(); + + async function lifecycle(method: "PATCH" | "POST") { + if (busy) return; + setBusy(true); + setError(undefined); + setCopied(false); + try { + const response = await fetch("/api/leash/keys", { + body: JSON.stringify(method === "PATCH" ? { agentId, keyId: key?.id } : { agentId }), + headers: { "content-type": "application/json" }, + method, + }); + const body = (await response.json()) as KeyResponse; + if (!response.ok || !body.key || !body.secret) { + throw new Error(body.error?.message ?? "The Leash key was not created."); + } + setKey(body.key); + onKeyChange?.(body.key); + setSecret(body.secret); + setConfirmingRotation(false); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "The Leash key was not created."); + } finally { + setBusy(false); + } + } + + async function copySecret() { + if (!secret) return; + try { + await navigator.clipboard.writeText(secret); + setCopied(true); + } catch { + setError("Copy was blocked. Select and copy the key manually."); + } + } + + return ( +
+
+
+ 1 +

Your Leash key

+
+ {key && !secret ? ( + + ) : null} +
+ + {!key ? ( +
+

+ No key exists yet. The generated secret is shown once and only its SHA-256 hash is + stored. +

+ +
+ ) : null} + + {key && !secret ? ( +
+ {maskedKey(key)} + + Stored as a hash. The full key cannot be revealed again. + +
+ ) : null} + + {secret ? ( +
+

+ Save this key now. It cannot be recovered after you close this view. +

+
+ {secret} + +
+ +
+ ) : null} + + {confirmingRotation ? ( +
+

Rotate this key?

+

The current key stops working immediately. Update your agent with the replacement.

+
+ + +
+
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/layout.tsx b/apps/web/app/(leash)/leash/(control)/layout.tsx new file mode 100644 index 0000000..5d1a47f --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/layout.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +import { requireCurrentOwner } from "../../../../lib/auth/current-owner"; +import { getServerDatabase } from "../../../../lib/db/server"; +import { countOwnerUnreadNotifications } from "../../../../lib/leash/notification-store"; +import { LeashShell } from "./leash-shell"; + +export default async function LeashControlLayout({ children }: { children: ReactNode }) { + const owner = await requireCurrentOwner(); + const unreadCount = await countOwnerUnreadNotifications(getServerDatabase().db, { + ownerId: owner.userId, + }); + + return ( + + {children} + + ); +} diff --git a/apps/web/app/(leash)/leash/(control)/leash-account-menu.tsx b/apps/web/app/(leash)/leash/(control)/leash-account-menu.tsx new file mode 100644 index 0000000..116a681 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/leash-account-menu.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useState } from "react"; + +import styles from "./leash-shell.module.css"; + +export function LeashAccountMenu({ email }: { email: string }) { + const [error, setError] = useState(); + const [signingOut, setSigningOut] = useState(false); + + async function signOut() { + if (signingOut) return; + setSigningOut(true); + setError(undefined); + + try { + const response = await fetch("/api/auth/logout", { method: "POST" }); + if (!response.ok) throw new Error("Server logout failed"); + window.location.assign("/leash/login"); + } catch { + setError("Couldn’t sign out. Try again."); + setSigningOut(false); + } + } + + return ( +
+ + + + Leash owner + {email} + + + +
+ + {error ?

{error}

: null} +
+
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/leash-overview.module.css b/apps/web/app/(leash)/leash/(control)/leash-overview.module.css new file mode 100644 index 0000000..bd6f3fc --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/leash-overview.module.css @@ -0,0 +1,250 @@ +.page { + display: grid; + gap: 14px; + padding: 26px 32px 48px; +} + +.header, +.titleLine, +.headerActions, +.cardHeading, +.spendMeta { + display: flex; + align-items: center; +} + +.header, +.cardHeading, +.spendMeta { + justify-content: space-between; +} + +.header { + gap: 20px; +} + +.eyebrow, +.cardLabel, +.cardHeading > span { + margin: 0; + color: #8c8a80; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.07em; +} + +.titleLine { + gap: 12px; + margin-top: 5px; +} + +.titleLine h1 { + margin: 0; + font-size: 21px; + letter-spacing: -0.02em; +} + +.statusChip { + padding: 4px 10px; + border: 1px solid #bee0d6; + border-radius: 999px; + background: #e7f3ef; + color: #17503a; + font-size: 11px; + font-weight: 600; +} + +.headerActions { + gap: 8px; +} + +.headerActions a, +.warning a { + padding: 9px 13px; + border-radius: 8px; + background: #206b4e; + color: #fff; + font-size: 12px; + font-weight: 600; + text-decoration: none; +} + +.headerActions .secondaryAction { + border: 1px solid #dbd9d0; + background: #fff; + color: #1c1b18; +} + +.warning { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 13px 16px; + border: 1px solid #f2e3c2; + border-radius: 11px; + background: #fbf1dc; + color: #7a5410; + font-size: 12.5px; +} + +.warning p { + margin: 3px 0 0; +} + +.spendCard, +.cards article { + border: 1px solid #e8e6df; + border-radius: 12px; + background: #fff; +} + +.spendCard { + padding: 19px 21px; +} + +.cardHeading a { + color: #206b4e; + font-size: 11.5px; + font-weight: 600; + text-decoration: none; +} + +.spendAmount { + display: flex; + align-items: baseline; + gap: 8px; + margin: 10px 0 0; + font-variant-numeric: tabular-nums; +} + +.spendAmount strong { + font-size: 32px; + letter-spacing: -0.03em; +} + +.spendAmount span { + color: #5f5d55; + font-size: 14px; +} + +.spendAmount b { + color: #206b4e; + font-size: 13px; +} + +.spendTrack { + position: relative; + display: flex; + height: 12px; + margin-top: 13px; + overflow: hidden; + border-radius: 999px; + background: #f1efe9; +} + +.settledFill { + background: #206b4e; +} + +.pendingFill { + background: repeating-linear-gradient(135deg, #73a38f 0 4px, #a8c7ba 4px 8px); +} + +.overageFill { + position: absolute; + inset: 0 0 0 auto; + background: repeating-linear-gradient(135deg, #b4383d 0 3px, transparent 3px 6px); + box-shadow: -1px 0 0 #fff; + pointer-events: none; +} + +.spendMeta { + flex-wrap: wrap; + gap: 5px 16px; + margin-top: 9px; + color: #8c8a80; + font-size: 11.5px; +} + +.cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.cards article { + min-height: 142px; + padding: 16px 18px; +} + +.cards article > strong { + display: block; + margin-top: 8px; + font-size: 15px; +} + +.cards article > p { + margin: 4px 0 0; + color: #5f5d55; + font-size: 11.5px; + line-height: 1.45; +} + +.cards article > small { + display: block; + margin-top: 9px; + color: #8c8a80; + font-size: 10.5px; +} + +.mono, +.floatList { + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; +} + +.floatList { + display: grid; + gap: 4px; + margin: 9px 0 0; + padding: 0; + color: #5f5d55; + font-size: 10.5px; + list-style: none; +} + +.floatList li { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.headerActions a:focus-visible, +.warning a:focus-visible, +.cardHeading a:focus-visible { + outline: 2px solid #206b4e; + outline-offset: 2px; +} + +@media (max-width: 920px) { + .cards { + grid-template-columns: 1fr; + } +} + +@media (max-width: 620px) { + .page { + padding: 22px 18px 36px; + } + + .header, + .warning, + .spendMeta { + align-items: flex-start; + flex-direction: column; + } + + .headerActions { + flex-wrap: wrap; + } +} diff --git a/apps/web/app/(leash)/leash/(control)/leash-overview.test.tsx b/apps/web/app/(leash)/leash/(control)/leash-overview.test.tsx new file mode 100644 index 0000000..907ec08 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/leash-overview.test.tsx @@ -0,0 +1,75 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import type { CapPolicyView } from "../../../../lib/leash/cap-view"; +import type { OwnerAgent } from "../../../../lib/leash/owner-agents"; +import { LeashOverview } from "./leash-overview"; + +const agent = { + agentAddress: null, + clientName: null, + clientVersion: null, + connectionCount: 0, + createdAt: new Date("2026-07-17T00:00:00.000Z"), + firstSeenAt: null, + id: "11111111-1111-4111-8111-111111111111", + lastSeenAt: null, + name: "Ledger agent", + status: "provisioned", + transport: null, +} satisfies OwnerAgent; + +function overagePolicy(): CapPolicyView { + return { + agentId: agent.id, + cap: { + amountUsdCents: "1000", + frequency: "daily", + updatedAt: "2026-07-17T10:00:00.000Z", + }, + cycle: { + id: "22222222-2222-4222-8222-222222222222", + nextResetAt: "2026-07-18T00:00:00.000Z", + startedAt: "2026-07-17T00:00:00.000Z", + }, + halted: true, + spend: { + approaching: true, + atOrAboveLimit: true, + blockedReceiptCount: 3, + capAtomic: "10000000", + capFillBasisPoints: "7143", + committedAtomic: "14000000", + committedBasisPoints: "14000", + overageAtomic: "4000000", + overageFillBasisPoints: "2857", + pendingAtomic: "6000000", + pendingFillBasisPoints: "4286", + settledAtomic: "8000000", + settledFillBasisPoints: "5714", + }, + }; +} + +describe("Leash overview cap evidence", () => { + it("renders exact overage scaling, status composition, and current-cycle blocked count", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("140%"); + expect(html).toContain("incl. $6.00 pending"); + expect(html).toContain("3 cap-blocked attempts this cycle"); + expect(html).toContain("$8.00 settled; $6.00 pending; $10.00 cap; $4.00 over cap"); + expect(html).toContain('style="width:57.14%"'); + expect(html).toContain('style="width:42.86%"'); + expect(html).toContain('style="width:28.57%"'); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/leash-overview.tsx b/apps/web/app/(leash)/leash/(control)/leash-overview.tsx new file mode 100644 index 0000000..c5be9a4 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/leash-overview.tsx @@ -0,0 +1,178 @@ +import Link from "next/link"; + +import type { readOwnerLeashKey } from "../../../../lib/auth/leash-key"; +import { + blockedReceiptEvidence, + type CapPolicyView, + capUsageDescription, +} from "../../../../lib/leash/cap-view"; +import { + capFillWidth, + formatBasisPoints, + formatUsdAtomic, + formatUsdCents, +} from "../../../../lib/leash/leash-format"; +import type { OwnerAgent } from "../../../../lib/leash/owner-agents"; +import styles from "./leash-overview.module.css"; +import { type NotificationPreview, OverviewNotifications } from "./overview-notifications"; + +type KeySummary = Awaited>; +type FloatRead = { balanceAtomic: string | null; label: string; network: string }; +const statusCopy = { + cancelled: "Cancelled — provisioning required", + frozen: "Frozen", + nuked: "Nuked — provisioning required", + paused: "Paused", + provisioned: "Active", +} as const; + +function maskedKey(key: NonNullable) { + return `${key.prefix}••••••••${key.last4}`; +} + +function floatTotal(reads: FloatRead[]) { + if (reads.some((read) => read.balanceAtomic === null)) return null; + return reads.reduce((total, read) => total + BigInt(read.balanceAtomic ?? "0"), BigInt(0)); +} + +export function LeashOverview({ + agent, + floats, + keySummary, + notifications, + policy, + unreadCount, +}: { + agent: OwnerAgent; + floats: FloatRead[] | null; + keySummary: KeySummary; + notifications: NotificationPreview[]; + policy: CapPolicyView | null; + unreadCount: number; +}) { + const totalFloat = floats ? floatTotal(floats) : null; + const query = `?agentId=${encodeURIComponent(agent.id)}`; + + return ( +
+
+
+

COMMAND BRIDGE

+
+

{agent.name}

+ {statusCopy[agent.status]} +
+
+
+ {policy ? "Adjust cap" : "Set cap"} + + Manage connection + +
+
+ + {!policy ? ( +
+
+ Set a cap to enable payments. +

The hosted signer currently refuses every payment for this agent.

+
+ Set cap +
+ ) : ( +
+
+ SPEND THIS CYCLE + Cap & limits → +
+

+ {formatUsdAtomic(policy.spend.committedAtomic)} + / {formatUsdCents(policy.cap.amountUsdCents)} + {formatBasisPoints(policy.spend.committedBasisPoints ?? "0")} +

+
+ + + {BigInt(policy.spend.overageAtomic) > BigInt(0) ? ( + + ) : null} +
+
+ + {BigInt(policy.spend.pendingAtomic) > BigInt(0) + ? `incl. ${formatUsdAtomic(policy.spend.pendingAtomic)} pending` + : "No pending payments"} + + {policy.halted ? "Payments halted at the cap" : "Cap gate active"} + {blockedReceiptEvidence(policy.spend.blockedReceiptCount)} +
+
+ )} + +
+
+ AGENT BALANCE + + {!agent.agentAddress + ? "Not provisioned" + : totalFloat === null + ? "Live read unavailable" + : formatUsdAtomic(totalFloat.toString())} + +

{agent.agentAddress ? "Native USDC floats" : "No signing address exists"}

+ {floats ? ( +
    + {floats.map((read) => ( +
  • + {read.label} + + {read.balanceAtomic === null + ? "Unavailable" + : formatUsdAtomic(read.balanceAtomic)} + +
  • + ))} +
+ ) : null} +
+
+ CONNECTED AGENT + {agent.clientName ?? "No client connected"} +

+ {agent.transport ?? "Connection metadata appears after the first real initialize call."} +

+ + {agent.connectionCount} recorded connection{agent.connectionCount === 1 ? "" : "s"} + +
+
+ LEASH KEY + {keySummary ? "Active" : "Not issued"} +

+ {keySummary ? maskedKey(keySummary) : "No key material exists"} +

+ + {keySummary + ? "Hash stored at rest · reveal unavailable" + : "Generate it once in Connect agent"} + +
+
+ + +
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/leash-shell.module.css b/apps/web/app/(leash)/leash/(control)/leash-shell.module.css new file mode 100644 index 0000000..83dc60e --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/leash-shell.module.css @@ -0,0 +1,279 @@ +.shell { + display: flex; + min-height: 100dvh; + background: #faf9f6; + color: #1c1b18; +} + +.sidebar { + position: sticky; + top: 0; + display: flex; + width: 224px; + height: 100dvh; + flex: none; + flex-direction: column; + padding: 16px 12px 14px; + border-right: 1px solid #e8e6df; + background: #f4f3ee; +} + +.brand { + display: flex; + align-items: center; + gap: 9px; + padding: 4px 6px; + color: #1c1b18; + font-size: 14.5px; + font-weight: 600; + letter-spacing: -0.01em; + text-decoration: none; +} + +.brandTile { + display: grid; + width: 26px; + height: 26px; + place-items: center; + border-radius: 7px; + background: #206b4e; + color: #fff; + font-size: 13px; + font-weight: 700; +} + +.navigation { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 22px; +} + +.navItem, +.activeNavItem { + display: flex; + min-height: 34px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 10px; + border-radius: 7px; + font-size: 13px; + text-decoration: none; +} + +.navItem { + color: #5f5d55; + font-weight: 500; +} + +.navItem:hover { + background: #edebe4; +} + +.activeNavItem { + background: #e9e7e0; + color: #1c1b18; + font-weight: 600; +} + +.badge { + display: inline-flex; + min-width: 18px; + height: 18px; + align-items: center; + justify-content: center; + padding: 0 5px; + border-radius: 999px; + background: #206b4e; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sidebarBottom { + margin-top: auto; +} + +.productNote { + margin: 0 6px 12px; + color: #8c8a80; + font-size: 10.5px; + line-height: 1.45; +} + +.accountMenu { + position: relative; + border-top: 1px solid #e8e6df; + padding-top: 11px; +} + +.accountSummary { + display: flex; + align-items: center; + gap: 8px; + padding: 6px; + border-radius: 8px; + cursor: pointer; + list-style: none; +} + +.accountSummary::-webkit-details-marker { + display: none; +} + +.accountSummary:hover { + background: #edebe4; +} + +.avatar { + display: grid; + width: 26px; + height: 26px; + flex: none; + place-items: center; + border: 1px solid #bee0d6; + border-radius: 50%; + background: #e7f3ef; + color: #17503a; + font-size: 10.5px; + font-weight: 700; +} + +.accountText { + display: grid; + min-width: 0; + flex: 1; +} + +.accountName { + font-size: 12px; + font-weight: 600; +} + +.accountEmail { + overflow: hidden; + color: #8c8a80; + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menuMark { + color: #8c8a80; +} + +.accountPopover { + position: absolute; + right: 4px; + bottom: calc(100% + 6px); + width: 184px; + padding: 6px; + border: 1px solid #dbd9d0; + border-radius: 9px; + background: #fff; + box-shadow: 0 10px 28px rgb(28 27 24 / 10%); +} + +.accountPopover button { + width: 100%; + padding: 7px 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: #1c1b18; + text-align: left; +} + +.accountPopover button:hover { + background: #f4f3ee; +} + +.accountPopover p { + margin: 5px 9px; + color: #b4383d; + font-size: 11px; +} + +.contentColumn { + min-width: 0; + flex: 1; +} + +.brand:focus-visible, +.navItem:focus-visible, +.activeNavItem:focus-visible, +.accountSummary:focus-visible, +.accountPopover button:focus-visible { + outline: 2px solid #206b4e; + outline-offset: 2px; +} + +@media (max-width: 760px) { + .shell { + display: block; + } + + .sidebar { + position: static; + width: 100%; + height: auto; + border-right: 0; + border-bottom: 1px solid #e8e6df; + } + + .navigation { + flex-direction: row; + overflow-x: auto; + } + + .navItem, + .activeNavItem { + flex: none; + } + + .sidebarBottom { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #e8e6df; + } + + .productNote { + display: none; + } + + .accountMenu { + width: min(100%, 280px); + min-width: 0; + margin-left: auto; + padding-top: 0; + border-top: 0; + } + + .accountSummary { + min-height: 38px; + border: 1px solid #e8e6df; + background: #faf9f6; + } + + .accountPopover { + z-index: 10; + top: calc(100% + 6px); + bottom: auto; + } +} diff --git a/apps/web/app/(leash)/leash/(control)/leash-shell.test.tsx b/apps/web/app/(leash)/leash/(control)/leash-shell.test.tsx new file mode 100644 index 0000000..6da2770 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/leash-shell.test.tsx @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs"; + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ usePathname: () => "/leash/cap" })); + +import { LeashShell } from "./leash-shell"; + +describe("Leash control-plane shell", () => { + it("renders only live Phase 7 destinations and an owner-scoped unread badge", () => { + const html = renderToStaticMarkup( + +
Owner control plane
+
, + ); + + expect(html).toContain('href="/leash"'); + expect(html).toContain('href="/leash/notifications"'); + expect(html).toContain('href="/leash/cap"'); + expect(html).toContain('href="/leash/connect"'); + expect(html).toContain("owner@example.test"); + expect(html).toContain("Owner control plane"); + expect(html).toContain('aria-current="page"'); + expect(html).toContain("3 unread notifications"); + expect(html).not.toContain('href="/leash/revocation"'); + expect(html).not.toContain('href="/leash/payments"'); + }); + + it("does not invent a zero badge when unread data is unavailable", () => { + const html = renderToStaticMarkup( + +
Owner control plane
+
, + ); + + expect(html).not.toContain("unread notifications"); + expect(html).not.toContain(">0<"); + }); + + it("keeps the owner sign-out control available in the mobile shell", () => { + const html = renderToStaticMarkup( + +
Owner control plane
+
, + ); + const css = readFileSync(new URL("./leash-shell.module.css", import.meta.url), "utf8"); + const mobileCss = css.slice(css.indexOf("@media (max-width: 760px)")); + + expect(html).toContain("Sign out"); + expect(html).toContain("owner@example.test"); + expect(mobileCss).not.toMatch(/\.sidebarBottom\s*\{[^}]*display:\s*none/); + expect(mobileCss).toMatch(/\.accountMenu\s*\{[^}]*min-width:/); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/leash-shell.tsx b/apps/web/app/(leash)/leash/(control)/leash-shell.tsx new file mode 100644 index 0000000..22a15e4 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/leash-shell.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +import { LeashAccountMenu } from "./leash-account-menu"; +import styles from "./leash-shell.module.css"; + +const navigation = [ + { href: "/leash", label: "Overview" }, + { href: "/leash/notifications", label: "Notifications" }, + { href: "/leash/cap", label: "Cap & limits" }, + { href: "/leash/connect", label: "Connect agent" }, +] as const; + +type LeashShellProps = { + children: ReactNode; + email: string; + unreadCount: number | null; +}; + +function isActive(pathname: string, href: string) { + return href === "/leash" ? pathname === href : pathname.startsWith(href); +} + +export function LeashShell({ children, email, unreadCount }: LeashShellProps) { + const pathname = usePathname(); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/notification-center-actions.test.ts b/apps/web/app/(leash)/leash/(control)/notification-center-actions.test.ts new file mode 100644 index 0000000..cba0493 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/notification-center-actions.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { NotificationResultView } from "../../../../lib/leash/notification-view"; +import { + loadAndCommitNotificationFilters, + markNotificationsRead, + type NotificationFilters, +} from "./notification-center-actions"; + +const agentId = "11111111-1111-4111-8111-111111111111"; +const notificationId = "33333333-3333-4333-8333-333333333333"; +const filters: NotificationFilters = { read: "all", resolution: "all", tier: "all" }; +const refreshedResult: NotificationResultView = { + nextCursor: null, + notifications: [], + unreadCount: 0, +}; + +function jsonResponse(body: unknown, ok = true) { + return { + json: vi.fn().mockResolvedValue(body), + ok, + }; +} + +describe("notification-center actions", () => { + it.each([ + ["one notification", notificationId, "read_one"], + ["all notifications", undefined, "read_all"], + ])("refreshes the server-backed shell count after marking %s read", async (_, id, action) => { + const events: string[] = []; + const request = vi.fn(async (_input: string, init?: RequestInit) => { + events.push(init?.method ?? "GET"); + return init?.method === "PATCH" ? jsonResponse({ ok: true }) : jsonResponse(refreshedResult); + }); + + const result = await markNotificationsRead({ + agentId, + filters, + notificationId: id, + onServerStateChanged: () => events.push("shell-refresh"), + request, + }); + + expect(result).toEqual(refreshedResult); + expect(events).toEqual(["PATCH", "shell-refresh", "GET"]); + expect(request).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toEqual( + id ? { action, agentId, notificationId: id } : { action, agentId }, + ); + }); + + it("does not commit selected filters when their notification request fails", async () => { + const commit = vi.fn(); + const next: NotificationFilters = { read: "unread", resolution: "active", tier: "3" }; + + await expect( + loadAndCommitNotificationFilters({ + commit, + filters: next, + load: vi.fn().mockRejectedValue(new Error("request failed")), + }), + ).rejects.toThrow("request failed"); + + expect(commit).not.toHaveBeenCalled(); + }); + + it("commits selected filters and matching rows together after a successful request", async () => { + const commit = vi.fn(); + const next: NotificationFilters = { read: "read", resolution: "resolved", tier: "2" }; + + await loadAndCommitNotificationFilters({ + commit, + filters: next, + load: vi.fn().mockResolvedValue(refreshedResult), + }); + + expect(commit).toHaveBeenCalledOnce(); + expect(commit).toHaveBeenCalledWith(next, refreshedResult); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/notification-center-actions.ts b/apps/web/app/(leash)/leash/(control)/notification-center-actions.ts new file mode 100644 index 0000000..5a17c84 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/notification-center-actions.ts @@ -0,0 +1,87 @@ +import type { NotificationResultView } from "../../../../lib/leash/notification-view"; + +export type NotificationFilters = { + read: "all" | "read" | "unread"; + resolution: "active" | "all" | "resolved"; + tier: "2" | "3" | "all"; +}; + +type NotificationRequest = ( + input: string, + init?: RequestInit, +) => Promise>; + +type NotificationResponse = NotificationResultView & { + error?: { message?: string }; +}; + +export async function loadNotificationResult({ + agentId, + cursor, + filters, + request = fetch, +}: { + agentId: string; + cursor?: string; + filters: NotificationFilters; + request?: NotificationRequest; +}) { + const query = new URLSearchParams({ + agentId, + read: filters.read, + resolution: filters.resolution, + }); + if (filters.tier !== "all") query.set("tier", filters.tier); + if (cursor) query.set("cursor", cursor); + + const response = await request(`/api/leash/notifications?${query}`); + const body = (await response.json()) as NotificationResponse; + if (!response.ok) { + throw new Error(body.error?.message ?? "Notifications could not be loaded."); + } + return body; +} + +export async function markNotificationsRead({ + agentId, + filters, + notificationId, + onServerStateChanged, + request = fetch, +}: { + agentId: string; + filters: NotificationFilters; + notificationId: string | undefined; + onServerStateChanged: () => void; + request?: NotificationRequest; +}) { + const response = await request("/api/leash/notifications", { + body: JSON.stringify( + notificationId + ? { action: "read_one", agentId, notificationId } + : { action: "read_all", agentId }, + ), + headers: { "content-type": "application/json" }, + method: "PATCH", + }); + if (!response.ok) { + const body = (await response.json()) as NotificationResponse; + throw new Error(body.error?.message ?? "The notification state was not saved."); + } + + onServerStateChanged(); + return loadNotificationResult({ agentId, filters, request }); +} + +export async function loadAndCommitNotificationFilters({ + commit, + filters, + load, +}: { + commit: (filters: NotificationFilters, result: NotificationResultView) => void; + filters: NotificationFilters; + load: (filters: NotificationFilters) => Promise; +}) { + const result = await load(filters); + commit(filters, result); +} diff --git a/apps/web/app/(leash)/leash/(control)/notification-center.module.css b/apps/web/app/(leash)/leash/(control)/notification-center.module.css new file mode 100644 index 0000000..90ed80c --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/notification-center.module.css @@ -0,0 +1,291 @@ +.page { + display: grid; + gap: 14px; + padding: 26px 32px 48px; +} + +.header, +.headerActions, +.filters, +.filters > div, +.itemTopline, +.itemActions { + display: flex; + align-items: center; +} + +.header { + justify-content: space-between; + gap: 20px; +} + +.header p { + margin: 0; + color: #8c8a80; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; +} + +.header h1 { + margin: 5px 0 0; + font-size: 22px; + letter-spacing: -0.02em; +} + +.headerSubtitle { + display: block; + margin-top: 5px; + color: #5f5d55; + font-size: 12.5px; +} + +.headerActions { + gap: 9px; +} + +.headerActions b { + padding: 5px 9px; + border-radius: 999px; + background: #e7f3ef; + color: #17503a; + font-size: 10.5px; +} + +.headerActions button, +.itemActions button, +.itemActions a, +.loadMore button { + border: 1px solid #dbd9d0; + border-radius: 8px; + background: #fff; + color: #1c1b18; + font-size: 11.5px; + font-weight: 600; +} + +.headerActions button { + height: 34px; + padding: 0 12px; +} + +.loadMore { + text-align: center; +} + +.loadMore button { + padding: 8px 12px; +} + +.filters { + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border: 1px solid #e8e6df; + border-radius: 11px; + background: #fff; +} + +.filters > div { + gap: 6px; +} + +.filters button { + height: 30px; + padding: 0 11px; + border: 1px solid #dbd9d0; + border-radius: 999px; + background: #fff; + color: #5f5d55; + font-size: 11.5px; +} + +.filters button[aria-pressed="true"] { + border-color: #1c1b18; + background: #1c1b18; + color: #fff; +} + +.filters label { + display: flex; + align-items: center; + gap: 7px; + color: #8c8a80; + font-size: 10.5px; + font-weight: 600; +} + +.filters select { + height: 30px; + padding: 0 8px; + border: 1px solid #dbd9d0; + border-radius: 7px; + background: #fff; + color: #1c1b18; + font-size: 11px; +} + +.list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.item, +.stickyItem, +.empty { + border: 1px solid #e8e6df; + border-radius: 11px; + background: #fff; +} + +.item, +.stickyItem { + padding: 15px 17px; +} + +.stickyItem { + border-color: #e8b6b9; + background: #fdf7f7; + box-shadow: inset 3px 0 #b4383d; +} + +.itemTopline { + gap: 7px; +} + +.itemTopline > span, +.itemTopline i { + padding: 3px 7px; + border-radius: 5px; + font-size: 9.5px; + font-style: normal; + font-weight: 700; + letter-spacing: 0.04em; +} + +.tier2 { + background: #fbf1dc; + color: #96650f; +} + +.tier3 { + background: #faecec; + color: #b4383d; +} + +.itemTopline i { + background: #e7f3ef; + color: #17503a; +} + +.itemTopline .resolved { + background: #efede7; + color: #5f5d55; +} + +.itemTopline time { + margin-left: auto; + color: #8c8a80; + font-size: 10.5px; +} + +.item h2 { + margin: 10px 0 0; + font-size: 14px; +} + +.stickyItem h2 { + margin: 10px 0 0; + font-size: 14px; +} + +.item > p, +.stickyItem > p { + margin: 4px 0 0; + color: #5f5d55; + font-size: 12px; + line-height: 1.5; +} + +.itemActions { + gap: 7px; + margin-top: 12px; +} + +.itemActions button, +.itemActions a { + padding: 6px 9px; + text-decoration: none; +} + +.itemActions a { + border-color: #206b4e; + background: #206b4e; + color: #fff; +} + +.empty { + padding: 48px 24px; + text-align: center; +} + +.empty h2 { + margin: 0; + font-size: 15px; +} + +.empty p { + margin: 6px 0 0; + color: #8c8a80; + font-size: 12px; +} + +.error { + margin: 0; + color: #b4383d; + font-size: 12px; +} + +.headerActions button:focus-visible, +.filters button:focus-visible, +.filters select:focus-visible, +.itemActions button:focus-visible, +.itemActions a:focus-visible, +.loadMore button:focus-visible { + outline: 2px solid #206b4e; + outline-offset: 2px; +} + +button:disabled, +.filters select:disabled { + opacity: 0.55; +} + +@media (max-width: 760px) { + .page { + padding: 22px 18px 36px; + } + + .header, + .filters { + align-items: flex-start; + flex-direction: column; + } + + .filters label { + width: 100%; + justify-content: space-between; + } + + .itemTopline { + flex-wrap: wrap; + } + + .itemTopline time { + width: 100%; + margin-left: 0; + } +} diff --git a/apps/web/app/(leash)/leash/(control)/notification-center.test.tsx b/apps/web/app/(leash)/leash/(control)/notification-center.test.tsx new file mode 100644 index 0000000..689ae13 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/notification-center.test.tsx @@ -0,0 +1,80 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) })); + +import type { NotificationResultView } from "../../../../lib/leash/notification-view"; +import { NotificationCenter } from "./notification-center"; + +const agentId = "11111111-1111-4111-8111-111111111111"; + +const result: NotificationResultView = { + nextCursor: "older-page", + notifications: [ + { + agentId, + createdAt: "2026-07-17T10:00:00.000Z", + cta: { + href: `/leash/cap?agentId=${agentId}#cap-controls`, + kind: "cap_remediation", + label: "Review cap", + }, + cycleId: "22222222-2222-4222-8222-222222222222", + id: "33333333-3333-4333-8333-333333333333", + metadata: { attemptedAtomic: "450000", capAtomic: "10000000" }, + readAt: null, + receiptId: "44444444-4444-4444-8444-444444444444", + resolvedAt: null, + resourceHost: null, + sticky: true, + tier: "3", + type: "cap_blocked", + }, + { + agentId, + createdAt: "2026-07-17T09:00:00.000Z", + cta: null, + cycleId: "22222222-2222-4222-8222-222222222222", + id: "55555555-5555-4555-8555-555555555555", + metadata: { resourceHost: "tool", resourceUrl: "mcp://tool/search" }, + readAt: "2026-07-17T09:30:00.000Z", + receiptId: "66666666-6666-4666-8666-666666666666", + resolvedAt: null, + resourceHost: "tool", + sticky: false, + tier: "2", + type: "unusual_domain", + }, + ], + unreadCount: 1, +}; + +describe("Leash notification center", () => { + it("renders real tiers, state, host, and remediation CTA", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Action required"); + expect(html).toContain("Alert"); + expect(html).toContain("A $0.45 payment was blocked before signing"); + expect(html).toContain("mcp://tool/search"); + expect(html).toContain("Review cap"); + expect(html).toContain("1 unread"); + expect(html).toContain("Mark all read"); + expect(html).toContain("Load older"); + expect(html).toContain("Active & resolved"); + }); + + it("uses an instructive empty state without seeded alerts", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("No notifications match this view"); + expect(html).toContain("Real cap, destination, and float events appear here"); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/notification-center.tsx b/apps/web/app/(leash)/leash/(control)/notification-center.tsx new file mode 100644 index 0000000..fc5c146 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/notification-center.tsx @@ -0,0 +1,252 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { formatUsdAtomic } from "../../../../lib/leash/leash-format"; +import type { + NotificationItemView, + NotificationResultView, +} from "../../../../lib/leash/notification-view"; +import styles from "./notification-center.module.css"; +import { + loadAndCommitNotificationFilters, + loadNotificationResult, + markNotificationsRead, + type NotificationFilters, +} from "./notification-center-actions"; + +type TierFilter = NotificationFilters["tier"]; +type ReadFilter = NotificationFilters["read"]; +type ResolutionFilter = NotificationFilters["resolution"]; + +const copy = { + cap_75: ["Cap is 75% used", "Current-cycle committed spend crossed the alert threshold."], + cap_blocked: ["Payment blocked at the cap", "The attempt was stopped before signing."], + cap_lowered_halt: [ + "Cap lowered below current spend", + "Payments remain halted until remediation.", + ], + float_empty: ["Agent float is empty", "The payment could not proceed on its requested network."], + float_low: ["Agent float is low", "The available native USDC float needs attention."], + unusual_domain: [ + "First payment to a new destination", + "Review this destination if you do not recognize it.", + ], +} as const; + +function stringMetadata(item: NotificationItemView, key: string) { + const value = item.metadata[key]; + return typeof value === "string" ? value : undefined; +} + +function detail(item: NotificationItemView) { + if (item.type === "cap_blocked") { + const attempted = stringMetadata(item, "attemptedAtomic"); + return attempted + ? `A ${formatUsdAtomic(attempted)} payment was blocked before signing.` + : copy[item.type][1]; + } + if (item.type === "unusual_domain") { + const resourceUrl = stringMetadata(item, "resourceUrl"); + const destination = resourceUrl?.startsWith("mcp:") ? resourceUrl : item.resourceHost; + return destination ? `First recorded payment attempt to ${destination}.` : copy[item.type][1]; + } + return copy[item.type][1]; +} + +export function NotificationCenter({ + agentId, + initialResult, +}: { + agentId: string; + initialResult: NotificationResultView; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const [read, setRead] = useState("all"); + const [resolution, setResolution] = useState("all"); + const [result, setResult] = useState(initialResult); + const [tier, setTier] = useState("all"); + + async function applyFilters(next: NotificationFilters) { + if (busy) return; + setBusy(true); + setError(undefined); + try { + await loadAndCommitNotificationFilters({ + commit: (filters, nextResult) => { + setRead(filters.read); + setResolution(filters.resolution); + setResult(nextResult); + setTier(filters.tier); + }, + filters: next, + load: (filters) => loadNotificationResult({ agentId, filters }), + }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Notifications could not be loaded."); + } finally { + setBusy(false); + } + } + + async function markRead(notificationId?: string) { + if (busy) return; + setBusy(true); + setError(undefined); + try { + const nextResult = await markNotificationsRead({ + agentId, + filters: { read, resolution, tier }, + notificationId, + onServerStateChanged: router.refresh, + }); + setResult(nextResult); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "The notification state was not saved."); + } finally { + setBusy(false); + } + } + + async function loadOlder() { + if (busy || !result.nextCursor) return; + setBusy(true); + setError(undefined); + try { + const older = await loadNotificationResult({ + agentId, + cursor: result.nextCursor, + filters: { read, resolution, tier }, + }); + setResult((current) => ({ + ...older, + notifications: [...current.notifications, ...older.notifications], + })); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Notifications could not be loaded."); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+

WATCHED EVENTS

+

Notifications

+ + Tier 2 alerts inform. Tier 3 events require a real owner action. + +
+
+ {result.unreadCount} unread + +
+
+ +
+
+ {(["all", "2", "3"] as const).map((value) => ( + + ))} +
+ + +
+ + {error ? ( +

+ {error} +

+ ) : null} + {result.notifications.length === 0 ? ( +
+

No notifications match this view

+

Real cap, destination, and float events appear here when they occur.

+
+ ) : ( +
    + {result.notifications.map((item) => ( +
  1. +
    + + {item.tier === "3" ? "Action required" : "Alert"} + + {!item.readAt ? Unread : null} + {item.resolvedAt ? Resolved : null} + +
    +

    {copy[item.type][0]}

    +

    {detail(item)}

    +
    + {item.cta ? {item.cta.label} : null} + {!item.readAt ? ( + + ) : null} +
    +
  2. + ))} +
+ )} + {result.nextCursor ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/notifications/page.tsx b/apps/web/app/(leash)/leash/(control)/notifications/page.tsx new file mode 100644 index 0000000..8f19e84 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/notifications/page.tsx @@ -0,0 +1,53 @@ +import { notFound } from "next/navigation"; + +import { requireCurrentOwner } from "../../../../../lib/auth/current-owner"; +import { getServerDatabase } from "../../../../../lib/db/server"; +import { listOwnerNotifications } from "../../../../../lib/leash/notification-store"; +import { notificationResultView } from "../../../../../lib/leash/notification-view"; +import { + LeashAgentSelectionError, + readOwnerAgentSelection, +} from "../../../../../lib/leash/owner-agents"; +import { AgentPicker, NoAgentState } from "../control-page"; +import { NotificationCenter } from "../notification-center"; + +type NotificationsPageProps = { searchParams: Promise<{ agentId?: string | string[] }> }; + +export default async function NotificationsPage({ searchParams }: NotificationsPageProps) { + const [owner, query] = await Promise.all([requireCurrentOwner(), searchParams]); + const requestedAgentId = typeof query.agentId === "string" ? query.agentId : undefined; + const database = getServerDatabase().db; + + let selection: Awaited>; + try { + selection = await readOwnerAgentSelection(database, { + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + ownerId: owner.userId, + }); + } catch (error) { + if (error instanceof LeashAgentSelectionError) notFound(); + throw error; + } + if (!selection.selected) return ; + + const result = await listOwnerNotifications(database, { + agentId: selection.selected.id, + cursor: undefined, + limit: 50, + ownerId: owner.userId, + read: "all", + resolution: "all", + tier: undefined, + type: undefined, + }); + + return ( + <> + + + + ); +} diff --git a/apps/web/app/(leash)/leash/(control)/overview-notifications.module.css b/apps/web/app/(leash)/leash/(control)/overview-notifications.module.css new file mode 100644 index 0000000..c7cc053 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/overview-notifications.module.css @@ -0,0 +1,113 @@ +.card { + overflow: hidden; + border: 1px solid #e8e6df; + border-radius: 12px; + background: #fff; +} + +.heading, +.heading div { + display: flex; + align-items: center; +} + +.heading { + justify-content: space-between; + padding: 13px 17px; + border-bottom: 1px solid #f0eee8; +} + +.heading div { + gap: 8px; + font-size: 13px; + font-weight: 600; +} + +.heading b { + padding: 3px 7px; + border-radius: 999px; + background: #e7f3ef; + color: #17503a; + font-size: 10px; +} + +.heading a { + color: #206b4e; + font-size: 11.5px; + font-weight: 600; + text-decoration: none; +} + +.card ul { + margin: 0; + padding: 0; + list-style: none; +} + +.card li { + display: grid; + grid-template-columns: 112px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 11px 17px; + border-bottom: 1px solid #f5f4f0; +} + +.card li > span { + width: max-content; + padding: 3px 7px; + border-radius: 5px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.tier2 { + background: #fbf1dc; + color: #96650f; +} + +.tier3 { + background: #faecec; + color: #b4383d; +} + +.card li strong { + font-size: 12px; +} + +.card li p, +.card time, +.empty { + color: #8c8a80; + font-size: 10.5px; +} + +.card li p { + margin: 2px 0 0; +} + +.card time { + text-align: right; +} + +.empty { + margin: 0; + padding: 30px 17px; + text-align: center; +} + +.heading a:focus-visible { + outline: 2px solid #206b4e; + outline-offset: 2px; +} + +@media (max-width: 620px) { + .card li { + grid-template-columns: 1fr; + } + + .card time { + text-align: left; + } +} diff --git a/apps/web/app/(leash)/leash/(control)/overview-notifications.test.tsx b/apps/web/app/(leash)/leash/(control)/overview-notifications.test.tsx new file mode 100644 index 0000000..13e6878 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/overview-notifications.test.tsx @@ -0,0 +1,28 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { OverviewNotifications } from "./overview-notifications"; + +describe("Leash overview notifications", () => { + it("shows the canonical MCP resource instead of the collapsed MCP host", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("mcp://tool/search"); + expect(html).not.toContain(">tool<"); + }); +}); diff --git a/apps/web/app/(leash)/leash/(control)/overview-notifications.tsx b/apps/web/app/(leash)/leash/(control)/overview-notifications.tsx new file mode 100644 index 0000000..293c682 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/overview-notifications.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; + +import styles from "./overview-notifications.module.css"; + +export type NotificationPreview = { + createdAt: Date; + id: string; + metadata: Record; + resourceHost: string | null; + tier: "2" | "3"; + type: string; +}; + +function notificationTitle(type: string) { + const titles: Record = { + cap_75: "Cap is 75% used", + cap_blocked: "Payment blocked at cap", + cap_lowered_halt: "Cap lowered below current spend", + float_empty: "Agent float is empty", + float_low: "Agent float is low", + unusual_domain: "First payment to a new destination", + }; + return titles[type] ?? "Leash notification"; +} + +function notificationDestination(notification: NotificationPreview) { + const resourceUrl = notification.metadata.resourceUrl; + if (typeof resourceUrl === "string" && resourceUrl.startsWith("mcp:")) return resourceUrl; + return notification.resourceHost ?? "Cap policy event"; +} + +export function OverviewNotifications({ + agentId, + notifications, + unreadCount, +}: { + agentId: string; + notifications: NotificationPreview[]; + unreadCount: number; +}) { + return ( +
+
+
+ Notifications + {unreadCount} unread +
+ View all → +
+ {notifications.length === 0 ? ( +

No real alert or action-required event has fired.

+ ) : ( +
    + {notifications.map((notification) => ( +
  • + + {notification.tier === "3" ? "ACTION REQUIRED" : "ALERT"} + +
    + {notificationTitle(notification.type)} +

    {notificationDestination(notification)}

    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/(leash)/leash/(control)/page.tsx b/apps/web/app/(leash)/leash/(control)/page.tsx new file mode 100644 index 0000000..76b3b47 --- /dev/null +++ b/apps/web/app/(leash)/leash/(control)/page.tsx @@ -0,0 +1,84 @@ +import { notFound } from "next/navigation"; + +import { requireCurrentOwner } from "../../../../lib/auth/current-owner"; +import { readOwnerLeashKey } from "../../../../lib/auth/leash-key"; +import { getServerDatabase } from "../../../../lib/db/server"; +import { readOwnerCap } from "../../../../lib/leash/cap-policy"; +import { capPolicyView } from "../../../../lib/leash/cap-view"; +import { readFloatBalance } from "../../../../lib/leash/float-balance"; +import { listOwnerNotifications } from "../../../../lib/leash/notification-store"; +import { + LeashAgentSelectionError, + readOwnerAgentSelection, +} from "../../../../lib/leash/owner-agents"; +import { AgentPicker, NoAgentState } from "./control-page"; +import { LeashOverview } from "./leash-overview"; + +type OverviewPageProps = { searchParams: Promise<{ agentId?: string | string[] }> }; + +async function liveFloatReads(address: string | null) { + if (!address) return null; + const networks = [ + { label: "Base", network: "eip155:8453" }, + { label: "Arbitrum", network: "eip155:42161" }, + ] as const; + return Promise.all( + networks.map(async (network) => { + try { + const balance = await readFloatBalance({ address, network: network.network }); + return { ...network, balanceAtomic: balance.toString() }; + } catch { + return { ...network, balanceAtomic: null }; + } + }), + ); +} + +export default async function LeashOverviewPage({ searchParams }: OverviewPageProps) { + const [owner, query] = await Promise.all([requireCurrentOwner(), searchParams]); + const requestedAgentId = typeof query.agentId === "string" ? query.agentId : undefined; + const database = getServerDatabase().db; + + let selection: Awaited>; + try { + selection = await readOwnerAgentSelection(database, { + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + ownerId: owner.userId, + }); + } catch (error) { + if (error instanceof LeashAgentSelectionError) notFound(); + throw error; + } + if (!selection.selected) return ; + const agent = selection.selected; + + const [policy, keySummary, notificationResult, floats] = await Promise.all([ + readOwnerCap(database, { agentId: agent.id, ownerId: owner.userId }), + readOwnerLeashKey(database, { agentId: agent.id, ownerId: owner.userId }), + listOwnerNotifications(database, { + agentId: agent.id, + cursor: undefined, + limit: 3, + ownerId: owner.userId, + read: "all", + resolution: "all", + tier: undefined, + type: undefined, + }), + liveFloatReads(agent.agentAddress), + ]); + + return ( + <> + + + + ); +} diff --git a/apps/web/app/(leash)/leash/login/page.tsx b/apps/web/app/(leash)/leash/login/page.tsx new file mode 100644 index 0000000..ee91f5e --- /dev/null +++ b/apps/web/app/(leash)/leash/login/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +import { LeashAuthPage } from "../../auth/leash-auth-page"; + +export const metadata: Metadata = { title: "Log in to Leash · Tab" }; + +export default function LeashLoginPage() { + return ; +} diff --git a/apps/web/app/api/agent/sign/route.integration.test.ts b/apps/web/app/api/agent/sign/route.integration.test.ts index 2778ada..460d403 100644 --- a/apps/web/app/api/agent/sign/route.integration.test.ts +++ b/apps/web/app/api/agent/sign/route.integration.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { issueLeashKey } from "../../../../lib/auth/leash-key"; import { createDatabase } from "../../../../lib/db/client"; -import { agents, capCycles, caps, receipts, users } from "../../../../lib/db/schema"; +import { agents, capCycles, caps, notifications, receipts, users } from "../../../../lib/db/schema"; import { closeServerDatabase } from "../../../../lib/db/server"; import { POST } from "./route"; @@ -61,6 +61,7 @@ function signBody(amount = "25000") { network: "eip155:8453", origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, payTo, + resourceUrl: "mcp://tool/search", signerRequest: { domain: { chainId: 8453, name: "USD Coin", verifyingContract: baseUsdc, version: "2" }, message: { @@ -173,6 +174,23 @@ describe("POST /api/agent/sign", () => { expect(rpcMethods).toHaveLength(0); }); + it("rejects an oversized uint256 before PostgreSQL can write partial audit state", async () => { + const identity = await provision(); + const uint256Max = (BigInt(2) ** BigInt(256) - BigInt(1)).toString(); + const response = await POST(request(identity.secret, signBody(uint256Max))); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "INVALID_SIGN_REQUEST" }, + }); + const receiptRows = await connection.db.select({ id: receipts.id }).from(receipts); + const notificationRows = await connection.db + .select({ id: notifications.id }) + .from(notifications); + expect({ notificationRows, receiptRows }).toEqual({ notificationRows: [], receiptRows: [] }); + expect(rpcMethods).toHaveLength(0); + }); + it("writes cap-exceeded as blocked without reading or signing", async () => { const identity = await provision({ capCents: "1" }); const response = await POST(request(identity.secret, signBody("25000"))); diff --git a/apps/web/app/api/auth/precheck/route.integration.test.ts b/apps/web/app/api/auth/precheck/route.integration.test.ts index 51e177d..0a8a0b0 100644 --- a/apps/web/app/api/auth/precheck/route.integration.test.ts +++ b/apps/web/app/api/auth/precheck/route.integration.test.ts @@ -60,6 +60,19 @@ describe("POST /api/auth/precheck with real PostgreSQL", () => { }); }); + it("allows signup for an exact Leash-first user who has no merchant yet", async () => { + const email = "leash-first@example.test"; + await connection.client` + insert into users (email, magic_issuer) + values (${email}, ${`did:ethr:${randomUUID()}`}) + `; + + const response = await POST(request({ email, flow: "signup" })); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ allowed: true }); + }); + it("allows login only for an existing merchant", async () => { const missingResponse = await POST(request({ email: "missing@example.test", flow: "login" })); diff --git a/apps/web/app/api/auth/verify/route.owner-upgrade.integration.test.ts b/apps/web/app/api/auth/verify/route.owner-upgrade.integration.test.ts new file mode 100644 index 0000000..1463535 --- /dev/null +++ b/apps/web/app/api/auth/verify/route.owner-upgrade.integration.test.ts @@ -0,0 +1,97 @@ +import { randomUUID } from "node:crypto"; + +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const magicBoundary = vi.hoisted(() => ({ verifyMerchantDidToken: vi.fn() })); + +vi.mock("../../../../lib/auth/magic-admin", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + verifyMerchantDidToken: magicBoundary.verifyMerchantDidToken, + }; +}); + +import { readSessionToken, SESSION_COOKIE_NAME } from "../../../../lib/auth/session"; +import { createDatabase } from "../../../../lib/db/client"; +import { closeServerDatabase } from "../../../../lib/db/server"; +import { POST } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for PostgreSQL integration tests"); + +const sessionSecret = "x".repeat(64); +const originalMagicSecret = process.env.MAGIC_SECRET_KEY; +const originalSessionSecret = process.env.SESSION_SECRET; +const connection = createDatabase(databaseUrl, 1); + +function request(body: unknown) { + return new Request("http://localhost/api/auth/verify", { + body: JSON.stringify(body), + headers: { "content-type": "application/json", origin: "http://localhost" }, + method: "POST", + }); +} + +function cookieToken(response: Response) { + const header = response.headers.get("set-cookie"); + if (!header) throw new Error("Expected a session cookie"); + const [cookie = ""] = header.split(";", 1); + const [name, token] = cookie.split("=", 2); + expect(name).toBe(SESSION_COOKIE_NAME); + if (!token) throw new Error("Expected a session token"); + return token; +} + +describe("merchant signup after Leash owner auth", () => { + beforeEach(async () => { + process.env.MAGIC_SECRET_KEY = "configured-magic-boundary"; + process.env.SESSION_SECRET = sessionSecret; + magicBoundary.verifyMerchantDidToken.mockReset(); + await connection.client`truncate table users cascade`; + }); + + afterEach(() => { + if (originalMagicSecret === undefined) delete process.env.MAGIC_SECRET_KEY; + else process.env.MAGIC_SECRET_KEY = originalMagicSecret; + if (originalSessionSecret === undefined) delete process.env.SESSION_SECRET; + else process.env.SESSION_SECRET = originalSessionSecret; + }); + + afterAll(async () => { + await connection.client`truncate table users cascade`; + await closeServerDatabase(); + await connection.client.end(); + }); + + it("attaches the merchant to the exact owner and mints real merchant claims", async () => { + const identity = { + email: "leash-first@example.test", + magicIssuer: `did:ethr:${randomUUID()}`, + receivingAddress: "0x1111111111111111111111111111111111111111", + }; + const [owner] = await connection.client<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${identity.email}, ${identity.magicIssuer}) + returning id + `; + if (!owner) throw new Error("PostgreSQL did not return the owner"); + magicBoundary.verifyMerchantDidToken.mockResolvedValue(identity); + + const response = await POST( + request({ didToken: "verified.did.token", email: identity.email, flow: "signup" }), + ); + const session = await readSessionToken(cookieToken(response), sessionSecret); + + expect(response.status).toBe(200); + await expect(response.clone().json()).resolves.toEqual({ redirectTo: "/dashboard/quickstart" }); + expect(session).toMatchObject({ email: identity.email, mode: "test", userId: owner.id }); + expect(session).toHaveProperty("merchantId"); + const [counts] = await connection.client< + { merchantsCount: number; usersCount: number }[] + >`select + (select count(*)::int from users) as "usersCount", + (select count(*)::int from merchants) as "merchantsCount"`; + expect(counts).toEqual({ merchantsCount: 1, usersCount: 1 }); + }); +}); diff --git a/apps/web/app/api/leash/auth/precheck/route.test.ts b/apps/web/app/api/leash/auth/precheck/route.test.ts new file mode 100644 index 0000000..e814807 --- /dev/null +++ b/apps/web/app/api/leash/auth/precheck/route.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { POST } from "./route"; + +const originalEnv = { + appUrl: process.env.NEXT_PUBLIC_APP_URL, + databaseUrl: process.env.DATABASE_URL, + magicSecret: process.env.MAGIC_SECRET_KEY, + sessionSecret: process.env.SESSION_SECRET, +}; + +function request(body: unknown, origin = "http://localhost") { + return new Request("http://localhost/api/leash/auth/precheck", { + body: JSON.stringify(body), + headers: { "content-type": "application/json", origin }, + method: "POST", + }); +} + +function restore(name: keyof typeof originalEnv, environmentName: string) { + const value = originalEnv[name]; + if (value === undefined) delete process.env[environmentName]; + else process.env[environmentName] = value; +} + +describe("POST /api/leash/auth/precheck", () => { + beforeEach(() => { + process.env.DATABASE_URL = "postgresql://configured.example/tab"; + process.env.MAGIC_SECRET_KEY = "configured-magic-boundary"; + process.env.NEXT_PUBLIC_APP_URL = "http://localhost"; + process.env.SESSION_SECRET = "x".repeat(64); + }); + + afterEach(() => { + restore("appUrl", "NEXT_PUBLIC_APP_URL"); + restore("databaseUrl", "DATABASE_URL"); + restore("magicSecret", "MAGIC_SECRET_KEY"); + restore("sessionSecret", "SESSION_SECRET"); + }); + + it("allows any valid owner email because verification finds or creates the principal", async () => { + const response = await POST(request({ email: "New.Owner@example.test" })); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.json()).resolves.toEqual({ allowed: true }); + }); + + it("rejects invalid email before starting Magic", async () => { + const response = await POST(request({ email: "not-an-email" })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: { code: "INVALID_EMAIL", message: "Enter a valid email address." }, + }); + }); + + it("rejects a cross-origin precheck", async () => { + const response = await POST( + request({ email: "owner@example.test" }, "https://attacker.example"), + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: { code: "ORIGIN_NOT_ALLOWED", message: "Request origin is not allowed." }, + }); + }); + + it("returns an honest configuration blocker before OTP", async () => { + delete process.env.MAGIC_SECRET_KEY; + const magicResponse = await POST(request({ email: "owner@example.test" })); + expect(magicResponse.status).toBe(503); + await expect(magicResponse.json()).resolves.toEqual({ + error: { + code: "MAGIC_NOT_CONFIGURED", + message: "Magic authentication is not configured.", + }, + }); + + process.env.MAGIC_SECRET_KEY = "configured-magic-boundary"; + delete process.env.SESSION_SECRET; + const sessionResponse = await POST(request({ email: "owner@example.test" })); + expect(sessionResponse.status).toBe(503); + await expect(sessionResponse.json()).resolves.toEqual({ + error: { + code: "SESSION_NOT_CONFIGURED", + message: "Session signing is not configured.", + }, + }); + + process.env.SESSION_SECRET = "x".repeat(64); + delete process.env.DATABASE_URL; + const databaseResponse = await POST(request({ email: "owner@example.test" })); + expect(databaseResponse.status).toBe(503); + await expect(databaseResponse.json()).resolves.toEqual({ + error: { + code: "DATABASE_NOT_CONFIGURED", + message: "Owner storage is not configured.", + }, + }); + }); +}); diff --git a/apps/web/app/api/leash/auth/precheck/route.ts b/apps/web/app/api/leash/auth/precheck/route.ts new file mode 100644 index 0000000..191059f --- /dev/null +++ b/apps/web/app/api/leash/auth/precheck/route.ts @@ -0,0 +1,49 @@ +import { magicAuthenticationConfigured } from "../../../../../lib/auth/magic-admin"; +import { requestOriginIsAllowed } from "../../../../../lib/auth/request-origin"; +import { sessionSigningConfigured } from "../../../../../lib/auth/session"; + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function json(body: unknown, status = 200) { + return Response.json(body, { + headers: { "cache-control": "no-store" }, + status, + }); +} + +function error(code: string, message: string, status: number) { + return json({ error: { code, message } }, status); +} + +export async function POST(request: Request) { + if (!requestOriginIsAllowed(request)) { + return error("ORIGIN_NOT_ALLOWED", "Request origin is not allowed.", 403); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + + const email = + typeof body === "object" && body !== null && "email" in body && typeof body.email === "string" + ? body.email.trim() + : ""; + + if (!emailPattern.test(email)) { + return error("INVALID_EMAIL", "Enter a valid email address.", 400); + } + if (!magicAuthenticationConfigured()) { + return error("MAGIC_NOT_CONFIGURED", "Magic authentication is not configured.", 503); + } + if (!sessionSigningConfigured()) { + return error("SESSION_NOT_CONFIGURED", "Session signing is not configured.", 503); + } + if (!process.env.DATABASE_URL?.trim()) { + return error("DATABASE_NOT_CONFIGURED", "Owner storage is not configured.", 503); + } + + return json({ allowed: true }); +} diff --git a/apps/web/app/api/leash/auth/verify/route.integration.test.ts b/apps/web/app/api/leash/auth/verify/route.integration.test.ts new file mode 100644 index 0000000..5867524 --- /dev/null +++ b/apps/web/app/api/leash/auth/verify/route.integration.test.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; + +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const magicBoundary = vi.hoisted(() => ({ verifyOwnerDidToken: vi.fn() })); + +vi.mock("server-only", () => ({})); +vi.mock("../../../../../lib/auth/magic-admin", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, verifyOwnerDidToken: magicBoundary.verifyOwnerDidToken }; +}); + +import { readSessionToken, SESSION_COOKIE_NAME } from "../../../../../lib/auth/session"; +import { createDatabase } from "../../../../../lib/db/client"; +import { provisionMerchant } from "../../../../../lib/db/provision-merchant"; +import { closeServerDatabase } from "../../../../../lib/db/server"; +import { POST } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for PostgreSQL integration tests"); +} + +const sessionSecret = "x".repeat(64); +const originalEnv = { + appUrl: process.env.NEXT_PUBLIC_APP_URL, + magicSecret: process.env.MAGIC_SECRET_KEY, + sessionSecret: process.env.SESSION_SECRET, +}; +const connection = createDatabase(databaseUrl, 1); + +function request(body: unknown, origin = "http://localhost") { + return new Request("http://localhost/api/leash/auth/verify", { + body: JSON.stringify(body), + headers: { "content-type": "application/json", origin }, + method: "POST", + }); +} + +function sessionCookie(response: Response) { + const header = response.headers.get("set-cookie"); + if (!header) throw new Error("Expected a session cookie"); + const cookie = header.split(";", 1)[0] ?? ""; + const [name, token] = cookie.split("=", 2); + expect(name).toBe(SESSION_COOKIE_NAME); + if (!token) throw new Error("Expected a session token"); + return token; +} + +function restore(name: keyof typeof originalEnv, environmentName: string) { + const value = originalEnv[name]; + if (value === undefined) delete process.env[environmentName]; + else process.env[environmentName] = value; +} + +describe("POST /api/leash/auth/verify with real PostgreSQL", () => { + beforeEach(async () => { + process.env.MAGIC_SECRET_KEY = "configured-magic-boundary"; + process.env.NEXT_PUBLIC_APP_URL = "http://localhost"; + process.env.SESSION_SECRET = sessionSecret; + magicBoundary.verifyOwnerDidToken.mockReset(); + await connection.client`truncate table users cascade`; + }); + + afterEach(() => { + restore("appUrl", "NEXT_PUBLIC_APP_URL"); + restore("magicSecret", "MAGIC_SECRET_KEY"); + restore("sessionSecret", "SESSION_SECRET"); + }); + + afterAll(async () => { + await connection.client`truncate table users cascade`; + await closeServerDatabase(); + await connection.client.end(); + }); + + it("creates a new user and issues an owner-only shared session", async () => { + const magicIssuer = `did:ethr:${randomUUID()}`; + magicBoundary.verifyOwnerDidToken.mockResolvedValue({ + email: "owner@example.test", + magicIssuer, + }); + + const response = await POST( + request({ didToken: "verified.did.token", email: "owner@example.test" }), + ); + const session = await readSessionToken(sessionCookie(response), sessionSecret); + const [counts] = await connection.client< + { merchantsCount: number; usersCount: number }[] + >`select + (select count(*)::int from users) as "usersCount", + (select count(*)::int from merchants) as "merchantsCount"`; + + expect(response.status).toBe(200); + await expect(response.clone().json()).resolves.toEqual({ redirectTo: "/leash" }); + expect(session).toMatchObject({ email: "owner@example.test" }); + expect(session).not.toHaveProperty("merchantId"); + expect(session).not.toHaveProperty("mode"); + expect(counts).toEqual({ merchantsCount: 0, usersCount: 1 }); + }); + + it("reuses a merchant user without fabricating merchant claims", async () => { + const identity = { + email: "shared@example.test", + magicIssuer: `did:ethr:${randomUUID()}`, + }; + const merchant = await provisionMerchant(connection.db, { + ...identity, + receivingAddress: "0x1111111111111111111111111111111111111111", + }); + magicBoundary.verifyOwnerDidToken.mockResolvedValue(identity); + + const response = await POST(request({ didToken: "verified.did.token", email: identity.email })); + const session = await readSessionToken(sessionCookie(response), sessionSecret); + + expect(session).toEqual({ email: identity.email, userId: merchant.userId }); + const [counts] = await connection.client< + { merchantsCount: number; usersCount: number }[] + >`select + (select count(*)::int from users) as "usersCount", + (select count(*)::int from merchants) as "merchantsCount"`; + expect(counts).toEqual({ merchantsCount: 1, usersCount: 1 }); + }); + + it("rejects a persisted Magic session for a different email before provisioning", async () => { + magicBoundary.verifyOwnerDidToken.mockResolvedValue({ + email: "saved@example.test", + magicIssuer: `did:ethr:${randomUUID()}`, + }); + + const response = await POST( + request({ didToken: "saved.did.token", email: "requested@example.test" }), + ); + + expect(response.status).toBe(409); + expect(response.headers.get("set-cookie")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "MAGIC_EMAIL_MISMATCH", + message: "This browser's saved Magic session belongs to a different email.", + }, + }); + const [count] = await connection.client<{ count: number }[]>` + select count(*)::int as count from users + `; + expect(count?.count).toBe(0); + }); + + it("rejects a verified identity collision instead of rebinding the user", async () => { + const firstIssuer = `did:ethr:${randomUUID()}`; + await connection.client` + insert into users (email, magic_issuer) + values ('bound@example.test', ${firstIssuer}) + `; + magicBoundary.verifyOwnerDidToken.mockResolvedValue({ + email: "bound@example.test", + magicIssuer: `did:ethr:${randomUUID()}`, + }); + + const response = await POST( + request({ didToken: "different.did.token", email: "bound@example.test" }), + ); + + expect(response.status).toBe(409); + expect(response.headers.get("set-cookie")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "MAGIC_IDENTITY_CONFLICT", + message: "This Magic identity is already bound to a different Tab account.", + }, + }); + }); + + it("rejects cross-origin session creation before contacting Magic", async () => { + const response = await POST( + request( + { didToken: "verified.did.token", email: "owner@example.test" }, + "https://attacker.example", + ), + ); + + expect(response.status).toBe(403); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(magicBoundary.verifyOwnerDidToken).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/leash/auth/verify/route.ts b/apps/web/app/api/leash/auth/verify/route.ts new file mode 100644 index 0000000..e9dce45 --- /dev/null +++ b/apps/web/app/api/leash/auth/verify/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; + +import { + InvalidMagicIdentityError, + InvalidMagicTokenError, + MagicServiceUnavailableError, + magicAuthenticationConfigured, + verifyOwnerDidToken, +} from "../../../../../lib/auth/magic-admin"; +import { magicEmailMatchesRequest } from "../../../../../lib/auth/magic-email"; +import { requestOriginIsAllowed } from "../../../../../lib/auth/request-origin"; +import { + createSessionToken, + SESSION_COOKIE_NAME, + sessionCookieOptions, + sessionSigningConfigured, +} from "../../../../../lib/auth/session"; +import { + findOrCreateOwnerIdentity, + OwnerIdentityConflictError, +} from "../../../../../lib/db/owner-identity"; +import { getServerDatabase } from "../../../../../lib/db/server"; + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function json(body: unknown, status: number) { + return NextResponse.json(body, { + headers: { "cache-control": "no-store" }, + status, + }); +} + +function error(code: string, message: string, status: number) { + return json({ error: { code, message } }, status); +} + +function authInput(body: unknown) { + if (typeof body !== "object" || body === null) return undefined; + const didToken = + "didToken" in body && typeof body.didToken === "string" ? body.didToken.trim() : ""; + const email = + "email" in body && typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + return didToken && emailPattern.test(email) ? { didToken, email } : undefined; +} + +export async function POST(request: Request) { + if (!requestOriginIsAllowed(request)) { + return error("ORIGIN_NOT_ALLOWED", "Request origin is not allowed.", 403); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + + const input = authInput(body); + if (!input) { + return error("INVALID_AUTH_REQUEST", "A DID token and valid email are required.", 400); + } + if (!magicAuthenticationConfigured()) { + return error("MAGIC_NOT_CONFIGURED", "Magic authentication is not configured.", 503); + } + if (!sessionSigningConfigured()) { + return error("SESSION_NOT_CONFIGURED", "Session signing is not configured.", 503); + } + + let identity: Awaited>; + try { + identity = await verifyOwnerDidToken(input.didToken); + } catch (authError) { + if ( + authError instanceof InvalidMagicTokenError || + authError instanceof InvalidMagicIdentityError + ) { + return error("INVALID_DID_TOKEN", "Magic authentication could not be verified.", 401); + } + if (authError instanceof MagicServiceUnavailableError) { + return error("MAGIC_UNAVAILABLE", "Magic authentication is temporarily unavailable.", 502); + } + throw authError; + } + + if (!magicEmailMatchesRequest(identity.email, input.email)) { + return error( + "MAGIC_EMAIL_MISMATCH", + "This browser's saved Magic session belongs to a different email.", + 409, + ); + } + + let owner: Awaited>; + try { + owner = await findOrCreateOwnerIdentity(getServerDatabase().db, identity); + } catch (ownerError) { + if (ownerError instanceof OwnerIdentityConflictError) { + return error( + "MAGIC_IDENTITY_CONFLICT", + "This Magic identity is already bound to a different Tab account.", + 409, + ); + } + throw ownerError; + } + + const token = await createSessionToken(owner); + const response = json({ redirectTo: "/leash" }, 200); + response.cookies.set(SESSION_COOKIE_NAME, token, sessionCookieOptions()); + return response; +} diff --git a/apps/web/app/api/leash/caps/route.integration.test.ts b/apps/web/app/api/leash/caps/route.integration.test.ts new file mode 100644 index 0000000..6434f70 --- /dev/null +++ b/apps/web/app/api/leash/caps/route.integration.test.ts @@ -0,0 +1,161 @@ +import { randomUUID } from "node:crypto"; + +import { NextRequest } from "next/server"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { createSessionToken, SESSION_COOKIE_NAME } from "../../../../lib/auth/session"; +import { createDatabase } from "../../../../lib/db/client"; +import { agents, users } from "../../../../lib/db/schema"; +import { POST as resetCycle } from "../cycles/reset/route"; +import { GET, PATCH, POST } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for Leash cap route tests"); + +const connection = createDatabase(databaseUrl, 2); +const appOrigin = new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost").origin; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(label: string) { + const email = `${label}-${randomUUID()}@example.test`; + const [owner] = await connection.db + .insert(users) + .values({ email, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.db + .insert(agents) + .values({ ownerId: owner.id, name: `${label} agent`, signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const token = await createSessionToken({ email, userId: owner.id }); + return { agentId: agent.id, ownerId: owner.id, token }; +} + +function request( + method: "GET" | "POST" | "PATCH", + path: string, + token?: string, + body?: unknown, + origin = appOrigin, +) { + return new NextRequest(new URL(path, appOrigin), { + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { + ...(body === undefined ? {} : { "content-type": "application/json", origin }), + ...(token ? { cookie: `${SESSION_COOKIE_NAME}=${token}` } : {}), + }, + method, + }); +} + +describe("owner-authenticated Leash cap routes", () => { + it("requires a shared owner session and same-origin mutations", async () => { + const owner = await provision("auth"); + const unauthorized = await GET(request("GET", `/api/leash/caps?agentId=${owner.agentId}`)); + expect(unauthorized.status).toBe(401); + + const crossOrigin = await POST( + request( + "POST", + "/api/leash/caps", + owner.token, + { agentId: owner.agentId, amount: "10.00", frequency: "daily" }, + "https://attacker.example.test", + ), + ); + expect(crossOrigin.status).toBe(403); + }); + + it("sets, reads, and adjusts an exact owner-scoped cap", async () => { + const owner = await provision("lifecycle"); + const created = await POST( + request("POST", "/api/leash/caps", owner.token, { + agentId: owner.agentId, + amount: "10.00", + frequency: "daily", + }), + ); + expect(created.status).toBe(201); + expect(created.headers.get("cache-control")).toBe("no-store"); + const first = await created.json(); + expect(first.policy).toMatchObject({ + agentId: owner.agentId, + cap: { amountUsdCents: "1000", frequency: "daily" }, + spend: { committedAtomic: "0", pendingAtomic: "0", settledAtomic: "0" }, + }); + + const adjusted = await PATCH( + request("PATCH", "/api/leash/caps", owner.token, { + agentId: owner.agentId, + amount: "7.50", + frequency: "daily", + }), + ); + expect(adjusted.status).toBe(200); + const second = await adjusted.json(); + expect(second.policy).toMatchObject({ + cap: { amountUsdCents: "750", frequency: "daily" }, + cycle: { id: first.policy.cycle.id }, + }); + + const read = await GET(request("GET", `/api/leash/caps?agentId=${owner.agentId}`, owner.token)); + expect(read.status).toBe(200); + expect(await read.json()).toEqual(second); + }); + + it("uses one generic not-found response across owner boundaries", async () => { + const owner = await provision("owned"); + const foreign = await provision("foreign"); + const response = await POST( + request("POST", "/api/leash/caps", foreign.token, { + agentId: owner.agentId, + amount: "10.00", + frequency: "daily", + }), + ); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "LEASH_AGENT_NOT_FOUND" }, + }); + + const malformed = await POST( + request("POST", "/api/leash/caps", owner.token, { + agentId: owner.agentId, + amount: "10.00", + extra: true, + frequency: "daily", + }), + ); + expect(malformed.status).toBe(400); + }); + + it("performs a real manual reset and returns a clean active cycle", async () => { + const owner = await provision("manual"); + const created = await POST( + request("POST", "/api/leash/caps", owner.token, { + agentId: owner.agentId, + amount: "10.00", + frequency: "never", + }), + ); + const first = await created.json(); + + const reset = await resetCycle( + request("POST", "/api/leash/cycles/reset", owner.token, { agentId: owner.agentId }), + ); + expect(reset.status).toBe(200); + const body = await reset.json(); + expect(body.policy).toMatchObject({ spend: { committedAtomic: "0" } }); + expect(body.policy.cycle.id).not.toBe(first.policy.cycle.id); + }); +}); diff --git a/apps/web/app/api/leash/caps/route.ts b/apps/web/app/api/leash/caps/route.ts new file mode 100644 index 0000000..aab26ec --- /dev/null +++ b/apps/web/app/api/leash/caps/route.ts @@ -0,0 +1,88 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { getServerDatabase } from "../../../../lib/db/server"; +import { + InvalidCapInputError, + parseAgentTarget, + parseCapMutation, +} from "../../../../lib/leash/cap-input"; +import { readOwnerCap, setOwnerCap } from "../../../../lib/leash/cap-policy"; +import { + capPolicyError, + LEASH_RESPONSE_HEADERS, + leashError, + requireLeashMutationOrigin, + requireOwnerRequest, +} from "../../../../lib/leash/leash-http"; + +async function owner(request: NextRequest) { + const result = await requireOwnerRequest(request); + return result instanceof Response ? { response: result } : { principal: result }; +} + +export async function GET(request: NextRequest) { + const authenticated = await owner(request); + if (authenticated.response) return authenticated.response; + + let target: ReturnType; + try { + target = parseAgentTarget({ agentId: request.nextUrl.searchParams.get("agentId") }); + } catch (error) { + if (error instanceof InvalidCapInputError) { + return leashError("INVALID_AGENT", "Choose a valid Leash agent.", 400); + } + throw error; + } + + try { + const policy = await readOwnerCap(getServerDatabase().db, { + ...target, + ownerId: authenticated.principal.userId, + }); + return NextResponse.json({ policy }, { headers: LEASH_RESPONSE_HEADERS, status: 200 }); + } catch (error) { + return capPolicyError(error); + } +} + +async function mutate(request: NextRequest, status: 200 | 201) { + const originError = requireLeashMutationOrigin(request); + if (originError) return originError; + const authenticated = await owner(request); + if (authenticated.response) return authenticated.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + + let mutation: ReturnType; + try { + mutation = parseCapMutation(body); + } catch (error) { + if (error instanceof InvalidCapInputError) { + return leashError(error.code, error.message, 400); + } + throw error; + } + + try { + const policy = await setOwnerCap(getServerDatabase().db, { + ...mutation, + ownerId: authenticated.principal.userId, + }); + return NextResponse.json({ policy }, { headers: LEASH_RESPONSE_HEADERS, status }); + } catch (error) { + return capPolicyError(error); + } +} + +export function POST(request: NextRequest) { + return mutate(request, 201); +} + +export function PATCH(request: NextRequest) { + return mutate(request, 200); +} diff --git a/apps/web/app/api/leash/cycles/reset/route.ts b/apps/web/app/api/leash/cycles/reset/route.ts new file mode 100644 index 0000000..b8f11ec --- /dev/null +++ b/apps/web/app/api/leash/cycles/reset/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { getServerDatabase } from "../../../../../lib/db/server"; +import { InvalidCapInputError, parseAgentTarget } from "../../../../../lib/leash/cap-input"; +import { resetOwnerCapCycle } from "../../../../../lib/leash/cap-policy"; +import { + capPolicyError, + LEASH_RESPONSE_HEADERS, + leashError, + requireLeashMutationOrigin, + requireOwnerRequest, +} from "../../../../../lib/leash/leash-http"; + +export async function POST(request: NextRequest) { + const originError = requireLeashMutationOrigin(request); + if (originError) return originError; + const owner = await requireOwnerRequest(request); + if (owner instanceof Response) return owner; + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + + let target: ReturnType; + try { + target = parseAgentTarget(body); + } catch (error) { + if (error instanceof InvalidCapInputError) { + return leashError("INVALID_AGENT", "Choose a valid Leash agent.", 400); + } + throw error; + } + + try { + const policy = await resetOwnerCapCycle(getServerDatabase().db, { + ...target, + ownerId: owner.userId, + }); + return NextResponse.json({ policy }, { headers: LEASH_RESPONSE_HEADERS, status: 200 }); + } catch (error) { + return capPolicyError(error); + } +} diff --git a/apps/web/app/api/leash/keys/route.integration.test.ts b/apps/web/app/api/leash/keys/route.integration.test.ts new file mode 100644 index 0000000..34e29c9 --- /dev/null +++ b/apps/web/app/api/leash/keys/route.integration.test.ts @@ -0,0 +1,186 @@ +import { randomUUID } from "node:crypto"; + +import { and, eq, isNull } from "drizzle-orm"; +import { NextRequest } from "next/server"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { + authenticateLeashKey, + hashLeashKey, + InvalidLeashKeyError, +} from "../../../../lib/auth/leash-key"; +import { createSessionToken, SESSION_COOKIE_NAME } from "../../../../lib/auth/session"; +import { createDatabase } from "../../../../lib/db/client"; +import { agents, leashKeys, users } from "../../../../lib/db/schema"; +import { GET, PATCH, POST } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for Leash key route tests"); + +const connection = createDatabase(databaseUrl, 2); +const appOrigin = new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost").origin; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(label: string) { + const email = `${label}-${randomUUID()}@example.test`; + const [owner] = await connection.db + .insert(users) + .values({ email, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.db + .insert(agents) + .values({ ownerId: owner.id, name: `${label} agent`, signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const token = await createSessionToken({ email, userId: owner.id }); + return { agentId: agent.id, ownerId: owner.id, token }; +} + +function request( + method: "GET" | "POST" | "PATCH", + path: string, + token?: string, + body?: unknown, + origin = appOrigin, +) { + return new NextRequest(new URL(path, appOrigin), { + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { + ...(body === undefined ? {} : { "content-type": "application/json", origin }), + ...(token ? { cookie: `${SESSION_COOKIE_NAME}=${token}` } : {}), + }, + method, + }); +} + +describe("owner-authenticated Leash key routes", () => { + it("requires a shared owner session and same-origin mutation", async () => { + const owner = await provision("auth"); + const unauthorized = await GET(request("GET", `/api/leash/keys?agentId=${owner.agentId}`)); + expect(unauthorized.status).toBe(401); + + const crossOrigin = await POST( + request( + "POST", + "/api/leash/keys", + owner.token, + { agentId: owner.agentId }, + "https://attacker.example.test", + ), + ); + expect(crossOrigin.status).toBe(403); + }); + + it("issues show-once material and only returns its durable mask afterward", async () => { + const owner = await provision("issue"); + const response = await POST( + request("POST", "/api/leash/keys", owner.token, { agentId: owner.agentId }), + ); + expect(response.status).toBe(201); + expect(response.headers.get("cache-control")).toBe("no-store"); + const created = await response.json(); + expect(created).toMatchObject({ + key: { agentId: owner.agentId, prefix: "leash_sk_" }, + secret: expect.stringMatching(/^leash_sk_[A-Za-z0-9_-]{43}$/), + }); + + const [stored] = await connection.db + .select() + .from(leashKeys) + .where(eq(leashKeys.id, created.key.id)); + expect(stored).toMatchObject({ + hashedKey: hashLeashKey(created.secret), + last4: created.secret.slice(-4), + }); + expect(JSON.stringify(stored)).not.toContain(created.secret); + + const read = await GET(request("GET", `/api/leash/keys?agentId=${owner.agentId}`, owner.token)); + expect(read.status).toBe(200); + const readBody = await read.json(); + expect(readBody).toEqual({ key: created.key }); + expect(JSON.stringify(readBody)).not.toContain(created.secret); + + const duplicate = await POST( + request("POST", "/api/leash/keys", owner.token, { agentId: owner.agentId }), + ); + expect(duplicate.status).toBe(409); + }); + + it("rotates atomically, reveals once, and rejects the old bearer", async () => { + const owner = await provision("rotate"); + const issued = await POST( + request("POST", "/api/leash/keys", owner.token, { agentId: owner.agentId }), + ); + const original = await issued.json(); + + const rotated = await PATCH( + request("PATCH", "/api/leash/keys", owner.token, { + agentId: owner.agentId, + keyId: original.key.id, + }), + ); + expect(rotated.status).toBe(200); + const replacement = await rotated.json(); + expect(replacement).toMatchObject({ + key: { agentId: owner.agentId, rotatedFromId: original.key.id }, + secret: expect.stringMatching(/^leash_sk_[A-Za-z0-9_-]{43}$/), + }); + expect(replacement.secret).not.toBe(original.secret); + + await expect( + authenticateLeashKey(connection.db, `Bearer ${original.secret}`), + ).rejects.toBeInstanceOf(InvalidLeashKeyError); + await expect( + authenticateLeashKey(connection.db, `Bearer ${replacement.secret}`), + ).resolves.toMatchObject({ agentId: owner.agentId, leashKeyId: replacement.key.id }); + const active = await connection.db + .select({ id: leashKeys.id }) + .from(leashKeys) + .where(and(eq(leashKeys.agentId, owner.agentId), isNull(leashKeys.revokedAt))); + expect(active).toEqual([{ id: replacement.key.id }]); + }); + + it("does not leak or mutate keys across owner boundaries", async () => { + const owned = await provision("owned"); + const foreign = await provision("foreign"); + const issued = await POST( + request("POST", "/api/leash/keys", owned.token, { agentId: owned.agentId }), + ); + const original = await issued.json(); + + for (const response of [ + await GET(request("GET", `/api/leash/keys?agentId=${owned.agentId}`, foreign.token)), + await POST(request("POST", "/api/leash/keys", foreign.token, { agentId: owned.agentId })), + await PATCH( + request("PATCH", "/api/leash/keys", foreign.token, { + agentId: owned.agentId, + keyId: original.key.id, + }), + ), + ]) { + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "LEASH_AGENT_NOT_FOUND" }, + }); + } + + const malformed = await PATCH( + request("PATCH", "/api/leash/keys", owned.token, { + agentId: owned.agentId, + extra: true, + keyId: original.key.id, + }), + ); + expect(malformed.status).toBe(400); + }); +}); diff --git a/apps/web/app/api/leash/keys/route.ts b/apps/web/app/api/leash/keys/route.ts new file mode 100644 index 0000000..068d34c --- /dev/null +++ b/apps/web/app/api/leash/keys/route.ts @@ -0,0 +1,125 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { + ActiveLeashKeyExistsError, + ActiveLeashKeyNotFoundError, + issueOwnerLeashKey, + LeashAgentNotFoundError, + readOwnerLeashKey, + rotateOwnerLeashKey, +} from "../../../../lib/auth/leash-key"; +import { getServerDatabase } from "../../../../lib/db/server"; +import { + InvalidLeashKeyInputError, + parseLeashKeyRotation, + parseLeashKeyScope, +} from "../../../../lib/leash/key-input"; +import { + LEASH_RESPONSE_HEADERS, + leashError, + requireLeashMutationOrigin, + requireOwnerRequest, +} from "../../../../lib/leash/leash-http"; + +async function owner(request: NextRequest) { + const result = await requireOwnerRequest(request); + return result instanceof Response ? { response: result } : { principal: result }; +} + +async function requestBody(request: NextRequest) { + try { + return (await request.json()) as unknown; + } catch { + return undefined; + } +} + +function inputError() { + return leashError("INVALID_KEY_INPUT", "Choose a valid Leash agent and key.", 400); +} + +function lifecycleError(error: unknown) { + if (error instanceof LeashAgentNotFoundError) { + return leashError("LEASH_AGENT_NOT_FOUND", "The Leash agent was not found.", 404); + } + if (error instanceof ActiveLeashKeyNotFoundError) { + return leashError("LEASH_KEY_NOT_FOUND", "The active Leash key was not found.", 404); + } + if (error instanceof ActiveLeashKeyExistsError) { + return leashError("LEASH_KEY_ALREADY_EXISTS", "Rotate the active Leash key instead.", 409); + } + throw error; +} + +export async function GET(request: NextRequest) { + const authenticated = await owner(request); + if (authenticated.response) return authenticated.response; + + let scope: ReturnType; + try { + scope = parseLeashKeyScope({ agentId: request.nextUrl.searchParams.get("agentId") }); + } catch (error) { + if (error instanceof InvalidLeashKeyInputError) return inputError(); + throw error; + } + + try { + const key = await readOwnerLeashKey(getServerDatabase().db, { + ...scope, + ownerId: authenticated.principal.userId, + }); + return NextResponse.json({ key }, { headers: LEASH_RESPONSE_HEADERS, status: 200 }); + } catch (error) { + return lifecycleError(error); + } +} + +export async function POST(request: NextRequest) { + const originError = requireLeashMutationOrigin(request); + if (originError) return originError; + const authenticated = await owner(request); + if (authenticated.response) return authenticated.response; + + let scope: ReturnType; + try { + scope = parseLeashKeyScope(await requestBody(request)); + } catch (error) { + if (error instanceof InvalidLeashKeyInputError) return inputError(); + throw error; + } + + try { + const created = await issueOwnerLeashKey(getServerDatabase().db, { + ...scope, + ownerId: authenticated.principal.userId, + }); + return NextResponse.json(created, { headers: LEASH_RESPONSE_HEADERS, status: 201 }); + } catch (error) { + return lifecycleError(error); + } +} + +export async function PATCH(request: NextRequest) { + const originError = requireLeashMutationOrigin(request); + if (originError) return originError; + const authenticated = await owner(request); + if (authenticated.response) return authenticated.response; + + let target: ReturnType; + try { + target = parseLeashKeyRotation(await requestBody(request)); + } catch (error) { + if (error instanceof InvalidLeashKeyInputError) return inputError(); + throw error; + } + + try { + const replacement = await rotateOwnerLeashKey(getServerDatabase().db, { + ...target, + ownerId: authenticated.principal.userId, + }); + return NextResponse.json(replacement, { headers: LEASH_RESPONSE_HEADERS, status: 200 }); + } catch (error) { + return lifecycleError(error); + } +} diff --git a/apps/web/app/api/leash/notifications/route.integration.test.ts b/apps/web/app/api/leash/notifications/route.integration.test.ts new file mode 100644 index 0000000..82cb9d9 --- /dev/null +++ b/apps/web/app/api/leash/notifications/route.integration.test.ts @@ -0,0 +1,291 @@ +import { randomUUID } from "node:crypto"; + +import { eq } from "drizzle-orm"; +import { NextRequest } from "next/server"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { createSessionToken, SESSION_COOKIE_NAME } from "../../../../lib/auth/session"; +import { createDatabase } from "../../../../lib/db/client"; +import { agents, capCycles, notifications, users } from "../../../../lib/db/schema"; +import { GET, PATCH } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for notification route tests"); + +const connection = createDatabase(databaseUrl, 2); +const appOrigin = new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost").origin; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(label: string) { + const email = `${label}-${randomUUID()}@example.test`; + const [owner] = await connection.db + .insert(users) + .values({ email, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.db + .insert(agents) + .values({ ownerId: owner.id, name: `${label} agent`, signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt: new Date("2026-07-17T09:00:00.000Z") }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected cycle"); + const token = await createSessionToken({ email, userId: owner.id }); + return { agentId: agent.id, cycleId: cycle.id, token }; +} + +interface SeedNotification { + createdAt: Date; + readAt?: Date; + resolvedAt?: Date; + tier: "2" | "3"; + type: typeof notifications.$inferInsert.type; +} + +async function seed(agentId: string, cycleId: string, input: SeedNotification) { + const [row] = await connection.db + .insert(notifications) + .values({ + agentId, + createdAt: input.createdAt, + cycleId, + eventKey: `route-test:${randomUUID()}`, + metadata: { source: "real-route-test" }, + ...(input.readAt ? { readAt: input.readAt } : {}), + ...(input.resolvedAt ? { resolvedAt: input.resolvedAt } : {}), + sticky: input.tier === "3", + tier: input.tier, + type: input.type, + }) + .returning({ id: notifications.id }); + if (!row) throw new Error("Expected notification"); + return row.id; +} + +function request( + method: "GET" | "PATCH", + path: string, + token?: string, + body?: unknown, + origin = appOrigin, +) { + return new NextRequest(new URL(path, appOrigin), { + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { + ...(body === undefined ? {} : { "content-type": "application/json", origin }), + ...(token ? { cookie: `${SESSION_COOKIE_NAME}=${token}` } : {}), + }, + method, + }); +} + +describe("owner notification API", () => { + it("requires an owner session, strict filters, and same-origin mutations", async () => { + const owner = await provision("auth"); + const unauthorized = await GET( + request("GET", `/api/leash/notifications?agentId=${owner.agentId}`), + ); + expect(unauthorized.status).toBe(401); + expect(unauthorized.headers.get("cache-control")).toBe("no-store"); + + const malformed = await GET( + request( + "GET", + `/api/leash/notifications?agentId=${owner.agentId}&tier=1&surprise=true`, + owner.token, + ), + ); + expect(malformed.status).toBe(400); + + const crossOrigin = await PATCH( + request( + "PATCH", + "/api/leash/notifications", + owner.token, + { action: "read_all", agentId: owner.agentId }, + "https://attacker.example.test", + ), + ); + expect(crossOrigin.status).toBe(403); + }); + + it("lists newest-first, exposes durable state and CTA, and filters without changing badge", async () => { + const owner = await provision("list"); + await seed(owner.agentId, owner.cycleId, { + createdAt: new Date("2026-07-17T10:00:00.000Z"), + readAt: new Date("2026-07-17T10:01:00.000Z"), + tier: "2", + type: "cap_75", + }); + await seed(owner.agentId, owner.cycleId, { + createdAt: new Date("2026-07-17T11:00:00.000Z"), + resolvedAt: new Date("2026-07-17T11:30:00.000Z"), + tier: "3", + type: "cap_lowered_halt", + }); + await seed(owner.agentId, owner.cycleId, { + createdAt: new Date("2026-07-17T12:00:00.000Z"), + tier: "2", + type: "float_low", + }); + await seed(owner.agentId, owner.cycleId, { + createdAt: new Date("2026-07-17T13:00:00.000Z"), + tier: "3", + type: "cap_blocked", + }); + + const response = await GET( + request("GET", `/api/leash/notifications?agentId=${owner.agentId}`, owner.token), + ); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = await response.json(); + expect(body.unreadCount).toBe(3); + expect(body.notifications.map((item: { type: string }) => item.type)).toEqual([ + "cap_blocked", + "float_low", + "cap_lowered_halt", + "cap_75", + ]); + expect(body.notifications[0]).toMatchObject({ + cta: { + href: `/leash/cap?agentId=${owner.agentId}#cap-controls`, + kind: "cap_remediation", + label: "Review cap", + }, + readAt: null, + resolvedAt: null, + sticky: true, + tier: "3", + }); + expect(body.notifications[2]).toMatchObject({ cta: null, resolvedAt: expect.any(String) }); + + const filtered = await GET( + request( + "GET", + `/api/leash/notifications?agentId=${owner.agentId}&tier=2&read=unread&resolution=active&type=float_low`, + owner.token, + ), + ); + await expect(filtered.json()).resolves.toMatchObject({ + notifications: [{ cta: null, type: "float_low" }], + unreadCount: 3, + }); + }); + + it("paginates stable timestamp ties without duplicates or skipped rows", async () => { + const owner = await provision("pages"); + const createdAt = new Date("2026-07-17T12:00:00.000Z"); + const expected = ( + await Promise.all( + Array.from({ length: 3 }, () => + seed(owner.agentId, owner.cycleId, { + createdAt, + tier: "2", + type: "float_low", + }), + ), + ) + ).sort((left, right) => right.localeCompare(left)); + + const first = await GET( + request("GET", `/api/leash/notifications?agentId=${owner.agentId}&limit=2`, owner.token), + ); + const firstBody = await first.json(); + expect(firstBody.notifications.map((item: { id: string }) => item.id)).toEqual( + expected.slice(0, 2), + ); + expect(firstBody.nextCursor).toEqual(expect.any(String)); + expect(firstBody.unreadCount).toBe(3); + + const second = await GET( + request( + "GET", + `/api/leash/notifications?agentId=${owner.agentId}&limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`, + owner.token, + ), + ); + await expect(second.json()).resolves.toMatchObject({ + nextCursor: null, + notifications: [{ id: expected[2] }], + unreadCount: 3, + }); + }); + + it("returns the same generic not-found response across owner boundaries", async () => { + const owner = await provision("owned"); + const foreign = await provision("foreign"); + const foreignNotificationId = await seed(foreign.agentId, foreign.cycleId, { + createdAt: new Date("2026-07-17T13:00:00.000Z"), + tier: "3", + type: "cap_blocked", + }); + + const foreignAgent = await GET( + request("GET", `/api/leash/notifications?agentId=${foreign.agentId}`, owner.token), + ); + const foreignNotification = await PATCH( + request("PATCH", "/api/leash/notifications", owner.token, { + action: "read_one", + agentId: owner.agentId, + notificationId: foreignNotificationId, + }), + ); + expect(foreignAgent.status).toBe(404); + expect(foreignNotification.status).toBe(404); + expect(await foreignNotification.json()).toEqual(await foreignAgent.json()); + }); + + it("marks one and all unread rows idempotently with real persisted timestamps", async () => { + const owner = await provision("read"); + const firstId = await seed(owner.agentId, owner.cycleId, { + createdAt: new Date("2026-07-17T10:00:00.000Z"), + tier: "2", + type: "cap_75", + }); + await seed(owner.agentId, owner.cycleId, { + createdAt: new Date("2026-07-17T11:00:00.000Z"), + tier: "2", + type: "float_low", + }); + const readOne = { action: "read_one", agentId: owner.agentId, notificationId: firstId }; + + const first = await PATCH(request("PATCH", "/api/leash/notifications", owner.token, readOne)); + await expect(first.json()).resolves.toEqual({ unreadCount: 1, updatedCount: 1 }); + const [stored] = await connection.db + .select({ readAt: notifications.readAt }) + .from(notifications) + .where(eq(notifications.id, firstId)); + expect(stored?.readAt).toBeInstanceOf(Date); + + const repeated = await PATCH( + request("PATCH", "/api/leash/notifications", owner.token, readOne), + ); + await expect(repeated.json()).resolves.toEqual({ unreadCount: 1, updatedCount: 0 }); + const [unchanged] = await connection.db + .select({ readAt: notifications.readAt }) + .from(notifications) + .where(eq(notifications.id, firstId)); + expect(unchanged?.readAt).toEqual(stored?.readAt); + + const readAll = { action: "read_all", agentId: owner.agentId }; + const all = await PATCH(request("PATCH", "/api/leash/notifications", owner.token, readAll)); + await expect(all.json()).resolves.toEqual({ unreadCount: 0, updatedCount: 1 }); + const allRepeated = await PATCH( + request("PATCH", "/api/leash/notifications", owner.token, readAll), + ); + await expect(allRepeated.json()).resolves.toEqual({ unreadCount: 0, updatedCount: 0 }); + }); +}); diff --git a/apps/web/app/api/leash/notifications/route.pagination-precision.integration.test.ts b/apps/web/app/api/leash/notifications/route.pagination-precision.integration.test.ts new file mode 100644 index 0000000..36999de --- /dev/null +++ b/apps/web/app/api/leash/notifications/route.pagination-precision.integration.test.ts @@ -0,0 +1,108 @@ +import { randomUUID } from "node:crypto"; + +import { NextRequest } from "next/server"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { createSessionToken, SESSION_COOKIE_NAME } from "../../../../lib/auth/session"; +import { createDatabase } from "../../../../lib/db/client"; +import { agents, capCycles, users } from "../../../../lib/db/schema"; +import { GET } from "./route"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for notification route tests"); + +const connection = createDatabase(databaseUrl, 1); +const appOrigin = new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost").origin; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision() { + const email = `precision-${randomUUID()}@example.test`; + const [owner] = await connection.db + .insert(users) + .values({ email, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.db + .insert(agents) + .values({ ownerId: owner.id, name: "Precision agent", signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt: new Date("2026-07-17T09:00:00.000Z") }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected cycle"); + const token = await createSessionToken({ email, userId: owner.id }); + return { agentId: agent.id, cycleId: cycle.id, token }; +} + +async function insertAt(agentId: string, cycleId: string, id: string, createdAtLiteral: string) { + await connection.client` + insert into notifications ( + id, agent_id, cycle_id, tier, type, event_key, metadata, sticky, created_at + ) values ( + ${id}, ${agentId}, ${cycleId}, '2', 'float_low', ${`precision:${id}`}, '{}', false, + ${createdAtLiteral}::timestamptz + ) + `; +} + +function request(agentId: string, token: string, cursor?: string) { + const url = new URL(`/api/leash/notifications?agentId=${agentId}&limit=2`, appOrigin); + if (cursor) url.searchParams.set("cursor", cursor); + return new NextRequest(url, { + headers: { cookie: `${SESSION_COOKIE_NAME}=${token}` }, + method: "GET", + }); +} + +describe("notification keyset timestamp precision", () => { + it("normalizes sub-millisecond inserts so page cursors cannot skip rows", async () => { + const owner = await provision(); + const ids = [randomUUID(), randomUUID(), randomUUID()]; + const literals = [ + "2026-07-17T12:00:00.000900Z", + "2026-07-17T12:00:00.000800Z", + "2026-07-17T12:00:00.000700Z", + ]; + for (const [index, id] of ids.entries()) { + if (!id) throw new Error("Expected notification id"); + await insertAt(owner.agentId, owner.cycleId, id, literals[index] ?? ""); + } + + const first = await GET(request(owner.agentId, owner.token)); + const firstBody = await first.json(); + expect(firstBody.notifications).toHaveLength(2); + expect(firstBody.nextCursor).toEqual(expect.any(String)); + + const second = await GET(request(owner.agentId, owner.token, firstBody.nextCursor)); + const secondBody = await second.json(); + const pagedIds = [...firstBody.notifications, ...secondBody.notifications].map( + (notification: { id: string }) => notification.id, + ); + expect(new Set(pagedIds)).toEqual(new Set(ids)); + expect(pagedIds).toHaveLength(ids.length); + expect(secondBody.nextCursor).toBeNull(); + + const stored = await connection.client<{ created_at: string }[]>` + select to_char(created_at at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') as created_at + from notifications + where agent_id = ${owner.agentId} + order by created_at desc, id desc + `; + expect(stored.map((row) => row.created_at)).toEqual([ + "2026-07-17 12:00:00.001000", + "2026-07-17 12:00:00.001000", + "2026-07-17 12:00:00.001000", + ]); + }); +}); diff --git a/apps/web/app/api/leash/notifications/route.ts b/apps/web/app/api/leash/notifications/route.ts new file mode 100644 index 0000000..8068c68 --- /dev/null +++ b/apps/web/app/api/leash/notifications/route.ts @@ -0,0 +1,100 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { getServerDatabase } from "../../../../lib/db/server"; +import { + LEASH_RESPONSE_HEADERS, + leashError, + requireLeashMutationOrigin, + requireOwnerRequest, +} from "../../../../lib/leash/leash-http"; +import { + InvalidNotificationInputError, + parseNotificationMutation, + parseNotificationQuery, +} from "../../../../lib/leash/notification-input"; +import { + LeashNotificationNotFoundError, + listOwnerNotifications, + markAllOwnerNotificationsRead, + markOwnerNotificationRead, +} from "../../../../lib/leash/notification-store"; + +async function owner(request: NextRequest) { + const result = await requireOwnerRequest(request); + return result instanceof Response ? { response: result } : { principal: result }; +} + +function notFound(error: unknown) { + if (error instanceof LeashNotificationNotFoundError) { + return leashError("LEASH_RESOURCE_NOT_FOUND", error.message, 404); + } + throw error; +} + +export async function GET(request: NextRequest) { + const authenticated = await owner(request); + if (authenticated.response) return authenticated.response; + + let query: ReturnType; + try { + query = parseNotificationQuery(request.nextUrl.searchParams); + } catch (error) { + if (error instanceof InvalidNotificationInputError) { + return leashError("INVALID_NOTIFICATION_INPUT", error.message, 400); + } + throw error; + } + + try { + const result = await listOwnerNotifications(getServerDatabase().db, { + ...query, + ownerId: authenticated.principal.userId, + }); + return NextResponse.json(result, { headers: LEASH_RESPONSE_HEADERS, status: 200 }); + } catch (error) { + return notFound(error); + } +} + +export async function PATCH(request: NextRequest) { + const originError = requireLeashMutationOrigin(request); + if (originError) return originError; + const authenticated = await owner(request); + if (authenticated.response) return authenticated.response; + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + + let mutation: ReturnType; + try { + mutation = parseNotificationMutation(body); + } catch (error) { + if (error instanceof InvalidNotificationInputError) { + return leashError("INVALID_NOTIFICATION_INPUT", error.message, 400); + } + throw error; + } + + const database = getServerDatabase().db; + const scope = { + agentId: mutation.agentId, + now: new Date(), + ownerId: authenticated.principal.userId, + }; + try { + const result = + mutation.action === "read_one" + ? await markOwnerNotificationRead(database, { + ...scope, + notificationId: mutation.notificationId, + }) + : await markAllOwnerNotificationsRead(database, scope); + return NextResponse.json(result, { headers: LEASH_RESPONSE_HEADERS, status: 200 }); + } catch (error) { + return notFound(error); + } +} diff --git a/apps/web/drizzle/0017_fine_black_tom.sql b/apps/web/drizzle/0017_fine_black_tom.sql new file mode 100644 index 0000000..e2c12ed --- /dev/null +++ b/apps/web/drizzle/0017_fine_black_tom.sql @@ -0,0 +1,75 @@ +CREATE TYPE "public"."notification_tier" AS ENUM('2', '3');--> statement-breakpoint +CREATE TYPE "public"."notification_type" AS ENUM('cap_75', 'cap_blocked', 'unusual_domain', 'cap_lowered_halt', 'float_low', 'float_empty');--> statement-breakpoint +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "agent_id" uuid NOT NULL, + "cycle_id" uuid NOT NULL, + "receipt_id" uuid, + "tier" "notification_tier" NOT NULL, + "type" "notification_type" NOT NULL, + "event_key" text NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "resource_host" text, + "sticky" boolean DEFAULT false NOT NULL, + "read_at" timestamp with time zone, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "notifications_tier_type_check" CHECK (("notifications"."tier" = '2' + and "notifications"."type" in ('cap_75', 'unusual_domain', 'float_low', 'float_empty')) + or ("notifications"."tier" = '3' + and "notifications"."type" in ('cap_blocked', 'cap_lowered_halt'))), + CONSTRAINT "notifications_sticky_check" CHECK (("notifications"."tier" = '2' and not "notifications"."sticky" and "notifications"."resolved_at" is null) + or ("notifications"."tier" = '3' and "notifications"."sticky")), + CONSTRAINT "notifications_resource_host_check" CHECK (("notifications"."type" = 'unusual_domain' and "notifications"."resource_host" is not null + and char_length("notifications"."resource_host") between 1 and 253 + and "notifications"."resource_host" = lower("notifications"."resource_host") + and "notifications"."resource_host" = btrim("notifications"."resource_host") + and "notifications"."resource_host" !~ '[/?#@[:space:]]') + or ("notifications"."type" <> 'unusual_domain' and "notifications"."resource_host" is null)), + CONSTRAINT "notifications_event_key_check" CHECK (char_length(btrim("notifications"."event_key")) between 1 and 300), + CONSTRAINT "notifications_metadata_check" CHECK (jsonb_typeof("notifications"."metadata") = 'object'), + CONSTRAINT "notifications_timestamps_check" CHECK (("notifications"."read_at" is null or "notifications"."read_at" >= "notifications"."created_at") + and ("notifications"."resolved_at" is null or "notifications"."resolved_at" >= "notifications"."created_at")) +); +--> statement-breakpoint +CREATE TABLE "push_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "owner_id" uuid NOT NULL, + "endpoint" text NOT NULL, + "p256dh" text NOT NULL, + "auth" text NOT NULL, + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone, + CONSTRAINT "push_subscriptions_endpoint_check" CHECK (char_length("push_subscriptions"."endpoint") between 1 and 2048 + and "push_subscriptions"."endpoint" ~ '^https://[^[:space:]]+$'), + CONSTRAINT "push_subscriptions_keys_check" CHECK (char_length(btrim("push_subscriptions"."p256dh")) between 1 and 512 + and char_length(btrim("push_subscriptions"."auth")) between 1 and 512), + CONSTRAINT "push_subscriptions_revoked_at_check" CHECK ("push_subscriptions"."revoked_at" is null or "push_subscriptions"."revoked_at" >= "push_subscriptions"."created_at") +); +--> statement-breakpoint +ALTER TABLE "receipts" ADD COLUMN "resource_url" text;--> statement-breakpoint +ALTER TABLE "receipts" ADD COLUMN "resource_host" text;--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_id_cycle_agent_unique" UNIQUE("id","cycle_id","agent_id");--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_cycle_agent_fk" FOREIGN KEY ("cycle_id","agent_id") REFERENCES "public"."cap_cycles"("id","agent_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_receipt_cycle_agent_fk" FOREIGN KEY ("receipt_id","cycle_id","agent_id") REFERENCES "public"."receipts"("id","cycle_id","agent_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "notifications_agent_event_key_unique" ON "notifications" USING btree ("agent_id","event_key");--> statement-breakpoint +CREATE UNIQUE INDEX "notifications_cap_75_cycle_unique" ON "notifications" USING btree ("agent_id","cycle_id") WHERE "notifications"."type" = 'cap_75';--> statement-breakpoint +CREATE UNIQUE INDEX "notifications_unusual_domain_unique" ON "notifications" USING btree ("agent_id","resource_host") WHERE "notifications"."type" = 'unusual_domain';--> statement-breakpoint +CREATE UNIQUE INDEX "notifications_one_active_cap_halt" ON "notifications" USING btree ("agent_id") WHERE "notifications"."resolved_at" is null + and "notifications"."type" in ('cap_blocked', 'cap_lowered_halt');--> statement-breakpoint +CREATE INDEX "notifications_agent_created_idx" ON "notifications" USING btree ("agent_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "notifications_agent_unread_idx" ON "notifications" USING btree ("agent_id","created_at" DESC NULLS LAST) WHERE "notifications"."read_at" is null;--> statement-breakpoint +CREATE INDEX "notifications_receipt_idx" ON "notifications" USING btree ("receipt_id") WHERE "notifications"."receipt_id" is not null;--> statement-breakpoint +CREATE UNIQUE INDEX "push_subscriptions_endpoint_unique" ON "push_subscriptions" USING btree ("endpoint");--> statement-breakpoint +CREATE INDEX "push_subscriptions_owner_active_idx" ON "push_subscriptions" USING btree ("owner_id","created_at" DESC NULLS LAST) WHERE "push_subscriptions"."revoked_at" is null;--> statement-breakpoint +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_resource_check" CHECK (("receipts"."resource_url" is null and "receipts"."resource_host" is null) + or ("receipts"."resource_url" is not null and "receipts"."resource_host" is not null + and char_length("receipts"."resource_url") between 1 and 2048 + and "receipts"."resource_url" ~ '^[A-Za-z][A-Za-z0-9+.-]*://[^[:space:]]+$' + and char_length("receipts"."resource_host") between 1 and 253 + and "receipts"."resource_host" = lower("receipts"."resource_host") + and "receipts"."resource_host" = btrim("receipts"."resource_host") + and "receipts"."resource_host" !~ '[/?#@[:space:]]')); diff --git a/apps/web/drizzle/0018_lucky_tusk.sql b/apps/web/drizzle/0018_lucky_tusk.sql new file mode 100644 index 0000000..daa5354 --- /dev/null +++ b/apps/web/drizzle/0018_lucky_tusk.sql @@ -0,0 +1,36 @@ +ALTER TABLE "notifications" DROP CONSTRAINT "notifications_resource_host_check";--> statement-breakpoint +DROP INDEX "notifications_unusual_domain_unique";--> statement-breakpoint +ALTER TABLE "notifications" ADD COLUMN "resource_key" text;--> statement-breakpoint +UPDATE "notifications" AS "notification" +SET "resource_key" = encode( + sha256( + convert_to( + CASE + WHEN "receipt"."resource_url" LIKE 'mcp://%' + THEN 'mcp-resource:' || "receipt"."resource_url" + ELSE 'http-host:' || "notification"."resource_host" + END, + 'UTF8' + ) + ), + 'hex' +) +FROM "receipts" AS "receipt" +WHERE "notification"."type" = 'unusual_domain' + AND "notification"."receipt_id" = "receipt"."id";--> statement-breakpoint +UPDATE "notifications" +SET "resource_key" = encode( + sha256(convert_to('http-host:' || "resource_host", 'UTF8')), + 'hex' +) +WHERE "type" = 'unusual_domain' AND "resource_key" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "notifications_unusual_resource_unique" ON "notifications" USING btree ("agent_id","resource_key") WHERE "notifications"."type" = 'unusual_domain';--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_resource_identity_check" CHECK (("notifications"."type" = 'unusual_domain' and "notifications"."resource_host" is not null + and char_length("notifications"."resource_host") between 1 and 253 + and "notifications"."resource_host" = lower("notifications"."resource_host") + and "notifications"."resource_host" = btrim("notifications"."resource_host") + and "notifications"."resource_host" !~ '[/?#@[:space:]]' + and "notifications"."resource_key" is not null + and "notifications"."resource_key" ~ '^[0-9a-f]{64}$') + or ("notifications"."type" <> 'unusual_domain' + and "notifications"."resource_host" is null and "notifications"."resource_key" is null)); diff --git a/apps/web/drizzle/0019_aspiring_frightful_four.sql b/apps/web/drizzle/0019_aspiring_frightful_four.sql new file mode 100644 index 0000000..c733b5e --- /dev/null +++ b/apps/web/drizzle/0019_aspiring_frightful_four.sql @@ -0,0 +1,2 @@ +ALTER TABLE "notifications" ALTER COLUMN "created_at" SET DATA TYPE timestamp (3) with time zone;--> statement-breakpoint +ALTER TABLE "notifications" ALTER COLUMN "created_at" SET DEFAULT now(); \ No newline at end of file diff --git a/apps/web/drizzle/meta/0017_snapshot.json b/apps/web/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000..482b90d --- /dev/null +++ b/apps/web/drizzle/meta/0017_snapshot.json @@ -0,0 +1,3284 @@ +{ + "id": "f7c32dd9-dbee-456f-ba59-5311a2cfbc8b", + "prevId": "8e03e866-5a49-4245-8457-19e2a96beb06", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "api_key_permissions", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_public_key_unique": { + "name": "api_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_secret_hash_unique": { + "name": "api_keys_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_one_active_publishable_per_env": { + "name": "api_keys_one_active_publishable_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"api_keys\".\"revoked_at\" is null and \"api_keys\".\"type\" = 'publishable'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_merchant_id_merchants_id_fk": { + "name": "api_keys_merchant_id_merchants_id_fk", + "tableFrom": "api_keys", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_rotated_from_id_api_keys_id_fk": { + "name": "api_keys_rotated_from_id_api_keys_id_fk", + "tableFrom": "api_keys", + "tableTo": "api_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_keys_material_check": { + "name": "api_keys_material_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"public_key\" is not null and \"api_keys\".\"secret_hash\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"public_key\" is null and \"api_keys\".\"secret_hash\" is not null\n and \"api_keys\".\"secret_hash\" ~ '^[0-9a-f]{64}$')" + }, + "api_keys_permissions_check": { + "name": "api_keys_permissions_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"permissions\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"permissions\" is not null)" + }, + "api_keys_prefix_check": { + "name": "api_keys_prefix_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'pk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'pk_live_')\n )) or (\"api_keys\".\"type\" = 'secret' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'sk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'sk_live_')\n ))" + }, + "api_keys_public_key_prefix_check": { + "name": "api_keys_public_key_prefix_check", + "value": "\"api_keys\".\"type\" = 'secret' or (\n left(\"api_keys\".\"public_key\", length(\"api_keys\".\"prefix\")) = \"api_keys\".\"prefix\"\n and \"api_keys\".\"public_key\" ~ '^pk_(test|live)_[A-Za-z0-9_-]+$'\n )" + } + }, + "isRLSEnabled": false + }, + "public.merchants": { + "name": "merchants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_etag": { + "name": "logo_etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_upload_count": { + "name": "logo_upload_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "logo_upload_window_started_at": { + "name": "logo_upload_window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "receiving_address": { + "name": "receiving_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "receiving_address_source": { + "name": "receiving_address_source", + "type": "receiving_address_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'magic_default'" + }, + "live_activated_at": { + "name": "live_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "merchants_user_id_unique": { + "name": "merchants_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merchants_user_id_users_id_fk": { + "name": "merchants_user_id_users_id_fk", + "tableFrom": "merchants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "merchants_receiving_address_check": { + "name": "merchants_receiving_address_check", + "value": "\"merchants\".\"receiving_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"merchants\".\"receiving_address\") <> '0x0000000000000000000000000000000000000000'" + } + }, + "isRLSEnabled": false + }, + "public.quickstart_progress": { + "name": "quickstart_progress", + "schema": "", + "columns": { + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "quickstart_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quickstart_progress_merchant_id_merchants_id_fk": { + "name": "quickstart_progress_merchant_id_merchants_id_fk", + "tableFrom": "quickstart_progress", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quickstart_progress_merchant_id_step_key_pk": { + "name": "quickstart_progress_merchant_id_step_key_pk", + "columns": ["merchant_id", "step_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "magic_issuer": { + "name": "magic_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_magic_issuer_unique": { + "name": "users_magic_issuer_unique", + "columns": [ + { + "expression": "magic_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "agent_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioned'" + }, + "signer_subject": { + "name": "signer_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_count": { + "name": "connection_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_signer_subject_unique": { + "name": "agents_signer_subject_unique", + "columns": [ + { + "expression": "signer_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_agent_address_unique": { + "name": "agents_agent_address_unique", + "columns": [ + { + "expression": "agent_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agents\".\"agent_address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_name_check": { + "name": "agents_name_check", + "value": "\"agents\".\"name\" ~ '[^[:space:]]'" + }, + "agents_signer_subject_check": { + "name": "agents_signer_subject_check", + "value": "\"agents\".\"signer_subject\" ~ '[^[:space:]]'" + }, + "agents_address_check": { + "name": "agents_address_check", + "value": "\"agents\".\"agent_address\" is null or (\"agents\".\"agent_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"agents\".\"agent_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "agents_connection_count_check": { + "name": "agents_connection_count_check", + "value": "\"agents\".\"connection_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.cap_cycles": { + "name": "cap_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reset_reason": { + "name": "reset_reason", + "type": "cap_reset_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cap_cycles_one_active_per_agent": { + "name": "cap_cycles_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cap_cycles\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "cap_cycles_agent_started_idx": { + "name": "cap_cycles_agent_started_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cap_cycles_agent_id_agents_id_fk": { + "name": "cap_cycles_agent_id_agents_id_fk", + "tableFrom": "cap_cycles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cap_cycles_id_agent_unique": { + "name": "cap_cycles_id_agent_unique", + "nullsNotDistinct": false, + "columns": ["id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "cap_cycles_end_check": { + "name": "cap_cycles_end_check", + "value": "(\"cap_cycles\".\"ended_at\" is null and \"cap_cycles\".\"reset_reason\" is null)\n or (\"cap_cycles\".\"ended_at\" > \"cap_cycles\".\"started_at\" and \"cap_cycles\".\"reset_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.caps": { + "name": "caps", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "amount_usd_cents": { + "name": "amount_usd_cents", + "type": "numeric(20, 0)", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "cap_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "caps_agent_id_agents_id_fk": { + "name": "caps_agent_id_agents_id_fk", + "tableFrom": "caps", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "caps_amount_check": { + "name": "caps_amount_check", + "value": "\"caps\".\"amount_usd_cents\" is null or \"caps\".\"amount_usd_cents\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.leash_keys": { + "name": "leash_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_at": { + "name": "last_auth_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_code": { + "name": "last_auth_failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "leash_keys_hashed_key_unique": { + "name": "leash_keys_hashed_key_unique", + "columns": [ + { + "expression": "hashed_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leash_keys_one_active_per_agent": { + "name": "leash_keys_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leash_keys\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leash_keys_agent_id_agents_id_fk": { + "name": "leash_keys_agent_id_agents_id_fk", + "tableFrom": "leash_keys", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leash_keys_rotated_from_id_leash_keys_id_fk": { + "name": "leash_keys_rotated_from_id_leash_keys_id_fk", + "tableFrom": "leash_keys", + "tableTo": "leash_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "leash_keys_hash_check": { + "name": "leash_keys_hash_check", + "value": "\"leash_keys\".\"hashed_key\" ~ '^[0-9a-f]{64}$'" + }, + "leash_keys_prefix_check": { + "name": "leash_keys_prefix_check", + "value": "\"leash_keys\".\"prefix\" = 'leash_sk_'" + }, + "leash_keys_last4_check": { + "name": "leash_keys_last4_check", + "value": "\"leash_keys\".\"last4\" ~ '^[A-Za-z0-9_-]{4}$'" + } + }, + "isRLSEnabled": false + }, + "public.agent_events": { + "name": "agent_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_surface": { + "name": "actor_surface", + "type": "agent_event_surface", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_events_agent_created_idx": { + "name": "agent_events_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_events_agent_id_agents_id_fk": { + "name": "agent_events_agent_id_agents_id_fk", + "tableFrom": "agent_events", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_events_metadata_check": { + "name": "agent_events_metadata_check", + "value": "jsonb_typeof(\"agent_events\".\"metadata\") = 'object'" + } + }, + "isRLSEnabled": false + }, + "public.floats": { + "name": "floats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "leash_asset", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "balance_atomic": { + "name": "balance_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "balance_usd": { + "name": "balance_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "floats_agent_network_unique": { + "name": "floats_agent_network_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "floats_agent_id_agents_id_fk": { + "name": "floats_agent_id_agents_id_fk", + "tableFrom": "floats", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "floats_balance_atomic_check": { + "name": "floats_balance_atomic_check", + "value": "\"floats\".\"balance_atomic\" >= 0 and \"floats\".\"balance_atomic\" = trunc(\"floats\".\"balance_atomic\")" + }, + "floats_balance_usd_check": { + "name": "floats_balance_usd_check", + "value": "\"floats\".\"balance_usd\" >= 0" + }, + "floats_native_usdc_check": { + "name": "floats_native_usdc_check", + "value": "(\"floats\".\"network\" = 'eip155:8453'\n and lower(\"floats\".\"token_address\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"floats\".\"network\" = 'eip155:42161'\n and lower(\"floats\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + } + }, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "notification_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "resource_host": { + "name": "resource_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sticky": { + "name": "sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_agent_event_key_unique": { + "name": "notifications_agent_event_key_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_cap_75_cycle_unique": { + "name": "notifications_cap_75_cycle_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"type\" = 'cap_75'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_unusual_domain_unique": { + "name": "notifications_unusual_domain_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"type\" = 'unusual_domain'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_one_active_cap_halt": { + "name": "notifications_one_active_cap_halt", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"resolved_at\" is null\n and \"notifications\".\"type\" in ('cap_blocked', 'cap_lowered_halt')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_agent_created_idx": { + "name": "notifications_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_agent_unread_idx": { + "name": "notifications_agent_unread_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notifications\".\"read_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_receipt_idx": { + "name": "notifications_receipt_idx", + "columns": [ + { + "expression": "receipt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notifications\".\"receipt_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_agent_id_agents_id_fk": { + "name": "notifications_agent_id_agents_id_fk", + "tableFrom": "notifications", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_cycle_agent_fk": { + "name": "notifications_cycle_agent_fk", + "tableFrom": "notifications", + "tableTo": "cap_cycles", + "columnsFrom": ["cycle_id", "agent_id"], + "columnsTo": ["id", "agent_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_receipt_cycle_agent_fk": { + "name": "notifications_receipt_cycle_agent_fk", + "tableFrom": "notifications", + "tableTo": "receipts", + "columnsFrom": ["receipt_id", "cycle_id", "agent_id"], + "columnsTo": ["id", "cycle_id", "agent_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notifications_tier_type_check": { + "name": "notifications_tier_type_check", + "value": "(\"notifications\".\"tier\" = '2'\n and \"notifications\".\"type\" in ('cap_75', 'unusual_domain', 'float_low', 'float_empty'))\n or (\"notifications\".\"tier\" = '3'\n and \"notifications\".\"type\" in ('cap_blocked', 'cap_lowered_halt'))" + }, + "notifications_sticky_check": { + "name": "notifications_sticky_check", + "value": "(\"notifications\".\"tier\" = '2' and not \"notifications\".\"sticky\" and \"notifications\".\"resolved_at\" is null)\n or (\"notifications\".\"tier\" = '3' and \"notifications\".\"sticky\")" + }, + "notifications_resource_host_check": { + "name": "notifications_resource_host_check", + "value": "(\"notifications\".\"type\" = 'unusual_domain' and \"notifications\".\"resource_host\" is not null\n and char_length(\"notifications\".\"resource_host\") between 1 and 253\n and \"notifications\".\"resource_host\" = lower(\"notifications\".\"resource_host\")\n and \"notifications\".\"resource_host\" = btrim(\"notifications\".\"resource_host\")\n and \"notifications\".\"resource_host\" !~ '[/?#@[:space:]]')\n or (\"notifications\".\"type\" <> 'unusual_domain' and \"notifications\".\"resource_host\" is null)" + }, + "notifications_event_key_check": { + "name": "notifications_event_key_check", + "value": "char_length(btrim(\"notifications\".\"event_key\")) between 1 and 300" + }, + "notifications_metadata_check": { + "name": "notifications_metadata_check", + "value": "jsonb_typeof(\"notifications\".\"metadata\") = 'object'" + }, + "notifications_timestamps_check": { + "name": "notifications_timestamps_check", + "value": "(\"notifications\".\"read_at\" is null or \"notifications\".\"read_at\" >= \"notifications\".\"created_at\")\n and (\"notifications\".\"resolved_at\" is null or \"notifications\".\"resolved_at\" >= \"notifications\".\"created_at\")" + } + }, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscriptions_owner_active_idx": { + "name": "push_subscriptions_owner_active_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"push_subscriptions\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_owner_id_users_id_fk": { + "name": "push_subscriptions_owner_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "push_subscriptions_endpoint_check": { + "name": "push_subscriptions_endpoint_check", + "value": "char_length(\"push_subscriptions\".\"endpoint\") between 1 and 2048\n and \"push_subscriptions\".\"endpoint\" ~ '^https://[^[:space:]]+$'" + }, + "push_subscriptions_keys_check": { + "name": "push_subscriptions_keys_check", + "value": "char_length(btrim(\"push_subscriptions\".\"p256dh\")) between 1 and 512\n and char_length(btrim(\"push_subscriptions\".\"auth\")) between 1 and 512" + }, + "push_subscriptions_revoked_at_check": { + "name": "push_subscriptions_revoked_at_check", + "value": "\"push_subscriptions\".\"revoked_at\" is null or \"push_subscriptions\".\"revoked_at\" >= \"push_subscriptions\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.receipts": { + "name": "receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "receipt_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "intended_network": { + "name": "intended_network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "pay_to": { + "name": "pay_to", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "resource_url": { + "name": "resource_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_host": { + "name": "resource_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_nonce": { + "name": "authorization_nonce", + "type": "varchar(66)", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "authorization_valid_before": { + "name": "authorization_valid_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settlement_response": { + "name": "settlement_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "receipts_agent_nonce_unique": { + "name": "receipts_agent_nonce_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "authorization_nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_fingerprint_unique": { + "name": "receipts_agent_fingerprint_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_network_tx_hash_unique": { + "name": "receipts_network_tx_hash_unique", + "columns": [ + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"receipts\".\"tx_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_cap_gate_idx": { + "name": "receipts_cap_gate_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_created_idx": { + "name": "receipts_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "receipts_agent_id_agents_id_fk": { + "name": "receipts_agent_id_agents_id_fk", + "tableFrom": "receipts", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "receipts_parent_id_receipts_id_fk": { + "name": "receipts_parent_id_receipts_id_fk", + "tableFrom": "receipts", + "tableTo": "receipts", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "receipts_cycle_agent_fk": { + "name": "receipts_cycle_agent_fk", + "tableFrom": "receipts", + "tableTo": "cap_cycles", + "columnsFrom": ["cycle_id", "agent_id"], + "columnsTo": ["id", "agent_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "receipts_id_cycle_agent_unique": { + "name": "receipts_id_cycle_agent_unique", + "nullsNotDistinct": false, + "columns": ["id", "cycle_id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "receipts_amount_atomic_check": { + "name": "receipts_amount_atomic_check", + "value": "\"receipts\".\"amount_atomic\" > 0 and \"receipts\".\"amount_atomic\" = trunc(\"receipts\".\"amount_atomic\")" + }, + "receipts_amount_usd_check": { + "name": "receipts_amount_usd_check", + "value": "\"receipts\".\"amount_usd\" > 0" + }, + "receipts_pay_to_check": { + "name": "receipts_pay_to_check", + "value": "\"receipts\".\"pay_to\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"receipts\".\"pay_to\") <> '0x0000000000000000000000000000000000000000'" + }, + "receipts_resource_check": { + "name": "receipts_resource_check", + "value": "(\"receipts\".\"resource_url\" is null and \"receipts\".\"resource_host\" is null)\n or (\"receipts\".\"resource_url\" is not null and \"receipts\".\"resource_host\" is not null\n and char_length(\"receipts\".\"resource_url\") between 1 and 2048\n and \"receipts\".\"resource_url\" ~ '^[A-Za-z][A-Za-z0-9+.-]*://[^[:space:]]+$'\n and char_length(\"receipts\".\"resource_host\") between 1 and 253\n and \"receipts\".\"resource_host\" = lower(\"receipts\".\"resource_host\")\n and \"receipts\".\"resource_host\" = btrim(\"receipts\".\"resource_host\")\n and \"receipts\".\"resource_host\" !~ '[/?#@[:space:]]')" + }, + "receipts_native_usdc_check": { + "name": "receipts_native_usdc_check", + "value": "(\"receipts\".\"network\" = 'eip155:8453'\n and lower(\"receipts\".\"asset\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"receipts\".\"network\" = 'eip155:42161'\n and lower(\"receipts\".\"asset\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + }, + "receipts_authorization_check": { + "name": "receipts_authorization_check", + "value": "\"receipts\".\"authorization_nonce\" ~ '^0x[0-9a-f]{64}$'\n and \"receipts\".\"request_fingerprint\" ~ '^[0-9a-f]{64}$'" + }, + "receipts_origin_check": { + "name": "receipts_origin_check", + "value": "\"receipts\".\"origin\" is null or (jsonb_typeof(\"receipts\".\"origin\") = 'object'\n and \"receipts\".\"origin\" ? 'transport'\n and \"receipts\".\"origin\"->>'transport' in ('mcp', 'http'))" + }, + "receipts_tx_hash_check": { + "name": "receipts_tx_hash_check", + "value": "\"receipts\".\"tx_hash\" is null or \"receipts\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "receipts_state_check": { + "name": "receipts_state_check", + "value": "(\"receipts\".\"status\" = 'pending' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is null\n and \"receipts\".\"settlement_response\" is null and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'settled' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is not null\n and \"receipts\".\"settlement_response\" is not null and \"receipts\".\"settled_at\" is not null)\n or (\"receipts\".\"status\" = 'failed' and \"receipts\".\"reason\" is not null\n and \"receipts\".\"reason\" ~ '[^[:space:]]' and \"receipts\".\"intended_network\" is null\n and \"receipts\".\"tx_hash\" is null and \"receipts\".\"settlement_response\" is null\n and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'blocked' and \"receipts\".\"reason\" is not null\n and \"receipts\".\"reason\" ~ '[^[:space:]]' and \"receipts\".\"intended_network\" is not null\n and \"receipts\".\"tx_hash\" is null and \"receipts\".\"settlement_response\" is null\n and \"receipts\".\"settled_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order_number": { + "name": "order_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_ref": { + "name": "payment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orders_payment_ref_unique": { + "name": "orders_payment_ref_unique", + "columns": [ + { + "expression": "payment_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_merchant_env_number_unique": { + "name": "orders_merchant_env_number_unique", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_payment_merchant_env_fk": { + "name": "orders_payment_merchant_env_fk", + "tableFrom": "orders", + "tableTo": "payments", + "columnsFrom": ["payment_ref", "merchant_id", "env"], + "columnsTo": ["ref_code", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "orders_order_number_check": { + "name": "orders_order_number_check", + "value": "\"orders\".\"order_number\" ~ '[^[:space:]]'" + }, + "orders_payment_ref_check": { + "name": "orders_payment_ref_check", + "value": "\"orders\".\"payment_ref\" ~ '^TAB-[A-Z0-9]+$'" + } + }, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ref_code": { + "name": "ref_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "receiver": { + "name": "receiver", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_chain_id": { + "name": "token_chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "intent_url": { + "name": "intent_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_type": { + "name": "payer_type", + "type": "payer_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "payer_email": { + "name": "payer_email", + "type": "citext", + "primaryKey": false, + "notNull": false + }, + "payer_address": { + "name": "payer_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_transaction_id": { + "name": "reported_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_token_changes": { + "name": "reported_token_changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_next_attempt_at": { + "name": "verification_next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_lease_token": { + "name": "verification_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verification_lease_expires_at": { + "name": "verification_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_ref_code_unique": { + "name": "payments_ref_code_unique", + "columns": [ + { + "expression": "ref_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_settlement_evidence_unique": { + "name": "payments_settlement_evidence_unique", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "livemode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_reported_transaction_id_idx": { + "name": "payments_reported_transaction_id_idx", + "columns": [ + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_merchant_env_created_idx": { + "name": "payments_merchant_env_created_idx", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_pending_sweep_idx": { + "name": "payments_pending_sweep_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_verification_sweep_idx": { + "name": "payments_verification_sweep_idx", + "columns": [ + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verification_next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_merchant_id_merchants_id_fk": { + "name": "payments_merchant_id_merchants_id_fk", + "tableFrom": "payments", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_id_merchant_env_unique": { + "name": "payments_id_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "payments_ref_merchant_env_unique": { + "name": "payments_ref_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["ref_code", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "payments_ref_code_check": { + "name": "payments_ref_code_check", + "value": "\"payments\".\"ref_code\" ~ '^TAB-[A-Z0-9]+$'" + }, + "payments_amount_check": { + "name": "payments_amount_check", + "value": "\"payments\".\"amount_usd\" > 0 and \"payments\".\"amount_usd\" < 100000000000000\n and scale(\"payments\".\"amount_usd\") <= 6" + }, + "payments_currency_check": { + "name": "payments_currency_check", + "value": "\"payments\".\"currency\" = 'USD'" + }, + "payments_chain_check": { + "name": "payments_chain_check", + "value": "\"payments\".\"token_chain_id\" = 42161" + }, + "payments_token_check": { + "name": "payments_token_check", + "value": "lower(\"payments\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831'" + }, + "payments_receiver_check": { + "name": "payments_receiver_check", + "value": "\"payments\".\"receiver\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"receiver\") <> '0x0000000000000000000000000000000000000000'" + }, + "payments_payer_address_check": { + "name": "payments_payer_address_check", + "value": "\"payments\".\"payer_address\" is null or (\"payments\".\"payer_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"payer_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "payments_livemode_check": { + "name": "payments_livemode_check", + "value": "(\"payments\".\"env\" = 'live' and \"payments\".\"livemode\")\n or (\"payments\".\"env\" = 'test' and not \"payments\".\"livemode\")" + }, + "payments_report_check": { + "name": "payments_report_check", + "value": "(\"payments\".\"reported_transaction_id\" is null and \"payments\".\"reported_token_changes\" is null and \"payments\".\"reported_at\" is null)\n or (\"payments\".\"reported_transaction_id\" is not null and btrim(\"payments\".\"reported_transaction_id\") <> ''\n and \"payments\".\"reported_token_changes\" is not null\n and jsonb_typeof(\"payments\".\"reported_token_changes\") = 'array' and \"payments\".\"reported_at\" is not null)" + }, + "payments_settled_at_check": { + "name": "payments_settled_at_check", + "value": "(\"payments\".\"status\" = 'settled' and \"payments\".\"settled_at\" is not null)\n or (\"payments\".\"status\" <> 'settled' and \"payments\".\"settled_at\" is null)" + }, + "payments_verification_lease_check": { + "name": "payments_verification_lease_check", + "value": "(\"payments\".\"verification_lease_token\" is null and \"payments\".\"verification_lease_expires_at\" is null)\n or (\"payments\".\"verification_lease_token\" is not null\n and \"payments\".\"verification_lease_expires_at\" is not null and \"payments\".\"env\" = 'live'\n and \"payments\".\"status\" = 'pending' and \"payments\".\"reported_at\" is not null\n and \"payments\".\"reported_transaction_id\" is not null and \"payments\".\"payer_address\" is not null)" + }, + "payments_verification_schedule_check": { + "name": "payments_verification_schedule_check", + "value": "\"payments\".\"verification_next_attempt_at\" is null\n or (\"payments\".\"env\" = 'live' and \"payments\".\"reported_at\" is not null)" + }, + "payments_failure_reason_check": { + "name": "payments_failure_reason_check", + "value": "(\"payments\".\"status\" = 'failed' and \"payments\".\"failure_reason\" is not null)\n or (\"payments\".\"status\" <> 'failed' and \"payments\".\"failure_reason\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "particle_transaction_id": { + "name": "particle_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "token_changes_json": { + "name": "token_changes_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "verification_method": { + "name": "verification_method", + "type": "settlement_verification_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "verification_trigger": { + "name": "verification_trigger", + "type": "settlement_verification_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlements_payment_id_unique": { + "name": "settlements_payment_id_unique", + "columns": [ + { + "expression": "payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_particle_transaction_id_unique": { + "name": "settlements_particle_transaction_id_unique", + "columns": [ + { + "expression": "particle_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_payment_evidence_fk": { + "name": "settlements_payment_evidence_fk", + "tableFrom": "settlements", + "tableTo": "payments", + "columnsFrom": ["payment_id", "particle_transaction_id", "livemode"], + "columnsTo": ["id", "reported_transaction_id", "livemode"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settlements_id_payment_unique": { + "name": "settlements_id_payment_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id"] + } + }, + "policies": {}, + "checkConstraints": { + "settlements_amount_atomic_check": { + "name": "settlements_amount_atomic_check", + "value": "\"settlements\".\"amount_atomic\" > 0 and \"settlements\".\"amount_atomic\" = trunc(\"settlements\".\"amount_atomic\")\n and \"settlements\".\"amount_atomic\" < 1000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "settlements_token_changes_check": { + "name": "settlements_token_changes_check", + "value": "jsonb_typeof(\"settlements\".\"token_changes_json\") = 'array'" + }, + "settlements_tx_hash_check": { + "name": "settlements_tx_hash_check", + "value": "\"settlements\".\"tx_hash\" is null or \"settlements\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "settlements_simulation_check": { + "name": "settlements_simulation_check", + "value": "(\"settlements\".\"verification_method\" = 'simulated_test' and not \"settlements\".\"livemode\")\n or (\"settlements\".\"verification_method\" <> 'simulated_test' and \"settlements\".\"livemode\")" + } + }, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retry_chain_id": { + "name": "retry_chain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_body_hash": { + "name": "request_body_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "encode(digest(\"webhook_deliveries\".\"request_body\", 'sha256'), 'hex')", + "type": "stored" + } + }, + "type": { + "name": "type", + "type": "webhook_delivery_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "webhook_delivery_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "webhook_delivery_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_kind": { + "name": "failure_kind", + "type": "webhook_failure_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "signature_header": { + "name": "signature_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body_snippet": { + "name": "response_body_snippet", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_delivery_id": { + "name": "parent_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_attempt": { + "name": "parent_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "superseded_by_attempt": { + "name": "superseded_by_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_deliveries_chain_attempt_unique": { + "name": "webhook_deliveries_chain_attempt_unique", + "columns": [ + { + "expression": "retry_chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_automatic_settlement_root_unique": { + "name": "webhook_deliveries_automatic_settlement_root_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"attempt\" = 1", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_dashboard_head_idx": { + "name": "webhook_deliveries_dashboard_head_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment'\n and \"webhook_deliveries\".\"superseded_by_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_due_idx": { + "name": "webhook_deliveries_due_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_event_idx": { + "name": "webhook_deliveries_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_deliveries_endpoint_scope_fk": { + "name": "webhook_deliveries_endpoint_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_endpoints", + "columnsFrom": ["endpoint_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_payment_scope_fk": { + "name": "webhook_deliveries_payment_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "payments", + "columnsFrom": ["payment_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_settlement_payment_fk": { + "name": "webhook_deliveries_settlement_payment_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "settlements", + "columnsFrom": ["settlement_id", "payment_id"], + "columnsTo": ["id", "payment_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_scope_fk": { + "name": "webhook_deliveries_chain_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "retry_chain_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_evidence_fk": { + "name": "webhook_deliveries_chain_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["retry_chain_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_scope_fk": { + "name": "webhook_deliveries_parent_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "parent_delivery_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_evidence_fk": { + "name": "webhook_deliveries_parent_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_sequence_fk": { + "name": "webhook_deliveries_parent_sequence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "retry_chain_id", "parent_attempt"], + "columnsTo": ["id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_successor_scope_fk": { + "name": "webhook_deliveries_successor_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["superseded_by_id", "id", "retry_chain_id", "superseded_by_attempt"], + "columnsTo": ["id", "parent_delivery_id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_id_chain_scope_unique": { + "name": "webhook_deliveries_id_chain_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ] + }, + "webhook_deliveries_id_tenant_unique": { + "name": "webhook_deliveries_id_tenant_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "webhook_deliveries_id_event_scope_unique": { + "name": "webhook_deliveries_id_event_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ] + }, + "webhook_deliveries_id_evidence_unique": { + "name": "webhook_deliveries_id_evidence_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id", "settlement_id"] + }, + "webhook_deliveries_id_retry_attempt_unique": { + "name": "webhook_deliveries_id_retry_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "retry_chain_id", "attempt"] + }, + "webhook_deliveries_id_parent_chain_attempt_unique": { + "name": "webhook_deliveries_id_parent_chain_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "parent_delivery_id", "retry_chain_id", "attempt"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_deliveries_event_id_check": { + "name": "webhook_deliveries_event_id_check", + "value": "\"webhook_deliveries\".\"event_id\" ~ '^evt_[A-Za-z0-9_-]+$'" + }, + "webhook_deliveries_body_hash_check": { + "name": "webhook_deliveries_body_hash_check", + "value": "\"webhook_deliveries\".\"request_body_hash\" ~ '^[0-9a-f]{64}$'" + }, + "webhook_deliveries_chain_root_check": { + "name": "webhook_deliveries_chain_root_check", + "value": "((\"webhook_deliveries\".\"attempt\" = 1 and \"webhook_deliveries\".\"retry_chain_id\" = \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_attempt\" is null)\n or (\"webhook_deliveries\".\"attempt\" > 1 and \"webhook_deliveries\".\"retry_chain_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_delivery_id\" is not null\n and \"webhook_deliveries\".\"parent_attempt\" = \"webhook_deliveries\".\"attempt\" - 1))\n and (\"webhook_deliveries\".\"parent_delivery_id\" is null or \"webhook_deliveries\".\"parent_delivery_id\" <> \"webhook_deliveries\".\"id\")" + }, + "webhook_deliveries_attempt_check": { + "name": "webhook_deliveries_attempt_check", + "value": "\"webhook_deliveries\".\"attempt\" between 1 and 3" + }, + "webhook_deliveries_type_check": { + "name": "webhook_deliveries_type_check", + "value": "(\"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"payment_id\" is not null\n and \"webhook_deliveries\".\"settlement_id\" is not null)\n or (\"webhook_deliveries\".\"type\" = 'test' and \"webhook_deliveries\".\"payment_id\" is null\n and \"webhook_deliveries\".\"settlement_id\" is null)" + }, + "webhook_deliveries_status_code_check": { + "name": "webhook_deliveries_status_code_check", + "value": "\"webhook_deliveries\".\"status_code\" is null or \"webhook_deliveries\".\"status_code\" between 100 and 599" + }, + "webhook_deliveries_response_time_check": { + "name": "webhook_deliveries_response_time_check", + "value": "\"webhook_deliveries\".\"response_time_ms\" is null or \"webhook_deliveries\".\"response_time_ms\" >= 0" + }, + "webhook_deliveries_signature_check": { + "name": "webhook_deliveries_signature_check", + "value": "\"webhook_deliveries\".\"signature_header\" is null\n or \"webhook_deliveries\".\"signature_header\" ~ '^t=[0-9]+,v1=[0-9a-f]{64}$'" + }, + "webhook_deliveries_lease_check": { + "name": "webhook_deliveries_lease_check", + "value": "(\"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null)\n or (\"webhook_deliveries\".\"lease_token\" is not null and \"webhook_deliveries\".\"lease_expires_at\" is not null)" + }, + "webhook_deliveries_successor_check": { + "name": "webhook_deliveries_successor_check", + "value": "(\"webhook_deliveries\".\"superseded_by_id\" is null and \"webhook_deliveries\".\"superseded_by_attempt\" is null)\n or (\"webhook_deliveries\".\"superseded_by_id\" is not null\n and \"webhook_deliveries\".\"superseded_by_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"superseded_by_attempt\" = \"webhook_deliveries\".\"attempt\" + 1\n and \"webhook_deliveries\".\"superseded_by_attempt\" between 2 and 3)" + }, + "webhook_deliveries_result_check": { + "name": "webhook_deliveries_result_check", + "value": "coalesce(((\"webhook_deliveries\".\"result\" = 'pending' and \"webhook_deliveries\".\"completed_at\" is null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" is null and \"webhook_deliveries\".\"response_time_ms\" is null\n and \"webhook_deliveries\".\"response_body_snippet\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'delivered' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" between 200 and 299\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'retrying' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is not null and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" in ('failed', 'timeout') and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is not null\n and ((\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network'))\n or (\"webhook_deliveries\".\"result\" = 'timeout' and \"webhook_deliveries\".\"failure_kind\" = 'timeout'))\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" = 'configuration'\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is null and \"webhook_deliveries\".\"status_code\" is null\n and \"webhook_deliveries\".\"response_time_ms\" is null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'gave_up' and \"webhook_deliveries\".\"attempt\" = 3\n and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is null and \"webhook_deliveries\".\"signature_header\" is not null\n and \"webhook_deliveries\".\"started_at\" is not null and \"webhook_deliveries\".\"response_time_ms\" is not null\n and \"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null\n and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))), false)" + } + }, + "isRLSEnabled": false + }, + "public.webhook_endpoints": { + "name": "webhook_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_nonce": { + "name": "secret_nonce", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "secret_auth_tag": { + "name": "secret_auth_tag", + "type": "varchar(22)", + "primaryKey": false, + "notNull": false + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_last4": { + "name": "secret_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_endpoints_one_active_per_env": { + "name": "webhook_endpoints_one_active_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_endpoints\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_endpoints_merchant_id_merchants_id_fk": { + "name": "webhook_endpoints_merchant_id_merchants_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_endpoints_id_scope_unique": { + "name": "webhook_endpoints_id_scope_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_endpoints_url_check": { + "name": "webhook_endpoints_url_check", + "value": "char_length(btrim(\"webhook_endpoints\".\"url\")) between 1 and 2048\n and (\"webhook_endpoints\".\"url\" ~ '^https://[^/?#[:space:]@]+([:/?#]|$)'\n or (\"webhook_endpoints\".\"env\" = 'test'\n and \"webhook_endpoints\".\"url\" ~ '^http://(127\\.0\\.0\\.1|\\[::1\\]|localhost)(:[0-9]+)?/'))\n and \"webhook_endpoints\".\"url\" !~* '^https://(localhost\\.?|127(\\.[0-9]{1,3}){3}|10(\\.[0-9]{1,3}){3}|192\\.168(\\.[0-9]{1,3}){2}|169\\.254(\\.[0-9]{1,3}){2}|172\\.(1[6-9]|2[0-9]|3[01])(\\.[0-9]{1,3}){2}|\\[(::1|f[cd][0-9a-f:]*|fe[89ab][0-9a-f:]*)\\])([:/?#]|$)'" + }, + "webhook_endpoints_last4_check": { + "name": "webhook_endpoints_last4_check", + "value": "char_length(\"webhook_endpoints\".\"secret_last4\") = 4" + }, + "webhook_endpoints_secret_envelope_check": { + "name": "webhook_endpoints_secret_envelope_check", + "value": "coalesce(((\"webhook_endpoints\".\"deleted_at\" is null\n and \"webhook_endpoints\".\"secret_ciphertext\" is not null\n and char_length(\"webhook_endpoints\".\"secret_ciphertext\") > 0\n and \"webhook_endpoints\".\"secret_nonce\" is not null\n and \"webhook_endpoints\".\"secret_nonce\" ~ '^[A-Za-z0-9_-]{16}$'\n and \"webhook_endpoints\".\"secret_auth_tag\" is not null\n and \"webhook_endpoints\".\"secret_auth_tag\" ~ '^[A-Za-z0-9_-]{22}$'\n and \"webhook_endpoints\".\"secret_key_version\" is not null\n and \"webhook_endpoints\".\"secret_key_version\" > 0)\n or (\"webhook_endpoints\".\"deleted_at\" is not null\n and \"webhook_endpoints\".\"secret_ciphertext\" is null and \"webhook_endpoints\".\"secret_nonce\" is null\n and \"webhook_endpoints\".\"secret_auth_tag\" is null and \"webhook_endpoints\".\"secret_key_version\" is null)), false)" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.api_key_permissions": { + "name": "api_key_permissions", + "schema": "public", + "values": ["full", "read_only"] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": ["secret", "publishable"] + }, + "public.environment": { + "name": "environment", + "schema": "public", + "values": ["test", "live"] + }, + "public.quickstart_source": { + "name": "quickstart_source", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.receiving_address_source": { + "name": "receiving_address_source", + "schema": "public", + "values": ["magic_default", "custom"] + }, + "public.agent_status": { + "name": "agent_status", + "schema": "public", + "values": ["provisioned", "paused", "frozen", "cancelled", "nuked"] + }, + "public.cap_frequency": { + "name": "cap_frequency", + "schema": "public", + "values": ["daily", "weekly", "monthly", "never"] + }, + "public.cap_reset_reason": { + "name": "cap_reset_reason", + "schema": "public", + "values": ["schedule", "manual", "frequency_change"] + }, + "public.leash_network": { + "name": "leash_network", + "schema": "public", + "values": ["eip155:8453", "eip155:42161"] + }, + "public.agent_event_surface": { + "name": "agent_event_surface", + "schema": "public", + "values": ["agent", "web", "pwa", "push_action", "system"] + }, + "public.agent_event_type": { + "name": "agent_event_type", + "schema": "public", + "values": ["connect", "sign", "block", "revoke"] + }, + "public.leash_asset": { + "name": "leash_asset", + "schema": "public", + "values": ["USDC"] + }, + "public.notification_tier": { + "name": "notification_tier", + "schema": "public", + "values": ["2", "3"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "cap_75", + "cap_blocked", + "unusual_domain", + "cap_lowered_halt", + "float_low", + "float_empty" + ] + }, + "public.receipt_status": { + "name": "receipt_status", + "schema": "public", + "values": ["pending", "settled", "failed", "blocked"] + }, + "public.payer_type": { + "name": "payer_type", + "schema": "public", + "values": ["human", "agent"] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": ["pending", "settled", "failed"] + }, + "public.settlement_verification_method": { + "name": "settlement_verification_method", + "schema": "public", + "values": ["rpc", "particle", "x402_receipt", "simulated_test"] + }, + "public.settlement_verification_trigger": { + "name": "settlement_verification_trigger", + "schema": "public", + "values": ["inline", "cron_sweep"] + }, + "public.webhook_delivery_result": { + "name": "webhook_delivery_result", + "schema": "public", + "values": ["pending", "delivered", "retrying", "failed", "timeout", "gave_up"] + }, + "public.webhook_delivery_trigger": { + "name": "webhook_delivery_trigger", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.webhook_delivery_type": { + "name": "webhook_delivery_type", + "schema": "public", + "values": ["payment", "test"] + }, + "public.webhook_failure_kind": { + "name": "webhook_failure_kind", + "schema": "public", + "values": ["http", "network", "timeout", "configuration"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/0018_snapshot.json b/apps/web/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..d6bcec4 --- /dev/null +++ b/apps/web/drizzle/meta/0018_snapshot.json @@ -0,0 +1,3290 @@ +{ + "id": "22c7c44b-df4d-48ae-bb9c-09c85ba4f4eb", + "prevId": "f7c32dd9-dbee-456f-ba59-5311a2cfbc8b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "api_key_permissions", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_public_key_unique": { + "name": "api_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_secret_hash_unique": { + "name": "api_keys_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_one_active_publishable_per_env": { + "name": "api_keys_one_active_publishable_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"api_keys\".\"revoked_at\" is null and \"api_keys\".\"type\" = 'publishable'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_merchant_id_merchants_id_fk": { + "name": "api_keys_merchant_id_merchants_id_fk", + "tableFrom": "api_keys", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_rotated_from_id_api_keys_id_fk": { + "name": "api_keys_rotated_from_id_api_keys_id_fk", + "tableFrom": "api_keys", + "tableTo": "api_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_keys_material_check": { + "name": "api_keys_material_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"public_key\" is not null and \"api_keys\".\"secret_hash\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"public_key\" is null and \"api_keys\".\"secret_hash\" is not null\n and \"api_keys\".\"secret_hash\" ~ '^[0-9a-f]{64}$')" + }, + "api_keys_permissions_check": { + "name": "api_keys_permissions_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"permissions\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"permissions\" is not null)" + }, + "api_keys_prefix_check": { + "name": "api_keys_prefix_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'pk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'pk_live_')\n )) or (\"api_keys\".\"type\" = 'secret' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'sk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'sk_live_')\n ))" + }, + "api_keys_public_key_prefix_check": { + "name": "api_keys_public_key_prefix_check", + "value": "\"api_keys\".\"type\" = 'secret' or (\n left(\"api_keys\".\"public_key\", length(\"api_keys\".\"prefix\")) = \"api_keys\".\"prefix\"\n and \"api_keys\".\"public_key\" ~ '^pk_(test|live)_[A-Za-z0-9_-]+$'\n )" + } + }, + "isRLSEnabled": false + }, + "public.merchants": { + "name": "merchants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_etag": { + "name": "logo_etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_upload_count": { + "name": "logo_upload_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "logo_upload_window_started_at": { + "name": "logo_upload_window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "receiving_address": { + "name": "receiving_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "receiving_address_source": { + "name": "receiving_address_source", + "type": "receiving_address_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'magic_default'" + }, + "live_activated_at": { + "name": "live_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "merchants_user_id_unique": { + "name": "merchants_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merchants_user_id_users_id_fk": { + "name": "merchants_user_id_users_id_fk", + "tableFrom": "merchants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "merchants_receiving_address_check": { + "name": "merchants_receiving_address_check", + "value": "\"merchants\".\"receiving_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"merchants\".\"receiving_address\") <> '0x0000000000000000000000000000000000000000'" + } + }, + "isRLSEnabled": false + }, + "public.quickstart_progress": { + "name": "quickstart_progress", + "schema": "", + "columns": { + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "quickstart_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quickstart_progress_merchant_id_merchants_id_fk": { + "name": "quickstart_progress_merchant_id_merchants_id_fk", + "tableFrom": "quickstart_progress", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quickstart_progress_merchant_id_step_key_pk": { + "name": "quickstart_progress_merchant_id_step_key_pk", + "columns": ["merchant_id", "step_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "magic_issuer": { + "name": "magic_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_magic_issuer_unique": { + "name": "users_magic_issuer_unique", + "columns": [ + { + "expression": "magic_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "agent_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioned'" + }, + "signer_subject": { + "name": "signer_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_count": { + "name": "connection_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_signer_subject_unique": { + "name": "agents_signer_subject_unique", + "columns": [ + { + "expression": "signer_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_agent_address_unique": { + "name": "agents_agent_address_unique", + "columns": [ + { + "expression": "agent_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agents\".\"agent_address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_name_check": { + "name": "agents_name_check", + "value": "\"agents\".\"name\" ~ '[^[:space:]]'" + }, + "agents_signer_subject_check": { + "name": "agents_signer_subject_check", + "value": "\"agents\".\"signer_subject\" ~ '[^[:space:]]'" + }, + "agents_address_check": { + "name": "agents_address_check", + "value": "\"agents\".\"agent_address\" is null or (\"agents\".\"agent_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"agents\".\"agent_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "agents_connection_count_check": { + "name": "agents_connection_count_check", + "value": "\"agents\".\"connection_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.cap_cycles": { + "name": "cap_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reset_reason": { + "name": "reset_reason", + "type": "cap_reset_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cap_cycles_one_active_per_agent": { + "name": "cap_cycles_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cap_cycles\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "cap_cycles_agent_started_idx": { + "name": "cap_cycles_agent_started_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cap_cycles_agent_id_agents_id_fk": { + "name": "cap_cycles_agent_id_agents_id_fk", + "tableFrom": "cap_cycles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cap_cycles_id_agent_unique": { + "name": "cap_cycles_id_agent_unique", + "nullsNotDistinct": false, + "columns": ["id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "cap_cycles_end_check": { + "name": "cap_cycles_end_check", + "value": "(\"cap_cycles\".\"ended_at\" is null and \"cap_cycles\".\"reset_reason\" is null)\n or (\"cap_cycles\".\"ended_at\" > \"cap_cycles\".\"started_at\" and \"cap_cycles\".\"reset_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.caps": { + "name": "caps", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "amount_usd_cents": { + "name": "amount_usd_cents", + "type": "numeric(20, 0)", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "cap_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "caps_agent_id_agents_id_fk": { + "name": "caps_agent_id_agents_id_fk", + "tableFrom": "caps", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "caps_amount_check": { + "name": "caps_amount_check", + "value": "\"caps\".\"amount_usd_cents\" is null or \"caps\".\"amount_usd_cents\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.leash_keys": { + "name": "leash_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_at": { + "name": "last_auth_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_code": { + "name": "last_auth_failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "leash_keys_hashed_key_unique": { + "name": "leash_keys_hashed_key_unique", + "columns": [ + { + "expression": "hashed_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leash_keys_one_active_per_agent": { + "name": "leash_keys_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leash_keys\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leash_keys_agent_id_agents_id_fk": { + "name": "leash_keys_agent_id_agents_id_fk", + "tableFrom": "leash_keys", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leash_keys_rotated_from_id_leash_keys_id_fk": { + "name": "leash_keys_rotated_from_id_leash_keys_id_fk", + "tableFrom": "leash_keys", + "tableTo": "leash_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "leash_keys_hash_check": { + "name": "leash_keys_hash_check", + "value": "\"leash_keys\".\"hashed_key\" ~ '^[0-9a-f]{64}$'" + }, + "leash_keys_prefix_check": { + "name": "leash_keys_prefix_check", + "value": "\"leash_keys\".\"prefix\" = 'leash_sk_'" + }, + "leash_keys_last4_check": { + "name": "leash_keys_last4_check", + "value": "\"leash_keys\".\"last4\" ~ '^[A-Za-z0-9_-]{4}$'" + } + }, + "isRLSEnabled": false + }, + "public.agent_events": { + "name": "agent_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_surface": { + "name": "actor_surface", + "type": "agent_event_surface", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_events_agent_created_idx": { + "name": "agent_events_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_events_agent_id_agents_id_fk": { + "name": "agent_events_agent_id_agents_id_fk", + "tableFrom": "agent_events", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_events_metadata_check": { + "name": "agent_events_metadata_check", + "value": "jsonb_typeof(\"agent_events\".\"metadata\") = 'object'" + } + }, + "isRLSEnabled": false + }, + "public.floats": { + "name": "floats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "leash_asset", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "balance_atomic": { + "name": "balance_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "balance_usd": { + "name": "balance_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "floats_agent_network_unique": { + "name": "floats_agent_network_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "floats_agent_id_agents_id_fk": { + "name": "floats_agent_id_agents_id_fk", + "tableFrom": "floats", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "floats_balance_atomic_check": { + "name": "floats_balance_atomic_check", + "value": "\"floats\".\"balance_atomic\" >= 0 and \"floats\".\"balance_atomic\" = trunc(\"floats\".\"balance_atomic\")" + }, + "floats_balance_usd_check": { + "name": "floats_balance_usd_check", + "value": "\"floats\".\"balance_usd\" >= 0" + }, + "floats_native_usdc_check": { + "name": "floats_native_usdc_check", + "value": "(\"floats\".\"network\" = 'eip155:8453'\n and lower(\"floats\".\"token_address\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"floats\".\"network\" = 'eip155:42161'\n and lower(\"floats\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + } + }, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "notification_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "resource_host": { + "name": "resource_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sticky": { + "name": "sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_agent_event_key_unique": { + "name": "notifications_agent_event_key_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_cap_75_cycle_unique": { + "name": "notifications_cap_75_cycle_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"type\" = 'cap_75'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_unusual_resource_unique": { + "name": "notifications_unusual_resource_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"type\" = 'unusual_domain'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_one_active_cap_halt": { + "name": "notifications_one_active_cap_halt", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"resolved_at\" is null\n and \"notifications\".\"type\" in ('cap_blocked', 'cap_lowered_halt')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_agent_created_idx": { + "name": "notifications_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_agent_unread_idx": { + "name": "notifications_agent_unread_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notifications\".\"read_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_receipt_idx": { + "name": "notifications_receipt_idx", + "columns": [ + { + "expression": "receipt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notifications\".\"receipt_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_agent_id_agents_id_fk": { + "name": "notifications_agent_id_agents_id_fk", + "tableFrom": "notifications", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_cycle_agent_fk": { + "name": "notifications_cycle_agent_fk", + "tableFrom": "notifications", + "tableTo": "cap_cycles", + "columnsFrom": ["cycle_id", "agent_id"], + "columnsTo": ["id", "agent_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_receipt_cycle_agent_fk": { + "name": "notifications_receipt_cycle_agent_fk", + "tableFrom": "notifications", + "tableTo": "receipts", + "columnsFrom": ["receipt_id", "cycle_id", "agent_id"], + "columnsTo": ["id", "cycle_id", "agent_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notifications_tier_type_check": { + "name": "notifications_tier_type_check", + "value": "(\"notifications\".\"tier\" = '2'\n and \"notifications\".\"type\" in ('cap_75', 'unusual_domain', 'float_low', 'float_empty'))\n or (\"notifications\".\"tier\" = '3'\n and \"notifications\".\"type\" in ('cap_blocked', 'cap_lowered_halt'))" + }, + "notifications_sticky_check": { + "name": "notifications_sticky_check", + "value": "(\"notifications\".\"tier\" = '2' and not \"notifications\".\"sticky\" and \"notifications\".\"resolved_at\" is null)\n or (\"notifications\".\"tier\" = '3' and \"notifications\".\"sticky\")" + }, + "notifications_resource_identity_check": { + "name": "notifications_resource_identity_check", + "value": "(\"notifications\".\"type\" = 'unusual_domain' and \"notifications\".\"resource_host\" is not null\n and char_length(\"notifications\".\"resource_host\") between 1 and 253\n and \"notifications\".\"resource_host\" = lower(\"notifications\".\"resource_host\")\n and \"notifications\".\"resource_host\" = btrim(\"notifications\".\"resource_host\")\n and \"notifications\".\"resource_host\" !~ '[/?#@[:space:]]'\n and \"notifications\".\"resource_key\" is not null\n and \"notifications\".\"resource_key\" ~ '^[0-9a-f]{64}$')\n or (\"notifications\".\"type\" <> 'unusual_domain'\n and \"notifications\".\"resource_host\" is null and \"notifications\".\"resource_key\" is null)" + }, + "notifications_event_key_check": { + "name": "notifications_event_key_check", + "value": "char_length(btrim(\"notifications\".\"event_key\")) between 1 and 300" + }, + "notifications_metadata_check": { + "name": "notifications_metadata_check", + "value": "jsonb_typeof(\"notifications\".\"metadata\") = 'object'" + }, + "notifications_timestamps_check": { + "name": "notifications_timestamps_check", + "value": "(\"notifications\".\"read_at\" is null or \"notifications\".\"read_at\" >= \"notifications\".\"created_at\")\n and (\"notifications\".\"resolved_at\" is null or \"notifications\".\"resolved_at\" >= \"notifications\".\"created_at\")" + } + }, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscriptions_owner_active_idx": { + "name": "push_subscriptions_owner_active_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"push_subscriptions\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_owner_id_users_id_fk": { + "name": "push_subscriptions_owner_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "push_subscriptions_endpoint_check": { + "name": "push_subscriptions_endpoint_check", + "value": "char_length(\"push_subscriptions\".\"endpoint\") between 1 and 2048\n and \"push_subscriptions\".\"endpoint\" ~ '^https://[^[:space:]]+$'" + }, + "push_subscriptions_keys_check": { + "name": "push_subscriptions_keys_check", + "value": "char_length(btrim(\"push_subscriptions\".\"p256dh\")) between 1 and 512\n and char_length(btrim(\"push_subscriptions\".\"auth\")) between 1 and 512" + }, + "push_subscriptions_revoked_at_check": { + "name": "push_subscriptions_revoked_at_check", + "value": "\"push_subscriptions\".\"revoked_at\" is null or \"push_subscriptions\".\"revoked_at\" >= \"push_subscriptions\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.receipts": { + "name": "receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "receipt_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "intended_network": { + "name": "intended_network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "pay_to": { + "name": "pay_to", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "resource_url": { + "name": "resource_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_host": { + "name": "resource_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_nonce": { + "name": "authorization_nonce", + "type": "varchar(66)", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "authorization_valid_before": { + "name": "authorization_valid_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settlement_response": { + "name": "settlement_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "receipts_agent_nonce_unique": { + "name": "receipts_agent_nonce_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "authorization_nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_fingerprint_unique": { + "name": "receipts_agent_fingerprint_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_network_tx_hash_unique": { + "name": "receipts_network_tx_hash_unique", + "columns": [ + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"receipts\".\"tx_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_cap_gate_idx": { + "name": "receipts_cap_gate_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_created_idx": { + "name": "receipts_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "receipts_agent_id_agents_id_fk": { + "name": "receipts_agent_id_agents_id_fk", + "tableFrom": "receipts", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "receipts_parent_id_receipts_id_fk": { + "name": "receipts_parent_id_receipts_id_fk", + "tableFrom": "receipts", + "tableTo": "receipts", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "receipts_cycle_agent_fk": { + "name": "receipts_cycle_agent_fk", + "tableFrom": "receipts", + "tableTo": "cap_cycles", + "columnsFrom": ["cycle_id", "agent_id"], + "columnsTo": ["id", "agent_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "receipts_id_cycle_agent_unique": { + "name": "receipts_id_cycle_agent_unique", + "nullsNotDistinct": false, + "columns": ["id", "cycle_id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "receipts_amount_atomic_check": { + "name": "receipts_amount_atomic_check", + "value": "\"receipts\".\"amount_atomic\" > 0 and \"receipts\".\"amount_atomic\" = trunc(\"receipts\".\"amount_atomic\")" + }, + "receipts_amount_usd_check": { + "name": "receipts_amount_usd_check", + "value": "\"receipts\".\"amount_usd\" > 0" + }, + "receipts_pay_to_check": { + "name": "receipts_pay_to_check", + "value": "\"receipts\".\"pay_to\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"receipts\".\"pay_to\") <> '0x0000000000000000000000000000000000000000'" + }, + "receipts_resource_check": { + "name": "receipts_resource_check", + "value": "(\"receipts\".\"resource_url\" is null and \"receipts\".\"resource_host\" is null)\n or (\"receipts\".\"resource_url\" is not null and \"receipts\".\"resource_host\" is not null\n and char_length(\"receipts\".\"resource_url\") between 1 and 2048\n and \"receipts\".\"resource_url\" ~ '^[A-Za-z][A-Za-z0-9+.-]*://[^[:space:]]+$'\n and char_length(\"receipts\".\"resource_host\") between 1 and 253\n and \"receipts\".\"resource_host\" = lower(\"receipts\".\"resource_host\")\n and \"receipts\".\"resource_host\" = btrim(\"receipts\".\"resource_host\")\n and \"receipts\".\"resource_host\" !~ '[/?#@[:space:]]')" + }, + "receipts_native_usdc_check": { + "name": "receipts_native_usdc_check", + "value": "(\"receipts\".\"network\" = 'eip155:8453'\n and lower(\"receipts\".\"asset\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"receipts\".\"network\" = 'eip155:42161'\n and lower(\"receipts\".\"asset\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + }, + "receipts_authorization_check": { + "name": "receipts_authorization_check", + "value": "\"receipts\".\"authorization_nonce\" ~ '^0x[0-9a-f]{64}$'\n and \"receipts\".\"request_fingerprint\" ~ '^[0-9a-f]{64}$'" + }, + "receipts_origin_check": { + "name": "receipts_origin_check", + "value": "\"receipts\".\"origin\" is null or (jsonb_typeof(\"receipts\".\"origin\") = 'object'\n and \"receipts\".\"origin\" ? 'transport'\n and \"receipts\".\"origin\"->>'transport' in ('mcp', 'http'))" + }, + "receipts_tx_hash_check": { + "name": "receipts_tx_hash_check", + "value": "\"receipts\".\"tx_hash\" is null or \"receipts\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "receipts_state_check": { + "name": "receipts_state_check", + "value": "(\"receipts\".\"status\" = 'pending' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is null\n and \"receipts\".\"settlement_response\" is null and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'settled' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is not null\n and \"receipts\".\"settlement_response\" is not null and \"receipts\".\"settled_at\" is not null)\n or (\"receipts\".\"status\" = 'failed' and \"receipts\".\"reason\" is not null\n and \"receipts\".\"reason\" ~ '[^[:space:]]' and \"receipts\".\"intended_network\" is null\n and \"receipts\".\"tx_hash\" is null and \"receipts\".\"settlement_response\" is null\n and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'blocked' and \"receipts\".\"reason\" is not null\n and \"receipts\".\"reason\" ~ '[^[:space:]]' and \"receipts\".\"intended_network\" is not null\n and \"receipts\".\"tx_hash\" is null and \"receipts\".\"settlement_response\" is null\n and \"receipts\".\"settled_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order_number": { + "name": "order_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_ref": { + "name": "payment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orders_payment_ref_unique": { + "name": "orders_payment_ref_unique", + "columns": [ + { + "expression": "payment_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_merchant_env_number_unique": { + "name": "orders_merchant_env_number_unique", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_payment_merchant_env_fk": { + "name": "orders_payment_merchant_env_fk", + "tableFrom": "orders", + "tableTo": "payments", + "columnsFrom": ["payment_ref", "merchant_id", "env"], + "columnsTo": ["ref_code", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "orders_order_number_check": { + "name": "orders_order_number_check", + "value": "\"orders\".\"order_number\" ~ '[^[:space:]]'" + }, + "orders_payment_ref_check": { + "name": "orders_payment_ref_check", + "value": "\"orders\".\"payment_ref\" ~ '^TAB-[A-Z0-9]+$'" + } + }, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ref_code": { + "name": "ref_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "receiver": { + "name": "receiver", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_chain_id": { + "name": "token_chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "intent_url": { + "name": "intent_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_type": { + "name": "payer_type", + "type": "payer_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "payer_email": { + "name": "payer_email", + "type": "citext", + "primaryKey": false, + "notNull": false + }, + "payer_address": { + "name": "payer_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_transaction_id": { + "name": "reported_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_token_changes": { + "name": "reported_token_changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_next_attempt_at": { + "name": "verification_next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_lease_token": { + "name": "verification_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verification_lease_expires_at": { + "name": "verification_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_ref_code_unique": { + "name": "payments_ref_code_unique", + "columns": [ + { + "expression": "ref_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_settlement_evidence_unique": { + "name": "payments_settlement_evidence_unique", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "livemode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_reported_transaction_id_idx": { + "name": "payments_reported_transaction_id_idx", + "columns": [ + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_merchant_env_created_idx": { + "name": "payments_merchant_env_created_idx", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_pending_sweep_idx": { + "name": "payments_pending_sweep_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_verification_sweep_idx": { + "name": "payments_verification_sweep_idx", + "columns": [ + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verification_next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_merchant_id_merchants_id_fk": { + "name": "payments_merchant_id_merchants_id_fk", + "tableFrom": "payments", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_id_merchant_env_unique": { + "name": "payments_id_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "payments_ref_merchant_env_unique": { + "name": "payments_ref_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["ref_code", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "payments_ref_code_check": { + "name": "payments_ref_code_check", + "value": "\"payments\".\"ref_code\" ~ '^TAB-[A-Z0-9]+$'" + }, + "payments_amount_check": { + "name": "payments_amount_check", + "value": "\"payments\".\"amount_usd\" > 0 and \"payments\".\"amount_usd\" < 100000000000000\n and scale(\"payments\".\"amount_usd\") <= 6" + }, + "payments_currency_check": { + "name": "payments_currency_check", + "value": "\"payments\".\"currency\" = 'USD'" + }, + "payments_chain_check": { + "name": "payments_chain_check", + "value": "\"payments\".\"token_chain_id\" = 42161" + }, + "payments_token_check": { + "name": "payments_token_check", + "value": "lower(\"payments\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831'" + }, + "payments_receiver_check": { + "name": "payments_receiver_check", + "value": "\"payments\".\"receiver\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"receiver\") <> '0x0000000000000000000000000000000000000000'" + }, + "payments_payer_address_check": { + "name": "payments_payer_address_check", + "value": "\"payments\".\"payer_address\" is null or (\"payments\".\"payer_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"payer_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "payments_livemode_check": { + "name": "payments_livemode_check", + "value": "(\"payments\".\"env\" = 'live' and \"payments\".\"livemode\")\n or (\"payments\".\"env\" = 'test' and not \"payments\".\"livemode\")" + }, + "payments_report_check": { + "name": "payments_report_check", + "value": "(\"payments\".\"reported_transaction_id\" is null and \"payments\".\"reported_token_changes\" is null and \"payments\".\"reported_at\" is null)\n or (\"payments\".\"reported_transaction_id\" is not null and btrim(\"payments\".\"reported_transaction_id\") <> ''\n and \"payments\".\"reported_token_changes\" is not null\n and jsonb_typeof(\"payments\".\"reported_token_changes\") = 'array' and \"payments\".\"reported_at\" is not null)" + }, + "payments_settled_at_check": { + "name": "payments_settled_at_check", + "value": "(\"payments\".\"status\" = 'settled' and \"payments\".\"settled_at\" is not null)\n or (\"payments\".\"status\" <> 'settled' and \"payments\".\"settled_at\" is null)" + }, + "payments_verification_lease_check": { + "name": "payments_verification_lease_check", + "value": "(\"payments\".\"verification_lease_token\" is null and \"payments\".\"verification_lease_expires_at\" is null)\n or (\"payments\".\"verification_lease_token\" is not null\n and \"payments\".\"verification_lease_expires_at\" is not null and \"payments\".\"env\" = 'live'\n and \"payments\".\"status\" = 'pending' and \"payments\".\"reported_at\" is not null\n and \"payments\".\"reported_transaction_id\" is not null and \"payments\".\"payer_address\" is not null)" + }, + "payments_verification_schedule_check": { + "name": "payments_verification_schedule_check", + "value": "\"payments\".\"verification_next_attempt_at\" is null\n or (\"payments\".\"env\" = 'live' and \"payments\".\"reported_at\" is not null)" + }, + "payments_failure_reason_check": { + "name": "payments_failure_reason_check", + "value": "(\"payments\".\"status\" = 'failed' and \"payments\".\"failure_reason\" is not null)\n or (\"payments\".\"status\" <> 'failed' and \"payments\".\"failure_reason\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "particle_transaction_id": { + "name": "particle_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "token_changes_json": { + "name": "token_changes_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "verification_method": { + "name": "verification_method", + "type": "settlement_verification_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "verification_trigger": { + "name": "verification_trigger", + "type": "settlement_verification_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlements_payment_id_unique": { + "name": "settlements_payment_id_unique", + "columns": [ + { + "expression": "payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_particle_transaction_id_unique": { + "name": "settlements_particle_transaction_id_unique", + "columns": [ + { + "expression": "particle_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_payment_evidence_fk": { + "name": "settlements_payment_evidence_fk", + "tableFrom": "settlements", + "tableTo": "payments", + "columnsFrom": ["payment_id", "particle_transaction_id", "livemode"], + "columnsTo": ["id", "reported_transaction_id", "livemode"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settlements_id_payment_unique": { + "name": "settlements_id_payment_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id"] + } + }, + "policies": {}, + "checkConstraints": { + "settlements_amount_atomic_check": { + "name": "settlements_amount_atomic_check", + "value": "\"settlements\".\"amount_atomic\" > 0 and \"settlements\".\"amount_atomic\" = trunc(\"settlements\".\"amount_atomic\")\n and \"settlements\".\"amount_atomic\" < 1000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "settlements_token_changes_check": { + "name": "settlements_token_changes_check", + "value": "jsonb_typeof(\"settlements\".\"token_changes_json\") = 'array'" + }, + "settlements_tx_hash_check": { + "name": "settlements_tx_hash_check", + "value": "\"settlements\".\"tx_hash\" is null or \"settlements\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "settlements_simulation_check": { + "name": "settlements_simulation_check", + "value": "(\"settlements\".\"verification_method\" = 'simulated_test' and not \"settlements\".\"livemode\")\n or (\"settlements\".\"verification_method\" <> 'simulated_test' and \"settlements\".\"livemode\")" + } + }, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retry_chain_id": { + "name": "retry_chain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_body_hash": { + "name": "request_body_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "encode(digest(\"webhook_deliveries\".\"request_body\", 'sha256'), 'hex')", + "type": "stored" + } + }, + "type": { + "name": "type", + "type": "webhook_delivery_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "webhook_delivery_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "webhook_delivery_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_kind": { + "name": "failure_kind", + "type": "webhook_failure_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "signature_header": { + "name": "signature_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body_snippet": { + "name": "response_body_snippet", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_delivery_id": { + "name": "parent_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_attempt": { + "name": "parent_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "superseded_by_attempt": { + "name": "superseded_by_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_deliveries_chain_attempt_unique": { + "name": "webhook_deliveries_chain_attempt_unique", + "columns": [ + { + "expression": "retry_chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_automatic_settlement_root_unique": { + "name": "webhook_deliveries_automatic_settlement_root_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"attempt\" = 1", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_dashboard_head_idx": { + "name": "webhook_deliveries_dashboard_head_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment'\n and \"webhook_deliveries\".\"superseded_by_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_due_idx": { + "name": "webhook_deliveries_due_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_event_idx": { + "name": "webhook_deliveries_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_deliveries_endpoint_scope_fk": { + "name": "webhook_deliveries_endpoint_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_endpoints", + "columnsFrom": ["endpoint_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_payment_scope_fk": { + "name": "webhook_deliveries_payment_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "payments", + "columnsFrom": ["payment_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_settlement_payment_fk": { + "name": "webhook_deliveries_settlement_payment_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "settlements", + "columnsFrom": ["settlement_id", "payment_id"], + "columnsTo": ["id", "payment_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_scope_fk": { + "name": "webhook_deliveries_chain_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "retry_chain_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_evidence_fk": { + "name": "webhook_deliveries_chain_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["retry_chain_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_scope_fk": { + "name": "webhook_deliveries_parent_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "parent_delivery_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_evidence_fk": { + "name": "webhook_deliveries_parent_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_sequence_fk": { + "name": "webhook_deliveries_parent_sequence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "retry_chain_id", "parent_attempt"], + "columnsTo": ["id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_successor_scope_fk": { + "name": "webhook_deliveries_successor_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["superseded_by_id", "id", "retry_chain_id", "superseded_by_attempt"], + "columnsTo": ["id", "parent_delivery_id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_id_chain_scope_unique": { + "name": "webhook_deliveries_id_chain_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ] + }, + "webhook_deliveries_id_tenant_unique": { + "name": "webhook_deliveries_id_tenant_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "webhook_deliveries_id_event_scope_unique": { + "name": "webhook_deliveries_id_event_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ] + }, + "webhook_deliveries_id_evidence_unique": { + "name": "webhook_deliveries_id_evidence_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id", "settlement_id"] + }, + "webhook_deliveries_id_retry_attempt_unique": { + "name": "webhook_deliveries_id_retry_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "retry_chain_id", "attempt"] + }, + "webhook_deliveries_id_parent_chain_attempt_unique": { + "name": "webhook_deliveries_id_parent_chain_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "parent_delivery_id", "retry_chain_id", "attempt"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_deliveries_event_id_check": { + "name": "webhook_deliveries_event_id_check", + "value": "\"webhook_deliveries\".\"event_id\" ~ '^evt_[A-Za-z0-9_-]+$'" + }, + "webhook_deliveries_body_hash_check": { + "name": "webhook_deliveries_body_hash_check", + "value": "\"webhook_deliveries\".\"request_body_hash\" ~ '^[0-9a-f]{64}$'" + }, + "webhook_deliveries_chain_root_check": { + "name": "webhook_deliveries_chain_root_check", + "value": "((\"webhook_deliveries\".\"attempt\" = 1 and \"webhook_deliveries\".\"retry_chain_id\" = \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_attempt\" is null)\n or (\"webhook_deliveries\".\"attempt\" > 1 and \"webhook_deliveries\".\"retry_chain_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_delivery_id\" is not null\n and \"webhook_deliveries\".\"parent_attempt\" = \"webhook_deliveries\".\"attempt\" - 1))\n and (\"webhook_deliveries\".\"parent_delivery_id\" is null or \"webhook_deliveries\".\"parent_delivery_id\" <> \"webhook_deliveries\".\"id\")" + }, + "webhook_deliveries_attempt_check": { + "name": "webhook_deliveries_attempt_check", + "value": "\"webhook_deliveries\".\"attempt\" between 1 and 3" + }, + "webhook_deliveries_type_check": { + "name": "webhook_deliveries_type_check", + "value": "(\"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"payment_id\" is not null\n and \"webhook_deliveries\".\"settlement_id\" is not null)\n or (\"webhook_deliveries\".\"type\" = 'test' and \"webhook_deliveries\".\"payment_id\" is null\n and \"webhook_deliveries\".\"settlement_id\" is null)" + }, + "webhook_deliveries_status_code_check": { + "name": "webhook_deliveries_status_code_check", + "value": "\"webhook_deliveries\".\"status_code\" is null or \"webhook_deliveries\".\"status_code\" between 100 and 599" + }, + "webhook_deliveries_response_time_check": { + "name": "webhook_deliveries_response_time_check", + "value": "\"webhook_deliveries\".\"response_time_ms\" is null or \"webhook_deliveries\".\"response_time_ms\" >= 0" + }, + "webhook_deliveries_signature_check": { + "name": "webhook_deliveries_signature_check", + "value": "\"webhook_deliveries\".\"signature_header\" is null\n or \"webhook_deliveries\".\"signature_header\" ~ '^t=[0-9]+,v1=[0-9a-f]{64}$'" + }, + "webhook_deliveries_lease_check": { + "name": "webhook_deliveries_lease_check", + "value": "(\"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null)\n or (\"webhook_deliveries\".\"lease_token\" is not null and \"webhook_deliveries\".\"lease_expires_at\" is not null)" + }, + "webhook_deliveries_successor_check": { + "name": "webhook_deliveries_successor_check", + "value": "(\"webhook_deliveries\".\"superseded_by_id\" is null and \"webhook_deliveries\".\"superseded_by_attempt\" is null)\n or (\"webhook_deliveries\".\"superseded_by_id\" is not null\n and \"webhook_deliveries\".\"superseded_by_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"superseded_by_attempt\" = \"webhook_deliveries\".\"attempt\" + 1\n and \"webhook_deliveries\".\"superseded_by_attempt\" between 2 and 3)" + }, + "webhook_deliveries_result_check": { + "name": "webhook_deliveries_result_check", + "value": "coalesce(((\"webhook_deliveries\".\"result\" = 'pending' and \"webhook_deliveries\".\"completed_at\" is null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" is null and \"webhook_deliveries\".\"response_time_ms\" is null\n and \"webhook_deliveries\".\"response_body_snippet\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'delivered' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" between 200 and 299\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'retrying' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is not null and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" in ('failed', 'timeout') and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is not null\n and ((\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network'))\n or (\"webhook_deliveries\".\"result\" = 'timeout' and \"webhook_deliveries\".\"failure_kind\" = 'timeout'))\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" = 'configuration'\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is null and \"webhook_deliveries\".\"status_code\" is null\n and \"webhook_deliveries\".\"response_time_ms\" is null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'gave_up' and \"webhook_deliveries\".\"attempt\" = 3\n and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is null and \"webhook_deliveries\".\"signature_header\" is not null\n and \"webhook_deliveries\".\"started_at\" is not null and \"webhook_deliveries\".\"response_time_ms\" is not null\n and \"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null\n and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))), false)" + } + }, + "isRLSEnabled": false + }, + "public.webhook_endpoints": { + "name": "webhook_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_nonce": { + "name": "secret_nonce", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "secret_auth_tag": { + "name": "secret_auth_tag", + "type": "varchar(22)", + "primaryKey": false, + "notNull": false + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_last4": { + "name": "secret_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_endpoints_one_active_per_env": { + "name": "webhook_endpoints_one_active_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_endpoints\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_endpoints_merchant_id_merchants_id_fk": { + "name": "webhook_endpoints_merchant_id_merchants_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_endpoints_id_scope_unique": { + "name": "webhook_endpoints_id_scope_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_endpoints_url_check": { + "name": "webhook_endpoints_url_check", + "value": "char_length(btrim(\"webhook_endpoints\".\"url\")) between 1 and 2048\n and (\"webhook_endpoints\".\"url\" ~ '^https://[^/?#[:space:]@]+([:/?#]|$)'\n or (\"webhook_endpoints\".\"env\" = 'test'\n and \"webhook_endpoints\".\"url\" ~ '^http://(127\\.0\\.0\\.1|\\[::1\\]|localhost)(:[0-9]+)?/'))\n and \"webhook_endpoints\".\"url\" !~* '^https://(localhost\\.?|127(\\.[0-9]{1,3}){3}|10(\\.[0-9]{1,3}){3}|192\\.168(\\.[0-9]{1,3}){2}|169\\.254(\\.[0-9]{1,3}){2}|172\\.(1[6-9]|2[0-9]|3[01])(\\.[0-9]{1,3}){2}|\\[(::1|f[cd][0-9a-f:]*|fe[89ab][0-9a-f:]*)\\])([:/?#]|$)'" + }, + "webhook_endpoints_last4_check": { + "name": "webhook_endpoints_last4_check", + "value": "char_length(\"webhook_endpoints\".\"secret_last4\") = 4" + }, + "webhook_endpoints_secret_envelope_check": { + "name": "webhook_endpoints_secret_envelope_check", + "value": "coalesce(((\"webhook_endpoints\".\"deleted_at\" is null\n and \"webhook_endpoints\".\"secret_ciphertext\" is not null\n and char_length(\"webhook_endpoints\".\"secret_ciphertext\") > 0\n and \"webhook_endpoints\".\"secret_nonce\" is not null\n and \"webhook_endpoints\".\"secret_nonce\" ~ '^[A-Za-z0-9_-]{16}$'\n and \"webhook_endpoints\".\"secret_auth_tag\" is not null\n and \"webhook_endpoints\".\"secret_auth_tag\" ~ '^[A-Za-z0-9_-]{22}$'\n and \"webhook_endpoints\".\"secret_key_version\" is not null\n and \"webhook_endpoints\".\"secret_key_version\" > 0)\n or (\"webhook_endpoints\".\"deleted_at\" is not null\n and \"webhook_endpoints\".\"secret_ciphertext\" is null and \"webhook_endpoints\".\"secret_nonce\" is null\n and \"webhook_endpoints\".\"secret_auth_tag\" is null and \"webhook_endpoints\".\"secret_key_version\" is null)), false)" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.api_key_permissions": { + "name": "api_key_permissions", + "schema": "public", + "values": ["full", "read_only"] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": ["secret", "publishable"] + }, + "public.environment": { + "name": "environment", + "schema": "public", + "values": ["test", "live"] + }, + "public.quickstart_source": { + "name": "quickstart_source", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.receiving_address_source": { + "name": "receiving_address_source", + "schema": "public", + "values": ["magic_default", "custom"] + }, + "public.agent_status": { + "name": "agent_status", + "schema": "public", + "values": ["provisioned", "paused", "frozen", "cancelled", "nuked"] + }, + "public.cap_frequency": { + "name": "cap_frequency", + "schema": "public", + "values": ["daily", "weekly", "monthly", "never"] + }, + "public.cap_reset_reason": { + "name": "cap_reset_reason", + "schema": "public", + "values": ["schedule", "manual", "frequency_change"] + }, + "public.leash_network": { + "name": "leash_network", + "schema": "public", + "values": ["eip155:8453", "eip155:42161"] + }, + "public.agent_event_surface": { + "name": "agent_event_surface", + "schema": "public", + "values": ["agent", "web", "pwa", "push_action", "system"] + }, + "public.agent_event_type": { + "name": "agent_event_type", + "schema": "public", + "values": ["connect", "sign", "block", "revoke"] + }, + "public.leash_asset": { + "name": "leash_asset", + "schema": "public", + "values": ["USDC"] + }, + "public.notification_tier": { + "name": "notification_tier", + "schema": "public", + "values": ["2", "3"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "cap_75", + "cap_blocked", + "unusual_domain", + "cap_lowered_halt", + "float_low", + "float_empty" + ] + }, + "public.receipt_status": { + "name": "receipt_status", + "schema": "public", + "values": ["pending", "settled", "failed", "blocked"] + }, + "public.payer_type": { + "name": "payer_type", + "schema": "public", + "values": ["human", "agent"] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": ["pending", "settled", "failed"] + }, + "public.settlement_verification_method": { + "name": "settlement_verification_method", + "schema": "public", + "values": ["rpc", "particle", "x402_receipt", "simulated_test"] + }, + "public.settlement_verification_trigger": { + "name": "settlement_verification_trigger", + "schema": "public", + "values": ["inline", "cron_sweep"] + }, + "public.webhook_delivery_result": { + "name": "webhook_delivery_result", + "schema": "public", + "values": ["pending", "delivered", "retrying", "failed", "timeout", "gave_up"] + }, + "public.webhook_delivery_trigger": { + "name": "webhook_delivery_trigger", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.webhook_delivery_type": { + "name": "webhook_delivery_type", + "schema": "public", + "values": ["payment", "test"] + }, + "public.webhook_failure_kind": { + "name": "webhook_failure_kind", + "schema": "public", + "values": ["http", "network", "timeout", "configuration"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/0019_snapshot.json b/apps/web/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..b277711 --- /dev/null +++ b/apps/web/drizzle/meta/0019_snapshot.json @@ -0,0 +1,3290 @@ +{ + "id": "b4915c99-7b12-427a-ab64-a006a839d7aa", + "prevId": "22c7c44b-df4d-48ae-bb9c-09c85ba4f4eb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "api_key_permissions", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_public_key_unique": { + "name": "api_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_secret_hash_unique": { + "name": "api_keys_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_one_active_publishable_per_env": { + "name": "api_keys_one_active_publishable_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"api_keys\".\"revoked_at\" is null and \"api_keys\".\"type\" = 'publishable'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_merchant_id_merchants_id_fk": { + "name": "api_keys_merchant_id_merchants_id_fk", + "tableFrom": "api_keys", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_rotated_from_id_api_keys_id_fk": { + "name": "api_keys_rotated_from_id_api_keys_id_fk", + "tableFrom": "api_keys", + "tableTo": "api_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_keys_material_check": { + "name": "api_keys_material_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"public_key\" is not null and \"api_keys\".\"secret_hash\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"public_key\" is null and \"api_keys\".\"secret_hash\" is not null\n and \"api_keys\".\"secret_hash\" ~ '^[0-9a-f]{64}$')" + }, + "api_keys_permissions_check": { + "name": "api_keys_permissions_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and \"api_keys\".\"permissions\" is null)\n or (\"api_keys\".\"type\" = 'secret' and \"api_keys\".\"permissions\" is not null)" + }, + "api_keys_prefix_check": { + "name": "api_keys_prefix_check", + "value": "(\"api_keys\".\"type\" = 'publishable' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'pk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'pk_live_')\n )) or (\"api_keys\".\"type\" = 'secret' and (\n (\"api_keys\".\"env\" = 'test' and \"api_keys\".\"prefix\" = 'sk_test_')\n or (\"api_keys\".\"env\" = 'live' and \"api_keys\".\"prefix\" = 'sk_live_')\n ))" + }, + "api_keys_public_key_prefix_check": { + "name": "api_keys_public_key_prefix_check", + "value": "\"api_keys\".\"type\" = 'secret' or (\n left(\"api_keys\".\"public_key\", length(\"api_keys\".\"prefix\")) = \"api_keys\".\"prefix\"\n and \"api_keys\".\"public_key\" ~ '^pk_(test|live)_[A-Za-z0-9_-]+$'\n )" + } + }, + "isRLSEnabled": false + }, + "public.merchants": { + "name": "merchants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_etag": { + "name": "logo_etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_upload_count": { + "name": "logo_upload_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "logo_upload_window_started_at": { + "name": "logo_upload_window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "receiving_address": { + "name": "receiving_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "receiving_address_source": { + "name": "receiving_address_source", + "type": "receiving_address_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'magic_default'" + }, + "live_activated_at": { + "name": "live_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "merchants_user_id_unique": { + "name": "merchants_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merchants_user_id_users_id_fk": { + "name": "merchants_user_id_users_id_fk", + "tableFrom": "merchants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "merchants_receiving_address_check": { + "name": "merchants_receiving_address_check", + "value": "\"merchants\".\"receiving_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"merchants\".\"receiving_address\") <> '0x0000000000000000000000000000000000000000'" + } + }, + "isRLSEnabled": false + }, + "public.quickstart_progress": { + "name": "quickstart_progress", + "schema": "", + "columns": { + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "quickstart_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quickstart_progress_merchant_id_merchants_id_fk": { + "name": "quickstart_progress_merchant_id_merchants_id_fk", + "tableFrom": "quickstart_progress", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quickstart_progress_merchant_id_step_key_pk": { + "name": "quickstart_progress_merchant_id_step_key_pk", + "columns": ["merchant_id", "step_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "magic_issuer": { + "name": "magic_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_magic_issuer_unique": { + "name": "users_magic_issuer_unique", + "columns": [ + { + "expression": "magic_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "agent_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioned'" + }, + "signer_subject": { + "name": "signer_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_count": { + "name": "connection_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_signer_subject_unique": { + "name": "agents_signer_subject_unique", + "columns": [ + { + "expression": "signer_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_agent_address_unique": { + "name": "agents_agent_address_unique", + "columns": [ + { + "expression": "agent_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agents\".\"agent_address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_name_check": { + "name": "agents_name_check", + "value": "\"agents\".\"name\" ~ '[^[:space:]]'" + }, + "agents_signer_subject_check": { + "name": "agents_signer_subject_check", + "value": "\"agents\".\"signer_subject\" ~ '[^[:space:]]'" + }, + "agents_address_check": { + "name": "agents_address_check", + "value": "\"agents\".\"agent_address\" is null or (\"agents\".\"agent_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"agents\".\"agent_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "agents_connection_count_check": { + "name": "agents_connection_count_check", + "value": "\"agents\".\"connection_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.cap_cycles": { + "name": "cap_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reset_reason": { + "name": "reset_reason", + "type": "cap_reset_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cap_cycles_one_active_per_agent": { + "name": "cap_cycles_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cap_cycles\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "cap_cycles_agent_started_idx": { + "name": "cap_cycles_agent_started_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cap_cycles_agent_id_agents_id_fk": { + "name": "cap_cycles_agent_id_agents_id_fk", + "tableFrom": "cap_cycles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cap_cycles_id_agent_unique": { + "name": "cap_cycles_id_agent_unique", + "nullsNotDistinct": false, + "columns": ["id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "cap_cycles_end_check": { + "name": "cap_cycles_end_check", + "value": "(\"cap_cycles\".\"ended_at\" is null and \"cap_cycles\".\"reset_reason\" is null)\n or (\"cap_cycles\".\"ended_at\" > \"cap_cycles\".\"started_at\" and \"cap_cycles\".\"reset_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.caps": { + "name": "caps", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "amount_usd_cents": { + "name": "amount_usd_cents", + "type": "numeric(20, 0)", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "cap_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "caps_agent_id_agents_id_fk": { + "name": "caps_agent_id_agents_id_fk", + "tableFrom": "caps", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "caps_amount_check": { + "name": "caps_amount_check", + "value": "\"caps\".\"amount_usd_cents\" is null or \"caps\".\"amount_usd_cents\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.leash_keys": { + "name": "leash_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_at": { + "name": "last_auth_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_auth_failure_code": { + "name": "last_auth_failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotated_from_id": { + "name": "rotated_from_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "leash_keys_hashed_key_unique": { + "name": "leash_keys_hashed_key_unique", + "columns": [ + { + "expression": "hashed_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leash_keys_one_active_per_agent": { + "name": "leash_keys_one_active_per_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leash_keys\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leash_keys_agent_id_agents_id_fk": { + "name": "leash_keys_agent_id_agents_id_fk", + "tableFrom": "leash_keys", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leash_keys_rotated_from_id_leash_keys_id_fk": { + "name": "leash_keys_rotated_from_id_leash_keys_id_fk", + "tableFrom": "leash_keys", + "tableTo": "leash_keys", + "columnsFrom": ["rotated_from_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "leash_keys_hash_check": { + "name": "leash_keys_hash_check", + "value": "\"leash_keys\".\"hashed_key\" ~ '^[0-9a-f]{64}$'" + }, + "leash_keys_prefix_check": { + "name": "leash_keys_prefix_check", + "value": "\"leash_keys\".\"prefix\" = 'leash_sk_'" + }, + "leash_keys_last4_check": { + "name": "leash_keys_last4_check", + "value": "\"leash_keys\".\"last4\" ~ '^[A-Za-z0-9_-]{4}$'" + } + }, + "isRLSEnabled": false + }, + "public.agent_events": { + "name": "agent_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_surface": { + "name": "actor_surface", + "type": "agent_event_surface", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_events_agent_created_idx": { + "name": "agent_events_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_events_agent_id_agents_id_fk": { + "name": "agent_events_agent_id_agents_id_fk", + "tableFrom": "agent_events", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_events_metadata_check": { + "name": "agent_events_metadata_check", + "value": "jsonb_typeof(\"agent_events\".\"metadata\") = 'object'" + } + }, + "isRLSEnabled": false + }, + "public.floats": { + "name": "floats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "leash_asset", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "balance_atomic": { + "name": "balance_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "balance_usd": { + "name": "balance_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "floats_agent_network_unique": { + "name": "floats_agent_network_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "floats_agent_id_agents_id_fk": { + "name": "floats_agent_id_agents_id_fk", + "tableFrom": "floats", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "floats_balance_atomic_check": { + "name": "floats_balance_atomic_check", + "value": "\"floats\".\"balance_atomic\" >= 0 and \"floats\".\"balance_atomic\" = trunc(\"floats\".\"balance_atomic\")" + }, + "floats_balance_usd_check": { + "name": "floats_balance_usd_check", + "value": "\"floats\".\"balance_usd\" >= 0" + }, + "floats_native_usdc_check": { + "name": "floats_native_usdc_check", + "value": "(\"floats\".\"network\" = 'eip155:8453'\n and lower(\"floats\".\"token_address\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"floats\".\"network\" = 'eip155:42161'\n and lower(\"floats\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + } + }, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "notification_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "resource_host": { + "name": "resource_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sticky": { + "name": "sticky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_agent_event_key_unique": { + "name": "notifications_agent_event_key_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_cap_75_cycle_unique": { + "name": "notifications_cap_75_cycle_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"type\" = 'cap_75'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_unusual_resource_unique": { + "name": "notifications_unusual_resource_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"type\" = 'unusual_domain'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_one_active_cap_halt": { + "name": "notifications_one_active_cap_halt", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notifications\".\"resolved_at\" is null\n and \"notifications\".\"type\" in ('cap_blocked', 'cap_lowered_halt')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_agent_created_idx": { + "name": "notifications_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_agent_unread_idx": { + "name": "notifications_agent_unread_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notifications\".\"read_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_receipt_idx": { + "name": "notifications_receipt_idx", + "columns": [ + { + "expression": "receipt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notifications\".\"receipt_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_agent_id_agents_id_fk": { + "name": "notifications_agent_id_agents_id_fk", + "tableFrom": "notifications", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_cycle_agent_fk": { + "name": "notifications_cycle_agent_fk", + "tableFrom": "notifications", + "tableTo": "cap_cycles", + "columnsFrom": ["cycle_id", "agent_id"], + "columnsTo": ["id", "agent_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_receipt_cycle_agent_fk": { + "name": "notifications_receipt_cycle_agent_fk", + "tableFrom": "notifications", + "tableTo": "receipts", + "columnsFrom": ["receipt_id", "cycle_id", "agent_id"], + "columnsTo": ["id", "cycle_id", "agent_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notifications_tier_type_check": { + "name": "notifications_tier_type_check", + "value": "(\"notifications\".\"tier\" = '2'\n and \"notifications\".\"type\" in ('cap_75', 'unusual_domain', 'float_low', 'float_empty'))\n or (\"notifications\".\"tier\" = '3'\n and \"notifications\".\"type\" in ('cap_blocked', 'cap_lowered_halt'))" + }, + "notifications_sticky_check": { + "name": "notifications_sticky_check", + "value": "(\"notifications\".\"tier\" = '2' and not \"notifications\".\"sticky\" and \"notifications\".\"resolved_at\" is null)\n or (\"notifications\".\"tier\" = '3' and \"notifications\".\"sticky\")" + }, + "notifications_resource_identity_check": { + "name": "notifications_resource_identity_check", + "value": "(\"notifications\".\"type\" = 'unusual_domain' and \"notifications\".\"resource_host\" is not null\n and char_length(\"notifications\".\"resource_host\") between 1 and 253\n and \"notifications\".\"resource_host\" = lower(\"notifications\".\"resource_host\")\n and \"notifications\".\"resource_host\" = btrim(\"notifications\".\"resource_host\")\n and \"notifications\".\"resource_host\" !~ '[/?#@[:space:]]'\n and \"notifications\".\"resource_key\" is not null\n and \"notifications\".\"resource_key\" ~ '^[0-9a-f]{64}$')\n or (\"notifications\".\"type\" <> 'unusual_domain'\n and \"notifications\".\"resource_host\" is null and \"notifications\".\"resource_key\" is null)" + }, + "notifications_event_key_check": { + "name": "notifications_event_key_check", + "value": "char_length(btrim(\"notifications\".\"event_key\")) between 1 and 300" + }, + "notifications_metadata_check": { + "name": "notifications_metadata_check", + "value": "jsonb_typeof(\"notifications\".\"metadata\") = 'object'" + }, + "notifications_timestamps_check": { + "name": "notifications_timestamps_check", + "value": "(\"notifications\".\"read_at\" is null or \"notifications\".\"read_at\" >= \"notifications\".\"created_at\")\n and (\"notifications\".\"resolved_at\" is null or \"notifications\".\"resolved_at\" >= \"notifications\".\"created_at\")" + } + }, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscriptions_owner_active_idx": { + "name": "push_subscriptions_owner_active_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"push_subscriptions\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscriptions_owner_id_users_id_fk": { + "name": "push_subscriptions_owner_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "push_subscriptions_endpoint_check": { + "name": "push_subscriptions_endpoint_check", + "value": "char_length(\"push_subscriptions\".\"endpoint\") between 1 and 2048\n and \"push_subscriptions\".\"endpoint\" ~ '^https://[^[:space:]]+$'" + }, + "push_subscriptions_keys_check": { + "name": "push_subscriptions_keys_check", + "value": "char_length(btrim(\"push_subscriptions\".\"p256dh\")) between 1 and 512\n and char_length(btrim(\"push_subscriptions\".\"auth\")) between 1 and 512" + }, + "push_subscriptions_revoked_at_check": { + "name": "push_subscriptions_revoked_at_check", + "value": "\"push_subscriptions\".\"revoked_at\" is null or \"push_subscriptions\".\"revoked_at\" >= \"push_subscriptions\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.receipts": { + "name": "receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "receipt_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(38, 6)", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "intended_network": { + "name": "intended_network", + "type": "leash_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "pay_to": { + "name": "pay_to", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "resource_url": { + "name": "resource_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_host": { + "name": "resource_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_nonce": { + "name": "authorization_nonce", + "type": "varchar(66)", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "authorization_valid_before": { + "name": "authorization_valid_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settlement_response": { + "name": "settlement_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "receipts_agent_nonce_unique": { + "name": "receipts_agent_nonce_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "authorization_nonce", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_fingerprint_unique": { + "name": "receipts_agent_fingerprint_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_network_tx_hash_unique": { + "name": "receipts_network_tx_hash_unique", + "columns": [ + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"receipts\".\"tx_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_cap_gate_idx": { + "name": "receipts_cap_gate_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "receipts_agent_created_idx": { + "name": "receipts_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "receipts_agent_id_agents_id_fk": { + "name": "receipts_agent_id_agents_id_fk", + "tableFrom": "receipts", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "receipts_parent_id_receipts_id_fk": { + "name": "receipts_parent_id_receipts_id_fk", + "tableFrom": "receipts", + "tableTo": "receipts", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "receipts_cycle_agent_fk": { + "name": "receipts_cycle_agent_fk", + "tableFrom": "receipts", + "tableTo": "cap_cycles", + "columnsFrom": ["cycle_id", "agent_id"], + "columnsTo": ["id", "agent_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "receipts_id_cycle_agent_unique": { + "name": "receipts_id_cycle_agent_unique", + "nullsNotDistinct": false, + "columns": ["id", "cycle_id", "agent_id"] + } + }, + "policies": {}, + "checkConstraints": { + "receipts_amount_atomic_check": { + "name": "receipts_amount_atomic_check", + "value": "\"receipts\".\"amount_atomic\" > 0 and \"receipts\".\"amount_atomic\" = trunc(\"receipts\".\"amount_atomic\")" + }, + "receipts_amount_usd_check": { + "name": "receipts_amount_usd_check", + "value": "\"receipts\".\"amount_usd\" > 0" + }, + "receipts_pay_to_check": { + "name": "receipts_pay_to_check", + "value": "\"receipts\".\"pay_to\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"receipts\".\"pay_to\") <> '0x0000000000000000000000000000000000000000'" + }, + "receipts_resource_check": { + "name": "receipts_resource_check", + "value": "(\"receipts\".\"resource_url\" is null and \"receipts\".\"resource_host\" is null)\n or (\"receipts\".\"resource_url\" is not null and \"receipts\".\"resource_host\" is not null\n and char_length(\"receipts\".\"resource_url\") between 1 and 2048\n and \"receipts\".\"resource_url\" ~ '^[A-Za-z][A-Za-z0-9+.-]*://[^[:space:]]+$'\n and char_length(\"receipts\".\"resource_host\") between 1 and 253\n and \"receipts\".\"resource_host\" = lower(\"receipts\".\"resource_host\")\n and \"receipts\".\"resource_host\" = btrim(\"receipts\".\"resource_host\")\n and \"receipts\".\"resource_host\" !~ '[/?#@[:space:]]')" + }, + "receipts_native_usdc_check": { + "name": "receipts_native_usdc_check", + "value": "(\"receipts\".\"network\" = 'eip155:8453'\n and lower(\"receipts\".\"asset\") = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913')\n or (\"receipts\".\"network\" = 'eip155:42161'\n and lower(\"receipts\".\"asset\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831')" + }, + "receipts_authorization_check": { + "name": "receipts_authorization_check", + "value": "\"receipts\".\"authorization_nonce\" ~ '^0x[0-9a-f]{64}$'\n and \"receipts\".\"request_fingerprint\" ~ '^[0-9a-f]{64}$'" + }, + "receipts_origin_check": { + "name": "receipts_origin_check", + "value": "\"receipts\".\"origin\" is null or (jsonb_typeof(\"receipts\".\"origin\") = 'object'\n and \"receipts\".\"origin\" ? 'transport'\n and \"receipts\".\"origin\"->>'transport' in ('mcp', 'http'))" + }, + "receipts_tx_hash_check": { + "name": "receipts_tx_hash_check", + "value": "\"receipts\".\"tx_hash\" is null or \"receipts\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "receipts_state_check": { + "name": "receipts_state_check", + "value": "(\"receipts\".\"status\" = 'pending' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is null\n and \"receipts\".\"settlement_response\" is null and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'settled' and \"receipts\".\"reason\" is null\n and \"receipts\".\"intended_network\" is null and \"receipts\".\"tx_hash\" is not null\n and \"receipts\".\"settlement_response\" is not null and \"receipts\".\"settled_at\" is not null)\n or (\"receipts\".\"status\" = 'failed' and \"receipts\".\"reason\" is not null\n and \"receipts\".\"reason\" ~ '[^[:space:]]' and \"receipts\".\"intended_network\" is null\n and \"receipts\".\"tx_hash\" is null and \"receipts\".\"settlement_response\" is null\n and \"receipts\".\"settled_at\" is null)\n or (\"receipts\".\"status\" = 'blocked' and \"receipts\".\"reason\" is not null\n and \"receipts\".\"reason\" ~ '[^[:space:]]' and \"receipts\".\"intended_network\" is not null\n and \"receipts\".\"tx_hash\" is null and \"receipts\".\"settlement_response\" is null\n and \"receipts\".\"settled_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order_number": { + "name": "order_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_ref": { + "name": "payment_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orders_payment_ref_unique": { + "name": "orders_payment_ref_unique", + "columns": [ + { + "expression": "payment_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_merchant_env_number_unique": { + "name": "orders_merchant_env_number_unique", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_payment_merchant_env_fk": { + "name": "orders_payment_merchant_env_fk", + "tableFrom": "orders", + "tableTo": "payments", + "columnsFrom": ["payment_ref", "merchant_id", "env"], + "columnsTo": ["ref_code", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "orders_order_number_check": { + "name": "orders_order_number_check", + "value": "\"orders\".\"order_number\" ~ '[^[:space:]]'" + }, + "orders_payment_ref_check": { + "name": "orders_payment_ref_check", + "value": "\"orders\".\"payment_ref\" ~ '^TAB-[A-Z0-9]+$'" + } + }, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ref_code": { + "name": "ref_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "receiver": { + "name": "receiver", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": true + }, + "token_chain_id": { + "name": "token_chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "intent_url": { + "name": "intent_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_type": { + "name": "payer_type", + "type": "payer_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "payer_email": { + "name": "payer_email", + "type": "citext", + "primaryKey": false, + "notNull": false + }, + "payer_address": { + "name": "payer_address", + "type": "varchar(42)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_transaction_id": { + "name": "reported_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_token_changes": { + "name": "reported_token_changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_next_attempt_at": { + "name": "verification_next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verification_lease_token": { + "name": "verification_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verification_lease_expires_at": { + "name": "verification_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_ref_code_unique": { + "name": "payments_ref_code_unique", + "columns": [ + { + "expression": "ref_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_settlement_evidence_unique": { + "name": "payments_settlement_evidence_unique", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "livemode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_reported_transaction_id_idx": { + "name": "payments_reported_transaction_id_idx", + "columns": [ + { + "expression": "reported_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_merchant_env_created_idx": { + "name": "payments_merchant_env_created_idx", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_pending_sweep_idx": { + "name": "payments_pending_sweep_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_verification_sweep_idx": { + "name": "payments_verification_sweep_idx", + "columns": [ + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reported_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verification_next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_merchant_id_merchants_id_fk": { + "name": "payments_merchant_id_merchants_id_fk", + "tableFrom": "payments", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_id_merchant_env_unique": { + "name": "payments_id_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "payments_ref_merchant_env_unique": { + "name": "payments_ref_merchant_env_unique", + "nullsNotDistinct": false, + "columns": ["ref_code", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "payments_ref_code_check": { + "name": "payments_ref_code_check", + "value": "\"payments\".\"ref_code\" ~ '^TAB-[A-Z0-9]+$'" + }, + "payments_amount_check": { + "name": "payments_amount_check", + "value": "\"payments\".\"amount_usd\" > 0 and \"payments\".\"amount_usd\" < 100000000000000\n and scale(\"payments\".\"amount_usd\") <= 6" + }, + "payments_currency_check": { + "name": "payments_currency_check", + "value": "\"payments\".\"currency\" = 'USD'" + }, + "payments_chain_check": { + "name": "payments_chain_check", + "value": "\"payments\".\"token_chain_id\" = 42161" + }, + "payments_token_check": { + "name": "payments_token_check", + "value": "lower(\"payments\".\"token_address\") = '0xaf88d065e77c8cc2239327c5edb3a432268e5831'" + }, + "payments_receiver_check": { + "name": "payments_receiver_check", + "value": "\"payments\".\"receiver\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"receiver\") <> '0x0000000000000000000000000000000000000000'" + }, + "payments_payer_address_check": { + "name": "payments_payer_address_check", + "value": "\"payments\".\"payer_address\" is null or (\"payments\".\"payer_address\" ~ '^0x[0-9a-fA-F]{40}$'\n and lower(\"payments\".\"payer_address\") <> '0x0000000000000000000000000000000000000000')" + }, + "payments_livemode_check": { + "name": "payments_livemode_check", + "value": "(\"payments\".\"env\" = 'live' and \"payments\".\"livemode\")\n or (\"payments\".\"env\" = 'test' and not \"payments\".\"livemode\")" + }, + "payments_report_check": { + "name": "payments_report_check", + "value": "(\"payments\".\"reported_transaction_id\" is null and \"payments\".\"reported_token_changes\" is null and \"payments\".\"reported_at\" is null)\n or (\"payments\".\"reported_transaction_id\" is not null and btrim(\"payments\".\"reported_transaction_id\") <> ''\n and \"payments\".\"reported_token_changes\" is not null\n and jsonb_typeof(\"payments\".\"reported_token_changes\") = 'array' and \"payments\".\"reported_at\" is not null)" + }, + "payments_settled_at_check": { + "name": "payments_settled_at_check", + "value": "(\"payments\".\"status\" = 'settled' and \"payments\".\"settled_at\" is not null)\n or (\"payments\".\"status\" <> 'settled' and \"payments\".\"settled_at\" is null)" + }, + "payments_verification_lease_check": { + "name": "payments_verification_lease_check", + "value": "(\"payments\".\"verification_lease_token\" is null and \"payments\".\"verification_lease_expires_at\" is null)\n or (\"payments\".\"verification_lease_token\" is not null\n and \"payments\".\"verification_lease_expires_at\" is not null and \"payments\".\"env\" = 'live'\n and \"payments\".\"status\" = 'pending' and \"payments\".\"reported_at\" is not null\n and \"payments\".\"reported_transaction_id\" is not null and \"payments\".\"payer_address\" is not null)" + }, + "payments_verification_schedule_check": { + "name": "payments_verification_schedule_check", + "value": "\"payments\".\"verification_next_attempt_at\" is null\n or (\"payments\".\"env\" = 'live' and \"payments\".\"reported_at\" is not null)" + }, + "payments_failure_reason_check": { + "name": "payments_failure_reason_check", + "value": "(\"payments\".\"status\" = 'failed' and \"payments\".\"failure_reason\" is not null)\n or (\"payments\".\"status\" <> 'failed' and \"payments\".\"failure_reason\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "particle_transaction_id": { + "name": "particle_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "varchar(66)", + "primaryKey": false, + "notNull": false + }, + "token_changes_json": { + "name": "token_changes_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "amount_atomic": { + "name": "amount_atomic", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "verification_method": { + "name": "verification_method", + "type": "settlement_verification_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "verification_trigger": { + "name": "verification_trigger", + "type": "settlement_verification_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlements_payment_id_unique": { + "name": "settlements_payment_id_unique", + "columns": [ + { + "expression": "payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_particle_transaction_id_unique": { + "name": "settlements_particle_transaction_id_unique", + "columns": [ + { + "expression": "particle_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_payment_evidence_fk": { + "name": "settlements_payment_evidence_fk", + "tableFrom": "settlements", + "tableTo": "payments", + "columnsFrom": ["payment_id", "particle_transaction_id", "livemode"], + "columnsTo": ["id", "reported_transaction_id", "livemode"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settlements_id_payment_unique": { + "name": "settlements_id_payment_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id"] + } + }, + "policies": {}, + "checkConstraints": { + "settlements_amount_atomic_check": { + "name": "settlements_amount_atomic_check", + "value": "\"settlements\".\"amount_atomic\" > 0 and \"settlements\".\"amount_atomic\" = trunc(\"settlements\".\"amount_atomic\")\n and \"settlements\".\"amount_atomic\" < 1000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "settlements_token_changes_check": { + "name": "settlements_token_changes_check", + "value": "jsonb_typeof(\"settlements\".\"token_changes_json\") = 'array'" + }, + "settlements_tx_hash_check": { + "name": "settlements_tx_hash_check", + "value": "\"settlements\".\"tx_hash\" is null or \"settlements\".\"tx_hash\" ~ '^0x[0-9a-fA-F]{64}$'" + }, + "settlements_simulation_check": { + "name": "settlements_simulation_check", + "value": "(\"settlements\".\"verification_method\" = 'simulated_test' and not \"settlements\".\"livemode\")\n or (\"settlements\".\"verification_method\" <> 'simulated_test' and \"settlements\".\"livemode\")" + } + }, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payment_id": { + "name": "payment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retry_chain_id": { + "name": "retry_chain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_body_hash": { + "name": "request_body_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "encode(digest(\"webhook_deliveries\".\"request_body\", 'sha256'), 'hex')", + "type": "stored" + } + }, + "type": { + "name": "type", + "type": "webhook_delivery_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "webhook_delivery_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "webhook_delivery_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_kind": { + "name": "failure_kind", + "type": "webhook_failure_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "signature_header": { + "name": "signature_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body_snippet": { + "name": "response_body_snippet", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_delivery_id": { + "name": "parent_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_attempt": { + "name": "parent_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "superseded_by_attempt": { + "name": "superseded_by_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_deliveries_chain_attempt_unique": { + "name": "webhook_deliveries_chain_attempt_unique", + "columns": [ + { + "expression": "retry_chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_automatic_settlement_root_unique": { + "name": "webhook_deliveries_automatic_settlement_root_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"attempt\" = 1", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_dashboard_head_idx": { + "name": "webhook_deliveries_dashboard_head_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook_deliveries\".\"trigger\" = 'auto' and \"webhook_deliveries\".\"type\" = 'payment'\n and \"webhook_deliveries\".\"superseded_by_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_due_idx": { + "name": "webhook_deliveries_due_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_event_idx": { + "name": "webhook_deliveries_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_deliveries_endpoint_scope_fk": { + "name": "webhook_deliveries_endpoint_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_endpoints", + "columnsFrom": ["endpoint_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_payment_scope_fk": { + "name": "webhook_deliveries_payment_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "payments", + "columnsFrom": ["payment_id", "merchant_id", "env"], + "columnsTo": ["id", "merchant_id", "env"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_settlement_payment_fk": { + "name": "webhook_deliveries_settlement_payment_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "settlements", + "columnsFrom": ["settlement_id", "payment_id"], + "columnsTo": ["id", "payment_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_scope_fk": { + "name": "webhook_deliveries_chain_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "retry_chain_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_chain_evidence_fk": { + "name": "webhook_deliveries_chain_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["retry_chain_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_scope_fk": { + "name": "webhook_deliveries_parent_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": [ + "parent_delivery_id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "columnsTo": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_evidence_fk": { + "name": "webhook_deliveries_parent_evidence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "payment_id", "settlement_id"], + "columnsTo": ["id", "payment_id", "settlement_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_parent_sequence_fk": { + "name": "webhook_deliveries_parent_sequence_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["parent_delivery_id", "retry_chain_id", "parent_attempt"], + "columnsTo": ["id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_successor_scope_fk": { + "name": "webhook_deliveries_successor_scope_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_deliveries", + "columnsFrom": ["superseded_by_id", "id", "retry_chain_id", "superseded_by_attempt"], + "columnsTo": ["id", "parent_delivery_id", "retry_chain_id", "attempt"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_id_chain_scope_unique": { + "name": "webhook_deliveries_id_chain_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type", + "trigger" + ] + }, + "webhook_deliveries_id_tenant_unique": { + "name": "webhook_deliveries_id_tenant_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + }, + "webhook_deliveries_id_event_scope_unique": { + "name": "webhook_deliveries_id_event_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "endpoint_id", + "merchant_id", + "env", + "event_id", + "request_body_hash", + "type" + ] + }, + "webhook_deliveries_id_evidence_unique": { + "name": "webhook_deliveries_id_evidence_unique", + "nullsNotDistinct": false, + "columns": ["id", "payment_id", "settlement_id"] + }, + "webhook_deliveries_id_retry_attempt_unique": { + "name": "webhook_deliveries_id_retry_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "retry_chain_id", "attempt"] + }, + "webhook_deliveries_id_parent_chain_attempt_unique": { + "name": "webhook_deliveries_id_parent_chain_attempt_unique", + "nullsNotDistinct": false, + "columns": ["id", "parent_delivery_id", "retry_chain_id", "attempt"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_deliveries_event_id_check": { + "name": "webhook_deliveries_event_id_check", + "value": "\"webhook_deliveries\".\"event_id\" ~ '^evt_[A-Za-z0-9_-]+$'" + }, + "webhook_deliveries_body_hash_check": { + "name": "webhook_deliveries_body_hash_check", + "value": "\"webhook_deliveries\".\"request_body_hash\" ~ '^[0-9a-f]{64}$'" + }, + "webhook_deliveries_chain_root_check": { + "name": "webhook_deliveries_chain_root_check", + "value": "((\"webhook_deliveries\".\"attempt\" = 1 and \"webhook_deliveries\".\"retry_chain_id\" = \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_attempt\" is null)\n or (\"webhook_deliveries\".\"attempt\" > 1 and \"webhook_deliveries\".\"retry_chain_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"parent_delivery_id\" is not null\n and \"webhook_deliveries\".\"parent_attempt\" = \"webhook_deliveries\".\"attempt\" - 1))\n and (\"webhook_deliveries\".\"parent_delivery_id\" is null or \"webhook_deliveries\".\"parent_delivery_id\" <> \"webhook_deliveries\".\"id\")" + }, + "webhook_deliveries_attempt_check": { + "name": "webhook_deliveries_attempt_check", + "value": "\"webhook_deliveries\".\"attempt\" between 1 and 3" + }, + "webhook_deliveries_type_check": { + "name": "webhook_deliveries_type_check", + "value": "(\"webhook_deliveries\".\"type\" = 'payment' and \"webhook_deliveries\".\"payment_id\" is not null\n and \"webhook_deliveries\".\"settlement_id\" is not null)\n or (\"webhook_deliveries\".\"type\" = 'test' and \"webhook_deliveries\".\"payment_id\" is null\n and \"webhook_deliveries\".\"settlement_id\" is null)" + }, + "webhook_deliveries_status_code_check": { + "name": "webhook_deliveries_status_code_check", + "value": "\"webhook_deliveries\".\"status_code\" is null or \"webhook_deliveries\".\"status_code\" between 100 and 599" + }, + "webhook_deliveries_response_time_check": { + "name": "webhook_deliveries_response_time_check", + "value": "\"webhook_deliveries\".\"response_time_ms\" is null or \"webhook_deliveries\".\"response_time_ms\" >= 0" + }, + "webhook_deliveries_signature_check": { + "name": "webhook_deliveries_signature_check", + "value": "\"webhook_deliveries\".\"signature_header\" is null\n or \"webhook_deliveries\".\"signature_header\" ~ '^t=[0-9]+,v1=[0-9a-f]{64}$'" + }, + "webhook_deliveries_lease_check": { + "name": "webhook_deliveries_lease_check", + "value": "(\"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null)\n or (\"webhook_deliveries\".\"lease_token\" is not null and \"webhook_deliveries\".\"lease_expires_at\" is not null)" + }, + "webhook_deliveries_successor_check": { + "name": "webhook_deliveries_successor_check", + "value": "(\"webhook_deliveries\".\"superseded_by_id\" is null and \"webhook_deliveries\".\"superseded_by_attempt\" is null)\n or (\"webhook_deliveries\".\"superseded_by_id\" is not null\n and \"webhook_deliveries\".\"superseded_by_id\" <> \"webhook_deliveries\".\"id\"\n and \"webhook_deliveries\".\"superseded_by_attempt\" = \"webhook_deliveries\".\"attempt\" + 1\n and \"webhook_deliveries\".\"superseded_by_attempt\" between 2 and 3)" + }, + "webhook_deliveries_result_check": { + "name": "webhook_deliveries_result_check", + "value": "coalesce(((\"webhook_deliveries\".\"result\" = 'pending' and \"webhook_deliveries\".\"completed_at\" is null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" is null and \"webhook_deliveries\".\"response_time_ms\" is null\n and \"webhook_deliveries\".\"response_body_snippet\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'delivered' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" is null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"status_code\" between 200 and 299\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'retrying' and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is not null and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" in ('failed', 'timeout') and \"webhook_deliveries\".\"attempt\" < 3\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is not null and \"webhook_deliveries\".\"started_at\" is not null\n and \"webhook_deliveries\".\"response_time_ms\" is not null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is not null\n and ((\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network'))\n or (\"webhook_deliveries\".\"result\" = 'timeout' and \"webhook_deliveries\".\"failure_kind\" = 'timeout'))\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))\n or (\"webhook_deliveries\".\"result\" = 'failed' and \"webhook_deliveries\".\"failure_kind\" = 'configuration'\n and \"webhook_deliveries\".\"completed_at\" is not null and \"webhook_deliveries\".\"next_retry_at\" is null\n and \"webhook_deliveries\".\"signature_header\" is null and \"webhook_deliveries\".\"status_code\" is null\n and \"webhook_deliveries\".\"response_time_ms\" is null and \"webhook_deliveries\".\"lease_token\" is null\n and \"webhook_deliveries\".\"lease_expires_at\" is null and \"webhook_deliveries\".\"superseded_by_id\" is null)\n or (\"webhook_deliveries\".\"result\" = 'gave_up' and \"webhook_deliveries\".\"attempt\" = 3\n and \"webhook_deliveries\".\"completed_at\" is not null\n and \"webhook_deliveries\".\"failure_kind\" in ('http', 'network', 'timeout')\n and \"webhook_deliveries\".\"next_retry_at\" is null and \"webhook_deliveries\".\"signature_header\" is not null\n and \"webhook_deliveries\".\"started_at\" is not null and \"webhook_deliveries\".\"response_time_ms\" is not null\n and \"webhook_deliveries\".\"lease_token\" is null and \"webhook_deliveries\".\"lease_expires_at\" is null\n and \"webhook_deliveries\".\"superseded_by_id\" is null\n and ((\"webhook_deliveries\".\"failure_kind\" = 'http' and (\"webhook_deliveries\".\"status_code\" < 200\n or \"webhook_deliveries\".\"status_code\" > 299))\n or (\"webhook_deliveries\".\"failure_kind\" in ('network', 'timeout') and \"webhook_deliveries\".\"status_code\" is null)))), false)" + } + }, + "isRLSEnabled": false + }, + "public.webhook_endpoints": { + "name": "webhook_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_nonce": { + "name": "secret_nonce", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "secret_auth_tag": { + "name": "secret_auth_tag", + "type": "varchar(22)", + "primaryKey": false, + "notNull": false + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_last4": { + "name": "secret_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_endpoints_one_active_per_env": { + "name": "webhook_endpoints_one_active_per_env", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_endpoints\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_endpoints_merchant_id_merchants_id_fk": { + "name": "webhook_endpoints_merchant_id_merchants_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "merchants", + "columnsFrom": ["merchant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_endpoints_id_scope_unique": { + "name": "webhook_endpoints_id_scope_unique", + "nullsNotDistinct": false, + "columns": ["id", "merchant_id", "env"] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_endpoints_url_check": { + "name": "webhook_endpoints_url_check", + "value": "char_length(btrim(\"webhook_endpoints\".\"url\")) between 1 and 2048\n and (\"webhook_endpoints\".\"url\" ~ '^https://[^/?#[:space:]@]+([:/?#]|$)'\n or (\"webhook_endpoints\".\"env\" = 'test'\n and \"webhook_endpoints\".\"url\" ~ '^http://(127\\.0\\.0\\.1|\\[::1\\]|localhost)(:[0-9]+)?/'))\n and \"webhook_endpoints\".\"url\" !~* '^https://(localhost\\.?|127(\\.[0-9]{1,3}){3}|10(\\.[0-9]{1,3}){3}|192\\.168(\\.[0-9]{1,3}){2}|169\\.254(\\.[0-9]{1,3}){2}|172\\.(1[6-9]|2[0-9]|3[01])(\\.[0-9]{1,3}){2}|\\[(::1|f[cd][0-9a-f:]*|fe[89ab][0-9a-f:]*)\\])([:/?#]|$)'" + }, + "webhook_endpoints_last4_check": { + "name": "webhook_endpoints_last4_check", + "value": "char_length(\"webhook_endpoints\".\"secret_last4\") = 4" + }, + "webhook_endpoints_secret_envelope_check": { + "name": "webhook_endpoints_secret_envelope_check", + "value": "coalesce(((\"webhook_endpoints\".\"deleted_at\" is null\n and \"webhook_endpoints\".\"secret_ciphertext\" is not null\n and char_length(\"webhook_endpoints\".\"secret_ciphertext\") > 0\n and \"webhook_endpoints\".\"secret_nonce\" is not null\n and \"webhook_endpoints\".\"secret_nonce\" ~ '^[A-Za-z0-9_-]{16}$'\n and \"webhook_endpoints\".\"secret_auth_tag\" is not null\n and \"webhook_endpoints\".\"secret_auth_tag\" ~ '^[A-Za-z0-9_-]{22}$'\n and \"webhook_endpoints\".\"secret_key_version\" is not null\n and \"webhook_endpoints\".\"secret_key_version\" > 0)\n or (\"webhook_endpoints\".\"deleted_at\" is not null\n and \"webhook_endpoints\".\"secret_ciphertext\" is null and \"webhook_endpoints\".\"secret_nonce\" is null\n and \"webhook_endpoints\".\"secret_auth_tag\" is null and \"webhook_endpoints\".\"secret_key_version\" is null)), false)" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.api_key_permissions": { + "name": "api_key_permissions", + "schema": "public", + "values": ["full", "read_only"] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": ["secret", "publishable"] + }, + "public.environment": { + "name": "environment", + "schema": "public", + "values": ["test", "live"] + }, + "public.quickstart_source": { + "name": "quickstart_source", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.receiving_address_source": { + "name": "receiving_address_source", + "schema": "public", + "values": ["magic_default", "custom"] + }, + "public.agent_status": { + "name": "agent_status", + "schema": "public", + "values": ["provisioned", "paused", "frozen", "cancelled", "nuked"] + }, + "public.cap_frequency": { + "name": "cap_frequency", + "schema": "public", + "values": ["daily", "weekly", "monthly", "never"] + }, + "public.cap_reset_reason": { + "name": "cap_reset_reason", + "schema": "public", + "values": ["schedule", "manual", "frequency_change"] + }, + "public.leash_network": { + "name": "leash_network", + "schema": "public", + "values": ["eip155:8453", "eip155:42161"] + }, + "public.agent_event_surface": { + "name": "agent_event_surface", + "schema": "public", + "values": ["agent", "web", "pwa", "push_action", "system"] + }, + "public.agent_event_type": { + "name": "agent_event_type", + "schema": "public", + "values": ["connect", "sign", "block", "revoke"] + }, + "public.leash_asset": { + "name": "leash_asset", + "schema": "public", + "values": ["USDC"] + }, + "public.notification_tier": { + "name": "notification_tier", + "schema": "public", + "values": ["2", "3"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "cap_75", + "cap_blocked", + "unusual_domain", + "cap_lowered_halt", + "float_low", + "float_empty" + ] + }, + "public.receipt_status": { + "name": "receipt_status", + "schema": "public", + "values": ["pending", "settled", "failed", "blocked"] + }, + "public.payer_type": { + "name": "payer_type", + "schema": "public", + "values": ["human", "agent"] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": ["pending", "settled", "failed"] + }, + "public.settlement_verification_method": { + "name": "settlement_verification_method", + "schema": "public", + "values": ["rpc", "particle", "x402_receipt", "simulated_test"] + }, + "public.settlement_verification_trigger": { + "name": "settlement_verification_trigger", + "schema": "public", + "values": ["inline", "cron_sweep"] + }, + "public.webhook_delivery_result": { + "name": "webhook_delivery_result", + "schema": "public", + "values": ["pending", "delivered", "retrying", "failed", "timeout", "gave_up"] + }, + "public.webhook_delivery_trigger": { + "name": "webhook_delivery_trigger", + "schema": "public", + "values": ["auto", "manual"] + }, + "public.webhook_delivery_type": { + "name": "webhook_delivery_type", + "schema": "public", + "values": ["payment", "test"] + }, + "public.webhook_failure_kind": { + "name": "webhook_failure_kind", + "schema": "public", + "values": ["http", "network", "timeout", "configuration"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 97070e1..1c3bf24 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -120,6 +120,27 @@ "when": 1784275503075, "tag": "0016_cold_riptide", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1784279808287, + "tag": "0017_fine_black_tom", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1784284232518, + "tag": "0018_lucky_tusk", + "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1784284950643, + "tag": "0019_aspiring_frightful_four", + "breakpoints": true } ] } diff --git a/apps/web/lib/auth/current-owner.test.ts b/apps/web/lib/auth/current-owner.test.ts new file mode 100644 index 0000000..2cdbad5 --- /dev/null +++ b/apps/web/lib/auth/current-owner.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + class InactiveOwnerSessionError extends Error {} + class InvalidOwnerSessionError extends Error {} + return { + cookieValue: undefined as string | undefined, + InactiveOwnerSessionError, + InvalidOwnerSessionError, + loadOwnerSession: vi.fn(), + redirect: vi.fn((path: string) => { + throw new Error(`redirect:${path}`); + }), + }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("next/headers", () => ({ + cookies: vi.fn(async () => ({ + get: () => (mocks.cookieValue ? { value: mocks.cookieValue } : undefined), + })), +})); +vi.mock("next/navigation", () => ({ redirect: mocks.redirect })); +vi.mock("../db/server", () => ({ getServerDatabase: () => ({ db: "database" }) })); +vi.mock("./owner-session", () => ({ + InactiveOwnerSessionError: mocks.InactiveOwnerSessionError, + InvalidOwnerSessionError: mocks.InvalidOwnerSessionError, + loadOwnerSession: mocks.loadOwnerSession, +})); + +import { getCurrentOwner, requireCurrentOwner } from "./current-owner"; + +describe("current Leash owner server helper", () => { + beforeEach(() => { + mocks.cookieValue = undefined; + mocks.loadOwnerSession.mockReset(); + mocks.redirect.mockClear(); + }); + + it("loads the owner from tab_session", async () => { + const owner = { email: "owner@example.test", userId: "owner-id" }; + mocks.cookieValue = "signed-session"; + mocks.loadOwnerSession.mockResolvedValue(owner); + + await expect(getCurrentOwner()).resolves.toBe(owner); + expect(mocks.loadOwnerSession).toHaveBeenCalledWith("database", "signed-session"); + }); + + it("returns no owner for missing, invalid, or inactive sessions", async () => { + await expect(getCurrentOwner()).resolves.toBeUndefined(); + + mocks.cookieValue = "invalid"; + mocks.loadOwnerSession.mockRejectedValueOnce(new mocks.InvalidOwnerSessionError()); + await expect(getCurrentOwner()).resolves.toBeUndefined(); + + mocks.cookieValue = "inactive"; + mocks.loadOwnerSession.mockRejectedValueOnce(new mocks.InactiveOwnerSessionError()); + await expect(getCurrentOwner()).resolves.toBeUndefined(); + }); + + it("redirects an unauthenticated owner to the dedicated Leash login", async () => { + await expect(requireCurrentOwner()).rejects.toThrow("redirect:/leash/login"); + expect(mocks.redirect).toHaveBeenCalledWith("/leash/login"); + }); + + it("does not hide unexpected owner lookup failures", async () => { + mocks.cookieValue = "signed-session"; + mocks.loadOwnerSession.mockRejectedValue(new Error("database unavailable")); + + await expect(getCurrentOwner()).rejects.toThrow("database unavailable"); + }); +}); diff --git a/apps/web/lib/auth/current-owner.ts b/apps/web/lib/auth/current-owner.ts new file mode 100644 index 0000000..ab182c2 --- /dev/null +++ b/apps/web/lib/auth/current-owner.ts @@ -0,0 +1,32 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerDatabase } from "../db/server"; +import { + InactiveOwnerSessionError, + InvalidOwnerSessionError, + loadOwnerSession, +} from "./owner-session"; +import { SESSION_COOKIE_NAME } from "./session"; + +export async function getCurrentOwner() { + const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value; + if (!token) return undefined; + + try { + return await loadOwnerSession(getServerDatabase().db, token); + } catch (error) { + if (error instanceof InvalidOwnerSessionError || error instanceof InactiveOwnerSessionError) { + return undefined; + } + throw error; + } +} + +export async function requireCurrentOwner() { + const owner = await getCurrentOwner(); + if (!owner) redirect("/leash/login"); + return owner; +} diff --git a/apps/web/lib/auth/leash-key.ts b/apps/web/lib/auth/leash-key.ts index e481430..8acbd0c 100644 --- a/apps/web/lib/auth/leash-key.ts +++ b/apps/web/lib/auth/leash-key.ts @@ -42,10 +42,18 @@ export interface LeashKeyScope { agentId: string; } +export interface OwnedLeashKeyScope extends LeashKeyScope { + ownerId: string; +} + export interface LeashKeyTarget extends LeashKeyScope { keyId: string; } +export interface OwnedLeashKeyTarget extends LeashKeyTarget { + ownerId: string; +} + export interface LeashKeyPrincipal { agentId: string; leashKeyId: string; @@ -87,12 +95,39 @@ export function readBearerLeashKey(authorizationHeader: string | null) { return bearer; } -export async function issueLeashKey(db: Database, input: LeashKeyScope) { +type InternalLeashKeyScope = LeashKeyScope & { ownerId?: string }; +type InternalLeashKeyTarget = LeashKeyTarget & { ownerId?: string }; + +function agentScope(input: InternalLeashKeyScope) { + return input.ownerId + ? and(eq(agents.id, input.agentId), eq(agents.ownerId, input.ownerId)) + : eq(agents.id, input.agentId); +} + +export async function readOwnerLeashKey(db: Database, input: OwnedLeashKeyScope) { + return db.transaction(async (transaction) => { + const [agent] = await transaction + .select({ id: agents.id }) + .from(agents) + .where(agentScope(input)) + .for("share"); + if (!agent) throw new LeashAgentNotFoundError(); + + const [key] = await transaction + .select(keySummary) + .from(leashKeys) + .where(and(eq(leashKeys.agentId, input.agentId), isNull(leashKeys.revokedAt))) + .limit(1); + return key ?? null; + }); +} + +async function issueScopedLeashKey(db: Database, input: InternalLeashKeyScope) { return db.transaction(async (transaction) => { const [agent] = await transaction .select({ id: agents.id }) .from(agents) - .where(eq(agents.id, input.agentId)) + .where(agentScope(input)) .for("update"); if (!agent) throw new LeashAgentNotFoundError(); @@ -119,12 +154,20 @@ export async function issueLeashKey(db: Database, input: LeashKeyScope) { }); } -export async function rotateLeashKey(db: Database, input: LeashKeyTarget) { +export function issueLeashKey(db: Database, input: LeashKeyScope) { + return issueScopedLeashKey(db, input); +} + +export function issueOwnerLeashKey(db: Database, input: OwnedLeashKeyScope) { + return issueScopedLeashKey(db, input); +} + +async function rotateScopedLeashKey(db: Database, input: InternalLeashKeyTarget) { return db.transaction(async (transaction) => { const [agent] = await transaction .select({ id: agents.id }) .from(agents) - .where(eq(agents.id, input.agentId)) + .where(agentScope(input)) .for("update"); if (!agent) throw new LeashAgentNotFoundError(); @@ -173,6 +216,14 @@ export async function rotateLeashKey(db: Database, input: LeashKeyTarget) { }); } +export function rotateLeashKey(db: Database, input: LeashKeyTarget) { + return rotateScopedLeashKey(db, input); +} + +export function rotateOwnerLeashKey(db: Database, input: OwnedLeashKeyTarget) { + return rotateScopedLeashKey(db, input); +} + export async function authenticateLeashKey( db: Database, authorizationHeader: string | null, diff --git a/apps/web/lib/auth/magic-admin.ts b/apps/web/lib/auth/magic-admin.ts index f4768e9..c10475e 100644 --- a/apps/web/lib/auth/magic-admin.ts +++ b/apps/web/lib/auth/magic-admin.ts @@ -130,3 +130,12 @@ export async function verifyBuyerDidToken(didToken: string) { payerAddress: identity.publicAddress, }; } + +export async function verifyOwnerDidToken(didToken: string) { + const identity = completeIdentity(await verifiedMetadata(didToken)); + + return { + email: identity.email, + magicIssuer: identity.issuer, + }; +} diff --git a/apps/web/lib/auth/merchant-session.integration.test.ts b/apps/web/lib/auth/merchant-session.integration.test.ts index 56e6873..466c4c8 100644 --- a/apps/web/lib/auth/merchant-session.integration.test.ts +++ b/apps/web/lib/auth/merchant-session.integration.test.ts @@ -4,6 +4,7 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { createDatabase } from "../db/client"; import { provisionMerchant } from "../db/provision-merchant"; +import { users } from "../db/schema"; import { createSessionToken } from "./session"; import { InactiveMerchantSessionError, @@ -71,6 +72,38 @@ describe("protected merchant session lookup with real PostgreSQL", () => { ); }); + it("does not promote a Leash-only owner token without a real merchant", async () => { + const email = "leash-only@example.test"; + const [owner] = await connection.db + .insert(users) + .values({ email, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ userId: users.id }); + if (!owner) throw new Error("PostgreSQL did not return the owner user"); + const token = await createSessionToken({ email, userId: owner.userId }, secret); + + await expect(loadMerchantSession(connection.db, token, secret)).rejects.toBeInstanceOf( + InactiveMerchantSessionError, + ); + }); + + it("resolves a real merchant from the shared owner-only token in safe test mode", async () => { + const provisioned = await provisionMerchant( + connection.db, + signupInput("shared-owner@example.test"), + ); + const token = await createSessionToken( + { email: "shared-owner@example.test", userId: provisioned.userId }, + secret, + ); + + await expect(loadMerchantSession(connection.db, token, secret)).resolves.toMatchObject({ + email: "shared-owner@example.test", + merchantId: provisioned.merchantId, + mode: "test", + userId: provisioned.userId, + }); + }); + it("rejects a signed session after its tenant is deleted", async () => { const provisioned = await provisionMerchant(connection.db, signupInput("deleted@example.test")); const token = await createSessionToken( diff --git a/apps/web/lib/auth/owner-request.ts b/apps/web/lib/auth/owner-request.ts new file mode 100644 index 0000000..4a37e38 --- /dev/null +++ b/apps/web/lib/auth/owner-request.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import type { NextRequest } from "next/server"; + +import { getServerDatabase } from "../db/server"; +import { + InactiveOwnerSessionError, + InvalidOwnerSessionError, + loadOwnerSession, +} from "./owner-session"; +import { SESSION_COOKIE_NAME } from "./session"; + +export async function authenticateOwnerRequest(request: NextRequest) { + const token = request.cookies.get(SESSION_COOKIE_NAME)?.value; + if (!token) return undefined; + + try { + return await loadOwnerSession(getServerDatabase().db, token); + } catch (error) { + if (error instanceof InvalidOwnerSessionError || error instanceof InactiveOwnerSessionError) { + return undefined; + } + throw error; + } +} diff --git a/apps/web/lib/auth/owner-session.integration.test.ts b/apps/web/lib/auth/owner-session.integration.test.ts new file mode 100644 index 0000000..ac3a8b1 --- /dev/null +++ b/apps/web/lib/auth/owner-session.integration.test.ts @@ -0,0 +1,157 @@ +import { randomUUID } from "node:crypto"; + +import { NextRequest } from "next/server"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createDatabase } from "../db/client"; +import { provisionMerchant } from "../db/provision-merchant"; +import { users } from "../db/schema"; +import { authenticateOwnerRequest } from "./owner-request"; +import { + InactiveOwnerSessionError, + InvalidOwnerSessionError, + loadOwnerSession, +} from "./owner-session"; +import { createSessionToken } from "./session"; + +vi.mock("server-only", () => ({})); + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for PostgreSQL integration tests"); +} + +const secret = "x".repeat(64); +const connection = createDatabase(databaseUrl, 1); + +function signupInput(label: string) { + return { + email: `${label}-${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + receivingAddress: "0x1111111111111111111111111111111111111111", + }; +} + +async function ownerSession(label: string) { + const email = `${label}-${randomUUID()}@example.test`; + const [owner] = await connection.db + .insert(users) + .values({ email, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ userId: users.id }); + if (!owner) throw new Error("PostgreSQL did not return the Leash owner"); + const token = await createSessionToken({ email, userId: owner.userId }, secret); + return { email, token, userId: owner.userId }; +} + +describe("Leash owner session lookup with real PostgreSQL", () => { + beforeEach(async () => { + await connection.client`truncate table users cascade`; + }); + + afterAll(async () => { + await connection.client.end(); + }); + + it("loads only the signed user as the Leash owner", async () => { + const owner = await ownerSession("valid-owner"); + + await expect(loadOwnerSession(connection.db, owner.token, secret)).resolves.toStrictEqual({ + email: owner.email, + userId: owner.userId, + }); + }); + + it("rejects a signed session whose user id and email identify different users", async () => { + const first = await ownerSession("cross-first"); + const second = await ownerSession("cross-second"); + const crossedToken = await createSessionToken( + { + email: second.email, + userId: first.userId, + }, + secret, + ); + + await expect(loadOwnerSession(connection.db, crossedToken, secret)).rejects.toBeInstanceOf( + InactiveOwnerSessionError, + ); + }); + + it("rejects a signed session after the user is deleted", async () => { + const owner = await ownerSession("deleted-owner"); + await connection.db.delete(users); + + await expect(loadOwnerSession(connection.db, owner.token, secret)).rejects.toBeInstanceOf( + InactiveOwnerSessionError, + ); + }); + + it("rejects a forged session signature", async () => { + const owner = await ownerSession("forged-owner"); + const [header, payload, signature = ""] = owner.token.split("."); + const forgedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; + const forgedToken = `${header}.${payload}.${forgedSignature}`; + + await expect(loadOwnerSession(connection.db, forgedToken, secret)).rejects.toBeInstanceOf( + InvalidOwnerSessionError, + ); + }); + + it("does not promote a foreign merchant resource claim into owner scope", async () => { + const signedOwner = await ownerSession("resource-owner"); + const foreignInput = signupInput("resource-foreign"); + const foreignMerchant = await provisionMerchant(connection.db, foreignInput); + const mismatchedResourceToken = await createSessionToken( + { + email: signedOwner.email, + merchantId: foreignMerchant.merchantId, + mode: "live", + userId: signedOwner.userId, + }, + secret, + ); + + await expect( + loadOwnerSession(connection.db, mismatchedResourceToken, secret), + ).resolves.toStrictEqual({ + email: signedOwner.email, + userId: signedOwner.userId, + }); + }); + + it("authenticates an owner request from the existing tab_session cookie", async () => { + const owner = await ownerSession("request-owner"); + const requestToken = await createSessionToken({ + email: owner.email, + userId: owner.userId, + }); + const request = new NextRequest("https://tab.example.test/api/leash/caps", { + headers: { cookie: `tab_session=${requestToken}` }, + }); + + await expect(authenticateOwnerRequest(request)).resolves.toStrictEqual({ + email: owner.email, + userId: owner.userId, + }); + }); + + it("returns one unauthenticated result for missing, invalid, and inactive owner cookies", async () => { + const owner = await ownerSession("request-inactive"); + const inactiveToken = await createSessionToken({ email: owner.email, userId: owner.userId }); + await connection.db.delete(users); + const requests = [ + new NextRequest("https://tab.example.test/api/leash/caps"), + new NextRequest("https://tab.example.test/api/leash/caps", { + headers: { cookie: "tab_session=not-a-jwt" }, + }), + new NextRequest("https://tab.example.test/api/leash/caps", { + headers: { cookie: `tab_session=${inactiveToken}` }, + }), + ]; + + for (const request of requests) { + await expect(authenticateOwnerRequest(request)).resolves.toBeUndefined(); + } + }); +}); diff --git a/apps/web/lib/auth/owner-session.ts b/apps/web/lib/auth/owner-session.ts new file mode 100644 index 0000000..86a82af --- /dev/null +++ b/apps/web/lib/auth/owner-session.ts @@ -0,0 +1,43 @@ +import "server-only"; + +import { and, eq } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { users } from "../db/schema"; +import { InvalidSessionTokenError, readSessionToken } from "./session"; + +export class InactiveOwnerSessionError extends Error { + constructor() { + super("The owner session is no longer active"); + this.name = "InactiveOwnerSessionError"; + } +} + +export class InvalidOwnerSessionError extends Error { + constructor(options?: ErrorOptions) { + super("The owner session is invalid", options); + this.name = "InvalidOwnerSessionError"; + } +} + +export async function loadOwnerSession(db: Database, token: string, secret?: string) { + let session: Awaited>; + + try { + session = await readSessionToken(token, secret); + } catch (error) { + if (error instanceof InvalidSessionTokenError) { + throw new InvalidOwnerSessionError({ cause: error }); + } + throw error; + } + + const [owner] = await db + .select({ email: users.email, userId: users.id }) + .from(users) + .where(and(eq(users.id, session.userId), eq(users.email, session.email))) + .limit(1); + + if (!owner) throw new InactiveOwnerSessionError(); + return owner; +} diff --git a/apps/web/lib/auth/session-principal.ts b/apps/web/lib/auth/session-principal.ts index c63b992..ed99cb9 100644 --- a/apps/web/lib/auth/session-principal.ts +++ b/apps/web/lib/auth/session-principal.ts @@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm"; import type { Database } from "../db/client"; import { merchants, users } from "../db/schema"; -import { InvalidSessionTokenError, readSessionToken } from "./session"; +import { InvalidSessionTokenError, isMerchantSession, readSessionToken } from "./session"; export class InactiveMerchantSessionError extends Error { constructor() { @@ -30,6 +30,8 @@ export async function loadMerchantSession(db: Database, token: string, secret?: throw error; } + const merchantSession = isMerchantSession(session) ? session : undefined; + const [principal] = await db .select({ businessName: merchants.businessName, @@ -44,7 +46,13 @@ export async function loadMerchantSession(db: Database, token: string, secret?: }) .from(users) .innerJoin(merchants, eq(merchants.userId, users.id)) - .where(and(eq(users.id, session.userId), eq(merchants.id, session.merchantId))) + .where( + and( + eq(users.id, session.userId), + eq(users.email, session.email), + merchantSession ? eq(merchants.id, merchantSession.merchantId) : undefined, + ), + ) .limit(1); if (!principal) { @@ -58,7 +66,7 @@ export async function loadMerchantSession(db: Database, token: string, secret?: logoEtag: principal.logoEtag, logoUrl: principal.logoUrl, merchantId: principal.merchantId, - mode: session.mode, + mode: merchantSession?.mode ?? "test", receivingAddress: principal.receivingAddress, receivingAddressSource: principal.receivingAddressSource, userId: principal.userId, diff --git a/apps/web/lib/auth/session.test.ts b/apps/web/lib/auth/session.test.ts index 662ca1a..a8ddd5a 100644 --- a/apps/web/lib/auth/session.test.ts +++ b/apps/web/lib/auth/session.test.ts @@ -10,6 +10,10 @@ const claims = { mode: "test" as const, userId: "20000000-0000-4000-8000-000000000002", }; +const ownerClaims = { + email: claims.email, + userId: claims.userId, +}; describe("merchant session tokens with real JOSE signing", () => { it("round-trips validated merchant claims", async () => { @@ -18,6 +22,29 @@ describe("merchant session tokens with real JOSE signing", () => { await expect(readSessionToken(token, secret)).resolves.toEqual(claims); }); + it("round-trips a genuine owner token without merchant resource claims", async () => { + const token = await createSessionToken(ownerClaims, secret); + + await expect(readSessionToken(token, secret)).resolves.toStrictEqual(ownerClaims); + }); + + it.each([ + { merchantId: claims.merchantId }, + { mode: claims.mode }, + ])("rejects a token with a partial merchant claim set", async (partialMerchantClaims) => { + const key = new TextEncoder().encode(secret); + const token = await new SignJWT({ email: claims.email, ...partialMerchantClaims }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer("tab") + .setAudience("tab-web") + .setSubject(claims.userId) + .setIssuedAt() + .setExpirationTime("1h") + .sign(key); + + await expect(readSessionToken(token, secret)).rejects.toThrow("Session claims are invalid"); + }); + it("rejects a token whose signature was changed", async () => { const token = await createSessionToken(claims, secret); const [header, payload, signature] = token.split("."); diff --git a/apps/web/lib/auth/session.ts b/apps/web/lib/auth/session.ts index d0ccd93..eaefdcc 100644 --- a/apps/web/lib/auth/session.ts +++ b/apps/web/lib/auth/session.ts @@ -7,13 +7,18 @@ const sessionIssuer = "tab"; const sessionAudience = "tab-web"; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -export interface MerchantSession { +export interface UserSession { email: string; + userId: string; +} + +export interface MerchantSession extends UserSession { merchantId: string; mode: "test" | "live"; - userId: string; } +export type Session = UserSession | MerchantSession; + export class InvalidSessionTokenError extends Error { constructor(message = "The merchant session token is invalid", options?: ErrorOptions) { super(message, options); @@ -46,29 +51,48 @@ export function sessionSigningConfigured() { return Boolean(secret && new TextEncoder().encode(secret).byteLength >= 32); } -function validatedClaims(payload: Record): MerchantSession { +function validatedClaims(payload: Record): Session { const { email, merchantId, mode, sub: userId } = payload; if ( typeof email !== "string" || !email.includes("@") || - typeof merchantId !== "string" || - !uuid.test(merchantId) || - (mode !== "test" && mode !== "live") || typeof userId !== "string" || !uuid.test(userId) ) { throw new Error("Session claims are invalid"); } + const hasMerchantId = merchantId !== undefined; + const hasMode = mode !== undefined; + if (hasMerchantId !== hasMode) throw new Error("Session claims are invalid"); + if (!hasMerchantId) return { email, userId }; + if ( + typeof merchantId !== "string" || + !uuid.test(merchantId) || + (mode !== "test" && mode !== "live") + ) { + throw new Error("Session claims are invalid"); + } + return { email, merchantId, mode, userId }; } -export async function createSessionToken(claims: MerchantSession, secret = configuredSecret()) { +export function isMerchantSession(session: Session): session is MerchantSession { + return "merchantId" in session; +} + +function merchantClaims(claims: Session) { + const hasMerchantId = "merchantId" in claims; + const hasMode = "mode" in claims; + if (hasMerchantId !== hasMode) throw new Error("Merchant session claims must be complete"); + return hasMerchantId ? { merchantId: claims.merchantId, mode: claims.mode } : {}; +} + +export async function createSessionToken(claims: Session, secret = configuredSecret()) { return new SignJWT({ email: claims.email, - merchantId: claims.merchantId, - mode: claims.mode, + ...merchantClaims(claims), }) .setProtectedHeader({ alg: "HS256" }) .setIssuer(sessionIssuer) diff --git a/apps/web/lib/db/leash-notification-migration-upgrade.integration.test.ts b/apps/web/lib/db/leash-notification-migration-upgrade.integration.test.ts new file mode 100644 index 0000000..51af1c1 --- /dev/null +++ b/apps/web/lib/db/leash-notification-migration-upgrade.integration.test.ts @@ -0,0 +1,149 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import postgres from "postgres"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for notification migration tests"); + +const sql = postgres(databaseUrl, { max: 1 }); +const schemas = new Set(); + +function schemaName() { + return `notification_upgrade_${randomUUID().replaceAll("-", "")}`; +} + +function resourceKey(taggedIdentity: string) { + return createHash("sha256").update(taggedIdentity).digest("hex"); +} + +async function createPhase7NotificationSchema(schema: string) { + await sql.unsafe(` + create schema "${schema}"; + create type "${schema}"."notification_type" as enum ( + 'cap_75', 'cap_blocked', 'unusual_domain', 'cap_lowered_halt', 'float_low', 'float_empty' + ); + create table "${schema}"."receipts" ( + "id" uuid primary key, + "resource_url" text + ); + create table "${schema}"."notifications" ( + "id" uuid primary key, + "agent_id" uuid not null, + "receipt_id" uuid, + "type" "${schema}"."notification_type" not null, + "event_key" text not null, + "resource_host" text, + constraint "notifications_resource_host_check" check ( + ("type" = 'unusual_domain' and "resource_host" is not null + and char_length("resource_host") between 1 and 253 + and "resource_host" = lower("resource_host") + and "resource_host" = btrim("resource_host") + and "resource_host" !~ '[/?#@[:space:]]') + or ("type" <> 'unusual_domain' and "resource_host" is null) + ) + ); + create unique index "notifications_unusual_domain_unique" + on "${schema}"."notifications" ("agent_id", "resource_host") + where "type" = 'unusual_domain'; + `); + schemas.add(schema); +} + +async function applyIdentityUpgrade(schema: string) { + const source = await readFile( + new URL("../../drizzle/0018_lucky_tusk.sql", import.meta.url), + "utf8", + ); + const statements = source + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter(Boolean); + + await sql.begin(async (transaction) => { + await transaction.unsafe(`set local search_path to "${schema}", public`); + for (const statement of statements) await transaction.unsafe(statement); + }); +} + +describe("0018 notification resource identity upgrade", () => { + afterEach(async () => { + for (const schema of schemas) { + await sql.unsafe(`drop schema if exists "${schema}" cascade`); + schemas.delete(schema); + } + }); + + afterAll(async () => { + await sql.end(); + }); + + it("backfills tagged SHA-256 keys without collapsing MCP tools onto their host", async () => { + const schema = schemaName(); + await createPhase7NotificationSchema(schema); + const agentId = randomUUID(); + const mcpReceiptId = randomUUID(); + const httpReceiptId = randomUUID(); + await sql.unsafe(` + insert into "${schema}"."receipts" (id, resource_url) values + ('${mcpReceiptId}', 'mcp://tool/search'), + ('${httpReceiptId}', 'https://api.vendor.test/pay'); + insert into "${schema}"."notifications" + (id, agent_id, receipt_id, type, event_key, resource_host) values + ('${randomUUID()}', '${agentId}', '${mcpReceiptId}', 'unusual_domain', 'mcp', 'tool'), + ('${randomUUID()}', '${agentId}', '${httpReceiptId}', 'unusual_domain', 'http', + 'api.vendor.test'), + ('${randomUUID()}', '${agentId}', null, 'unusual_domain', 'legacy', + 'legacy.vendor.test'), + ('${randomUUID()}', '${agentId}', null, 'cap_75', 'cap', null); + `); + + await applyIdentityUpgrade(schema); + + const rows = await sql.unsafe<{ event_key: string; resource_key: string | null }[]>(` + select event_key, resource_key from "${schema}"."notifications" order by event_key + `); + expect(rows).toEqual([ + { event_key: "cap", resource_key: null }, + { + event_key: "http", + resource_key: resourceKey("http-host:api.vendor.test"), + }, + { + event_key: "legacy", + resource_key: resourceKey("http-host:legacy.vendor.test"), + }, + { + event_key: "mcp", + resource_key: resourceKey("mcp-resource:mcp://tool/search"), + }, + ]); + + const exportKey = resourceKey("mcp-resource:mcp://tool/export"); + await sql.unsafe(` + insert into "${schema}"."notifications" + (id, agent_id, type, event_key, resource_host, resource_key) + values ( + '${randomUUID()}', '${agentId}', 'unusual_domain', 'mcp-export', 'tool', '${exportKey}' + ) + `); + await expect( + sql.unsafe(` + insert into "${schema}"."notifications" + (id, agent_id, type, event_key, resource_host, resource_key) + values ( + '${randomUUID()}', '${agentId}', 'unusual_domain', 'mcp-replay', 'other', + '${resourceKey("mcp-resource:mcp://tool/search")}' + ) + `), + ).rejects.toMatchObject({ code: "23505" }); + await expect( + sql.unsafe(` + insert into "${schema}"."notifications" + (id, agent_id, type, event_key, resource_host, resource_key) + values ('${randomUUID()}', '${randomUUID()}', 'unusual_domain', 'bad', 'tool', 'secret') + `), + ).rejects.toMatchObject({ code: "23514" }); + }); +}); diff --git a/apps/web/lib/db/leash-notification-precision-migration-upgrade.integration.test.ts b/apps/web/lib/db/leash-notification-precision-migration-upgrade.integration.test.ts new file mode 100644 index 0000000..e529b3f --- /dev/null +++ b/apps/web/lib/db/leash-notification-precision-migration-upgrade.integration.test.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import postgres from "postgres"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for notification migration tests"); + +const sql = postgres(databaseUrl, { max: 1 }); +const schemas = new Set(); + +function schemaName() { + return `notification_precision_${randomUUID().replaceAll("-", "")}`; +} + +async function applyPrecisionUpgrade(schema: string) { + const source = await readFile( + new URL("../../drizzle/0019_aspiring_frightful_four.sql", import.meta.url), + "utf8", + ); + const statements = source + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter(Boolean); + + await sql.begin(async (transaction) => { + await transaction.unsafe(`set local search_path to "${schema}", public`); + for (const statement of statements) await transaction.unsafe(statement); + }); +} + +describe("0019 notification cursor precision upgrade", () => { + afterEach(async () => { + for (const schema of schemas) { + await sql.unsafe(`drop schema if exists "${schema}" cascade`); + schemas.delete(schema); + } + }); + + afterAll(async () => { + await sql.end(); + }); + + it("converts existing and future notification timestamps to millisecond precision", async () => { + const schema = schemaName(); + schemas.add(schema); + await sql.unsafe(` + create schema "${schema}"; + create table "${schema}"."notifications" ( + "id" uuid primary key, + "created_at" timestamp with time zone default now() not null + ); + insert into "${schema}"."notifications" (id, created_at) values + ('${randomUUID()}', '2026-07-17T12:00:00.000900Z'), + ('${randomUUID()}', '2026-07-17T12:00:00.000400Z'); + `); + + await applyPrecisionUpgrade(schema); + await sql.unsafe(` + insert into "${schema}"."notifications" (id, created_at) + values ('${randomUUID()}', '2026-07-17T12:00:00.000800Z') + `); + + const [column] = await sql<{ datetime_precision: number }[]>` + select datetime_precision + from information_schema.columns + where table_schema = ${schema} + and table_name = 'notifications' + and column_name = 'created_at' + `; + expect(column?.datetime_precision).toBe(3); + + const rows = await sql.unsafe<{ created_at: string }[]>(` + select to_char(created_at at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') as created_at + from "${schema}"."notifications" + order by created_at + `); + expect(rows.map((row) => row.created_at)).toEqual([ + "2026-07-17 12:00:00.000000", + "2026-07-17 12:00:00.001000", + "2026-07-17 12:00:00.001000", + ]); + }); +}); diff --git a/apps/web/lib/db/leash-notification-schema.integration.test.ts b/apps/web/lib/db/leash-notification-schema.integration.test.ts new file mode 100644 index 0000000..da1456f --- /dev/null +++ b/apps/web/lib/db/leash-notification-schema.integration.test.ts @@ -0,0 +1,245 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import postgres from "postgres"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for notification schema tests"); + +const sql = postgres(databaseUrl, { max: 1 }); +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const payTo = "0x1111111111111111111111111111111111111111"; + +async function identity(label: string) { + const [owner] = await sql<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${label}-${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) + returning id + `; + if (!owner) throw new Error("Expected owner"); + const [agent] = await sql<{ id: string }[]>` + insert into agents (owner_id, name, signer_subject) + values (${owner.id}, ${`Agent ${label}`}, ${`leash:${randomUUID()}`}) + returning id + `; + if (!agent) throw new Error("Expected agent"); + const [cycle] = await sql<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${agent.id}, now() - interval '1 minute') + returning id + `; + if (!cycle) throw new Error("Expected cycle"); + return { agentId: agent.id, cycleId: cycle.id, ownerId: owner.id }; +} + +async function receipt(agentId: string, cycleId: string) { + const [row] = await sql<{ id: string }[]>` + insert into receipts ( + agent_id, cycle_id, amount_atomic, amount_usd, asset, network, pay_to, + authorization_nonce, request_fingerprint, authorization_valid_before + ) values ( + ${agentId}, ${cycleId}, 25000, 0.025, ${baseUsdc}, 'eip155:8453', ${payTo}, + ${`0x${randomBytes(32).toString("hex")}`}, ${randomBytes(32).toString("hex")}, + now() + interval '5 minutes' + ) + returning id + `; + if (!row) throw new Error("Expected receipt"); + return row.id; +} + +beforeEach(async () => { + await sql`truncate table users cascade`; +}); + +afterAll(async () => { + await sql.end(); +}); + +describe("Phase 7 notification and resource schema", () => { + it("keeps legacy receipts nullable while requiring a normalized resource pair", async () => { + const owner = await identity("resource"); + const receiptId = await receipt(owner.agentId, owner.cycleId); + + await expect(sql` + update receipts set resource_url = 'https://api.example.test/paid' + where id = ${receiptId} + `).rejects.toMatchObject({ code: "23514" }); + await sql` + update receipts + set resource_url = 'https://api.example.test/paid', resource_host = 'api.example.test' + where id = ${receiptId} + `; + await expect(sql` + update receipts set resource_host = 'API.example.test' where id = ${receiptId} + `).rejects.toMatchObject({ code: "23514" }); + + const [stored] = await sql<{ resource_host: string; resource_url: string }[]>` + select resource_url, resource_host from receipts where id = ${receiptId} + `; + expect(stored).toEqual({ + resource_host: "api.example.test", + resource_url: "https://api.example.test/paid", + }); + }); + + it("stores only canonical tier/type combinations with object metadata", async () => { + const owner = await identity("canonical"); + const [enumValues] = await sql<{ tiers: string[]; types: string[] }[]>` + select + enum_range(null::notification_tier)::text[] as tiers, + enum_range(null::notification_type)::text[] as types + `; + expect(enumValues).toEqual({ + tiers: ["2", "3"], + types: [ + "cap_75", + "cap_blocked", + "unusual_domain", + "cap_lowered_halt", + "float_low", + "float_empty", + ], + }); + + await sql` + insert into notifications ( + agent_id, cycle_id, tier, type, event_key, metadata, sticky + ) values ( + ${owner.agentId}, ${owner.cycleId}, '2', 'cap_75', 'cap-75:first', + ${sql.json({ percent: 75 })}, false + ) + `; + + await expect(sql` + insert into notifications ( + agent_id, cycle_id, tier, type, event_key, metadata, sticky + ) values ( + ${owner.agentId}, ${owner.cycleId}, '3', 'float_empty', 'invalid:tier', + ${sql.json({})}, true + ) + `).rejects.toMatchObject({ code: "23514" }); + await expect(sql` + insert into notifications ( + agent_id, cycle_id, tier, type, event_key, metadata, sticky + ) values ( + ${owner.agentId}, ${owner.cycleId}, '2', 'float_low', 'invalid:metadata', + ${sql.json([])}, false + ) + `).rejects.toMatchObject({ code: "23514" }); + + const rows = await sql<{ tier: string; type: string }[]>` + select tier::text, type::text from notifications + `; + expect(rows).toEqual([{ tier: "2", type: "cap_75" }]); + }); + + it("deduplicates threshold, domain, and unresolved cap-halt episodes", async () => { + const owner = await identity("dedupe"); + const resourceKey = "a".repeat(64); + await sql` + insert into notifications (agent_id, cycle_id, tier, type, event_key, metadata, sticky) + values (${owner.agentId}, ${owner.cycleId}, '2', 'cap_75', 'cap:first', '{}', false) + `; + await expect(sql` + insert into notifications (agent_id, cycle_id, tier, type, event_key, metadata, sticky) + values (${owner.agentId}, ${owner.cycleId}, '2', 'cap_75', 'cap:replay', '{}', false) + `).rejects.toMatchObject({ code: "23505" }); + + await sql` + insert into notifications ( + agent_id, cycle_id, tier, type, event_key, metadata, resource_host, resource_key, sticky + ) values ( + ${owner.agentId}, ${owner.cycleId}, '2', 'unusual_domain', 'domain:first', '{}', + 'api.example.test', ${resourceKey}, false + ) + `; + await expect(sql` + insert into notifications ( + agent_id, cycle_id, tier, type, event_key, metadata, resource_host, resource_key, sticky + ) values ( + ${owner.agentId}, ${owner.cycleId}, '2', 'unusual_domain', 'domain:replay', '{}', + 'different.example.test', ${resourceKey}, false + ) + `).rejects.toMatchObject({ code: "23505" }); + await sql` + insert into notifications ( + agent_id, cycle_id, tier, type, event_key, metadata, resource_host, resource_key, sticky + ) values ( + ${owner.agentId}, ${owner.cycleId}, '2', 'unusual_domain', 'domain:second-resource', '{}', + 'api.example.test', ${"c".repeat(64)}, false + ) + `; + await expect(sql` + insert into notifications ( + agent_id, cycle_id, tier, type, event_key, metadata, resource_host, sticky + ) values ( + ${owner.agentId}, ${owner.cycleId}, '2', 'unusual_domain', 'domain:missing-key', '{}', + 'missing.example.test', false + ) + `).rejects.toMatchObject({ code: "23514" }); + + const [active] = await sql<{ id: string }[]>` + insert into notifications (agent_id, cycle_id, tier, type, event_key, metadata, sticky) + values (${owner.agentId}, ${owner.cycleId}, '3', 'cap_blocked', 'halt:first', '{}', true) + returning id + `; + if (!active) throw new Error("Expected active halt notification"); + await expect(sql` + insert into notifications (agent_id, cycle_id, tier, type, event_key, metadata, sticky) + values ( + ${owner.agentId}, ${owner.cycleId}, '3', 'cap_lowered_halt', 'halt:duplicate', '{}', true + ) + `).rejects.toMatchObject({ code: "23505" }); + await sql`update notifications set resolved_at = now() where id = ${active.id}`; + await sql` + insert into notifications (agent_id, cycle_id, tier, type, event_key, metadata, sticky) + values ( + ${owner.agentId}, ${owner.cycleId}, '3', 'cap_lowered_halt', 'halt:new', '{}', true + ) + `; + }); + + it("prevents a notification from linking another agent's receipt", async () => { + const first = await identity("receipt-first"); + const second = await identity("receipt-second"); + const receiptId = await receipt(first.agentId, first.cycleId); + const resourceKey = "b".repeat(64); + + await expect(sql` + insert into notifications ( + agent_id, cycle_id, receipt_id, tier, type, event_key, metadata, resource_host, + resource_key, sticky + ) values ( + ${second.agentId}, ${second.cycleId}, ${receiptId}, '2', 'unusual_domain', + 'cross-agent', '{}', 'api.example.test', ${resourceKey}, false + ) + `).rejects.toMatchObject({ code: "23503" }); + await sql` + insert into notifications ( + agent_id, cycle_id, receipt_id, tier, type, event_key, metadata, resource_host, + resource_key, sticky + ) values ( + ${first.agentId}, ${first.cycleId}, ${receiptId}, '2', 'unusual_domain', + 'owned-receipt', '{}', 'api.example.test', ${resourceKey}, false + ) + `; + }); + + it("stores owner-scoped push subscriptions with globally unique endpoints", async () => { + const first = await identity("push-first"); + const second = await identity("push-second"); + await sql` + insert into push_subscriptions (owner_id, endpoint, p256dh, auth, user_agent) + values (${first.ownerId}, 'https://push.example.test/one', 'public-key', 'auth-key', 'PWA') + `; + await expect(sql` + insert into push_subscriptions (owner_id, endpoint, p256dh, auth) + values (${second.ownerId}, 'https://push.example.test/one', 'other-key', 'other-auth') + `).rejects.toMatchObject({ code: "23505" }); + await expect(sql` + insert into push_subscriptions (owner_id, endpoint, p256dh, auth) + values (${second.ownerId}, 'https://push.example.test/two', '', 'auth-key') + `).rejects.toMatchObject({ code: "23514" }); + }); +}); diff --git a/apps/web/lib/db/leash-notification-schema.ts b/apps/web/lib/db/leash-notification-schema.ts new file mode 100644 index 0000000..43b12b5 --- /dev/null +++ b/apps/web/lib/db/leash-notification-schema.ts @@ -0,0 +1,155 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + check, + foreignKey, + index, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +import { users } from "./identity-schema"; +import { agents, capCycles } from "./leash-control-schema"; +import { receipts } from "./leash-receipt-schema"; + +export const notificationTier = pgEnum("notification_tier", ["2", "3"]); +export const notificationType = pgEnum("notification_type", [ + "cap_75", + "cap_blocked", + "unusual_domain", + "cap_lowered_halt", + "float_low", + "float_empty", +]); + +export const notifications = pgTable( + "notifications", + { + id: uuid("id").defaultRandom().primaryKey(), + agentId: uuid("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + cycleId: uuid("cycle_id").notNull(), + receiptId: uuid("receipt_id"), + tier: notificationTier("tier").notNull(), + type: notificationType("type").notNull(), + eventKey: text("event_key").notNull(), + metadata: jsonb("metadata") + .$type>() + .default(sql`'{}'::jsonb`) + .notNull(), + resourceHost: text("resource_host"), + resourceKey: text("resource_key"), + sticky: boolean("sticky").default(false).notNull(), + readAt: timestamp("read_at", { withTimezone: true }), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { precision: 3, withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + foreignKey({ + columns: [table.cycleId, table.agentId], + foreignColumns: [capCycles.id, capCycles.agentId], + name: "notifications_cycle_agent_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [table.receiptId, table.cycleId, table.agentId], + foreignColumns: [receipts.id, receipts.cycleId, receipts.agentId], + name: "notifications_receipt_cycle_agent_fk", + }).onDelete("cascade"), + uniqueIndex("notifications_agent_event_key_unique").on(table.agentId, table.eventKey), + uniqueIndex("notifications_cap_75_cycle_unique") + .on(table.agentId, table.cycleId) + .where(sql`${table.type} = 'cap_75'`), + uniqueIndex("notifications_unusual_resource_unique") + .on(table.agentId, table.resourceKey) + .where(sql`${table.type} = 'unusual_domain'`), + uniqueIndex("notifications_one_active_cap_halt") + .on(table.agentId) + .where( + sql`${table.resolvedAt} is null + and ${table.type} in ('cap_blocked', 'cap_lowered_halt')`, + ), + index("notifications_agent_created_idx").on(table.agentId, table.createdAt.desc()), + index("notifications_agent_unread_idx") + .on(table.agentId, table.createdAt.desc()) + .where(sql`${table.readAt} is null`), + index("notifications_receipt_idx") + .on(table.receiptId) + .where(sql`${table.receiptId} is not null`), + check( + "notifications_tier_type_check", + sql`(${table.tier} = '2' + and ${table.type} in ('cap_75', 'unusual_domain', 'float_low', 'float_empty')) + or (${table.tier} = '3' + and ${table.type} in ('cap_blocked', 'cap_lowered_halt'))`, + ), + check( + "notifications_sticky_check", + sql`(${table.tier} = '2' and not ${table.sticky} and ${table.resolvedAt} is null) + or (${table.tier} = '3' and ${table.sticky})`, + ), + check( + "notifications_resource_identity_check", + sql`(${table.type} = 'unusual_domain' and ${table.resourceHost} is not null + and char_length(${table.resourceHost}) between 1 and 253 + and ${table.resourceHost} = lower(${table.resourceHost}) + and ${table.resourceHost} = btrim(${table.resourceHost}) + and ${table.resourceHost} !~ '[/?#@[:space:]]' + and ${table.resourceKey} is not null + and ${table.resourceKey} ~ '^[0-9a-f]{64}$') + or (${table.type} <> 'unusual_domain' + and ${table.resourceHost} is null and ${table.resourceKey} is null)`, + ), + check( + "notifications_event_key_check", + sql`char_length(btrim(${table.eventKey})) between 1 and 300`, + ), + check("notifications_metadata_check", sql`jsonb_typeof(${table.metadata}) = 'object'`), + check( + "notifications_timestamps_check", + sql`(${table.readAt} is null or ${table.readAt} >= ${table.createdAt}) + and (${table.resolvedAt} is null or ${table.resolvedAt} >= ${table.createdAt})`, + ), + ], +); + +export const pushSubscriptions = pgTable( + "push_subscriptions", + { + id: uuid("id").defaultRandom().primaryKey(), + ownerId: uuid("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + endpoint: text("endpoint").notNull(), + p256dh: text("p256dh").notNull(), + auth: text("auth").notNull(), + userAgent: text("user_agent"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => [ + uniqueIndex("push_subscriptions_endpoint_unique").on(table.endpoint), + index("push_subscriptions_owner_active_idx") + .on(table.ownerId, table.createdAt.desc()) + .where(sql`${table.revokedAt} is null`), + check( + "push_subscriptions_endpoint_check", + sql`char_length(${table.endpoint}) between 1 and 2048 + and ${table.endpoint} ~ '^https://[^[:space:]]+$'`, + ), + check( + "push_subscriptions_keys_check", + sql`char_length(btrim(${table.p256dh})) between 1 and 512 + and char_length(btrim(${table.auth})) between 1 and 512`, + ), + check( + "push_subscriptions_revoked_at_check", + sql`${table.revokedAt} is null or ${table.revokedAt} >= ${table.createdAt}`, + ), + ], +); diff --git a/apps/web/lib/db/leash-receipt-schema.ts b/apps/web/lib/db/leash-receipt-schema.ts index 7ce026c..8d8e1b9 100644 --- a/apps/web/lib/db/leash-receipt-schema.ts +++ b/apps/web/lib/db/leash-receipt-schema.ts @@ -10,6 +10,7 @@ import { pgTable, text, timestamp, + unique, uniqueIndex, uuid, varchar, @@ -42,6 +43,8 @@ export const receipts = pgTable( network: leashNetwork("network").notNull(), intendedNetwork: leashNetwork("intended_network"), payTo: varchar("pay_to", { length: 42 }).notNull(), + resourceUrl: text("resource_url"), + resourceHost: text("resource_host"), authorizationNonce: varchar("authorization_nonce", { length: 66 }).notNull(), requestFingerprint: varchar("request_fingerprint", { length: 64 }).notNull(), authorizationValidBefore: timestamp("authorization_valid_before", { @@ -59,6 +62,7 @@ export const receipts = pgTable( foreignColumns: [capCycles.id, capCycles.agentId], name: "receipts_cycle_agent_fk", }), + unique("receipts_id_cycle_agent_unique").on(table.id, table.cycleId, table.agentId), uniqueIndex("receipts_agent_nonce_unique").on(table.agentId, table.authorizationNonce), uniqueIndex("receipts_agent_fingerprint_unique").on(table.agentId, table.requestFingerprint), uniqueIndex("receipts_network_tx_hash_unique") @@ -76,6 +80,17 @@ export const receipts = pgTable( sql`${table.payTo} ~ '^0x[0-9a-fA-F]{40}$' and lower(${table.payTo}) <> '0x0000000000000000000000000000000000000000'`, ), + check( + "receipts_resource_check", + sql`(${table.resourceUrl} is null and ${table.resourceHost} is null) + or (${table.resourceUrl} is not null and ${table.resourceHost} is not null + and char_length(${table.resourceUrl}) between 1 and 2048 + and ${table.resourceUrl} ~ '^[A-Za-z][A-Za-z0-9+.-]*://[^[:space:]]+$' + and char_length(${table.resourceHost}) between 1 and 253 + and ${table.resourceHost} = lower(${table.resourceHost}) + and ${table.resourceHost} = btrim(${table.resourceHost}) + and ${table.resourceHost} !~ '[/?#@[:space:]]')`, + ), check( "receipts_native_usdc_check", sql`(${table.network} = 'eip155:8453' diff --git a/apps/web/lib/db/leash-schema.ts b/apps/web/lib/db/leash-schema.ts index bad1e24..b731c54 100644 --- a/apps/web/lib/db/leash-schema.ts +++ b/apps/web/lib/db/leash-schema.ts @@ -1,3 +1,4 @@ export * from "./leash-control-schema"; export * from "./leash-ledger-schema"; +export * from "./leash-notification-schema"; export * from "./leash-receipt-schema"; diff --git a/apps/web/lib/db/merchant-provisioning.integration.test.ts b/apps/web/lib/db/merchant-provisioning.integration.test.ts index 296c1b1..a45f452 100644 --- a/apps/web/lib/db/merchant-provisioning.integration.test.ts +++ b/apps/web/lib/db/merchant-provisioning.integration.test.ts @@ -4,6 +4,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { createDatabase } from "./client"; import { MerchantAlreadyExistsError, provisionMerchant } from "./provision-merchant"; +import { users } from "./schema"; const databaseUrl = process.env.DATABASE_URL; @@ -108,6 +109,54 @@ describe("merchant provisioning with real PostgreSQL", () => { expect(keys.find((key) => key.env === "live")?.public_key).toMatch(/^pk_live_/); }); + it("attaches merchant resources to an exact verified Leash-first principal", async () => { + const input = signupInput("leash-first@example.test"); + const [owner] = await connection.db + .insert(users) + .values({ email: input.email, magicIssuer: input.magicIssuer }) + .returning({ userId: users.id }); + if (!owner) throw new Error("PostgreSQL did not return the Leash-first owner"); + + const result = await provisionMerchant(connection.db, input); + + expect(result.userId).toBe(owner.userId); + const countRow = requiredRow( + await connection.client<{ users_count: number; merchants_count: number }[]>`select + (select count(*)::int from users) as users_count, + (select count(*)::int from merchants) as merchants_count`, + "shared principal counts", + ); + expect({ + merchantsCount: countRow.merchants_count, + usersCount: countRow.users_count, + }).toEqual({ merchantsCount: 1, usersCount: 1 }); + }); + + it("rejects a bare-user email or issuer collision without attaching a merchant", async () => { + const input = signupInput("bound-owner@example.test"); + await connection.db + .insert(users) + .values({ email: input.email, magicIssuer: input.magicIssuer }); + + await expect( + provisionMerchant(connection.db, { + ...input, + magicIssuer: `did:ethr:${randomUUID()}`, + }), + ).rejects.toBeInstanceOf(MerchantAlreadyExistsError); + await expect( + provisionMerchant(connection.db, { + ...input, + email: `other-${randomUUID()}@example.test`, + }), + ).rejects.toBeInstanceOf(MerchantAlreadyExistsError); + + const [count] = await connection.client<{ count: number }[]>` + select count(*)::int as count from merchants + `; + expect(count?.count).toBe(0); + }); + it("rejects a case-insensitive duplicate email without creating another tenant", async () => { await provisionMerchant(connection.db, signupInput("owner@example.test")); diff --git a/apps/web/lib/db/owner-identity.integration.test.ts b/apps/web/lib/db/owner-identity.integration.test.ts new file mode 100644 index 0000000..a71c385 --- /dev/null +++ b/apps/web/lib/db/owner-identity.integration.test.ts @@ -0,0 +1,123 @@ +import { randomUUID } from "node:crypto"; + +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { createDatabase } from "./client"; +import { findOrCreateOwnerIdentity, OwnerIdentityConflictError } from "./owner-identity"; +import { provisionMerchant } from "./provision-merchant"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required for PostgreSQL integration tests"); +} + +const connection = createDatabase(databaseUrl, 2); + +function identity(label: string) { + return { + email: `${label}-${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + }; +} + +describe("Leash owner identity provisioning with real PostgreSQL", () => { + beforeEach(async () => { + await connection.client`truncate table users cascade`; + }); + + afterAll(async () => { + await connection.client`truncate table users cascade`; + await connection.client.end(); + }); + + it("creates only a user principal for a new Leash owner", async () => { + const source = identity("new-owner"); + + const owner = await findOrCreateOwnerIdentity(connection.db, source); + const [counts] = await connection.client< + { merchantsCount: number; usersCount: number }[] + >`select + (select count(*)::int from users) as "usersCount", + (select count(*)::int from merchants) as "merchantsCount"`; + + expect(owner).toMatchObject({ email: source.email }); + expect(owner.userId).toMatch(/^[0-9a-f-]{36}$/i); + expect(counts).toEqual({ merchantsCount: 0, usersCount: 1 }); + }); + + it("reuses the shared user principal when the owner already has a merchant", async () => { + const source = identity("merchant-owner"); + const merchant = await provisionMerchant(connection.db, { + ...source, + receivingAddress: "0x1111111111111111111111111111111111111111", + }); + + await expect(findOrCreateOwnerIdentity(connection.db, source)).resolves.toEqual({ + email: source.email, + userId: merchant.userId, + }); + + const [counts] = await connection.client< + { merchantsCount: number; usersCount: number }[] + >`select + (select count(*)::int from users) as "usersCount", + (select count(*)::int from merchants) as "merchantsCount"`; + expect(counts).toEqual({ merchantsCount: 1, usersCount: 1 }); + }); + + it("returns one principal when the same verified identity arrives concurrently", async () => { + const source = identity("concurrent-owner"); + + const [first, second] = await Promise.all([ + findOrCreateOwnerIdentity(connection.db, source), + findOrCreateOwnerIdentity(connection.db, source), + ]); + + expect(first).toEqual(second); + const [count] = await connection.client<{ count: number }[]>` + select count(*)::int as count from users + `; + expect(count?.count).toBe(1); + }); + + it("converges on one user when Leash login and merchant signup race", async () => { + const source = identity("cross-product-race"); + + const [owner, merchant] = await Promise.all([ + findOrCreateOwnerIdentity(connection.db, source), + provisionMerchant(connection.db, { + ...source, + receivingAddress: "0x1111111111111111111111111111111111111111", + }), + ]); + + expect(merchant.userId).toBe(owner.userId); + const [counts] = await connection.client< + { merchantsCount: number; usersCount: number }[] + >`select + (select count(*)::int from users) as "usersCount", + (select count(*)::int from merchants) as "merchantsCount"`; + expect(counts).toEqual({ merchantsCount: 1, usersCount: 1 }); + }); + + it("rejects an email or issuer collision instead of rebinding an owner", async () => { + const source = identity("bound-owner"); + await findOrCreateOwnerIdentity(connection.db, source); + + await expect( + findOrCreateOwnerIdentity(connection.db, { + email: source.email, + magicIssuer: `did:ethr:${randomUUID()}`, + }), + ).rejects.toBeInstanceOf(OwnerIdentityConflictError); + await expect( + findOrCreateOwnerIdentity(connection.db, { + email: `other-${randomUUID()}@example.test`, + magicIssuer: source.magicIssuer, + }), + ).rejects.toBeInstanceOf(OwnerIdentityConflictError); + }); +}); diff --git a/apps/web/lib/db/owner-identity.ts b/apps/web/lib/db/owner-identity.ts new file mode 100644 index 0000000..446bed3 --- /dev/null +++ b/apps/web/lib/db/owner-identity.ts @@ -0,0 +1,48 @@ +import "server-only"; + +import { and, eq } from "drizzle-orm"; + +import type { Database } from "./client"; +import { users } from "./schema"; + +export class OwnerIdentityConflictError extends Error { + constructor() { + super("The verified Magic identity conflicts with an existing owner"); + this.name = "OwnerIdentityConflictError"; + } +} + +export interface OwnerIdentityInput { + email: string; + magicIssuer: string; +} + +function normalizedIdentity(input: OwnerIdentityInput) { + const email = input.email.trim().toLowerCase(); + const magicIssuer = input.magicIssuer.trim(); + + if (!email.includes("@")) throw new Error("A valid email is required"); + if (!magicIssuer.startsWith("did:")) throw new Error("A valid Magic issuer is required"); + + return { email, magicIssuer }; +} + +export async function findOrCreateOwnerIdentity(db: Database, input: OwnerIdentityInput) { + const identity = normalizedIdentity(input); + const [created] = await db + .insert(users) + .values(identity) + .onConflictDoNothing() + .returning({ email: users.email, userId: users.id }); + + if (created) return created; + + const [existing] = await db + .select({ email: users.email, userId: users.id }) + .from(users) + .where(and(eq(users.email, identity.email), eq(users.magicIssuer, identity.magicIssuer))) + .limit(1); + + if (!existing) throw new OwnerIdentityConflictError(); + return existing; +} diff --git a/apps/web/lib/db/provision-merchant.ts b/apps/web/lib/db/provision-merchant.ts index f17103c..bea27b2 100644 --- a/apps/web/lib/db/provision-merchant.ts +++ b/apps/web/lib/db/provision-merchant.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; -import { eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { normalizeReceivingAddress } from "../merchant/receiving-address"; import type { Database } from "./client"; import { apiKeys, merchants, users } from "./schema"; @@ -32,7 +32,11 @@ function publishableKey(env: "test" | "live") { }; } -const identityConstraints = new Set(["users_email_unique", "users_magic_issuer_unique"]); +const identityConstraints = new Set([ + "merchants_user_id_unique", + "users_email_unique", + "users_magic_issuer_unique", +]); function uniqueViolationConstraint(error: unknown) { const seen = new Set(); @@ -72,23 +76,22 @@ export async function provisionMerchant(db: Database, input: ProvisionMerchantIn try { return await db.transaction(async (transaction) => { - const [existingUser] = await transaction - .select({ id: users.id }) - .from(users) - .where(or(eq(users.email, email), eq(users.magicIssuer, magicIssuer))) - .limit(1); - - if (existingUser) { - throw new MerchantAlreadyExistsError(); - } - - const [user] = await transaction + const [createdUser] = await transaction .insert(users) .values({ email, magicIssuer }) + .onConflictDoNothing() .returning({ id: users.id }); + const [existingUser] = createdUser + ? [] + : await transaction + .select({ id: users.id }) + .from(users) + .where(and(eq(users.email, email), eq(users.magicIssuer, magicIssuer))) + .limit(1); + const user = createdUser ?? existingUser; if (!user) { - throw new Error("PostgreSQL did not return the created user"); + throw new MerchantAlreadyExistsError(); } const [merchant] = await transaction diff --git a/apps/web/lib/leash/cap-cycle-policy-store.ts b/apps/web/lib/leash/cap-cycle-policy-store.ts new file mode 100644 index 0000000..4db0cde --- /dev/null +++ b/apps/web/lib/leash/cap-cycle-policy-store.ts @@ -0,0 +1,85 @@ +import { and, eq, isNull } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { capCycles, caps } from "../db/schema"; +import { LeashCapNotFoundError } from "./cap-policy-errors"; +import { ensureCurrentCapCycle } from "./cycles"; + +type Transaction = Parameters[0]>[0]; +type CurrentCycle = NonNullable>>; + +async function activeCycle(transaction: Transaction, agentId: string) { + const [cycle] = await transaction + .select({ id: capCycles.id, startedAt: capCycles.startedAt }) + .from(capCycles) + .where(and(eq(capCycles.agentId, agentId), isNull(capCycles.endedAt))) + .for("update"); + return cycle; +} + +export async function replaceCapCycle( + transaction: Transaction, + input: { agentId: string; now: Date; reason: "frequency_change" | "manual" }, +) { + const current = await activeCycle(transaction, input.agentId); + let startedAt = input.now; + if (current) { + if (startedAt <= current.startedAt) { + startedAt = new Date(current.startedAt.getTime() + 1); + } + const [closed] = await transaction + .update(capCycles) + .set({ endedAt: startedAt, resetReason: input.reason }) + .where(and(eq(capCycles.id, current.id), isNull(capCycles.endedAt))) + .returning({ id: capCycles.id }); + if (!closed) throw new Error("The active cap cycle changed"); + } + const [opened] = await transaction + .insert(capCycles) + .values({ agentId: input.agentId, startedAt }) + .returning({ id: capCycles.id, startedAt: capCycles.startedAt }); + if (!opened) throw new Error("PostgreSQL did not return the opened cap cycle"); + return opened; +} + +export async function resetCapCycleAtBoundary( + transaction: Transaction, + cycle: CurrentCycle, + input: { agentId: string; now: Date; reason: "frequency_change" | "manual" }, +) { + if (cycle.rolledFromId && cycle.startedAt.getTime() === input.now.getTime()) { + const [reclassified] = await transaction + .update(capCycles) + .set({ resetReason: input.reason }) + .where(eq(capCycles.id, cycle.rolledFromId)) + .returning({ id: capCycles.id }); + if (!reclassified) throw new Error("The rolled cap cycle changed"); + return { id: cycle.id, startedAt: cycle.startedAt }; + } + return replaceCapCycle(transaction, input); +} + +export async function readCurrentCapPolicy(transaction: Transaction, agentId: string, now: Date) { + const [cap] = await transaction + .select({ + amountUsdCents: caps.amountUsdCents, + frequency: caps.frequency, + updatedAt: caps.updatedAt, + }) + .from(caps) + .where(eq(caps.agentId, agentId)) + .for("update"); + if (!cap?.amountUsdCents) return undefined; + const cycle = await ensureCurrentCapCycle(transaction, { + agentId, + frequency: cap.frequency, + now, + }); + if (!cycle) throw new LeashCapNotFoundError(); + return { + amountUsdCents: cap.amountUsdCents, + cycle, + frequency: cap.frequency, + updatedAt: cap.updatedAt, + }; +} diff --git a/apps/web/lib/leash/cap-display.test.ts b/apps/web/lib/leash/cap-display.test.ts new file mode 100644 index 0000000..b3d8193 --- /dev/null +++ b/apps/web/lib/leash/cap-display.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { deriveCapDisplay } from "./cap-display"; + +describe("Leash cap display", () => { + it("keeps settled and pending spend separate while matching the committed cap total", () => { + expect( + deriveCapDisplay({ + capUsdCents: "1000", + pendingAtomic: "800000", + settledAtomic: "4200000", + }), + ).toEqual({ + approaching: false, + atOrAboveLimit: false, + capFillBasisPoints: "10000", + capAtomic: "10000000", + committedAtomic: "5000000", + committedBasisPoints: "5000", + overageAtomic: "0", + overageFillBasisPoints: "0", + pendingAtomic: "800000", + pendingFillBasisPoints: "800", + settledAtomic: "4200000", + settledFillBasisPoints: "4200", + }); + }); + + it("renders an exact-cap pending reservation as full and explainable", () => { + expect( + deriveCapDisplay({ + capUsdCents: "1000", + pendingAtomic: "1000000", + settledAtomic: "9000000", + }), + ).toMatchObject({ + approaching: true, + atOrAboveLimit: true, + committedAtomic: "10000000", + committedBasisPoints: "10000", + overageAtomic: "0", + overageFillBasisPoints: "0", + pendingFillBasisPoints: "1000", + settledFillBasisPoints: "9000", + }); + }); + + it("scales a 101% overage against committed spend without inventing width", () => { + expect( + deriveCapDisplay({ + capUsdCents: "1000", + pendingAtomic: "100000", + settledAtomic: "10000000", + }), + ).toMatchObject({ + capFillBasisPoints: "9901", + committedAtomic: "10100000", + committedBasisPoints: "10100", + overageAtomic: "100000", + overageFillBasisPoints: "99", + pendingFillBasisPoints: "99", + settledFillBasisPoints: "9901", + }); + }); + + it("retains truthful settled and pending composition after a cap is lowered to 140%", () => { + expect( + deriveCapDisplay({ + capUsdCents: "1000", + pendingAtomic: "6000000", + settledAtomic: "8000000", + }), + ).toMatchObject({ + approaching: true, + atOrAboveLimit: true, + capFillBasisPoints: "7143", + committedBasisPoints: "14000", + overageAtomic: "4000000", + overageFillBasisPoints: "2857", + pendingFillBasisPoints: "4286", + settledFillBasisPoints: "5714", + }); + }); + + it("represents no-cap as a distinct state without inventing percentages", () => { + expect( + deriveCapDisplay({ + capUsdCents: null, + pendingAtomic: "0", + settledAtomic: "0", + }), + ).toEqual({ + approaching: false, + atOrAboveLimit: false, + capFillBasisPoints: null, + capAtomic: null, + committedAtomic: "0", + committedBasisPoints: null, + overageAtomic: "0", + overageFillBasisPoints: null, + pendingAtomic: "0", + pendingFillBasisPoints: null, + settledAtomic: "0", + settledFillBasisPoints: null, + }); + }); +}); diff --git a/apps/web/lib/leash/cap-display.ts b/apps/web/lib/leash/cap-display.ts new file mode 100644 index 0000000..9117d3a --- /dev/null +++ b/apps/web/lib/leash/cap-display.ts @@ -0,0 +1,76 @@ +const ATOMIC_UNITS_PER_CENT = BigInt(10_000); +const FULL_BAR_BASIS_POINTS = BigInt(10_000); +const APPROACHING_BASIS_POINTS = BigInt(7_500); + +type CapDisplayInput = { + capUsdCents: string | null; + pendingAtomic: string; + settledAtomic: string; +}; + +function nonnegativeInteger(value: string, field: string) { + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + throw new Error(`${field} must be a nonnegative integer`); + } + return BigInt(value); +} + +function minimum(left: bigint, right: bigint) { + return left < right ? left : right; +} + +function roundedBasisPoints(value: bigint, scaleAtomic: bigint) { + return (value * FULL_BAR_BASIS_POINTS + scaleAtomic / BigInt(2)) / scaleAtomic; +} + +export function deriveCapDisplay(input: CapDisplayInput) { + const pendingAtomic = nonnegativeInteger(input.pendingAtomic, "pendingAtomic"); + const settledAtomic = nonnegativeInteger(input.settledAtomic, "settledAtomic"); + const committedAtomic = settledAtomic + pendingAtomic; + + if (input.capUsdCents === null) { + return { + approaching: false, + atOrAboveLimit: false, + capAtomic: null, + capFillBasisPoints: null, + committedAtomic: committedAtomic.toString(), + committedBasisPoints: null, + overageAtomic: "0", + overageFillBasisPoints: null, + pendingAtomic: pendingAtomic.toString(), + pendingFillBasisPoints: null, + settledAtomic: settledAtomic.toString(), + settledFillBasisPoints: null, + }; + } + + const capUsdCents = nonnegativeInteger(input.capUsdCents, "capUsdCents"); + if (capUsdCents === BigInt(0)) throw new Error("capUsdCents must be positive"); + const capAtomic = capUsdCents * ATOMIC_UNITS_PER_CENT; + const displayScaleAtomic = committedAtomic > capAtomic ? committedAtomic : capAtomic; + const committedFill = roundedBasisPoints(committedAtomic, displayScaleAtomic); + const settledFill = roundedBasisPoints(settledAtomic, displayScaleAtomic); + const pendingFill = committedFill - settledFill; + const capFill = roundedBasisPoints(minimum(capAtomic, displayScaleAtomic), displayScaleAtomic); + const overageFill = committedAtomic > capAtomic ? FULL_BAR_BASIS_POINTS - capFill : BigInt(0); + const committedBasisPoints = (committedAtomic * FULL_BAR_BASIS_POINTS) / capAtomic; + + return { + approaching: committedBasisPoints >= APPROACHING_BASIS_POINTS, + atOrAboveLimit: committedAtomic >= capAtomic, + capAtomic: capAtomic.toString(), + capFillBasisPoints: capFill.toString(), + committedAtomic: committedAtomic.toString(), + committedBasisPoints: committedBasisPoints.toString(), + overageAtomic: (committedAtomic > capAtomic + ? committedAtomic - capAtomic + : BigInt(0) + ).toString(), + overageFillBasisPoints: overageFill.toString(), + pendingAtomic: pendingAtomic.toString(), + pendingFillBasisPoints: pendingFill.toString(), + settledAtomic: settledAtomic.toString(), + settledFillBasisPoints: settledFill.toString(), + }; +} diff --git a/apps/web/lib/leash/cap-halt-store.ts b/apps/web/lib/leash/cap-halt-store.ts new file mode 100644 index 0000000..5efa94d --- /dev/null +++ b/apps/web/lib/leash/cap-halt-store.ts @@ -0,0 +1,21 @@ +import { and, eq, inArray, isNull } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { notifications } from "../db/schema"; + +type Transaction = Parameters[0]>[0]; + +export async function findActiveCapHalt(transaction: Transaction, agentId: string) { + const [halt] = await transaction + .select({ id: notifications.id, type: notifications.type }) + .from(notifications) + .where( + and( + eq(notifications.agentId, agentId), + isNull(notifications.resolvedAt), + inArray(notifications.type, ["cap_blocked", "cap_lowered_halt"]), + ), + ) + .limit(1); + return halt; +} diff --git a/apps/web/lib/leash/cap-halt.integration.test.ts b/apps/web/lib/leash/cap-halt.integration.test.ts new file mode 100644 index 0000000..39c883d --- /dev/null +++ b/apps/web/lib/leash/cap-halt.integration.test.ts @@ -0,0 +1,219 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { eq } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { agentEvents, notifications, receipts } from "../db/schema"; +import { readOwnerCap, resetOwnerCapCycle, setOwnerCap } from "./cap-policy"; +import { completePreSigningChecks, reserveSignRequest } from "./sign-store"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for cap-halt integration tests"); + +const connection = createDatabase(databaseUrl, 4); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const nowSeconds = 1_784_271_300; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(capCents = "100") { + const [owner] = await connection.client<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) returning id + `; + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.client<{ id: string }[]>` + insert into agents (owner_id, name, signer_subject, agent_address) + values (${owner.id}, 'Cap halt test', ${`leash:${randomUUID()}`}, ${agentAddress}) returning id + `; + if (!agent) throw new Error("Expected agent"); + const [key] = await connection.client<{ id: string }[]>` + insert into leash_keys (agent_id, hashed_key, prefix, last4) + values (${agent.id}, ${randomBytes(32).toString("hex")}, 'leash_sk_', 'a1B2') returning id + `; + const [cycle] = await connection.client<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${agent.id}, ${new Date((nowSeconds - 60) * 1_000).toISOString()}::timestamptz) + returning id + `; + if (!key || !cycle) throw new Error("Expected cap halt identity"); + await connection.client` + insert into caps (agent_id, amount_usd_cents, frequency) + values (${agent.id}, ${capCents}, 'daily') + `; + return { agentId: agent.id, cycleId: cycle.id, keyId: key.id, ownerId: owner.id }; +} + +function signBody(amount: string) { + return { + amount, + asset: baseUsdc, + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo, + resourceUrl: "https://durable.vendor.test/pay", + signerRequest: { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: baseUsdc, + version: "2", + }, + message: { + from: agentAddress, + nonce: `0x${randomBytes(32).toString("hex")}`, + to: payTo, + validAfter: "0", + validBefore: String(nowSeconds + 300), + value: amount, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +function reserve(identity: Awaited>, amount: string, offset: number) { + return reserveSignRequest(connection.db, { + ...identity, + body: signBody(amount), + nowSeconds: nowSeconds + offset, + }); +} + +describe("durable cap halt with real PostgreSQL", () => { + it("blocks fitting attempts until a sufficient raise or cycle reset", async () => { + const identity = await provision(); + const committed = await reserve(identity, "800000", 0); + const firstBlocked = await reserve(identity, "300000", 1); + const fittingButHalted = await reserve(identity, "100000", 2); + expect([committed.kind, firstBlocked.kind, fittingButHalted.kind]).toEqual([ + "pending", + "blocked", + "blocked", + ]); + await expect( + readOwnerCap(connection.db, { + agentId: identity.agentId, + now: new Date((nowSeconds + 3) * 1_000), + ownerId: identity.ownerId, + }), + ).resolves.toMatchObject({ halted: true, spend: { committedAtomic: "800000" } }); + + const same = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "100", + frequency: "daily", + now: new Date((nowSeconds + 4) * 1_000), + }); + const lower = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "90", + frequency: "daily", + now: new Date((nowSeconds + 5) * 1_000), + }); + const stillBlocked = await reserve(identity, "10000", 6); + expect([same.halted, lower.halted, stillBlocked.kind]).toEqual([true, true, "blocked"]); + + const raised = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "110", + frequency: "daily", + now: new Date((nowSeconds + 7) * 1_000), + }); + const resumed = await reserve(identity, "100000", 8); + expect([raised.halted, resumed.kind]).toEqual([false, "pending"]); + + expect((await reserve(identity, "300000", 9)).kind).toBe("blocked"); + const reset = await resetOwnerCapCycle(connection.db, { + ...identity, + now: new Date((nowSeconds + 10) * 1_000), + }); + const resumedAfterReset = await reserve(identity, "100000", 11); + expect([reset.halted, resumedAfterReset.kind]).toEqual([false, "pending"]); + + const capBlocks = await connection.db + .select({ resolvedAt: notifications.resolvedAt }) + .from(notifications) + .where(eq(notifications.type, "cap_blocked")); + expect(capBlocks).toHaveLength(2); + expect(capBlocks.every((item) => item.resolvedAt !== null)).toBe(true); + const stored = await connection.db + .select({ amountAtomic: receipts.amountAtomic, status: receipts.status }) + .from(receipts); + expect(stored.filter((receipt) => receipt.status === "blocked")).toHaveLength(4); + expect( + stored + .filter((receipt) => receipt.status === "pending") + .map((receipt) => receipt.amountAtomic) + .sort(), + ).toEqual(["100000", "100000", "800000"]); + }); + + it("blocks a pre-halt reservation at the final pre-sign gate", async () => { + const identity = await provision(); + const first = await reserve(identity, "400000", 0); + if (first.kind !== "pending") throw new Error("Expected first reservation"); + const openingBlock = await reserve(identity, "700000", 1); + expect(openingBlock.kind).toBe("blocked"); + + await expect( + completePreSigningChecks(connection.db, { + ...identity, + liveBalanceAtomic: BigInt(10_000_000), + nowSeconds: nowSeconds + 2, + receiptId: first.receiptId, + signerAvailable: true, + }), + ).resolves.toMatchObject({ code: "LEASH_CAP_EXCEEDED", kind: "blocked" }); + const [stored] = await connection.db + .select({ + intendedNetwork: receipts.intendedNetwork, + reason: receipts.reason, + settlementResponse: receipts.settlementResponse, + settledAt: receipts.settledAt, + status: receipts.status, + txHash: receipts.txHash, + }) + .from(receipts) + .where(eq(receipts.id, first.receiptId)); + expect(stored).toEqual({ + intendedNetwork: "eip155:8453", + reason: "LEASH_CAP_EXCEEDED", + settlementResponse: null, + settledAt: null, + status: "blocked", + txHash: null, + }); + const capBlocks = await connection.db + .select({ id: notifications.id }) + .from(notifications) + .where(eq(notifications.type, "cap_blocked")); + expect(capBlocks).toHaveLength(1); + const blockEvents = await connection.db + .select({ metadata: agentEvents.metadata }) + .from(agentEvents) + .where(eq(agentEvents.type, "block")); + expect(blockEvents.map((event) => event.metadata?.receiptId).sort()).toEqual( + [first.receiptId, openingBlock.receiptId].sort(), + ); + }); +}); diff --git a/apps/web/lib/leash/cap-input.test.ts b/apps/web/lib/leash/cap-input.test.ts new file mode 100644 index 0000000..9fe1af3 --- /dev/null +++ b/apps/web/lib/leash/cap-input.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { InvalidCapInputError, parseCapMutation } from "./cap-input"; + +const agentId = "11111111-1111-4111-8111-111111111111"; + +describe("Leash cap mutation input", () => { + it.each([ + ["10", "1000"], + ["10.5", "1050"], + ["0.01", "1"], + ["999999999999999999.99", "99999999999999999999"], + ])("parses %s USD into exact integer cents", (amount, amountUsdCents) => { + expect(parseCapMutation({ agentId, amount, frequency: "daily" })).toEqual({ + agentId, + amountUsdCents, + frequency: "daily", + }); + }); + + it.each([ + "daily", + "weekly", + "monthly", + "never", + ] as const)("accepts the canonical %s frequency", (frequency) => { + expect(parseCapMutation({ agentId, amount: "1.00", frequency }).frequency).toBe(frequency); + }); + + it.each([ + {}, + { agentId, amount: "1", frequency: "custom" }, + { agentId, amount: "0", frequency: "daily" }, + { agentId, amount: "-1", frequency: "daily" }, + { agentId, amount: "1.001", frequency: "daily" }, + { agentId, amount: "1e2", frequency: "daily" }, + { agentId, amount: "1", extra: true, frequency: "daily" }, + { agentId: "not-a-uuid", amount: "1", frequency: "daily" }, + { agentId, amount: "1000000000000000000", frequency: "daily" }, + ])("rejects malformed or out-of-range input %#", (value) => { + expect(() => parseCapMutation(value)).toThrow(InvalidCapInputError); + }); +}); diff --git a/apps/web/lib/leash/cap-input.ts b/apps/web/lib/leash/cap-input.ts new file mode 100644 index 0000000..61ffa6e --- /dev/null +++ b/apps/web/lib/leash/cap-input.ts @@ -0,0 +1,65 @@ +const CAP_FREQUENCIES = new Set(["daily", "weekly", "monthly", "never"]); +const MAX_CAP_USD_CENTS = BigInt("99999999999999999999"); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type CapFrequency = "daily" | "weekly" | "monthly" | "never"; + +export class InvalidCapInputError extends Error { + readonly code = "INVALID_CAP"; + + constructor() { + super("Choose a positive cap amount and reset frequency."); + this.name = "InvalidCapInputError"; + } +} + +function exactRecord(value: unknown) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new InvalidCapInputError(); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== 3 || keys.join("\0") !== "agentId\0amount\0frequency") { + throw new InvalidCapInputError(); + } + return record; +} + +export function parseAgentTarget(value: unknown) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new InvalidCapInputError(); + } + const record = value as Record; + if ( + Object.keys(record).length !== 1 || + typeof record.agentId !== "string" || + !UUID.test(record.agentId) + ) { + throw new InvalidCapInputError(); + } + return { agentId: record.agentId }; +} + +function amountInCents(value: unknown) { + if (typeof value !== "string") throw new InvalidCapInputError(); + const match = /^(0|[1-9][0-9]*)(?:\.([0-9]{1,2}))?$/.exec(value.trim()); + if (!match?.[1]) throw new InvalidCapInputError(); + const cents = BigInt(match[1]) * BigInt(100) + BigInt((match[2] ?? "").padEnd(2, "0") || "0"); + if (cents === BigInt(0) || cents > MAX_CAP_USD_CENTS) throw new InvalidCapInputError(); + return cents.toString(); +} + +export function parseCapMutation(value: unknown) { + const input = exactRecord(value); + if (typeof input.agentId !== "string" || !UUID.test(input.agentId)) { + throw new InvalidCapInputError(); + } + if (typeof input.frequency !== "string" || !CAP_FREQUENCIES.has(input.frequency)) { + throw new InvalidCapInputError(); + } + return { + agentId: input.agentId, + amountUsdCents: amountInCents(input.amount), + frequency: input.frequency as CapFrequency, + }; +} diff --git a/apps/web/lib/leash/cap-policy-errors.ts b/apps/web/lib/leash/cap-policy-errors.ts new file mode 100644 index 0000000..7b4f36c --- /dev/null +++ b/apps/web/lib/leash/cap-policy-errors.ts @@ -0,0 +1,13 @@ +export class LeashAgentNotFoundError extends Error { + constructor() { + super("The Leash agent was not found"); + this.name = "LeashAgentNotFoundError"; + } +} + +export class LeashCapNotFoundError extends Error { + constructor() { + super("The Leash cap was not found"); + this.name = "LeashCapNotFoundError"; + } +} diff --git a/apps/web/lib/leash/cap-policy-summary.ts b/apps/web/lib/leash/cap-policy-summary.ts new file mode 100644 index 0000000..8c342fe --- /dev/null +++ b/apps/web/lib/leash/cap-policy-summary.ts @@ -0,0 +1,86 @@ +import { and, eq, inArray, sql } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { receipts } from "../db/schema"; +import { deriveCapDisplay } from "./cap-display"; +import { findActiveCapHalt } from "./cap-halt-store"; +import { type CapFrequency, deriveCycleWindow } from "./cycles"; + +const ATOMIC_UNITS_PER_CENT = BigInt(10_000); +type Transaction = Parameters[0]>[0]; + +export type PolicyCycle = { + id: string; + nextResetAt?: Date | null; + startedAt: Date; +}; + +async function usage(transaction: Transaction, agentId: string, cycleId: string) { + const [row] = await transaction + .select({ + blockedReceiptCount: sql`count(*) filter + (where ${receipts.status} = 'blocked')::integer`, + pendingAtomic: sql`coalesce(sum(${receipts.amountAtomic}) filter + (where ${receipts.status} = 'pending'), 0)::text`, + settledAtomic: sql`coalesce(sum(${receipts.amountAtomic}) filter + (where ${receipts.status} = 'settled'), 0)::text`, + }) + .from(receipts) + .where( + and( + eq(receipts.agentId, agentId), + eq(receipts.cycleId, cycleId), + inArray(receipts.status, ["blocked", "pending", "settled"]), + ), + ); + return { + blockedReceiptCount: row?.blockedReceiptCount ?? 0, + pendingAtomic: row?.pendingAtomic ?? "0", + settledAtomic: row?.settledAtomic ?? "0", + }; +} + +export async function capPolicySummary( + transaction: Transaction, + input: { + agentId: string; + amountUsdCents: string; + cycle: PolicyCycle; + frequency: CapFrequency; + updatedAt: Date; + }, +) { + const currentUsage = await usage(transaction, input.agentId, input.cycle.id); + const spend = { + blockedReceiptCount: currentUsage.blockedReceiptCount, + ...deriveCapDisplay({ + capUsdCents: input.amountUsdCents, + pendingAtomic: currentUsage.pendingAtomic, + settledAtomic: currentUsage.settledAtomic, + }), + }; + const activeHalt = await findActiveCapHalt(transaction, input.agentId); + return { + agentId: input.agentId, + cap: { + amountUsdCents: input.amountUsdCents, + frequency: input.frequency, + updatedAt: input.updatedAt, + }, + cycle: { + id: input.cycle.id, + nextResetAt: + input.cycle.nextResetAt === undefined + ? deriveCycleWindow(input.cycle.startedAt, input.frequency, input.cycle.startedAt) + .nextResetAt + : input.cycle.nextResetAt, + startedAt: input.cycle.startedAt, + }, + halted: + activeHalt !== undefined || + BigInt(spend.committedAtomic) >= BigInt(input.amountUsdCents) * ATOMIC_UNITS_PER_CENT, + spend, + }; +} + +export type CapPolicySummary = Awaited>; diff --git a/apps/web/lib/leash/cap-policy.integration.test.ts b/apps/web/lib/leash/cap-policy.integration.test.ts new file mode 100644 index 0000000..78c6b97 --- /dev/null +++ b/apps/web/lib/leash/cap-policy.integration.test.ts @@ -0,0 +1,285 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { asc, eq } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { agents, capCycles, caps, notifications, receipts, users } from "../db/schema"; +import { + LeashAgentNotFoundError, + readOwnerCap, + resetOwnerCapCycle, + setOwnerCap, +} from "./cap-policy"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for cap-policy integration tests"); + +const connection = createDatabase(databaseUrl, 4); +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const payTo = "0x1111111111111111111111111111111111111111"; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(label: string) { + const [owner] = await connection.db + .insert(users) + .values({ + email: `${label}-${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.db + .insert(agents) + .values({ ownerId: owner.id, name: `${label} agent`, signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + return { agentId: agent.id, ownerId: owner.id }; +} + +async function insertReceipt( + identity: Awaited>, + cycleId: string, + status: "pending" | "settled" | "failed" | "blocked", + amountAtomic: string, +) { + const atomic = BigInt(amountAtomic); + const settled = status === "settled"; + const blocked = status === "blocked"; + await connection.db.insert(receipts).values({ + agentId: identity.agentId, + amountAtomic, + amountUsd: `${atomic / BigInt(1_000_000)}.${(atomic % BigInt(1_000_000)) + .toString() + .padStart(6, "0")}`, + asset: baseUsdc, + authorizationNonce: `0x${randomBytes(32).toString("hex")}`, + authorizationValidBefore: new Date("2030-01-01T00:00:00.000Z"), + cycleId, + intendedNetwork: blocked ? "eip155:8453" : null, + network: "eip155:8453", + payTo, + reason: status === "failed" ? "FLOAT_EMPTY" : blocked ? "LEASH_CAP_EXCEEDED" : null, + requestFingerprint: randomBytes(32).toString("hex"), + settledAt: settled ? new Date("2026-07-17T00:01:00.000Z") : null, + settlementResponse: settled ? { verified: true } : null, + status, + txHash: settled ? `0x${randomBytes(32).toString("hex")}` : null, + }); +} + +describe("owner-scoped cap policy with real PostgreSQL", () => { + it("creates the first real cap and cycle, then reads exact zero usage", async () => { + const identity = await provision("first"); + const now = new Date("2026-07-17T00:00:00.000Z"); + + const created = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "1000", + frequency: "daily", + now, + }); + + expect(created).toMatchObject({ + agentId: identity.agentId, + cap: { amountUsdCents: "1000", frequency: "daily" }, + cycle: { startedAt: now }, + halted: false, + spend: { committedAtomic: "0", pendingAtomic: "0", settledAtomic: "0" }, + }); + await expect(readOwnerCap(connection.db, { ...identity, now })).resolves.toEqual(created); + }); + + it("retains the cycle on amount-only changes and halts immediately below committed spend", async () => { + const identity = await provision("lower"); + const started = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "200", + frequency: "daily", + now: new Date("2026-07-17T00:00:00.000Z"), + }); + await insertReceipt(identity, started.cycle.id, "settled", "700000"); + await insertReceipt(identity, started.cycle.id, "pending", "200000"); + await insertReceipt(identity, started.cycle.id, "failed", "900000"); + await insertReceipt(identity, started.cycle.id, "blocked", "900000"); + + const lowered = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "80", + frequency: "daily", + now: new Date("2026-07-17T01:00:00.000Z"), + }); + expect(lowered).toMatchObject({ + cycle: { id: started.cycle.id }, + halted: true, + spend: { + blockedReceiptCount: 1, + committedAtomic: "900000", + pendingAtomic: "200000", + settledAtomic: "700000", + }, + }); + const activeAlerts = await connection.db + .select({ resolvedAt: notifications.resolvedAt, type: notifications.type }) + .from(notifications) + .where(eq(notifications.agentId, identity.agentId)); + expect(activeAlerts).toEqual([{ resolvedAt: null, type: "cap_lowered_halt" }]); + + const raised = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "100", + frequency: "daily", + now: new Date("2026-07-17T02:00:00.000Z"), + }); + expect(raised).toMatchObject({ cycle: { id: started.cycle.id }, halted: false }); + const [resolved] = await connection.db + .select({ resolvedAt: notifications.resolvedAt }) + .from(notifications) + .where(eq(notifications.agentId, identity.agentId)); + expect(resolved?.resolvedAt).toEqual(new Date("2026-07-17T02:00:00.000Z")); + }); + + it("starts a clean cycle on frequency change and manual reset", async () => { + const identity = await provision("reset"); + const foreign = await provision("reset-foreign"); + const first = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "100", + frequency: "daily", + now: new Date("2026-07-17T00:00:00.000Z"), + }); + await insertReceipt(identity, first.cycle.id, "settled", "500000"); + await insertReceipt(identity, first.cycle.id, "blocked", "900000"); + const foreignCycle = await setOwnerCap(connection.db, { + ...foreign, + amountUsdCents: "100", + frequency: "daily", + now: new Date("2026-07-17T00:00:00.000Z"), + }); + await insertReceipt(foreign, foreignCycle.cycle.id, "blocked", "900000"); + + const weekly = await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "100", + frequency: "weekly", + now: new Date("2026-07-17T03:00:00.000Z"), + }); + expect(weekly).toMatchObject({ + cap: { frequency: "weekly" }, + spend: { blockedReceiptCount: 0, committedAtomic: "0" }, + }); + expect(weekly.cycle.id).not.toBe(first.cycle.id); + await insertReceipt(identity, weekly.cycle.id, "blocked", "900000"); + + const reset = await resetOwnerCapCycle(connection.db, { + ...identity, + now: new Date("2026-07-17T04:00:00.000Z"), + }); + expect(reset.cycle.id).not.toBe(weekly.cycle.id); + expect(reset.spend).toMatchObject({ blockedReceiptCount: 0, committedAtomic: "0" }); + const history = await connection.db + .select({ id: capCycles.id, resetReason: capCycles.resetReason }) + .from(capCycles) + .where(eq(capCycles.agentId, identity.agentId)) + .orderBy(asc(capCycles.startedAt)); + expect(history).toEqual([ + { id: first.cycle.id, resetReason: "frequency_change" }, + { id: weekly.cycle.id, resetReason: "manual" }, + { id: reset.cycle.id, resetReason: null }, + ]); + }); + + it("reports the immutable month-end reset after a persisted short-month rollover", async () => { + const identity = await provision("month-end"); + await setOwnerCap(connection.db, { + ...identity, + amountUsdCents: "100", + frequency: "monthly", + now: new Date("2026-01-31T00:00:00.000Z"), + }); + + const february = await readOwnerCap(connection.db, { + ...identity, + now: new Date("2026-02-28T00:00:00.000Z"), + }); + expect(february).toMatchObject({ + cycle: { + nextResetAt: new Date("2026-03-31T00:00:00.000Z"), + startedAt: new Date("2026-02-28T00:00:00.000Z"), + }, + }); + }); + + it("re-anchors frequency changes and manual resets exactly at a UTC boundary", async () => { + const frequencyIdentity = await provision("boundary-frequency"); + await setOwnerCap(connection.db, { + ...frequencyIdentity, + amountUsdCents: "100", + frequency: "daily", + now: new Date("2026-07-16T00:00:00.000Z"), + }); + const weekly = await setOwnerCap(connection.db, { + ...frequencyIdentity, + amountUsdCents: "100", + frequency: "weekly", + now: new Date("2026-07-17T00:00:00.000Z"), + }); + expect(weekly).toMatchObject({ + cap: { frequency: "weekly" }, + cycle: { + nextResetAt: new Date("2026-07-24T00:00:00.000Z"), + startedAt: new Date("2026-07-17T00:00:00.000Z"), + }, + }); + + const manualIdentity = await provision("boundary-manual"); + await setOwnerCap(connection.db, { + ...manualIdentity, + amountUsdCents: "100", + frequency: "daily", + now: new Date("2026-07-16T00:00:00.000Z"), + }); + const reset = await resetOwnerCapCycle(connection.db, { + ...manualIdentity, + now: new Date("2026-07-17T00:00:00.000Z"), + }); + expect(reset.cycle).toMatchObject({ + nextResetAt: new Date("2026-07-18T00:00:00.000Z"), + startedAt: new Date("2026-07-17T00:00:00.000Z"), + }); + + const reasons = await connection.db + .select({ agentId: capCycles.agentId, resetReason: capCycles.resetReason }) + .from(capCycles) + .where(eq(capCycles.endedAt, new Date("2026-07-17T00:00:00.000Z"))); + expect(reasons).toEqual( + expect.arrayContaining([ + { agentId: frequencyIdentity.agentId, resetReason: "frequency_change" }, + { agentId: manualIdentity.agentId, resetReason: "manual" }, + ]), + ); + }); + + it("does not reveal or mutate an agent owned by another user", async () => { + const owner = await provision("owner"); + const foreign = await provision("foreign"); + + await expect( + setOwnerCap(connection.db, { + agentId: owner.agentId, + amountUsdCents: "100", + frequency: "daily", + now: new Date("2026-07-17T00:00:00.000Z"), + ownerId: foreign.ownerId, + }), + ).rejects.toBeInstanceOf(LeashAgentNotFoundError); + expect(await connection.db.select().from(caps)).toEqual([]); + }); +}); diff --git a/apps/web/lib/leash/cap-policy.ts b/apps/web/lib/leash/cap-policy.ts new file mode 100644 index 0000000..f28bd06 --- /dev/null +++ b/apps/web/lib/leash/cap-policy.ts @@ -0,0 +1,154 @@ +import { and, eq } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { agents, caps } from "../db/schema"; +import { + readCurrentCapPolicy, + replaceCapCycle, + resetCapCycleAtBoundary, +} from "./cap-cycle-policy-store"; +import { LeashAgentNotFoundError, LeashCapNotFoundError } from "./cap-policy-errors"; +import { capPolicySummary } from "./cap-policy-summary"; +import type { CapFrequency } from "./cycles"; +import { emitCapLoweredHalt, resolveActiveCapHalt } from "./notifier"; + +const ATOMIC_UNITS_PER_CENT = BigInt(10_000); +type Transaction = Parameters[0]>[0]; + +type OwnerAgent = { agentId: string; ownerId: string }; +type TimedOwnerAgent = OwnerAgent & { now?: Date }; +type CapMutation = TimedOwnerAgent & { + amountUsdCents: string; + frequency: CapFrequency; +}; + +export { LeashAgentNotFoundError, LeashCapNotFoundError } from "./cap-policy-errors"; + +function validNow(now: Date | undefined) { + const value = now ?? new Date(); + if (!Number.isFinite(value.getTime())) throw new Error("The cap timestamp is invalid"); + return value; +} + +function validAmount(value: string) { + if (!/^[1-9][0-9]*$/.test(value)) throw new Error("The cap amount is invalid"); + return BigInt(value); +} + +async function lockOwnedAgent(transaction: Transaction, input: OwnerAgent) { + const [agent] = await transaction + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.id, input.agentId), eq(agents.ownerId, input.ownerId))) + .for("update"); + if (!agent) throw new LeashAgentNotFoundError(); + return agent; +} + +export async function readOwnerCap(db: Database, input: TimedOwnerAgent) { + const now = validNow(input.now); + return db.transaction(async (transaction) => { + await lockOwnedAgent(transaction, input); + const policy = await readCurrentCapPolicy(transaction, input.agentId, now); + if (!policy) return null; + return capPolicySummary(transaction, { agentId: input.agentId, ...policy }); + }); +} + +export async function setOwnerCap(db: Database, input: CapMutation) { + const amount = validAmount(input.amountUsdCents); + const now = validNow(input.now); + return db.transaction(async (transaction) => { + await lockOwnedAgent(transaction, input); + const previous = await readCurrentCapPolicy(transaction, input.agentId, now); + const frequencyChanged = previous !== undefined && previous.frequency !== input.frequency; + const cycle = + previous && !frequencyChanged + ? previous.cycle + : previous + ? await resetCapCycleAtBoundary(transaction, previous.cycle, { + agentId: input.agentId, + now, + reason: "frequency_change", + }) + : await replaceCapCycle(transaction, { + agentId: input.agentId, + now, + reason: "frequency_change", + }); + const [stored] = await transaction + .insert(caps) + .values({ + agentId: input.agentId, + amountUsdCents: amount.toString(), + frequency: input.frequency, + updatedAt: now, + }) + .onConflictDoUpdate({ + set: { amountUsdCents: amount.toString(), frequency: input.frequency, updatedAt: now }, + target: caps.agentId, + }) + .returning({ + amountUsdCents: caps.amountUsdCents, + frequency: caps.frequency, + updatedAt: caps.updatedAt, + }); + if (!stored?.amountUsdCents) throw new Error("PostgreSQL did not return the stored cap"); + const resultBeforeRemediation = await capPolicySummary(transaction, { + agentId: input.agentId, + amountUsdCents: stored.amountUsdCents, + cycle, + frequency: stored.frequency, + updatedAt: stored.updatedAt, + }); + const capAtomic = (amount * ATOMIC_UNITS_PER_CENT).toString(); + const committedAtomic = resultBeforeRemediation.spend.committedAtomic; + const loweredWithinCycle = + previous !== undefined && !frequencyChanged && amount < BigInt(previous.amountUsdCents); + const raisedWithinCycle = + previous !== undefined && !frequencyChanged && amount > BigInt(previous.amountUsdCents); + if (loweredWithinCycle && BigInt(committedAtomic) > BigInt(capAtomic)) { + await emitCapLoweredHalt(transaction, { + agentId: input.agentId, + capAtomic, + committedAtomic, + cycleId: cycle.id, + now, + }); + } else if ( + (frequencyChanged || raisedWithinCycle) && + BigInt(committedAtomic) < BigInt(capAtomic) + ) { + await resolveActiveCapHalt(transaction, { agentId: input.agentId, now }); + } + return capPolicySummary(transaction, { + agentId: input.agentId, + amountUsdCents: stored.amountUsdCents, + cycle, + frequency: stored.frequency, + updatedAt: stored.updatedAt, + }); + }); +} + +export async function resetOwnerCapCycle(db: Database, input: TimedOwnerAgent) { + const now = validNow(input.now); + return db.transaction(async (transaction) => { + await lockOwnedAgent(transaction, input); + const policy = await readCurrentCapPolicy(transaction, input.agentId, now); + if (!policy) throw new LeashCapNotFoundError(); + const cycle = await resetCapCycleAtBoundary(transaction, policy.cycle, { + agentId: input.agentId, + now, + reason: "manual", + }); + await resolveActiveCapHalt(transaction, { agentId: input.agentId, now }); + return capPolicySummary(transaction, { + agentId: input.agentId, + amountUsdCents: policy.amountUsdCents, + cycle, + frequency: policy.frequency, + updatedAt: policy.updatedAt, + }); + }); +} diff --git a/apps/web/lib/leash/cap-view.ts b/apps/web/lib/leash/cap-view.ts new file mode 100644 index 0000000..aab176c --- /dev/null +++ b/apps/web/lib/leash/cap-view.ts @@ -0,0 +1,45 @@ +import type { CapPolicySummary } from "./cap-policy-summary"; +import { formatUsdAtomic, formatUsdCents } from "./leash-format"; + +export type CapPolicyView = Omit & { + cap: Omit & { updatedAt: string }; + cycle: Omit & { + nextResetAt: string | null; + startedAt: string; + }; +}; + +export function capPolicyView(policy: CapPolicySummary): CapPolicyView { + return { + ...policy, + cap: { ...policy.cap, updatedAt: policy.cap.updatedAt.toISOString() }, + cycle: { + ...policy.cycle, + nextResetAt: policy.cycle.nextResetAt?.toISOString() ?? null, + startedAt: policy.cycle.startedAt.toISOString(), + }, + }; +} + +export function blockedReceiptEvidence(count: number) { + const attempts = + count === 0 + ? "No cap-blocked attempts" + : `${count} cap-blocked attempt${count === 1 ? "" : "s"}`; + return `${attempts} this cycle · blocked and failed receipts are excluded from spend.`; +} + +export function capUsageDescription(policy: CapPolicyView) { + const overage = + BigInt(policy.spend.overageAtomic) > BigInt(0) + ? `${formatUsdAtomic(policy.spend.overageAtomic)} over cap` + : "no overage"; + return [ + "Current cycle cap usage:", + `${formatUsdAtomic(policy.spend.settledAtomic)} settled;`, + `${formatUsdAtomic(policy.spend.pendingAtomic)} pending;`, + `${formatUsdCents(policy.cap.amountUsdCents)} cap;`, + `${overage};`, + blockedReceiptEvidence(policy.spend.blockedReceiptCount), + ].join(" "); +} diff --git a/apps/web/lib/leash/cycles.integration.test.ts b/apps/web/lib/leash/cycles.integration.test.ts new file mode 100644 index 0000000..3d03248 --- /dev/null +++ b/apps/web/lib/leash/cycles.integration.test.ts @@ -0,0 +1,266 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { and, asc, eq, isNull } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { issueLeashKey } from "../auth/leash-key"; +import { createDatabase } from "../db/client"; +import { agents, capCycles, caps, notifications, receipts, users } from "../db/schema"; +import { completePreSigningChecks, reserveSignRequest } from "./sign-store"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for cycle integration tests"); + +const connection = createDatabase(databaseUrl, 4); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(frequency: "daily" | "weekly" | "monthly" | "never", startedAt: Date) { + const [owner] = await connection.db + .insert(users) + .values({ email: `${randomUUID()}@example.test`, magicIssuer: `did:ethr:${randomUUID()}` }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected cycle owner"); + const [agent] = await connection.db + .insert(agents) + .values({ + agentAddress, + name: "Cycle test", + ownerId: owner.id, + signerSubject: `leash:${randomUUID()}`, + }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected cycle agent"); + const key = await issueLeashKey(connection.db, { agentId: agent.id }); + await connection.db.insert(caps).values({ + agentId: agent.id, + amountUsdCents: "100", + frequency, + }); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected active cycle"); + return { agentId: agent.id, cycleId: cycle.id, keyId: key.key.id }; +} + +function signBody(nowSeconds: number, amount = "100000", nonce = randomBytes(32).toString("hex")) { + return { + amount, + asset: baseUsdc, + network: "eip155:8453", + origin: { clientName: "Cycle integration", toolName: "pay", transport: "mcp" }, + payTo, + resourceUrl: "mcp://tool/pay", + signerRequest: { + domain: { chainId: 8453, name: "USD Coin", verifyingContract: baseUsdc, version: "2" }, + message: { + from: agentAddress, + nonce: `0x${nonce}`, + to: payTo, + validAfter: "0", + validBefore: String(nowSeconds + 300), + value: amount, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +async function reserveAt( + identity: Awaited>, + now: Date, + nonce?: string, +) { + const nowSeconds = Math.floor(now.getTime() / 1_000); + return reserveSignRequest(connection.db, { + agentId: identity.agentId, + body: signBody(nowSeconds, "100000", nonce), + keyId: identity.keyId, + nowSeconds, + }); +} + +async function insertOldSettled(identity: Awaited>) { + const [row] = await connection.db + .insert(receipts) + .values({ + agentId: identity.agentId, + amountAtomic: "1000000", + amountUsd: "1.000000", + asset: baseUsdc, + authorizationNonce: `0x${randomBytes(32).toString("hex")}`, + authorizationValidBefore: new Date("2030-01-01T00:00:00.000Z"), + cycleId: identity.cycleId, + network: "eip155:8453", + payTo, + requestFingerprint: randomBytes(32).toString("hex"), + settledAt: new Date("2026-01-01T00:01:00.000Z"), + settlementResponse: { verified: true }, + status: "settled", + txHash: `0x${randomBytes(32).toString("hex")}`, + }) + .returning({ id: receipts.id }); + if (!row) throw new Error("Expected old receipt"); + return row.id; +} + +describe("scheduled cap-cycle rollover with real PostgreSQL", () => { + it("advances missed periods and leaves historical receipts in the closed cycle", async () => { + const identity = await provision("daily", new Date("2026-01-01T00:00:00.000Z")); + const oldReceiptId = await insertOldSettled(identity); + const result = await reserveAt(identity, new Date("2026-04-15T12:00:00.000Z")); + expect(result).toMatchObject({ kind: "pending" }); + + const cycles = await connection.db + .select() + .from(capCycles) + .where(eq(capCycles.agentId, identity.agentId)) + .orderBy(asc(capCycles.startedAt)); + expect(cycles).toMatchObject([ + { + endedAt: new Date("2026-01-02T00:00:00.000Z"), + id: identity.cycleId, + resetReason: "schedule", + }, + { endedAt: null, startedAt: new Date("2026-04-15T00:00:00.000Z") }, + ]); + const stored = await connection.db + .select({ cycleId: receipts.cycleId, id: receipts.id }) + .from(receipts) + .where(eq(receipts.agentId, identity.agentId)); + expect(stored.find((row) => row.id === oldReceiptId)?.cycleId).toBe(identity.cycleId); + expect(stored.find((row) => row.id === result.receiptId)?.cycleId).toBe(cycles[1]?.id); + }); + + it("creates one active cycle for concurrent first reads at an exact boundary", async () => { + const identity = await provision("daily", new Date("2026-07-16T00:00:00.000Z")); + const boundary = new Date("2026-07-17T00:00:00.000Z"); + const results = await Promise.all([ + reserveAt(identity, boundary, "1".repeat(64)), + reserveAt(identity, boundary, "2".repeat(64)), + ]); + expect(results.map((result) => result.kind)).toEqual(["pending", "pending"]); + + const active = await connection.db + .select({ id: capCycles.id, startedAt: capCycles.startedAt }) + .from(capCycles) + .where(and(eq(capCycles.agentId, identity.agentId), isNull(capCycles.endedAt))); + expect(active).toEqual([{ id: expect.any(String), startedAt: boundary }]); + const receiptCycles = await connection.db + .select({ cycleId: receipts.cycleId }) + .from(receipts) + .where(eq(receipts.agentId, identity.agentId)); + expect(new Set(receiptCycles.map((row) => row.cycleId))).toEqual(new Set([active[0]?.id])); + }); + + it("auto-resumes a cap halt when the scheduled UTC cycle rolls", async () => { + const identity = await provision("daily", new Date("2026-07-16T00:00:00.000Z")); + const [alert] = await connection.db + .insert(notifications) + .values({ + agentId: identity.agentId, + createdAt: new Date("2026-07-16T12:00:00.000Z"), + cycleId: identity.cycleId, + eventKey: `cap_lowered_halt:${identity.cycleId}:test`, + metadata: {}, + sticky: true, + tier: "3", + type: "cap_lowered_halt", + }) + .returning({ id: notifications.id }); + if (!alert) throw new Error("Expected cap halt notification"); + + const boundary = new Date("2026-07-17T00:00:00.000Z"); + await expect(reserveAt(identity, boundary)).resolves.toMatchObject({ kind: "pending" }); + const [resolved] = await connection.db + .select({ resolvedAt: notifications.resolvedAt }) + .from(notifications) + .where(eq(notifications.id, alert.id)); + expect(resolved?.resolvedAt).toEqual(boundary); + }); + + it("keeps a never-frequency cycle open", async () => { + const identity = await provision("never", new Date("2026-01-01T00:00:00.000Z")); + const result = await reserveAt(identity, new Date("2030-07-17T00:00:00.000Z")); + expect(result).toMatchObject({ kind: "pending" }); + + const cycles = await connection.db + .select({ endedAt: capCycles.endedAt, id: capCycles.id }) + .from(capCycles) + .where(eq(capCycles.agentId, identity.agentId)); + expect(cycles).toEqual([{ endedAt: null, id: identity.cycleId }]); + }); + + it("retains a month-end anchor across sequential persisted rollovers", async () => { + const identity = await provision("monthly", new Date("2026-01-31T10:15:00.000Z")); + + await expect(reserveAt(identity, new Date("2026-02-28T00:00:00.000Z"))).resolves.toMatchObject({ + kind: "pending", + }); + await expect(reserveAt(identity, new Date("2026-03-31T00:00:00.000Z"))).resolves.toMatchObject({ + kind: "pending", + }); + + const cycles = await connection.db + .select({ endedAt: capCycles.endedAt, startedAt: capCycles.startedAt }) + .from(capCycles) + .where(eq(capCycles.agentId, identity.agentId)) + .orderBy(asc(capCycles.startedAt)); + expect(cycles).toEqual([ + { + endedAt: new Date("2026-02-28T00:00:00.000Z"), + startedAt: new Date("2026-01-31T10:15:00.000Z"), + }, + { + endedAt: new Date("2026-03-31T00:00:00.000Z"), + startedAt: new Date("2026-02-28T00:00:00.000Z"), + }, + { endedAt: null, startedAt: new Date("2026-03-31T00:00:00.000Z") }, + ]); + }); + + it("rolls during final preflight and rejects the old-cycle reservation", async () => { + const identity = await provision("daily", new Date("2026-07-16T00:00:00.000Z")); + const reservedAt = new Date("2026-07-16T23:59:00.000Z"); + const reserved = await reserveAt(identity, reservedAt); + if (reserved.kind !== "pending") throw new Error("Expected pending reservation"); + + await expect( + completePreSigningChecks(connection.db, { + agentId: identity.agentId, + keyId: identity.keyId, + liveBalanceAtomic: BigInt(10_000_000), + nowSeconds: Math.floor(new Date("2026-07-17T00:00:00.000Z").getTime() / 1_000), + receiptId: reserved.receiptId, + signerAvailable: true, + }), + ).resolves.toMatchObject({ code: "CAP_CYCLE_CHANGED", kind: "failed" }); + + const [active] = await connection.db + .select({ startedAt: capCycles.startedAt }) + .from(capCycles) + .where(and(eq(capCycles.agentId, identity.agentId), isNull(capCycles.endedAt))); + expect(active?.startedAt).toEqual(new Date("2026-07-17T00:00:00.000Z")); + }); +}); diff --git a/apps/web/lib/leash/cycles.test.ts b/apps/web/lib/leash/cycles.test.ts new file mode 100644 index 0000000..0356a3c --- /dev/null +++ b/apps/web/lib/leash/cycles.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { deriveCycleWindow } from "./cycles"; + +describe("UTC cap-cycle boundaries", () => { + it("rolls daily at the exact anchored boundary", () => { + expect( + deriveCycleWindow( + new Date("2026-07-01T00:00:00.000Z"), + "daily", + new Date("2026-07-03T00:00:00.000Z"), + ), + ).toEqual({ + nextResetAt: new Date("2026-07-04T00:00:00.000Z"), + startedAt: new Date("2026-07-03T00:00:00.000Z"), + }); + }); + + it("advances across missed weekly periods without moving the anchor", () => { + expect( + deriveCycleWindow( + new Date("2026-01-05T08:30:00.000Z"), + "weekly", + new Date("2026-01-27T12:00:00.000Z"), + ), + ).toEqual({ + nextResetAt: new Date("2026-02-02T00:00:00.000Z"), + startedAt: new Date("2026-01-26T00:00:00.000Z"), + }); + }); + + it("keeps a month-end UTC anchor after a shorter month", () => { + expect( + deriveCycleWindow( + new Date("2026-01-31T10:15:00.000Z"), + "monthly", + new Date("2026-03-31T10:15:00.000Z"), + ), + ).toEqual({ + nextResetAt: new Date("2026-04-30T00:00:00.000Z"), + startedAt: new Date("2026-03-31T00:00:00.000Z"), + }); + }); + + it("normalizes a daily schedule to UTC midnight", () => { + expect( + deriveCycleWindow( + new Date("2026-07-01T18:45:00.000Z"), + "daily", + new Date("2026-07-03T12:00:00.000Z"), + ), + ).toEqual({ + nextResetAt: new Date("2026-07-04T00:00:00.000Z"), + startedAt: new Date("2026-07-03T00:00:00.000Z"), + }); + }); + + it("never schedules a rollover for a manual-only cycle", () => { + const anchor = new Date("2026-01-01T00:00:00.000Z"); + expect(deriveCycleWindow(anchor, "never", new Date("2030-01-01T00:00:00.000Z"))).toEqual({ + nextResetAt: null, + startedAt: anchor, + }); + }); +}); diff --git a/apps/web/lib/leash/cycles.ts b/apps/web/lib/leash/cycles.ts new file mode 100644 index 0000000..714f33a --- /dev/null +++ b/apps/web/lib/leash/cycles.ts @@ -0,0 +1,122 @@ +import { and, asc, desc, eq, inArray, isNull } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { capCycles } from "../db/schema"; +import { resolveActiveCapHalt } from "./notifier"; + +const DAY_MS = 24 * 60 * 60 * 1_000; +const WEEK_MS = 7 * DAY_MS; + +export type CapFrequency = "daily" | "weekly" | "monthly" | "never"; + +type Transaction = Parameters[0]>[0]; + +function monthlyBoundary(anchor: Date, offset: number) { + const month = anchor.getUTCMonth() + offset; + const year = anchor.getUTCFullYear() + Math.floor(month / 12); + const monthInYear = ((month % 12) + 12) % 12; + const lastDay = new Date(Date.UTC(year, monthInYear + 1, 0)).getUTCDate(); + return new Date(Date.UTC(year, monthInYear, Math.min(anchor.getUTCDate(), lastDay), 0, 0, 0, 0)); +} + +function utcMidnight(value: Date) { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); +} + +function fixedWindow(anchor: Date, now: Date, durationMs: number) { + const offset = Math.max(0, Math.floor((now.getTime() - anchor.getTime()) / durationMs)); + return { + nextResetAt: new Date(anchor.getTime() + (offset + 1) * durationMs), + startedAt: new Date(anchor.getTime() + offset * durationMs), + }; +} + +function monthlyWindow(anchor: Date, now: Date) { + let offset = Math.max( + 0, + (now.getUTCFullYear() - anchor.getUTCFullYear()) * 12 + + now.getUTCMonth() - + anchor.getUTCMonth(), + ); + while (offset > 0 && monthlyBoundary(anchor, offset) > now) offset -= 1; + while (monthlyBoundary(anchor, offset + 1) <= now) offset += 1; + return { + nextResetAt: monthlyBoundary(anchor, offset + 1), + startedAt: monthlyBoundary(anchor, offset), + }; +} + +export function deriveCycleWindow(anchor: Date, frequency: CapFrequency, now: Date) { + if (!Number.isFinite(anchor.getTime()) || !Number.isFinite(now.getTime())) { + throw new Error("Cycle timestamps must be valid dates"); + } + if (frequency === "never") { + return { nextResetAt: null, startedAt: new Date(anchor) }; + } + const normalizedAnchor = utcMidnight(anchor); + if (frequency === "monthly") return monthlyWindow(normalizedAnchor, now); + return fixedWindow(normalizedAnchor, now, frequency === "daily" ? DAY_MS : WEEK_MS); +} + +async function scheduleAnchor(transaction: Transaction, agentId: string) { + const [latestManualBoundary] = await transaction + .select({ anchor: capCycles.endedAt }) + .from(capCycles) + .where( + and( + eq(capCycles.agentId, agentId), + inArray(capCycles.resetReason, ["manual", "frequency_change"]), + ), + ) + .orderBy(desc(capCycles.endedAt)) + .limit(1); + if (latestManualBoundary?.anchor) return latestManualBoundary.anchor; + + const [first] = await transaction + .select({ anchor: capCycles.startedAt }) + .from(capCycles) + .where(eq(capCycles.agentId, agentId)) + .orderBy(asc(capCycles.startedAt)) + .limit(1); + return first?.anchor; +} + +export async function ensureCurrentCapCycle( + transaction: Transaction, + options: { agentId: string; frequency: CapFrequency; now: Date }, +) { + const [active] = await transaction + .select({ id: capCycles.id, startedAt: capCycles.startedAt }) + .from(capCycles) + .where(and(eq(capCycles.agentId, options.agentId), isNull(capCycles.endedAt))) + .for("update"); + if (!active) return undefined; + + const anchor = (await scheduleAnchor(transaction, options.agentId)) ?? active.startedAt; + const window = deriveCycleWindow(anchor, options.frequency, options.now); + if (window.startedAt <= active.startedAt) { + return { ...active, nextResetAt: window.nextResetAt, rolledFromId: null }; + } + const activeWindow = deriveCycleWindow(anchor, options.frequency, active.startedAt); + if (!activeWindow.nextResetAt) { + return { ...active, nextResetAt: null, rolledFromId: null }; + } + + const [closed] = await transaction + .update(capCycles) + .set({ endedAt: activeWindow.nextResetAt, resetReason: "schedule" }) + .where(and(eq(capCycles.id, active.id), isNull(capCycles.endedAt))) + .returning({ id: capCycles.id }); + if (!closed) throw new Error("The active cap cycle changed during rollover"); + const [opened] = await transaction + .insert(capCycles) + .values({ agentId: options.agentId, startedAt: window.startedAt }) + .returning({ id: capCycles.id, startedAt: capCycles.startedAt }); + if (!opened) throw new Error("PostgreSQL did not return the scheduled cap cycle"); + await resolveActiveCapHalt(transaction, { agentId: options.agentId, now: options.now }); + return { + ...opened, + nextResetAt: window.nextResetAt, + rolledFromId: active.id, + }; +} diff --git a/apps/web/lib/leash/float-empty-notifier.integration.test.ts b/apps/web/lib/leash/float-empty-notifier.integration.test.ts new file mode 100644 index 0000000..921a5a0 --- /dev/null +++ b/apps/web/lib/leash/float-empty-notifier.integration.test.ts @@ -0,0 +1,118 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { asc } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { agents, capCycles, notifications, receipts, users } from "../db/schema"; +import { emitFloatEmpty } from "./notifier"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for float-empty notifier tests"); + +const connection = createDatabase(databaseUrl, 4); +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const arbitrumUsdc = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; +const payTo = "0x1111111111111111111111111111111111111111"; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision() { + const [owner] = await connection.db + .insert(users) + .values({ + email: `${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.db + .insert(agents) + .values({ ownerId: owner.id, name: "Float notifier", signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt: new Date("2026-07-17T00:00:00.000Z") }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected cycle"); + const agentId = agent.id; + const cycleId = cycle.id; + + async function receipt(network: "eip155:8453" | "eip155:42161", asset: string) { + const [row] = await connection.db + .insert(receipts) + .values({ + agentId, + amountAtomic: "600000", + amountUsd: "0.600000", + asset, + authorizationNonce: `0x${randomBytes(32).toString("hex")}`, + authorizationValidBefore: new Date("2030-01-01T00:00:00.000Z"), + cycleId, + network, + payTo, + requestFingerprint: randomBytes(32).toString("hex"), + }) + .returning({ id: receipts.id }); + if (!row) throw new Error("Expected receipt"); + return row.id; + } + + return { + agentId, + arbitrumReceiptId: await receipt("eip155:42161", arbitrumUsdc), + baseReceiptId: await receipt("eip155:8453", baseUsdc), + cycleId, + }; +} + +describe("float-empty notifier multi-chain dedupe", () => { + it("emits once per cycle and network under concurrent duplicate delivery", async () => { + const identity = await provision(); + const now = new Date("2026-07-17T00:01:00.000Z"); + const base = { + agentId: identity.agentId, + availableAtomic: "100000", + cycleId: identity.cycleId, + network: "eip155:8453" as const, + now, + receiptId: identity.baseReceiptId, + reservedAtomic: "600000", + }; + const arbitrum = { + ...base, + availableAtomic: "200000", + network: "eip155:42161" as const, + receiptId: identity.arbitrumReceiptId, + reservedAtomic: "700000", + }; + + const [baseFirst, baseReplay, arbitrumFirst] = await Promise.all([ + connection.db.transaction((transaction) => emitFloatEmpty(transaction, base)), + connection.db.transaction((transaction) => emitFloatEmpty(transaction, base)), + connection.db.transaction((transaction) => emitFloatEmpty(transaction, arbitrum)), + ]); + + expect(baseReplay.id).toBe(baseFirst.id); + expect(arbitrumFirst.id).not.toBe(baseFirst.id); + const rows = await connection.db + .select() + .from(notifications) + .orderBy(asc(notifications.eventKey)); + expect(rows.map((row) => row.eventKey)).toEqual([ + `float_empty:${identity.cycleId}:eip155:42161`, + `float_empty:${identity.cycleId}:eip155:8453`, + ]); + expect(rows.map((row) => row.metadata)).toEqual([ + { availableAtomic: "200000", network: "eip155:42161", reservedAtomic: "700000" }, + { availableAtomic: "100000", network: "eip155:8453", reservedAtomic: "600000" }, + ]); + }); +}); diff --git a/apps/web/lib/leash/key-input.ts b/apps/web/lib/leash/key-input.ts new file mode 100644 index 0000000..7ee2aa2 --- /dev/null +++ b/apps/web/lib/leash/key-input.ts @@ -0,0 +1,36 @@ +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export class InvalidLeashKeyInputError extends Error { + constructor() { + super("Choose a valid Leash agent and key."); + this.name = "InvalidLeashKeyInputError"; + } +} + +function exactRecord(value: unknown, expectedKeys: string[]) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new InvalidLeashKeyInputError(); + } + const record = value as Record; + if (Object.keys(record).sort().join("\0") !== expectedKeys.sort().join("\0")) { + throw new InvalidLeashKeyInputError(); + } + return record; +} + +function uuid(value: unknown) { + if (typeof value !== "string" || !UUID.test(value)) { + throw new InvalidLeashKeyInputError(); + } + return value; +} + +export function parseLeashKeyScope(value: unknown) { + const record = exactRecord(value, ["agentId"]); + return { agentId: uuid(record.agentId) }; +} + +export function parseLeashKeyRotation(value: unknown) { + const record = exactRecord(value, ["agentId", "keyId"]); + return { agentId: uuid(record.agentId), keyId: uuid(record.keyId) }; +} diff --git a/apps/web/lib/leash/leash-format.test.ts b/apps/web/lib/leash/leash-format.test.ts new file mode 100644 index 0000000..e813f88 --- /dev/null +++ b/apps/web/lib/leash/leash-format.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + capFillWidth, + formatBasisPoints, + formatCycleFrequency, + formatRemaining, + formatUsdAtomic, + formatUsdCents, +} from "./leash-format"; + +describe("Leash exact display formatting", () => { + it.each([ + ["0", "$0.00"], + ["1230000", "$1.23"], + ["1235000", "$1.24"], + ["1234567890000", "$1,234,567.89"], + ])("formats %s atomic USDC without floating point", (value, expected) => { + expect(formatUsdAtomic(value)).toBe(expected); + }); + + it("formats arbitrarily large integer cents without Number coercion", () => { + expect(formatUsdCents("99999999999999999999")).toBe("$999,999,999,999,999,999.99"); + }); + + it("preserves exact percentages while clamping only the rendered bar", () => { + expect(formatBasisPoints("7667")).toBe("76.67%"); + expect(formatBasisPoints("14000")).toBe("140%"); + expect(capFillWidth("14000")).toBe("100%"); + }); + + it("renders real cycle language and countdowns", () => { + expect(formatCycleFrequency("daily")).toBe("Daily"); + expect(formatCycleFrequency("never")).toBe("No automatic reset"); + expect(formatRemaining(null, new Date("2026-07-17T00:00:00Z"))).toBe("No scheduled reset"); + expect( + formatRemaining(new Date("2026-07-18T02:31:00Z"), new Date("2026-07-17T00:00:00Z")), + ).toBe("in 1d 2h 31m"); + }); + + it.each(["-1", "1.2", "NaN", ""])("rejects invalid integer input %s", (value) => { + expect(() => formatUsdAtomic(value)).toThrow(); + }); +}); diff --git a/apps/web/lib/leash/leash-format.ts b/apps/web/lib/leash/leash-format.ts new file mode 100644 index 0000000..bdb2475 --- /dev/null +++ b/apps/web/lib/leash/leash-format.ts @@ -0,0 +1,53 @@ +function nonnegativeInteger(value: string, field: string) { + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + throw new Error(`${field} must be a nonnegative integer`); + } + return BigInt(value); +} + +function grouped(value: bigint) { + return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +export function formatUsdCents(value: string) { + const cents = nonnegativeInteger(value, "USD cents"); + return `$${grouped(cents / BigInt(100))}.${(cents % BigInt(100)).toString().padStart(2, "0")}`; +} + +export function formatUsdAtomic(value: string) { + const atomic = nonnegativeInteger(value, "USDC atomic units"); + const roundedCents = (atomic + BigInt(5_000)) / BigInt(10_000); + return formatUsdCents(roundedCents.toString()); +} + +function percentParts(basisPoints: bigint) { + const whole = basisPoints / BigInt(100); + const fraction = (basisPoints % BigInt(100)).toString().padStart(2, "0").replace(/0+$/, ""); + return fraction ? `${whole}.${fraction}%` : `${whole}%`; +} + +export function formatBasisPoints(value: string) { + return percentParts(nonnegativeInteger(value, "basis points")); +} + +export function capFillWidth(value: string) { + const basisPoints = nonnegativeInteger(value, "basis points"); + return percentParts(basisPoints > BigInt(10_000) ? BigInt(10_000) : basisPoints); +} + +export function formatCycleFrequency(frequency: "daily" | "weekly" | "monthly" | "never") { + if (frequency === "never") return "No automatic reset"; + return `${frequency.charAt(0).toUpperCase()}${frequency.slice(1)}`; +} + +export function formatRemaining(nextResetAt: Date | null, now = new Date()) { + if (!nextResetAt) return "No scheduled reset"; + const milliseconds = nextResetAt.getTime() - now.getTime(); + if (milliseconds <= 0) return "Reset due"; + const totalMinutes = Math.ceil(milliseconds / 60_000); + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + const parts = [days ? `${days}d` : "", hours ? `${hours}h` : "", `${minutes}m`].filter(Boolean); + return `in ${parts.join(" ")}`; +} diff --git a/apps/web/lib/leash/leash-http.ts b/apps/web/lib/leash/leash-http.ts new file mode 100644 index 0000000..4792cd8 --- /dev/null +++ b/apps/web/lib/leash/leash-http.ts @@ -0,0 +1,35 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { authenticateOwnerRequest } from "../auth/owner-request"; +import { requestOriginIsAllowed } from "../auth/request-origin"; +import { LeashAgentNotFoundError, LeashCapNotFoundError } from "./cap-policy"; + +export const LEASH_RESPONSE_HEADERS = { "cache-control": "no-store" }; + +export function leashError(code: string, message: string, status: number) { + return NextResponse.json( + { error: { code, message } }, + { headers: LEASH_RESPONSE_HEADERS, status }, + ); +} + +export function requireLeashMutationOrigin(request: NextRequest) { + return requestOriginIsAllowed(request) + ? undefined + : leashError("ORIGIN_NOT_ALLOWED", "Request origin is not allowed.", 403); +} + +export async function requireOwnerRequest(request: NextRequest) { + const owner = await authenticateOwnerRequest(request); + return owner ?? leashError("SESSION_REQUIRED", "A valid owner session is required.", 401); +} + +export function capPolicyError(error: unknown) { + if (error instanceof LeashAgentNotFoundError) { + return leashError("LEASH_AGENT_NOT_FOUND", "The Leash agent was not found.", 404); + } + if (error instanceof LeashCapNotFoundError) { + return leashError("LEASH_CAP_NOT_SET", "Set a cap before resetting its cycle.", 409); + } + throw error; +} diff --git a/apps/web/lib/leash/leash-key-view.ts b/apps/web/lib/leash/leash-key-view.ts new file mode 100644 index 0000000..eb75481 --- /dev/null +++ b/apps/web/lib/leash/leash-key-view.ts @@ -0,0 +1,18 @@ +import type { readOwnerLeashKey } from "../auth/leash-key"; + +type KeySummary = NonNullable>>; + +export type LeashKeyView = Omit & { + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; +}; + +export function leashKeyView(key: KeySummary): LeashKeyView { + return { + ...key, + createdAt: key.createdAt.toISOString(), + lastUsedAt: key.lastUsedAt?.toISOString() ?? null, + revokedAt: key.revokedAt?.toISOString() ?? null, + }; +} diff --git a/apps/web/lib/leash/notification-input.test.ts b/apps/web/lib/leash/notification-input.test.ts new file mode 100644 index 0000000..21e2c71 --- /dev/null +++ b/apps/web/lib/leash/notification-input.test.ts @@ -0,0 +1,95 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + InvalidNotificationInputError, + parseNotificationMutation, + parseNotificationQuery, +} from "./notification-input"; + +describe("notification request parsing", () => { + it("parses strict list filters with explicit defaults", () => { + const agentId = randomUUID(); + const cursorId = randomUUID(); + const cursorTime = "2026-07-17T10:00:00.000Z"; + const cursor = Buffer.from(JSON.stringify({ createdAt: cursorTime, id: cursorId })).toString( + "base64url", + ); + + expect( + parseNotificationQuery( + new URLSearchParams({ + agentId, + cursor, + limit: "25", + read: "unread", + resolution: "active", + tier: "3", + type: "cap_blocked", + }), + ), + ).toEqual({ + agentId, + cursor: { createdAt: new Date(cursorTime), id: cursorId }, + limit: 25, + read: "unread", + resolution: "active", + tier: "3", + type: "cap_blocked", + }); + expect(parseNotificationQuery(new URLSearchParams({ agentId }))).toEqual({ + agentId, + cursor: undefined, + limit: 50, + read: "all", + resolution: "all", + tier: undefined, + type: undefined, + }); + }); + + it.each([ + new URLSearchParams(), + new URLSearchParams({ agentId: "not-a-uuid" }), + new URLSearchParams({ agentId: randomUUID(), tier: "1" }), + new URLSearchParams({ agentId: randomUUID(), read: "yes" }), + new URLSearchParams({ agentId: randomUUID(), resolution: "pending" }), + new URLSearchParams({ agentId: randomUUID(), type: "settled" }), + new URLSearchParams({ agentId: randomUUID(), limit: "0" }), + new URLSearchParams({ agentId: randomUUID(), limit: "101" }), + new URLSearchParams({ agentId: randomUUID(), limit: "1.5" }), + new URLSearchParams({ agentId: randomUUID(), cursor: "not-a-cursor" }), + new URLSearchParams({ agentId: randomUUID(), cursor: "a".repeat(257) }), + new URLSearchParams({ agentId: randomUUID(), surprise: "true" }), + new URLSearchParams(`agentId=${randomUUID()}&tier=2&tier=3`), + ])("rejects malformed or ambiguous list input", (query) => { + expect(() => parseNotificationQuery(query)).toThrow(InvalidNotificationInputError); + }); + + it("accepts only exact mark-one and read-all mutation shapes", () => { + const agentId = randomUUID(); + const notificationId = randomUUID(); + + expect(parseNotificationMutation({ action: "read_one", agentId, notificationId })).toEqual({ + action: "read_one", + agentId, + notificationId, + }); + expect(parseNotificationMutation({ action: "read_all", agentId })).toEqual({ + action: "read_all", + agentId, + }); + }); + + it.each([ + undefined, + { action: "read_one", agentId: randomUUID() }, + { action: "read_one", agentId: randomUUID(), notificationId: "bad" }, + { action: "read_all", agentId: randomUUID(), notificationId: randomUUID() }, + { action: "delete", agentId: randomUUID() }, + { action: "read_all", agentId: randomUUID(), extra: true }, + ])("rejects malformed mutation input", (input) => { + expect(() => parseNotificationMutation(input)).toThrow(InvalidNotificationInputError); + }); +}); diff --git a/apps/web/lib/leash/notification-input.ts b/apps/web/lib/leash/notification-input.ts new file mode 100644 index 0000000..8886999 --- /dev/null +++ b/apps/web/lib/leash/notification-input.ts @@ -0,0 +1,141 @@ +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const TIERS = new Set(["2", "3"]); +const READ_STATES = new Set(["all", "read", "unread"]); +const RESOLUTION_STATES = new Set(["active", "all", "resolved"]); +const NOTIFICATION_TYPES = new Set([ + "cap_75", + "cap_blocked", + "unusual_domain", + "cap_lowered_halt", + "float_low", + "float_empty", +]); +const QUERY_KEYS = new Set(["agentId", "cursor", "limit", "tier", "read", "resolution", "type"]); +const BASE64URL = /^[A-Za-z0-9_-]+$/; + +export type NotificationTier = "2" | "3"; +export type NotificationReadState = "all" | "read" | "unread"; +export type NotificationResolutionState = "active" | "all" | "resolved"; +export type NotificationType = + | "cap_75" + | "cap_blocked" + | "unusual_domain" + | "cap_lowered_halt" + | "float_low" + | "float_empty"; + +export class InvalidNotificationInputError extends Error { + constructor() { + super("Choose valid notification filters or a valid read action."); + this.name = "InvalidNotificationInputError"; + } +} + +function validUuid(value: unknown): value is string { + return typeof value === "string" && UUID.test(value); +} + +export interface NotificationCursor { + createdAt: Date; + id: string; +} + +function parseCursor(value: string | null): NotificationCursor | undefined { + if (value === null) return undefined; + if (value.length > 256 || !BASE64URL.test(value)) throw new InvalidNotificationInputError(); + + try { + const decoded: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) { + throw new InvalidNotificationInputError(); + } + const cursor = decoded as Record; + const createdAt = typeof cursor.createdAt === "string" ? new Date(cursor.createdAt) : null; + if ( + Object.keys(cursor).sort().join("\0") !== "createdAt\0id" || + !createdAt || + Number.isNaN(createdAt.getTime()) || + createdAt.toISOString() !== cursor.createdAt || + !validUuid(cursor.id) + ) { + throw new InvalidNotificationInputError(); + } + return { createdAt, id: cursor.id }; + } catch (error) { + if (error instanceof InvalidNotificationInputError) throw error; + throw new InvalidNotificationInputError(); + } +} + +export function encodeNotificationCursor(cursor: NotificationCursor) { + return Buffer.from( + JSON.stringify({ createdAt: cursor.createdAt.toISOString(), id: cursor.id }), + ).toString("base64url"); +} + +export function parseNotificationQuery(params: URLSearchParams) { + const keys = [...params.keys()]; + if ( + keys.some((key) => !QUERY_KEYS.has(key)) || + [...new Set(keys)].some((key) => params.getAll(key).length !== 1) + ) { + throw new InvalidNotificationInputError(); + } + + const agentId = params.get("agentId"); + const limitValue = params.get("limit"); + const limit = limitValue === null ? 50 : Number(limitValue); + const tier = params.get("tier"); + const read = params.get("read") ?? "all"; + const resolution = params.get("resolution") ?? "all"; + const type = params.get("type"); + if ( + !validUuid(agentId) || + (limitValue !== null && !/^[1-9]\d{0,2}$/.test(limitValue)) || + limit > 100 || + (tier !== null && !TIERS.has(tier)) || + !READ_STATES.has(read) || + !RESOLUTION_STATES.has(resolution) || + (type !== null && !NOTIFICATION_TYPES.has(type)) + ) { + throw new InvalidNotificationInputError(); + } + + return { + agentId, + cursor: parseCursor(params.get("cursor")), + limit, + read: read as NotificationReadState, + resolution: resolution as NotificationResolutionState, + tier: (tier ?? undefined) as NotificationTier | undefined, + type: (type ?? undefined) as NotificationType | undefined, + }; +} + +function exactKeys(record: Record, expected: string[]) { + return Object.keys(record).sort().join("\0") === expected.sort().join("\0"); +} + +export function parseNotificationMutation(value: unknown) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new InvalidNotificationInputError(); + } + const input = value as Record; + if (!validUuid(input.agentId)) throw new InvalidNotificationInputError(); + + if (input.action === "read_all" && exactKeys(input, ["action", "agentId"])) { + return { action: "read_all" as const, agentId: input.agentId }; + } + if ( + input.action === "read_one" && + validUuid(input.notificationId) && + exactKeys(input, ["action", "agentId", "notificationId"]) + ) { + return { + action: "read_one" as const, + agentId: input.agentId, + notificationId: input.notificationId, + }; + } + throw new InvalidNotificationInputError(); +} diff --git a/apps/web/lib/leash/notification-owner-count.integration.test.ts b/apps/web/lib/leash/notification-owner-count.integration.test.ts new file mode 100644 index 0000000..5a010fd --- /dev/null +++ b/apps/web/lib/leash/notification-owner-count.integration.test.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; + +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { agents, capCycles, notifications, users } from "../db/schema"; +import { countOwnerUnreadNotifications } from "./notification-store"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for owner notification count tests"); + +const connection = createDatabase(databaseUrl, 2); + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(label: string) { + const [owner] = await connection.db + .insert(users) + .values({ + email: `${label}-${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.db + .insert(agents) + .values({ name: `${label} agent`, ownerId: owner.id, signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt: new Date("2026-07-17T00:00:00Z") }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected cycle"); + return { agentId: agent.id, cycleId: cycle.id, ownerId: owner.id }; +} + +describe("owner-wide unread notification badge", () => { + it("counts only unread T2/T3 rows belonging to the exact owner", async () => { + const first = await provision("first"); + const second = await provision("second"); + const createdAt = new Date("2026-07-17T00:00:00Z"); + await connection.db.insert(notifications).values([ + { + agentId: first.agentId, + createdAt, + cycleId: first.cycleId, + eventKey: `cap_75:${first.cycleId}`, + tier: "2", + type: "cap_75", + }, + { + agentId: first.agentId, + createdAt, + cycleId: first.cycleId, + eventKey: `float_empty:${first.cycleId}:eip155:8453`, + tier: "2", + type: "float_empty", + }, + { + agentId: first.agentId, + createdAt, + cycleId: first.cycleId, + eventKey: `float_low:${first.cycleId}:eip155:8453`, + readAt: new Date("2026-07-17T01:00:00Z"), + tier: "2", + type: "float_low", + }, + { + agentId: second.agentId, + createdAt, + cycleId: second.cycleId, + eventKey: `cap_75:${second.cycleId}`, + tier: "2", + type: "cap_75", + }, + ]); + + await expect( + countOwnerUnreadNotifications(connection.db, { ownerId: first.ownerId }), + ).resolves.toBe(2); + await expect( + countOwnerUnreadNotifications(connection.db, { ownerId: second.ownerId }), + ).resolves.toBe(1); + }); +}); diff --git a/apps/web/lib/leash/notification-store.ts b/apps/web/lib/leash/notification-store.ts new file mode 100644 index 0000000..21d0851 --- /dev/null +++ b/apps/web/lib/leash/notification-store.ts @@ -0,0 +1,205 @@ +import { + and, + count, + desc, + eq, + inArray, + isNotNull, + isNull, + lt, + or, + type SQL, + sql, +} from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { agents, notifications } from "../db/schema"; +import { encodeNotificationCursor, type parseNotificationQuery } from "./notification-input"; + +type NotificationFilters = ReturnType; +type Transaction = Parameters[0]>[0]; + +export class LeashNotificationNotFoundError extends Error { + constructor() { + super("The Leash notification resource was not found."); + this.name = "LeashNotificationNotFoundError"; + } +} + +async function requireOwnedAgent( + database: Database | Transaction, + ownerId: string, + agentId: string, +) { + const [owned] = await database + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.ownerId, ownerId))) + .limit(1); + if (!owned) throw new LeashNotificationNotFoundError(); +} + +async function unreadCount(database: Database | Transaction, agentId: string) { + const [result] = await database + .select({ value: count() }) + .from(notifications) + .where( + and( + eq(notifications.agentId, agentId), + isNull(notifications.readAt), + inArray(notifications.tier, ["2", "3"]), + ), + ); + return result?.value ?? 0; +} + +export async function countOwnerUnreadNotifications( + database: Database, + options: { ownerId: string }, +) { + const [result] = await database + .select({ value: count() }) + .from(notifications) + .innerJoin(agents, eq(agents.id, notifications.agentId)) + .where( + and( + eq(agents.ownerId, options.ownerId), + isNull(notifications.readAt), + inArray(notifications.tier, ["2", "3"]), + ), + ); + return result?.value ?? 0; +} + +function cta(agentId: string, tier: "2" | "3", resolvedAt: Date | null) { + if (tier !== "3" || resolvedAt) return null; + return { + href: `/leash/cap?agentId=${encodeURIComponent(agentId)}#cap-controls`, + kind: "cap_remediation" as const, + label: "Review cap", + }; +} + +export async function listOwnerNotifications( + database: Database, + options: NotificationFilters & { ownerId: string }, +) { + await requireOwnedAgent(database, options.ownerId, options.agentId); + + const conditions: SQL[] = [eq(notifications.agentId, options.agentId)]; + if (options.tier) conditions.push(eq(notifications.tier, options.tier)); + if (options.type) conditions.push(eq(notifications.type, options.type)); + if (options.read === "read") conditions.push(isNotNull(notifications.readAt)); + if (options.read === "unread") conditions.push(isNull(notifications.readAt)); + if (options.resolution === "active") conditions.push(isNull(notifications.resolvedAt)); + if (options.resolution === "resolved") conditions.push(isNotNull(notifications.resolvedAt)); + if (options.cursor) { + conditions.push( + or( + lt(notifications.createdAt, options.cursor.createdAt), + and( + eq(notifications.createdAt, options.cursor.createdAt), + lt(notifications.id, options.cursor.id), + ), + ) as SQL, + ); + } + + const [rows, badgeCount] = await Promise.all([ + database + .select({ + agentId: notifications.agentId, + createdAt: notifications.createdAt, + cycleId: notifications.cycleId, + id: notifications.id, + metadata: notifications.metadata, + readAt: notifications.readAt, + receiptId: notifications.receiptId, + resolvedAt: notifications.resolvedAt, + resourceHost: notifications.resourceHost, + sticky: notifications.sticky, + tier: notifications.tier, + type: notifications.type, + }) + .from(notifications) + .where(and(...conditions)) + .orderBy(desc(notifications.createdAt), desc(notifications.id)) + .limit(options.limit + 1), + unreadCount(database, options.agentId), + ]); + + const page = rows.slice(0, options.limit); + const last = page.at(-1); + const nextCursor = + rows.length > options.limit && last + ? encodeNotificationCursor({ createdAt: last.createdAt, id: last.id }) + : null; + + return { + nextCursor, + notifications: page.map((row) => ({ + ...row, + cta: cta(row.agentId, row.tier, row.resolvedAt), + })), + unreadCount: badgeCount, + }; +} + +export async function markOwnerNotificationRead( + database: Database, + options: { agentId: string; notificationId: string; now: Date; ownerId: string }, +) { + return database.transaction(async (transaction) => { + await requireOwnedAgent(transaction, options.ownerId, options.agentId); + const updated = await transaction + .update(notifications) + .set({ + readAt: sql`greatest(${options.now.toISOString()}::timestamptz, ${notifications.createdAt})`, + }) + .where( + and( + eq(notifications.id, options.notificationId), + eq(notifications.agentId, options.agentId), + isNull(notifications.readAt), + ), + ) + .returning({ id: notifications.id }); + if (updated.length === 0) { + const [existing] = await transaction + .select({ id: notifications.id }) + .from(notifications) + .where( + and( + eq(notifications.id, options.notificationId), + eq(notifications.agentId, options.agentId), + ), + ) + .limit(1); + if (!existing) throw new LeashNotificationNotFoundError(); + } + return { + unreadCount: await unreadCount(transaction, options.agentId), + updatedCount: updated.length, + }; + }); +} + +export async function markAllOwnerNotificationsRead( + database: Database, + options: { agentId: string; now: Date; ownerId: string }, +) { + return database.transaction(async (transaction) => { + await requireOwnedAgent(transaction, options.ownerId, options.agentId); + const updated = await transaction + .update(notifications) + .set({ + readAt: sql`greatest(${options.now.toISOString()}::timestamptz, ${notifications.createdAt})`, + }) + .where(and(eq(notifications.agentId, options.agentId), isNull(notifications.readAt))) + .returning({ id: notifications.id }); + return { + unreadCount: await unreadCount(transaction, options.agentId), + updatedCount: updated.length, + }; + }); +} diff --git a/apps/web/lib/leash/notification-view.ts b/apps/web/lib/leash/notification-view.ts new file mode 100644 index 0000000..9fddd21 --- /dev/null +++ b/apps/web/lib/leash/notification-view.ts @@ -0,0 +1,29 @@ +import type { listOwnerNotifications } from "./notification-store"; + +type NotificationResult = Awaited>; +type NotificationItem = NotificationResult["notifications"][number]; + +export type NotificationItemView = Omit & { + createdAt: string; + readAt: string | null; + resolvedAt: string | null; +}; + +export type NotificationResultView = { + nextCursor: string | null; + notifications: NotificationItemView[]; + unreadCount: number; +}; + +export function notificationResultView(result: NotificationResult): NotificationResultView { + return { + nextCursor: result.nextCursor, + notifications: result.notifications.map((item) => ({ + ...item, + createdAt: item.createdAt.toISOString(), + readAt: item.readAt?.toISOString() ?? null, + resolvedAt: item.resolvedAt?.toISOString() ?? null, + })), + unreadCount: result.unreadCount, + }; +} diff --git a/apps/web/lib/leash/notifier.integration.test.ts b/apps/web/lib/leash/notifier.integration.test.ts new file mode 100644 index 0000000..4fd8e29 --- /dev/null +++ b/apps/web/lib/leash/notifier.integration.test.ts @@ -0,0 +1,294 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { eq } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { agents, capCycles, notifications, receipts, users } from "../db/schema"; +import { + emitCap75, + emitCapBlocked, + emitCapLoweredHalt, + emitUnusualDomain, + resolveActiveCapHalt, +} from "./notifier"; +import { canonicalResourceIdentity } from "./resource-identity"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for notifier integration tests"); + +const connection = createDatabase(databaseUrl, 8); +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const payTo = "0x1111111111111111111111111111111111111111"; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(label: string) { + const [owner] = await connection.db + .insert(users) + .values({ + email: `${label}-${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + }) + .returning({ id: users.id }); + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.db + .insert(agents) + .values({ ownerId: owner.id, name: `${label} agent`, signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!agent) throw new Error("Expected agent"); + const [cycle] = await connection.db + .insert(capCycles) + .values({ agentId: agent.id, startedAt: new Date("2026-07-17T00:00:00.000Z") }) + .returning({ id: capCycles.id }); + if (!cycle) throw new Error("Expected cycle"); + return { agentId: agent.id, cycleId: cycle.id }; +} + +async function insertReceipt( + identity: Awaited>, + options: { blocked?: boolean; resourceHost?: string } = {}, +) { + const [row] = await connection.db + .insert(receipts) + .values({ + agentId: identity.agentId, + amountAtomic: "100000", + amountUsd: "0.100000", + asset: baseUsdc, + authorizationNonce: `0x${randomBytes(32).toString("hex")}`, + authorizationValidBefore: new Date("2030-01-01T00:00:00.000Z"), + cycleId: identity.cycleId, + intendedNetwork: options.blocked ? "eip155:8453" : null, + network: "eip155:8453", + payTo, + reason: options.blocked ? "LEASH_CAP_EXCEEDED" : null, + requestFingerprint: randomBytes(32).toString("hex"), + resourceHost: options.resourceHost, + resourceUrl: options.resourceHost ? `https://${options.resourceHost}/paid` : undefined, + status: options.blocked ? "blocked" : "pending", + }) + .returning({ id: receipts.id }); + if (!row) throw new Error("Expected receipt"); + return row.id; +} + +describe("transactional Leash notifier with real PostgreSQL", () => { + it("emits cap_75 once per cycle under concurrent transactions", async () => { + const identity = await provision("cap-75"); + const now = new Date("2026-07-17T00:01:00.000Z"); + const options = { + ...identity, + capAtomic: "1000000", + committedAtomic: "750000", + now, + }; + + const [first, replay] = await Promise.all([ + connection.db.transaction((tx) => emitCap75(tx, options)), + connection.db.transaction((tx) => emitCap75(tx, options)), + ]); + + expect(replay.id).toBe(first.id); + const rows = await connection.db + .select() + .from(notifications) + .where(eq(notifications.agentId, identity.agentId)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + createdAt: now, + eventKey: `cap_75:${identity.cycleId}`, + metadata: { capAtomic: "1000000", committedAtomic: "750000", thresholdPercent: 75 }, + sticky: false, + tier: "2", + type: "cap_75", + }); + }); + + it("emits an unusual domain once per agent and links its receipt", async () => { + const identity = await provision("domain"); + const resourceHost = "api.vendor.test"; + const resourceUrl = `https://${resourceHost}/paid`; + const { resourceKey } = canonicalResourceIdentity(resourceUrl, resourceHost); + const receiptId = await insertReceipt(identity, { resourceHost }); + const options = { + ...identity, + now: new Date("2026-07-17T00:02:00.000Z"), + receiptId, + resourceHost, + resourceKey, + resourceUrl, + }; + + const [first, replay] = await Promise.all([ + connection.db.transaction((tx) => emitUnusualDomain(tx, options)), + connection.db.transaction((tx) => emitUnusualDomain(tx, options)), + ]); + + expect(replay.id).toBe(first.id); + expect(first).toMatchObject({ + eventKey: `unusual_domain:${resourceKey}`, + metadata: { resourceHost, resourceUrl }, + receiptId, + resourceHost, + resourceKey, + sticky: false, + tier: "2", + type: "unusual_domain", + }); + expect(await connection.db.select().from(notifications)).toHaveLength(1); + }); + + it("emits one unresolved cap-blocked T3 episode linked to the blocked receipt", async () => { + const identity = await provision("blocked"); + const receiptId = await insertReceipt(identity, { blocked: true }); + const options = { + ...identity, + attemptedAtomic: "1", + capAtomic: "1000000", + committedAtomic: "1000000", + now: new Date("2026-07-17T00:03:00.000Z"), + receiptId, + }; + + const [first, replay] = await Promise.all([ + connection.db.transaction((tx) => emitCapBlocked(tx, options)), + connection.db.transaction((tx) => emitCapBlocked(tx, options)), + ]); + + expect(replay.id).toBe(first.id); + expect(first).toMatchObject({ + eventKey: `cap_blocked:${receiptId}`, + metadata: { + attemptedAtomic: "1", + capAtomic: "1000000", + committedAtomic: "1000000", + }, + receiptId, + resolvedAt: null, + sticky: true, + tier: "3", + type: "cap_blocked", + }); + expect(await connection.db.select().from(notifications)).toHaveLength(1); + }); + + it("shares one active cap-halt episode and resolves it only at the remedial timestamp", async () => { + const identity = await provision("lowered"); + const receiptId = await insertReceipt(identity, { blocked: true }); + const loweredAt = new Date("2026-07-17T00:04:00.000Z"); + const lowered = await connection.db.transaction((tx) => + emitCapLoweredHalt(tx, { + ...identity, + capAtomic: "800000", + committedAtomic: "900000", + now: loweredAt, + }), + ); + const blockedReplay = await connection.db.transaction((tx) => + emitCapBlocked(tx, { + ...identity, + attemptedAtomic: "1", + capAtomic: "800000", + committedAtomic: "900000", + now: new Date("2026-07-17T00:05:00.000Z"), + receiptId, + }), + ); + expect(blockedReplay.id).toBe(lowered.id); + expect(lowered).toMatchObject({ + eventKey: `cap_lowered_halt:${identity.cycleId}:${loweredAt.toISOString()}`, + metadata: { capAtomic: "800000", committedAtomic: "900000" }, + sticky: true, + tier: "3", + type: "cap_lowered_halt", + }); + + const resolvedAt = new Date("2026-07-17T00:06:00.000Z"); + const resolved = await connection.db.transaction((tx) => + resolveActiveCapHalt(tx, { agentId: identity.agentId, now: resolvedAt }), + ); + expect(resolved?.resolvedAt).toEqual(resolvedAt); + await expect( + connection.db.transaction((tx) => + resolveActiveCapHalt(tx, { agentId: identity.agentId, now: resolvedAt }), + ), + ).resolves.toBeUndefined(); + + const nextAt = new Date("2026-07-17T00:07:00.000Z"); + const [next, competing] = await Promise.all([ + connection.db.transaction((tx) => + emitCapBlocked(tx, { + ...identity, + attemptedAtomic: "1", + capAtomic: "800000", + committedAtomic: "900000", + now: nextAt, + receiptId, + }), + ), + connection.db.transaction((tx) => + emitCapLoweredHalt(tx, { + ...identity, + capAtomic: "800000", + committedAtomic: "900000", + now: nextAt, + }), + ), + ]); + expect(next.id).not.toBe(lowered.id); + expect(competing.id).toBe(next.id); + const episodes = await connection.db + .select({ resolvedAt: notifications.resolvedAt }) + .from(notifications) + .where(eq(notifications.agentId, identity.agentId)); + expect(episodes.filter((episode) => episode.resolvedAt === null)).toHaveLength(1); + }); + + it("propagates unique failures outside the expected idempotency constraint", async () => { + const identity = await provision("unexpected"); + await connection.db.insert(notifications).values({ + ...identity, + eventKey: `cap_75:${identity.cycleId}`, + metadata: {}, + sticky: false, + tier: "2", + type: "float_low", + }); + + await expect( + connection.db.transaction((tx) => + emitCap75(tx, { + ...identity, + capAtomic: "1000000", + committedAtomic: "750000", + now: new Date("2026-07-17T00:08:00.000Z"), + }), + ), + ).rejects.toMatchObject({ + cause: { code: "23505", constraint_name: "notifications_agent_event_key_unique" }, + }); + }); + + it("participates in the caller transaction rollback", async () => { + const identity = await provision("rollback"); + await expect( + connection.db.transaction(async (tx) => { + await emitCap75(tx, { + ...identity, + capAtomic: "1000000", + committedAtomic: "750000", + now: new Date("2026-07-17T00:09:00.000Z"), + }); + throw new Error("roll back business transaction"); + }), + ).rejects.toThrow("roll back business transaction"); + expect(await connection.db.select().from(notifications)).toEqual([]); + }); +}); diff --git a/apps/web/lib/leash/notifier.ts b/apps/web/lib/leash/notifier.ts new file mode 100644 index 0000000..aa918f6 --- /dev/null +++ b/apps/web/lib/leash/notifier.ts @@ -0,0 +1,291 @@ +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { notifications } from "../db/schema"; + +type Transaction = Parameters[0]>[0]; +type Notification = typeof notifications.$inferSelect; +type NotificationInsert = typeof notifications.$inferInsert; + +interface CapNotificationOptions { + agentId: string; + capAtomic: string; + committedAtomic: string; + cycleId: string; + now: Date; +} + +interface ReceiptNotificationOptions extends CapNotificationOptions { + attemptedAtomic: string; + receiptId: string; +} + +interface FloatEmptyNotificationOptions { + agentId: string; + availableAtomic: string; + cycleId: string; + network: "eip155:8453" | "eip155:42161"; + now: Date; + receiptId: string; + reservedAtomic: string; +} + +function constraintName(error: unknown): string | undefined { + if (!(error instanceof Error)) return undefined; + const candidate = error as Error & { cause?: unknown; constraint_name?: unknown }; + if (typeof candidate.constraint_name === "string") return candidate.constraint_name; + return constraintName(candidate.cause); +} + +async function insertOrFind( + transaction: Transaction, + event: string, + insert: (savepoint: Transaction) => Promise, + find: () => Promise, +) { + let eventKeyConflict: unknown; + try { + const inserted = await transaction.transaction(insert); + if (inserted) return inserted; + } catch (error) { + if (constraintName(error) !== "notifications_agent_event_key_unique") throw error; + eventKeyConflict = error; + } + const existing = await find(); + if (existing) return existing; + if (eventKeyConflict) throw eventKeyConflict; + throw new Error(`Expected an existing ${event} notification`); +} + +async function findActiveCapHalt(transaction: Transaction, agentId: string) { + const [notification] = await transaction + .select() + .from(notifications) + .where( + and( + eq(notifications.agentId, agentId), + isNull(notifications.resolvedAt), + inArray(notifications.type, ["cap_blocked", "cap_lowered_halt"]), + ), + ) + .limit(1); + return notification; +} + +async function emitCapHalt(transaction: Transaction, values: NotificationInsert) { + return insertOrFind( + transaction, + "cap-halt", + async (savepoint) => { + const [inserted] = await savepoint + .insert(notifications) + .values(values) + .onConflictDoNothing({ + target: notifications.agentId, + where: sql`${notifications.resolvedAt} is null + and ${notifications.type} in ('cap_blocked', 'cap_lowered_halt')`, + }) + .returning(); + return inserted; + }, + () => findActiveCapHalt(transaction, values.agentId), + ); +} + +export async function emitCap75(transaction: Transaction, options: CapNotificationOptions) { + return insertOrFind( + transaction, + "cap_75", + async (savepoint) => { + const [inserted] = await savepoint + .insert(notifications) + .values({ + agentId: options.agentId, + createdAt: options.now, + cycleId: options.cycleId, + eventKey: `cap_75:${options.cycleId}`, + metadata: { + capAtomic: options.capAtomic, + committedAtomic: options.committedAtomic, + thresholdPercent: 75, + }, + sticky: false, + tier: "2", + type: "cap_75", + }) + .onConflictDoNothing({ + target: [notifications.agentId, notifications.cycleId], + where: sql`${notifications.type} = 'cap_75'`, + }) + .returning(); + return inserted; + }, + async () => { + const [existing] = await transaction + .select() + .from(notifications) + .where( + and( + eq(notifications.agentId, options.agentId), + eq(notifications.cycleId, options.cycleId), + eq(notifications.type, "cap_75"), + ), + ) + .limit(1); + return existing; + }, + ); +} + +export async function emitUnusualDomain( + transaction: Transaction, + options: { + agentId: string; + cycleId: string; + now: Date; + receiptId: string; + resourceHost: string; + resourceKey: string; + resourceUrl: string; + }, +) { + return insertOrFind( + transaction, + "unusual-domain", + async (savepoint) => { + const [inserted] = await savepoint + .insert(notifications) + .values({ + agentId: options.agentId, + createdAt: options.now, + cycleId: options.cycleId, + eventKey: `unusual_domain:${options.resourceKey}`, + metadata: { + resourceHost: options.resourceHost, + resourceUrl: options.resourceUrl, + }, + receiptId: options.receiptId, + resourceHost: options.resourceHost, + resourceKey: options.resourceKey, + sticky: false, + tier: "2", + type: "unusual_domain", + }) + .onConflictDoNothing({ + target: [notifications.agentId, notifications.resourceKey], + where: sql`${notifications.type} = 'unusual_domain'`, + }) + .returning(); + return inserted; + }, + async () => { + const [existing] = await transaction + .select() + .from(notifications) + .where( + and( + eq(notifications.agentId, options.agentId), + eq(notifications.resourceKey, options.resourceKey), + eq(notifications.type, "unusual_domain"), + ), + ) + .limit(1); + return existing; + }, + ); +} + +export async function emitFloatEmpty( + transaction: Transaction, + options: FloatEmptyNotificationOptions, +) { + const eventKey = `float_empty:${options.cycleId}:${options.network}`; + return insertOrFind( + transaction, + "float-empty", + async (savepoint) => { + const [inserted] = await savepoint + .insert(notifications) + .values({ + agentId: options.agentId, + createdAt: options.now, + cycleId: options.cycleId, + eventKey, + metadata: { + availableAtomic: options.availableAtomic, + network: options.network, + reservedAtomic: options.reservedAtomic, + }, + receiptId: options.receiptId, + sticky: false, + tier: "2", + type: "float_empty", + }) + .returning(); + return inserted; + }, + async () => { + const [existing] = await transaction + .select() + .from(notifications) + .where( + and( + eq(notifications.agentId, options.agentId), + eq(notifications.eventKey, eventKey), + eq(notifications.type, "float_empty"), + ), + ) + .limit(1); + return existing; + }, + ); +} + +export function emitCapBlocked(transaction: Transaction, options: ReceiptNotificationOptions) { + return emitCapHalt(transaction, { + agentId: options.agentId, + createdAt: options.now, + cycleId: options.cycleId, + eventKey: `cap_blocked:${options.receiptId}`, + metadata: { + attemptedAtomic: options.attemptedAtomic, + capAtomic: options.capAtomic, + committedAtomic: options.committedAtomic, + }, + receiptId: options.receiptId, + sticky: true, + tier: "3", + type: "cap_blocked", + }); +} + +export function emitCapLoweredHalt(transaction: Transaction, options: CapNotificationOptions) { + return emitCapHalt(transaction, { + agentId: options.agentId, + createdAt: options.now, + cycleId: options.cycleId, + eventKey: `cap_lowered_halt:${options.cycleId}:${options.now.toISOString()}`, + metadata: { capAtomic: options.capAtomic, committedAtomic: options.committedAtomic }, + sticky: true, + tier: "3", + type: "cap_lowered_halt", + }); +} + +export async function resolveActiveCapHalt( + transaction: Transaction, + options: { agentId: string; now: Date }, +) { + const [resolved] = await transaction + .update(notifications) + .set({ resolvedAt: options.now }) + .where( + and( + eq(notifications.agentId, options.agentId), + isNull(notifications.resolvedAt), + inArray(notifications.type, ["cap_blocked", "cap_lowered_halt"]), + ), + ) + .returning(); + return resolved; +} diff --git a/apps/web/lib/leash/owner-agents.integration.test.ts b/apps/web/lib/leash/owner-agents.integration.test.ts new file mode 100644 index 0000000..c011d1c --- /dev/null +++ b/apps/web/lib/leash/owner-agents.integration.test.ts @@ -0,0 +1,79 @@ +import { randomUUID } from "node:crypto"; + +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { agents, users } from "../db/schema"; +import { LeashAgentSelectionError, readOwnerAgentSelection } from "./owner-agents"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for owner-agent tests"); + +const connection = createDatabase(databaseUrl, 2); + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function owner(label: string) { + const [row] = await connection.db + .insert(users) + .values({ + email: `${label}-${randomUUID()}@example.test`, + magicIssuer: `did:ethr:${randomUUID()}`, + }) + .returning({ id: users.id }); + if (!row) throw new Error("Expected owner"); + return row; +} + +async function agent(ownerId: string, name: string, createdAt: Date) { + const [row] = await connection.db + .insert(agents) + .values({ createdAt, name, ownerId, signerSubject: `leash:${randomUUID()}` }) + .returning({ id: agents.id }); + if (!row) throw new Error("Expected agent"); + return row; +} + +describe("owner agent selection with real PostgreSQL", () => { + it("returns an honest empty selection when an owner has no provisioned agent", async () => { + const first = await owner("empty"); + await expect(readOwnerAgentSelection(connection.db, { ownerId: first.id })).resolves.toEqual({ + agents: [], + selected: null, + }); + }); + + it("selects the newest owned agent by default and an exact requested agent", async () => { + const first = await owner("owned"); + const older = await agent(first.id, "Older worker", new Date("2026-07-15T00:00:00Z")); + const newer = await agent(first.id, "Newer worker", new Date("2026-07-16T00:00:00Z")); + + const defaultSelection = await readOwnerAgentSelection(connection.db, { ownerId: first.id }); + expect(defaultSelection.agents.map((item) => item.id)).toEqual([newer.id, older.id]); + expect(defaultSelection.selected?.id).toBe(newer.id); + + const requested = await readOwnerAgentSelection(connection.db, { + agentId: older.id, + ownerId: first.id, + }); + expect(requested.selected?.id).toBe(older.id); + }); + + it("uses one selection error for malformed, missing, and foreign agent ids", async () => { + const first = await owner("first"); + const second = await owner("second"); + const foreign = await agent(second.id, "Foreign worker", new Date()); + + for (const agentId of ["not-a-uuid", randomUUID(), foreign.id]) { + await expect( + readOwnerAgentSelection(connection.db, { agentId, ownerId: first.id }), + ).rejects.toBeInstanceOf(LeashAgentSelectionError); + } + }); +}); diff --git a/apps/web/lib/leash/owner-agents.ts b/apps/web/lib/leash/owner-agents.ts new file mode 100644 index 0000000..31b1f2e --- /dev/null +++ b/apps/web/lib/leash/owner-agents.ts @@ -0,0 +1,51 @@ +import { desc, eq } from "drizzle-orm"; + +import type { Database } from "../db/client"; +import { agents } from "../db/schema"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export class LeashAgentSelectionError extends Error { + constructor() { + super("The Leash agent was not found."); + this.name = "LeashAgentSelectionError"; + } +} + +const ownerAgent = { + agentAddress: agents.agentAddress, + clientName: agents.clientName, + clientVersion: agents.clientVersion, + connectionCount: agents.connectionCount, + createdAt: agents.createdAt, + firstSeenAt: agents.firstSeenAt, + id: agents.id, + lastSeenAt: agents.lastSeenAt, + name: agents.name, + status: agents.status, + transport: agents.transport, +}; + +export async function readOwnerAgentSelection( + database: Database, + options: { agentId?: string; ownerId: string }, +) { + if (options.agentId && !UUID.test(options.agentId)) { + throw new LeashAgentSelectionError(); + } + + const rows = await database + .select(ownerAgent) + .from(agents) + .where(eq(agents.ownerId, options.ownerId)) + .orderBy(desc(agents.createdAt), desc(agents.id)); + + if (!options.agentId) return { agents: rows, selected: rows[0] ?? null }; + const selected = rows.find((row) => row.id === options.agentId); + if (!selected) throw new LeashAgentSelectionError(); + return { agents: rows, selected }; +} + +export type OwnerAgent = NonNullable< + Awaited>["selected"] +>; diff --git a/apps/web/lib/leash/pay-result-store.integration.test.ts b/apps/web/lib/leash/pay-result-store.integration.test.ts index 598d2e5..93c4732 100644 --- a/apps/web/lib/leash/pay-result-store.integration.test.ts +++ b/apps/web/lib/leash/pay-result-store.integration.test.ts @@ -2,7 +2,8 @@ import { randomBytes, randomUUID } from "node:crypto"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createDatabase } from "../db/client"; +import { createDatabase, type Database } from "../db/client"; +import { notifications } from "../db/schema"; import { applySettlementObservation, SettlementResultConflictError } from "./pay-result-store"; import { parseSettlementObservation } from "./settlement-evidence"; @@ -23,7 +24,7 @@ afterAll(async () => { await connection.client.end(); }); -async function pendingReceipt() { +async function pendingReceipt(options: { amountAtomic?: string; capCents?: string } = {}) { const [owner] = await connection.client<{ id: string }[]>` insert into users (email, magic_issuer) values (${`${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) returning id @@ -39,17 +40,23 @@ async function pendingReceipt() { values (${agent.id}, now() - interval '1 minute') returning id `; if (!cycle) throw new Error("Expected cycle"); + await connection.client` + insert into caps (agent_id, amount_usd_cents, frequency) + values (${agent.id}, ${options.capCents ?? "100"}, 'daily') + `; + const amountAtomic = options.amountAtomic ?? "25000"; const [receipt] = await connection.client<{ id: string }[]>` insert into receipts ( agent_id, cycle_id, amount_atomic, amount_usd, asset, network, pay_to, authorization_nonce, request_fingerprint, authorization_valid_before ) values ( - ${agent.id}, ${cycle.id}, '25000', '0.025000', ${baseUsdc}, 'eip155:8453', + ${agent.id}, ${cycle.id}, ${amountAtomic}, ${amountAtomic}::numeric / 1000000, + ${baseUsdc}, 'eip155:8453', ${payTo}, ${nonce}, ${randomBytes(32).toString("hex")}, now() + interval '5 minutes' ) returning id `; if (!receipt) throw new Error("Expected receipt"); - return { agentId: agent.id, receiptId: receipt.id }; + return { agentId: agent.id, cycleId: cycle.id, receiptId: receipt.id }; } function observation(receiptId: string, txHash = transaction) { @@ -109,6 +116,67 @@ describe("on-chain verified receipt finalization", () => { tx_hash: transaction, }); expect(verify).toHaveBeenCalledOnce(); + expect(await connection.db.select().from(notifications)).toEqual([]); + }); + + it("emits cap_75 once per cycle after concurrent verified finalization", async () => { + const pending = await pendingReceipt({ amountAtomic: "750000" }); + const evidence = observation(pending.receiptId); + + const results = await Promise.all([ + applySettlementObservation(connection.db, { + agentId: pending.agentId, + evidence, + verify: async () => true, + }), + applySettlementObservation(connection.db, { + agentId: pending.agentId, + evidence, + verify: async () => true, + }), + ]); + + expect(results).toEqual([ + { kind: "settled", receiptId: pending.receiptId, verified: true }, + { kind: "settled", receiptId: pending.receiptId, verified: true }, + ]); + const rows = await connection.db.select().from(notifications); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + cycleId: pending.cycleId, + eventKey: `cap_75:${pending.cycleId}`, + metadata: { + capAtomic: "1000000", + committedAtomic: "750000", + thresholdPercent: 75, + }, + sticky: false, + tier: "2", + type: "cap_75", + }); + }); + + it("rolls verified settlement and its threshold notification back together", async () => { + const pending = await pendingReceipt({ amountAtomic: "750000" }); + + await expect( + connection.db.transaction(async (transaction) => { + const result = await applySettlementObservation(transaction as unknown as Database, { + agentId: pending.agentId, + evidence: observation(pending.receiptId), + verify: async () => true, + }); + expect(result.kind).toBe("settled"); + expect(await transaction.select().from(notifications)).toHaveLength(1); + throw new Error("rollback settlement"); + }), + ).rejects.toThrow("rollback settlement"); + + const [stored] = await connection.client<{ status: string }[]>` + select status from receipts where id = ${pending.receiptId} + `; + expect(stored?.status).toBe("pending"); + expect(await connection.db.select().from(notifications)).toEqual([]); }); it("scopes observations to the authenticated agent and rejects conflicting final evidence", async () => { diff --git a/apps/web/lib/leash/pay-result-store.ts b/apps/web/lib/leash/pay-result-store.ts index 68a56b5..c0872a5 100644 --- a/apps/web/lib/leash/pay-result-store.ts +++ b/apps/web/lib/leash/pay-result-store.ts @@ -1,11 +1,13 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import type { Database } from "../db/client"; -import { agents, receipts } from "../db/schema"; +import { agents, capCycles, caps, receipts } from "../db/schema"; +import { emitCap75 } from "./notifier"; import { type parseSettlementObservation, verifySettlementOnchain } from "./settlement-evidence"; type SettlementEvidence = ReturnType; type VerifySettlement = typeof verifySettlementOnchain; +const ATOMIC_UNITS_PER_CENT = BigInt(10_000); export class SettlementResultConflictError extends Error { readonly code = "SETTLEMENT_RESULT_CONFLICT"; @@ -44,6 +46,7 @@ export async function applySettlementObservation( agentAddress: agents.agentAddress, amountAtomic: receipts.amountAtomic, authorizationNonce: receipts.authorizationNonce, + cycleId: receipts.cycleId, id: receipts.id, network: receipts.network, payTo: receipts.payTo, @@ -54,8 +57,7 @@ export async function applySettlementObservation( .innerJoin(agents, eq(agents.id, receipts.agentId)) .where( and(eq(receipts.id, options.evidence.receiptId), eq(receipts.agentId, options.agentId)), - ) - .for("update"); + ); if (!row) return { kind: "not_found" as const }; if (row.status !== "pending") return terminalResult(row, options.evidence); if (!row.agentAddress) { @@ -65,6 +67,7 @@ export async function applySettlementObservation( agentAddress: row.agentAddress, amountAtomic: row.amountAtomic, authorizationNonce: row.authorizationNonce as `0x${string}`, + cycleId: row.cycleId, kind: "candidate" as const, network: row.network, payTo: row.payTo, @@ -86,6 +89,25 @@ export async function applySettlementObservation( } return db.transaction(async (transaction) => { + const [agent] = await transaction + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.id, options.agentId)) + .for("update"); + if (!agent) return { kind: "not_found" as const }; + + const [cap] = await transaction + .select({ amountUsdCents: caps.amountUsdCents }) + .from(caps) + .where(eq(caps.agentId, agent.id)) + .for("update"); + const [cycle] = await transaction + .select({ id: capCycles.id }) + .from(capCycles) + .where(and(eq(capCycles.id, candidate.cycleId), eq(capCycles.agentId, agent.id))) + .for("update"); + if (!cycle) return { kind: "not_found" as const }; + const [current] = await transaction .select({ id: receipts.id, status: receipts.status, txHash: receipts.txHash }) .from(receipts) @@ -94,10 +116,11 @@ export async function applySettlementObservation( if (!current) return { kind: "not_found" as const }; if (current.status !== "pending") return terminalResult(current, options.evidence); + const settledAt = new Date(); const [settled] = await transaction .update(receipts) .set({ - settledAt: new Date(), + settledAt, settlementResponse: { network: options.evidence.network, payer: options.evidence.payer, @@ -111,6 +134,31 @@ export async function applySettlementObservation( .where(and(eq(receipts.id, current.id), eq(receipts.status, "pending"))) .returning({ id: receipts.id }); if (!settled) throw new SettlementResultConflictError(); + if (cap?.amountUsdCents) { + const [usage] = await transaction + .select({ + amountAtomic: sql`coalesce(sum(${receipts.amountAtomic}), 0)::text`, + }) + .from(receipts) + .where( + and( + eq(receipts.agentId, agent.id), + eq(receipts.cycleId, cycle.id), + inArray(receipts.status, ["pending", "settled"]), + ), + ); + const committedAtomic = usage?.amountAtomic ?? "0"; + const capAtomic = (BigInt(cap.amountUsdCents) * ATOMIC_UNITS_PER_CENT).toString(); + if (BigInt(committedAtomic) * BigInt(100) >= BigInt(capAtomic) * BigInt(75)) { + await emitCap75(transaction, { + agentId: agent.id, + capAtomic, + committedAtomic, + cycleId: cycle.id, + now: settledAt, + }); + } + } return { kind: "settled" as const, receiptId: settled.id, verified: true }; }); } diff --git a/apps/web/lib/leash/resource-identity.test.ts b/apps/web/lib/leash/resource-identity.test.ts new file mode 100644 index 0000000..7d67c8d --- /dev/null +++ b/apps/web/lib/leash/resource-identity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { canonicalResourceIdentity } from "./resource-identity"; + +describe("canonical notification resource identity", () => { + it("uses one tagged host identity across HTTP schemes and paths", () => { + const first = canonicalResourceIdentity("https://api.vendor.test/pay", "api.vendor.test"); + const second = canonicalResourceIdentity("http://api.vendor.test/export", "api.vendor.test"); + + expect(first).toEqual({ + resourceIdentityKind: "http_host", + resourceKey: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + expect(second.resourceKey).toBe(first.resourceKey); + }); + + it("uses the canonical MCP URL so tools sharing a host remain distinct", () => { + const search = canonicalResourceIdentity("mcp://tool/search", "tool"); + const exportTool = canonicalResourceIdentity("mcp://tool/export", "tool"); + + expect(search.resourceIdentityKind).toBe("mcp_resource"); + expect(exportTool.resourceKey).not.toBe(search.resourceKey); + }); + + it.each([ + ["credentials", "mcp://user:pass@tool/search", "tool"], + ["query", "mcp://tool/search?secret=value", "tool"], + ["host mismatch", "mcp://tool/search", "other"], + ["unsupported protocol", "ftp://tool/search", "tool"], + ])("rejects a non-canonical %s input", (_label, resourceUrl, resourceHost) => { + expect(() => canonicalResourceIdentity(resourceUrl, resourceHost)).toThrow( + /resource identity/i, + ); + }); +}); diff --git a/apps/web/lib/leash/resource-identity.ts b/apps/web/lib/leash/resource-identity.ts new file mode 100644 index 0000000..0e92096 --- /dev/null +++ b/apps/web/lib/leash/resource-identity.ts @@ -0,0 +1,34 @@ +import { createHash } from "node:crypto"; + +export type ResourceIdentityKind = "http_host" | "mcp_resource"; + +function digest(taggedIdentity: string) { + return createHash("sha256").update(taggedIdentity).digest("hex"); +} + +export function canonicalResourceIdentity(resourceUrl: string, resourceHost: string) { + const url = new URL(resourceUrl); + if ( + url.toString() !== resourceUrl || + url.hostname !== resourceHost || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" + ) { + throw new Error("Resource identity requires a canonical redacted URL"); + } + if (url.protocol === "http:" || url.protocol === "https:") { + return { + resourceIdentityKind: "http_host" as const, + resourceKey: digest(`http-host:${resourceHost}`), + }; + } + if (url.protocol === "mcp:") { + return { + resourceIdentityKind: "mcp_resource" as const, + resourceKey: digest(`mcp-resource:${resourceUrl}`), + }; + } + throw new Error("Unsupported resource identity protocol"); +} diff --git a/apps/web/lib/leash/resource-novelty.integration.test.ts b/apps/web/lib/leash/resource-novelty.integration.test.ts new file mode 100644 index 0000000..3e56b66 --- /dev/null +++ b/apps/web/lib/leash/resource-novelty.integration.test.ts @@ -0,0 +1,220 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { eq } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase, type Database } from "../db/client"; +import { notifications, receipts } from "../db/schema"; +import { reserveSignRequest } from "./sign-store"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for resource novelty tests"); + +const connection = createDatabase(databaseUrl, 4); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const nowSeconds = 1_784_271_300; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision() { + const [owner] = await connection.client<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) returning id + `; + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.client<{ id: string }[]>` + insert into agents (owner_id, name, signer_subject, agent_address) + values (${owner.id}, 'Novelty test', ${`leash:${randomUUID()}`}, ${agentAddress}) returning id + `; + if (!agent) throw new Error("Expected agent"); + const [key] = await connection.client<{ id: string }[]>` + insert into leash_keys (agent_id, hashed_key, prefix, last4) + values (${agent.id}, ${randomBytes(32).toString("hex")}, 'leash_sk_', 'a1B2') returning id + `; + const [cycle] = await connection.client<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${agent.id}, ${new Date((nowSeconds - 60) * 1_000).toISOString()}::timestamptz) + returning id + `; + await connection.client` + insert into caps (agent_id, amount_usd_cents, frequency) values (${agent.id}, '1000', 'daily') + `; + if (!key || !cycle) throw new Error("Expected key and cycle"); + return { agentId: agent.id, cycleId: cycle.id, keyId: key.id }; +} + +function signBody(resourceUrl: string) { + const amount = "25000"; + return { + amount, + asset: baseUsdc, + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo, + resourceUrl, + signerRequest: { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: baseUsdc, + version: "2", + }, + message: { + from: agentAddress, + nonce: `0x${randomBytes(32).toString("hex")}`, + to: payTo, + validAfter: "0", + validBefore: String(nowSeconds + 300), + value: amount, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +async function unusualDomains() { + return connection.db.select().from(notifications).where(eq(notifications.type, "unusual_domain")); +} + +async function insertHistoricalMcp( + identity: Awaited>, + resourceUrl: string, +) { + await connection.client` + insert into receipts ( + agent_id, cycle_id, amount_atomic, amount_usd, asset, network, pay_to, + resource_url, resource_host, authorization_nonce, request_fingerprint, + authorization_valid_before + ) values ( + ${identity.agentId}, ${identity.cycleId}, '1', '0.000001', ${baseUsdc}, + 'eip155:8453', ${payTo}, ${resourceUrl}, 'tool', + ${`0x${randomBytes(32).toString("hex")}`}, ${randomBytes(32).toString("hex")}, + ${new Date((nowSeconds + 300) * 1_000).toISOString()}::timestamptz + ) + `; +} + +describe("resource novelty identity", () => { + it("treats canonical MCP tool URLs as distinct while replaying each URL once", async () => { + const identity = await provision(); + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("mcp://tool/search?secret=one"), + nowSeconds, + }); + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("mcp://tool/export?secret=two"), + nowSeconds, + }); + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("MCP://user:pass@TOOL/search#replay"), + nowSeconds, + }); + + const rows = await unusualDomains(); + expect(rows).toHaveLength(2); + expect(new Set(rows.map((row) => row.resourceHost))).toEqual(new Set(["tool"])); + expect(new Set(rows.map((row) => row.metadata.resourceUrl))).toEqual( + new Set(["mcp://tool/search", "mcp://tool/export"]), + ); + expect(rows.map((row) => row.resourceKey)).toEqual([ + expect.stringMatching(/^[0-9a-f]{64}$/), + expect.stringMatching(/^[0-9a-f]{64}$/), + ]); + expect(new Set(rows.map((row) => row.resourceKey))).toHaveProperty("size", 2); + expect(rows.every((row) => row.eventKey === `unusual_domain:${row.resourceKey}`)).toBe(true); + expect(JSON.stringify(rows)).not.toMatch(/secret|user|pass/); + const linked = await connection.db + .select({ resourceUrl: receipts.resourceUrl }) + .from(receipts) + .where(eq(receipts.agentId, identity.agentId)); + expect(new Set(linked.map((row) => row.resourceUrl))).toEqual( + new Set(["mcp://tool/search", "mcp://tool/export"]), + ); + }); + + it("deduplicates concurrent HTTP paths by normalized host", async () => { + const identity = await provision(); + await Promise.all([ + reserveSignRequest(connection.db, { + ...identity, + body: signBody("https://API.Vendor.TEST/pay?secret=one"), + nowSeconds, + }), + reserveSignRequest(connection.db, { + ...identity, + body: signBody("https://api.vendor.test/export?secret=two"), + nowSeconds, + }), + ]); + + expect(await unusualDomains()).toHaveLength(1); + }); + + it("uses the full canonical MCP URL when checking historical receipts", async () => { + const identity = await provision(); + await insertHistoricalMcp(identity, "mcp://tool/search"); + + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("mcp://tool/search"), + nowSeconds, + }); + expect(await unusualDomains()).toEqual([]); + + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("mcp://tool/export"), + nowSeconds, + }); + expect(await unusualDomains()).toEqual([ + expect.objectContaining({ + metadata: { resourceHost: "tool", resourceUrl: "mcp://tool/export" }, + resourceHost: "tool", + }), + ]); + }); + + it("rolls the resource identity and linked receipt back together", async () => { + const identity = await provision(); + await expect( + connection.db.transaction(async (transaction) => { + await reserveSignRequest(transaction as unknown as Database, { + ...identity, + body: signBody("mcp://tool/rollback"), + nowSeconds, + }); + expect( + await transaction + .select() + .from(notifications) + .where(eq(notifications.type, "unusual_domain")), + ).toHaveLength(1); + throw new Error("rollback novelty"); + }), + ).rejects.toThrow("rollback novelty"); + + expect(await unusualDomains()).toEqual([]); + expect(await connection.db.select().from(receipts)).toEqual([]); + }); +}); diff --git a/apps/web/lib/leash/resource-provenance.integration.test.ts b/apps/web/lib/leash/resource-provenance.integration.test.ts new file mode 100644 index 0000000..b81a588 --- /dev/null +++ b/apps/web/lib/leash/resource-provenance.integration.test.ts @@ -0,0 +1,149 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase } from "../db/client"; +import { reserveSignRequest } from "./sign-store"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for resource provenance tests"); + +const connection = createDatabase(databaseUrl, 3); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const nowSeconds = 1_784_271_300; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(capCents: string) { + const [owner] = await connection.client<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) + returning id + `; + if (!owner) throw new Error("Expected an owner"); + const [agent] = await connection.client<{ id: string }[]>` + insert into agents (owner_id, name, status, signer_subject, agent_address) + values ( + ${owner.id}, 'Resource provenance', 'provisioned', + ${`leash:${randomUUID()}`}, ${agentAddress} + ) returning id + `; + if (!agent) throw new Error("Expected an agent"); + const [key] = await connection.client<{ id: string }[]>` + insert into leash_keys (agent_id, hashed_key, prefix, last4) + values (${agent.id}, ${randomBytes(32).toString("hex")}, 'leash_sk_', 'a1B2') + returning id + `; + await connection.client` + insert into cap_cycles (agent_id, started_at) + values (${agent.id}, ${new Date((nowSeconds - 60) * 1_000).toISOString()}::timestamptz) + `; + await connection.client` + insert into caps (agent_id, amount_usd_cents, frequency) + values (${agent.id}, ${capCents}, 'daily') + `; + if (!key) throw new Error("Expected a key"); + return { agentId: agent.id, keyId: key.id }; +} + +function signBody(resourceUrl: string, nonce = randomBytes(32).toString("hex")) { + return { + amount: "25000", + asset: baseUsdc, + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo, + resourceUrl, + signerRequest: { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: baseUsdc, + version: "2", + }, + message: { + from: agentAddress, + nonce: `0x${nonce}`, + to: payTo, + validAfter: "0", + validBefore: String(nowSeconds + 300), + value: "25000", + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +describe("receipt resource provenance persistence", () => { + it.each([ + ["pending", "100"], + ["blocked", "1"], + ] as const)("persists sanitized provenance on a new %s receipt", async (status, capCents) => { + const identity = await provision(capCents); + const result = await reserveSignRequest(connection.db, { + ...identity, + body: signBody( + "MCP://receipt-user:receipt-password@PAYMENTS.Example.TEST/tool/search?api_key=receipt-secret#fragment-secret", + ), + nowSeconds, + }); + + expect(result.kind).toBe(status); + const [stored] = await connection.client< + { origin: unknown; resource_host: string; resource_url: string; status: string }[] + >` + select status, origin, resource_url, resource_host from receipts where id = ${result.receiptId} + `; + expect(stored).toEqual({ + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + resource_host: "payments.example.test", + resource_url: "mcp://payments.example.test/tool/search", + status, + }); + expect(JSON.stringify(stored)).not.toMatch( + /receipt-user|receipt-password|receipt-secret|fragment-secret/, + ); + }); + + it("preserves the first resource when an idempotent nonce is replayed", async () => { + const identity = await provision("100"); + const nonce = randomBytes(32).toString("hex"); + const first = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("https://FIRST.Example.TEST/pay?secret=first", nonce), + nowSeconds, + }); + const replay = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("https://second.example.test/other?secret=second", nonce), + nowSeconds, + }); + + expect(replay).toMatchObject({ receiptId: first.receiptId, replayed: true }); + const [stored] = await connection.client<{ resource_host: string; resource_url: string }[]>` + select resource_url, resource_host from receipts where id = ${first.receiptId} + `; + expect(stored).toEqual({ + resource_host: "first.example.test", + resource_url: "https://first.example.test/pay", + }); + }); +}); diff --git a/apps/web/lib/leash/sign-notifications.integration.test.ts b/apps/web/lib/leash/sign-notifications.integration.test.ts new file mode 100644 index 0000000..c707976 --- /dev/null +++ b/apps/web/lib/leash/sign-notifications.integration.test.ts @@ -0,0 +1,242 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { eq } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { createDatabase, type Database } from "../db/client"; +import { notifications, receipts } from "../db/schema"; +import { reserveSignRequest } from "./sign-store"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is required for sign notification tests"); + +const connection = createDatabase(databaseUrl, 4); +const agentAddress = "0x2222222222222222222222222222222222222222"; +const payTo = "0x1111111111111111111111111111111111111111"; +const baseUsdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const nowSeconds = 1_784_271_300; + +beforeEach(async () => { + await connection.client`truncate table users cascade`; +}); + +afterAll(async () => { + await connection.client.end(); +}); + +async function provision(capCents = "100") { + const [owner] = await connection.client<{ id: string }[]>` + insert into users (email, magic_issuer) + values (${`${randomUUID()}@example.test`}, ${`did:ethr:${randomUUID()}`}) returning id + `; + if (!owner) throw new Error("Expected owner"); + const [agent] = await connection.client<{ id: string }[]>` + insert into agents (owner_id, name, signer_subject, agent_address) + values (${owner.id}, 'Notification test', ${`leash:${randomUUID()}`}, ${agentAddress}) + returning id + `; + if (!agent) throw new Error("Expected agent"); + const [key] = await connection.client<{ id: string }[]>` + insert into leash_keys (agent_id, hashed_key, prefix, last4) + values (${agent.id}, ${randomBytes(32).toString("hex")}, 'leash_sk_', 'a1B2') + returning id + `; + const [cycle] = await connection.client<{ id: string }[]>` + insert into cap_cycles (agent_id, started_at) + values (${agent.id}, ${new Date((nowSeconds - 60) * 1_000).toISOString()}::timestamptz) + returning id + `; + await connection.client` + insert into caps (agent_id, amount_usd_cents, frequency) + values (${agent.id}, ${capCents}, 'daily') + `; + if (!key || !cycle) throw new Error("Expected key and cycle"); + return { agentId: agent.id, cycleId: cycle.id, keyId: key.id }; +} + +function signBody(amount: string, resourceUrl: string, nonce = randomBytes(32).toString("hex")) { + return { + amount, + asset: baseUsdc, + network: "eip155:8453", + origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, + payTo, + resourceUrl, + signerRequest: { + domain: { + chainId: 8453, + name: "USD Coin", + verifyingContract: baseUsdc, + version: "2", + }, + message: { + from: agentAddress, + nonce: `0x${nonce}`, + to: payTo, + validAfter: "0", + validBefore: String(nowSeconds + 300), + value: amount, + }, + primaryType: "TransferWithAuthorization", + types: { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], + }, + }, + }; +} + +async function insertHistorical( + identity: Awaited>, + status: "pending" | "settled" | "blocked", + resourceHost: string, +) { + const settled = status === "settled"; + const blocked = status === "blocked"; + await connection.client` + insert into receipts ( + agent_id, cycle_id, status, reason, amount_atomic, amount_usd, asset, network, + intended_network, pay_to, resource_url, resource_host, authorization_nonce, + request_fingerprint, authorization_valid_before, tx_hash, settlement_response, settled_at + ) values ( + ${identity.agentId}, ${identity.cycleId}, ${status}, + ${blocked ? "LEASH_CAP_EXCEEDED" : null}, '1', '0.000001', ${baseUsdc}, 'eip155:8453', + ${blocked ? "eip155:8453" : null}, ${payTo}, ${`https://${resourceHost}/old`}, + ${resourceHost}, ${`0x${randomBytes(32).toString("hex")}`}, + ${randomBytes(32).toString("hex")}, now() + interval '5 minutes', + ${settled ? `0x${randomBytes(32).toString("hex")}` : null}, + ${settled ? JSON.stringify({ verified: true }) : null}::jsonb, + ${settled ? new Date().toISOString() : null}::timestamptz + ) + `; +} + +describe("transactional notifications from signer reservation", () => { + it("keeps committed spend separate from the blocked attempted amount and replays once", async () => { + const identity = await provision(); + const resourceUrl = "https://api.vendor.test/pay"; + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("600000", resourceUrl), + nowSeconds, + }); + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("400000", resourceUrl), + nowSeconds, + }); + const nonce = randomBytes(32).toString("hex"); + const body = signBody("1", resourceUrl, nonce); + const blocked = await reserveSignRequest(connection.db, { + ...identity, + body, + nowSeconds, + }); + const replay = await reserveSignRequest(connection.db, { + ...identity, + body: signBody("1", "https://replay-ignored.vendor.test/pay", nonce), + nowSeconds, + }); + + expect(replay).toEqual(blocked); + const rows = await connection.db + .select() + .from(notifications) + .where(eq(notifications.type, "cap_blocked")); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + metadata: { + attemptedAtomic: "1", + capAtomic: "1000000", + committedAtomic: "1000000", + }, + receiptId: blocked.receiptId, + sticky: true, + tier: "3", + }); + const domains = await connection.db + .select() + .from(notifications) + .where(eq(notifications.type, "unusual_domain")); + expect(domains).toHaveLength(1); + expect(domains[0]?.resourceHost).toBe("api.vendor.test"); + }); + + it("emits one normalized unusual-domain event under concurrent first use", async () => { + const identity = await provision("200"); + const results = await Promise.all([ + reserveSignRequest(connection.db, { + ...identity, + body: signBody("250000", "https://API.Vendor.TEST/pay?key=one"), + nowSeconds, + }), + reserveSignRequest(connection.db, { + ...identity, + body: signBody("250000", "https://api.vendor.test/other?key=two"), + nowSeconds, + }), + ]); + + const rows = await connection.db + .select() + .from(notifications) + .where(eq(notifications.type, "unusual_domain")); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + metadata: { resourceHost: "api.vendor.test" }, + resourceHost: "api.vendor.test", + tier: "2", + }); + expect(results.map((result) => result.receiptId)).toContain(rows[0]?.receiptId); + }); + + it.each([ + "pending", + "settled", + "blocked", + ] as const)("treats a real historical %s receipt as prior domain use", async (status) => { + const identity = await provision("200"); + await insertHistorical(identity, status, "known.vendor.test"); + + await reserveSignRequest(connection.db, { + ...identity, + body: signBody("250000", "https://KNOWN.VENDOR.TEST/new"), + nowSeconds, + }); + + const rows = await connection.db + .select() + .from(notifications) + .where(eq(notifications.type, "unusual_domain")); + expect(rows).toEqual([]); + }); + + it("rolls receipt, audit event, and both notifications back together", async () => { + const identity = await provision("1"); + await expect( + connection.db.transaction(async (transaction) => { + const blocked = await reserveSignRequest(transaction as unknown as Database, { + ...identity, + body: signBody("25000", "https://rollback.vendor.test/pay"), + nowSeconds, + }); + expect(blocked.kind).toBe("blocked"); + expect(await transaction.select().from(notifications)).toHaveLength(2); + throw new Error("rollback reservation"); + }), + ).rejects.toThrow("rollback reservation"); + + expect(await connection.db.select().from(receipts)).toEqual([]); + expect(await connection.db.select().from(notifications)).toEqual([]); + const [eventCount] = await connection.client< + { count: string }[] + >`select count(*) from agent_events`; + expect(eventCount?.count).toBe("0"); + }); +}); diff --git a/apps/web/lib/leash/sign-preflight-store.integration.test.ts b/apps/web/lib/leash/sign-preflight-store.integration.test.ts index 2933585..6f4d5eb 100644 --- a/apps/web/lib/leash/sign-preflight-store.integration.test.ts +++ b/apps/web/lib/leash/sign-preflight-store.integration.test.ts @@ -1,9 +1,15 @@ import { randomBytes, randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; -import { createDatabase } from "../db/client"; -import { completePreSigningChecks, reserveSignRequest } from "./sign-store"; +import { createDatabase, type Database } from "../db/client"; +import { notifications } from "../db/schema"; +import { + completePreSigningChecks, + failSignRequestBeforeSigning, + reserveSignRequest, +} from "./sign-store"; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) throw new Error("DATABASE_URL is required for sign preflight tests"); @@ -59,6 +65,7 @@ function signBody(amount: string, validBefore = nowSeconds + 300) { network: "eip155:8453", origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, payTo, + resourceUrl: "mcp://tool/search", signerRequest: { domain: { chainId: 8453, @@ -131,6 +138,10 @@ function finalCheck( }); } +function floatNotifications() { + return connection.db.select().from(notifications).where(eq(notifications.type, "float_empty")); +} + describe("final hosted-signer policy decision", () => { it("fails a reservation when the cap is lowered during the RPC gap", async () => { const identity = await provision("100"); @@ -141,7 +152,7 @@ describe("final hosted-signer policy decision", () => { await expect(finalCheck(identity, pending.receiptId)).resolves.toMatchObject({ code: "LEASH_CAP_EXCEEDED", - kind: "failed", + kind: "blocked", }); }); @@ -166,6 +177,97 @@ describe("final hosted-signer policy decision", () => { code: "FLOAT_EMPTY", kind: "failed", }); + const [event] = await floatNotifications(); + expect(event).toMatchObject({ + cycleId: current.cycleId, + eventKey: `float_empty:${current.cycleId}:eip155:8453`, + metadata: { + availableAtomic: "1000000", + network: "eip155:8453", + reservedAtomic: "1200000", + }, + receiptId: pending.receiptId, + sticky: false, + tier: "2", + }); + }); + + it("deduplicates concurrent and replayed float-empty failures once per cycle", async () => { + const identity = await provision("300"); + const [first, second] = await Promise.all([ + reserve(identity, "600000"), + reserve(identity, "600000"), + reserve(identity, "600000"), + ]); + if (!first || !second) throw new Error("Expected reservations"); + + const results = await Promise.all([ + finalCheck(identity, first.receiptId, BigInt(1_000_000)), + finalCheck(identity, second.receiptId, BigInt(1_000_000)), + ]); + expect(results).toMatchObject([{ code: "FLOAT_EMPTY" }, { code: "FLOAT_EMPTY" }]); + await expect(finalCheck(identity, first.receiptId, BigInt(1_000_000))).resolves.toMatchObject({ + code: "FLOAT_EMPTY", + }); + + const rows = await floatNotifications(); + expect(rows).toHaveLength(1); + expect([first.receiptId, second.receiptId]).toContain(rows[0]?.receiptId); + expect(rows[0]?.metadata).toMatchObject({ reservedAtomic: "1800000" }); + }); + + it("does not emit float-empty for signer or balance-check availability failures", async () => { + const identity = await provision(); + const signerFailure = await reserve(identity, "250000"); + const checkFailure = await reserve(identity, "250000"); + + await expect( + completePreSigningChecks(connection.db, { + ...identity, + liveBalanceAtomic: BigInt(10_000_000), + nowSeconds, + receiptId: signerFailure.receiptId, + signerAvailable: false, + }), + ).resolves.toMatchObject({ code: "SIGNER_NOT_CONFIGURED" }); + await expect( + failSignRequestBeforeSigning(connection.db, { + agentId: identity.agentId, + reason: "FLOAT_CHECK_UNAVAILABLE", + receiptId: checkFailure.receiptId, + }), + ).resolves.toMatchObject({ code: "FLOAT_CHECK_UNAVAILABLE" }); + expect(await floatNotifications()).toEqual([]); + }); + + it("rolls the failed receipt and float-empty notification back together", async () => { + const identity = await provision(); + const pending = await reserve(identity, "600000"); + await reserve(identity, "600000"); + + await expect( + connection.db.transaction(async (transaction) => { + await completePreSigningChecks(transaction as unknown as Database, { + ...identity, + liveBalanceAtomic: BigInt(1_000_000), + nowSeconds, + receiptId: pending.receiptId, + signerAvailable: true, + }); + const inside = await transaction + .select() + .from(notifications) + .where(eq(notifications.type, "float_empty")); + expect(inside).toHaveLength(1); + throw new Error("rollback float decision"); + }), + ).rejects.toThrow("rollback float decision"); + + const [stored] = await connection.client<{ status: string }[]>` + select status from receipts where id = ${pending.receiptId} + `; + expect(stored?.status).toBe("pending"); + expect(await floatNotifications()).toEqual([]); }); it("rechecks authorization expiry immediately before signing", async () => { diff --git a/apps/web/lib/leash/sign-preflight-store.ts b/apps/web/lib/leash/sign-preflight-store.ts index 9acc644..73d3b44 100644 --- a/apps/web/lib/leash/sign-preflight-store.ts +++ b/apps/web/lib/leash/sign-preflight-store.ts @@ -1,7 +1,10 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import type { Database } from "../db/client"; -import { agents, capCycles, caps, leashKeys, receipts } from "../db/schema"; +import { agentEvents, agents, caps, leashKeys, receipts } from "../db/schema"; +import { findActiveCapHalt } from "./cap-halt-store"; +import { ensureCurrentCapCycle } from "./cycles"; +import { emitFloatEmpty } from "./notifier"; import { type SignGateCode, SignGateError, statusGateError } from "./sign-gate"; const ATOMIC_UNITS_PER_CENT = BigInt(10_000); @@ -16,11 +19,33 @@ async function failLockedReceipt( receipt: typeof receipts.$inferSelect, failure: PreflightFailure, ) { - await transaction + const blockedByCap = failure === "LEASH_CAP_EXCEEDED"; + const [terminalized] = await transaction .update(receipts) - .set({ reason: failure, status: "failed" }) - .where(and(eq(receipts.id, receipt.id), eq(receipts.status, "pending"))); - return { code: failure, kind: "failed" as const, receiptId: receipt.id }; + .set({ + intendedNetwork: blockedByCap ? receipt.network : null, + reason: failure, + settlementResponse: null, + settledAt: null, + status: blockedByCap ? "blocked" : "failed", + txHash: null, + }) + .where(and(eq(receipts.id, receipt.id), eq(receipts.status, "pending"))) + .returning({ id: receipts.id }); + if (!terminalized) throw new Error("The pending receipt changed during pre-sign checks"); + if (blockedByCap) { + await transaction.insert(agentEvents).values({ + actorSurface: "agent", + agentId: receipt.agentId, + metadata: { receiptId: receipt.id }, + type: "block", + }); + } + return { + code: failure, + kind: blockedByCap ? ("blocked" as const) : ("failed" as const), + receiptId: receipt.id, + }; } export async function completePreSigningChecks( @@ -52,12 +77,27 @@ export async function completePreSigningChecks( isNull(leashKeys.revokedAt), ), ); + if (!agent || !key) throw new SignGateError("INVALID_LEASH_KEY", 401); + + const [cap] = await transaction + .select({ amountUsdCents: caps.amountUsdCents, frequency: caps.frequency }) + .from(caps) + .where(eq(caps.agentId, agent.id)) + .for("update"); + const cycle = cap + ? await ensureCurrentCapCycle(transaction, { + agentId: agent.id, + frequency: cap.frequency, + now: checkedAt, + }) + : undefined; + const [receipt] = await transaction .select() .from(receipts) .where(and(eq(receipts.id, options.receiptId), eq(receipts.agentId, options.agentId))) .for("update"); - if (!agent || !key || !receipt) throw new SignGateError("INVALID_LEASH_KEY", 401); + if (!receipt) throw new SignGateError("INVALID_LEASH_KEY", 401); if (receipt.status !== "pending") return terminalReceipt(receipt); let failure: PreflightFailure | undefined = statusGateError(agent.status)?.code; @@ -65,36 +105,29 @@ export async function completePreSigningChecks( failure = "AUTHORIZATION_EXPIRED"; } - const [policy] = failure - ? [] - : await transaction - .select({ amountUsdCents: caps.amountUsdCents, cycleId: capCycles.id }) - .from(caps) - .innerJoin(capCycles, and(eq(capCycles.agentId, caps.agentId), isNull(capCycles.endedAt))) - .where(eq(caps.agentId, agent.id)) - .for("update"); - if (!failure && !policy?.amountUsdCents) failure = "LEASH_CAP_NOT_SET"; - if (!failure && policy?.cycleId !== receipt.cycleId) failure = "CAP_CYCLE_CHANGED"; + if (!failure && (!cap?.amountUsdCents || !cycle)) failure = "LEASH_CAP_NOT_SET"; + if (!failure && cycle?.id !== receipt.cycleId) failure = "CAP_CYCLE_CHANGED"; + if (!failure && (await findActiveCapHalt(transaction, agent.id))) { + failure = "LEASH_CAP_EXCEEDED"; + } - if (!failure && policy?.amountUsdCents) { + if (!failure && cap?.amountUsdCents && cycle) { const [usage] = await transaction .select({ amountAtomic: sql`coalesce(sum(${receipts.amountAtomic}), 0)::text` }) .from(receipts) .where( and( eq(receipts.agentId, agent.id), - eq(receipts.cycleId, policy.cycleId), + eq(receipts.cycleId, cycle.id), inArray(receipts.status, ["pending", "settled"]), ), ); - if ( - BigInt(usage?.amountAtomic ?? "0") > - BigInt(policy.amountUsdCents) * ATOMIC_UNITS_PER_CENT - ) { + if (BigInt(usage?.amountAtomic ?? "0") > BigInt(cap.amountUsdCents) * ATOMIC_UNITS_PER_CENT) { failure = "LEASH_CAP_EXCEEDED"; } } + let reservedAtomic: string | undefined; if (!failure) { const [reserved] = await transaction .select({ amountAtomic: sql`coalesce(sum(${receipts.amountAtomic}), 0)::text` }) @@ -106,13 +139,31 @@ export async function completePreSigningChecks( eq(receipts.status, "pending"), ), ); - if (BigInt(reserved?.amountAtomic ?? "0") > options.liveBalanceAtomic) { + reservedAtomic = reserved?.amountAtomic ?? "0"; + if (BigInt(reservedAtomic) > options.liveBalanceAtomic) { failure = "FLOAT_EMPTY"; } else if (!options.signerAvailable) { failure = "SIGNER_NOT_CONFIGURED"; } } - if (failure) return failLockedReceipt(transaction, receipt, failure); + if (failure) { + const result = await failLockedReceipt(transaction, receipt, failure); + if (failure === "FLOAT_EMPTY") { + if (!cycle || reservedAtomic === undefined) { + throw new Error("Float insufficiency requires an active cap cycle and reservation total"); + } + await emitFloatEmpty(transaction, { + agentId: agent.id, + availableAtomic: options.liveBalanceAtomic.toString(), + cycleId: cycle.id, + network: receipt.network, + now: checkedAt, + receiptId: receipt.id, + reservedAtomic, + }); + } + return result; + } return { kind: "ready" as const, receiptId: receipt.id }; }); } diff --git a/apps/web/lib/leash/sign-request.test.ts b/apps/web/lib/leash/sign-request.test.ts index 511945f..1c0ca70 100644 --- a/apps/web/lib/leash/sign-request.test.ts +++ b/apps/web/lib/leash/sign-request.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { InvalidSignRequestError, parseSignRequest } from "./sign-request"; const agentAddress = "0x2222222222222222222222222222222222222222"; +const maxAtomicUsdcAmount = BigInt("999999999999999999990000"); function validRequest() { return { @@ -11,6 +12,7 @@ function validRequest() { network: "eip155:8453", origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, payTo: "0x1111111111111111111111111111111111111111", + resourceUrl: "mcp://tool/search", signerRequest: { domain: { chainId: 8453, @@ -52,10 +54,59 @@ describe("remote EIP-3009 sign request validation", () => { authorizationValidBefore: new Date(1_784_271_600_000), network: "eip155:8453", payTo: "0x1111111111111111111111111111111111111111", + resourceHost: "tool", + resourceUrl: "mcp://tool/search", requestFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/), }); }); + it("accepts the cap-domain ceiling and rejects one atomic unit above it", () => { + const largest = validRequest(); + largest.amount = maxAtomicUsdcAmount.toString(); + largest.signerRequest.message.value = largest.amount; + expect( + parseSignRequest(largest, { agentAddress, nowSeconds: 1_784_271_300 }).amountAtomic, + ).toBe(maxAtomicUsdcAmount.toString()); + + const oversized = validRequest(); + oversized.amount = (maxAtomicUsdcAmount + BigInt(1)).toString(); + oversized.signerRequest.message.value = oversized.amount; + expect(() => parseSignRequest(oversized, { agentAddress, nowSeconds: 1_784_271_300 })).toThrow( + InvalidSignRequestError, + ); + }); + + it("canonicalizes receipt resource provenance and removes URL secrets server-side", () => { + const request = validRequest(); + request.resourceUrl = + "MCP://receipt-user:receipt-password@PAYMENTS.Example.TEST/tool/search?api_key=receipt-secret#fragment-secret"; + + const parsed = parseSignRequest(request, { agentAddress, nowSeconds: 1_784_271_300 }); + + expect(parsed).toMatchObject({ + resourceHost: "payments.example.test", + resourceUrl: "mcp://payments.example.test/tool/search", + }); + expect(JSON.stringify(parsed)).not.toMatch( + /receipt-user|receipt-password|receipt-secret|fragment-secret/, + ); + }); + + it.each([ + "not an absolute URL", + "file:///hostless/path", + "mailto:hostless@example.test", + "ftp://tool.example.test/pay", + `https://example.test/${"x".repeat(2_049)}`, + ])("rejects malformed, hostless, or overlong resource provenance: %s", (resourceUrl) => { + const request = validRequest(); + request.resourceUrl = resourceUrl; + + expect(() => parseSignRequest(request, { agentAddress, nowSeconds: 1_784_271_300 })).toThrow( + InvalidSignRequestError, + ); + }); + it("canonicalizes nonce casing before fingerprinting and returning signer data", () => { const mixedCase = validRequest(); mixedCase.signerRequest.message.nonce = `0x${"aB".repeat(32)}`; diff --git a/apps/web/lib/leash/sign-request.ts b/apps/web/lib/leash/sign-request.ts index feee5e0..4375144 100644 --- a/apps/web/lib/leash/sign-request.ts +++ b/apps/web/lib/leash/sign-request.ts @@ -2,11 +2,19 @@ import { createHash } from "node:crypto"; import { getAddress, isAddress } from "viem"; +import { canonicalResourceIdentity } from "./resource-identity"; + const BASE_NETWORK = "eip155:8453"; const ARBITRUM_NETWORK = "eip155:42161"; const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; const ARBITRUM_USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; const MAX_AUTHORIZATION_LIFETIME_SECONDS = 600; +// Mirrors numeric(20,0) cap cents at 10,000 atomic USDC units per cent. +const MAX_CAP_USD_CENTS = BigInt(10) ** BigInt(20) - BigInt(1); +const ATOMIC_UNITS_PER_CENT = BigInt(10_000); +export const MAX_USDC_AMOUNT_ATOMIC = MAX_CAP_USD_CENTS * ATOMIC_UNITS_PER_CENT; +const MAX_RESOURCE_HOST_LENGTH = 253; +const MAX_RESOURCE_URL_LENGTH = 2_048; const HTTP_METHODS = new Set([ "CONNECT", "DELETE", @@ -126,6 +134,36 @@ function origin(value: unknown) { }; } +function resource(value: unknown) { + if (typeof value !== "string" || value.length < 1 || value.length > MAX_RESOURCE_URL_LENGTH) { + throw new InvalidSignRequestError(); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new InvalidSignRequestError(); + } + const resourceHost = url.hostname.toLowerCase(); + if (resourceHost.length < 1 || resourceHost.length > MAX_RESOURCE_HOST_LENGTH) { + throw new InvalidSignRequestError(); + } + url.hostname = resourceHost; + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + const resourceUrl = url.toString(); + if (resourceUrl.length < 1 || resourceUrl.length > MAX_RESOURCE_URL_LENGTH) { + throw new InvalidSignRequestError(); + } + try { + return { resourceHost, resourceUrl, ...canonicalResourceIdentity(resourceUrl, resourceHost) }; + } catch { + throw new InvalidSignRequestError(); + } +} + export function parseSignRequest( value: unknown, options: { agentAddress: string; nowSeconds?: number }, @@ -136,13 +174,18 @@ export function parseSignRequest( "network", "origin", "payTo", + "resourceUrl", "signerRequest", ]); const network = supportedNetwork(body.network); const amountAtomic = unsigned(body.amount); - if (BigInt(amountAtomic) === BigInt(0)) throw new InvalidSignRequestError(); + const amount = BigInt(amountAtomic); + if (amount === BigInt(0) || amount > MAX_USDC_AMOUNT_ATOMIC) { + throw new InvalidSignRequestError(); + } const asset = address(body.asset); const payTo = address(body.payTo); + const parsedResource = resource(body.resourceUrl); if (asset !== network.asset) throw new InvalidSignRequestError(); const signerRequest = exactRecord(body.signerRequest, [ @@ -218,6 +261,7 @@ export function parseSignRequest( network: network.network, origin: origin(body.origin), payTo, + ...parsedResource, requestFingerprint: createHash("sha256").update(fingerprint.join("\0")).digest("hex"), signerRequest: { domain, diff --git a/apps/web/lib/leash/sign-store.integration.test.ts b/apps/web/lib/leash/sign-store.integration.test.ts index 3a4cc4a..2020daa 100644 --- a/apps/web/lib/leash/sign-store.integration.test.ts +++ b/apps/web/lib/leash/sign-store.integration.test.ts @@ -68,6 +68,7 @@ function signBody(amount = "250000", nonce = randomBytes(32).toString("hex")) { network: "eip155:8453", origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" }, payTo, + resourceUrl: "mcp://tool/search", signerRequest: { domain: { chainId: 8453, diff --git a/apps/web/lib/leash/sign-store.ts b/apps/web/lib/leash/sign-store.ts index 73d4e9b..67c22a0 100644 --- a/apps/web/lib/leash/sign-store.ts +++ b/apps/web/lib/leash/sign-store.ts @@ -1,7 +1,10 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import type { Database } from "../db/client"; -import { agentEvents, agents, capCycles, caps, leashKeys, receipts } from "../db/schema"; +import { agentEvents, agents, caps, leashKeys, receipts } from "../db/schema"; +import { findActiveCapHalt } from "./cap-halt-store"; +import { ensureCurrentCapCycle } from "./cycles"; +import { emitCapBlocked, emitUnusualDomain } from "./notifier"; import { SignGateError, statusGateError } from "./sign-gate"; import { InvalidSignRequestError, parseSignRequest } from "./sign-request"; @@ -66,6 +69,8 @@ export async function reserveSignRequest( nowSeconds?: number; }, ) { + const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1_000); + const now = new Date(nowSeconds * 1_000); return db.transaction(async (transaction) => { const [agent] = await transaction .select({ address: agents.agentAddress, id: agents.id, status: agents.status }) @@ -92,8 +97,22 @@ export async function reserveSignRequest( const parsed = parseSignRequest(decodeBody(options.body), { agentAddress: agent.address, - ...(options.nowSeconds === undefined ? {} : { nowSeconds: options.nowSeconds }), + nowSeconds, }); + const [cap] = await transaction + .select({ amountUsdCents: caps.amountUsdCents, frequency: caps.frequency }) + .from(caps) + .where(eq(caps.agentId, agent.id)) + .for("update"); + if (!cap?.amountUsdCents) throw new SignGateError("LEASH_CAP_NOT_SET", 403); + const cycle = await ensureCurrentCapCycle(transaction, { + agentId: agent.id, + frequency: cap.frequency, + now, + }); + if (!cycle) throw new SignGateError("LEASH_CAP_NOT_SET", 403); + const activeHalt = await findActiveCapHalt(transaction, agent.id); + const [existing] = await transaction .select() .from(receipts) @@ -108,6 +127,30 @@ export async function reserveSignRequest( throw new SignGateError("SIGN_REQUEST_CONFLICT", 409); } if (existing.status === "pending") { + if (activeHalt) { + await transaction + .update(receipts) + .set({ + intendedNetwork: existing.network, + reason: "LEASH_CAP_EXCEEDED", + settlementResponse: null, + settledAt: null, + status: "blocked", + txHash: null, + }) + .where(and(eq(receipts.id, existing.id), eq(receipts.status, "pending"))); + await transaction.insert(agentEvents).values({ + actorSurface: "agent", + agentId: agent.id, + metadata: { receiptId: existing.id }, + type: "block", + }); + return { + code: "LEASH_CAP_EXCEEDED" as const, + kind: "blocked" as const, + receiptId: existing.id, + }; + } return pendingResult(existing.id, parsed, agent.address, true); } return { @@ -120,12 +163,21 @@ export async function reserveSignRequest( } as const; } - const [policy] = await transaction - .select({ amountUsdCents: caps.amountUsdCents, cycleId: capCycles.id }) - .from(caps) - .innerJoin(capCycles, and(eq(capCycles.agentId, caps.agentId), isNull(capCycles.endedAt))) - .where(eq(caps.agentId, agent.id)); - if (!policy?.amountUsdCents) throw new SignGateError("LEASH_CAP_NOT_SET", 403); + const [knownResource] = await transaction + .select({ id: receipts.id }) + .from(receipts) + .where( + and( + eq(receipts.agentId, agent.id), + parsed.resourceIdentityKind === "mcp_resource" + ? eq(receipts.resourceUrl, parsed.resourceUrl) + : and( + eq(receipts.resourceHost, parsed.resourceHost), + sql`${receipts.resourceUrl} ~ '^https?://'`, + ), + ), + ) + .limit(1); const [usage] = await transaction .select({ @@ -135,14 +187,15 @@ export async function reserveSignRequest( .where( and( eq(receipts.agentId, agent.id), - eq(receipts.cycleId, policy.cycleId), + eq(receipts.cycleId, cycle.id), inArray(receipts.status, ["pending", "settled"]), ), ); const exceedsCap = BigInt(usage?.amountAtomic ?? "0") + BigInt(parsed.amountAtomic) > - BigInt(policy.amountUsdCents) * ATOMIC_UNITS_PER_CENT; - const status = exceedsCap ? "blocked" : "pending"; + BigInt(cap.amountUsdCents) * ATOMIC_UNITS_PER_CENT; + const blockedByCap = activeHalt !== undefined || exceedsCap; + const status = blockedByCap ? "blocked" : "pending"; const receiptValues: typeof receipts.$inferInsert = { agentId: agent.id, amountAtomic: parsed.amountAtomic, @@ -150,12 +203,14 @@ export async function reserveSignRequest( asset: parsed.asset, authorizationNonce: parsed.authorizationNonce, authorizationValidBefore: parsed.authorizationValidBefore, - cycleId: policy.cycleId, - intendedNetwork: exceedsCap ? parsed.network : null, + cycleId: cycle.id, + intendedNetwork: blockedByCap ? parsed.network : null, network: parsed.network, origin: parsed.origin, payTo: parsed.payTo, - reason: exceedsCap ? "LEASH_CAP_EXCEEDED" : null, + resourceHost: parsed.resourceHost, + resourceUrl: parsed.resourceUrl, + reason: blockedByCap ? "LEASH_CAP_EXCEEDED" : null, requestFingerprint: parsed.requestFingerprint, status, }; @@ -168,9 +223,29 @@ export async function reserveSignRequest( actorSurface: "agent", agentId: agent.id, metadata: { receiptId: created.id }, - type: exceedsCap ? "block" : "sign", + type: blockedByCap ? "block" : "sign", }); - if (exceedsCap) { + if (!knownResource) { + await emitUnusualDomain(transaction, { + agentId: agent.id, + cycleId: cycle.id, + now, + receiptId: created.id, + resourceHost: parsed.resourceHost, + resourceKey: parsed.resourceKey, + resourceUrl: parsed.resourceUrl, + }); + } + if (blockedByCap) { + await emitCapBlocked(transaction, { + agentId: agent.id, + attemptedAtomic: parsed.amountAtomic, + capAtomic: (BigInt(cap.amountUsdCents) * ATOMIC_UNITS_PER_CENT).toString(), + committedAtomic: usage?.amountAtomic ?? "0", + cycleId: cycle.id, + now, + receiptId: created.id, + }); return { code: "LEASH_CAP_EXCEEDED" as const, kind: "blocked" as const,