diff --git a/.gitignore b/.gitignore index 19837500..fc4b565f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ yarn-error.log* .DS_Store *.pem .env*.local +.dev.vars +.wrangler/ .gstack/ diff --git a/apps/demo/package.json b/apps/demo/package.json index 622400dc..c0683551 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -16,7 +16,7 @@ "push:sandbox": "bun scripts/push-sandbox.ts", "start": "next start", "tunnel": "set -a; [ -f .env ] && . ./.env; set +a; if [ -n \"$CF_TUNNEL_ID\" ]; then cloudflared tunnel --url http://localhost:3000 run \"$CF_TUNNEL_ID\"; fi", - "paykitjs": "tsx --conditions=paykit-source ../../packages/paykit/src/cli/index.ts", + "paykitjs": "bun ../../packages/paykit/src/cli/index.ts", "typecheck": "tsc --noEmit", "push": "bun push:auth && bun push:paykit:polar && bun push:paykit:stripe && bun push:autumn", "push:auth": "bunx auth migrate --config src/lib/auth.ts --yes", diff --git a/apps/wh/drizzle.config.ts b/apps/wh/drizzle.config.ts new file mode 100644 index 00000000..400f5a19 --- /dev/null +++ b/apps/wh/drizzle.config.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { defineConfig } from "drizzle-kit"; + +const d1StateDir = path.resolve(process.cwd(), ".wrangler/state/v3/d1/miniflare-D1DatabaseObject"); +const isGenerateCommand = process.argv.includes("generate"); + +function resolveLocalSqliteFile(): string { + const files = fs + .readdirSync(d1StateDir) + .filter((file) => file.endsWith(".sqlite") && file !== "metadata.sqlite") + .toSorted(); + + const file = files[0]; + if (!file) { + throw new Error( + "No local D1 SQLite database found. Run `bun --filter wh db:migrate:local` first.", + ); + } + + return path.join(d1StateDir, file); +} + +export default defineConfig({ + dialect: "sqlite", + out: "./migrations", + schema: "./src/db/schema.ts", + dbCredentials: { + get url() { + return isGenerateCommand ? ":memory:" : resolveLocalSqliteFile(); + }, + }, +}); diff --git a/apps/wh/migrations/0000_init.sql b/apps/wh/migrations/0000_init.sql new file mode 100644 index 00000000..e3bfb124 --- /dev/null +++ b/apps/wh/migrations/0000_init.sql @@ -0,0 +1,33 @@ +CREATE TABLE `tunnel` ( + `id` text PRIMARY KEY NOT NULL, + `device_token_hash` text NOT NULL, + `provider_id` text NOT NULL, + `environment` text NOT NULL, + `provider_account_id` text NOT NULL, + `provider_webhook_endpoint_id` text, + `status` text NOT NULL DEFAULT 'active', + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `last_seen_at` integer NOT NULL, + `disabled_at` integer +); + +CREATE UNIQUE INDEX `tunnel_device_provider_unique` + ON `tunnel` (`device_token_hash`, `provider_id`, `environment`, `provider_account_id`); + +CREATE INDEX `tunnel_device_idx` ON `tunnel` (`device_token_hash`); + +CREATE TABLE `delivery` ( + `id` text PRIMARY KEY NOT NULL, + `tunnel_id` text NOT NULL, + `method` text NOT NULL, + `headers` text NOT NULL, + `body` text NOT NULL, + `status` text NOT NULL DEFAULT 'pending', + `received_at` integer NOT NULL, + `delivered_at` integer, + FOREIGN KEY (`tunnel_id`) REFERENCES `tunnel`(`id`) ON DELETE cascade +); + +CREATE INDEX `delivery_tunnel_status_received_idx` + ON `delivery` (`tunnel_id`, `status`, `received_at`); diff --git a/apps/wh/migrations/0001_delivery-failure-tracking.sql b/apps/wh/migrations/0001_delivery-failure-tracking.sql new file mode 100644 index 00000000..6b2d6ffd --- /dev/null +++ b/apps/wh/migrations/0001_delivery-failure-tracking.sql @@ -0,0 +1,5 @@ +DROP INDEX `delivery_tunnel_status_received_idx`;--> statement-breakpoint +ALTER TABLE `delivery` ADD `failed_at` integer;--> statement-breakpoint +ALTER TABLE `delivery` ADD `error` text;--> statement-breakpoint +CREATE INDEX `delivery_tunnel_delivery_idx` ON `delivery` (`tunnel_id`,`delivered_at`,`failed_at`,`received_at`);--> statement-breakpoint +ALTER TABLE `delivery` DROP COLUMN `status`; diff --git a/apps/wh/migrations/meta/0000_snapshot.json b/apps/wh/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..07c4fad4 --- /dev/null +++ b/apps/wh/migrations/meta/0000_snapshot.json @@ -0,0 +1,215 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "09df25c0-d0eb-49e5-8c78-c5cac3323bd4", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "delivery": { + "name": "delivery", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "delivery_tunnel_status_received_idx": { + "name": "delivery_tunnel_status_received_idx", + "columns": [ + "tunnel_id", + "status", + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "delivery_tunnel_id_tunnel_id_fk": { + "name": "delivery_tunnel_id_tunnel_id_fk", + "tableFrom": "delivery", + "tableTo": "tunnel", + "columnsFrom": [ + "tunnel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tunnel": { + "name": "tunnel", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_token_hash": { + "name": "device_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_webhook_endpoint_id": { + "name": "provider_webhook_endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tunnel_device_provider_unique": { + "name": "tunnel_device_provider_unique", + "columns": [ + "device_token_hash", + "provider_id", + "environment", + "provider_account_id" + ], + "isUnique": true + }, + "tunnel_device_idx": { + "name": "tunnel_device_idx", + "columns": [ + "device_token_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/wh/migrations/meta/0001_snapshot.json b/apps/wh/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..870bdacb --- /dev/null +++ b/apps/wh/migrations/meta/0001_snapshot.json @@ -0,0 +1,222 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "617459ed-59ef-4877-b22e-cad7fe84b2f4", + "prevId": "09df25c0-d0eb-49e5-8c78-c5cac3323bd4", + "tables": { + "delivery": { + "name": "delivery", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failed_at": { + "name": "failed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "delivery_tunnel_delivery_idx": { + "name": "delivery_tunnel_delivery_idx", + "columns": [ + "tunnel_id", + "delivered_at", + "failed_at", + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "delivery_tunnel_id_tunnel_id_fk": { + "name": "delivery_tunnel_id_tunnel_id_fk", + "tableFrom": "delivery", + "tableTo": "tunnel", + "columnsFrom": [ + "tunnel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tunnel": { + "name": "tunnel", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_token_hash": { + "name": "device_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_webhook_endpoint_id": { + "name": "provider_webhook_endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tunnel_device_provider_unique": { + "name": "tunnel_device_provider_unique", + "columns": [ + "device_token_hash", + "provider_id", + "environment", + "provider_account_id" + ], + "isUnique": true + }, + "tunnel_device_idx": { + "name": "tunnel_device_idx", + "columns": [ + "device_token_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/wh/migrations/meta/_journal.json b/apps/wh/migrations/meta/_journal.json new file mode 100644 index 00000000..c05b2a6e --- /dev/null +++ b/apps/wh/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1778238077949, + "tag": "0000_keen_bastion", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1778238601783, + "tag": "0001_delivery-failure-tracking", + "breakpoints": true + } + ] +} diff --git a/apps/wh/package.json b/apps/wh/package.json new file mode 100644 index 00000000..d1e5a0e2 --- /dev/null +++ b/apps/wh/package.json @@ -0,0 +1,27 @@ +{ + "name": "wh", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev --local", + "deploy": "if [ -z \"$PAYKIT_WEBHOOK_API_BASE_URL\" ]; then printf '%s\n' 'Missing PAYKIT_WEBHOOK_API_BASE_URL'; exit 1; fi; wrangler deploy --var \"PAYKIT_WEBHOOK_API_BASE_URL:$PAYKIT_WEBHOOK_API_BASE_URL\"", + "db:migrate:local": "wrangler d1 migrations apply paykit-wh --local", + "db:migrate:remote": "wrangler d1 migrations apply paykit-wh --remote", + "db:studio": "drizzle-kit studio --config drizzle.config.ts", + "ship": "bun run db:migrate:remote && bun run deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "drizzle-orm": "^0.45.1", + "hono": "^4.10.1" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260503.1", + "better-sqlite3": "^11.10.0", + "drizzle-kit": "^0.31.5", + "typescript": "catalog:", + "wrangler": "^4.42.1" + }, + "packageManager": "bun@1.3.13" +} diff --git a/apps/wh/src/db/schema.ts b/apps/wh/src/db/schema.ts new file mode 100644 index 00000000..01928af2 --- /dev/null +++ b/apps/wh/src/db/schema.ts @@ -0,0 +1,64 @@ +import { relations } from "drizzle-orm"; +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +export const tunnel = sqliteTable( + "tunnel", + { + id: text("id").primaryKey(), + deviceTokenHash: text("device_token_hash").notNull(), + providerId: text("provider_id").notNull(), + environment: text("environment").notNull(), + providerAccountId: text("provider_account_id").notNull(), + providerWebhookEndpointId: text("provider_webhook_endpoint_id"), + status: text("status").notNull().default("active"), + createdAt: integer("created_at", { mode: "number" }).notNull(), + updatedAt: integer("updated_at", { mode: "number" }).notNull(), + lastSeenAt: integer("last_seen_at", { mode: "number" }).notNull(), + disabledAt: integer("disabled_at", { mode: "number" }), + }, + (table) => [ + uniqueIndex("tunnel_device_provider_unique").on( + table.deviceTokenHash, + table.providerId, + table.environment, + table.providerAccountId, + ), + index("tunnel_device_idx").on(table.deviceTokenHash), + ], +); + +export const delivery = sqliteTable( + "delivery", + { + id: text("id").primaryKey(), + tunnelId: text("tunnel_id") + .notNull() + .references(() => tunnel.id, { onDelete: "cascade" }), + method: text("method").notNull(), + headers: text("headers", { mode: "json" }).$type>().notNull(), + body: text("body").notNull(), + receivedAt: integer("received_at", { mode: "number" }).notNull(), + deliveredAt: integer("delivered_at", { mode: "number" }), + failedAt: integer("failed_at", { mode: "number" }), + error: text("error"), + }, + (table) => [ + index("delivery_tunnel_delivery_idx").on( + table.tunnelId, + table.deliveredAt, + table.failedAt, + table.receivedAt, + ), + ], +); + +export const tunnelRelations = relations(tunnel, ({ many }) => ({ + deliveries: many(delivery), +})); + +export const deliveryRelations = relations(delivery, ({ one }) => ({ + tunnel: one(tunnel, { + fields: [delivery.tunnelId], + references: [tunnel.id], + }), +})); diff --git a/apps/wh/src/index.ts b/apps/wh/src/index.ts new file mode 100644 index 00000000..e298b483 --- /dev/null +++ b/apps/wh/src/index.ts @@ -0,0 +1,552 @@ +import { and, asc, count, eq, gte, isNull, lt, or, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/d1"; +import { Hono, type Context } from "hono"; +import { HTTPException } from "hono/http-exception"; + +import { delivery, tunnel } from "./db/schema"; + +interface Bindings { + DB: D1Database; + MAX_BODY_BYTES: string; + MAX_DELIVERIES_PER_TUNNEL: string; + PAYKIT_WEBHOOK_API_BASE_URL?: string; + RETENTION_DAYS: string; +} + +const app = new Hono<{ Bindings: Bindings }>(); +type AppContext = Context<{ Bindings: Bindings }>; + +function getDb(env: Bindings) { + return drizzle(env.DB); +} + +function now(): number { + return Date.now(); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function getNumericVar(value: string, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function getRequiredWebhookBaseUrl(env: Bindings): string { + const baseUrl = env.PAYKIT_WEBHOOK_API_BASE_URL?.trim(); + if (!baseUrl) { + throw new Error("PAYKIT_WEBHOOK_API_BASE_URL is required"); + } + + return baseUrl.replace(/\/$/, ""); +} + +function generateId(prefix: string): string { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + let suffix = ""; + for (const byte of bytes) { + suffix += alphabet[byte % alphabet.length]; + } + return `${prefix}_${suffix}`; +} + +async function hashToken(token: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function requireDeviceTokenHash(c: AppContext) { + const authHeader = c.req.header("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + throw new HTTPException(401, { message: "Missing bearer token" }); + } + + const token = authHeader.slice("Bearer ".length).trim(); + if (!token) { + throw new HTTPException(401, { message: "Missing bearer token" }); + } + + return hashToken(token); +} + +function getWebhookUrl(params: { env: Bindings; tunnelId: string }): string { + const baseUrl = getRequiredWebhookBaseUrl(params.env); + return `${baseUrl}/${params.tunnelId}`; +} + +function getRequestHeaders(request: Request): Record { + const headers: Record = {}; + request.headers.forEach((value, key) => { + headers[key] = value; + }); + return headers; +} + +async function getOwnedTunnel(params: { + db: ReturnType; + deviceTokenHash: string; + tunnelId: string; +}) { + const rows = await params.db + .select() + .from(tunnel) + .where(and(eq(tunnel.id, params.tunnelId), eq(tunnel.deviceTokenHash, params.deviceTokenHash))) + .limit(1); + + return rows[0] ?? null; +} + +function readNumberParam(value: string | undefined, fallback: number): number { + if (value === undefined) { + return fallback; + } + + const parsed = Number(value); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function readOptionalNumberParam(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + + const parsed = Number(value); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function buildPullableDeliveryWhere(params: { + includeFailedBefore?: number; + retryWindowMs: number; + tunnelId: string; +}) { + const conditions = [ + eq(delivery.tunnelId, params.tunnelId), + isNull(delivery.deliveredAt), + isNull(delivery.failedAt), + ]; + + if (params.retryWindowMs > 0 && typeof params.includeFailedBefore === "number") { + conditions[2] = or( + isNull(delivery.failedAt), + and( + lt(delivery.failedAt, params.includeFailedBefore), + gte(delivery.receivedAt, now() - params.retryWindowMs), + ), + )!; + } + + return and(...conditions); +} + +async function getPullableCount( + db: ReturnType, + params: { includeFailedBefore?: number; retryWindowMs: number; tunnelId: string }, +): Promise { + const rows = await db + .select({ count: count() }) + .from(delivery) + .where(buildPullableDeliveryWhere(params)); + return rows[0]?.count ?? 0; +} + +async function pruneDeliveries(params: { + db: ReturnType; + env: Bindings; + tunnelId: string; +}) { + const retentionDays = getNumericVar(params.env.RETENTION_DAYS, 30); + const maxDeliveries = getNumericVar(params.env.MAX_DELIVERIES_PER_TUNNEL, 5000); + const cutoff = now() - retentionDays * 24 * 60 * 60 * 1000; + + await params.db + .delete(delivery) + .where(and(eq(delivery.tunnelId, params.tunnelId), lt(delivery.receivedAt, cutoff))); + + const rows = await params.db + .select({ count: count() }) + .from(delivery) + .where(eq(delivery.tunnelId, params.tunnelId)); + const overflow = (rows[0]?.count ?? 0) - maxDeliveries; + + if (overflow > 0) { + await params.db.run(sql` + delete from delivery + where id in ( + select id from delivery + where tunnel_id = ${params.tunnelId} + order by received_at asc, id asc + limit ${overflow} + ) + `); + } +} + +app.get("/api/health", (c) => c.json({ ok: true })); + +app.post("/api/tunnels/ensure", async (c) => { + const deviceTokenHash = await requireDeviceTokenHash(c); + const db = getDb(c.env); + const body = (await c.req.json()) as { + createIfMissing?: boolean; + environment?: string; + includeFailedBefore?: number; + providerAccountId?: string; + providerId?: string; + retryWindowMs?: number; + }; + + if (!body.providerId || !body.providerAccountId || !body.environment) { + return c.text("providerId, providerAccountId, and environment are required", 400); + } + + const retryWindowMs = Math.max(0, readNumberParam(String(body.retryWindowMs ?? "0"), 0)); + const includeFailedBefore = + typeof body.includeFailedBefore === "number" && !Number.isNaN(body.includeFailedBefore) + ? body.includeFailedBefore + : undefined; + + const createIfMissing = body.createIfMissing !== false; + const existing = await db + .select() + .from(tunnel) + .where( + and( + eq(tunnel.deviceTokenHash, deviceTokenHash), + eq(tunnel.providerId, body.providerId), + eq(tunnel.environment, body.environment), + eq(tunnel.providerAccountId, body.providerAccountId), + ), + ) + .limit(1); + + const current = existing[0]; + if (!current) { + if (!createIfMissing) { + return c.json({ found: false }); + } + + const tunnelId = generateId("ep"); + const timestamp = now(); + await db.insert(tunnel).values({ + createdAt: timestamp, + deviceTokenHash, + environment: body.environment, + id: tunnelId, + lastSeenAt: timestamp, + providerAccountId: body.providerAccountId, + providerId: body.providerId, + status: "active", + updatedAt: timestamp, + }); + + return c.json({ + found: true, + pendingCount: 0, + providerWebhookEndpointId: null, + tunnelId, + webhookUrl: getWebhookUrl({ env: c.env, tunnelId }), + }); + } + + const timestamp = now(); + await db + .update(tunnel) + .set({ + disabledAt: createIfMissing ? null : current.disabledAt, + lastSeenAt: timestamp, + status: createIfMissing ? "active" : current.status, + updatedAt: timestamp, + }) + .where(eq(tunnel.id, current.id)); + + return c.json({ + found: true, + pendingCount: await getPullableCount(db, { + includeFailedBefore, + retryWindowMs, + tunnelId: current.id, + }), + providerWebhookEndpointId: current.providerWebhookEndpointId, + tunnelId: current.id, + webhookUrl: getWebhookUrl({ env: c.env, tunnelId: current.id }), + }); +}); + +app.get("/api/tunnels/:tunnelId/welcome", async (c) => { + const deviceTokenHash = await requireDeviceTokenHash(c); + const db = getDb(c.env); + const current = await getOwnedTunnel({ + db, + deviceTokenHash, + tunnelId: c.req.param("tunnelId"), + }); + + if (!current) { + return c.text("Tunnel not found", 404); + } + + if (current.status === "disabled") { + return c.text("Tunnel disabled", 410); + } + + const retryWindowMs = Math.max(0, readNumberParam(c.req.query("retryWindowMs"), 0)); + const includeFailedBefore = readOptionalNumberParam(c.req.query("includeFailedBefore")); + + return c.json({ + pendingCount: await getPullableCount(db, { + includeFailedBefore, + retryWindowMs, + tunnelId: current.id, + }), + tunnelId: current.id, + }); +}); + +app.post("/api/tunnels/:tunnelId/provider-webhook", async (c) => { + const deviceTokenHash = await requireDeviceTokenHash(c); + const db = getDb(c.env); + const current = await getOwnedTunnel({ + db, + deviceTokenHash, + tunnelId: c.req.param("tunnelId"), + }); + + if (!current) { + return c.text("Tunnel not found", 404); + } + + if (current.status === "disabled") { + return c.text("Tunnel disabled", 410); + } + + const body = (await c.req.json()) as { providerWebhookEndpointId?: string }; + if (!body.providerWebhookEndpointId) { + return c.text("providerWebhookEndpointId is required", 400); + } + + await db + .update(tunnel) + .set({ providerWebhookEndpointId: body.providerWebhookEndpointId, updatedAt: now() }) + .where(eq(tunnel.id, current.id)); + + return c.json({ ok: true }); +}); + +app.get("/api/tunnels/:tunnelId/pull", async (c) => { + const deviceTokenHash = await requireDeviceTokenHash(c); + const db = getDb(c.env); + const current = await getOwnedTunnel({ + db, + deviceTokenHash, + tunnelId: c.req.param("tunnelId"), + }); + + if (!current) { + return c.text("Tunnel not found", 404); + } + + if (current.status === "disabled") { + return c.text("Tunnel disabled", 410); + } + + const limit = clamp(readNumberParam(c.req.query("limit"), 30), 1, 100); + const offset = clamp(readNumberParam(c.req.query("offset"), 0), 0, 10_000); + const retryWindowMs = Math.max(0, readNumberParam(c.req.query("retryWindowMs"), 0)); + const includeFailedBefore = readOptionalNumberParam(c.req.query("includeFailedBefore")); + const deliveries = await db + .select() + .from(delivery) + .where( + buildPullableDeliveryWhere({ + includeFailedBefore, + retryWindowMs, + tunnelId: current.id, + }), + ) + .orderBy(asc(delivery.receivedAt), asc(delivery.id)) + .limit(limit) + .offset(offset); + + return c.json({ + deliveries: deliveries.map((item) => ({ + body: item.body, + headers: item.headers, + id: item.id, + method: item.method, + receivedAt: new Date(item.receivedAt).toISOString(), + })), + }); +}); + +app.get("/api/deliveries/:deliveryId", async (c) => { + const deviceTokenHash = await requireDeviceTokenHash(c); + const db = getDb(c.env); + const rows = await db + .select() + .from(delivery) + .where(eq(delivery.id, c.req.param("deliveryId"))) + .limit(1); + const currentDelivery = rows[0]; + + if (!currentDelivery) { + return c.text("Delivery not found", 404); + } + + const currentTunnel = await getOwnedTunnel({ + db, + deviceTokenHash, + tunnelId: currentDelivery.tunnelId, + }); + if (!currentTunnel) { + return c.text("Delivery not found", 404); + } + + if (currentTunnel.status === "disabled") { + return c.text("Tunnel disabled", 410); + } + + return c.json({ + body: currentDelivery.body, + deliveredAt: currentDelivery.deliveredAt, + failedAt: currentDelivery.failedAt, + headers: currentDelivery.headers, + id: currentDelivery.id, + method: currentDelivery.method, + receivedAt: new Date(currentDelivery.receivedAt).toISOString(), + }); +}); + +app.post("/api/deliveries/:deliveryId/ack", async (c) => { + const deviceTokenHash = await requireDeviceTokenHash(c); + const db = getDb(c.env); + const rows = await db + .select({ id: delivery.id, tunnelId: delivery.tunnelId }) + .from(delivery) + .where(eq(delivery.id, c.req.param("deliveryId"))) + .limit(1); + + const currentDelivery = rows[0]; + if (!currentDelivery) { + return c.text("Delivery not found", 404); + } + + const currentTunnel = await getOwnedTunnel({ + db, + deviceTokenHash, + tunnelId: currentDelivery.tunnelId, + }); + if (!currentTunnel) { + return c.text("Delivery not found", 404); + } + + if (currentTunnel.status === "disabled") { + return c.text("Tunnel disabled", 410); + } + + await db + .update(delivery) + .set({ deliveredAt: now(), error: null, failedAt: null }) + .where(eq(delivery.id, currentDelivery.id)); + + return c.json({ ok: true }); +}); + +app.post("/api/deliveries/:deliveryId/fail", async (c) => { + const deviceTokenHash = await requireDeviceTokenHash(c); + const db = getDb(c.env); + const rows = await db + .select() + .from(delivery) + .where(eq(delivery.id, c.req.param("deliveryId"))) + .limit(1); + const currentDelivery = rows[0]; + + if (!currentDelivery) { + return c.text("Delivery not found", 404); + } + + const currentTunnel = await getOwnedTunnel({ + db, + deviceTokenHash, + tunnelId: currentDelivery.tunnelId, + }); + if (!currentTunnel) { + return c.text("Delivery not found", 404); + } + + if (currentTunnel.status === "disabled") { + return c.text("Tunnel disabled", 410); + } + + const body = (await c.req.json()) as { error?: string }; + await db + .update(delivery) + .set({ error: body.error ?? null, failedAt: now() }) + .where(eq(delivery.id, currentDelivery.id)); + + return c.json({ ok: true }); +}); + +app.post("/api/tunnels/:tunnelId/disable", async (c) => { + const deviceTokenHash = await requireDeviceTokenHash(c); + const db = getDb(c.env); + const current = await getOwnedTunnel({ + db, + deviceTokenHash, + tunnelId: c.req.param("tunnelId"), + }); + + if (!current) { + return c.text("Tunnel not found", 404); + } + + const timestamp = now(); + await db + .update(tunnel) + .set({ disabledAt: timestamp, status: "disabled", updatedAt: timestamp }) + .where(eq(tunnel.id, current.id)); + + return c.json({ ok: true }); +}); + +app.post("/:tunnelId", async (c) => { + const db = getDb(c.env); + const current = await db + .select() + .from(tunnel) + .where(eq(tunnel.id, c.req.param("tunnelId"))) + .limit(1); + const currentTunnel = current[0]; + + if (!currentTunnel) { + return c.text("Not found", 404); + } + + if (currentTunnel.status !== "active") { + return c.text("Tunnel disabled", 410); + } + + const body = await c.req.text(); + const bodyBytes = new TextEncoder().encode(body).byteLength; + if (bodyBytes > getNumericVar(c.env.MAX_BODY_BYTES, 262_144)) { + return c.text("Payload too large", 413); + } + + await db.insert(delivery).values({ + body, + error: null, + failedAt: null, + headers: getRequestHeaders(c.req.raw), + id: generateId("del"), + method: c.req.method, + receivedAt: now(), + tunnelId: currentTunnel.id, + }); + await pruneDeliveries({ db, env: c.env, tunnelId: currentTunnel.id }); + + return c.json({ received: true }); +}); + +export default app; diff --git a/apps/wh/tsconfig.json b/apps/wh/tsconfig.json new file mode 100644 index 00000000..19b665fd --- /dev/null +++ b/apps/wh/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "emitDeclarationOnly": false, + "lib": ["esnext"], + "noEmit": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/wh/wrangler.jsonc b/apps/wh/wrangler.jsonc new file mode 100644 index 00000000..a3562e8f --- /dev/null +++ b/apps/wh/wrangler.jsonc @@ -0,0 +1,18 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "paykit-wh", + "main": "src/index.ts", + "compatibility_date": "2026-05-03", + "d1_databases": [ + { + "binding": "DB", + "database_name": "paykit-wh", + "database_id": "1d9fdd66-9eb5-43fe-ace2-843abce58bf4" + } + ], + "vars": { + "MAX_BODY_BYTES": "262144", + "MAX_DELIVERIES_PER_TUNNEL": "5000", + "RETENTION_DAYS": "30" + } +} diff --git a/bun.lock b/bun.lock index fb02160f..da4b5a74 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "oxfmt": "^0.36.0", "oxlint": "^1.51.0", "tinyglobby": "^0.2.16", - "tsx": "^4.21.0", "turbo": "^2.8.10", "typescript": "catalog:", "vitest": "^4.0.18", @@ -127,6 +126,21 @@ "typescript": "^5.8.2", }, }, + "apps/wh": { + "name": "wh", + "version": "0.1.0", + "dependencies": { + "drizzle-orm": "^0.45.1", + "hono": "^4.10.1", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260503.1", + "better-sqlite3": "^11.10.0", + "drizzle-kit": "^0.31.5", + "typescript": "catalog:", + "wrangler": "^4.42.1", + }, + }, "e2e": { "name": "e2e", "devDependencies": { @@ -191,6 +205,7 @@ "pino-pretty": "^13.1.3", "posthog-node": "^5.28.8", "typescript": "^5.9.2", + "yocto-spinner": "^0.2.1", "zod": "^4.0.0", }, "devDependencies": { @@ -357,6 +372,24 @@ "@clack/prompts": ["@clack/prompts@1.2.0", "https://registry.better-npm.dev/@clack/prompts/-/prompts-1.2.0.tgz", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "https://registry.better-npm.dev/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "https://registry.better-npm.dev/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260430.1", "https://registry.better-npm.dev/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260430.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260430.1", "https://registry.better-npm.dev/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260430.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260430.1", "https://registry.better-npm.dev/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260430.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260430.1", "https://registry.better-npm.dev/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260430.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260430.1", "https://registry.better-npm.dev/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260430.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260503.1", "https://registry.better-npm.dev/@cloudflare/workers-types/-/workers-types-4.20260503.1.tgz", {}, "sha512-8VKtafR4fNMtddutOnam3yq3AQvrl9bzuMio3B3AEAfrdx7xaaDV0Oyxz54P07lODwX0jydukGLC1rpDdYXAAA=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "https://registry.better-npm.dev/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.better-npm.dev/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.62.0", "https://registry.better-npm.dev/@dotenvx/dotenvx/-/dotenvx-1.62.0.tgz", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-dHMoiNqIyLnDxbsy16Zr55qN6a52dyocvOiVV4+ptjRIWNrBItbCNjazcv+hwKZGa7+WSKDHLTlyxzpK5yhxaQ=="], @@ -685,6 +718,12 @@ "@polar-sh/sdk": ["@polar-sh/sdk@0.47.0", "https://registry.better-npm.dev/@polar-sh/sdk/-/sdk-0.47.0.tgz", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.65 || ^4.0.0" } }, "sha512-tZEt8eLWsQVhmyfUQzXhnN84e5Cnu/JsYFnCci1ranWkIGveQc+wTiyzDCG9Tl1xiz1Y/ztPsAJ1fevj6ExdAA=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "https://registry.better-npm.dev/@poppinss/colors/-/colors-4.1.6.tgz", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "https://registry.better-npm.dev/@poppinss/dumper/-/dumper-0.6.5.tgz", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "https://registry.better-npm.dev/@poppinss/exception/-/exception-1.2.3.tgz", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@posthog/core": ["@posthog/core@1.27.2", "https://registry.better-npm.dev/@posthog/core/-/core-1.27.2.tgz", { "dependencies": { "@posthog/types": "1.371.3" } }, "sha512-y4YzMnUPbuVmL9s31JnJ2lTXxqy1QBTttxzjtfAuogQCN7nGpeDJoVAFz48CMFfLVFexLC2zt7LnkVWfq2hrxw=="], "@posthog/types": ["@posthog/types@1.371.3", "https://registry.better-npm.dev/@posthog/types/-/types-1.371.3.tgz", {}, "sha512-oRmCJUMTM43tgbiH8fgGTu5ksjN5d6Lc6ckEYGUpbEMUVB+Of/yIOjb7Okaaqw0erSvtQumFM0teEP+nUI3JtQ=="], @@ -865,8 +904,12 @@ "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "https://registry.better-npm.dev/@sinclair/typebox/-/typebox-0.34.49.tgz", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "https://registry.better-npm.dev/@sindresorhus/is/-/is-7.2.0.tgz", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "https://registry.better-npm.dev/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "https://registry.better-npm.dev/@speed-highlight/core/-/core-1.2.15.tgz", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "https://registry.better-npm.dev/@stablelib/base64/-/base64-1.0.1.tgz", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.better-npm.dev/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -1077,16 +1120,26 @@ "balanced-match": ["balanced-match@4.0.4", "https://registry.better-npm.dev/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-js": ["base64-js@1.5.1", "https://registry.better-npm.dev/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.21", "https://registry.better-npm.dev/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA=="], "better-auth": ["better-auth@1.6.9", "https://registry.better-npm.dev/better-auth/-/better-auth-1.6.9.tgz", { "dependencies": { "@better-auth/core": "1.6.9", "@better-auth/drizzle-adapter": "1.6.9", "@better-auth/kysely-adapter": "1.6.9", "@better-auth/memory-adapter": "1.6.9", "@better-auth/mongo-adapter": "1.6.9", "@better-auth/prisma-adapter": "1.6.9", "@better-auth/telemetry": "1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.14", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA=="], "better-call": ["better-call@2.0.3", "https://registry.better-npm.dev/better-call/-/better-call-2.0.3.tgz", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-0ZrD4tszOwN+vn9pCiHahe6wPb7AL1U+LgaPMstyZelD0kuA7lW3WwyxGmh22WRApKiWy4epNCN5pf7dfl6Sdw=="], + "better-sqlite3": ["better-sqlite3@11.10.0", "https://registry.better-npm.dev/better-sqlite3/-/better-sqlite3-11.10.0.tgz", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], + "binary-extensions": ["binary-extensions@2.3.0", "https://registry.better-npm.dev/binary-extensions/-/binary-extensions-2.3.0.tgz", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bindings": ["bindings@1.5.0", "https://registry.better-npm.dev/bindings/-/bindings-1.5.0.tgz", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "birpc": ["birpc@4.0.0", "https://registry.better-npm.dev/birpc/-/birpc-4.0.0.tgz", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "bl": ["bl@4.1.0", "https://registry.better-npm.dev/bl/-/bl-4.1.0.tgz", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "https://registry.better-npm.dev/blake3-wasm/-/blake3-wasm-2.1.5.tgz", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "body-parser": ["body-parser@2.2.2", "https://registry.better-npm.dev/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@5.0.5", "https://registry.better-npm.dev/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], @@ -1095,6 +1148,8 @@ "browserslist": ["browserslist@4.28.2", "https://registry.better-npm.dev/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer": ["buffer@5.7.1", "https://registry.better-npm.dev/buffer/-/buffer-5.7.1.tgz", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-from": ["buffer-from@1.1.2", "https://registry.better-npm.dev/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bumpp": ["bumpp@10.4.1", "https://registry.better-npm.dev/bumpp/-/bumpp-10.4.1.tgz", { "dependencies": { "ansis": "^4.2.0", "args-tokenizer": "^0.3.0", "c12": "^3.3.3", "cac": "^6.7.14", "escalade": "^3.2.0", "jsonc-parser": "^3.3.1", "package-manager-detector": "^1.6.0", "semver": "^7.7.3", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "yaml": "^2.8.2" }, "bin": { "bumpp": "bin/bumpp.mjs" } }, "sha512-X/bwWs5Gbb/D7rN4aHLB7zdjiA6nGdjckM1sTHhI9oovIbEw2L5pw5S4xzk8ZTeOZ8EnwU/Ze4SoZ6/Vr3pM2Q=="], @@ -1139,7 +1194,7 @@ "chokidar": ["chokidar@5.0.0", "https://registry.better-npm.dev/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "chownr": ["chownr@2.0.0", "https://registry.better-npm.dev/chownr/-/chownr-2.0.0.tgz", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + "chownr": ["chownr@1.1.4", "https://registry.better-npm.dev/chownr/-/chownr-1.1.4.tgz", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], "citty": ["citty@0.1.6", "https://registry.better-npm.dev/citty/-/citty-0.1.6.tgz", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], @@ -1263,8 +1318,12 @@ "decode-uri-component": ["decode-uri-component@0.4.1", "https://registry.better-npm.dev/decode-uri-component/-/decode-uri-component-0.4.1.tgz", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="], + "decompress-response": ["decompress-response@6.0.0", "https://registry.better-npm.dev/decompress-response/-/decompress-response-6.0.0.tgz", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "dedent": ["dedent@1.7.2", "https://registry.better-npm.dev/dedent/-/dedent-1.7.2.tgz", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + "deep-extend": ["deep-extend@0.6.0", "https://registry.better-npm.dev/deep-extend/-/deep-extend-0.6.0.tgz", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deepmerge": ["deepmerge@4.3.1", "https://registry.better-npm.dev/deepmerge/-/deepmerge-4.3.1.tgz", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "default-browser": ["default-browser@5.5.0", "https://registry.better-npm.dev/default-browser/-/default-browser-5.5.0.tgz", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -1341,6 +1400,8 @@ "error-ex": ["error-ex@1.3.4", "https://registry.better-npm.dev/error-ex/-/error-ex-1.3.4.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "https://registry.better-npm.dev/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "ervy": ["ervy@1.0.7", "https://registry.better-npm.dev/ervy/-/ervy-1.0.7.tgz", {}, "sha512-LyHLPwIKxCKKtTO/qBMFzwA1BD5IjpM0AA3k6CeK9hrEn4Kbayi93G9eD/Ko4suEIjerSl7YpMmaOoL0g9OCoQ=="], "es-define-property": ["es-define-property@1.0.1", "https://registry.better-npm.dev/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -1397,6 +1458,8 @@ "execa": ["execa@9.6.1", "https://registry.better-npm.dev/execa/-/execa-9.6.1.tgz", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expand-template": ["expand-template@2.0.3", "https://registry.better-npm.dev/expand-template/-/expand-template-2.0.3.tgz", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "https://registry.better-npm.dev/expect-type/-/expect-type-1.3.0.tgz", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "express": ["express@5.2.1", "https://registry.better-npm.dev/express/-/express-5.2.1.tgz", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -1437,6 +1500,8 @@ "file-type": ["file-type@22.0.1", "https://registry.better-npm.dev/file-type/-/file-type-22.0.1.tgz", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "https://registry.better-npm.dev/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "fill-range": ["fill-range@7.1.1", "https://registry.better-npm.dev/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "filter-obj": ["filter-obj@5.1.0", "https://registry.better-npm.dev/filter-obj/-/filter-obj-5.1.0.tgz", {}, "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng=="], @@ -1455,6 +1520,8 @@ "fresh": ["fresh@2.0.0", "https://registry.better-npm.dev/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-constants": ["fs-constants@1.0.0", "https://registry.better-npm.dev/fs-constants/-/fs-constants-1.0.0.tgz", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-extra": ["fs-extra@11.3.4", "https://registry.better-npm.dev/fs-extra/-/fs-extra-11.3.4.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], "fs-minipass": ["fs-minipass@2.1.0", "https://registry.better-npm.dev/fs-minipass/-/fs-minipass-2.1.0.tgz", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], @@ -1493,6 +1560,8 @@ "giget": ["giget@3.2.0", "https://registry.better-npm.dev/giget/-/giget-3.2.0.tgz", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="], + "github-from-package": ["github-from-package@0.0.0", "https://registry.better-npm.dev/github-from-package/-/github-from-package-0.0.0.tgz", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "github-slugger": ["github-slugger@2.0.0", "https://registry.better-npm.dev/github-slugger/-/github-slugger-2.0.0.tgz", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob-parent": ["glob-parent@5.1.2", "https://registry.better-npm.dev/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1561,6 +1630,8 @@ "inherits": ["inherits@2.0.4", "https://registry.better-npm.dev/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@1.3.8", "https://registry.better-npm.dev/ini/-/ini-1.3.8.tgz", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ink": ["ink@6.8.0", "https://registry.better-npm.dev/ink/-/ink-6.8.0.tgz", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], "ink-big-text": ["ink-big-text@2.0.0", "https://registry.better-npm.dev/ink-big-text/-/ink-big-text-2.0.0.tgz", { "dependencies": { "cfonts": "^3.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "ink": ">=4", "react": ">=18" } }, "sha512-Juzqv+rIOLGuhMJiE50VtS6dg6olWfzFdL7wsU/EARSL5Eaa5JNXMogMBm9AkjgzO2Y3UwWCOh87jbhSn8aNdw=="], @@ -1859,6 +1930,10 @@ "mimic-function": ["mimic-function@5.0.1", "https://registry.better-npm.dev/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "mimic-response": ["mimic-response@3.1.0", "https://registry.better-npm.dev/mimic-response/-/mimic-response-3.1.0.tgz", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "miniflare": ["miniflare@4.20260430.0", "https://registry.better-npm.dev/miniflare/-/miniflare-4.20260430.0.tgz", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260430.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA=="], + "minimatch": ["minimatch@10.2.5", "https://registry.better-npm.dev/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "https://registry.better-npm.dev/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1869,6 +1944,8 @@ "mkdirp": ["mkdirp@1.0.4", "https://registry.better-npm.dev/mkdirp/-/mkdirp-1.0.4.tgz", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "https://registry.better-npm.dev/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "mlly": ["mlly@1.8.2", "https://registry.better-npm.dev/mlly/-/mlly-1.8.2.tgz", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "motion": ["motion@12.38.0", "https://registry.better-npm.dev/motion/-/motion-12.38.0.tgz", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], @@ -1889,12 +1966,16 @@ "nanostores": ["nanostores@1.3.0", "https://registry.better-npm.dev/nanostores/-/nanostores-1.3.0.tgz", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "https://registry.better-npm.dev/napi-build-utils/-/napi-build-utils-2.0.0.tgz", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "negotiator": ["negotiator@1.0.0", "https://registry.better-npm.dev/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "next": ["next@16.2.4", "https://registry.better-npm.dev/next/-/next-16.2.4.tgz", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="], "next-themes": ["next-themes@0.4.6", "https://registry.better-npm.dev/next-themes/-/next-themes-0.4.6.tgz", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-abi": ["node-abi@3.92.0", "https://registry.better-npm.dev/node-abi/-/node-abi-3.92.0.tgz", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], + "node-domexception": ["node-domexception@1.0.0", "https://registry.better-npm.dev/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "https://registry.better-npm.dev/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], @@ -2029,6 +2110,8 @@ "powershell-utils": ["powershell-utils@0.1.0", "https://registry.better-npm.dev/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "prebuild-install": ["prebuild-install@7.1.3", "https://registry.better-npm.dev/prebuild-install/-/prebuild-install-7.1.3.tgz", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prettier": ["prettier@3.8.3", "https://registry.better-npm.dev/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "pretty-ms": ["pretty-ms@9.3.0", "https://registry.better-npm.dev/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], @@ -2063,6 +2146,8 @@ "raw-body": ["raw-body@3.0.2", "https://registry.better-npm.dev/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc": ["rc@1.2.8", "https://registry.better-npm.dev/rc/-/rc-1.2.8.tgz", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "rc9": ["rc9@3.0.1", "https://registry.better-npm.dev/rc9/-/rc9-3.0.1.tgz", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], "react": ["react@19.2.5", "https://registry.better-npm.dev/react/-/react-19.2.5.tgz", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], @@ -2087,6 +2172,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.better-npm.dev/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "readable-stream": ["readable-stream@3.6.2", "https://registry.better-npm.dev/readable-stream/-/readable-stream-3.6.2.tgz", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readdirp": ["readdirp@5.0.0", "https://registry.better-npm.dev/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "real-require": ["real-require@0.2.0", "https://registry.better-npm.dev/real-require/-/real-require-0.2.0.tgz", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -2167,6 +2254,8 @@ "rxjs": ["rxjs@7.8.2", "https://registry.better-npm.dev/rxjs/-/rxjs-7.8.2.tgz", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-buffer": ["safe-buffer@5.2.1", "https://registry.better-npm.dev/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.better-npm.dev/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safer-buffer": ["safer-buffer@2.1.2", "https://registry.better-npm.dev/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -2217,6 +2306,10 @@ "signal-exit": ["signal-exit@4.1.0", "https://registry.better-npm.dev/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-concat": ["simple-concat@1.0.1", "https://registry.better-npm.dev/simple-concat/-/simple-concat-1.0.1.tgz", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "https://registry.better-npm.dev/simple-get/-/simple-get-4.0.1.tgz", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "sisteransi": ["sisteransi@1.0.5", "https://registry.better-npm.dev/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "slice-ansi": ["slice-ansi@8.0.0", "https://registry.better-npm.dev/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], @@ -2255,6 +2348,8 @@ "string-width": ["string-width@7.2.0", "https://registry.better-npm.dev/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string_decoder": ["string_decoder@1.3.0", "https://registry.better-npm.dev/string_decoder/-/string_decoder-1.3.0.tgz", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "stringify-entities": ["stringify-entities@4.0.4", "https://registry.better-npm.dev/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "stringify-object": ["stringify-object@5.0.0", "https://registry.better-npm.dev/stringify-object/-/stringify-object-5.0.0.tgz", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], @@ -2301,6 +2396,10 @@ "tar": ["tar@6.2.1", "https://registry.better-npm.dev/tar/-/tar-6.2.1.tgz", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "tar-fs": ["tar-fs@2.1.4", "https://registry.better-npm.dev/tar-fs/-/tar-fs-2.1.4.tgz", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "https://registry.better-npm.dev/tar-stream/-/tar-stream-2.2.0.tgz", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "terminal-size": ["terminal-size@4.0.1", "https://registry.better-npm.dev/terminal-size/-/terminal-size-4.0.1.tgz", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], "thread-stream": ["thread-stream@4.0.0", "https://registry.better-npm.dev/thread-stream/-/thread-stream-4.0.0.tgz", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], @@ -2347,6 +2446,8 @@ "tsx": ["tsx@4.21.0", "https://registry.better-npm.dev/tsx/-/tsx-4.21.0.tgz", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "https://registry.better-npm.dev/tunnel-agent/-/tunnel-agent-0.6.0.tgz", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "turbo": ["turbo@2.9.6", "https://registry.better-npm.dev/turbo/-/turbo-2.9.6.tgz", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], "tw-animate-css": ["tw-animate-css@1.4.0", "https://registry.better-npm.dev/tw-animate-css/-/tw-animate-css-1.4.0.tgz", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -2363,8 +2464,12 @@ "unconfig-core": ["unconfig-core@7.5.0", "https://registry.better-npm.dev/unconfig-core/-/unconfig-core-7.5.0.tgz", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], + "undici": ["undici@7.24.8", "https://registry.better-npm.dev/undici/-/undici-7.24.8.tgz", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + "undici-types": ["undici-types@7.19.2", "https://registry.better-npm.dev/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "unenv": ["unenv@2.0.0-rc.24", "https://registry.better-npm.dev/unenv/-/unenv-2.0.0-rc.24.tgz", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "https://registry.better-npm.dev/unicorn-magic/-/unicorn-magic-0.3.0.tgz", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unified": ["unified@11.0.5", "https://registry.better-npm.dev/unified/-/unified-11.0.5.tgz", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -2429,6 +2534,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://registry.better-npm.dev/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "wh": ["wh@workspace:apps/wh"], + "when-exit": ["when-exit@2.1.5", "https://registry.better-npm.dev/when-exit/-/when-exit-2.1.5.tgz", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], "which": ["which@4.0.0", "https://registry.better-npm.dev/which/-/which-4.0.0.tgz", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], @@ -2439,6 +2546,10 @@ "window-size": ["window-size@1.1.1", "https://registry.better-npm.dev/window-size/-/window-size-1.1.1.tgz", { "dependencies": { "define-property": "^1.0.0", "is-number": "^3.0.0" }, "bin": { "window-size": "cli.js" } }, "sha512-5D/9vujkmVQ7pSmc0SCBmHXbkv6eaHwXEx65MywhmUMsI8sGqJ972APq1lotfcwMKPFLuCFfL8xGHLIp7jaBmA=="], + "workerd": ["workerd@1.20260430.1", "https://registry.better-npm.dev/workerd/-/workerd-1.20260430.1.tgz", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260430.1", "@cloudflare/workerd-darwin-arm64": "1.20260430.1", "@cloudflare/workerd-linux-64": "1.20260430.1", "@cloudflare/workerd-linux-arm64": "1.20260430.1", "@cloudflare/workerd-windows-64": "1.20260430.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q=="], + + "wrangler": ["wrangler@4.87.0", "https://registry.better-npm.dev/wrangler/-/wrangler-4.87.0.tgz", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260430.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260430.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260430.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.better-npm.dev/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "https://registry.better-npm.dev/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -2461,7 +2572,7 @@ "yn": ["yn@3.1.1", "https://registry.better-npm.dev/yn/-/yn-3.1.1.tgz", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], - "yocto-spinner": ["yocto-spinner@1.1.0", "https://registry.better-npm.dev/yocto-spinner/-/yocto-spinner-1.1.0.tgz", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA=="], + "yocto-spinner": ["yocto-spinner@0.2.3", "https://registry.better-npm.dev/yocto-spinner/-/yocto-spinner-0.2.3.tgz", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], "yoctocolors": ["yoctocolors@2.1.2", "https://registry.better-npm.dev/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], @@ -2469,6 +2580,10 @@ "yoga-layout": ["yoga-layout@3.2.1", "https://registry.better-npm.dev/yoga-layout/-/yoga-layout-3.2.1.tgz", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@4.1.0-beta.10", "https://registry.better-npm.dev/youch/-/youch-4.1.0-beta.10.tgz", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "https://registry.better-npm.dev/youch-core/-/youch-core-0.3.3.tgz", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zod": ["zod@4.3.6", "https://registry.better-npm.dev/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.better-npm.dev/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -2489,10 +2604,14 @@ "@better-auth/core/better-call": ["better-call@1.3.5", "https://registry.better-npm.dev/better-call/-/better-call-1.3.5.tgz", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "https://registry.better-npm.dev/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "https://registry.better-npm.dev/commander/-/commander-11.1.0.tgz", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "https://registry.better-npm.dev/execa/-/execa-5.1.1.tgz", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "@dotenvx/dotenvx/yocto-spinner": ["yocto-spinner@1.1.0", "https://registry.better-npm.dev/yocto-spinner/-/yocto-spinner-1.1.0.tgz", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA=="], + "@ecies/ciphers/@noble/ciphers": ["@noble/ciphers@1.3.0", "https://registry.better-npm.dev/@noble/ciphers/-/ciphers-1.3.0.tgz", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "https://registry.better-npm.dev/esbuild/-/esbuild-0.18.20.tgz", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -2519,6 +2638,8 @@ "@paykitjs/stripe/typescript": ["typescript@5.9.3", "https://registry.better-npm.dev/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "https://registry.better-npm.dev/supports-color/-/supports-color-10.2.2.tgz", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "https://registry.better-npm.dev/immer/-/immer-11.1.4.tgz", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.better-npm.dev/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -2541,6 +2662,8 @@ "atmn/open": ["open@10.2.0", "https://registry.better-npm.dev/open/-/open-10.2.0.tgz", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "atmn/yocto-spinner": ["yocto-spinner@1.1.0", "https://registry.better-npm.dev/yocto-spinner/-/yocto-spinner-1.1.0.tgz", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA=="], + "better-auth/better-call": ["better-call@1.3.5", "https://registry.better-npm.dev/better-call/-/better-call-1.3.5.tgz", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], "better-call/rou3": ["rou3@0.7.12", "https://registry.better-npm.dev/rou3/-/rou3-0.7.12.tgz", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -2619,6 +2742,8 @@ "micromatch/picomatch": ["picomatch@2.3.2", "https://registry.better-npm.dev/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "miniflare/ws": ["ws@8.18.0", "https://registry.better-npm.dev/ws/-/ws-8.18.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "minizlib/minipass": ["minipass@3.3.6", "https://registry.better-npm.dev/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minizlib/yallist": ["yallist@4.0.0", "https://registry.better-npm.dev/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -2641,6 +2766,8 @@ "prompts/kleur": ["kleur@3.0.3", "https://registry.better-npm.dev/kleur/-/kleur-3.0.3.tgz", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "https://registry.better-npm.dev/strip-json-comments/-/strip-json-comments-2.0.1.tgz", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "restore-cursor/onetime": ["onetime@7.0.0", "https://registry.better-npm.dev/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "https://registry.better-npm.dev/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], @@ -2663,6 +2790,8 @@ "subsume/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.better-npm.dev/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "tar/chownr": ["chownr@2.0.0", "https://registry.better-npm.dev/chownr/-/chownr-2.0.0.tgz", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + "tar/yallist": ["yallist@4.0.0", "https://registry.better-npm.dev/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "to-regex-range/is-number": ["is-number@7.0.0", "https://registry.better-npm.dev/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], @@ -2677,6 +2806,8 @@ "widest-line/string-width": ["string-width@8.2.0", "https://registry.better-npm.dev/string-width/-/string-width-8.2.0.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + "wrangler/esbuild": ["esbuild@0.27.3", "https://registry.better-npm.dev/esbuild/-/esbuild-0.27.3.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "yargs/string-width": ["string-width@4.2.3", "https://registry.better-npm.dev/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@better-auth/core/better-call/rou3": ["rou3@0.7.12", "https://registry.better-npm.dev/rou3/-/rou3-0.7.12.tgz", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -2927,6 +3058,58 @@ "web/@types/node/undici-types": ["undici-types@6.21.0", "https://registry.better-npm.dev/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "https://registry.better-npm.dev/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "https://registry.better-npm.dev/@esbuild/android-arm/-/android-arm-0.27.3.tgz", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "https://registry.better-npm.dev/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "https://registry.better-npm.dev/@esbuild/android-x64/-/android-x64-0.27.3.tgz", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "https://registry.better-npm.dev/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "https://registry.better-npm.dev/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "https://registry.better-npm.dev/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "https://registry.better-npm.dev/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "https://registry.better-npm.dev/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "https://registry.better-npm.dev/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "https://registry.better-npm.dev/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "https://registry.better-npm.dev/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "https://registry.better-npm.dev/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "https://registry.better-npm.dev/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "https://registry.better-npm.dev/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "https://registry.better-npm.dev/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "https://registry.better-npm.dev/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "https://registry.better-npm.dev/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.better-npm.dev/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.better-npm.dev/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], diff --git a/package.json b/package.json index 41556991..b17c5e49 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "oxfmt": "^0.36.0", "oxlint": "^1.51.0", "tinyglobby": "^0.2.16", - "tsx": "^4.21.0", "turbo": "^2.8.10", "typescript": "catalog:", "vitest": "^4.0.18" diff --git a/packages/paykit/package.json b/packages/paykit/package.json index 5df43540..9cd2e701 100644 --- a/packages/paykit/package.json +++ b/packages/paykit/package.json @@ -69,6 +69,7 @@ "pino-pretty": "^13.1.3", "posthog-node": "^5.28.8", "typescript": "^5.9.2", + "yocto-spinner": "^0.2.1", "zod": "^4.0.0" }, "devDependencies": { diff --git a/packages/paykit/src/api/__tests__/methods.test.ts b/packages/paykit/src/api/__tests__/methods.test.ts index f17b543a..73847740 100644 --- a/packages/paykit/src/api/__tests__/methods.test.ts +++ b/packages/paykit/src/api/__tests__/methods.test.ts @@ -83,6 +83,7 @@ describe("api/methods router", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ received: true }); expect(handleWebhook).toHaveBeenCalledWith({ + allowStaleSignatures: false, body: '{"ok":true}', headers: { "content-type": "text/plain;charset=UTF-8", diff --git a/packages/paykit/src/cli/commands/listen.ts b/packages/paykit/src/cli/commands/listen.ts new file mode 100644 index 00000000..c6495801 --- /dev/null +++ b/packages/paykit/src/cli/commands/listen.ts @@ -0,0 +1,763 @@ +import path from "node:path"; + +import { Command } from "commander"; +import picocolors from "picocolors"; + +import type { PaymentProvider } from "../../providers/provider"; +import { createDevLogger } from "../utils/dev-logger"; +import { getOrCreateDeviceToken } from "../utils/device-token"; +import { getPayKitConfig } from "../utils/get-config"; +import { capture } from "../utils/telemetry"; + +const DEFAULT_CLOUD_BASE_URL = "https://wh.paykit.sh"; +const DEFAULT_URL = "http://localhost:3000"; +const DEFAULT_BATCH_SIZE = 30; +const DEFAULT_ERROR_BACKOFF_MS = 2_000; +const MAX_ERROR_BACKOFF_MS = 15_000; +const DEFAULT_POLL_INTERVAL_MS = 2_000; +const DEFAULT_RETRY_WINDOW = "5m"; +const REPLAY_HEADER = "x-paykit-cloud-replay"; + +interface TunnelResponse { + found: boolean; + pendingCount: number; + providerWebhookEndpointId: string | null; + tunnelId: string; + webhookUrl: string; +} + +interface DeliveryResponse { + body: string; + headers: Record; + id: string; + method: string; + receivedAt: string; +} + +interface TunnelCapableProvider extends PaymentProvider { + disableTunnelWebhook(data: { endpointId: string }): Promise; + ensureTunnelWebhook(data: { existingEndpointId?: string | null; url: string }): Promise<{ + created: boolean; + endpointId: string; + webhookSecret?: string; + }>; + getTunnelAccount(): Promise<{ + displayName?: string; + environment: string; + providerAccountId: string; + providerId: string; + }>; +} + +interface TunnelAccountSummary { + displayName?: string; + environment: string; + providerAccountId: string; + providerId: string; +} + +interface ReplayResult { + error?: string; + ok: boolean; + status?: number; +} + +interface DeliveryDetails { + eventId?: string; + eventType?: string; +} + +interface RelayRuntimeContext { + account: TunnelAccountSummary; + config: Awaited>; + deviceToken: string; + provider: TunnelCapableProvider; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseRetryWindowMs(value: string): number { + const trimmed = value.trim().toLowerCase(); + if (trimmed === "0" || trimmed === "none") { + return 0; + } + + const match = /^(\d+)(ms|s|m|h)?$/.exec(trimmed); + if (!match) { + throw new Error(`--retry must look like 0, none, 30s, 5m, or 1h. Received "${value}"`); + } + + const amount = Number(match[1]); + const unit = match[2] ?? "m"; + switch (unit) { + case "ms": + return amount; + case "s": + return amount * 1000; + case "m": + return amount * 60_000; + case "h": + return amount * 60 * 60_000; + default: + return amount * 60_000; + } +} + +function normalizeLocalOrigin(url: string): string { + const parsed = new URL(url); + if (parsed.pathname !== "/" || parsed.search || parsed.hash) { + throw new Error(`--url must be an origin only, received "${url}"`); + } + + return parsed.origin; +} + +function buildLocalWebhookUrl(origin: string, basePath: string): string { + return new URL(`${basePath}/webhook`, `${origin}/`).toString(); +} + +function formatEnvironment(environment: string): string { + switch (environment) { + case "test": + return "sandbox"; + case "live": + return "production"; + default: + return environment; + } +} + +function parseDeliveryDetails(body: string): DeliveryDetails { + try { + const parsed = JSON.parse(body) as { id?: unknown; type?: unknown }; + return { + eventId: typeof parsed.id === "string" ? parsed.id : undefined, + eventType: typeof parsed.type === "string" ? parsed.type : undefined, + }; + } catch { + return {}; + } +} + +function isMissingWebhookEndpointError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /no such webhook endpoint/i.test(message); +} + +function printReadyBlock( + devLogger: ReturnType, + params: { + account: TunnelAccountSummary; + localWebhookUrl: string; + webhookSecret?: string; + webhookUrl: string; + }, +) { + const bullet = picocolors.cyan("•"); + const labelWidth = 16; + const formatLabel = (label: string) => label + " ".repeat(labelWidth - label.length); + const providerLabel = formatLabel("Stripe"); + const endpointLabel = formatLabel("Webhook endpoint"); + const secretLabel = formatLabel("Webhook secret"); + const accountName = params.account.displayName ?? params.account.providerAccountId; + const accountSummary = `${accountName} ${picocolors.dim(`(${formatEnvironment(params.account.environment)})`)}`; + const reminder = params.webhookSecret + ? `\n${" ".repeat(2 + labelWidth + 1)}${picocolors.dim("^ don't forget add to .env")}` + : ""; + + devLogger.print( + `Webhooks forwarding to ${picocolors.cyan(params.localWebhookUrl)}\n\n` + + `${bullet} ${providerLabel} ${accountSummary}\n` + + `${bullet} ${endpointLabel} ${params.webhookUrl}\n` + + `${bullet} ${secretLabel} ${params.webhookSecret ?? picocolors.dim("(existing secret hidden)")}${reminder}\n` + + `Ready!`, + ); +} + +function printEnableSummary( + devLogger: ReturnType, + params: { + account: TunnelAccountSummary; + webhookSecret?: string; + webhookUrl: string; + }, +) { + const bullet = picocolors.cyan("•"); + const labelWidth = 16; + const formatLabel = (label: string) => label + " ".repeat(labelWidth - label.length); + const providerLabel = formatLabel("Stripe"); + const endpointLabel = formatLabel("Webhook endpoint"); + const secretLabel = formatLabel("Webhook secret"); + const accountName = params.account.displayName ?? params.account.providerAccountId; + const accountSummary = `${accountName} ${picocolors.dim(`(${formatEnvironment(params.account.environment)})`)}`; + const reminder = params.webhookSecret + ? `\n${" ".repeat(2 + labelWidth + 1)}${picocolors.dim("^ don't forget add to .env")}` + : ""; + + devLogger.print( + `Webhook listener enabled.\n\n` + + `${bullet} ${providerLabel} ${accountSummary}\n` + + `${bullet} ${endpointLabel} ${params.webhookUrl}\n` + + `${bullet} ${secretLabel} ${params.webhookSecret ?? picocolors.dim("(existing secret hidden)")}${reminder}\n\n` + + `You're good to go.`, + ); +} + +function printRetrySummary( + devLogger: ReturnType, + params: { + deliveryId: string; + eventId?: string; + eventType?: string; + }, +) { + const label = params.eventType ?? "unknown"; + const id = params.eventId ?? params.deliveryId; + devLogger.print(`Retried ${label} ${picocolors.dim(id)}.`); +} + +function assertTunnelProvider(provider: PaymentProvider): TunnelCapableProvider { + if ( + typeof provider.getTunnelAccount !== "function" || + typeof provider.ensureTunnelWebhook !== "function" || + typeof provider.disableTunnelWebhook !== "function" + ) { + throw new Error(`Provider "${provider.name}" does not support paykitjs listen yet.`); + } + + return provider as TunnelCapableProvider; +} + +function sanitizeReplayHeaders(headers: Record): Headers { + const nextHeaders = new Headers(); + for (const [key, value] of Object.entries(headers)) { + const lowerKey = key.toLowerCase(); + if (lowerKey === "content-length" || lowerKey === "connection" || lowerKey === "host") { + continue; + } + nextHeaders.set(key, value); + } + nextHeaders.set(REPLAY_HEADER, "1"); + return nextHeaders; +} + +async function requestCloud( + deviceToken: string, + pathname: string, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${deviceToken}`); + if (init.body && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + + const cloudBaseUrl = + process.env.PAYKIT_WEBHOOK_API_BASE_URL ?? + process.env.PAYKIT_CLOUD_URL ?? + DEFAULT_CLOUD_BASE_URL; + + const response = await fetch(`${cloudBaseUrl}${pathname}`, { + ...init, + headers, + }); + + if (!response.ok) { + const contentType = response.headers.get("content-type") ?? ""; + const body = await response.text(); + const message = contentType.includes("text/html") + ? `PayKit server request failed (${response.status} ${response.statusText})` + : body || `PayKit server request failed (${response.status} ${response.statusText})`; + throw new Error(message); + } + + return (await response.json()) as T; +} + +async function ensureTunnel(params: { + account: TunnelAccountSummary; + createIfMissing: boolean; + deviceToken: string; + includeFailedBefore?: number; + retryWindowMs: number; +}): Promise { + const response = await requestCloud(params.deviceToken, "/api/tunnels/ensure", { + body: JSON.stringify({ + createIfMissing: params.createIfMissing, + environment: params.account.environment, + includeFailedBefore: params.includeFailedBefore, + providerAccountId: params.account.providerAccountId, + providerId: params.account.providerId, + retryWindowMs: params.retryWindowMs, + }), + method: "POST", + }); + + return response.found ? response : null; +} + +async function attachProviderWebhook(params: { + deviceToken: string; + endpointId: string; + providerWebhookEndpointId: string; +}): Promise { + await requestCloud(params.deviceToken, `/api/tunnels/${params.endpointId}/provider-webhook`, { + body: JSON.stringify({ providerWebhookEndpointId: params.providerWebhookEndpointId }), + method: "POST", + }); +} + +async function ackDelivery(params: { deliveryId: string; deviceToken: string }): Promise { + await requestCloud(params.deviceToken, `/api/deliveries/${params.deliveryId}/ack`, { + method: "POST", + }); +} + +async function pullDeliveries(params: { + deviceToken: string; + includeFailedBefore?: number; + limit: number; + offset?: number; + retryWindowMs: number; + tunnelId: string; +}): Promise { + const search = new URLSearchParams({ + limit: String(params.limit), + retryWindowMs: String(params.retryWindowMs), + }); + if (typeof params.includeFailedBefore === "number") { + search.set("includeFailedBefore", String(params.includeFailedBefore)); + } + if (params.offset) { + search.set("offset", String(params.offset)); + } + + const response = await requestCloud<{ deliveries: DeliveryResponse[] }>( + params.deviceToken, + `/api/tunnels/${params.tunnelId}/pull?${search.toString()}`, + ); + return response.deliveries; +} + +async function getDelivery(params: { + deliveryId: string; + deviceToken: string; +}): Promise { + return requestCloud(params.deviceToken, `/api/deliveries/${params.deliveryId}`); +} + +async function failDelivery(params: { + deliveryId: string; + deviceToken: string; + error: string; +}): Promise { + await requestCloud(params.deviceToken, `/api/deliveries/${params.deliveryId}/fail`, { + body: JSON.stringify({ error: params.error }), + method: "POST", + }); +} + +async function replayDelivery(params: { + delivery: DeliveryResponse; + localWebhookUrl: string; +}): Promise { + try { + const response = await fetch(params.localWebhookUrl, { + body: params.delivery.body, + headers: sanitizeReplayHeaders(params.delivery.headers), + method: params.delivery.method, + }); + + return { ok: response.ok, status: response.status }; + } catch { + return { error: "connection failed", ok: false }; + } +} + +async function syncProviderWebhook(params: { + deviceToken: string; + provider: TunnelCapableProvider; + tunnel: TunnelResponse; +}): Promise<{ webhookSecret?: string }> { + const providerWebhook = await params.provider.ensureTunnelWebhook({ + existingEndpointId: params.tunnel.providerWebhookEndpointId, + url: params.tunnel.webhookUrl, + }); + + if (providerWebhook.endpointId !== params.tunnel.providerWebhookEndpointId) { + await attachProviderWebhook({ + deviceToken: params.deviceToken, + endpointId: params.tunnel.tunnelId, + providerWebhookEndpointId: providerWebhook.endpointId, + }); + } + + return { webhookSecret: providerWebhook.webhookSecret }; +} + +async function processPendingDeliveries(params: { + devLogger: ReturnType; + deliveries: DeliveryResponse[]; + deviceToken: string; + localWebhookUrl: string; + mode: "live" | "replay"; +}): Promise<{ + hadDeliveries: boolean; + processedCount: number; +}> { + const deliveries = params.deliveries; + + if (deliveries.length === 0) { + return { hadDeliveries: false, processedCount: 0 }; + } + + for (const delivery of deliveries) { + const result = await replayDelivery({ delivery, localWebhookUrl: params.localWebhookUrl }); + const details = parseDeliveryDetails(delivery.body); + const eventId = details.eventId ?? delivery.id; + const eventType = details.eventType ?? "unknown"; + + if (!result.ok) { + const statusLabel = result.error ?? String(result.status ?? "failed"); + await failDelivery({ + deliveryId: delivery.id, + deviceToken: params.deviceToken, + error: statusLabel, + }); + + params.devLogger.event({ + eventId, + eventType, + replay: params.mode === "replay", + status: statusLabel, + }); + continue; + } + + params.devLogger.event({ + eventId, + eventType, + replay: params.mode === "replay", + status: result.status ?? 200, + }); + + await ackDelivery({ deliveryId: delivery.id, deviceToken: params.deviceToken }); + } + + return { hadDeliveries: true, processedCount: deliveries.length }; +} + +function getNextErrorBackoff(currentMs: number): number { + return currentMs === 0 ? DEFAULT_ERROR_BACKOFF_MS : Math.min(currentMs * 2, MAX_ERROR_BACKOFF_MS); +} + +async function loadRelayRuntimeContext(params: { + configPath?: string; + cwd: string; + devLogger: ReturnType; +}): Promise { + params.devLogger.start("Loading PayKit config"); + const config = await getPayKitConfig({ configPath: params.configPath, cwd: params.cwd }); + const provider = assertTunnelProvider(config.options.provider.createAdapter()); + const deviceToken = getOrCreateDeviceToken(); + + params.devLogger.update("Connecting to Stripe"); + const account = await provider.getTunnelAccount(); + params.devLogger.update("Connecting to PayKit"); + + return { + account, + config, + deviceToken, + provider, + }; +} + +async function listenAction(options: { + config?: string; + cwd: string; + retry: string; + url: string; +}): Promise { + const cwd = path.resolve(options.cwd); + capture("cli_command", { command: "listen" }); + const devLogger = createDevLogger(); + const localOrigin = normalizeLocalOrigin(options.url); + const retryWindowMs = parseRetryWindowMs(options.retry); + const relayStartedAt = Date.now(); + + const { account, config, deviceToken, provider } = await loadRelayRuntimeContext({ + configPath: options.config, + cwd, + devLogger, + }); + const tunnel = await ensureTunnel({ + account, + createIfMissing: true, + deviceToken, + includeFailedBefore: relayStartedAt, + retryWindowMs, + }); + + if (!tunnel) { + devLogger.stop(); + throw new Error("Failed to create or load webhook tunnel."); + } + + devLogger.update("Ensuring webhook endpoint"); + const { webhookSecret } = await syncProviderWebhook({ deviceToken, provider, tunnel }); + + const localWebhookUrl = buildLocalWebhookUrl(localOrigin, config.options.basePath ?? "/paykit"); + devLogger.stop(); + printReadyBlock(devLogger, { + account, + localWebhookUrl, + webhookSecret, + webhookUrl: tunnel.webhookUrl, + }); + + if (tunnel.pendingCount > 0) { + devLogger.info( + `replaying ${String(tunnel.pendingCount)} missed webhook event${tunnel.pendingCount === 1 ? "" : "s"}`, + ); + } + + let mode: "live" | "replay" = "replay"; + let errorBackoffMs = 0; + + for (;;) { + try { + const deliveries = await pullDeliveries({ + deviceToken, + includeFailedBefore: mode === "replay" ? relayStartedAt : undefined, + limit: DEFAULT_BATCH_SIZE, + retryWindowMs: mode === "replay" ? retryWindowMs : 0, + tunnelId: tunnel.tunnelId, + }); + + const result = await processPendingDeliveries({ + devLogger, + deliveries, + deviceToken, + localWebhookUrl, + mode, + }); + + errorBackoffMs = 0; + + if (!result.hadDeliveries && mode === "replay") { + devLogger.info("replay complete, listening for new webhooks"); + mode = "live"; + continue; + } + + await sleep(result.processedCount > 0 ? 250 : DEFAULT_POLL_INTERVAL_MS); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + devLogger.warn(`Listen loop failed: ${message}`); + errorBackoffMs = getNextErrorBackoff(errorBackoffMs); + await sleep(errorBackoffMs); + } + } +} + +async function enableAction(options: { config?: string; cwd: string; url: string }): Promise { + const cwd = path.resolve(options.cwd); + capture("cli_command", { command: "listen_enable" }); + const devLogger = createDevLogger(); + const localOrigin = normalizeLocalOrigin(options.url); + + const { account, config, deviceToken, provider } = await loadRelayRuntimeContext({ + configPath: options.config, + cwd, + devLogger, + }); + const tunnel = await ensureTunnel({ + account, + createIfMissing: true, + deviceToken, + retryWindowMs: 0, + }); + + if (!tunnel) { + devLogger.stop(); + throw new Error("Failed to create or load webhook tunnel."); + } + + devLogger.update("Ensuring webhook endpoint"); + const { webhookSecret } = await syncProviderWebhook({ deviceToken, provider, tunnel }); + + buildLocalWebhookUrl(localOrigin, config.options.basePath ?? "/paykit"); + devLogger.stop(); + printEnableSummary(devLogger, { + account, + webhookSecret, + webhookUrl: tunnel.webhookUrl, + }); +} + +async function disableAction(options: { config?: string; cwd: string }): Promise { + const cwd = path.resolve(options.cwd); + capture("cli_command", { command: "listen_disable" }); + const devLogger = createDevLogger(); + + const { account, deviceToken, provider } = await loadRelayRuntimeContext({ + configPath: options.config, + cwd, + devLogger, + }); + const tunnel = await ensureTunnel({ + account, + createIfMissing: false, + deviceToken, + retryWindowMs: 0, + }); + + if (!tunnel) { + devLogger.stop(); + devLogger.print("No webhook tunnel found for this provider account."); + return; + } + + if (tunnel.providerWebhookEndpointId) { + try { + await provider.disableTunnelWebhook({ endpointId: tunnel.providerWebhookEndpointId }); + } catch (error) { + if (!isMissingWebhookEndpointError(error)) { + const message = error instanceof Error ? error.message : String(error); + devLogger.warn(`Failed to delete provider webhook endpoint: ${message}`); + } + } + } + + await requestCloud(deviceToken, `/api/tunnels/${tunnel.tunnelId}/disable`, { method: "POST" }); + devLogger.stop(); + devLogger.print(picocolors.green("Webhook tunnel disabled.")); +} + +async function retryAction(options: { + config?: string; + cwd: string; + deliveryId: string; + url: string; +}): Promise { + const cwd = path.resolve(options.cwd); + capture("cli_command", { command: "listen_retry" }); + const devLogger = createDevLogger(); + const localOrigin = normalizeLocalOrigin(options.url); + + const { config, deviceToken } = await loadRelayRuntimeContext({ + configPath: options.config, + cwd, + devLogger, + }); + const localWebhookUrl = buildLocalWebhookUrl(localOrigin, config.options.basePath ?? "/paykit"); + const delivery = await getDelivery({ deliveryId: options.deliveryId, deviceToken }); + devLogger.stop(); + + const details = parseDeliveryDetails(delivery.body); + const result = await replayDelivery({ delivery, localWebhookUrl }); + if (!result.ok) { + const statusLabel = result.error ?? String(result.status ?? "failed"); + await failDelivery({ deliveryId: delivery.id, deviceToken, error: statusLabel }); + devLogger.event({ + eventId: details.eventId ?? delivery.id, + eventType: details.eventType ?? "unknown", + replay: true, + status: statusLabel, + }); + throw new Error( + `Retry failed for ${details.eventType ?? "unknown"} ${details.eventId ?? delivery.id}.`, + ); + } + + await ackDelivery({ deliveryId: delivery.id, deviceToken }); + devLogger.event({ + eventId: details.eventId ?? delivery.id, + eventType: details.eventType ?? "unknown", + replay: true, + status: result.status ?? 200, + }); + printRetrySummary(devLogger, { + deliveryId: delivery.id, + eventId: details.eventId, + eventType: details.eventType, + }); +} + +function mergeRelaySubcommandOptions< + TOptions extends { config?: string; cwd?: string; retry?: string; url?: string }, +>( + options: TOptions, + command: Command, +): { config?: string; cwd: string; retry?: string; url: string } { + const parentOptions = command.parent?.opts() as + | { config?: string; cwd?: string; retry?: string; url?: string } + | undefined; + + return { + config: options.config ?? parentOptions?.config, + cwd: options.cwd ?? parentOptions?.cwd ?? process.cwd(), + retry: options.retry ?? parentOptions?.retry, + url: options.url ?? parentOptions?.url ?? DEFAULT_URL, + }; +} + +export const listenCommand = new Command("listen") + .description("Register a provider webhook tunnel, replay missed events, and keep polling") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd(), + ) + .option("--config ", "the path to the PayKit configuration file to load.") + .option( + "--retry ", + "retry failed deliveries received within this window", + DEFAULT_RETRY_WINDOW, + ) + .option("--url ", "local app origin", DEFAULT_URL) + .action(listenAction) + .addCommand( + new Command("enable") + .description("Ensure the webhook tunnel and provider webhook endpoint, then exit") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd(), + ) + .option("--config ", "the path to the PayKit configuration file to load.") + .option("--url ", "local app origin") + .action((options, command) => enableAction(mergeRelaySubcommandOptions(options, command))), + ) + .addCommand( + new Command("retry") + .description("Retry one stored delivery once, then exit") + .argument("", "stored delivery id") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd(), + ) + .option("--config ", "the path to the PayKit configuration file to load.") + .option("--url ", "local app origin") + .action((deliveryId, options, command) => + retryAction({ + ...mergeRelaySubcommandOptions(options, command), + deliveryId, + }), + ), + ) + .addCommand( + new Command("disable") + .description("Disable the webhook tunnel for the current provider account") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd(), + ) + .option("--config ", "the path to the PayKit configuration file to load.") + .action((options, command) => disableAction(mergeRelaySubcommandOptions(options, command))), + ); diff --git a/packages/paykit/src/cli/index.ts b/packages/paykit/src/cli/index.ts index c406b53b..8640db47 100755 --- a/packages/paykit/src/cli/index.ts +++ b/packages/paykit/src/cli/index.ts @@ -24,15 +24,23 @@ switch (commandName) { program.addCommand(pushCommand); break; } + case "listen": { + const { listenCommand } = await import("./commands/listen"); + program.addCommand(listenCommand); + break; + } default: { - const [{ statusCommand }, { initCommand }, { pushCommand }] = await Promise.all([ - import("./commands/status"), - import("./commands/init"), - import("./commands/push"), - ]); + const [{ statusCommand }, { initCommand }, { pushCommand }, { listenCommand }] = + await Promise.all([ + import("./commands/status"), + import("./commands/init"), + import("./commands/push"), + import("./commands/listen"), + ]); program.addCommand(statusCommand); program.addCommand(initCommand); program.addCommand(pushCommand); + program.addCommand(listenCommand); } } diff --git a/packages/paykit/src/cli/utils/dev-logger.ts b/packages/paykit/src/cli/utils/dev-logger.ts new file mode 100644 index 00000000..49df4f67 --- /dev/null +++ b/packages/paykit/src/cli/utils/dev-logger.ts @@ -0,0 +1,73 @@ +import picocolors from "picocolors"; +import yoctoSpinner from "yocto-spinner"; + +function writeLine(message = ""): void { + process.stdout.write(`${message}\n`); +} + +function formatTimestamp(date = new Date()): string { + return date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function timestampLabel(): string { + return picocolors.dim(formatTimestamp()); +} + +export function createDevLogger() { + const spinner = yoctoSpinner({ text: "" }); + + function flushSpinner() { + spinner.stop(); + } + + return { + start(message: string) { + spinner.start(message); + }, + + update(message: string) { + spinner.text = message; + }, + + stop() { + flushSpinner(); + }, + + info(message: string) { + flushSpinner(); + writeLine(`${timestampLabel()} ${message}`); + }, + + warn(message: string) { + flushSpinner(); + writeLine(`${timestampLabel()} ${picocolors.yellow(message)}`); + }, + + event(params: { + eventId: string; + eventType: string; + replay: boolean; + status: number | string; + }) { + flushSpinner(); + const statusLabel = + typeof params.status === "number" || /^\d+$/.test(String(params.status)) + ? picocolors.green(String(params.status)) + : picocolors.yellow(String(params.status)); + const replaySuffix = params.replay ? picocolors.dim(" (replay)") : ""; + writeLine( + `${timestampLabel()} ${statusLabel} ${params.eventType} ${picocolors.dim(params.eventId)}${replaySuffix}`, + ); + }, + + print(message: string) { + flushSpinner(); + writeLine(message); + }, + }; +} diff --git a/packages/paykit/src/cli/utils/device-token.ts b/packages/paykit/src/cli/utils/device-token.ts new file mode 100644 index 00000000..4489e30d --- /dev/null +++ b/packages/paykit/src/cli/utils/device-token.ts @@ -0,0 +1,40 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const DEVICE_CONFIG_PATH = path.join(os.homedir(), ".config", "paykit.json"); + +interface DeviceConfig { + deviceToken: string; +} + +function generateDeviceToken(): string { + return `pk_dev_${crypto.randomBytes(24).toString("base64url")}`; +} + +export function getDeviceConfigPath(): string { + return DEVICE_CONFIG_PATH; +} + +export function getOrCreateDeviceToken(): string { + if (fs.existsSync(DEVICE_CONFIG_PATH)) { + let parsed: Partial = {}; + try { + parsed = JSON.parse(fs.readFileSync(DEVICE_CONFIG_PATH, "utf8")) as Partial; + } catch { + console.warn(`Invalid device token config at ${DEVICE_CONFIG_PATH}, creating a fresh token.`); + } + + if (typeof parsed.deviceToken === "string" && parsed.deviceToken.length > 0) { + return parsed.deviceToken; + } + } + + const deviceToken = generateDeviceToken(); + fs.mkdirSync(path.dirname(DEVICE_CONFIG_PATH), { recursive: true }); + fs.writeFileSync(DEVICE_CONFIG_PATH, JSON.stringify({ deviceToken }, null, 2) + "\n", { + mode: 0o600, + }); + return deviceToken; +} diff --git a/packages/paykit/src/index.ts b/packages/paykit/src/index.ts index 3c1ae5eb..68ee4056 100644 --- a/packages/paykit/src/index.ts +++ b/packages/paykit/src/index.ts @@ -29,6 +29,8 @@ export type { PaymentProvider, ProviderCustomer, ProviderCustomerMap, + ProviderTunnelAccount, + ProviderTunnelWebhook, ProviderTestClock, } from "./providers/provider"; export type { diff --git a/packages/paykit/src/providers/provider.ts b/packages/paykit/src/providers/provider.ts index 7159cc89..73a5a0c9 100644 --- a/packages/paykit/src/providers/provider.ts +++ b/packages/paykit/src/providers/provider.ts @@ -31,6 +31,19 @@ export interface PayKitProviderCapabilities { testClocks: boolean; } +export interface ProviderTunnelAccount { + displayName?: string; + environment: string; + providerAccountId: string; + providerId: string; +} + +export interface ProviderTunnelWebhook { + created: boolean; + endpointId: string; + webhookSecret?: string; +} + export interface ProviderInvoice { currency: string; hostedUrl?: string | null; @@ -160,6 +173,7 @@ export interface PaymentProvider { }>; handleWebhook(data: { + allowStaleSignatures?: boolean; body: string; headers: Record; }): Promise; @@ -169,6 +183,15 @@ export interface PaymentProvider { returnUrl: string; }): Promise<{ url: string }>; + getTunnelAccount?(): Promise; + + ensureTunnelWebhook?(data: { + existingEndpointId?: string | null; + url: string; + }): Promise; + + disableTunnelWebhook?(data: { endpointId: string }): Promise; + check?(): Promise<{ ok: boolean; displayName: string; diff --git a/packages/paykit/src/webhook/webhook.api.ts b/packages/paykit/src/webhook/webhook.api.ts index 4a7ee4bb..2d76bf35 100644 --- a/packages/paykit/src/webhook/webhook.api.ts +++ b/packages/paykit/src/webhook/webhook.api.ts @@ -9,6 +9,18 @@ function headersToRecord(headers: Headers): Record { return result; } +function shouldAllowStaleSignatures(headers: Headers): boolean { + if (headers.get("x-paykit-cloud-replay") !== "1") { + return false; + } + + return ( + process.env.PAYKIT_ALLOW_STALE_SIGNATURES === "1" || + process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "test" + ); +} + /** Applies an incoming provider webhook payload. */ export const receiveWebhook = definePayKitMethod( { @@ -18,10 +30,14 @@ export const receiveWebhook = definePayKitMethod( path: "/webhook", requireHeaders: true, requireRequest: true, - resolveInput: async (ctx) => ({ - body: await ctx.request!.text(), - headers: headersToRecord(ctx.headers ?? new Headers()), - }), + resolveInput: async (ctx) => { + const headers = ctx.headers ?? new Headers(); + return { + allowStaleSignatures: shouldAllowStaleSignatures(headers), + body: await ctx.request!.text(), + headers: headersToRecord(headers), + }; + }, }, }, // TODO: if we'll add multiple providers on one app, we gotta make sure detecting provider based on request HERE diff --git a/packages/paykit/src/webhook/webhook.service.ts b/packages/paykit/src/webhook/webhook.service.ts index 0f25e2bf..6337c837 100644 --- a/packages/paykit/src/webhook/webhook.service.ts +++ b/packages/paykit/src/webhook/webhook.service.ts @@ -16,6 +16,7 @@ import { import type { AnyNormalizedWebhookEvent, WebhookApplyAction } from "../types/events"; export interface HandleWebhookInput { + allowStaleSignatures?: boolean; body: string; headers: Record; } @@ -209,6 +210,7 @@ export async function handleWebhook( return ctx.logger.trace.run("wh", async () => { const startTime = Date.now(); const events = await ctx.provider.handleWebhook({ + allowStaleSignatures: input.allowStaleSignatures, body: input.body, headers: input.headers, }); diff --git a/packages/stripe/src/stripe-provider.ts b/packages/stripe/src/stripe-provider.ts index 76fb9667..51c324a3 100644 --- a/packages/stripe/src/stripe-provider.ts +++ b/packages/stripe/src/stripe-provider.ts @@ -14,6 +14,18 @@ import StripeSdk from "stripe"; export const PAYKIT_STRIPE_API_VERSION = "2025-10-29.clover"; const STRIPE_MANAGED_PAYMENTS_MIN_VERSION = "2026-03-04.preview"; +const STRIPE_WEBHOOK_EVENTS: StripeSdk.WebhookEndpointCreateParams.EnabledEvent[] = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "invoice.created", + "invoice.finalized", + "invoice.paid", + "invoice.payment_failed", + "invoice.updated", + "payment_method.detached", +]; export interface StripeOptions { secretKey: string; @@ -173,6 +185,26 @@ function assertStripeTestKey(options: StripeOptions): void { } } +function getStripeEnvironment(secretKey: string): string { + return secretKey.startsWith("sk_test_") || secretKey.startsWith("rk_test_") ? "test" : "live"; +} + +function getStripeDisplayName(account: StripeSdk.Account): string { + return account.settings?.dashboard?.display_name || account.business_profile?.name || account.id; +} + +function isStripeResourceMissingError(error: unknown): boolean { + if (!(error instanceof StripeSdk.errors.StripeError)) { + return false; + } + + return ( + error.type === "StripeInvalidRequestError" && + error.code === "resource_missing" && + error.statusCode === 404 + ); +} + async function retrieveExpandedSubscription( client: StripeSdk, providerSubscriptionId: string, @@ -925,7 +957,13 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING); } - const event = client.webhooks.constructEvent(data.body, signature, options.webhookSecret); + const tolerance = data.allowStaleSignatures ? Number.POSITIVE_INFINITY : undefined; + const event = client.webhooks.constructEvent( + data.body, + signature, + options.webhookSecret, + tolerance, + ); return [ ...(await createCheckoutCompletedEvents(client, event)), ...(await createSubscriptionEvents(event)), @@ -934,6 +972,54 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): ]; }, + async getTunnelAccount() { + const account = await client.accounts.retrieve(); + const displayName = getStripeDisplayName(account); + return { + displayName, + environment: getStripeEnvironment(options.secretKey), + providerAccountId: account.id, + providerId: "stripe", + }; + }, + + async ensureTunnelWebhook(data) { + if (data.existingEndpointId) { + try { + const endpoint = await client.webhookEndpoints.update(data.existingEndpointId, { + enabled_events: STRIPE_WEBHOOK_EVENTS, + url: data.url, + }); + return { + created: false, + endpointId: endpoint.id, + webhookSecret: options.webhookSecret, + }; + } catch (error) { + if (!isStripeResourceMissingError(error)) { + throw error; + } + + // Fall through to create a fresh endpoint when the stored one no longer exists. + } + } + + const endpoint = await client.webhookEndpoints.create({ + enabled_events: STRIPE_WEBHOOK_EVENTS, + url: data.url, + }); + + return { + created: true, + endpointId: endpoint.id, + webhookSecret: endpoint.secret ?? undefined, + }; + }, + + async disableTunnelWebhook(data) { + await client.webhookEndpoints.del(data.endpointId); + }, + async createPortalSession(data) { const session = await client.billingPortal.sessions.create({ customer: data.providerCustomerId, @@ -943,14 +1029,10 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): }, async check() { - const mode = - options.secretKey.startsWith("sk_test_") || options.secretKey.startsWith("rk_test_") - ? "test mode" - : "live mode"; + const mode = getStripeEnvironment(options.secretKey) === "test" ? "test mode" : "live mode"; try { const account = await client.accounts.retrieve(); - const displayName = - account.settings?.dashboard?.display_name || account.business_profile?.name || account.id; + const displayName = getStripeDisplayName(account); let webhookEndpoints: Array<{ url: string; status: string }> = []; try { diff --git a/scripts/worktree-setup.sh b/scripts/worktree-setup.sh index a373f8a5..66ee6ce7 100755 --- a/scripts/worktree-setup.sh +++ b/scripts/worktree-setup.sh @@ -6,6 +6,7 @@ ROOT=$(git worktree list | head -1 | awk '{print $1}') # alias .env files to root ln -sf "$ROOT/.env" ./ +ln -sf "$ROOT/.dev.vars" ./apps/wh/ ln -sf "$ROOT/apps/web/.env" ./apps/web/ ln -sf "$ROOT/apps/demo/.env" ./apps/demo/ ln -sf "$ROOT/ob" ./ob