From bb3edc170e07dce0a0d39d77e3addbfde074d9f6 Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:02:00 -0400 Subject: [PATCH 1/6] chore: ignore file-backed store data --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ae41fb7..4092b09 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist .env.* *.log .DS_Store +.agentpay-store/ +.agentpay-store-test-*/ From 8f43864be40de6c2d52309160d0fe2ea71549d4f Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:02:12 -0400 Subject: [PATCH 2/6] docs: document persistence adapter --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index fa721c7..4a62111 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,30 @@ API gateway, metering, and billing backend for the AgentPay protocol (machine-to ``` Server runs at `http://localhost:3001`. Try `GET /health` and `GET /api/v1/version`. +## Persistence + +AgentPay uses in-memory stores by default: + +```bash +STORAGE_DRIVER=memory +``` + +To keep usage counters, registered services, disabled-service flags, service +metadata, API keys, and webhooks across process restarts, enable the file +storage driver: + +```bash +STORAGE_DRIVER=file STORAGE_PATH=.agentpay-store npm start +``` + +`STORAGE_PATH` must resolve inside the project directory. The file driver writes +one JSON file per store and replaces files atomically via a temporary file. Store +contents are flushed on every write and again during the SIGTERM/SIGINT graceful +shutdown path. + +The on-disk files contain application state, including API key records. Keep the +storage directory private and out of version control. + ## Project structure ``` From fc7eea12a53dbe8c4e7d70f52ca259b1c8d715ac Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:02:29 -0400 Subject: [PATCH 3/6] feat: flush stores on shutdown --- src/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 9ece0f2..d08fda1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { createMetricsRouter } from "./routes/metrics.js"; import { createServicesRouter } from "./routes/services.js"; import { createUsageRouter } from "./routes/usage.js"; import { createWebhooksRouter } from "./routes/webhooks.js"; +import { flushStores } from "./store/state.js"; const PORT = process.env.PORT ?? 3001; @@ -50,12 +51,18 @@ if (process.argv[1]?.endsWith("index.js") || process.argv[1]?.endsWith("index.ts }); const shutdown = (signal: string) => { - console.log(`Received ${signal}, draining…`); + console.log(`Received ${signal}, draining...`); server.close((err) => { if (err) { console.error("server.close error:", err); process.exit(1); } + try { + flushStores(); + } catch (flushErr) { + console.error("store flush error:", flushErr); + process.exit(1); + } process.exit(0); }); setTimeout(() => { From 950c0da66980198ab22bf4e37a25c82705e9b2b6 Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:02:39 -0400 Subject: [PATCH 4/6] feat: wire stores through persistence layer --- src/store/state.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/store/state.ts b/src/store/state.ts index 6c573f4..7ca2b23 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -1,8 +1,13 @@ +import { createStoreMap, createStoreSet, flushStores } from "./index.js"; + +export { flushStores }; + /** - * Mutable process-local stores used by the in-memory AgentPay API. + * Mutable stores used by the AgentPay API. * - * These exports preserve the existing development behavior: state lives only - * for the lifetime of the Node process and resets on restart. + * The default storage driver is in-memory. Set STORAGE_DRIVER=file to back + * service, usage, API-key, metadata, disabled-service, and webhook stores with + * JSON files that survive process restarts. */ export type ApiKeyRecord = { label: string; createdAt: number }; @@ -21,25 +26,25 @@ export const config: Record = { }; /** Opaque API keys keyed by full secret token. */ -export const apiKeyStore = new Map(); +export const apiKeyStore = createStoreMap("api-keys"); /** Outstanding usage counters keyed by `${agent}::${serviceId}`. */ -export const usageStore = new Map(); +export const usageStore = createStoreMap("usage"); /** Builds the shared in-memory usage key for an agent/service pair. */ export const usageKey = (agent: string, serviceId: string) => `${agent}::${serviceId}`; /** Registered services and their per-request prices. */ -export const servicesStore = new Map(); +export const servicesStore = createStoreMap<{ priceStroops: number }>("services"); /** Services currently disabled for write traffic. */ -export const servicesDisabled = new Set(); +export const servicesDisabled = createStoreSet("services-disabled"); /** Optional service description/owner metadata. */ -export const servicesMetadata = new Map(); +export const servicesMetadata = createStoreMap("services-metadata"); /** Registered webhooks and their event subscriptions. */ -export const webhookStore = new Map(); +export const webhookStore = createStoreMap("webhooks"); /** Rate-limiter windows keyed by source IP. */ export const rateBuckets = new Map(); From 6b1c4cd04262374cfbd31eaf69877ff9bdd91080 Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:02:57 -0400 Subject: [PATCH 5/6] feat: add persistence store implementations --- src/store/index.ts | 237 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 src/store/index.ts diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..6ff07e9 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,237 @@ +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +export interface Store { + /** Return the value for a key, if present. */ + get(key: string): V | undefined; + /** Persist a value under a key. */ + set(key: string, value: V): void; + /** Remove a key and return whether it existed. */ + delete(key: string): boolean; + /** Iterate every key/value pair in insertion order. */ + entries(): IterableIterator<[string, V]>; + /** Iterate key/value pairs whose key starts with the supplied prefix. */ + scanByPrefix(prefix: string): IterableIterator<[string, V]>; + /** Remove all entries. */ + clear(): void; + /** Flush any buffered state to durable storage. */ + flush(): void; +} + +type Flushable = { flush(): void }; + +const flushables: Flushable[] = []; + +export class InMemoryStore implements Store { + protected readonly data = new Map(); + + get(key: string) { + return this.data.get(key); + } + + set(key: string, value: V) { + this.data.set(key, value); + } + + delete(key: string) { + return this.data.delete(key); + } + + entries() { + return this.data.entries(); + } + + *scanByPrefix(prefix: string) { + for (const [key, value] of this.data.entries()) { + if (key.startsWith(prefix)) yield [key, value] as [string, V]; + } + } + + clear() { + this.data.clear(); + } + + flush() { + // In-memory stores have nothing to flush. + } +} + +type FilePayload = { + version: 1; + entries: [string, V][]; +}; + +const safeStoreName = /^[A-Za-z0-9._-]+$/; + +const resolveStorageDir = (rawDir: string) => { + const base = process.cwd(); + const resolved = path.resolve(base, rawDir); + const relative = path.relative(base, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("STORAGE_PATH must stay inside the project directory"); + } + return resolved; +}; + +export class JsonFileStore extends InMemoryStore { + private dirty = false; + private readonly filePath: string; + private readonly dirPath: string; + + constructor(storeName: string, storageDir: string) { + super(); + if (!safeStoreName.test(storeName)) { + throw new Error(`invalid store name: ${storeName}`); + } + this.dirPath = resolveStorageDir(storageDir); + this.filePath = path.join(this.dirPath, `${storeName}.json`); + this.load(); + } + + override set(key: string, value: V) { + super.set(key, value); + this.dirty = true; + this.flush(); + } + + override delete(key: string) { + const deleted = super.delete(key); + if (deleted) { + this.dirty = true; + this.flush(); + } + return deleted; + } + + override clear() { + super.clear(); + this.dirty = true; + this.flush(); + } + + override flush() { + if (!this.dirty) return; + mkdirSync(this.dirPath, { recursive: true }); + const payload: FilePayload = { + version: 1, + entries: Array.from(this.entries()), + }; + const tempPath = `${this.filePath}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + renameSync(tempPath, this.filePath); + this.dirty = false; + } + + private load() { + if (!existsSync(this.filePath)) return; + const raw = readFileSync(this.filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial>; + if (parsed.version !== 1 || !Array.isArray(parsed.entries)) { + throw new Error(`invalid storage file format: ${this.filePath}`); + } + for (const [key, value] of parsed.entries) { + if (typeof key !== "string") { + throw new Error(`invalid storage key in ${this.filePath}`); + } + this.data.set(key, value); + } + } +} + +export class StoreMap extends Map { + constructor(private readonly backend: Store) { + super(); + for (const [key, value] of backend.entries()) { + super.set(key, value); + } + } + + override set(key: string, value: V) { + super.set(key, value); + this.backend.set(key, value); + return this; + } + + override delete(key: string) { + const deleted = super.delete(key); + const backendDeleted = this.backend.delete(key); + return deleted || backendDeleted; + } + + override clear() { + super.clear(); + this.backend.clear(); + } + + scanByPrefix(prefix: string) { + return this.backend.scanByPrefix(prefix); + } + + flush() { + this.backend.flush(); + } +} + +export class StoreSet extends Set { + constructor(private readonly backend: Store) { + super(); + for (const [key, enabled] of backend.entries()) { + if (enabled) super.add(key); + } + } + + override add(value: string) { + super.add(value); + this.backend.set(value, true); + return this; + } + + override delete(value: string) { + const deleted = super.delete(value); + const backendDeleted = this.backend.delete(value); + return deleted || backendDeleted; + } + + override clear() { + super.clear(); + this.backend.clear(); + } + + flush() { + this.backend.flush(); + } +} + +const createBackend = (storeName: string): Store => { + const driver = (process.env.STORAGE_DRIVER ?? "memory").toLowerCase(); + if (driver === "memory") return new InMemoryStore(); + if (driver === "file") { + return new JsonFileStore( + storeName, + process.env.STORAGE_PATH ?? ".agentpay-store" + ); + } + throw new Error("STORAGE_DRIVER must be either memory or file"); +}; + +export const createStoreMap = (storeName: string) => { + const store = new StoreMap(createBackend(storeName)); + flushables.push(store); + return store; +}; + +export const createStoreSet = (storeName: string) => { + const store = new StoreSet(createBackend(storeName)); + flushables.push(store); + return store; +}; + +export const flushStores = () => { + for (const store of flushables) store.flush(); +}; From 54300a127a68066fe51da590e552af88f931109a Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:03:09 -0400 Subject: [PATCH 6/6] test: cover persistence store behavior --- src/store/store.test.ts | 112 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/store/store.test.ts diff --git a/src/store/store.test.ts b/src/store/store.test.ts new file mode 100644 index 0000000..76d47a0 --- /dev/null +++ b/src/store/store.test.ts @@ -0,0 +1,112 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { InMemoryStore, JsonFileStore, StoreMap, StoreSet } from "./index.js"; + +let tempRoot = ""; + +const relativeTempDir = () => path.relative(process.cwd(), tempRoot); + +beforeEach(() => { + tempRoot = mkdtempSync(path.join(process.cwd(), ".agentpay-store-test-")); +}); + +afterEach(() => { + if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = ""; +}); + +void describe("store adapters", () => { + void it("supports in-memory get, set, delete, entries, and prefix scans", () => { + const store = new InMemoryStore(); + + store.set("agent-a::svc-1", 3); + store.set("agent-a::svc-2", 4); + store.set("agent-b::svc-1", 5); + + assert.strictEqual(store.get("agent-a::svc-1"), 3); + assert.deepStrictEqual(Array.from(store.scanByPrefix("agent-a::")), [ + ["agent-a::svc-1", 3], + ["agent-a::svc-2", 4], + ]); + assert.strictEqual(store.delete("agent-b::svc-1"), true); + assert.deepStrictEqual(Array.from(store.entries()), [ + ["agent-a::svc-1", 3], + ["agent-a::svc-2", 4], + ]); + }); + + void it("round-trips JSON file state across store instances", () => { + const first = new JsonFileStore("usage", relativeTempDir()); + first.set("agent-a::svc-1", 7); + first.set("agent-b::svc-2", 9); + first.flush(); + + const second = new JsonFileStore("usage", relativeTempDir()); + assert.strictEqual(second.get("agent-a::svc-1"), 7); + assert.deepStrictEqual(Array.from(second.scanByPrefix("agent-b::")), [ + ["agent-b::svc-2", 9], + ]); + }); + + void it("starts empty when the JSON file is missing", () => { + const store = new JsonFileStore("missing", relativeTempDir()); + assert.deepStrictEqual(Array.from(store.entries()), []); + }); + + void it("fails closed on corrupt JSON files", () => { + writeFileSync(path.join(tempRoot, "corrupt.json"), "{not-json", "utf8"); + + assert.throws( + () => new JsonFileStore("corrupt", relativeTempDir()), + SyntaxError + ); + }); + + void it("rejects storage paths outside the project directory", () => { + assert.throws( + () => new JsonFileStore("usage", os.tmpdir()), + /STORAGE_PATH/ + ); + }); + + void it("keeps StoreMap writes, deletes, and clears durable", () => { + const map = new StoreMap( + new JsonFileStore<{ priceStroops: number }>("services", relativeTempDir()) + ); + + map.set("svc-a", { priceStroops: 12 }); + assert.strictEqual(map.get("svc-a")?.priceStroops, 12); + map.delete("svc-a"); + map.set("svc-b", { priceStroops: 20 }); + + let reloaded = new StoreMap( + new JsonFileStore<{ priceStroops: number }>("services", relativeTempDir()) + ); + assert.strictEqual(reloaded.has("svc-a"), false); + assert.strictEqual(reloaded.get("svc-b")?.priceStroops, 20); + + reloaded.clear(); + reloaded = new StoreMap( + new JsonFileStore<{ priceStroops: number }>("services", relativeTempDir()) + ); + assert.strictEqual(reloaded.size, 0); + }); + + void it("keeps StoreSet writes and deletes durable", () => { + const set = new StoreSet( + new JsonFileStore("services-disabled", relativeTempDir()) + ); + + set.add("svc-a"); + set.add("svc-b"); + set.delete("svc-a"); + + const reloaded = new StoreSet( + new JsonFileStore("services-disabled", relativeTempDir()) + ); + assert.deepStrictEqual(Array.from(reloaded), ["svc-b"]); + }); +});