From 5b63c478927e83610e7cdd2663ecf2d9d209c033 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 14 Oct 2026 23:20:29 +0000 Subject: [PATCH 1/3] wip: engine core, ledger, worker, TinyLaunch adapter, acceptance tests Co-Authored-By: Claude Opus 5.5 --- catalog/seed.json | 36 + package.json | 62 ++ pnpm-lock.yaml | 2104 ++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + src/adapters/index.ts | 10 + src/adapters/tinylaunch.ts | 241 +++++ src/adapters/types.ts | 94 ++ src/budgets.ts | 54 + src/campaigns.ts | 400 +++++++ src/catalog.ts | 360 ++++++ src/db.ts | 70 ++ src/ledger.ts | 137 +++ src/migrations.ts | 236 ++++ src/products.ts | 120 ++ src/reports.ts | 169 +++ src/service.ts | 242 +++++ src/state.ts | 60 + src/util.ts | 107 ++ src/worker.ts | 291 +++++ src/workspaces.ts | 135 +++ test/campaign.test.ts | 137 +++ test/coverage.test.ts | 102 ++ test/helpers.ts | 127 +++ test/security.test.ts | 75 ++ test/worker.test.ts | 237 ++++ tsconfig.build.json | 5 + tsconfig.json | 20 + vitest.config.ts | 8 + 28 files changed, 5641 insertions(+) create mode 100644 catalog/seed.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/tinylaunch.ts create mode 100644 src/adapters/types.ts create mode 100644 src/budgets.ts create mode 100644 src/campaigns.ts create mode 100644 src/catalog.ts create mode 100644 src/db.ts create mode 100644 src/ledger.ts create mode 100644 src/migrations.ts create mode 100644 src/products.ts create mode 100644 src/reports.ts create mode 100644 src/service.ts create mode 100644 src/state.ts create mode 100644 src/util.ts create mode 100644 src/worker.ts create mode 100644 src/workspaces.ts create mode 100644 test/campaign.test.ts create mode 100644 test/coverage.test.ts create mode 100644 test/helpers.ts create mode 100644 test/security.test.ts create mode 100644 test/worker.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/catalog/seed.json b/catalog/seed.json new file mode 100644 index 0000000..1da6588 --- /dev/null +++ b/catalog/seed.json @@ -0,0 +1,36 @@ +{ + "note": "Reviewed seed records. A record here is research, not coverage: none is live-tested, so none counts toward the 100-destination release gate.", + "destinations": [ + { + "slug": "tinylaunch", + "name": "TinyLaunch", + "canonical_domain": "tinylaunch.com", + "listing_group": "tinylaunch.com", + "kind": "launch", + "submission_url": "https://www.tinylaunch.com/api/v1/launches", + "required_fields": ["name", "tagline", "description", "url", "category_id", "logo"], + "account_requirement": "email_otp_maker_profile", + "fee_status": "verified_free", + "fee_conditions": "Free launch uses a daily free-slot pool; premium ($39) and a 110-directory service ($279) are separate paid options that SubmitFleet never selects.", + "fee_evidence_url": "https://www.tinylaunch.com/llms.txt", + "fee_checked_at": "2026-10-14T00:00:00.000Z", + "automation_method": "api", + "automation_permission": "permitted", + "permission_evidence": "Official agent guide documents the API for agents: https://www.tinylaunch.com/agents and https://www.tinylaunch.com/llms.txt", + "permission_checked_at": "2026-10-14T00:00:00.000Z", + "challenge_policy": "none", + "backlink_required": false, + "quality_decision": "unreviewed", + "quality_basis": null, + "adapter_id": "tinylaunch", + "adapter_version": "0.1.0", + "adapter_health": "unknown", + "reconcile_support": "limited", + "reconcile_limitation": "Launches are not listable via the documented API; confirmation comes from the POST receipt and the public launch page.", + "queue_limit_per_account": 1, + "queue_window_hours": 24, + "source": "https://www.tinylaunch.com/llms.txt", + "source_license": "facts only" + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8a9a727 --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "@profullstack/submitfleet", + "version": "0.1.0", + "description": "SubmitFleet: one product profile, coordinated launch and directory submissions, proof of what went live. Self-hostable engine with CLI, TUI, HTTP API, MCP server and a mobile dashboard.", + "type": "module", + "license": "MIT", + "homepage": "https://submitfleet.com", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/submitfleet.git" + }, + "bugs": { + "url": "https://github.com/profullstack/submitfleet/issues" + }, + "keywords": [ + "launch", + "directory", + "submission", + "mcp", + "cli", + "startup" + ], + "bin": { + "submitfleet": "dist/cli.js" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist", + "catalog", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22.5.0" + }, + "packageManager": "pnpm@11.18.0", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsx src/cli.ts", + "start": "node dist/cli.js serve", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "@hono/node-server": "2.1.5", + "@libsql/client": "0.18.0", + "@modelcontextprotocol/sdk": "1.32.1", + "hono": "4.13.13", + "zod": "4.6.7" + }, + "devDependencies": { + "@types/node": "^26.6.5", + "tsx": "^4.23.15", + "typescript": "^7.0.2", + "vitest": "5.0.4" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..fab96fe --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2104 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hono/node-server': + specifier: 2.1.5 + version: 2.1.5(hono@4.13.13) + '@libsql/client': + specifier: 0.18.0 + version: 0.18.0 + '@modelcontextprotocol/sdk': + specifier: 1.32.1 + version: 1.32.1(zod@4.6.7) + hono: + specifier: 4.13.13 + version: 4.13.13 + zod: + specifier: 4.6.7 + version: 4.6.7 + devDependencies: + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: 5.0.4 + version: 5.0.4(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15)) + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@libsql/client@0.18.0': + resolution: {integrity: sha512-zMCCo58vv7w27j7+cnt/GW+X6/Rih6cHZ86PKi0AxCMOqMfrVgG9OZ5lW2bDMJhSeOjsv2szH2nJF3A9l36UmA==} + + '@libsql/core@0.18.0': + resolution: {integrity: sha512-N8RR2CIpcgtbKZnXs2KVpw2lJQfinUj3I56e7qRDRpK18Hty0Rr8nr00VmFb4R/DsqOanPB/TaP3+QAQLZeXMg==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.10.0': + resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/mocker@5.0.4': + resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.4': + resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + js-base64@3.9.4: + resolution: {integrity: sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.4: + resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.4 + '@vitest/browser-preview': 5.0.4 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.4 + '@vitest/coverage-v8': 5.0.4 + '@vitest/ui': 5.0.4 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hono/node-server@2.1.5(hono@4.13.13)': + dependencies: + hono: 4.13.13 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@libsql/client@0.18.0': + dependencies: + '@libsql/core': 0.18.0 + '@libsql/hrana-client': 0.10.0 + js-base64: 3.9.4 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.18.0': + dependencies: + js-base64: 3.9.4 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.10.0': + dependencies: + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.9.4 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.2 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': + dependencies: + '@hono/node-server': 2.1.5(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.13 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + + '@neon-rs/load@0.0.4': {} + + '@oxc-project/types@0.153.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 + + '@types/ws@8.18.2': + dependencies: + '@types/node': 26.6.5 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/mocker@5.0.4(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.4 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15) + + '@vitest/spy@5.0.4': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + assertion-error@2.0.1: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@6.3.0: {} + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + detect-libc@2.0.2: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + expect-type@1.4.0: {} + + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.4 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.13.13: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.12: {} + + js-base64@3.9.4: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@3.0.0: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkce-challenge@5.0.1: {} + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + promise-limit@2.7.0: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.2: {} + + statuses@2.0.2: {} + + std-env@4.3.0: {} + + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + toidentifier@1.0.1: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.9.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.30 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.5 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitest@5.0.4(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.4(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(tsx@4.23.15) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.5 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + wrappy@1.0.2: {} + + ws@8.22.0: {} + + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..c9246c9 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,10 @@ +import { tinyLaunchAdapter } from './tinylaunch.js'; +import type { Adapter } from './types.js'; + +/** Adapters shipped in this build. Presence here is necessary, never sufficient, for coverage. */ +export function builtinAdapters(): Map { + const list = [tinyLaunchAdapter()]; + return new Map(list.map((a) => [a.id, a])); +} + +export type { Adapter } from './types.js'; diff --git a/src/adapters/tinylaunch.ts b/src/adapters/tinylaunch.ts new file mode 100644 index 0000000..00da524 --- /dev/null +++ b/src/adapters/tinylaunch.ts @@ -0,0 +1,241 @@ +// TinyLaunch adapter, built against the official agent guide (https://www.tinylaunch.com/llms.txt, +// read 2026-10-14). Free route only: `premium` and `submission_service_tier` are never +// sent, and any response that carries a payment link or a premium/unpaid flag stops the +// job for a human instead of being reported as success. +// +// STATUS: contract-tested against recorded shapes only. No live submission has been +// made, so the catalog record must not be marked live-tested until one is. + +import type { Destination } from '../catalog.js'; +import type { Product } from '../products.js'; +import { SubmitFleetError } from '../util.js'; +import type { Adapter, ExecContext, ExecResult, PrepareContext, ReconcileResult, ValidationIssue } from './types.js'; + +export const TINYLAUNCH_BASE = 'https://www.tinylaunch.com/api/v1'; +const MAX_LOGO_BYTES = 209_715; +const LOGO_EXT = /\.(png|jpe?g|webp)$/i; +const MAX_DATE_ATTEMPTS = 3; + +interface TinyLaunchPayload { + startup: { name: string; tagline: string; description: string; url: string; category_id: number; logo: { filename: string; content_base64: string } }; + earliest_date: string | null; + latest_date: string | null; + _estimated_cost_cents: number; +} + +export function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); +} + +/** Plain text paragraphs to TinyLaunch's allowed HTML subset (

only, escaped). */ +export function toDescriptionHtml(text: string): string { + return text + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => `

${escapeHtml(p).replace(/\n/g, '
')}

`) + .join(''); +} + +function fields(product: Product): Record { + return product.destination_fields.tinylaunch ?? {}; +} + +async function api(ctx: ExecContext, method: string, path: string, token: string | null, body?: unknown): Promise<{ status: number; json: Record }> { + const res = await ctx.fetch(`${TINYLAUNCH_BASE}${path}`, { + method, + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, + signal: ctx.signal, + redirect: 'error', + }); + let json: Record = {}; + try { + json = (await res.json()) as Record; + } catch { + // non-JSON body; status alone decides + } + return { status: res.status, json }; +} + +function paidSignal(j: Record): string | null { + if (j.payment_url) return 'payment_url_returned'; + if (j.premium === true) return 'premium_flag_set'; + if (j.paid === false) return 'unpaid_launch'; + if (j.submission_service_tier) return 'paid_tier_set'; + return null; +} + +export function tinyLaunchAdapter(): Adapter { + return { + id: 'tinylaunch', + version: '0.1.0', + destinationSlug: 'tinylaunch', + method: 'api', + + capabilities() { + return { + update: false, + withdraw: false, + reconcile: 'limited', + reconcileLimitation: + 'The documented API lists your startups but not your launches, so a scheduled launch is confirmed from the POST /launches receipt and the public /launch/{id} page; moderator review status is not exposed.', + }; + }, + + validate(product: Product, _d: Destination): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const need = (ok: boolean, field: string, code: string, message: string) => ok || issues.push({ field, code, message }); + need(!!product.name && product.name.length <= 30, 'name', 'missing_or_too_long', 'name is required, 1-30 characters'); + need(!!product.tagline && product.tagline.length <= 60, 'tagline', 'missing_or_too_long', 'tagline is required, 1-60 characters'); + need(!!product.description && product.description.length <= 9000, 'description', 'missing_or_too_long', 'description is required'); + need(/^https?:\/\//.test(product.url), 'url', 'invalid', 'url must be http(s)'); + const cat = Number(fields(product).category_id); + need(Number.isInteger(cat) && cat > 0, 'destination_fields.tinylaunch.category_id', 'missing_fact', 'choose a TinyLaunch category id; it is never guessed'); + need(!!product.logo_path && LOGO_EXT.test(product.logo_path), 'logo_path', 'missing_or_bad_format', 'logo must be PNG, JPG or WebP'); + return issues; + }, + + async prepare(ctx: PrepareContext) { + const p = ctx.product; + const issues = this.validate(p, ctx.destination); + if (issues.length) throw new SubmitFleetError('validation_failed', issues.map((i) => i.field).join(', '), issues); + const logo = await ctx.readAsset(p.logo_path!); + if (logo.length > MAX_LOGO_BYTES) throw new SubmitFleetError('logo_too_large', `${logo.length} bytes > ${MAX_LOGO_BYTES}`); + const payload: TinyLaunchPayload = { + startup: { + name: p.name!, + tagline: p.tagline!, + description: toDescriptionHtml(p.description!), + url: p.url, + category_id: Number(fields(p).category_id), + logo: { filename: p.logo_path!.split('/').pop()!, content_base64: logo.toString('base64') }, + }, + earliest_date: ctx.earliestDate ?? null, + latest_date: ctx.latestDate ?? null, + _estimated_cost_cents: 0, + }; + return payload as unknown as Record; + }, + + async preflight(ctx) { + const creds = await ctx.credentials(); + if (!creds.access_token) return { ok: false, reasonCode: 'session_missing' }; + const exp = Number(creds.expires_at_ms ?? 0); + if (exp && exp - ctx.now() < 5 * 60_000) return { ok: false, reasonCode: 'session_expired' }; + return { ok: true }; + }, + + async execute(ctx): Promise { + const payload = ctx.payload as unknown as TinyLaunchPayload; + const { access_token: token } = await ctx.credentials(); + if (!token) return { kind: 'needs_human', reasonCode: 'session_missing', externalActionPerformed: false }; + + const maker = await api(ctx, 'GET', '/maker', token); + if (maker.status === 401) return { kind: 'needs_human', reasonCode: 'session_expired', externalActionPerformed: !!ctx.remoteRefs.startup_id }; + // Creating a maker profile is account setup, a separately authorised operation. + if (maker.status === 404) return { kind: 'needs_human', reasonCode: 'maker_profile_missing', externalActionPerformed: false }; + if (maker.status !== 200) return { kind: 'failed', reasonCode: `maker_http_${maker.status}`, externalActionPerformed: !!ctx.remoteRefs.startup_id }; + + let startupId = ctx.remoteRefs.startup_id as number | undefined; + if (!startupId) { + const s = await api(ctx, 'POST', '/startups', token, payload.startup); + if (s.status === 201 && typeof s.json.id === 'number') { + startupId = s.json.id; + await ctx.recordRemote({ startup_id: startupId, startup_slug: s.json.slug ?? null }); + } else if (s.status >= 500) { + return { kind: 'uncertain', reasonCode: `startup_http_${s.status}` }; + } else { + return { kind: 'failed', reasonCode: String(s.json.error ?? `startup_http_${s.status}`), externalActionPerformed: false, evidence: { status: s.status, error: s.json.error } }; + } + } + + const dates = await bookableFreeDates(ctx, payload.earliest_date, payload.latest_date); + if (!dates.length) return { kind: 'needs_human', reasonCode: 'no_free_date_in_approved_window', externalActionPerformed: true }; + + for (const date of dates.slice(0, MAX_DATE_ATTEMPTS)) { + // Free route: exactly startup_id + date. Never premium, never a submission tier. + const l = await api(ctx, 'POST', '/launches', token, { startup_id: startupId, date }); + const paid = paidSignal(l.json); + if (paid) { + await ctx.recordRemote({ launch_id: l.json.id ?? null, unexpected_payment: paid }); + return { kind: 'needs_human', reasonCode: `unexpected_payment:${paid}`, externalActionPerformed: true, evidence: { launch_id: l.json.id ?? null } }; + } + if (l.status === 201 && l.json.id != null) { + const launchId = String(l.json.id); + await ctx.recordRemote({ launch_id: launchId, launch_date: date }); + return { + kind: 'scheduled', + remoteId: launchId, + receiptId: `tinylaunch:launch:${launchId}`, + listingUrl: `https://www.tinylaunch.com/launch/${launchId}`, + costCents: 0, + evidence: { startup_id: startupId, launch_id: launchId, date, premium: l.json.premium ?? null }, + }; + } + if (l.status === 409 && l.json.error === 'date_full') continue; + if (l.status === 409 && l.json.error === 'duplicate_launch') return { kind: 'needs_human', reasonCode: 'duplicate_launch_reported', externalActionPerformed: true }; + if (l.status >= 500) return { kind: 'uncertain', reasonCode: `launch_http_${l.status}` }; + return { kind: 'needs_human', reasonCode: String(l.json.error ?? `launch_http_${l.status}`), externalActionPerformed: true }; + } + return { kind: 'needs_human', reasonCode: 'free_dates_full', externalActionPerformed: true }; + }, + + async reconcile(ctx): Promise { + const launchId = ctx.remoteRefs.launch_id; + if (launchId) { + const res = await ctx.fetch(`https://www.tinylaunch.com/launch/${launchId}`, { method: 'GET', signal: ctx.signal, redirect: 'follow' }); + if (res.status === 200) return { status: 'found', state: 'scheduled', remoteId: String(launchId), listingUrl: `https://www.tinylaunch.com/launch/${launchId}`, evidence: { http: 200 } }; + return { status: 'unknown', reasonCode: `launch_page_http_${res.status}` }; + } + const { access_token: token } = await ctx.credentials(); + if (!token) return { status: 'unknown', reasonCode: 'session_missing' }; + const list = await api(ctx, 'GET', '/startups', token); + if (list.status !== 200) return { status: 'unknown', reasonCode: `startups_http_${list.status}` }; + const items = (Array.isArray(list.json) ? list.json : (list.json.startups as unknown[]) ?? []) as Record[]; + const want = (ctx.payload as unknown as TinyLaunchPayload).startup.url.replace(/\/+$/, ''); + const match = items.find((s) => String(s.url ?? '').replace(/\/+$/, '') === want); + if (match) { + await ctx.recordRemote({ startup_id: match.id }); + // The startup exists but launches are not listable: a launch may or may not exist. + return { status: 'unknown', reasonCode: 'startup_found_launch_unknown', evidence: { startup_id: match.id } }; + } + // No startup means no launch could have been created for it. + return { status: 'absent', evidence: { startups_checked: items.length } }; + }, + }; +} + +async function bookableFreeDates(ctx: ExecContext, earliest: string | null, latest: string | null): Promise { + const out: string[] = []; + let offset: number | null = 0; + for (let page = 0; page < 5 && offset != null; page++) { + const r = await api(ctx, 'GET', `/launch-dates?offset=${offset}`, null); + if (r.status !== 200) break; + const windows = (r.json.windows as { date: string; bookable_free: boolean }[]) ?? []; + for (const w of windows) { + if (!w.bookable_free) continue; + if (earliest && w.date < earliest) continue; + if (latest && w.date > latest) return out; + out.push(w.date); + } + offset = typeof r.json.next_offset === 'number' ? r.json.next_offset : null; + if (out.length >= MAX_DATE_ATTEMPTS) break; + } + return out; +} + +/** OTP login handoff. The code comes from the owner's inbox; it is never stored. */ +export async function tinyLaunchRequestCode(f: typeof fetch, email: string): Promise { + const r = await f(`${TINYLAUNCH_BASE}/auth/request-code`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }) }); + if (!r.ok) throw new SubmitFleetError('otp_request_failed', `HTTP ${r.status}`); +} + +export async function tinyLaunchVerify(f: typeof fetch, email: string, code: string, now = Date.now()): Promise<{ secret: Record; expiresAt: string }> { + const r = await f(`${TINYLAUNCH_BASE}/auth/verify`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, code }) }); + const j = (await r.json().catch(() => ({}))) as Record; + if (!r.ok || !j.access_token) throw new SubmitFleetError('otp_verify_failed', j.error ?? `HTTP ${r.status}`); + // The guide says tokens last about an hour and documents no refresh call. + const exp = now + 55 * 60_000; + return { secret: { access_token: j.access_token, refresh_token: j.refresh_token ?? '', expires_at_ms: String(exp) }, expiresAt: new Date(exp).toISOString() }; +} diff --git a/src/adapters/types.ts b/src/adapters/types.ts new file mode 100644 index 0000000..1be8da0 --- /dev/null +++ b/src/adapters/types.ts @@ -0,0 +1,94 @@ +import type { Destination } from '../catalog.js'; +import type { Product } from '../products.js'; + +export interface AdapterCapabilities { + update: boolean; + withdraw: boolean; + /** 'tested' = reconcile proven against the live destination; 'limited' = see limitation text. */ + reconcile: 'tested' | 'limited' | 'none'; + reconcileLimitation?: string; +} + +export interface ValidationIssue { + field: string; + code: string; + message: string; +} + +export interface PrepareContext { + product: Product; + destination: Destination; + /** Approved date window, YYYY-MM-DD. */ + earliestDate?: string | null; + latestDate?: string | null; + readAsset: (path: string) => Promise; +} + +export interface ExecContext { + intentId: string; + payload: Record; + destination: Destination; + /** Remote references persisted by earlier attempts (e.g. a created startup id). */ + remoteRefs: Record; + /** Persist a remote reference immediately, before the next remote write. */ + recordRemote: (refs: Record) => Promise; + /** Credentials are fetched on demand and never returned to callers or logged. */ + credentials: () => Promise>; + fetch: typeof fetch; + signal: AbortSignal; + now: () => number; +} + +export type ExecResult = + | { + kind: 'submitted' | 'scheduled'; + remoteId: string; + receiptId: string; + listingUrl?: string | null; + costCents?: number; + evidence: Record; + } + | { + kind: 'needs_human' | 'failed'; + reasonCode: string; + /** True when a remote write is known to have happened before stopping. */ + externalActionPerformed: boolean; + evidence?: Record; + } + | { kind: 'uncertain'; reasonCode: string; evidence?: Record }; + +export type ReconcileResult = + | { + status: 'found'; + state: 'submitted' | 'scheduled' | 'under_review' | 'published_verified' | 'rejected' | 'removal_observed'; + remoteId?: string; + listingUrl?: string | null; + evidence: Record; + } + /** Definitive: the destination proves no write exists, so a retry cannot duplicate. */ + | { status: 'absent'; evidence: Record } + | { status: 'unknown'; reasonCode: string; evidence?: Record }; + +export interface PreflightResult { + ok: boolean; + reasonCode?: string; + evidence?: Record; +} + +export interface Adapter { + id: string; + version: string; + destinationSlug: string; + method: 'api' | 'browser'; + capabilities(): AdapterCapabilities; + /** Pure checks against the approved product facts. No network. */ + validate(product: Product, destination: Destination): ValidationIssue[]; + /** Builds the destination payload. Must not perform any remote write (no drafts). */ + prepare(ctx: PrepareContext): Promise>; + /** Non-mutating checks immediately before a write: fee/policy evidence, session, dates. */ + preflight(ctx: ExecContext): Promise; + execute(ctx: ExecContext): Promise; + reconcile(ctx: ExecContext): Promise; + update?(ctx: ExecContext): Promise; + withdraw?(ctx: ExecContext): Promise; +} diff --git a/src/budgets.ts b/src/budgets.ts new file mode 100644 index 0000000..bc5a906 --- /dev/null +++ b/src/budgets.ts @@ -0,0 +1,54 @@ +import { one, run, type Db } from './db.js'; +import { newId, nowIso } from './util.js'; + +export async function createBudget(db: Db, workspaceId: string, campaignId: string, limitCents: number): Promise { + const existing = await one<{ id: string }>(db, 'SELECT id FROM budgets WHERE campaign_id = ?', campaignId); + if (existing) { + await run(db, 'UPDATE budgets SET limit_cents = MAX(?, reserved_cents + spent_cents) WHERE id = ?', limitCents, existing.id); + return existing.id; + } + const id = newId('bud'); + await run(db, 'INSERT INTO budgets (id, workspace_id, campaign_id, limit_cents) VALUES (?, ?, ?, ?)', id, workspaceId, campaignId, limitCents); + return id; +} + +/** + * Atomically reserves operating spend. A single conditional UPDATE is the whole + * check-and-increment, so parallel workers cannot overspend the same balance. + */ +export async function reserve(db: Db, budgetId: string, intentId: string, amountCents: number): Promise { + if (amountCents < 0) throw new Error('negative_reservation'); + const n = await run( + db, + 'UPDATE budgets SET reserved_cents = reserved_cents + ? WHERE id = ? AND reserved_cents + spent_cents + ? <= limit_cents', + amountCents, + budgetId, + amountCents, + ); + if (n !== 1) return null; + const id = newId('res'); + await run(db, "INSERT INTO reservations (id, budget_id, intent_id, amount_cents, status, created_at) VALUES (?, ?, ?, ?, 'reserved', ?)", id, budgetId, intentId, amountCents, nowIso()); + return id; +} + +/** Settles actual usage. Spend above the reservation is capped and flagged, never silently absorbed. */ +export async function settle(db: Db, reservationId: string, actualCents: number): Promise<{ charged: number; overrun: number }> { + const r = await one<{ budget_id: string; amount_cents: number; status: string }>(db, 'SELECT budget_id, amount_cents, status FROM reservations WHERE id = ?', reservationId); + if (!r || r.status !== 'reserved') return { charged: 0, overrun: 0 }; + const amount = Number(r.amount_cents); + const charged = Math.min(Math.max(actualCents, 0), amount); + const changed = await run(db, "UPDATE reservations SET status = 'settled', actual_cents = ? WHERE id = ? AND status = 'reserved'", actualCents, reservationId); + if (changed) await run(db, 'UPDATE budgets SET reserved_cents = reserved_cents - ?, spent_cents = spent_cents + ? WHERE id = ?', amount, charged, r.budget_id); + return { charged, overrun: Math.max(actualCents - amount, 0) }; +} + +export async function release(db: Db, reservationId: string): Promise { + const r = await one<{ budget_id: string; amount_cents: number }>(db, "SELECT budget_id, amount_cents FROM reservations WHERE id = ? AND status = 'reserved'", reservationId); + if (!r) return; + const changed = await run(db, "UPDATE reservations SET status = 'released' WHERE id = ? AND status = 'reserved'", reservationId); + if (changed) await run(db, 'UPDATE budgets SET reserved_cents = reserved_cents - ? WHERE id = ?', Number(r.amount_cents), r.budget_id); +} + +export async function getBudget(db: Db, campaignId: string): Promise<{ id: string; limit_cents: number; reserved_cents: number; spent_cents: number } | null> { + return one(db, 'SELECT id, limit_cents, reserved_cents, spent_cents FROM budgets WHERE campaign_id = ?', campaignId); +} diff --git a/src/campaigns.ts b/src/campaigns.ts new file mode 100644 index 0000000..54efa22 --- /dev/null +++ b/src/campaigns.ts @@ -0,0 +1,400 @@ +import type { Adapter } from './adapters/types.js'; +import { createBudget } from './budgets.js'; +import { listDestinations, qualify, RELEASE_MINIMUM, type Destination } from './catalog.js'; +import { all, one, run, type Db } from './db.js'; +import { requireProduct, type Product } from './products.js'; +import { appendEvent } from './ledger.js'; +import { hashValue, newId, nowIso, parseJson, sha256, SubmitFleetError } from './util.js'; +import { requireOwner, usableAccount, type Actor } from './workspaces.js'; + +export interface CampaignPolicy { + /** Default and only supported mode today: zero listing spend. */ + free_only: true; + allow_conditional_free: false; + allow_account_creation: false; +} + +export const DEFAULT_POLICY: CampaignPolicy = { free_only: true, allow_conditional_free: false, allow_account_creation: false }; + +export interface PlanInput { + productId: string; + minExecutableDestinations?: number; + spendLimitCents?: number; + earliestDate?: string | null; + latestDate?: string | null; + releaseScope?: string; + /** Restrict to these destination slugs; otherwise every catalog entry is considered. */ + destinations?: string[]; +} + +export interface TargetPlan { + destination: string; + listing_group: string; + executable: boolean; + blockers: string[]; + account_id: string | null; + payload_hash: string | null; +} + +export interface Campaign { + id: string; + workspace_id: string; + product_id: string; + status: string; + min_executable: number; + release_scope: string; + policy: CampaignPolicy; + spend_limit_cents: number; + earliest_date: string | null; + latest_date: string | null; + executable_count: number; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface CampaignPlan { + campaign: Campaign; + counts: { + considered: number; + release_qualifying: number; + product_eligible: number; + account_ready: number; + not_duplicate: number; + executable: number; + min_executable: number; + }; + targets: TargetPlan[]; +} + +export interface PlanDeps { + adapters: Map; + readAsset: (path: string) => Promise; + now?: () => number; +} + +function rowToCampaign(r: Record): Campaign { + return { + ...(r as unknown as Campaign), + policy: parseJson(r.policy_json, DEFAULT_POLICY), + min_executable: Number(r.min_executable), + spend_limit_cents: Number(r.spend_limit_cents), + executable_count: Number(r.executable_count), + }; +} + +export async function getCampaign(db: Db, workspaceId: string, id: string): Promise { + const r = await one>(db, 'SELECT * FROM campaigns WHERE id = ? AND workspace_id = ?', id, workspaceId); + if (!r) throw new SubmitFleetError('not_found', `campaign ${id}`); + return rowToCampaign(r); +} + +export async function listCampaigns(db: Db, workspaceId: string, limit = 100, offset = 0): Promise { + const rows = await all>(db, 'SELECT * FROM campaigns WHERE workspace_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', workspaceId, limit, offset); + return rows.map(rowToCampaign); +} + +export function dedupeKey(workspaceId: string, product: Product, d: Destination, releaseScope: string): string { + // Deliberately excludes the account and the draft wording: a second account or a + // reworded pitch for the same product at the same listing surface is a duplicate. + return sha256([workspaceId, product.canonical_key, d.listing_group, releaseScope].join('|')); +} + +function productFits(product: Product, d: Destination): boolean { + if (!d.categories.length) return true; + const mine = new Set(product.categories.map((c) => c.toLowerCase())); + return d.categories.some((c) => mine.has(c.toLowerCase())); +} + +export function productContentHash(p: Product): string { + return hashValue({ + url: p.url, + name: p.name, + tagline: p.tagline, + description: p.description, + categories: p.categories, + contact_email: p.contact_email, + logo_path: p.logo_path, + facts: p.facts, + destination_fields: p.destination_fields, + }); +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** Builds (or rebuilds) a campaign plan. Re-planning invalidates any prior approval. */ +export async function planCampaign(db: Db, actor: Actor, input: PlanInput, deps: PlanDeps, campaignId?: string): Promise { + const product = await requireProduct(db, actor.workspaceId, input.productId); + const min = input.minExecutableDestinations ?? RELEASE_MINIMUM; + if (!Number.isInteger(min) || min < 1) throw new SubmitFleetError('invalid_request', 'min_executable_destinations must be a positive integer'); + for (const d of [input.earliestDate, input.latestDate]) if (d && !DATE_RE.test(d)) throw new SubmitFleetError('invalid_request', 'dates must be YYYY-MM-DD'); + const releaseScope = input.releaseScope ?? 'initial'; + const now = deps.now?.() ?? Date.now(); + const installed = new Set(deps.adapters.keys()); + + let destinations = await listDestinations(db, { limit: 100_000 }); + if (input.destinations?.length) { + const want = new Set(input.destinations); + destinations = destinations.filter((d) => want.has(d.slug)); + } + + const counts = { considered: 0, release_qualifying: 0, product_eligible: 0, account_ready: 0, not_duplicate: 0, executable: 0, min_executable: min }; + const seenGroups = new Set(); + const targets: (TargetPlan & { destination_id: string; payload: Record | null })[] = []; + + for (const d of destinations) { + counts.considered++; + const blockers: string[] = []; + const q = qualify(d, { now, installedAdapters: installed }); + blockers.push(...q.blockers); + if (q.stages.release_qualifying) counts.release_qualifying++; + + if (seenGroups.has(d.listing_group)) blockers.push('same_listing_group_as_other_target'); + if (!product.approved_at) blockers.push('product_facts_unapproved'); + if (!productFits(product, d)) blockers.push('category_mismatch'); + const adapter = d.adapter_id ? deps.adapters.get(d.adapter_id) : undefined; + if (adapter) for (const issue of adapter.validate(product, d)) blockers.push(`${issue.code}:${issue.field}`); + const eligible = blockers.length === 0; + if (eligible) counts.product_eligible++; + + const account = await usableAccount(db, actor.workspaceId, d.id, now); + if (!account && d.account_requirement !== 'none') blockers.push('account_not_ready'); + else if (eligible) counts.account_ready++; + + const key = dedupeKey(actor.workspaceId, product, d, releaseScope); + const dup = await one<{ id: string; state: string }>(db, 'SELECT id, state FROM intents WHERE dedupe_key = ? AND dedupe_active = 1', key); + if (dup) blockers.push(`duplicate_existing_submission:${dup.state}`); + else if (eligible && account) counts.not_duplicate++; + + let payload: Record | null = null; + if (blockers.length === 0 && adapter) { + try { + payload = await adapter.prepare({ product, destination: d, earliestDate: input.earliestDate, latestDate: input.latestDate, readAsset: deps.readAsset }); + } catch (e) { + blockers.push(`prepare_failed:${e instanceof SubmitFleetError ? e.code : 'error'}`); + } + } + const executable = blockers.length === 0 && payload !== null; + if (executable) { + counts.executable++; + seenGroups.add(d.listing_group); + } + targets.push({ + destination: d.slug, + destination_id: d.id, + listing_group: d.listing_group, + executable, + blockers, + account_id: account?.id ?? null, + payload, + payload_hash: payload ? hashValue(payload) : null, + }); + } + + const status = counts.executable >= min ? 'draft' : 'coverage_insufficient'; + const ts = nowIso(); + const id = campaignId ?? newId('cmp'); + if (campaignId) { + const c = await getCampaign(db, actor.workspaceId, campaignId); + if (!['draft', 'coverage_insufficient', 'awaiting_approval', 'approved'].includes(c.status)) { + throw new SubmitFleetError('campaign_not_replannable', `campaign is ${c.status}`); + } + await run(db, "UPDATE approvals SET status = 'invalidated', reason = 'replanned', decided_at = ? WHERE campaign_id = ? AND status IN ('pending', 'granted')", ts, id); + await run(db, 'DELETE FROM campaign_targets WHERE campaign_id = ?', id); + await run( + db, + 'UPDATE campaigns SET status = ?, min_executable = ?, release_scope = ?, spend_limit_cents = ?, earliest_date = ?, latest_date = ?, executable_count = ?, updated_at = ? WHERE id = ?', + status, min, releaseScope, input.spendLimitCents ?? 0, input.earliestDate, input.latestDate, counts.executable, ts, id, + ); + } else { + await run( + db, + `INSERT INTO campaigns (id, workspace_id, product_id, status, min_executable, release_scope, policy_json, spend_limit_cents, earliest_date, latest_date, executable_count, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, actor.workspaceId, product.id, status, min, releaseScope, JSON.stringify(DEFAULT_POLICY), input.spendLimitCents ?? 0, + input.earliestDate, input.latestDate, counts.executable, actor.id, ts, ts, + ); + } + for (const t of targets) { + await run( + db, + 'INSERT INTO campaign_targets (id, campaign_id, destination_id, account_id, executable, blockers_json, payload_json, payload_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + newId('tgt'), id, t.destination_id, t.account_id, t.executable ? 1 : 0, JSON.stringify(t.blockers), t.payload ? JSON.stringify(t.payload) : null, t.payload_hash, + ); + } + return { + campaign: await getCampaign(db, actor.workspaceId, id), + counts, + targets: targets.map(({ destination_id: _d, payload: _p, ...t }) => t), + }; +} + +interface TargetRow { + destination_id: string; + slug: string; + adapter_id: string | null; + adapter_version: string | null; + account_id: string | null; + executable: number; + blockers_json: string; + payload_json: string | null; + payload_hash: string | null; +} + +async function targetRows(db: Db, campaignId: string): Promise { + return all( + db, + `SELECT t.destination_id, d.slug, d.adapter_id, d.adapter_version, t.account_id, t.executable, t.blockers_json, t.payload_json, t.payload_hash + FROM campaign_targets t JOIN destinations d ON d.id = t.destination_id WHERE t.campaign_id = ? ORDER BY d.slug`, + campaignId, + ); +} + +/** The exact bounded thing an owner approves. Any change to it changes the hash. */ +export async function approvalScope(db: Db, c: Campaign): Promise> { + const product = await requireProduct(db, c.workspace_id, c.product_id); + const targets = (await targetRows(db, c.id)) + .filter((t) => Number(t.executable) === 1) + .map((t) => ({ destination: t.slug, adapter: `${t.adapter_id}@${t.adapter_version}`, account_id: t.account_id, payload_hash: t.payload_hash })); + return { + campaign_id: c.id, + product: { id: product.id, canonical_key: product.canonical_key, content_hash: productContentHash(product), facts_approved_at: product.approved_at }, + targets, + min_executable: c.min_executable, + release_scope: c.release_scope, + earliest_date: c.earliest_date, + latest_date: c.latest_date, + spend_limit_cents: c.spend_limit_cents, + listing_spend_limit_cents: 0, + policy: c.policy, + }; +} + +export async function previewCampaign(db: Db, workspaceId: string, id: string) { + const c = await getCampaign(db, workspaceId, id); + const rows = await targetRows(db, id); + const scope = await approvalScope(db, c); + return { + campaign: c, + scope_hash: hashValue(scope), + executable: rows.filter((t) => Number(t.executable) === 1).map((t) => ({ destination: t.slug, account_id: t.account_id, payload: parseJson(t.payload_json, {}) })), + blocked: rows.filter((t) => Number(t.executable) !== 1).map((t) => ({ destination: t.slug, blockers: parseJson(t.blockers_json, []) })), + }; +} + +export async function requestApproval(db: Db, actor: Actor, campaignId: string): Promise<{ approval_id: string; scope_hash: string }> { + const c = await getCampaign(db, actor.workspaceId, campaignId); + if (c.status === 'coverage_insufficient') { + throw new SubmitFleetError('coverage_insufficient', `${c.executable_count} executable, ${c.min_executable} required`, { executable: c.executable_count, required: c.min_executable }); + } + if (c.status !== 'draft' && c.status !== 'awaiting_approval') throw new SubmitFleetError('invalid_state', `campaign is ${c.status}`); + const scope = await approvalScope(db, c); + const id = newId('apr'); + const ts = nowIso(); + await run(db, "UPDATE approvals SET status = 'invalidated', reason = 'superseded', decided_at = ? WHERE campaign_id = ? AND status = 'pending'", ts, campaignId); + await run( + db, + "INSERT INTO approvals (id, workspace_id, campaign_id, scope_hash, scope_json, status, requested_by, created_at) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)", + id, actor.workspaceId, campaignId, hashValue(scope), JSON.stringify(scope), actor.id, ts, + ); + await run(db, "UPDATE campaigns SET status = 'awaiting_approval', updated_at = ? WHERE id = ?", ts, campaignId); + return { approval_id: id, scope_hash: hashValue(scope) }; +} + +/** Only the owner can grant, and only for the scope as it stands right now. */ +export async function grantApproval(db: Db, actor: Actor, campaignId: string, expectedScopeHash?: string): Promise<{ approval_id: string; scope_hash: string }> { + requireOwner(actor); + const c = await getCampaign(db, actor.workspaceId, campaignId); + const pending = await one<{ id: string; scope_hash: string; requested_by: string }>( + db, + "SELECT id, scope_hash, requested_by FROM approvals WHERE campaign_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1", + campaignId, + ); + if (!pending) throw new SubmitFleetError('no_pending_approval', 'request approval first'); + const current = hashValue(await approvalScope(db, c)); + const ts = nowIso(); + if (current !== pending.scope_hash || (expectedScopeHash && expectedScopeHash !== current)) { + await run(db, "UPDATE approvals SET status = 'invalidated', reason = 'scope_changed', decided_at = ? WHERE id = ?", ts, pending.id); + throw new SubmitFleetError('approval_invalidated', 'the campaign changed after approval was requested; preview and request again'); + } + await run(db, "UPDATE approvals SET status = 'granted', decided_by = ?, decided_at = ? WHERE id = ?", actor.id, ts, pending.id); + await run(db, "UPDATE campaigns SET status = 'approved', updated_at = ? WHERE id = ?", ts, campaignId); + return { approval_id: pending.id, scope_hash: current }; +} + +/** Queues intents for an approved campaign after re-checking the approval still matches. */ +export async function runCampaign(db: Db, actor: Actor, campaignId: string): Promise<{ queued: number; duplicates: string[] }> { + const c = await getCampaign(db, actor.workspaceId, campaignId); + if (c.status === 'paused') { + await run(db, "UPDATE campaigns SET status = 'running', updated_at = ? WHERE id = ?", nowIso(), campaignId); + return { queued: 0, duplicates: [] }; + } + if (c.status !== 'approved') throw new SubmitFleetError('not_approved', `campaign is ${c.status}`); + const approval = await one<{ id: string; scope_hash: string }>( + db, + "SELECT id, scope_hash FROM approvals WHERE campaign_id = ? AND status = 'granted' ORDER BY decided_at DESC LIMIT 1", + campaignId, + ); + if (!approval) throw new SubmitFleetError('not_approved'); + const ts = nowIso(); + if (hashValue(await approvalScope(db, c)) !== approval.scope_hash) { + await run(db, "UPDATE approvals SET status = 'invalidated', reason = 'scope_changed', decided_at = ? WHERE id = ?", ts, approval.id); + await run(db, "UPDATE campaigns SET status = 'draft', updated_at = ? WHERE id = ?", ts, campaignId); + throw new SubmitFleetError('approval_invalidated', 'material change since approval'); + } + const product = await requireProduct(db, actor.workspaceId, c.product_id); + const rows = (await targetRows(db, campaignId)).filter((t) => Number(t.executable) === 1); + const duplicates: string[] = []; + let queued = 0; + await createBudget(db, actor.workspaceId, campaignId, c.spend_limit_cents); + for (const t of rows) { + const d = (await one>(db, 'SELECT listing_group FROM destinations WHERE id = ?', t.destination_id))!; + const key = dedupeKey(actor.workspaceId, product, { listing_group: String(d.listing_group) } as Destination, c.release_scope); + const id = newId('int'); + try { + // Intent is persisted before any external action is attempted. + await run( + db, + `INSERT INTO intents (id, workspace_id, campaign_id, product_id, destination_id, account_id, approval_id, dedupe_key, state, payload_json, payload_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?)`, + id, actor.workspaceId, campaignId, product.id, t.destination_id, t.account_id, approval.id, key, t.payload_json, t.payload_hash, ts, ts, + ); + await appendEvent(db, id, 'queued', { to: 'queued' }); + queued++; + } catch (e) { + if (String(e).includes('UNIQUE')) duplicates.push(t.slug); + else throw e; + } + } + await run(db, "UPDATE campaigns SET status = 'running', updated_at = ? WHERE id = ?", ts, campaignId); + return { queued, duplicates }; +} + +export async function pauseCampaign(db: Db, workspaceId: string, id: string): Promise { + const n = await run(db, "UPDATE campaigns SET status = 'paused', updated_at = ? WHERE id = ? AND workspace_id = ? AND status = 'running'", nowIso(), id, workspaceId); + if (!n) throw new SubmitFleetError('invalid_state', 'only a running campaign can be paused'); +} + +/** Cancels unsent work. Remote listings already created are untouched and stay reported. */ +export async function cancelCampaign(db: Db, workspaceId: string, id: string): Promise<{ canceled: number; already_external: number }> { + const c = await getCampaign(db, workspaceId, id); + if (['canceled', 'completed'].includes(c.status)) throw new SubmitFleetError('invalid_state', `campaign is ${c.status}`); + const ts = nowIso(); + await run(db, "UPDATE campaigns SET status = 'canceled', updated_at = ? WHERE id = ?", ts, id); + await run(db, "UPDATE approvals SET status = 'invalidated', reason = 'canceled', decided_at = ? WHERE campaign_id = ? AND status IN ('pending', 'granted')", ts, id); + const rows = await all<{ id: string }>(db, "SELECT id FROM intents WHERE campaign_id = ? AND state IN ('queued', 'needs_human') AND external_action_performed = 0", id); + for (const r of rows) { + const n = await run(db, "UPDATE intents SET state = 'canceled', dedupe_active = 0, reason_code = 'campaign_canceled', updated_at = ? WHERE id = ? AND state IN ('queued', 'needs_human') AND external_action_performed = 0", ts, r.id); + if (n) await appendEvent(db, r.id, 'canceled', { to: 'canceled' }); + } + const ext = await one<{ n: number }>(db, 'SELECT COUNT(*) AS n FROM intents WHERE campaign_id = ? AND external_action_performed = 1', id); + return { canceled: rows.length, already_external: Number(ext?.n ?? 0) }; +} + +export async function campaignStatus(db: Db, workspaceId: string, id: string) { + const c = await getCampaign(db, workspaceId, id); + const states = await all<{ state: string; n: number }>(db, 'SELECT state, COUNT(*) AS n FROM intents WHERE campaign_id = ? GROUP BY state', id); + const by: Record = {}; + for (const s of states) by[s.state] = Number(s.n); + return { campaign: c, intents: by }; +} diff --git a/src/catalog.ts b/src/catalog.ts new file mode 100644 index 0000000..0cb8fff --- /dev/null +++ b/src/catalog.ts @@ -0,0 +1,360 @@ +import { all, one, run, type Db } from './db.js'; +import { newId, nowIso, parseJson, SubmitFleetError } from './util.js'; + +export const RELEASE_MINIMUM = 100; +/** Proposed: coverage counts only evidence checked within this many days. */ +export const COVERAGE_EVIDENCE_MAX_AGE_DAYS = 30; +/** Proposed: volatile fee/policy claims must be refreshed this recently before a write. */ +export const WRITE_EVIDENCE_MAX_AGE_HOURS = 24; + +const COUNTED_KINDS = new Set(['launch', 'directory']); + +export interface Destination { + id: string; + slug: string; + name: string; + canonical_domain: string; + aliases: string[]; + listing_group: string; + kind: string; + categories: string[]; + submission_url: string | null; + required_fields: string[]; + account_requirement: string; + fee_status: string; + fee_conditions: string | null; + fee_evidence_url: string | null; + fee_checked_at: string | null; + automation_method: string; + automation_permission: string; + permission_evidence: string | null; + permission_checked_at: string | null; + challenge_policy: string; + backlink_required: boolean; + quality_decision: string; + quality_basis: string | null; + adapter_id: string | null; + adapter_version: string | null; + adapter_health: string; + health_reason: string | null; + contract_tested_at: string | null; + live_tested_at: string | null; + live_evidence_ref: string | null; + reconcile_support: string; + reconcile_limitation: string | null; + queue_limit_per_account: number | null; + queue_window_hours: number | null; + cooldown_hours: number | null; + disabled: boolean; + source: string | null; + source_license: string | null; + archived: boolean; + created_at: string; + updated_at: string; +} + +type Row = Record; + +export function rowToDestination(r: Row): Destination { + return { + ...(r as unknown as Destination), + aliases: parseJson(r.aliases_json, []), + categories: parseJson(r.categories_json, []), + required_fields: parseJson(r.required_fields_json, []), + backlink_required: Number(r.backlink_required) === 1, + disabled: Number(r.disabled) === 1, + archived: Number(r.archived) === 1, + queue_limit_per_account: r.queue_limit_per_account == null ? null : Number(r.queue_limit_per_account), + queue_window_hours: r.queue_window_hours == null ? null : Number(r.queue_window_hours), + cooldown_hours: r.cooldown_hours == null ? null : Number(r.cooldown_hours), + }; +} + +// Fields an operator may set through a reviewed registry edit. +const REVIEWED_FIELDS = [ + 'name', 'canonical_domain', 'aliases', 'listing_group', 'kind', 'categories', 'submission_url', + 'required_fields', 'account_requirement', 'fee_status', 'fee_conditions', 'fee_evidence_url', + 'fee_checked_at', 'automation_method', 'automation_permission', 'permission_evidence', + 'permission_checked_at', 'challenge_policy', 'backlink_required', 'quality_decision', 'quality_basis', + 'adapter_id', 'adapter_version', 'adapter_health', 'health_reason', 'contract_tested_at', + 'live_tested_at', 'live_evidence_ref', 'reconcile_support', 'reconcile_limitation', + 'queue_limit_per_account', 'queue_window_hours', 'cooldown_hours', 'disabled', 'source', 'source_license', +] as const; + +// Imports are discovery input only: they may never set anything that enables execution. +const IMPORT_FIELDS = ['name', 'canonical_domain', 'aliases', 'kind', 'categories', 'submission_url', 'source', 'source_license'] as const; + +export type DestinationInput = Partial> & { + slug: string; + name: string; + canonical_domain: string; + kind: string; +}; + +const JSON_FIELDS: Record = { + aliases: 'aliases_json', + categories: 'categories_json', + required_fields: 'required_fields_json', +}; + +function toColumns(input: Record, allowed: readonly string[]): Record { + const cols: Record = {}; + for (const k of allowed) { + if (!(k in input) || input[k] === undefined) continue; + const v = input[k]; + if (JSON_FIELDS[k]) cols[JSON_FIELDS[k]] = JSON.stringify(v ?? []); + else if (typeof v === 'boolean') cols[k] = v ? 1 : 0; + else cols[k] = v; + } + return cols; +} + +export function normalizeDomain(d: string): string { + return d.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, ''); +} + +export async function upsertDestination(db: Db, input: DestinationInput, mode: 'reviewed' | 'import'): Promise { + const allowed = mode === 'reviewed' ? REVIEWED_FIELDS : IMPORT_FIELDS; + const domain = normalizeDomain(input.canonical_domain); + const cols = toColumns({ ...input, canonical_domain: domain }, allowed); + const existing = await one(db, 'SELECT * FROM destinations WHERE slug = ?', input.slug); + const now = nowIso(); + if (existing) { + if (mode === 'import') { + // An import never overwrites reviewed facts on an existing record. + return rowToDestination(existing); + } + const sets = Object.keys(cols); + if (sets.length) { + await run( + db, + `UPDATE destinations SET ${sets.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE slug = ?`, + ...sets.map((c) => cols[c]), + now, + input.slug, + ); + } + } else { + // Unless reviewed otherwise, a domain is its own independent listing group. + cols.listing_group ??= domain; + if (mode === 'import' && cols.kind && !['launch', 'directory', 'social', 'newsletter', 'press', 'backlink', 'other'].includes(String(cols.kind))) { + cols.kind = 'other'; + } + const names = ['id', 'slug', ...Object.keys(cols), 'created_at', 'updated_at']; + await run( + db, + `INSERT INTO destinations (${names.join(', ')}) VALUES (${names.map(() => '?').join(', ')})`, + newId('dst'), + input.slug, + ...Object.values(cols), + now, + now, + ); + } + return (await getDestination(db, input.slug))!; +} + +export async function getDestination(db: Db, idOrSlug: string): Promise { + const r = await one(db, 'SELECT * FROM destinations WHERE id = ? OR slug = ?', idOrSlug, idOrSlug); + return r ? rowToDestination(r) : null; +} + +export async function listDestinations(db: Db, opts: { query?: string; includeArchived?: boolean; limit?: number; offset?: number } = {}): Promise { + const where: string[] = []; + const args: unknown[] = []; + if (!opts.includeArchived) where.push('archived = 0'); + if (opts.query) { + where.push('(name LIKE ? OR slug LIKE ? OR canonical_domain LIKE ? OR categories_json LIKE ?)'); + const q = `%${opts.query}%`; + args.push(q, q, q, q); + } + const rows = await all( + db, + `SELECT * FROM destinations ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY slug LIMIT ? OFFSET ?`, + ...args, + opts.limit ?? 500, + opts.offset ?? 0, + ); + return rows.map(rowToDestination); +} + +export async function archiveDestination(db: Db, slug: string): Promise { + const n = await run(db, 'UPDATE destinations SET archived = 1, updated_at = ? WHERE slug = ?', nowIso(), slug); + if (!n) throw new SubmitFleetError('not_found', `destination ${slug}`); +} + +/** Quarantine on policy/fee/form change: live coverage drops immediately. */ +export async function setAdapterHealth(db: Db, slug: string, health: 'healthy' | 'quarantined' | 'broken', reason: string): Promise { + await run(db, 'UPDATE destinations SET adapter_health = ?, health_reason = ?, updated_at = ? WHERE slug = ?', health, reason, nowIso(), slug); +} + +// ── Qualification ───────────────────────────────────────────────────────────── + +export interface QualificationOptions { + now?: number; + evidenceMaxAgeDays?: number; + /** Adapter ids actually installed in this engine build. */ + installedAdapters: Set; +} + +export interface Qualification { + stages: { + discovered: boolean; + source_labeled_free: boolean; + verified_free: boolean; + permission_reviewed: boolean; + live_tested: boolean; + release_qualifying: boolean; + }; + blockers: string[]; +} + +function fresh(iso: string | null, now: number, maxAgeMs: number): boolean { + if (!iso) return false; + const t = Date.parse(iso); + return Number.isFinite(t) && now - t <= maxAgeMs && t <= now + 60_000; +} + +export function qualify(d: Destination, opts: QualificationOptions): Qualification { + const now = opts.now ?? Date.now(); + const maxAge = (opts.evidenceMaxAgeDays ?? COVERAGE_EVIDENCE_MAX_AGE_DAYS) * 86_400_000; + const blockers: string[] = []; + const discovered = !d.archived; + if (!discovered) blockers.push('archived'); + if (!COUNTED_KINDS.has(d.kind)) blockers.push(`kind_not_counted:${d.kind}`); + + const sourceFree = d.fee_status === 'source_labeled_free' || d.fee_status === 'verified_free'; + const verifiedFree = d.fee_status === 'verified_free' && fresh(d.fee_checked_at, now, maxAge) && !!d.fee_evidence_url; + if (d.fee_status === 'paid') blockers.push('paid_route'); + else if (d.fee_status === 'conditional_free') blockers.push('conditional_free'); + else if (d.fee_status !== 'verified_free') blockers.push('fee_unverified'); + else if (!verifiedFree) blockers.push('fee_evidence_stale'); + if (d.backlink_required) blockers.push('backlink_required'); + + const permitted = d.automation_permission === 'permitted' && fresh(d.permission_checked_at, now, maxAge) && !!d.permission_evidence; + if (d.automation_permission === 'denied') blockers.push('automation_denied'); + else if (d.automation_permission !== 'permitted') blockers.push('automation_permission_unknown'); + else if (!permitted) blockers.push('permission_evidence_stale'); + + if (d.automation_method === 'assisted' || d.automation_method === 'none') blockers.push(`not_unattended:${d.automation_method}`); + if (d.challenge_policy === 'always') blockers.push('always_requires_challenge'); + if (d.challenge_policy === 'unknown') blockers.push('challenge_policy_unknown'); + + const adapterInstalled = !!d.adapter_id && opts.installedAdapters.has(d.adapter_id); + if (!d.adapter_id) blockers.push('no_adapter'); + else if (!adapterInstalled) blockers.push('adapter_not_installed'); + if (!d.contract_tested_at) blockers.push('no_contract_tests'); + const liveTested = !!d.live_tested_at && !!d.live_evidence_ref; + if (!liveTested) blockers.push('no_live_submission_evidence'); + if (d.reconcile_support === 'none') blockers.push('no_reconcile'); + if (d.reconcile_support === 'limited' && !d.reconcile_limitation) blockers.push('reconcile_limitation_undocumented'); + if (d.adapter_health !== 'healthy') blockers.push(`adapter_${d.adapter_health}`); + if (d.disabled) blockers.push('disabled'); + if (d.quality_decision !== 'accepted') blockers.push(`quality_${d.quality_decision}`); + for (const f of ['account_requirement'] as const) if (d[f] === 'unknown') blockers.push(`${f}_unknown`); + + return { + stages: { + discovered, + source_labeled_free: discovered && sourceFree, + verified_free: discovered && verifiedFree, + permission_reviewed: discovered && permitted, + live_tested: discovered && liveTested, + release_qualifying: blockers.length === 0, + }, + blockers, + }; +} + +export interface EngineCoverage { + discovered: number; + source_labeled_free: number; + verified_free: number; + permission_reviewed: number; + live_tested: number; + /** Distinct independent listing groups, so aliases and mirrors never multiply coverage. */ + release_qualifying: number; + release_minimum: number; + status: 'release_ready' | 'below_minimum'; + qualifying: { slug: string; listing_group: string }[]; + blocker_counts: Record; +} + +export async function engineCoverage(db: Db, opts: QualificationOptions): Promise { + const ds = await listDestinations(db, { includeArchived: false, limit: 100_000 }); + const groups = { + discovered: new Set(), + source_labeled_free: new Set(), + verified_free: new Set(), + permission_reviewed: new Set(), + live_tested: new Set(), + release_qualifying: new Set(), + }; + const qualifying: { slug: string; listing_group: string }[] = []; + const blocker_counts: Record = {}; + for (const d of ds) { + const q = qualify(d, opts); + for (const [stage, ok] of Object.entries(q.stages)) if (ok) groups[stage as keyof typeof groups].add(d.listing_group); + if (q.stages.release_qualifying && !qualifying.some((x) => x.listing_group === d.listing_group)) { + qualifying.push({ slug: d.slug, listing_group: d.listing_group }); + } + for (const b of q.blockers) blocker_counts[b] = (blocker_counts[b] ?? 0) + 1; + } + const n = groups.release_qualifying.size; + return { + discovered: groups.discovered.size, + source_labeled_free: groups.source_labeled_free.size, + verified_free: groups.verified_free.size, + permission_reviewed: groups.permission_reviewed.size, + live_tested: groups.live_tested.size, + release_qualifying: n, + release_minimum: RELEASE_MINIMUM, + status: n >= RELEASE_MINIMUM ? 'release_ready' : 'below_minimum', + qualifying, + blocker_counts, + }; +} + +// ── Discovery import ───────────────────────────────────────────────────────── + +export interface ImportRecord { + name: string; + url: string; + kind?: string; + categories?: string[]; + labeled_free?: boolean; +} + +/** + * Imports discovery records. A source's "free" label becomes source_labeled_free at + * most; nothing imported can enable execution. Duplicate domains collapse. + */ +export async function importDiscovery(db: Db, records: ImportRecord[], source: string, license: string): Promise<{ created: number; skipped: number }> { + let created = 0; + let skipped = 0; + for (const r of records) { + let domain: string; + try { + domain = normalizeDomain(new URL(r.url).hostname); + } catch { + skipped++; + continue; + } + const slug = domain.replace(/[^a-z0-9]+/g, '-'); + const exists = await one(db, 'SELECT id FROM destinations WHERE slug = ? OR canonical_domain = ?', slug, domain); + if (exists) { + skipped++; + continue; + } + await upsertDestination( + db, + { slug, name: r.name, canonical_domain: domain, kind: r.kind ?? 'other', categories: r.categories ?? [], submission_url: r.url, source, source_license: license }, + 'import', + ); + if (r.labeled_free) { + await run(db, "UPDATE destinations SET fee_status = 'source_labeled_free' WHERE slug = ? AND fee_status = 'unknown'", slug); + } + created++; + } + return { created, skipped }; +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..fd03d29 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,70 @@ +import { createClient, type Client, type InArgs, type Row } from '@libsql/client'; +import { MIGRATIONS } from './migrations.js'; + +export type Db = Client; + +export interface OpenDbOptions { + url: string; + authToken?: string; +} + +/** Opens a libSQL database (a local file or Turso) and applies forward-only migrations. */ +export async function openDb(opts: OpenDbOptions): Promise { + const db = createClient({ url: opts.url, authToken: opts.authToken }); + if (opts.url.startsWith('file:')) { + await db.execute('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA busy_timeout = 5000'); + } + await db.execute('PRAGMA foreign_keys = ON'); + await migrate(db); + return db; +} + +export async function migrate(db: Db): Promise { + await db.execute( + 'CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)', + ); + const done = await db.execute('SELECT version FROM schema_migrations'); + const applied = new Set(done.rows.map((r) => Number(r.version))); + let count = 0; + for (const m of MIGRATIONS) { + if (applied.has(m.version)) continue; + const stmts = m.sql + .split(/;\s*$/m) + .map((s) => s.trim()) + .filter(Boolean); + await db.batch( + [ + ...stmts, + { + sql: 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)', + args: [m.version, new Date().toISOString()], + }, + ], + 'write', + ); + count++; + } + return count; +} + +// libSQL rejects `undefined` on the remote client but binds it as null locally, +// so normalise every argument list before it leaves this module. +function clean(args: unknown[]): InArgs { + return args.map((a) => (a === undefined ? null : a)) as InArgs; +} + +export async function all(db: Db, sql: string, ...args: unknown[]): Promise { + const r = await db.execute({ sql, args: clean(args) }); + return r.rows as unknown as T[]; +} + +export async function one(db: Db, sql: string, ...args: unknown[]): Promise { + const rows = await all(db, sql, ...args); + return rows[0] ?? null; +} + +export async function run(db: Db, sql: string, ...args: unknown[]): Promise { + const r = await db.execute({ sql, args: clean(args) }); + return r.rowsAffected; +} diff --git a/src/ledger.ts b/src/ledger.ts new file mode 100644 index 0000000..b2d9f17 --- /dev/null +++ b/src/ledger.ts @@ -0,0 +1,137 @@ +import { all, one, run, type Db } from './db.js'; +import { assertTransition, type IntentState } from './state.js'; +import { nowIso, parseJson, redact, SubmitFleetError } from './util.js'; + +export interface Intent { + id: string; + workspace_id: string; + campaign_id: string; + product_id: string; + destination_id: string; + account_id: string | null; + approval_id: string; + dedupe_key: string; + state: IntentState; + reason_code: string | null; + payload: Record; + payload_hash: string; + lease_owner: string | null; + lease_expires_at: string | null; + fence: number; + attempts: number; + external_action_performed: boolean; + remote_id: string | null; + remote_refs: Record; + receipt_id: string | null; + listing_url: string | null; + cost_cents: number; + submitted_at: string | null; + published_at: string | null; + removed_at: string | null; + created_at: string; + updated_at: string; +} + +export function rowToIntent(r: Record): Intent { + return { + ...(r as unknown as Intent), + payload: parseJson(r.payload_json, {}), + remote_refs: parseJson(r.remote_refs_json, {}), + fence: Number(r.fence), + attempts: Number(r.attempts), + cost_cents: Number(r.cost_cents), + external_action_performed: Number(r.external_action_performed) === 1, + }; +} + +export async function appendEvent(db: Db, intentId: string, kind: string, data: { from?: string; to?: string; fence?: number; [k: string]: unknown } = {}): Promise { + const { from, to, fence, ...rest } = data; + await run( + db, + 'INSERT INTO intent_events (intent_id, at, kind, fence, from_state, to_state, data_json) VALUES (?, ?, ?, ?, ?, ?, ?)', + intentId, nowIso(), kind, fence, from, to, JSON.stringify(redact(rest)), + ); +} + +export async function getIntent(db: Db, workspaceId: string, id: string): Promise { + const r = await one>(db, 'SELECT * FROM intents WHERE id = ? AND workspace_id = ?', id, workspaceId); + if (!r) throw new SubmitFleetError('not_found', `submission ${id}`); + return rowToIntent(r); +} + +export async function intentById(db: Db, id: string): Promise { + const r = await one>(db, 'SELECT * FROM intents WHERE id = ?', id); + return r ? rowToIntent(r) : null; +} + +export async function listIntents(db: Db, workspaceId: string, campaignId: string): Promise { + const rows = await all>(db, 'SELECT * FROM intents WHERE workspace_id = ? AND campaign_id = ? ORDER BY created_at', workspaceId, campaignId); + return rows.map(rowToIntent); +} + +export async function intentEvents(db: Db, intentId: string) { + return all(db, 'SELECT at, kind, fence, from_state, to_state, data_json FROM intent_events WHERE intent_id = ? ORDER BY id', intentId); +} + +export interface TransitionPatch { + reason_code?: string | null; + external_action_performed?: boolean; + remote_id?: string | null; + receipt_id?: string | null; + listing_url?: string | null; + cost_cents?: number; + submitted_at?: string | null; + published_at?: string | null; + removed_at?: string | null; + release_dedupe?: boolean; + clear_lease?: boolean; + bump_fence?: boolean; +} + +/** + * Compare-and-set transition. With `fence`, only the holder of the current lease can + * complete; a worker whose lease was taken over gets `false` and must not write. + */ +export async function transition( + db: Db, + intent: Pick, + to: IntentState, + patch: TransitionPatch & { evidence?: Record } = {}, + fence?: number, +): Promise { + assertTransition(intent.state, to); + const sets = ['state = ?', 'updated_at = ?']; + const args: unknown[] = [to, nowIso()]; + const set = (col: string, v: unknown) => { + sets.push(`${col} = ?`); + args.push(v); + }; + if (patch.reason_code !== undefined) set('reason_code', patch.reason_code); + if (patch.external_action_performed) sets.push('external_action_performed = 1'); + for (const k of ['remote_id', 'receipt_id', 'listing_url', 'submitted_at', 'published_at', 'removed_at'] as const) { + if (patch[k] !== undefined) set(k, patch[k]); + } + if (patch.cost_cents !== undefined) sets.push(`cost_cents = cost_cents + ${Math.max(0, Math.floor(patch.cost_cents))}`); + if (patch.release_dedupe) sets.push('dedupe_active = 0'); + if (patch.clear_lease) sets.push('lease_owner = NULL', 'lease_expires_at = NULL'); + if (patch.bump_fence) sets.push('fence = fence + 1'); + let where = 'id = ? AND state = ?'; + args.push(intent.id, intent.state); + if (fence !== undefined) { + where += ' AND fence = ?'; + args.push(fence); + } + const n = await run(db, `UPDATE intents SET ${sets.join(', ')} WHERE ${where}`, ...args); + if (n === 1) await appendEvent(db, intent.id, 'transition', { from: intent.state, to, fence, reason: patch.reason_code, evidence: patch.evidence }); + else await appendEvent(db, intent.id, 'stale_transition_rejected', { from: intent.state, to, fence }); + return n === 1; +} + +export async function recordRemoteRefs(db: Db, intentId: string, fence: number, refs: Record): Promise { + const cur = await one<{ remote_refs_json: string }>(db, 'SELECT remote_refs_json FROM intents WHERE id = ? AND fence = ?', intentId, fence); + if (!cur) throw new SubmitFleetError('lease_lost', 'fence moved; refusing to record'); + const merged = { ...parseJson>(cur.remote_refs_json, {}), ...refs }; + // A remote ref means a remote write happened, so the intent can never be blindly retried. + await run(db, 'UPDATE intents SET remote_refs_json = ?, external_action_performed = 1, updated_at = ? WHERE id = ? AND fence = ?', JSON.stringify(merged), nowIso(), intentId, fence); + await appendEvent(db, intentId, 'remote_ref', { fence, refs }); +} diff --git a/src/migrations.ts b/src/migrations.ts new file mode 100644 index 0000000..6c6257c --- /dev/null +++ b/src/migrations.ts @@ -0,0 +1,236 @@ +// Forward-only migrations. Never edit an applied migration; add a new one. +export const MIGRATIONS: { version: number; sql: string }[] = [ + { + version: 1, + sql: ` +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + paused INTEGER NOT NULL DEFAULT 0, + paused_at TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + role TEXT NOT NULL CHECK (role IN ('owner', 'agent')), + label TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE TABLE products ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + canonical_key TEXT NOT NULL, + url TEXT NOT NULL, + name TEXT, + tagline TEXT, + description TEXT, + categories_json TEXT NOT NULL DEFAULT '[]', + contact_email TEXT, + logo_path TEXT, + facts_json TEXT NOT NULL DEFAULT '{}', + destination_fields_json TEXT NOT NULL DEFAULT '{}', + approved_at TEXT, + approved_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (workspace_id, canonical_key) +); + +CREATE TABLE destinations ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + canonical_domain TEXT NOT NULL, + aliases_json TEXT NOT NULL DEFAULT '[]', + listing_group TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('launch', 'directory', 'social', 'newsletter', 'press', 'backlink', 'other')), + categories_json TEXT NOT NULL DEFAULT '[]', + submission_url TEXT, + required_fields_json TEXT NOT NULL DEFAULT '[]', + account_requirement TEXT NOT NULL DEFAULT 'unknown', + fee_status TEXT NOT NULL DEFAULT 'unknown' + CHECK (fee_status IN ('unknown', 'source_labeled_free', 'verified_free', 'conditional_free', 'paid')), + fee_conditions TEXT, + fee_evidence_url TEXT, + fee_checked_at TEXT, + automation_method TEXT NOT NULL DEFAULT 'none' CHECK (automation_method IN ('api', 'browser', 'assisted', 'none')), + automation_permission TEXT NOT NULL DEFAULT 'unknown' CHECK (automation_permission IN ('unknown', 'permitted', 'denied')), + permission_evidence TEXT, + permission_checked_at TEXT, + challenge_policy TEXT NOT NULL DEFAULT 'unknown' CHECK (challenge_policy IN ('unknown', 'none', 'occasional', 'always')), + backlink_required INTEGER NOT NULL DEFAULT 0, + quality_decision TEXT NOT NULL DEFAULT 'unreviewed' CHECK (quality_decision IN ('unreviewed', 'accepted', 'rejected')), + quality_basis TEXT, + adapter_id TEXT, + adapter_version TEXT, + adapter_health TEXT NOT NULL DEFAULT 'unknown' CHECK (adapter_health IN ('unknown', 'healthy', 'quarantined', 'broken')), + health_reason TEXT, + contract_tested_at TEXT, + live_tested_at TEXT, + live_evidence_ref TEXT, + reconcile_support TEXT NOT NULL DEFAULT 'none' CHECK (reconcile_support IN ('tested', 'limited', 'none')), + reconcile_limitation TEXT, + queue_limit_per_account INTEGER, + queue_window_hours INTEGER, + cooldown_hours INTEGER, + disabled INTEGER NOT NULL DEFAULT 0, + source TEXT, + source_license TEXT, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE accounts ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + destination_id TEXT NOT NULL REFERENCES destinations(id), + label TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'connected', 'needs_reauth', 'disconnected')), + sealed_secret TEXT, + expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE campaigns ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + product_id TEXT NOT NULL REFERENCES products(id), + status TEXT NOT NULL CHECK (status IN ('draft', 'coverage_insufficient', 'awaiting_approval', 'approved', 'running', 'paused', 'canceled', 'completed')), + min_executable INTEGER NOT NULL, + release_scope TEXT NOT NULL DEFAULT 'initial', + policy_json TEXT NOT NULL, + spend_limit_cents INTEGER NOT NULL DEFAULT 0, + earliest_date TEXT, + latest_date TEXT, + executable_count INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE campaign_targets ( + id TEXT PRIMARY KEY, + campaign_id TEXT NOT NULL REFERENCES campaigns(id), + destination_id TEXT NOT NULL REFERENCES destinations(id), + account_id TEXT REFERENCES accounts(id), + executable INTEGER NOT NULL, + blockers_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT, + payload_hash TEXT, + UNIQUE (campaign_id, destination_id) +); + +CREATE TABLE approvals ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + campaign_id TEXT NOT NULL REFERENCES campaigns(id), + scope_hash TEXT NOT NULL, + scope_json TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'granted', 'invalidated', 'rejected')), + requested_by TEXT NOT NULL, + decided_by TEXT, + reason TEXT, + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE intents ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + campaign_id TEXT NOT NULL REFERENCES campaigns(id), + product_id TEXT NOT NULL REFERENCES products(id), + destination_id TEXT NOT NULL REFERENCES destinations(id), + account_id TEXT REFERENCES accounts(id), + approval_id TEXT NOT NULL REFERENCES approvals(id), + dedupe_key TEXT NOT NULL, + dedupe_active INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL, + reason_code TEXT, + payload_json TEXT NOT NULL, + payload_hash TEXT NOT NULL, + lease_owner TEXT, + lease_expires_at TEXT, + fence INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + external_action_performed INTEGER NOT NULL DEFAULT 0, + remote_id TEXT, + remote_refs_json TEXT NOT NULL DEFAULT '{}', + receipt_id TEXT, + listing_url TEXT, + cost_cents INTEGER NOT NULL DEFAULT 0, + submitted_at TEXT, + published_at TEXT, + removed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX intents_dedupe_active ON intents (dedupe_key) WHERE dedupe_active = 1; +CREATE INDEX intents_state ON intents (state, workspace_id); + +CREATE TABLE intent_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + intent_id TEXT NOT NULL REFERENCES intents(id), + at TEXT NOT NULL, + kind TEXT NOT NULL, + fence INTEGER, + from_state TEXT, + to_state TEXT, + data_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE TRIGGER intent_events_immutable_update BEFORE UPDATE ON intent_events +BEGIN SELECT RAISE(ABORT, 'intent_events is append-only'); END; + +CREATE TRIGGER intent_events_immutable_delete BEFORE DELETE ON intent_events +BEGIN SELECT RAISE(ABORT, 'intent_events is append-only'); END; + +CREATE TABLE budgets ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + campaign_id TEXT NOT NULL UNIQUE REFERENCES campaigns(id), + limit_cents INTEGER NOT NULL, + reserved_cents INTEGER NOT NULL DEFAULT 0, + spent_cents INTEGER NOT NULL DEFAULT 0, + CHECK (reserved_cents >= 0 AND spent_cents >= 0 AND reserved_cents + spent_cents <= limit_cents) +); + +CREATE TABLE reservations ( + id TEXT PRIMARY KEY, + budget_id TEXT NOT NULL REFERENCES budgets(id), + intent_id TEXT NOT NULL REFERENCES intents(id), + amount_cents INTEGER NOT NULL, + actual_cents INTEGER, + status TEXT NOT NULL CHECK (status IN ('reserved', 'settled', 'released')), + created_at TEXT NOT NULL +); + +CREATE TABLE effort_log ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + campaign_id TEXT REFERENCES campaigns(id), + activity TEXT NOT NULL, + minutes REAL NOT NULL, + note TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE outcomes ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES intents(id), + observed_at TEXT NOT NULL, + metric TEXT NOT NULL, + value REAL, + source TEXT NOT NULL, + note TEXT +) +`, + }, +]; diff --git a/src/products.ts b/src/products.ts new file mode 100644 index 0000000..6887789 --- /dev/null +++ b/src/products.ts @@ -0,0 +1,120 @@ +import { all, one, run, type Db } from './db.js'; +import { canonicalProductKey, newId, nowIso, parseJson, SubmitFleetError } from './util.js'; + +export interface Product { + id: string; + workspace_id: string; + canonical_key: string; + url: string; + name: string | null; + tagline: string | null; + description: string | null; + categories: string[]; + contact_email: string | null; + logo_path: string | null; + /** Owner-approved facts. Adapters may only use these, never invent claims. */ + facts: Record; + /** Destination-specific values, keyed by destination slug (e.g. a category id). */ + destination_fields: Record>; + approved_at: string | null; + approved_by: string | null; + created_at: string; + updated_at: string; +} + +export interface ProductInput { + url: string; + name?: string | null; + tagline?: string | null; + description?: string | null; + categories?: string[]; + contact_email?: string | null; + logo_path?: string | null; + facts?: Record; + destination_fields?: Record>; +} + +function rowToProduct(r: Record): Product { + return { + ...(r as unknown as Product), + categories: parseJson(r.categories_json, []), + facts: parseJson(r.facts_json, {}), + destination_fields: parseJson(r.destination_fields_json, {}), + }; +} + +export async function createProduct(db: Db, workspaceId: string, input: ProductInput): Promise { + const key = canonicalProductKey(input.url); + const dup = await one(db, 'SELECT id FROM products WHERE workspace_id = ? AND canonical_key = ?', workspaceId, key); + if (dup) throw new SubmitFleetError('product_exists', `product ${key} already exists`, { id: dup.id }); + const now = nowIso(); + const id = newId('prd'); + await run( + db, + `INSERT INTO products (id, workspace_id, canonical_key, url, name, tagline, description, categories_json, contact_email, logo_path, facts_json, destination_fields_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, workspaceId, key, input.url, input.name, input.tagline, input.description, + JSON.stringify(input.categories ?? []), input.contact_email, input.logo_path, + JSON.stringify(input.facts ?? {}), JSON.stringify(input.destination_fields ?? {}), now, now, + ); + return (await getProduct(db, workspaceId, id))!; +} + +export async function getProduct(db: Db, workspaceId: string, id: string): Promise { + const r = await one>(db, 'SELECT * FROM products WHERE workspace_id = ? AND id = ?', workspaceId, id); + return r ? rowToProduct(r) : null; +} + +export async function requireProduct(db: Db, workspaceId: string, id: string): Promise { + const p = await getProduct(db, workspaceId, id); + if (!p) throw new SubmitFleetError('not_found', `product ${id}`); + return p; +} + +export async function listProducts(db: Db, workspaceId: string, limit = 100, offset = 0): Promise { + const rows = await all>(db, 'SELECT * FROM products WHERE workspace_id = ? ORDER BY created_at LIMIT ? OFFSET ?', workspaceId, limit, offset); + return rows.map(rowToProduct); +} + +/** Any content change clears the owner's approval of the facts. */ +export async function updateProduct(db: Db, workspaceId: string, id: string, patch: Partial): Promise { + const p = await requireProduct(db, workspaceId, id); + if (patch.url && canonicalProductKey(patch.url) !== p.canonical_key) { + throw new SubmitFleetError('canonical_identity_immutable', 'create a new product for a different canonical URL'); + } + const merged = { + url: patch.url ?? p.url, + name: patch.name !== undefined ? patch.name : p.name, + tagline: patch.tagline !== undefined ? patch.tagline : p.tagline, + description: patch.description !== undefined ? patch.description : p.description, + categories: patch.categories ?? p.categories, + contact_email: patch.contact_email !== undefined ? patch.contact_email : p.contact_email, + logo_path: patch.logo_path !== undefined ? patch.logo_path : p.logo_path, + facts: patch.facts ?? p.facts, + destination_fields: patch.destination_fields ?? p.destination_fields, + }; + await run( + db, + `UPDATE products SET url = ?, name = ?, tagline = ?, description = ?, categories_json = ?, contact_email = ?, logo_path = ?, + facts_json = ?, destination_fields_json = ?, approved_at = NULL, approved_by = NULL, updated_at = ? WHERE id = ? AND workspace_id = ?`, + merged.url, merged.name, merged.tagline, merged.description, JSON.stringify(merged.categories), merged.contact_email, + merged.logo_path, JSON.stringify(merged.facts), JSON.stringify(merged.destination_fields), nowIso(), id, workspaceId, + ); + return requireProduct(db, workspaceId, id); +} + +export async function approveProductFacts(db: Db, workspaceId: string, id: string, ownerId: string): Promise { + await requireProduct(db, workspaceId, id); + await run(db, 'UPDATE products SET approved_at = ?, approved_by = ? WHERE id = ? AND workspace_id = ?', nowIso(), ownerId, id, workspaceId); + return requireProduct(db, workspaceId, id); +} + +/** Local data only. Remote listings are never deleted by this. */ +export async function deleteProduct(db: Db, workspaceId: string, id: string): Promise { + const used = await one(db, 'SELECT id FROM intents WHERE product_id = ? LIMIT 1', id); + if (used) throw new SubmitFleetError('product_has_submissions', 'product has submission history kept for duplicate prevention'); + await run(db, 'DELETE FROM campaign_targets WHERE campaign_id IN (SELECT id FROM campaigns WHERE product_id = ?)', id); + await run(db, 'DELETE FROM campaigns WHERE product_id = ? AND workspace_id = ?', id, workspaceId); + const n = await run(db, 'DELETE FROM products WHERE id = ? AND workspace_id = ?', id, workspaceId); + if (!n) throw new SubmitFleetError('not_found', `product ${id}`); +} diff --git a/src/reports.ts b/src/reports.ts new file mode 100644 index 0000000..c40172f --- /dev/null +++ b/src/reports.ts @@ -0,0 +1,169 @@ +import { getBudget } from './budgets.js'; +import { getCampaign, previewCampaign } from './campaigns.js'; +import { all, type Db } from './db.js'; +import { listIntents } from './ledger.js'; +import { requireProduct } from './products.js'; +import { parseJson } from './util.js'; + +export interface CampaignReport { + campaign_id: string; + product: { id: string; url: string; name: string | null }; + status: string; + generated_at: string; + original_targets: number; + executable_at_plan: number; + min_executable: number; + exclusions: { destination: string; blockers: string[] }[]; + outcomes: Record; + /** Technical execution rate keeps runtime challenges and failures in the denominator. */ + execution: { attempted_denominator: number; evidenced_receipts: number; rate: number | null }; + submissions: { + id: string; + destination: string; + state: string; + reason_code: string | null; + external_action_performed: boolean; + receipt_id: string | null; + remote_id: string | null; + listing_url: string | null; + submitted_at: string | null; + published_at: string | null; + removed_at: string | null; + cost_cents: number; + }[]; + unresolved: string[]; + human_effort_minutes: number | null; + costs: { operating_spent_cents: number; operating_limit_cents: number; listing_spend_cents: 0 }; + analytics: { referral_visits: number | null; signups: number | null; paying_customers: number | null; note: string }; + limitations: string[]; +} + +const UNRESOLVED = new Set(['queued', 'executing', 'uncertain', 'needs_human']); + +export async function campaignReport(db: Db, workspaceId: string, campaignId: string): Promise { + const c = await getCampaign(db, workspaceId, campaignId); + const product = await requireProduct(db, workspaceId, c.product_id); + const preview = await previewCampaign(db, workspaceId, campaignId); + const intents = await listIntents(db, workspaceId, campaignId); + const slugs = new Map((await all<{ id: string; slug: string }>(db, 'SELECT id, slug FROM destinations')).map((r) => [r.id, r.slug])); + const outcomes: Record = {}; + for (const i of intents) outcomes[i.state] = (outcomes[i.state] ?? 0) + 1; + const budget = await getBudget(db, campaignId); + const effort = await all<{ m: number | null }>(db, 'SELECT SUM(minutes) AS m FROM effort_log WHERE campaign_id = ?', campaignId); + const metrics = await all<{ metric: string; v: number | null }>( + db, + 'SELECT o.metric, SUM(o.value) AS v FROM outcomes o JOIN intents i ON i.id = o.intent_id WHERE i.campaign_id = ? GROUP BY o.metric', + campaignId, + ); + const metric = (k: string) => { + const m = metrics.find((x) => x.metric === k); + return m && m.v != null ? Number(m.v) : null; + }; + const evidenced = intents.filter((i) => i.receipt_id).length; + const denominator = intents.filter((i) => i.state !== 'canceled' || i.external_action_performed).length; + const limitations: string[] = []; + if (!metrics.length) limitations.push('No analytics connected: referral outcomes are unknown, not zero.'); + if (effort[0]?.m == null) limitations.push('No human effort was logged; labour totals are unknown.'); + if (intents.some((i) => i.state === 'scheduled' || i.state === 'submitted')) { + limitations.push('Submitted and scheduled listings are not published until verified live.'); + } + return { + campaign_id: c.id, + product: { id: product.id, url: product.url, name: product.name }, + status: c.status, + generated_at: new Date().toISOString(), + original_targets: preview.executable.length + preview.blocked.length, + executable_at_plan: c.executable_count, + min_executable: c.min_executable, + exclusions: preview.blocked, + outcomes, + execution: { attempted_denominator: denominator, evidenced_receipts: evidenced, rate: denominator ? evidenced / denominator : null }, + submissions: intents.map((i) => ({ + id: i.id, + destination: slugs.get(i.destination_id) ?? i.destination_id, + state: i.state, + reason_code: i.reason_code, + external_action_performed: i.external_action_performed, + receipt_id: i.receipt_id, + remote_id: i.remote_id, + listing_url: i.listing_url, + submitted_at: i.submitted_at, + published_at: i.published_at, + removed_at: i.removed_at, + cost_cents: i.cost_cents, + })), + unresolved: intents.filter((i) => UNRESOLVED.has(i.state)).map((i) => i.id), + human_effort_minutes: effort[0]?.m == null ? null : Number(effort[0].m), + costs: { operating_spent_cents: Number(budget?.spent_cents ?? 0), operating_limit_cents: Number(budget?.limit_cents ?? c.spend_limit_cents), listing_spend_cents: 0 }, + analytics: { + referral_visits: metric('referral_visits'), + signups: metric('signups'), + paying_customers: metric('paying_customers'), + note: 'Attributed outcomes are not automatically incremental.', + }, + limitations, + }; +} + +const show = (v: unknown) => (v === null || v === undefined ? 'unknown' : String(v)); +const cents = (n: number) => `$${(n / 100).toFixed(2)}`; + +export function reportMarkdown(r: CampaignReport): string { + const lines = [ + `# SubmitFleet campaign report`, + '', + `- Product: ${r.product.name ?? r.product.url} (${r.product.url})`, + `- Campaign: \`${r.campaign_id}\`, status **${r.status}**`, + `- Generated: ${r.generated_at}`, + `- Target set at plan: ${r.original_targets} considered, ${r.executable_at_plan} executable, ${r.min_executable} required`, + `- Technical execution: ${r.execution.evidenced_receipts} evidenced receipts of ${r.execution.attempted_denominator} (${r.execution.rate == null ? 'n/a' : `${Math.round(r.execution.rate * 100)}%`})`, + `- Human effort: ${r.human_effort_minutes == null ? 'unknown (not logged)' : `${r.human_effort_minutes} min`}`, + `- Operating cost: ${cents(r.costs.operating_spent_cents)} of ${cents(r.costs.operating_limit_cents)} limit; listing fees ${cents(0)}`, + '', + '## Outcomes', + '', + ...Object.entries(r.outcomes).map(([k, v]) => `- ${k}: ${v}`), + ...(Object.keys(r.outcomes).length ? [] : ['- none yet']), + '', + '## Submissions', + '', + '| Destination | State | Receipt | Listing | Submitted | Published |', + '| --- | --- | --- | --- | --- | --- |', + ...r.submissions.map((s) => `| ${s.destination} | ${s.state}${s.reason_code ? ` (${s.reason_code})` : ''} | ${show(s.receipt_id)} | ${s.listing_url ?? 'unknown'} | ${show(s.submitted_at)} | ${show(s.published_at)} |`), + '', + '## Analytics', + '', + `- Referral visits: ${show(r.analytics.referral_visits)}`, + `- Signups: ${show(r.analytics.signups)}`, + `- Paying customers: ${show(r.analytics.paying_customers)}`, + `- ${r.analytics.note}`, + '', + '## Exclusions', + '', + ...r.exclusions.slice(0, 200).map((e) => `- ${e.destination}: ${e.blockers.join(', ')}`), + ...(r.exclusions.length > 200 ? [`- ...and ${r.exclusions.length - 200} more`] : []), + '', + '## Limitations', + '', + ...r.limitations.map((l) => `- ${l}`), + ...(r.limitations.length ? [] : ['- none recorded']), + '', + ]; + return lines.join('\n'); +} + +function csvCell(v: unknown): string { + const s = v == null ? '' : String(v); + // Neutralise spreadsheet formula injection as well as quoting. + const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s; + return /[",\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} + +export function reportCsv(r: CampaignReport): string { + const cols = ['id', 'destination', 'state', 'reason_code', 'external_action_performed', 'receipt_id', 'remote_id', 'listing_url', 'submitted_at', 'published_at', 'removed_at', 'cost_cents'] as const; + return [cols.join(','), ...r.submissions.map((s) => cols.map((c) => csvCell(s[c])).join(','))].join('\n') + '\n'; +} + +export function parseBlockers(json: string): string[] { + return parseJson(json, []); +} diff --git a/src/service.ts b/src/service.ts new file mode 100644 index 0000000..8aba2fc --- /dev/null +++ b/src/service.ts @@ -0,0 +1,242 @@ +import { readFile, stat } from 'node:fs/promises'; +import { isAbsolute, resolve, sep } from 'node:path'; +import { builtinAdapters } from './adapters/index.js'; +import type { Adapter } from './adapters/types.js'; +import { tinyLaunchRequestCode, tinyLaunchVerify } from './adapters/tinylaunch.js'; +import * as campaigns from './campaigns.js'; +import * as catalog from './catalog.js'; +import { all, openDb, run, type Db } from './db.js'; +import * as ledger from './ledger.js'; +import * as products from './products.js'; +import * as reports from './reports.js'; +import { loadSecretKey, newId, nowIso, SubmitFleetError } from './util.js'; +import { Worker } from './worker.js'; +import * as ws from './workspaces.js'; +import type { Actor } from './workspaces.js'; + +export interface SubmitFleetConfig { + dbUrl: string; + dbAuthToken?: string; + secretKey?: string; + /** Product assets (logos, screenshots) are only read from inside this directory. */ + assetsDir: string; + adapters?: Map; + fetch?: typeof fetch; + now?: () => number; +} + +const MAX_ASSET_BYTES = 5 * 1024 * 1024; + +export class SubmitFleet { + readonly adapters: Map; + readonly secretKey: Buffer | null; + private fetchImpl: typeof fetch; + private now: () => number; + + private constructor( + readonly db: Db, + readonly config: SubmitFleetConfig, + ) { + this.adapters = config.adapters ?? builtinAdapters(); + this.secretKey = config.secretKey ? loadSecretKey(config.secretKey) : null; + this.fetchImpl = config.fetch ?? fetch; + this.now = config.now ?? Date.now; + } + + static async open(config: SubmitFleetConfig): Promise { + const db = await openDb({ url: config.dbUrl, authToken: config.dbAuthToken }); + return new SubmitFleet(db, config); + } + + close(): void { + this.db.close(); + } + + readAsset = async (path: string): Promise => { + const root = resolve(this.config.assetsDir); + const full = resolve(root, path); + if (isAbsolute(path) && !full.startsWith(root + sep)) throw new SubmitFleetError('asset_outside_root', path); + if (!full.startsWith(root + sep)) throw new SubmitFleetError('asset_outside_root', path); + if (/\.(exe|sh|bat|cmd|js|mjs|html?|svg)$/i.test(full)) throw new SubmitFleetError('asset_type_not_allowed', path); + const s = await stat(full); + if (s.size > MAX_ASSET_BYTES) throw new SubmitFleetError('asset_too_large', path); + return readFile(full); + }; + + private planDeps() { + return { adapters: this.adapters, readAsset: this.readAsset, now: this.now }; + } + + worker(opts: { workerId?: string; leaseMs?: number; execTimeoutMs?: number } = {}): Worker { + return new Worker({ db: this.db, adapters: this.adapters, secretKey: this.secretKey, fetch: this.fetchImpl, now: this.now, ...opts }); + } + + // ── Products ── + productCreate = (a: Actor, input: products.ProductInput) => products.createProduct(this.db, a.workspaceId, input); + productGet = (a: Actor, id: string) => products.requireProduct(this.db, a.workspaceId, id); + productList = (a: Actor, limit?: number, offset?: number) => products.listProducts(this.db, a.workspaceId, limit, offset); + productUpdate = (a: Actor, id: string, patch: Partial) => products.updateProduct(this.db, a.workspaceId, id, patch); + productDelete = async (a: Actor, id: string) => { + ws.requireOwner(a); + return products.deleteProduct(this.db, a.workspaceId, id); + }; + productApproveFacts = async (a: Actor, id: string) => { + ws.requireOwner(a); + return products.approveProductFacts(this.db, a.workspaceId, id, a.id); + }; + + // ── Catalog ── + catalogSearch = (query?: string, limit?: number, offset?: number) => catalog.listDestinations(this.db, { query, limit, offset }); + catalogGet = async (slug: string) => { + const d = await catalog.getDestination(this.db, slug); + if (!d) throw new SubmitFleetError('not_found', `destination ${slug}`); + return { ...d, qualification: catalog.qualify(d, { installedAdapters: new Set(this.adapters.keys()), now: this.now() }) }; + }; + /** Reviewed registry edit: owner only. */ + catalogUpsert = async (a: Actor, input: catalog.DestinationInput) => { + ws.requireOwner(a); + return catalog.upsertDestination(this.db, input, 'reviewed'); + }; + catalogArchive = async (a: Actor, slug: string) => { + ws.requireOwner(a); + return catalog.archiveDestination(this.db, slug); + }; + catalogImport = async (a: Actor, records: catalog.ImportRecord[], source: string, license: string) => { + ws.requireOwner(a); + return catalog.importDiscovery(this.db, records, source, license); + }; + catalogSetHealth = async (a: Actor, slug: string, health: 'healthy' | 'quarantined' | 'broken', reason: string) => { + ws.requireOwner(a); + return catalog.setAdapterHealth(this.db, slug, health, reason); + }; + coverageReport = () => catalog.engineCoverage(this.db, { installedAdapters: new Set(this.adapters.keys()), now: this.now() }); + + // ── Accounts ── + accountStatus = (a: Actor) => ws.listAccounts(this.db, a.workspaceId); + accountDisconnect = async (a: Actor, id: string) => { + ws.requireOwner(a); + return ws.disconnectAccount(this.db, a.workspaceId, id); + }; + /** Step 1 of an OTP handoff: the code goes to the owner's inbox, not to us. */ + accountConnectStart = async (a: Actor, destination: string, email: string) => { + ws.requireOwner(a); + const d = await this.requireDestination(destination); + if (d.adapter_id !== 'tinylaunch') throw new SubmitFleetError('unsupported_operation', `no connect flow for ${destination}`); + await tinyLaunchRequestCode(this.fetchImpl, email); + return { destination: d.slug, next: 'submit the emailed code with account_connect_finish' }; + }; + accountConnectFinish = async (a: Actor, destination: string, email: string, code: string, label?: string) => { + ws.requireOwner(a); + if (!this.secretKey) throw new SubmitFleetError('secret_key_missing'); + const d = await this.requireDestination(destination); + if (d.adapter_id !== 'tinylaunch') throw new SubmitFleetError('unsupported_operation'); + const { secret, expiresAt } = await tinyLaunchVerify(this.fetchImpl, email, code, this.now()); + return ws.connectAccount(this.db, this.secretKey, a.workspaceId, d.id, label ?? email, secret, expiresAt); + }; + /** For destinations whose account needs no secret (account_requirement 'none') or for tests. */ + accountConnectSecret = async (a: Actor, destination: string, label: string, secret: Record, expiresAt: string | null) => { + ws.requireOwner(a); + if (!this.secretKey) throw new SubmitFleetError('secret_key_missing'); + const d = await this.requireDestination(destination); + return ws.connectAccount(this.db, this.secretKey, a.workspaceId, d.id, label, secret, expiresAt); + }; + + // ── Campaigns ── + campaignPlan = (a: Actor, input: campaigns.PlanInput, campaignId?: string) => campaigns.planCampaign(this.db, a, input, this.planDeps(), campaignId); + campaignList = (a: Actor) => campaigns.listCampaigns(this.db, a.workspaceId); + campaignPreview = (a: Actor, id: string) => campaigns.previewCampaign(this.db, a.workspaceId, id); + approvalRequest = (a: Actor, id: string) => campaigns.requestApproval(this.db, a, id); + approvalGrant = (a: Actor, id: string, scopeHash?: string) => campaigns.grantApproval(this.db, a, id, scopeHash); + campaignRun = (a: Actor, id: string) => campaigns.runCampaign(this.db, a, id); + campaignStatus = (a: Actor, id: string) => campaigns.campaignStatus(this.db, a.workspaceId, id); + campaignPause = (a: Actor, id: string) => campaigns.pauseCampaign(this.db, a.workspaceId, id); + campaignCancel = (a: Actor, id: string) => campaigns.cancelCampaign(this.db, a.workspaceId, id); + + /** Global stop: anyone in the workspace may pause; only the owner may resume. */ + pauseAll = (a: Actor) => ws.setPaused(this.db, a.workspaceId, true); + resumeAll = async (a: Actor) => { + ws.requireOwner(a); + return ws.setPaused(this.db, a.workspaceId, false); + }; + pausedState = (a: Actor) => ws.isPaused(this.db, a.workspaceId); + + // ── Submissions ── + submissionGet = async (a: Actor, id: string) => { + const i = await ledger.getIntent(this.db, a.workspaceId, id); + const { payload: _p, ...rest } = i; + return { ...rest, events: await ledger.intentEvents(this.db, id) }; + }; + submissionReconcile = async (a: Actor, id: string) => { + const i = await ledger.getIntent(this.db, a.workspaceId, id); + const w = this.worker(); + if (i.state === 'uncertain') { + await w.reconcileUncertain(1000); + return (await ledger.getIntent(this.db, a.workspaceId, id)).state; + } + if (['submitted', 'scheduled', 'under_review', 'published_verified'].includes(i.state)) return w.reconcileOutcome(i); + throw new SubmitFleetError('invalid_state', `nothing to reconcile in state ${i.state}`); + }; + submissionUpdate = async (a: Actor, id: string) => this.optionalOp(a, id, 'update'); + submissionWithdraw = async (a: Actor, id: string) => this.optionalOp(a, id, 'withdraw'); + /** Return a needs_human intent to the queue once the owner resolved the blocker. */ + submissionRequeue = async (a: Actor, id: string) => { + ws.requireOwner(a); + const i = await ledger.getIntent(this.db, a.workspaceId, id); + if (i.state !== 'needs_human') throw new SubmitFleetError('invalid_state', `state is ${i.state}`); + return ledger.transition(this.db, i, 'queued', { reason_code: 'owner_requeued' }); + }; + + private async optionalOp(a: Actor, id: string, op: 'update' | 'withdraw') { + ws.requireOwner(a); + const i = await ledger.getIntent(this.db, a.workspaceId, id); + const d = await this.requireDestination(i.destination_id); + const adapter = d.adapter_id ? this.adapters.get(d.adapter_id) : undefined; + const caps = adapter?.capabilities(); + if (!adapter || !caps?.[op] || !adapter[op]) { + return { ok: false, reason_code: 'unsupported_operation', external_action_performed: false, detail: `${d.slug} does not support ${op}; nothing was changed remotely` }; + } + throw new SubmitFleetError('not_implemented', `${op} requires its own approval flow`); + } + + // ── Reports ── + reportGet = (a: Actor, id: string) => reports.campaignReport(this.db, a.workspaceId, id); + reportExport = async (a: Actor, id: string, format: 'markdown' | 'json' | 'csv') => { + const r = await reports.campaignReport(this.db, a.workspaceId, id); + if (format === 'markdown') return reports.reportMarkdown(r); + if (format === 'csv') return reports.reportCsv(r); + return JSON.stringify(r, null, 2); + }; + logEffort = async (a: Actor, campaignId: string | null, activity: string, minutes: number, note?: string) => { + if (!(minutes >= 0)) throw new SubmitFleetError('invalid_request', 'minutes must be >= 0'); + await run(this.db, 'INSERT INTO effort_log (id, workspace_id, campaign_id, activity, minutes, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', newId('eff'), a.workspaceId, campaignId, activity, minutes, note, nowIso()); + }; + recordOutcome = async (a: Actor, intentId: string, metric: 'referral_visits' | 'signups' | 'paying_customers', value: number, source: string) => { + await ledger.getIntent(this.db, a.workspaceId, intentId); + await run(this.db, 'INSERT INTO outcomes (id, intent_id, observed_at, metric, value, source) VALUES (?, ?, ?, ?, ?, ?)', newId('out'), intentId, nowIso(), metric, value, source); + }; + + humanQueue = async (a: Actor) => + all(this.db, "SELECT i.id, i.campaign_id, d.slug AS destination, i.state, i.reason_code, i.updated_at FROM intents i JOIN destinations d ON d.id = i.destination_id WHERE i.workspace_id = ? AND i.state IN ('needs_human', 'uncertain') ORDER BY i.updated_at", a.workspaceId); + + private async requireDestination(idOrSlug: string) { + const d = await catalog.getDestination(this.db, idOrSlug); + if (!d) throw new SubmitFleetError('not_found', `destination ${idOrSlug}`); + return d; + } + + /** Non-mutating health check of this install. */ + doctor = async () => { + const checks: { check: string; ok: boolean; detail?: string }[] = []; + try { + await all(this.db, 'SELECT 1'); + checks.push({ check: 'database', ok: true, detail: this.config.dbUrl.replace(/\?.*$/, '') }); + } catch (e) { + checks.push({ check: 'database', ok: false, detail: String(e) }); + } + checks.push({ check: 'secret_key', ok: !!this.secretKey, detail: this.secretKey ? 'loaded' : 'SUBMITFLEET_SECRET_KEY not set: accounts cannot be connected' }); + checks.push({ check: 'adapters', ok: this.adapters.size > 0, detail: [...this.adapters.values()].map((a) => `${a.id}@${a.version}`).join(', ') }); + const cov = await this.coverageReport(); + checks.push({ check: 'release_coverage', ok: cov.status === 'release_ready', detail: `${cov.release_qualifying}/${cov.release_minimum} qualifying (${cov.status})` }); + return checks; + }; +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..1e3a877 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,60 @@ +import { SubmitFleetError } from './util.js'; + +export const INTENT_STATES = [ + 'queued', + 'executing', + 'submitted', + 'scheduled', + 'needs_human', + 'uncertain', + 'failed', + 'under_review', + 'published_verified', + 'rejected', + 'removal_observed', + 'canceled', +] as const; + +export type IntentState = (typeof INTENT_STATES)[number]; + +// draft -> validated -> awaiting_approval -> approved live on the campaign; an +// intent row only exists once an approved campaign queues it. +const TRANSITIONS: Record = { + queued: ['executing', 'canceled'], + executing: ['submitted', 'scheduled', 'needs_human', 'uncertain', 'failed', 'queued'], + // queued again only after reconciliation proves no remote write happened + uncertain: ['submitted', 'scheduled', 'queued', 'needs_human', 'failed'], + needs_human: ['queued', 'submitted', 'scheduled', 'failed', 'canceled'], + submitted: ['under_review', 'published_verified', 'rejected'], + scheduled: ['under_review', 'published_verified', 'rejected'], + under_review: ['published_verified', 'rejected'], + published_verified: ['removal_observed'], + rejected: [], + removal_observed: [], + failed: [], + canceled: [], +}; + +export function canTransition(from: IntentState, to: IntentState): boolean { + return TRANSITIONS[from]?.includes(to) ?? false; +} + +export function assertTransition(from: IntentState, to: IntentState): void { + if (!canTransition(from, to)) throw new SubmitFleetError('invalid_transition', `${from} -> ${to}`); +} + +/** States in which a remote write may exist; such intents always keep their dedupe slot. */ +export const REMOTE_WRITE_STATES: IntentState[] = [ + 'submitted', + 'scheduled', + 'under_review', + 'published_verified', + 'rejected', + 'removal_observed', + 'uncertain', +]; + +/** Account-queue occupancy: work that counts against a destination's per-account queue limit. */ +export const OCCUPYING_STATES: IntentState[] = ['executing', 'submitted', 'scheduled', 'under_review', 'uncertain']; + +export const TERMINAL_STATES: IntentState[] = ['published_verified', 'rejected', 'removal_observed', 'failed', 'canceled']; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..f7fd9e6 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,107 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +export function newId(prefix: string): string { + return `${prefix}_${randomBytes(12).toString('base64url')}`; +} + +export function nowIso(clock: () => number = Date.now): string { + return new Date(clock()).toISOString(); +} + +/** JSON with sorted keys, so equal values always hash equally. */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +function sortKeys(v: unknown): unknown { + if (Array.isArray(v)) return v.map(sortKeys); + if (v && typeof v === 'object') { + const out: Record = {}; + for (const k of Object.keys(v as object).sort()) { + const val = (v as Record)[k]; + if (val !== undefined) out[k] = sortKeys(val); + } + return out; + } + return v; +} + +export function sha256(s: string | Buffer): string { + return createHash('sha256').update(s).digest('hex'); +} + +export function hashValue(v: unknown): string { + return sha256(canonicalJson(v)); +} + +/** + * Canonical product identity: lower-cased host without www, path without trailing + * slash, no query or fragment. Tracking parameters must never create a new product. + */ +export function canonicalProductKey(url: string): string { + const u = new URL(url); + if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('invalid_url_scheme'); + const host = u.hostname.toLowerCase().replace(/^www\./, ''); + const path = u.pathname.replace(/\/+$/, ''); + return `${host}${path}`; +} + +export function parseJson(s: unknown, fallback: T): T { + if (typeof s !== 'string' || s === '') return fallback; + try { + return JSON.parse(s) as T; + } catch { + return fallback; + } +} + +// ── Secret sealing ──────────────────────────────────────────────────────────── +// AES-256-GCM with a key from SUBMITFLEET_SECRET_KEY (32 bytes, base64 or hex). +// Sealed blobs live in the accounts table; plaintext never reaches logs, reports +// or model context. + +export function loadSecretKey(raw: string | undefined): Buffer { + if (!raw) throw new Error('secret_key_missing: set SUBMITFLEET_SECRET_KEY (32 bytes, base64 or hex)'); + const buf = /^[0-9a-f]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64'); + if (buf.length !== 32) throw new Error('secret_key_invalid: expected 32 bytes'); + return buf; +} + +export function seal(key: Buffer, plaintext: string): string { + const iv = randomBytes(12); + const c = createCipheriv('aes-256-gcm', key, iv); + const body = Buffer.concat([c.update(plaintext, 'utf8'), c.final()]); + return ['v1', iv.toString('base64url'), c.getAuthTag().toString('base64url'), body.toString('base64url')].join('.'); +} + +export function unseal(key: Buffer, sealed: string): string { + const [v, iv, tag, body] = sealed.split('.'); + if (v !== 'v1' || !iv || !tag || !body) throw new Error('sealed_secret_invalid'); + const d = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url')); + d.setAuthTag(Buffer.from(tag, 'base64url')); + return Buffer.concat([d.update(Buffer.from(body, 'base64url')), d.final()]).toString('utf8'); +} + +/** Removes anything that looks like a credential before a value is logged or reported. */ +export function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = /token|secret|password|cookie|otp|code|authorization|api[_-]?key/i.test(k) ? '[redacted]' : redact(v); + } + return out; + } + if (typeof value === 'string' && /^(Bearer\s+)?eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(value)) return '[redacted]'; + return value; +} + +export class SubmitFleetError extends Error { + constructor( + public code: string, + message?: string, + public detail?: unknown, + ) { + super(message ?? code); + } +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..540781d --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,291 @@ +import type { Adapter, ExecContext, ExecResult } from './adapters/types.js'; +import { getBudget, release, reserve, settle } from './budgets.js'; +import { getDestination, WRITE_EVIDENCE_MAX_AGE_HOURS, type Destination } from './catalog.js'; +import { all, type Db } from './db.js'; +import { appendEvent, intentById, recordRemoteRefs, rowToIntent, transition, type Intent } from './ledger.js'; +import { OCCUPYING_STATES } from './state.js'; +import { newId, nowIso, SubmitFleetError } from './util.js'; +import { markNeedsReauth, readAccountSecret } from './workspaces.js'; + +export interface WorkerOptions { + db: Db; + adapters: Map; + secretKey: Buffer | null; + workerId?: string; + leaseMs?: number; + execTimeoutMs?: number; + fetch?: typeof fetch; + now?: () => number; + /** Uncertain intents still unknown after this many reconcile attempts go to the human queue. */ + maxReconcileAttempts?: number; +} + +const occupying = OCCUPYING_STATES.map((s) => `'${s}'`).join(', '); + +export class Worker { + readonly id: string; + private db: Db; + private adapters: Map; + private leaseMs: number; + private execTimeoutMs: number; + private fetchImpl: typeof fetch; + private now: () => number; + private maxReconcile: number; + + constructor(private opts: WorkerOptions) { + this.id = opts.workerId ?? newId('wrk'); + this.db = opts.db; + this.adapters = opts.adapters; + this.leaseMs = opts.leaseMs ?? 120_000; + this.execTimeoutMs = opts.execTimeoutMs ?? 90_000; + this.fetchImpl = opts.fetch ?? fetch; + this.now = opts.now ?? Date.now; + this.maxReconcile = opts.maxReconcileAttempts ?? 5; + } + + /** One pass: recover expired leases, reconcile uncertain work, execute up to `max` intents. */ + async tick(max = 10): Promise<{ recovered: number; reconciled: number; executed: number }> { + const recovered = await this.recoverExpiredLeases(); + const reconciled = await this.reconcileUncertain(); + let executed = 0; + for (; executed < max; executed++) { + const intent = await this.claim(); + if (!intent) break; + await this.execute(intent); + } + return { recovered, reconciled, executed }; + } + + /** + * Atomic claim. One UPDATE ... WHERE id = (SELECT ...) both selects and leases, and + * re-checks pause, campaign state, destination health, account state, per-account + * queue limits and cooldowns inside the same statement, so concurrent workers + * cannot both take a slot. + */ + async claim(): Promise { + const now = nowIso(this.now); + const lease = new Date(this.now() + this.leaseMs).toISOString(); + const rows = await all>( + this.db, + `UPDATE intents SET state = 'executing', lease_owner = ?, lease_expires_at = ?, fence = fence + 1, attempts = attempts + 1, updated_at = ? + WHERE state = 'queued' AND id = ( + SELECT i.id FROM intents i + JOIN campaigns c ON c.id = i.campaign_id AND c.status = 'running' + JOIN workspaces w ON w.id = i.workspace_id AND w.paused = 0 + JOIN destinations d ON d.id = i.destination_id AND d.disabled = 0 AND d.archived = 0 AND d.adapter_health = 'healthy' + LEFT JOIN accounts a ON a.id = i.account_id + WHERE i.state = 'queued' + AND (i.account_id IS NULL OR a.status = 'connected') + AND (d.queue_limit_per_account IS NULL OR ( + SELECT COUNT(*) FROM intents o + WHERE o.destination_id = i.destination_id AND o.account_id IS i.account_id AND o.state IN (${occupying}) + AND (d.queue_window_hours IS NULL OR COALESCE(o.submitted_at, o.updated_at) >= strftime('%Y-%m-%dT%H:%M:%fZ', ?, '-' || d.queue_window_hours || ' hours')) + ) < d.queue_limit_per_account) + AND (d.cooldown_hours IS NULL OR NOT EXISTS ( + SELECT 1 FROM intents o + WHERE o.destination_id = i.destination_id AND o.account_id IS i.account_id AND o.submitted_at IS NOT NULL + AND o.submitted_at >= strftime('%Y-%m-%dT%H:%M:%fZ', ?, '-' || d.cooldown_hours || ' hours') + )) + ORDER BY i.created_at LIMIT 1 + ) + RETURNING *`, + this.id, lease, now, now, now, + ); + const r = rows[0]; + if (!r) return null; + const intent = rowToIntent(r); + await appendEvent(this.db, intent.id, 'claimed', { from: 'queued', to: 'executing', fence: intent.fence, worker: this.id }); + return intent; + } + + async execute(intent: Intent): Promise { + const fence = intent.fence; + const d = await getDestination(this.db, intent.destination_id); + const adapter = d?.adapter_id ? this.adapters.get(d.adapter_id) : undefined; + if (!d || !adapter) { + await transition(this.db, intent, 'failed', { reason_code: 'adapter_unavailable', release_dedupe: true, clear_lease: true }, fence); + return; + } + + const stale = staleEvidence(d, this.now()); + if (stale) { + await transition(this.db, intent, 'needs_human', { reason_code: stale, clear_lease: true }, fence); + return; + } + + const budget = await getBudget(this.db, intent.campaign_id); + const estimate = Number(intent.payload._estimated_cost_cents ?? 0); + const reservation = budget ? await reserve(this.db, budget.id, intent.id, estimate) : null; + if (!reservation) { + await transition(this.db, intent, 'needs_human', { reason_code: 'budget_exhausted', clear_lease: true }, fence); + return; + } + + const ctl = new AbortController(); + const ctx = this.context(intent, d, ctl.signal); + let result: ExecResult; + try { + const pre = await adapter.preflight(ctx); + if (!pre.ok) { + await release(this.db, reservation); + await transition(this.db, intent, 'needs_human', { reason_code: pre.reasonCode ?? 'preflight_failed', clear_lease: true, evidence: pre.evidence }, fence); + return; + } + result = await withTimeout(adapter.execute(ctx), this.execTimeoutMs, ctl); + } catch (e) { + // A throw or timeout after a possible write is uncertain, never failed. + result = { kind: 'uncertain', reasonCode: e instanceof TimeoutError ? 'timeout' : `exception:${e instanceof SubmitFleetError ? e.code : 'error'}` }; + } + + const actual = 'costCents' in result && typeof result.costCents === 'number' ? result.costCents : estimate; + const { charged } = await settle(this.db, reservation, actual); + await this.applyResult(intent, result, fence, charged); + } + + private async applyResult(intent: Intent, result: ExecResult, fence: number, charged: number): Promise { + const ts = nowIso(this.now); + switch (result.kind) { + case 'submitted': + case 'scheduled': + await transition( + this.db, + intent, + result.kind, + { external_action_performed: true, remote_id: result.remoteId, receipt_id: result.receiptId, listing_url: result.listingUrl ?? null, submitted_at: ts, cost_cents: charged, clear_lease: true, evidence: result.evidence }, + fence, + ); + return; + case 'needs_human': + if (result.reasonCode === 'session_expired' && intent.account_id) await markNeedsReauth(this.db, intent.account_id); + await transition(this.db, intent, 'needs_human', { reason_code: result.reasonCode, external_action_performed: result.externalActionPerformed, cost_cents: charged, clear_lease: true, evidence: result.evidence }, fence); + return; + case 'failed': { + const fresh = await intentById(this.db, intent.id); + const external = result.externalActionPerformed || !!fresh?.external_action_performed; + await transition( + this.db, + intent, + 'failed', + { reason_code: result.reasonCode, external_action_performed: external, release_dedupe: !external, cost_cents: charged, clear_lease: true, evidence: result.evidence }, + fence, + ); + return; + } + case 'uncertain': + await transition(this.db, intent, 'uncertain', { reason_code: result.reasonCode, cost_cents: charged, clear_lease: true, evidence: result.evidence }, fence); + return; + } + } + + /** A lease that expired mid-execution may have written remotely: mark uncertain, never requeue. */ + async recoverExpiredLeases(): Promise { + const now = nowIso(this.now); + const rows = await all<{ id: string; fence: number }>( + this.db, + "UPDATE intents SET state = 'uncertain', reason_code = 'lease_expired', fence = fence + 1, lease_owner = NULL, lease_expires_at = NULL, updated_at = ? WHERE state = 'executing' AND lease_expires_at < ? RETURNING id, fence", + now, + now, + ); + for (const r of rows) await appendEvent(this.db, r.id, 'lease_expired', { from: 'executing', to: 'uncertain', fence: Number(r.fence) }); + return rows.length; + } + + /** Reconcile before any retry: only definitive absence lets an uncertain intent run again. */ + async reconcileUncertain(limit = 20): Promise { + const rows = await all>( + this.db, + `SELECT i.* FROM intents i JOIN workspaces w ON w.id = i.workspace_id AND w.paused = 0 WHERE i.state = 'uncertain' ORDER BY i.updated_at LIMIT ?`, + limit, + ); + let done = 0; + for (const r of rows) { + const intent = rowToIntent(r); + const d = await getDestination(this.db, intent.destination_id); + const adapter = d?.adapter_id ? this.adapters.get(d.adapter_id) : undefined; + if (!d || !adapter) continue; + const ctl = new AbortController(); + let res; + try { + res = await withTimeout(adapter.reconcile(this.context(intent, d, ctl.signal)), this.execTimeoutMs, ctl); + } catch { + res = { status: 'unknown' as const, reasonCode: 'reconcile_error' }; + } + const prior = await all<{ n: number }>(this.db, "SELECT COUNT(*) AS n FROM intent_events WHERE intent_id = ? AND kind = 'reconcile'", intent.id); + await appendEvent(this.db, intent.id, 'reconcile', { result: res.status, fence: intent.fence }); + if (res.status === 'found') { + const to = res.state === 'submitted' || res.state === 'scheduled' ? res.state : 'submitted'; + await transition(this.db, intent, to, { external_action_performed: true, remote_id: res.remoteId ?? intent.remote_id, listing_url: res.listingUrl ?? null, submitted_at: nowIso(this.now), reason_code: 'reconciled', evidence: res.evidence }, intent.fence); + } else if (res.status === 'absent') { + await transition(this.db, intent, 'queued', { reason_code: 'reconciled_absent', evidence: res.evidence }, intent.fence); + } else if (Number(prior[0]?.n ?? 0) + 1 >= this.maxReconcile) { + await transition(this.db, intent, 'needs_human', { reason_code: `uncertain_unresolved:${res.reasonCode}` }, intent.fence); + } + done++; + } + return done; + } + + /** Checks a submitted/scheduled listing for publication, rejection or removal. */ + async reconcileOutcome(intent: Intent): Promise { + const d = await getDestination(this.db, intent.destination_id); + const adapter = d?.adapter_id ? this.adapters.get(d.adapter_id) : undefined; + if (!d || !adapter) throw new SubmitFleetError('adapter_unavailable'); + const ctl = new AbortController(); + const res = await withTimeout(adapter.reconcile(this.context(intent, d, ctl.signal)), this.execTimeoutMs, ctl); + await appendEvent(this.db, intent.id, 'reconcile', { result: res.status, state: res.status === 'found' ? res.state : undefined }); + if (res.status !== 'found' || res.state === intent.state) return intent.state; + const ts = nowIso(this.now); + const ok = await transition(this.db, intent, res.state, { + listing_url: res.listingUrl ?? undefined, + published_at: res.state === 'published_verified' ? ts : undefined, + removed_at: res.state === 'removal_observed' ? ts : undefined, + evidence: res.evidence, + }).catch(() => false); + return ok ? res.state : intent.state; + } + + private context(intent: Intent, d: Destination, signal: AbortSignal): ExecContext { + const { db, secretKey } = this.opts; + return { + intentId: intent.id, + payload: intent.payload, + destination: d, + remoteRefs: intent.remote_refs, + recordRemote: (refs) => recordRemoteRefs(db, intent.id, intent.fence, refs), + credentials: async () => { + if (!intent.account_id) return {}; + if (!secretKey) throw new SubmitFleetError('secret_key_missing'); + return readAccountSecret(db, secretKey, intent.workspace_id, intent.account_id); + }, + fetch: this.fetchImpl, + signal, + now: this.now, + }; + } +} + +export function staleEvidence(d: Destination, now: number): string | null { + const max = WRITE_EVIDENCE_MAX_AGE_HOURS * 3_600_000; + const age = (iso: string | null) => (iso ? now - Date.parse(iso) : Infinity); + if (d.fee_status !== 'verified_free') return 'fee_not_verified_free'; + if (age(d.fee_checked_at) > max) return 'fee_evidence_stale'; + if (age(d.permission_checked_at) > max) return 'permission_evidence_stale'; + return null; +} + +class TimeoutError extends Error {} + +async function withTimeout(p: Promise, ms: number, ctl: AbortController): Promise { + let t: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + t = setTimeout(() => { + ctl.abort(); + reject(new TimeoutError('timeout')); + }, ms); + }); + try { + return await Promise.race([p, timeout]); + } finally { + clearTimeout(t); + } +} diff --git a/src/workspaces.ts b/src/workspaces.ts new file mode 100644 index 0000000..ed72724 --- /dev/null +++ b/src/workspaces.ts @@ -0,0 +1,135 @@ +import { randomBytes } from 'node:crypto'; +import { all, one, run, type Db } from './db.js'; +import { newId, nowIso, seal, sha256, SubmitFleetError, unseal } from './util.js'; + +export type Role = 'owner' | 'agent'; + +export interface Actor { + workspaceId: string; + role: Role; + /** Stable principal id, e.g. the API key id. */ + id: string; +} + +export async function createWorkspace(db: Db, name: string): Promise { + const id = newId('wsp'); + await run(db, 'INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)', id, name, nowIso()); + return id; +} + +export async function ensureDefaultWorkspace(db: Db): Promise { + const r = await one<{ id: string }>(db, 'SELECT id FROM workspaces ORDER BY created_at LIMIT 1'); + return r?.id ?? createWorkspace(db, 'default'); +} + +/** Returns the plaintext token once; only its hash is stored. */ +export async function createApiKey(db: Db, workspaceId: string, role: Role, label: string): Promise<{ id: string; token: string }> { + const id = newId('key'); + const token = `sf_${role === 'owner' ? 'own' : 'agt'}_${randomBytes(24).toString('base64url')}`; + await run(db, 'INSERT INTO api_keys (id, workspace_id, role, label, token_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)', id, workspaceId, role, label, sha256(token), nowIso()); + return { id, token }; +} + +export async function authenticate(db: Db, token: string | undefined | null): Promise { + if (!token) return null; + const r = await one<{ id: string; workspace_id: string; role: Role }>( + db, + 'SELECT id, workspace_id, role FROM api_keys WHERE token_hash = ? AND revoked_at IS NULL', + sha256(token), + ); + return r ? { id: r.id, workspaceId: r.workspace_id, role: r.role } : null; +} + +export async function revokeApiKey(db: Db, workspaceId: string, id: string): Promise { + await run(db, 'UPDATE api_keys SET revoked_at = ? WHERE id = ? AND workspace_id = ?', nowIso(), id, workspaceId); +} + +export function requireOwner(actor: Actor): void { + if (actor.role !== 'owner') throw new SubmitFleetError('owner_required', 'this operation needs the owner, not an agent'); +} + +// ── Global pause ───────────────────────────────────────────────────────────── + +export async function setPaused(db: Db, workspaceId: string, paused: boolean): Promise { + await run(db, 'UPDATE workspaces SET paused = ?, paused_at = ? WHERE id = ?', paused ? 1 : 0, paused ? nowIso() : null, workspaceId); +} + +export async function isPaused(db: Db, workspaceId: string): Promise { + const r = await one<{ paused: number }>(db, 'SELECT paused FROM workspaces WHERE id = ?', workspaceId); + return Number(r?.paused) === 1; +} + +// ── Destination accounts ───────────────────────────────────────────────────── + +export interface Account { + id: string; + workspace_id: string; + destination_id: string; + label: string; + status: 'pending' | 'connected' | 'needs_reauth' | 'disconnected'; + expires_at: string | null; + created_at: string; + updated_at: string; +} + +const ACCOUNT_COLS = 'id, workspace_id, destination_id, label, status, expires_at, created_at, updated_at'; + +export async function connectAccount( + db: Db, + key: Buffer, + workspaceId: string, + destinationId: string, + label: string, + secret: Record, + expiresAt: string | null, +): Promise { + const now = nowIso(); + const existing = await one<{ id: string }>(db, 'SELECT id FROM accounts WHERE workspace_id = ? AND destination_id = ? AND label = ?', workspaceId, destinationId, label); + const sealed = seal(key, JSON.stringify(secret)); + let id = existing?.id; + if (id) { + await run(db, "UPDATE accounts SET sealed_secret = ?, status = 'connected', expires_at = ?, updated_at = ? WHERE id = ?", sealed, expiresAt, now, id); + } else { + id = newId('acc'); + await run( + db, + "INSERT INTO accounts (id, workspace_id, destination_id, label, status, sealed_secret, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?)", + id, workspaceId, destinationId, label, sealed, expiresAt, now, now, + ); + } + return (await getAccount(db, workspaceId, id))!; +} + +export async function getAccount(db: Db, workspaceId: string, id: string): Promise { + return one(db, `SELECT ${ACCOUNT_COLS} FROM accounts WHERE workspace_id = ? AND id = ?`, workspaceId, id); +} + +export async function listAccounts(db: Db, workspaceId: string): Promise { + return all(db, `SELECT ${ACCOUNT_COLS} FROM accounts WHERE workspace_id = ? ORDER BY created_at`, workspaceId); +} + +export async function usableAccount(db: Db, workspaceId: string, destinationId: string, now = Date.now()): Promise { + const rows = await all( + db, + `SELECT ${ACCOUNT_COLS} FROM accounts WHERE workspace_id = ? AND destination_id = ? AND status = 'connected' ORDER BY created_at`, + workspaceId, + destinationId, + ); + return rows.find((a) => !a.expires_at || Date.parse(a.expires_at) > now) ?? null; +} + +/** Disconnect wipes the sealed secret; queued work for the account can no longer be claimed. */ +export async function disconnectAccount(db: Db, workspaceId: string, id: string): Promise { + const n = await run(db, "UPDATE accounts SET status = 'disconnected', sealed_secret = NULL, updated_at = ? WHERE id = ? AND workspace_id = ?", nowIso(), id, workspaceId); + if (!n) throw new SubmitFleetError('not_found', `account ${id}`); +} + +export async function markNeedsReauth(db: Db, id: string): Promise { + await run(db, "UPDATE accounts SET status = 'needs_reauth', updated_at = ? WHERE id = ?", nowIso(), id); +} + +export async function readAccountSecret(db: Db, key: Buffer, workspaceId: string, id: string): Promise> { + const r = await one<{ sealed_secret: string | null; status: string }>(db, 'SELECT sealed_secret, status FROM accounts WHERE id = ? AND workspace_id = ?', id, workspaceId); + if (!r || !r.sealed_secret || r.status !== 'connected') throw new SubmitFleetError('account_unavailable'); + return JSON.parse(unseal(key, r.sealed_secret)) as Record; +} diff --git a/test/campaign.test.ts b/test/campaign.test.ts new file mode 100644 index 0000000..8ae0337 --- /dev/null +++ b/test/campaign.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { all } from '../src/db.js'; +import { approvedProduct, fakeAdapter, harness, launch, qualifyingDestination, seedDestinations, type Harness } from './helpers.js'; + +let h: Harness; +afterEach(() => h?.cleanup()); + +describe('campaigns, approvals and reporting', () => { + it('3. a strict campaign with 73 executable matches makes no submission writes', async () => { + const adapter = fakeAdapter(); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 73); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await h.fleet.campaignPlan(h.agent, { productId: p.id, minExecutableDestinations: 100 }); + expect(plan.campaign.status).toBe('coverage_insufficient'); + expect(plan.counts.executable).toBe(73); + await expect(h.fleet.approvalRequest(h.agent, plan.campaign.id)).rejects.toMatchObject({ code: 'coverage_insufficient', detail: { executable: 73, required: 100 } }); + await expect(h.fleet.campaignRun(h.owner, plan.campaign.id)).rejects.toMatchObject({ code: 'not_approved' }); + await h.fleet.worker().tick(100); + expect(adapter.calls.execute).toHaveLength(0); + expect(await all(h.fleet.db, 'SELECT id FROM intents')).toHaveLength(0); + }); + + it('the owner can explicitly approve a smaller campaign without waiving the engine gate', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 5); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await launch(h, p.id, 5); + expect(plan.counts.executable).toBe(5); + expect((await h.fleet.coverageReport()).status).toBe('below_minimum'); + }); + + it('8. changed payloads or targets invalidate approvals; agents cannot approve', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 3); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await h.fleet.campaignPlan(h.agent, { productId: p.id, minExecutableDestinations: 3 }); + await h.fleet.approvalRequest(h.agent, plan.campaign.id); + // Self-approval by the requesting agent fails. + await expect(h.fleet.approvalGrant(h.agent, plan.campaign.id)).rejects.toMatchObject({ code: 'owner_required' }); + + // Material content change after the request: grant is refused. + await h.fleet.productUpdate(h.owner, p.id, { tagline: 'Now with AI' }); + await h.fleet.productApproveFacts(h.owner, p.id); + await expect(h.fleet.approvalGrant(h.owner, plan.campaign.id)).rejects.toMatchObject({ code: 'approval_invalidated' }); + + // Change after grant: run refuses and drops back to draft. + await h.fleet.campaignPlan(h.agent, { productId: p.id, minExecutableDestinations: 3 }, plan.campaign.id); + await h.fleet.approvalRequest(h.agent, plan.campaign.id); + await h.fleet.approvalGrant(h.owner, plan.campaign.id); + await h.fleet.productUpdate(h.owner, p.id, { description: 'changed' }); + await h.fleet.productApproveFacts(h.owner, p.id); + await expect(h.fleet.campaignRun(h.owner, plan.campaign.id)).rejects.toMatchObject({ code: 'approval_invalidated' }); + expect(await all(h.fleet.db, 'SELECT id FROM intents')).toHaveLength(0); + + // Spend limit is part of the scope too. + const p2 = await approvedProduct(h.fleet, h.owner, 'https://other.example'); + const plan2 = await h.fleet.campaignPlan(h.agent, { productId: p2.id, minExecutableDestinations: 3, spendLimitCents: 0 }); + const req = await h.fleet.approvalRequest(h.agent, plan2.campaign.id); + await expect(h.fleet.approvalGrant(h.owner, plan2.campaign.id, 'not-the-hash')).rejects.toMatchObject({ code: 'approval_invalidated' }); + const again = await h.fleet.approvalRequest(h.agent, plan2.campaign.id); + expect(again.scope_hash).toBe(req.scope_hash); + await h.fleet.approvalGrant(h.owner, plan2.campaign.id, again.scope_hash); + }); + + it('unapproved product facts block every target', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 2); + const p = await h.fleet.productCreate(h.owner, { url: 'https://acme.example', name: 'Acme' }); + const plan = await h.fleet.campaignPlan(h.agent, { productId: p.id, minExecutableDestinations: 1 }); + expect(plan.counts.executable).toBe(0); + expect(plan.targets[0]!.blockers).toContain('product_facts_unapproved'); + }); + + it('dedupes by canonical product and listing group, not by wording or tracking params', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 2); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 2); + await expect(h.fleet.productCreate(h.owner, { url: 'https://www.ACME.example/?utm_source=x' })).rejects.toMatchObject({ code: 'product_exists' }); + await h.fleet.productUpdate(h.owner, p.id, { tagline: 'Reworded pitch' }); + await h.fleet.productApproveFacts(h.owner, p.id); + const again = await h.fleet.campaignPlan(h.agent, { productId: p.id, minExecutableDestinations: 1 }); + expect(again.counts.executable).toBe(0); + expect(again.targets.every((t) => t.blockers.some((b) => b.startsWith('duplicate_existing_submission')))).toBe(true); + // A mirror in the same listing group is not a second target either. + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(50, { listing_group: 'dest-0.example' })); + const p2 = await approvedProduct(h.fleet, h.owner, 'https://beta.example'); + const plan2 = await h.fleet.campaignPlan(h.agent, { productId: p2.id, minExecutableDestinations: 1 }); + expect(plan2.counts.executable).toBe(2); + }); + + it('11, 12, 17, 20. report keeps states distinct, keeps failures in the denominator and unknowns unknown', async () => { + let n = 0; + const adapter = fakeAdapter('fake', { + execute: async (ctx) => { + n++; + if (n === 1) return { kind: 'scheduled', remoteId: 'a', receiptId: 'ra', listingUrl: null, evidence: {} }; + if (n === 2) return { kind: 'needs_human', reasonCode: 'captcha', externalActionPerformed: false }; + if (n === 3) return { kind: 'failed', reasonCode: 'http_400', externalActionPerformed: false }; + return { kind: 'submitted', remoteId: ctx.intentId, receiptId: `r-${ctx.intentId}`, evidence: {} }; + }, + }); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 4); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await launch(h, p.id, 4); + await h.fleet.worker().tick(10); + const r = await h.fleet.reportGet(h.owner, plan.campaign.id); + expect(r.outcomes).toEqual({ scheduled: 1, needs_human: 1, failed: 1, submitted: 1 }); + expect(r.outcomes.published_verified).toBeUndefined(); + expect(r.execution).toEqual({ attempted_denominator: 4, evidenced_receipts: 2, rate: 0.5 }); + expect(r.analytics.referral_visits).toBeNull(); + expect(r.human_effort_minutes).toBeNull(); + expect(r.limitations.join(' ')).toMatch(/unknown, not zero/); + expect(r.costs.listing_spend_cents).toBe(0); + + await h.fleet.logEffort(h.owner, plan.campaign.id, 'copy review', 12.5); + const md = await h.fleet.reportExport(h.owner, plan.campaign.id, 'markdown'); + expect(md).toContain('Human effort: 12.5 min'); + expect(md).toContain('Referral visits: unknown'); + const csv = await h.fleet.reportExport(h.owner, plan.campaign.id, 'csv'); + expect(csv.split('\n')[0]).toContain('receipt_id'); + }); + + it('18. unsupported remote update/withdraw fail explicitly without remote action', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await launch(h, p.id, 1); + await h.fleet.worker().tick(); + const [i] = await all<{ id: string }>(h.fleet.db, 'SELECT id FROM intents WHERE campaign_id = ?', plan.campaign.id); + const res = await h.fleet.submissionWithdraw(h.owner, i!.id); + expect(res).toMatchObject({ ok: false, reason_code: 'unsupported_operation', external_action_performed: false }); + expect(await h.fleet.submissionUpdate(h.owner, i!.id)).toMatchObject({ reason_code: 'unsupported_operation' }); + }); +}); diff --git a/test/coverage.test.ts b/test/coverage.test.ts new file mode 100644 index 0000000..62cc656 --- /dev/null +++ b/test/coverage.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { harness, qualifyingDestination, seedDestinations, type Harness } from './helpers.js'; + +let h: Harness; +afterEach(() => h?.cleanup()); + +describe('100+ destination release gate (PRD §4, §16 tests 1, 2, 4, 5, 19)', () => { + it('1. five hundred imported names with 99 qualifying adapters fail release', async () => { + h = await harness(); + const records = Array.from({ length: 500 }, (_, i) => ({ name: `Dir ${i}`, url: `https://imported-${i}.example/submit`, kind: 'directory', labeled_free: true })); + const imp = await h.fleet.catalogImport(h.owner, records, 'awesome-submitlist', 'CC0-1.0'); + expect(imp.created).toBe(500); + await seedDestinations(h.fleet, h.owner, 99); + const cov = await h.fleet.coverageReport(); + expect(cov.discovered).toBe(599); + expect(cov.source_labeled_free).toBe(599); + expect(cov.verified_free).toBe(99); + expect(cov.release_qualifying).toBe(99); + expect(cov.status).toBe('below_minimum'); + + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(99)); + const cov2 = await h.fleet.coverageReport(); + expect(cov2.release_qualifying).toBe(100); + expect(cov2.status).toBe('release_ready'); + }); + + it('imports never enable execution, even when the import claims everything', async () => { + h = await harness(); + await h.fleet.catalogImport(h.owner, [{ name: 'X', url: 'https://x.example', kind: 'launch', labeled_free: true }], 's', 'MIT'); + const d = await h.fleet.catalogGet('x-example'); + expect(d.fee_status).toBe('source_labeled_free'); + expect(d.automation_permission).toBe('unknown'); + expect(d.adapter_id).toBeNull(); + expect(d.qualification.stages.release_qualifying).toBe(false); + // Re-import cannot overwrite reviewed facts. + await h.fleet.catalogUpsert(h.owner, { ...qualifyingDestination(0), slug: 'x-example', canonical_domain: 'x.example', listing_group: 'x.example' }); + await h.fleet.catalogImport(h.owner, [{ name: 'X', url: 'https://x.example' }], 's', 'MIT'); + expect((await h.fleet.catalogGet('x-example')).qualification.stages.release_qualifying).toBe(true); + }); + + it('2. aliases, syndication, assisted workflows and non-directory channels never inflate coverage', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 3); + // Mirror / language path of dest-0: same independent listing group. + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(100, { listing_group: 'dest-0.example', canonical_domain: 'de.dest-0.example' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(101, { automation_method: 'assisted' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(102, { challenge_policy: 'always' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(103, { kind: 'social' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(104, { kind: 'newsletter' })); + const cov = await h.fleet.coverageReport(); + expect(cov.release_qualifying).toBe(3); + }); + + it('4. every counted adapter has live evidence, contract tests and an installed adapter', async () => { + h = await harness(); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(0, { live_evidence_ref: null })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(1, { contract_tested_at: null })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(2, { adapter_id: 'not-installed' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(3, { reconcile_support: 'none' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(4)); + const cov = await h.fleet.coverageReport(); + expect(cov.release_qualifying).toBe(1); + expect(cov.blocker_counts.no_live_submission_evidence).toBe(1); + expect(cov.blocker_counts.no_contract_tests).toBe(1); + expect(cov.blocker_counts.adapter_not_installed).toBe(1); + expect(cov.blocker_counts.no_reconcile).toBe(1); + }); + + it('5. paid, conditional, stale, permission-less and backlink routes are excluded', async () => { + h = await harness(); + const old = new Date(Date.now() - 90 * 86_400_000).toISOString(); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(0, { fee_status: 'paid' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(1, { fee_status: 'conditional_free' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(2, { fee_checked_at: old })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(3, { automation_permission: 'unknown' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(4, { backlink_required: true })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(5, { automation_permission: 'denied' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(6, { quality_decision: 'rejected' })); + const cov = await h.fleet.coverageReport(); + expect(cov.release_qualifying).toBe(0); + for (const b of ['paid_route', 'conditional_free', 'fee_evidence_stale', 'automation_permission_unknown', 'backlink_required', 'automation_denied', 'quality_rejected']) { + expect(cov.blocker_counts[b], b).toBe(1); + } + }); + + it('19. a broken or newly paid adapter reduces live coverage immediately', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 100); + expect((await h.fleet.coverageReport()).status).toBe('release_ready'); + await h.fleet.catalogSetHealth(h.owner, 'dest-5', 'quarantined', 'form changed'); + await h.fleet.catalogUpsert(h.owner, { ...qualifyingDestination(6), fee_status: 'paid' }); + const cov = await h.fleet.coverageReport(); + expect(cov.release_qualifying).toBe(98); + expect(cov.status).toBe('below_minimum'); + }); + + it('registry edits are owner-only', async () => { + h = await harness(); + await expect(h.fleet.catalogUpsert(h.agent, qualifyingDestination(0))).rejects.toMatchObject({ code: 'owner_required' }); + await expect(h.fleet.catalogImport(h.agent, [], 's', 'l')).rejects.toMatchObject({ code: 'owner_required' }); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..0e59159 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import type { Adapter, ExecContext, ExecResult, ReconcileResult } from '../src/adapters/types.js'; +import type { DestinationInput } from '../src/catalog.js'; +import { SubmitFleet } from '../src/service.js'; +import { createApiKey, ensureDefaultWorkspace, type Actor } from '../src/workspaces.js'; + +export const SECRET = randomBytes(32).toString('base64'); + +export interface FakeBehaviour { + execute?: (ctx: ExecContext) => Promise; + reconcile?: (ctx: ExecContext) => Promise; + capabilities?: Partial>; +} + +export function fakeAdapter(id = 'fake', b: FakeBehaviour = {}): Adapter & { calls: { execute: string[]; reconcile: string[] } } { + const calls = { execute: [] as string[], reconcile: [] as string[] }; + return { + id, + version: '1.0.0', + destinationSlug: '*', + method: 'api', + calls, + capabilities: () => ({ update: false, withdraw: false, reconcile: 'tested', ...b.capabilities }), + validate: (p) => (p.name ? [] : [{ field: 'name', code: 'missing_fact', message: 'name required' }]), + prepare: async ({ product, destination }) => ({ name: product.name, url: product.url, to: destination.slug, _estimated_cost_cents: 0 }), + preflight: async () => ({ ok: true }), + execute: async (ctx) => { + calls.execute.push(ctx.intentId); + if (b.execute) return b.execute(ctx); + return { kind: 'submitted', remoteId: `r-${ctx.intentId}`, receiptId: `rcpt-${ctx.intentId}`, listingUrl: `https://${ctx.destination.canonical_domain}/p/1`, evidence: { ok: true } }; + }, + reconcile: async (ctx) => { + calls.reconcile.push(ctx.intentId); + if (b.reconcile) return b.reconcile(ctx); + return { status: 'absent', evidence: {} }; + }, + }; +} + +export function qualifyingDestination(i: number, over: Partial = {}): DestinationInput { + const now = new Date().toISOString(); + return { + slug: `dest-${i}`, + name: `Destination ${i}`, + canonical_domain: `dest-${i}.example`, + kind: 'directory', + listing_group: `dest-${i}.example`, + account_requirement: 'none', + fee_status: 'verified_free', + fee_evidence_url: `https://dest-${i}.example/pricing`, + fee_checked_at: now, + automation_method: 'api', + automation_permission: 'permitted', + permission_evidence: 'documented public API', + permission_checked_at: now, + challenge_policy: 'none', + quality_decision: 'accepted', + quality_basis: 'relevant software directory with substantive listings', + adapter_id: 'fake', + adapter_version: '1.0.0', + adapter_health: 'healthy', + contract_tested_at: now, + live_tested_at: now, + live_evidence_ref: `evidence/dest-${i}.json`, + reconcile_support: 'tested', + ...over, + }; +} + +export interface Harness { + fleet: SubmitFleet; + owner: Actor; + agent: Actor; + dir: string; + dbUrl: string; + reopen: (adapters?: Map) => Promise; + cleanup: () => void; +} + +export async function harness(adapters: Adapter[] = [fakeAdapter()]): Promise { + const dir = mkdtempSync(join(tmpdir(), 'submitfleet-test-')); + writeFileSync(join(dir, 'logo.png'), Buffer.from('89504e470d0a1a0a', 'hex')); + const dbUrl = `file:${join(dir, 'sf.db')}`; + const map = new Map(adapters.map((a) => [a.id, a])); + const fleet = await SubmitFleet.open({ dbUrl, secretKey: SECRET, assetsDir: dir, adapters: map }); + const ws = await ensureDefaultWorkspace(fleet.db); + const ownerKey = await createApiKey(fleet.db, ws, 'owner', 'test owner'); + const agentKey = await createApiKey(fleet.db, ws, 'agent', 'test agent'); + const opened: SubmitFleet[] = [fleet]; + return { + fleet, + owner: { workspaceId: ws, role: 'owner', id: ownerKey.id }, + agent: { workspaceId: ws, role: 'agent', id: agentKey.id }, + dir, + dbUrl, + reopen: async (a = map) => { + const f = await SubmitFleet.open({ dbUrl, secretKey: SECRET, assetsDir: dir, adapters: a }); + opened.push(f); + return f; + }, + cleanup: () => { + for (const f of opened) f.close(); + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +export async function seedDestinations(fleet: SubmitFleet, owner: Actor, n: number, over: (i: number) => Partial = () => ({})) { + for (let i = 0; i < n; i++) await fleet.catalogUpsert(owner, qualifyingDestination(i, over(i))); +} + +export async function approvedProduct(fleet: SubmitFleet, owner: Actor, url = 'https://acme.example/') { + const p = await fleet.productCreate(owner, { url, name: 'Acme', tagline: 'The friendly to-do app', description: 'A to-do app.', logo_path: 'logo.png' }); + return fleet.productApproveFacts(owner, p.id); +} + +/** Plan, request (as agent), grant (as owner) and run. */ +export async function launch(h: Harness, productId: string, min: number, extra: Record = {}) { + const plan = await h.fleet.campaignPlan(h.agent, { productId, minExecutableDestinations: min, ...extra }); + await h.fleet.approvalRequest(h.agent, plan.campaign.id); + await h.fleet.approvalGrant(h.owner, plan.campaign.id); + await h.fleet.campaignRun(h.owner, plan.campaign.id); + return plan; +} diff --git a/test/security.test.ts b/test/security.test.ts new file mode 100644 index 0000000..28af827 --- /dev/null +++ b/test/security.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { all } from '../src/db.js'; +import { createApiKey, createWorkspace, authenticate } from '../src/workspaces.js'; +import { redact, seal, unseal, loadSecretKey } from '../src/util.js'; +import { approvedProduct, fakeAdapter, harness, launch, qualifyingDestination, SECRET, type Harness } from './helpers.js'; + +let h: Harness; +afterEach(() => h?.cleanup()); + +describe('9. secrets and tenant isolation', () => { + it('account secrets are sealed at rest and never appear in submissions, events or reports', async () => { + const TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.c2lnbmF0dXJl'; + const adapter = fakeAdapter('fake', { + execute: async (ctx) => { + const c = await ctx.credentials(); + expect(c.access_token).toBe(TOKEN); + return { kind: 'submitted', remoteId: 'r', receiptId: 'rc', evidence: { authorization: `Bearer ${c.access_token}`, echoed: c.access_token } }; + }, + }); + h = await harness([adapter]); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(0, { account_requirement: 'login' })); + await h.fleet.accountConnectSecret(h.owner, 'dest-0', 'main', { access_token: TOKEN, password: 'hunter2' }, null); + const raw = await all<{ sealed_secret: string }>(h.fleet.db, 'SELECT sealed_secret FROM accounts'); + expect(raw[0]!.sealed_secret).not.toContain('hunter2'); + expect(JSON.stringify(await h.fleet.accountStatus(h.owner))).not.toMatch(/hunter2|eyJ|sealed/); + + const p = await approvedProduct(h.fleet, h.owner); + const plan = await launch(h, p.id, 1); + await h.fleet.worker().tick(); + const [i] = await all<{ id: string }>(h.fleet.db, 'SELECT id FROM intents'); + const dump = JSON.stringify(await h.fleet.submissionGet(h.owner, i!.id)) + (await h.fleet.reportExport(h.owner, plan.campaign.id, 'json')); + expect(dump).not.toContain(TOKEN); + expect(dump).not.toContain('hunter2'); + }); + + it("another workspace's records cannot be read or changed", async () => { + h = await harness(); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(0)); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await launch(h, p.id, 1); + const otherWs = await createWorkspace(h.fleet.db, 'other'); + const k = await createApiKey(h.fleet.db, otherWs, 'owner', 'other'); + const other = (await authenticate(h.fleet.db, k.token))!; + expect(other.workspaceId).toBe(otherWs); + await expect(h.fleet.productGet(other, p.id)).rejects.toMatchObject({ code: 'not_found' }); + await expect(h.fleet.campaignPreview(other, plan.campaign.id)).rejects.toMatchObject({ code: 'not_found' }); + await expect(h.fleet.campaignCancel(other, plan.campaign.id)).rejects.toMatchObject({ code: 'not_found' }); + await expect(h.fleet.reportGet(other, plan.campaign.id)).rejects.toMatchObject({ code: 'not_found' }); + expect(await h.fleet.productList(other)).toHaveLength(0); + const [i] = await all<{ id: string }>(h.fleet.db, 'SELECT id FROM intents'); + await expect(h.fleet.submissionGet(other, i!.id)).rejects.toMatchObject({ code: 'not_found' }); + expect(await authenticate(h.fleet.db, 'sf_own_wrong')).toBeNull(); + }); + + it('assets cannot escape the asset root or be executable', async () => { + h = await harness(); + await expect(h.fleet.readAsset('../../etc/passwd')).rejects.toMatchObject({ code: 'asset_outside_root' }); + await expect(h.fleet.readAsset('/etc/passwd')).rejects.toMatchObject({ code: 'asset_outside_root' }); + await expect(h.fleet.readAsset('evil.sh')).rejects.toMatchObject({ code: 'asset_type_not_allowed' }); + expect((await h.fleet.readAsset('logo.png')).length).toBe(8); + }); + + it('seal/unseal round-trips and tampering fails', () => { + const key = loadSecretKey(SECRET); + const s = seal(key, 'secret'); + expect(unseal(key, s)).toBe('secret'); + const parts = s.split('.'); + parts[3] = Buffer.from('other').toString('base64url'); + expect(() => unseal(key, parts.join('.'))).toThrow(); + }); + + it('redact strips credential-like keys and JWTs', () => { + expect(redact({ access_token: 'x', nested: { password: 'p', ok: 1 }, jwt: 'eyJa.eyJb.c' })).toEqual({ access_token: '[redacted]', nested: { password: '[redacted]', ok: 1 }, jwt: '[redacted]' }); + }); +}); diff --git a/test/worker.test.ts b/test/worker.test.ts new file mode 100644 index 0000000..23e920f --- /dev/null +++ b/test/worker.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createBudget, reserve } from '../src/budgets.js'; +import { all, one, run } from '../src/db.js'; +import { getIntent } from '../src/ledger.js'; +import { approvedProduct, fakeAdapter, harness, launch, qualifyingDestination, seedDestinations, type Harness } from './helpers.js'; + +let h: Harness; +afterEach(() => h?.cleanup()); + +const intents = (h: Harness) => all<{ id: string; state: string; reason_code: string | null; fence: number }>(h.fleet.db, 'SELECT id, state, reason_code, fence FROM intents ORDER BY created_at'); + +describe('durable execution', () => { + it('7a. concurrent workers on separate connections execute each intent exactly once', async () => { + const adapter = fakeAdapter('fake', { + execute: async (ctx) => { + await new Promise((r) => setTimeout(r, 5)); + return { kind: 'submitted', remoteId: ctx.intentId, receiptId: `r-${ctx.intentId}`, evidence: {} }; + }, + }); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 30); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 30); + const others = await Promise.all([h.reopen(), h.reopen(), h.reopen()]); + const workers = [h.fleet, ...others].map((f, i) => f.worker({ workerId: `w${i}` })); + await Promise.all(workers.map((w) => w.tick(30))); + expect(adapter.calls.execute).toHaveLength(30); + expect(new Set(adapter.calls.execute).size).toBe(30); + expect((await intents(h)).every((i) => i.state === 'submitted')).toBe(true); + }); + + it('7b. a timeout after a possible write is uncertain and is reconciled, never blindly retried', async () => { + let first = true; + const adapter = fakeAdapter('fake', { + execute: async (ctx) => { + if (first) { + first = false; + await ctx.recordRemote({ draft: 1 }); + await new Promise((r) => setTimeout(r, 300)); + } + return { kind: 'submitted', remoteId: 'x', receiptId: 'rx', evidence: {} }; + }, + reconcile: async () => ({ status: 'found', state: 'submitted', remoteId: 'found-remote', listingUrl: 'https://dest-0.example/p', evidence: { matched: true } }), + }); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 1); + const w = h.fleet.worker({ execTimeoutMs: 50 }); + await w.tick(); + let [i] = await intents(h); + expect(i!.state).toBe('uncertain'); + expect(i!.reason_code).toBe('timeout'); + expect(adapter.calls.execute).toHaveLength(1); + await w.tick(); + [i] = await intents(h); + expect(i!.state).toBe('submitted'); + expect(adapter.calls.execute).toHaveLength(1); + expect(adapter.calls.reconcile).toHaveLength(1); + }); + + it('7c. an expired lease becomes uncertain; a stale worker cannot complete after takeover', async () => { + let clock = Date.now(); + const adapter = fakeAdapter('fake', { reconcile: async () => ({ status: 'unknown', reasonCode: 'cannot_tell' }) }); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 1); + const f2 = await h.reopen(); + const w1 = h.fleet.worker({ leaseMs: 1000 }); + (w1 as unknown as { now: () => number }).now = () => clock; + const claimed = await w1.claim(); + expect(claimed).not.toBeNull(); + clock += 5000; + const w2 = f2.worker(); + (w2 as unknown as { now: () => number }).now = () => clock; + expect(await w2.recoverExpiredLeases()).toBe(1); + // w1 finishes late with its old fence: the write is rejected. + await w1.execute(claimed!); + const i = await getIntent(h.fleet.db, h.owner.workspaceId, claimed!.id); + expect(i.state).toBe('uncertain'); + const ev = await all<{ kind: string }>(h.fleet.db, 'SELECT kind FROM intent_events WHERE intent_id = ?', i.id); + expect(ev.map((e) => e.kind)).toContain('stale_transition_rejected'); + // Unknown reconciliation eventually asks a human rather than retrying. + for (let k = 0; k < 5; k++) await w2.reconcileUncertain(); + expect((await getIntent(h.fleet.db, h.owner.workspaceId, i.id)).state).toBe('needs_human'); + expect(adapter.calls.execute).toHaveLength(1); + }); + + it('reconciled absence is the only path from uncertain back to the queue', async () => { + let calls = 0; + const adapter = fakeAdapter('fake', { + execute: async () => (++calls === 1 ? { kind: 'uncertain', reasonCode: 'http_502' } : { kind: 'submitted', remoteId: 'ok', receiptId: 'r', evidence: {} }), + reconcile: async () => ({ status: 'absent', evidence: { searched: true } }), + }); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 1); + const w = h.fleet.worker(); + await w.tick(); + expect((await intents(h))[0]!.state).toBe('uncertain'); + await w.tick(); + expect((await intents(h))[0]!.state).toBe('submitted'); + expect(calls).toBe(2); + }); + + it('6. account-wide queue limits apply across campaigns and workers', async () => { + h = await harness(); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(0, { account_requirement: 'login', queue_limit_per_account: 1, queue_window_hours: 24 })); + const acct = await h.fleet.accountConnectSecret(h.owner, 'dest-0', 'main', { token: 't' }, null); + const a = await approvedProduct(h.fleet, h.owner, 'https://a.example'); + const b = await approvedProduct(h.fleet, h.owner, 'https://b.example'); + await launch(h, a.id, 1); + await launch(h, b.id, 1); + const f2 = await h.reopen(); + await Promise.all([h.fleet.worker().tick(5), f2.worker().tick(5)]); + const rows = await all<{ state: string; account_id: string }>(h.fleet.db, 'SELECT state, account_id FROM intents'); + expect(rows.every((r) => r.account_id === acct.id)).toBe(true); + expect(rows.map((r) => r.state).sort()).toEqual(['queued', 'submitted']); + }); + + it('14. pause stops new writes; cancel cancels unsent work without claiming remote deletion', async () => { + const adapter = fakeAdapter(); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 4); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await launch(h, p.id, 4); + const w = h.fleet.worker(); + await w.tick(1); + await h.fleet.pauseAll(h.agent); + await w.tick(10); + expect(adapter.calls.execute).toHaveLength(1); + await expect(h.fleet.resumeAll(h.agent)).rejects.toMatchObject({ code: 'owner_required' }); + await h.fleet.resumeAll(h.owner); + await h.fleet.campaignPause(h.owner, plan.campaign.id); + await w.tick(10); + expect(adapter.calls.execute).toHaveLength(1); + const res = await h.fleet.campaignCancel(h.owner, plan.campaign.id); + expect(res).toEqual({ canceled: 3, already_external: 1 }); + const states = (await intents(h)).map((i) => i.state).sort(); + expect(states).toEqual(['canceled', 'canceled', 'canceled', 'submitted']); + }); + + it('disconnecting an account or disabling a destination stops new writes', async () => { + const adapter = fakeAdapter(); + h = await harness([adapter]); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(0, { account_requirement: 'login' })); + await h.fleet.catalogUpsert(h.owner, qualifyingDestination(1)); + const acct = await h.fleet.accountConnectSecret(h.owner, 'dest-0', 'main', { token: 't' }, null); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 2); + await h.fleet.accountDisconnect(h.owner, acct.id); + await h.fleet.catalogUpsert(h.owner, { ...qualifyingDestination(1), disabled: true }); + await h.fleet.worker().tick(10); + expect(adapter.calls.execute).toHaveLength(0); + }); + + it('13. restarts preserve jobs, approvals, costs and receipts', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 2); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await launch(h, p.id, 2, { spendLimitCents: 500 }); + await h.fleet.worker().tick(1); + h.fleet.close(); + const f = await h.reopen(); + const st = await f.campaignStatus(h.owner, plan.campaign.id); + expect(st.intents).toEqual({ submitted: 1, queued: 1 }); + expect(await one(f.db, "SELECT id FROM approvals WHERE status = 'granted' AND campaign_id = ?", plan.campaign.id)).not.toBeNull(); + expect(await one(f.db, 'SELECT limit_cents FROM budgets WHERE campaign_id = ?', plan.campaign.id)).toMatchObject({ limit_cents: 500 }); + await f.worker().tick(5); + const r = await f.reportGet(h.owner, plan.campaign.id); + expect(r.submissions.filter((s) => s.receipt_id)).toHaveLength(2); + (h as { fleet: typeof f }).fleet = f; + }); + + it('15. concurrent budget reservations enforce the spending bound', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + const plan = await launch(h, p.id, 1); + const budgetId = await createBudget(h.fleet.db, h.owner.workspaceId, plan.campaign.id, 100); + const [intent] = await intents(h); + const others = await Promise.all([h.reopen(), h.reopen()]); + const dbs = [h.fleet.db, ...others.map((o) => o.db)]; + const results = await Promise.all(Array.from({ length: 30 }, (_, k) => reserve(dbs[k % 3]!, budgetId, intent!.id, 10))); + expect(results.filter(Boolean)).toHaveLength(10); + expect(await one(h.fleet.db, 'SELECT reserved_cents FROM budgets WHERE id = ?', budgetId)).toMatchObject({ reserved_cents: 100 }); + }); + + it('a job whose estimated cost exceeds the budget goes to the human queue unexecuted', async () => { + const adapter = fakeAdapter(); + adapter.prepare = async ({ product }) => ({ name: product.name, _estimated_cost_cents: 50 }); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 1, { spendLimitCents: 10 }); + await h.fleet.worker().tick(); + expect((await intents(h))[0]).toMatchObject({ state: 'needs_human', reason_code: 'budget_exhausted' }); + expect(adapter.calls.execute).toHaveLength(0); + }); + + it('stale fee or permission evidence blocks the write until refreshed', async () => { + const adapter = fakeAdapter(); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 1); + await run(h.fleet.db, 'UPDATE destinations SET fee_checked_at = ?', new Date(Date.now() - 48 * 3_600_000).toISOString()); + await h.fleet.worker().tick(); + expect((await intents(h))[0]).toMatchObject({ state: 'needs_human', reason_code: 'fee_evidence_stale' }); + expect(adapter.calls.execute).toHaveLength(0); + }); + + it('10. a challenge produces a scoped handoff, not a bypass', async () => { + const adapter = fakeAdapter('fake', { execute: async () => ({ kind: 'needs_human', reasonCode: 'captcha_required', externalActionPerformed: false }) }); + h = await harness([adapter]); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 1); + await h.fleet.worker().tick(); + const q = await h.fleet.humanQueue(h.owner); + expect(q).toHaveLength(1); + expect(q[0]).toMatchObject({ state: 'needs_human', reason_code: 'captcha_required', destination: 'dest-0' }); + await expect(h.fleet.submissionRequeue(h.agent, String(q[0]!.id))).rejects.toMatchObject({ code: 'owner_required' }); + expect(await h.fleet.submissionRequeue(h.owner, String(q[0]!.id))).toBe(true); + }); + + it('intent events are append-only', async () => { + h = await harness(); + await seedDestinations(h.fleet, h.owner, 1); + const p = await approvedProduct(h.fleet, h.owner); + await launch(h, p.id, 1); + await expect(run(h.fleet.db, 'DELETE FROM intent_events')).rejects.toThrow(/append-only/); + await expect(run(h.fleet.db, "UPDATE intent_events SET kind = 'x'")).rejects.toThrow(/append-only/); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..8dd2190 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "rootDir": "src", "declaration": true, "sourceMap": true }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e7684fd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM"], + "types": ["node"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7be5da0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + testTimeout: 20000, + }, +}); From 90ee5e4bd4f216f8938143497c96f0521035d6f3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 14 Oct 2026 23:26:57 +0000 Subject: [PATCH 2/3] SubmitFleet engine foundation: release gate, approvals, durable workers, CLI/TUI/API/MCP/dashboard - Catalog with staged qualification; the 100-destination gate counts independent listing groups only and imports can never enable execution - Strict campaigns return coverage_insufficient with the real count - Owner approvals bound to a scope hash; any material change invalidates them; no agent or MCP path grants approval - Transactional SQL ledger: intents persisted before writes, atomic leased claims with fencing, per-account queue limits and cooldowns, uncertain-then-reconcile, append-only events, atomic budget reservations - TinyLaunch adapter (free route only, stops on any payment signal), contract-tested - Hono /api/v1, MCP over stdio and Streamable HTTP, CLI with exit codes, TUI, mobile dashboard with Copy Markdown and clipboard fallback - 48 tests covering the PRD acceptance list Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 19 ++ README.md | 115 +++++++++++ src/cli.ts | 399 +++++++++++++++++++++++++++++++++++++++ src/config.ts | 40 ++++ src/dashboard.ts | 194 +++++++++++++++++++ src/http.ts | 234 +++++++++++++++++++++++ src/index.ts | 11 ++ src/mcp.ts | 129 +++++++++++++ src/service.ts | 3 +- src/tui.ts | 71 +++++++ src/util.ts | 9 +- test/http.test.ts | 109 +++++++++++ test/tinylaunch.test.ts | 133 +++++++++++++ 13 files changed, 1462 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/dashboard.ts create mode 100644 src/http.ts create mode 100644 src/index.ts create mode 100644 src/mcp.ts create mode 100644 src/tui.ts create mode 100644 test/http.test.ts create mode 100644 test/tinylaunch.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1951617 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: ci +on: + push: + branches: [main] + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test:run + - run: pnpm build diff --git a/README.md b/README.md index 44b1783..579bc41 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,118 @@ # SubmitFleet One product profile. Coordinated submissions. Proof of what went live. + +SubmitFleet is an open-source, self-hostable engine for submitting legitimate products to launch +sites and directories. It keeps one approved product profile, plans a bounded campaign, asks the +owner to approve exactly what will be sent, runs durable workers, and records receipts and verified +listings. The same core serves a CLI, a TUI, an HTTP API, an MCP server and a mobile dashboard. + +## Status: early engine, below the release gate + +| | | +| --- | --- | +| Release-qualifying destinations | **0 of 100 required** (`submitfleet coverage report`) | +| Adapters shipped | 1 (TinyLaunch, contract-tested against its published agent guide; no live submission yet) | +| Deployed | No. Nothing runs at submitfleet.com yet. | +| Submissions made | None | + +This build is the foundation the 100-destination release is measured against, not that release. +It does not claim traffic, ranking or customer outcomes. The PRD's value pilot, design-partner +evidence and the adapter build-out to 100+ are still ahead. + +## What the engine enforces + +- **The release gate.** A destination counts only if it is a launch site or directory, verified free + with fresh evidence, permission-reviewed, unattended (not assisted, not always-captcha), has an + installed adapter with contract tests, owner-authorized live-submission evidence, reconciliation + (or a documented limitation), healthy status and an accepted quality review. Counts are of + independent listing groups, so aliases, mirrors and language paths never multiply coverage. + Imports from public lists are discovery input only and can never enable execution. +- **Strict campaigns.** `--min-executable-destinations` defaults to 100. A campaign with fewer + executable targets returns `coverage_insufficient` with the real count and every blocker, and + cannot be approved or run. The owner may plan a smaller campaign explicitly; the engine gate is + unchanged. +- **Approvals bound to scope.** The owner approves a hash of the exact targets, accounts, payloads, + product facts, dates and spend limits. Any material change invalidates it. Agents (and the MCP + server) can request approval but there is no agent path to grant it; the CLI only approves from + an interactive terminal. +- **Free only by default.** Listing spend is fixed at $0. Paid, conditional-free and + backlink-required routes are excluded. An adapter that meets an unexpected payment stops for a + human. +- **Durable, honest execution.** Intents are persisted before any external write. Workers claim + atomically with leases and fencing; per-account queue limits and cooldowns are enforced inside the + claim. A timeout after a possible write is `uncertain` and is reconciled before anything is + retried. Submitted, scheduled, under review and published are separate states. The event history + is append-only. +- **Bounded spend.** Operating costs are reserved atomically before dispatch and settled after, so + parallel workers cannot overspend. +- **Stops.** Global pause, campaign pause, account disconnect and destination disable all stop new + writes. Cancel stops unsent work and never claims remote deletion. +- **Secrets.** Account credentials are sealed with AES-256-GCM and never appear in model context, + reports or logs. Workspaces are isolated. +- **Reports.** Original target set, exclusions, receipts, listing URLs, unresolved jobs, effort and + itemized cost, in Markdown, JSON or CSV. Missing analytics or effort stay unknown, never zero. + +## Quick start (self-hosted) + +```sh +pnpm install && pnpm build +node dist/cli.js init # workspace, secret key, owner API key (shown once) +node dist/cli.js catalog seed +node dist/cli.js coverage report # exit 3 while below 100 +node dist/cli.js products add https://your-product.example --name "Name" --tagline "..." \ + --description "..." --logo logo.png --field tinylaunch.category_id=9 +node dist/cli.js products approve PRODUCT_ID +node dist/cli.js accounts connect tinylaunch --email you@example.com +node dist/cli.js campaign plan --product PRODUCT_ID --free-only --min-executable-destinations 100 +node dist/cli.js campaign preview CAMPAIGN_ID +node dist/cli.js campaign approve CAMPAIGN_ID +node dist/cli.js campaign run CAMPAIGN_ID +node dist/cli.js worker # or: serve (API + dashboard + worker) +node dist/cli.js report export CAMPAIGN_ID --format markdown +``` + +Assets (logos) are read only from `$SUBMITFLEET_HOME/assets`. Run `submitfleet help` for every +command. Exit codes: 0 ok, 1 error, 2 usage, 3 gate not met, 4 jobs waiting for a person. + +### Configuration + +| Variable | Default | +| --- | --- | +| `SUBMITFLEET_HOME` | `~/.local/share/submitfleet` | +| `SUBMITFLEET_DB_URL` / `SUBMITFLEET_DB_AUTH_TOKEN` | local SQLite file; set a `libsql://` URL for Turso | +| `SUBMITFLEET_SECRET_KEY` | generated key file in `SUBMITFLEET_HOME` (hosted: inject from the vault) | +| `SUBMITFLEET_ASSETS_DIR` | `$SUBMITFLEET_HOME/assets` | +| `PORT`, `HOST` | `8787`, `127.0.0.1` | +| `SUBMITFLEET_ALLOWED_ORIGINS` | none (same-origin only) | + +## Surfaces + +- **CLI** `submitfleet ...` and **TUI** `submitfleet tui` +- **HTTP API** under `/api/v1` with bearer API keys (`owner` or `agent` role) +- **MCP**: `submitfleet mcp --stdio` locally, or authenticated Streamable HTTP at `/mcp`. All tools + use the `submitfleet_` prefix. +- **Dashboard** at `/app`: coverage, portfolio, campaigns (preview, approve, run, pause, cancel), + human-action queue, accounts, reports with Copy Markdown and downloads. + +## Adapters + +Every adapter implements `capabilities`, `validate`, `prepare` (no remote writes), `preflight`, +`execute` and `reconcile`; `update` and `withdraw` are optional and otherwise return +`unsupported_operation`. See `src/adapters/types.ts` and the TinyLaunch adapter for the pattern. +An adapter in the code is necessary, never sufficient: a destination counts only after its catalog +record carries contract-test and live-submission evidence through a reviewed registry edit. + +## Known gaps against the PRD + +- 99+ more qualifying adapters, and the first live TinyLaunch submission. +- Fact extraction from a product URL (products are entered, then owner-approved). +- Dashboard sign-in is an API key; the house magic-link and passkey sign-in is not wired yet. +- The dashboard is server-rendered HTML rather than Next.js. +- Browser-adapter runtime (isolated profiles, selectors, receipts) is not built. +- Analytics integration: outcomes can be recorded but no provider is connected. +- Hosting, DNS and TLS for submitfleet.com are not configured. + +## License + +MIT diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..85d43fe --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,399 @@ +#!/usr/bin/env node +import { serve } from '@hono/node-server'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { readFileSync, realpathSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import type { DestinationInput, ImportRecord } from './catalog.js'; +import { loadConfig } from './config.js'; +import { buildApp } from './http.js'; +import { buildMcpServer } from './mcp.js'; +import { SubmitFleet } from './service.js'; +import { runTui } from './tui.js'; +import { SubmitFleetError } from './util.js'; +import { createApiKey, ensureDefaultWorkspace, type Actor, type Role } from './workspaces.js'; + +export const EXIT = { ok: 0, error: 1, usage: 2, gate: 3, needs_human: 4 } as const; + +const HELP = `submitfleet - coordinated launch and directory submissions with proof of what went live + +Usage: submitfleet [options] + +Setup + init create the local workspace, secret key and an owner API key + doctor non-mutating health check + keys create --role owner|agent [--label L] + +Catalog + catalog seed load the reviewed seed records + catalog search [QUERY] [--json] + catalog get SLUG + catalog import FILE.json --source S --license L + catalog health SLUG --set healthy|quarantined|broken --reason TEXT + coverage report [--min-supported 100] [--json] exit 3 when below the minimum + +Products + products add URL [--name N] [--tagline T] [--description D] [--category C]... [--logo PATH] [--field dest.key=value]... + products list | products get ID + products approve ID owner approves the product facts (interactive) + +Accounts + accounts connect DESTINATION --email EMAIL emailed-code handoff; the code is typed here, never stored + accounts list | accounts disconnect ID + +Campaigns + campaign plan --product ID [--free-only] [--min-executable-destinations 100] [--spend-limit-cents 0] + [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--dest SLUG]... + campaign list | preview ID | approve ID | run ID | status ID [--json] | pause ID | cancel ID + pause-all | resume + queue jobs waiting for a person + submission get ID | reconcile ID | requeue ID + report export ID [--format markdown|json|csv] + effort log --minutes M --activity TEXT [--campaign ID] + +Run + worker [--once] [--interval-ms 5000] + serve [--port 8787] [--no-worker] + mcp --stdio + tui + +Data lives in $SUBMITFLEET_HOME (default ~/.local/share/submitfleet); set SUBMITFLEET_DB_URL for Turso. +Machine output goes to stdout, diagnostics to stderr.`; + +const out = (v: unknown) => process.stdout.write(typeof v === 'string' ? (v.endsWith('\n') ? v : `${v}\n`) : `${JSON.stringify(v, null, 2)}\n`); +const log = (s: string) => process.stderr.write(`${s}\n`); + +function packageRoot(): string { + return join(dirname(fileURLToPath(import.meta.url)), '..'); +} + +async function confirmTty(question: string, expect: string): Promise { + if (!process.stdin.isTTY) { + throw new SubmitFleetError('interactive_owner_required', 'approval needs an interactive owner terminal or the dashboard; agents cannot sign approvals'); + } + const rl = createInterface({ input: process.stdin, output: process.stderr }); + const answer = await rl.question(`${question}\nType "${expect}" to confirm: `); + rl.close(); + return answer.trim() === expect; +} + +async function promptLine(q: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + const a = await rl.question(q); + rl.close(); + return a.trim(); +} + +function parseFields(list: string[] | undefined): Record> { + const fields: Record> = {}; + for (const f of list ?? []) { + const m = /^([a-z0-9-]+)\.([a-z0-9_]+)=(.*)$/i.exec(f); + if (!m) throw new SubmitFleetError('invalid_request', `--field must look like dest.key=value, got ${f}`); + const [, dest, k, raw] = m; + fields[dest!] ??= {}; + fields[dest!]![k!] = /^-?\d+(\.\d+)?$/.test(raw!) ? Number(raw) : raw; + } + return fields; +} + +export async function main(argv = process.argv.slice(2)): Promise { + const { values: o, positionals: pos } = parseArgs({ + args: argv, + allowPositionals: true, + strict: false, + options: { + json: { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + role: { type: 'string' }, + label: { type: 'string' }, + source: { type: 'string' }, + license: { type: 'string' }, + set: { type: 'string' }, + reason: { type: 'string' }, + 'min-supported': { type: 'string' }, + name: { type: 'string' }, + tagline: { type: 'string' }, + description: { type: 'string' }, + category: { type: 'string', multiple: true }, + logo: { type: 'string' }, + field: { type: 'string', multiple: true }, + email: { type: 'string' }, + product: { type: 'string' }, + 'free-only': { type: 'boolean' }, + 'min-executable-destinations': { type: 'string' }, + 'spend-limit-cents': { type: 'string' }, + from: { type: 'string' }, + to: { type: 'string' }, + dest: { type: 'string', multiple: true }, + format: { type: 'string' }, + minutes: { type: 'string' }, + activity: { type: 'string' }, + campaign: { type: 'string' }, + once: { type: 'boolean' }, + 'interval-ms': { type: 'string' }, + port: { type: 'string' }, + 'no-worker': { type: 'boolean' }, + stdio: { type: 'boolean' }, + 'dry-run': { type: 'boolean' }, + }, + }); + const [cmd, sub, arg] = pos; + if (!cmd || o.help || cmd === 'help') { + out(HELP); + return cmd || o.help ? EXIT.ok : EXIT.usage; + } + if (cmd === 'version' || cmd === '--version') { + out(JSON.parse(readFileSync(join(packageRoot(), 'package.json'), 'utf8')).version); + return EXIT.ok; + } + + const fleet = await SubmitFleet.open(loadConfig()); + const ws = await ensureDefaultWorkspace(fleet.db); + // Local CLI access implies filesystem access to the database: it acts as the owner. + const owner: Actor = { workspaceId: ws, role: 'owner', id: 'local-cli' }; + const num = (v: unknown, d?: number) => (v === undefined ? d : Number(v)); + try { + switch (cmd) { + case 'init': { + const k = await createApiKey(fleet.db, ws, 'owner', 'local owner'); + log('Workspace ready. Store this owner key now; it is not shown again:'); + out({ workspace_id: ws, owner_key: k.token }); + return EXIT.ok; + } + case 'doctor': { + const checks = await fleet.doctor(); + out(o.json ? checks : checks.map((c) => `${c.ok ? 'ok ' : 'FAIL'} ${c.check}: ${c.detail ?? ''}`).join('\n')); + return checks.find((c) => c.check === 'database')?.ok ? EXIT.ok : EXIT.error; + } + case 'keys': { + if (sub !== 'create') return usage(); + const role = (o.role ?? 'agent') as Role; + if (role !== 'owner' && role !== 'agent') return usage('--role must be owner or agent'); + const k = await createApiKey(fleet.db, ws, role, String(o.label ?? `${role} key`)); + log('Store this key now; it is not shown again.'); + out({ id: k.id, role, token: k.token }); + return EXIT.ok; + } + case 'catalog': { + if (sub === 'seed') { + const seed = JSON.parse(readFileSync(join(packageRoot(), 'catalog', 'seed.json'), 'utf8')) as { destinations: DestinationInput[] }; + for (const d of seed.destinations) await fleet.catalogUpsert(owner, d); + out({ seeded: seed.destinations.length }); + return EXIT.ok; + } + if (sub === 'search') { + const ds = await fleet.catalogSearch(arg); + out(o.json ? ds : ds.map((d) => `${d.slug}\t${d.kind}\t${d.fee_status}\t${d.automation_method}\t${d.adapter_health}`).join('\n') || 'no destinations'); + return EXIT.ok; + } + if (sub === 'get' && arg) return out(await fleet.catalogGet(arg)), EXIT.ok; + if (sub === 'import' && arg) { + if (!o.source || !o.license) return usage('--source and --license are required'); + const records = JSON.parse(readFileSync(arg, 'utf8')) as ImportRecord[]; + out(await fleet.catalogImport(owner, records, String(o.source), String(o.license))); + return EXIT.ok; + } + if (sub === 'health' && arg && o.set && o.reason) { + await fleet.catalogSetHealth(owner, arg, o.set as 'healthy', String(o.reason)); + out(await fleet.catalogGet(arg)); + return EXIT.ok; + } + return usage(); + } + case 'coverage': { + const c = await fleet.coverageReport(); + const min = num(o['min-supported'], c.release_minimum)!; + if (o.json) out(c); + else + out( + [ + `discovered ${c.discovered}`, + `source-labeled free ${c.source_labeled_free}`, + `verified free ${c.verified_free}`, + `permission reviewed ${c.permission_reviewed}`, + `live tested ${c.live_tested}`, + `release qualifying ${c.release_qualifying} / ${min} ${c.release_qualifying >= min ? '(meets minimum)' : '(below_minimum)'}`, + ].join('\n'), + ); + return c.release_qualifying >= min ? EXIT.ok : EXIT.gate; + } + case 'products': { + if (sub === 'add' && arg) { + const p = await fleet.productCreate(owner, { + url: arg, + name: o.name as string | undefined, + tagline: o.tagline as string | undefined, + description: o.description as string | undefined, + categories: o.category as string[] | undefined, + logo_path: o.logo as string | undefined, + destination_fields: parseFields(o.field as string[] | undefined), + }); + out(p); + log('Review the facts, then approve them with: submitfleet products approve ' + p.id); + return EXIT.ok; + } + if (sub === 'list') return out(await fleet.productList(owner)), EXIT.ok; + if (sub === 'get' && arg) return out(await fleet.productGet(owner, arg)), EXIT.ok; + if (sub === 'approve' && arg) { + const p = await fleet.productGet(owner, arg); + log(JSON.stringify({ url: p.url, name: p.name, tagline: p.tagline, description: p.description, categories: p.categories, logo_path: p.logo_path, destination_fields: p.destination_fields }, null, 2)); + if (!(await confirmTty('Approve these facts for use in submissions?', 'approve'))) return log('Not approved.'), EXIT.error; + out(await fleet.productApproveFacts(owner, arg)); + return EXIT.ok; + } + return usage(); + } + case 'accounts': { + if (sub === 'list') return out(await fleet.accountStatus(owner)), EXIT.ok; + if (sub === 'disconnect' && arg) return await fleet.accountDisconnect(owner, arg), out({ disconnected: arg }), EXIT.ok; + if (sub === 'connect' && arg && o.email) { + await fleet.accountConnectStart(owner, arg, String(o.email)); + if (!process.stdin.isTTY) throw new SubmitFleetError('interactive_owner_required', 'enter the emailed code in an interactive terminal or the dashboard'); + const code = await promptLine(`Enter the code ${arg} emailed to ${o.email}: `); + const a = await fleet.accountConnectFinish(owner, arg, String(o.email), code); + out(a); + return EXIT.ok; + } + return usage(); + } + case 'campaign': { + if (sub === 'plan') { + if (!o.product) return usage('--product is required'); + const plan = await fleet.campaignPlan(owner, { + productId: String(o.product), + minExecutableDestinations: num(o['min-executable-destinations']), + spendLimitCents: num(o['spend-limit-cents'], 0), + earliestDate: o.from as string | undefined, + latestDate: o.to as string | undefined, + destinations: o.dest as string[] | undefined, + }); + out(o.json ? plan : { campaign: plan.campaign.id, status: plan.campaign.status, counts: plan.counts, blocked: plan.targets.filter((t) => !t.executable).slice(0, 50) }); + return plan.campaign.status === 'coverage_insufficient' ? EXIT.gate : EXIT.ok; + } + if (sub === 'list') return out(await fleet.campaignList(owner)), EXIT.ok; + if (!arg) return usage(); + if (sub === 'preview') return out(await fleet.campaignPreview(owner, arg)), EXIT.ok; + if (sub === 'approve') { + const p = await fleet.campaignPreview(owner, arg); + log(`${p.executable.length} free submissions; listing spend $0; operating limit $${(p.campaign.spend_limit_cents / 100).toFixed(2)}`); + log(p.executable.map((e) => ` - ${e.destination}`).join('\n')); + const req = await fleet.approvalRequest(owner, arg); + if (!(await confirmTty(`Scope ${req.scope_hash}`, req.scope_hash.slice(0, 8)))) return log('Not approved.'), EXIT.error; + out(await fleet.approvalGrant({ ...owner, id: 'local-owner-tty' }, arg, req.scope_hash)); + return EXIT.ok; + } + if (sub === 'run') return out(await fleet.campaignRun(owner, arg)), EXIT.ok; + if (sub === 'status') return out(await fleet.campaignStatus(owner, arg)), EXIT.ok; + if (sub === 'pause') return await fleet.campaignPause(owner, arg), out({ paused: arg }), EXIT.ok; + if (sub === 'cancel') return out({ ...(await fleet.campaignCancel(owner, arg)), remote_listings_deleted: false }), EXIT.ok; + return usage(); + } + case 'pause-all': + await fleet.pauseAll(owner); + out({ paused: true }); + return EXIT.ok; + case 'resume': + await fleet.resumeAll(owner); + out({ paused: false }); + return EXIT.ok; + case 'queue': { + const q = await fleet.humanQueue(owner); + out(o.json ? q : q.map((i) => `${i.id}\t${i.destination}\t${i.state}\t${i.reason_code ?? ''}`).join('\n') || 'nothing waiting'); + return q.length ? EXIT.needs_human : EXIT.ok; + } + case 'submission': { + if (!arg) return usage(); + if (sub === 'get') return out(await fleet.submissionGet(owner, arg)), EXIT.ok; + if (sub === 'reconcile') return out({ state: await fleet.submissionReconcile(owner, arg) }), EXIT.ok; + if (sub === 'requeue') return out({ requeued: await fleet.submissionRequeue(owner, arg) }), EXIT.ok; + return usage(); + } + case 'report': { + if (sub !== 'export' || !arg) return usage(); + const f = String(o.format ?? 'markdown'); + if (!['markdown', 'json', 'csv'].includes(f)) return usage('--format must be markdown, json or csv'); + out(await fleet.reportExport(owner, arg, f as 'markdown')); + return EXIT.ok; + } + case 'effort': { + if (sub !== 'log' || !o.minutes || !o.activity) return usage(); + await fleet.logEffort(owner, (o.campaign as string) ?? null, String(o.activity), Number(o.minutes)); + out({ ok: true }); + return EXIT.ok; + } + case 'worker': { + const w = fleet.worker(); + if (o.once) return out(await w.tick(50)), EXIT.ok; + await loop(w, num(o['interval-ms'], 5000)!); + return EXIT.ok; + } + case 'serve': { + const port = num(o.port, Number(process.env.PORT ?? 8787))!; + const app = buildApp(fleet, { allowedOrigins: (process.env.SUBMITFLEET_ALLOWED_ORIGINS ?? '').split(',').filter(Boolean) }); + serve({ fetch: app.fetch, port, hostname: process.env.HOST ?? '127.0.0.1' }); + log(`submitfleet listening on http://${process.env.HOST ?? '127.0.0.1'}:${port}`); + if (!o['no-worker']) await loop(fleet.worker(), Number(process.env.SUBMITFLEET_WORKER_INTERVAL_MS ?? 5000)); + else await new Promise(() => {}); + return EXIT.ok; + } + case 'mcp': { + const server = buildMcpServer(fleet, { workspaceId: ws, role: 'agent', id: 'mcp-stdio' }); + await server.connect(new StdioServerTransport()); + log('submitfleet MCP server on stdio'); + await new Promise(() => {}); + return EXIT.ok; + } + case 'tui': + await runTui(fleet, owner); + return EXIT.ok; + default: + return usage(`unknown command: ${cmd}`); + } + } catch (e) { + if (e instanceof SubmitFleetError) { + out({ error: e.code, message: e.message, detail: e.detail ?? null }); + return e.code === 'coverage_insufficient' ? EXIT.gate : EXIT.error; + } + log(String(e instanceof Error ? (e.stack ?? e.message) : e)); + return EXIT.error; + } finally { + if (!['serve', 'mcp', 'worker', 'tui'].includes(cmd)) fleet.close(); + } + + function usage(msg?: string): number { + if (msg) log(msg); + log('Run `submitfleet help` for usage.'); + return EXIT.usage; + } +} + +async function loop(w: ReturnType, intervalMs: number): Promise { + let stop = false; + for (const sig of ['SIGINT', 'SIGTERM'] as const) process.once(sig, () => (stop = true)); + log(`worker ${w.id} polling every ${intervalMs} ms`); + while (!stop) { + try { + const r = await w.tick(10); + if (r.executed || r.recovered || r.reconciled) log(`[worker] ${JSON.stringify(r)}`); + } catch (e) { + log(`[worker] tick failed: ${String(e)}`); + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +function invokedDirectly(): boolean { + try { + return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (invokedDirectly()) { + main().then((code) => { + process.exitCode = code; + }); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..34fb489 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,40 @@ +import { randomBytes } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { SubmitFleetConfig } from './service.js'; + +export function dataDir(): string { + return process.env.SUBMITFLEET_HOME ?? join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'submitfleet'); +} + +/** + * Hosted mode reads SUBMITFLEET_SECRET_KEY from the environment the vault injects. + * Self-hosted local mode generates a key file (0600) on first use. + */ +export function resolveSecretKey(create: boolean): string | undefined { + if (process.env.SUBMITFLEET_SECRET_KEY) return process.env.SUBMITFLEET_SECRET_KEY; + const file = join(dataDir(), 'secret.key'); + if (existsSync(file)) return readFileSync(file, 'utf8').trim(); + if (!create) return undefined; + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const key = randomBytes(32).toString('base64'); + writeFileSync(file, `${key}\n`, { mode: 0o600 }); + chmodSync(file, 0o600); + return key; +} + +export function loadConfig(opts: { createSecret?: boolean } = {}): SubmitFleetConfig { + const dir = dataDir(); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const assetsDir = process.env.SUBMITFLEET_ASSETS_DIR ?? join(dir, 'assets'); + mkdirSync(assetsDir, { recursive: true }); + return { + dbUrl: process.env.SUBMITFLEET_DB_URL ?? `file:${join(dir, 'submitfleet.db')}`, + dbAuthToken: process.env.SUBMITFLEET_DB_AUTH_TOKEN, + secretKey: resolveSecretKey(opts.createSecret ?? true), + assetsDir, + }; +} + +export const PUBLIC_ORIGIN = process.env.SUBMITFLEET_PUBLIC_ORIGIN ?? 'https://submitfleet.com'; diff --git a/src/dashboard.ts b/src/dashboard.ts new file mode 100644 index 0000000..1e1fd79 --- /dev/null +++ b/src/dashboard.ts @@ -0,0 +1,194 @@ +// Mobile-first dashboard. Plain HTML + one same-origin script, so the CSP can forbid +// inline script. The browser only talks to /api/v1 with the user's bearer key. + +export const LANDING_HTML = ` + +SubmitFleet + +
+

SubmitFleet

+

One product profile. Coordinated submissions. Proof of what went live.

+

An open-source, self-hostable engine for launch-site and directory submissions: maintained adapters, owner-approved campaigns, durable workers, receipts and verified listings. Free listings only by default.

+

Status: early engine. Destination coverage is reported honestly by submitfleet coverage report; it is below the 100-destination release gate today.

+

Dashboard · Source

+
`; + +export const DASHBOARD_HTML = ` + +SubmitFleet Dashboard + +

SubmitFleet

+ +
+ +
+
+
+ +`; + +export const DASHBOARD_JS = String.raw`(() => { + const $ = (s) => document.querySelector(s); + let key = null; + try { key = sessionStorage.getItem('sf_key'); } catch {} + const toast = (msg) => { const t = $('#toast'); t.textContent = msg; t.style.display = 'block'; clearTimeout(t._h); t._h = setTimeout(() => (t.style.display = 'none'), 3500); }; + const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + async function api(path, opts = {}) { + const res = await fetch('/api/v1' + path, { ...opts, headers: { authorization: 'Bearer ' + key, 'content-type': 'application/json', ...(opts.headers || {}) } }); + const type = res.headers.get('content-type') || ''; + const data = type.includes('json') ? await res.json() : await res.text(); + if (res.status === 401) { showLogin(); throw new Error('unauthorized'); } + if (!res.ok) throw new Error((data && data.error ? data.error + ': ' + (data.message || '') : 'HTTP ' + res.status)); + return data; + } + function showLogin() { $('#login').hidden = false; $('#view').innerHTML = ''; } + $('#saveKey').onclick = () => { key = $('#key').value.trim(); try { sessionStorage.setItem('sf_key', key); } catch {} $('#login').hidden = true; boot(); }; + + async function copyText(text, fallbackHost) { + try { + if (!navigator.clipboard || !window.isSecureContext) throw new Error('no clipboard'); + await navigator.clipboard.writeText(text); + toast('Copied Markdown'); + } catch { + fallbackHost.innerHTML = '

Clipboard is unavailable here. The text below is selected: copy it manually.

'; + const ta = fallbackHost.querySelector('textarea'); ta.value = text; ta.focus(); ta.select(); + } + } + async function download(path, name) { + const res = await fetch('/api/v1' + path, { headers: { authorization: 'Bearer ' + key } }); + if (!res.ok) return toast('Download failed: HTTP ' + res.status); + const url = URL.createObjectURL(await res.blob()); + const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + const views = { + async coverage(v) { + const c = await api('/coverage'); + const stages = ['discovered', 'source_labeled_free', 'verified_free', 'permission_reviewed', 'live_tested', 'release_qualifying']; + const blockers = Object.entries(c.blocker_counts).sort((a, b) => b[1] - a[1]).slice(0, 12); + v.innerHTML = '

Engine coverage

' + c.release_qualifying + ' of ' + c.release_minimum + ' required qualifying destinations (' + esc(c.status) + ')

' + + stages.map((s) => '
' + c[s] + '' + s.replace(/_/g, ' ') + '
').join('') + '
' + + '

Top blockers

' + blockers.map(([b, n]) => '').join('') + '
' + esc(b) + '' + n + '
'; + }, + async products(v) { + const ps = await api('/products'); + v.innerHTML = '

Portfolio

' + (ps.length ? '' : '

No products yet. Add one with submitfleet products add URL.

') + + ps.map((p) => '
' + esc(p.name || p.url) + '
' + esc(p.url) + '
' + (p.approved_at ? 'facts approved' : 'facts not approved') + '
' + esc(p.id) + '
').join('') + '
'; + }, + async campaigns(v) { + const cs = await api('/campaigns'); + v.innerHTML = '

Campaigns

' + (cs.length ? '' : '

No campaigns yet.

') + cs.map((c) => + '
' + esc(c.id) + '' + esc(c.status) + '
' + + '

' + c.executable_count + ' executable of ' + c.min_executable + ' required · spend limit $' + (c.spend_limit_cents / 100).toFixed(2) + ' · listing fees $0.00

' + + '
').join(''); + v.querySelectorAll('[data-act]').forEach((b) => (b.onclick = () => act(b.closest('[data-id]').dataset.id, b.dataset.act, b.closest('.card').querySelector('.out')))); + }, + async queue(v) { + const q = await api('/human-queue'); + v.innerHTML = '

Human actions

' + (q.length ? '
' + + q.map((i) => '').join('') + '
DestinationStateReason
' + esc(i.destination) + '' + esc(i.state) + '' + esc(i.reason_code) + '' + (i.state === 'needs_human' ? '' : '') + '
' : '

Nothing is waiting for you.

') + + '

Some steps (captchas, payments, logins) need a desktop browser at the destination; they cannot be done from here.

'; + v.querySelectorAll('[data-requeue]').forEach((b) => (b.onclick = async () => { await api('/submissions/' + b.dataset.requeue + '/requeue', { method: 'POST' }); toast('Requeued'); views.queue(v); })); + }, + async accounts(v) { + const as = await api('/accounts'); + v.innerHTML = '

Accounts

' + (as.length ? '' + as.map((a) => '').join('') + '
' + esc(a.label) + '' + esc(a.status) + '' + esc(a.expires_at || '') + '
' : '

No connected accounts.

') + + '

Connect accounts with submitfleet accounts connect DESTINATION --email you@example.com. Codes and passwords never pass through an agent.

'; + }, + }; + + async function act(id, what, out) { + try { + if (what === 'preview') { const p = await api('/campaigns/' + id + '/preview'); out.innerHTML = '

' + p.executable.length + ' executable, ' + p.blocked.length + ' blocked. Scope ' + esc(p.scope_hash.slice(0, 12)) + '

' + esc(JSON.stringify(p.executable.map((e) => e.destination), null, 1)) + '
'; } + if (what === 'approve') { + const p = await api('/campaigns/' + id + '/preview'); + if (!confirm('Approve ' + p.executable.length + ' free submissions for this campaign?\nListing spend: $0. Operating limit: $' + (p.campaign.spend_limit_cents / 100).toFixed(2) + '\nScope ' + p.scope_hash.slice(0, 12))) return; + await api('/campaigns/' + id + '/approve', { method: 'POST', body: JSON.stringify({ scope_hash: p.scope_hash }) }); toast('Approved'); + } + if (what === 'run') { const r = await api('/campaigns/' + id + '/run', { method: 'POST' }); toast('Queued ' + r.queued); } + if (what === 'status') { const s = await api('/campaigns/' + id + '/status'); out.innerHTML = '
' + esc(JSON.stringify(s.intents, null, 1)) + '
'; } + if (what === 'pause') { await api('/campaigns/' + id + '/pause', { method: 'POST' }); toast('Paused'); } + if (what === 'cancel') { if (!confirm('Cancel unsent work? Listings that already exist stay live.')) return; const r = await api('/campaigns/' + id + '/cancel', { method: 'POST' }); toast('Canceled ' + r.canceled + '; ' + r.already_external + ' already sent'); } + if (what === 'report') { + const md = await api('/reports/' + id + '?format=markdown'); + out.innerHTML = '
';
+        out.querySelector('pre').textContent = md;
+        out.querySelector('[data-copy]').onclick = () => copyText(md, out.querySelector('.fallback'));
+        out.querySelectorAll('[data-dl]').forEach((b) => (b.onclick = () => download('/reports/' + id + '?format=' + b.dataset.dl, id + '.' + (b.dataset.dl === 'markdown' ? 'md' : b.dataset.dl))));
+      }
+    } catch (e) { toast(String(e.message || e)); }
+  }
+
+  let current = 'coverage';
+  document.querySelectorAll('#tabs button').forEach((b) => (b.onclick = () => { current = b.dataset.tab; document.querySelectorAll('#tabs button').forEach((x) => x.setAttribute('aria-selected', String(x === b))); render(); }));
+  $('#pauseAll').onclick = async () => { if (!confirm('Stop all new outbound writes now?')) return; await api('/pause', { method: 'POST' }); toast('All outbound writes paused'); };
+  async function render() { try { await views[current]($('#view')); } catch (e) { if (String(e.message) !== 'unauthorized') $('#view').innerHTML = '

' + esc(e.message) + '

'; } } + async function boot() { + if (!key) return showLogin(); + try { const w = await api('/whoami'); $('#who').textContent = w.role; render(); } catch {} + } + boot(); +})(); +`; + +export const INSTALL_SH = `#!/bin/sh +# SubmitFleet CLI installer: curl -fsSL https://submitfleet.com/install.sh | sh +set -eu +if ! command -v node >/dev/null 2>&1; then + echo "submitfleet needs Node.js 22.5 or newer: https://nodejs.org/" >&2 + exit 1 +fi +major=$(node -p 'process.versions.node.split(".")[0]') +if [ "$major" -lt 22 ]; then + echo "submitfleet needs Node.js 22.5 or newer (found $(node -v))" >&2 + exit 1 +fi +if command -v pnpm >/dev/null 2>&1; then + pnpm add -g @profullstack/submitfleet +else + npm install -g @profullstack/submitfleet +fi +submitfleet doctor || true +echo "Installed. Try: submitfleet coverage report" +`; diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..fca15e5 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,234 @@ +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import { DASHBOARD_HTML, DASHBOARD_JS, LANDING_HTML, INSTALL_SH } from './dashboard.js'; +import { buildMcpServer } from './mcp.js'; +import type { SubmitFleet } from './service.js'; +import { SubmitFleetError } from './util.js'; +import { authenticate, type Actor } from './workspaces.js'; + +type Env = { Variables: { actor: Actor } }; + +const STATUS: Record = { + not_found: 404, + owner_required: 403, + unauthorized: 401, + invalid_request: 400, + validation_failed: 400, + invalid_url_scheme: 400, + canonical_identity_immutable: 409, + product_exists: 409, + product_has_submissions: 409, + coverage_insufficient: 409, + approval_invalidated: 409, + no_pending_approval: 409, + not_approved: 409, + invalid_state: 409, + invalid_transition: 409, + campaign_not_replannable: 409, + unsupported_operation: 409, + secret_key_missing: 503, +}; + +const productBody = z.object({ + url: z.string().url().optional(), + name: z.string().max(200).nullable().optional(), + tagline: z.string().max(200).nullable().optional(), + description: z.string().max(20_000).nullable().optional(), + categories: z.array(z.string().max(100)).max(50).optional(), + contact_email: z.string().email().nullable().optional(), + logo_path: z.string().max(500).nullable().optional(), + facts: z.record(z.string(), z.unknown()).optional(), + destination_fields: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), +}); + +const planBody = z.object({ + product_id: z.string(), + min_executable_destinations: z.number().int().min(1).optional(), + spend_limit_cents: z.number().int().min(0).optional(), + earliest_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + latest_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + release_scope: z.string().max(100).optional(), + destinations: z.array(z.string()).max(1000).optional(), +}); + +async function body(c: Context, schema: z.ZodType): Promise { + let raw: unknown = {}; + const text = await c.req.text(); + if (text) { + try { + raw = JSON.parse(text); + } catch { + throw new SubmitFleetError('invalid_request', 'body must be JSON'); + } + } + const r = schema.safeParse(raw); + if (!r.success) throw new SubmitFleetError('invalid_request', 'invalid body', r.error.issues); + return r.data; +} + +function bearer(c: Context): string | null { + const h = c.req.header('authorization') ?? ''; + return h.startsWith('Bearer ') ? h.slice(7).trim() : null; +} + +export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[] } = {}): Hono { + const app = new Hono(); + const allowed = new Set(opts.allowedOrigins ?? []); + + app.onError((err, c) => { + if (err instanceof SubmitFleetError) { + return c.json({ error: err.code, message: err.message, detail: err.detail ?? null }, (STATUS[err.code] ?? 400) as 400); + } + console.error('[submitfleet] unhandled', err); + return c.json({ error: 'internal_error' }, 500); + }); + + app.use('*', async (c, next) => { + await next(); + c.header('x-content-type-options', 'nosniff'); + c.header('referrer-policy', 'no-referrer'); + c.header('x-frame-options', 'DENY'); + }); + + app.get('/', (c) => c.html(LANDING_HTML)); + app.get('/healthz', (c) => c.json({ ok: true })); + app.get('/readyz', async (c) => { + const checks = await fleet.doctor(); + const ready = checks.find((x) => x.check === 'database')?.ok === true; + return c.json({ ready, checks }, ready ? 200 : 503); + }); + app.get('/install.sh', (c) => c.body(INSTALL_SH, 200, { 'content-type': 'text/x-shellscript; charset=utf-8' })); + app.get('/app', (c) => { + c.header('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'"); + return c.html(DASHBOARD_HTML); + }); + app.get('/app/app.js', (c) => c.body(DASHBOARD_JS, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })); + + // Browsers only reach the API from our own origin; agents and the CLI send no Origin. + const originGuard = async (c: Context, next: () => Promise) => { + const origin = c.req.header('origin'); + if (origin && origin !== new URL(c.req.url).origin && !allowed.has(origin)) { + return c.json({ error: 'origin_not_allowed' }, 403); + } + await next(); + }; + + const auth = async (c: Context, next: () => Promise) => { + const actor = await authenticate(fleet.db, bearer(c)); + if (!actor) return c.json({ error: 'unauthorized' }, 401); + c.set('actor', actor); + await next(); + }; + + // ── MCP over Streamable HTTP (stateless: one server per request) ── + app.all('/mcp', originGuard, auth, async (c) => { + const server = buildMcpServer(fleet, c.get('actor')); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + try { + return await transport.handleRequest(c.req.raw); + } finally { + void server.close(); + } + }); + + const api = new Hono(); + api.use('*', originGuard, auth); + const A = (c: Context) => c.get('actor'); + const page = (c: Context) => ({ limit: Math.min(Number(c.req.query('limit') ?? 100) || 100, 500), offset: Number(c.req.query('offset') ?? 0) || 0 }); + + api.get('/whoami', (c) => c.json({ workspace_id: A(c).workspaceId, role: A(c).role, key_id: A(c).id })); + + api.get('/products', async (c) => c.json(await fleet.productList(A(c), page(c).limit, page(c).offset))); + api.post('/products', async (c) => { + const b = await body(c, productBody.extend({ url: z.string().url() })); + return c.json(await fleet.productCreate(A(c), b as never), 201); + }); + api.get('/products/:id', async (c) => c.json(await fleet.productGet(A(c), c.req.param('id')))); + api.patch('/products/:id', async (c) => c.json(await fleet.productUpdate(A(c), c.req.param('id'), (await body(c, productBody)) as never))); + api.delete('/products/:id', async (c) => { + await fleet.productDelete(A(c), c.req.param('id')); + return c.json({ deleted: c.req.param('id'), remote_listings_deleted: false }); + }); + api.post('/products/:id/approve-facts', async (c) => c.json(await fleet.productApproveFacts(A(c), c.req.param('id')))); + + api.get('/catalog', async (c) => c.json(await fleet.catalogSearch(c.req.query('q'), page(c).limit, page(c).offset))); + api.get('/catalog/:slug', async (c) => c.json(await fleet.catalogGet(c.req.param('slug')))); + api.post('/catalog/import', async (c) => { + const b = await body(c, z.object({ source: z.string(), license: z.string(), records: z.array(z.object({ name: z.string(), url: z.string().url(), kind: z.string().optional(), categories: z.array(z.string()).optional(), labeled_free: z.boolean().optional() })).max(5000) })); + return c.json(await fleet.catalogImport(A(c), b.records, b.source, b.license)); + }); + api.post('/catalog/:slug/health', async (c) => { + const b = await body(c, z.object({ health: z.enum(['healthy', 'quarantined', 'broken']), reason: z.string().min(1).max(500) })); + await fleet.catalogSetHealth(A(c), c.req.param('slug'), b.health, b.reason); + return c.json(await fleet.catalogGet(c.req.param('slug'))); + }); + api.get('/coverage', async (c) => c.json(await fleet.coverageReport())); + + api.get('/campaigns', async (c) => c.json(await fleet.campaignList(A(c)))); + const plan = (c: Context, b: z.infer, id?: string) => + fleet.campaignPlan(A(c), { productId: b.product_id, minExecutableDestinations: b.min_executable_destinations, spendLimitCents: b.spend_limit_cents, earliestDate: b.earliest_date, latestDate: b.latest_date, releaseScope: b.release_scope, destinations: b.destinations }, id); + api.post('/campaigns', async (c) => c.json(await plan(c, await body(c, planBody)), 201)); + api.post('/campaigns/:id/plan', async (c) => c.json(await plan(c, await body(c, planBody), c.req.param('id')))); + api.get('/campaigns/:id/preview', async (c) => c.json(await fleet.campaignPreview(A(c), c.req.param('id')))); + api.post('/campaigns/:id/approval-request', async (c) => c.json(await fleet.approvalRequest(A(c), c.req.param('id')))); + api.post('/campaigns/:id/approve', async (c) => { + const b = await body(c, z.object({ scope_hash: z.string().length(64) })); + return c.json(await fleet.approvalGrant(A(c), c.req.param('id'), b.scope_hash)); + }); + api.post('/campaigns/:id/run', async (c) => c.json(await fleet.campaignRun(A(c), c.req.param('id')))); + api.get('/campaigns/:id/status', async (c) => c.json(await fleet.campaignStatus(A(c), c.req.param('id')))); + api.post('/campaigns/:id/pause', async (c) => { + await fleet.campaignPause(A(c), c.req.param('id')); + return c.json({ paused: c.req.param('id') }); + }); + api.post('/campaigns/:id/cancel', async (c) => c.json({ ...(await fleet.campaignCancel(A(c), c.req.param('id'))), remote_listings_deleted: false })); + + api.get('/pause', async (c) => c.json({ paused: await fleet.pausedState(A(c)) })); + api.post('/pause', async (c) => { + await fleet.pauseAll(A(c)); + return c.json({ paused: true }); + }); + api.post('/resume', async (c) => { + await fleet.resumeAll(A(c)); + return c.json({ paused: false }); + }); + + api.get('/accounts', async (c) => c.json(await fleet.accountStatus(A(c)))); + api.post('/accounts/connect/start', async (c) => { + const b = await body(c, z.object({ destination: z.string(), email: z.string().email() })); + return c.json(await fleet.accountConnectStart(A(c), b.destination, b.email)); + }); + api.post('/accounts/connect/finish', async (c) => { + const b = await body(c, z.object({ destination: z.string(), email: z.string().email(), code: z.string().regex(/^\d{4,8}$/), label: z.string().max(100).optional() })); + return c.json(await fleet.accountConnectFinish(A(c), b.destination, b.email, b.code, b.label)); + }); + api.delete('/accounts/:id', async (c) => { + await fleet.accountDisconnect(A(c), c.req.param('id')); + return c.json({ disconnected: c.req.param('id') }); + }); + + api.get('/human-queue', async (c) => c.json(await fleet.humanQueue(A(c)))); + api.get('/submissions/:id', async (c) => c.json(await fleet.submissionGet(A(c), c.req.param('id')))); + api.post('/submissions/:id/reconcile', async (c) => c.json({ state: await fleet.submissionReconcile(A(c), c.req.param('id')) })); + api.post('/submissions/:id/requeue', async (c) => c.json({ requeued: await fleet.submissionRequeue(A(c), c.req.param('id')) })); + api.post('/submissions/:id/update', async (c) => c.json(await fleet.submissionUpdate(A(c), c.req.param('id')), 409)); + api.post('/submissions/:id/withdraw', async (c) => c.json(await fleet.submissionWithdraw(A(c), c.req.param('id')), 409)); + + api.get('/reports/:campaignId', async (c) => { + const format = (c.req.query('format') ?? 'json') as 'markdown' | 'json' | 'csv'; + if (!['markdown', 'json', 'csv'].includes(format)) throw new SubmitFleetError('invalid_request', 'format must be markdown, json or csv'); + const out = await fleet.reportExport(A(c), c.req.param('campaignId'), format); + const type = format === 'markdown' ? 'text/markdown' : format === 'csv' ? 'text/csv' : 'application/json'; + return c.body(out, 200, { 'content-type': `${type}; charset=utf-8` }); + }); + api.post('/effort', async (c) => { + const b = await body(c, z.object({ campaign_id: z.string().nullable(), activity: z.string().max(200), minutes: z.number().min(0).max(10_000), note: z.string().max(1000).optional() })); + await fleet.logEffort(A(c), b.campaign_id, b.activity, b.minutes, b.note); + return c.json({ ok: true }); + }); + + app.route('/api/v1', api); + return app; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3628ade --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +export { SubmitFleet, type SubmitFleetConfig } from './service.js'; +export { buildApp } from './http.js'; +export { buildMcpServer } from './mcp.js'; +export { Worker } from './worker.js'; +export { builtinAdapters } from './adapters/index.js'; +export type { Adapter, ExecContext, ExecResult, ReconcileResult, ValidationIssue } from './adapters/types.js'; +export { RELEASE_MINIMUM, qualify, type Destination, type EngineCoverage } from './catalog.js'; +export type { Product, ProductInput } from './products.js'; +export type { Actor, Role } from './workspaces.js'; +export { INTENT_STATES, type IntentState } from './state.js'; +export { SubmitFleetError } from './util.js'; diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..aadd6b7 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,129 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { SubmitFleet } from './service.js'; +import { SubmitFleetError } from './util.js'; +import type { Actor } from './workspaces.js'; + +export const MCP_SERVER_VERSION = '0.1.0'; + +type Json = unknown; + +function ok(value: Json) { + const text = typeof value === 'string' ? value : JSON.stringify(value ?? { ok: true }, null, 2); + return { content: [{ type: 'text' as const, text }] }; +} + +function fail(e: unknown) { + const code = e instanceof SubmitFleetError ? e.code : 'internal_error'; + const message = e instanceof Error ? e.message : String(e); + const detail = e instanceof SubmitFleetError ? e.detail : undefined; + return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify({ error: code, message, detail, external_action_performed: false }) }] }; +} + +const wrap = + (fn: (args: A) => Promise) => + async (args: A) => { + try { + return ok(await fn(args)); + } catch (e) { + return fail(e); + } + }; + +const page = { limit: z.number().int().min(1).max(500).optional(), offset: z.number().int().min(0).optional() }; +const productFields = { + name: z.string().max(200).nullable().optional(), + tagline: z.string().max(200).nullable().optional(), + description: z.string().max(20_000).nullable().optional(), + categories: z.array(z.string().max(100)).max(50).optional(), + contact_email: z.string().email().nullable().optional(), + logo_path: z.string().max(500).nullable().optional(), + facts: z.record(z.string(), z.unknown()).optional(), + destination_fields: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), +}; + +/** + * Builds the MCP server for one authenticated actor. There is deliberately no tool + * that grants approval: an agent can request approval, never sign it. + */ +export function buildMcpServer(fleet: SubmitFleet, actor: Actor): McpServer { + const s = new McpServer({ name: 'submitfleet', version: MCP_SERVER_VERSION }); + const write = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }; + const read = { readOnlyHint: true, openWorldHint: false }; + + s.registerTool('submitfleet_product_create', { description: 'Create a product profile from its canonical URL and facts you can source. Facts must be approved by the owner before any campaign can use them.', inputSchema: { url: z.string().url(), ...productFields }, annotations: write }, wrap((a) => fleet.productCreate(actor, a as never))); + s.registerTool('submitfleet_product_get', { description: 'Get one product.', inputSchema: { id: z.string() }, annotations: read }, wrap((a) => fleet.productGet(actor, a.id))); + s.registerTool('submitfleet_product_list', { description: 'List products in this workspace.', inputSchema: page, annotations: read }, wrap((a) => fleet.productList(actor, a.limit, a.offset))); + s.registerTool('submitfleet_product_update', { description: 'Update product facts. Any change clears owner approval of the facts and invalidates affected campaign approvals.', inputSchema: { id: z.string(), ...productFields }, annotations: write }, wrap(({ id, ...patch }) => fleet.productUpdate(actor, id, patch as never))); + s.registerTool('submitfleet_product_delete', { description: 'Delete local product data (owner only). Never deletes remote listings.', inputSchema: { id: z.string() }, annotations: { ...write, destructiveHint: true } }, wrap(async (a) => { await fleet.productDelete(actor, a.id); return { deleted: a.id, remote_listings_deleted: false }; })); + + s.registerTool('submitfleet_catalog_search', { description: 'Search the destination catalog.', inputSchema: { query: z.string().max(200).optional(), ...page }, annotations: read }, wrap((a) => fleet.catalogSearch(a.query, a.limit, a.offset))); + s.registerTool('submitfleet_catalog_get', { description: 'Get one destination with its qualification stages and blockers.', inputSchema: { slug: z.string() }, annotations: read }, wrap((a) => fleet.catalogGet(a.slug))); + s.registerTool( + 'submitfleet_catalog_import', + { description: 'Import discovery records (owner only). Imported records never enable execution.', inputSchema: { source: z.string(), license: z.string(), records: z.array(z.object({ name: z.string(), url: z.string().url(), kind: z.string().optional(), categories: z.array(z.string()).optional(), labeled_free: z.boolean().optional() })).max(5000) }, annotations: write }, + wrap((a) => fleet.catalogImport(actor, a.records, a.source, a.license)), + ); + s.registerTool('submitfleet_catalog_refresh', { description: 'Report which catalog evidence is stale and needs a reviewed refresh. Non-mutating.', inputSchema: {}, annotations: read }, wrap(async () => { + const cov = await fleet.coverageReport(); + return { stale: { fee: cov.blocker_counts.fee_evidence_stale ?? 0, permission: cov.blocker_counts.permission_evidence_stale ?? 0 }, note: 'Refreshing evidence is a reviewed registry edit; this tool never marks anything verified.' }; + })); + s.registerTool('submitfleet_coverage_report', { description: 'Engine coverage by stage. The 100-destination release gate counts independent listing groups only.', inputSchema: {}, annotations: read }, wrap(() => fleet.coverageReport())); + + const planInput = { + product_id: z.string(), + min_executable_destinations: z.number().int().min(1).optional(), + spend_limit_cents: z.number().int().min(0).optional(), + earliest_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + latest_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + release_scope: z.string().max(100).optional(), + destinations: z.array(z.string()).max(1000).optional(), + campaign_id: z.string().optional(), + }; + s.registerTool( + 'submitfleet_campaign_plan', + { description: 'Plan (or re-plan) a free-only campaign. If fewer than min_executable_destinations (default 100) are executable, the result is coverage_insufficient with the real count and blockers, and nothing can run.', inputSchema: planInput, annotations: write }, + wrap(async (a) => { + const plan = await fleet.campaignPlan( + actor, + { productId: a.product_id, minExecutableDestinations: a.min_executable_destinations, spendLimitCents: a.spend_limit_cents, earliestDate: a.earliest_date, latestDate: a.latest_date, releaseScope: a.release_scope, destinations: a.destinations }, + a.campaign_id, + ); + return { campaign: plan.campaign, counts: plan.counts, blocked: plan.targets.filter((t) => !t.executable).slice(0, 200), executable: plan.targets.filter((t) => t.executable).map((t) => t.destination) }; + }), + ); + s.registerTool('submitfleet_campaign_preview', { description: 'Exact payloads, accounts and blockers the owner would approve, plus the scope hash.', inputSchema: { campaign_id: z.string() }, annotations: read }, wrap((a) => fleet.campaignPreview(actor, a.campaign_id))); + s.registerTool('submitfleet_approval_request', { description: 'Ask the owner to approve this exact campaign scope. Approval happens in the dashboard or CLI, never through MCP.', inputSchema: { campaign_id: z.string() }, annotations: write }, wrap(async (a) => ({ ...(await fleet.approvalRequest(actor, a.campaign_id)), next: 'owner approves at /app or with `submitfleet campaign approve`' }))); + s.registerTool('submitfleet_campaign_run', { description: 'Queue an owner-approved campaign for the workers (or resume a paused one). Refuses if anything material changed since approval.', inputSchema: { campaign_id: z.string() }, annotations: { ...write, openWorldHint: true } }, wrap((a) => fleet.campaignRun(actor, a.campaign_id))); + s.registerTool('submitfleet_campaign_status', { description: 'Campaign state and intent counts by state.', inputSchema: { campaign_id: z.string() }, annotations: read }, wrap((a) => fleet.campaignStatus(actor, a.campaign_id))); + s.registerTool('submitfleet_campaign_pause', { description: 'Pause a running campaign. Use pause_all to stop every outbound write.', inputSchema: { campaign_id: z.string() }, annotations: write }, wrap(async (a) => { await fleet.campaignPause(actor, a.campaign_id); return { paused: a.campaign_id }; })); + s.registerTool('submitfleet_campaign_cancel', { description: 'Cancel unsent work. Does not delete remote listings that already exist.', inputSchema: { campaign_id: z.string() }, annotations: { ...write, destructiveHint: true } }, wrap(async (a) => ({ ...(await fleet.campaignCancel(actor, a.campaign_id)), remote_listings_deleted: false }))); + s.registerTool('submitfleet_pause_all', { description: 'Immediately stop all new outbound writes for the workspace. Only the owner can resume.', inputSchema: {}, annotations: write }, wrap(async () => { await fleet.pauseAll(actor); return { paused: true }; })); + + s.registerTool('submitfleet_account_status', { description: 'Connected destination accounts (never secrets).', inputSchema: {}, annotations: read }, wrap(() => fleet.accountStatus(actor))); + s.registerTool('submitfleet_account_connect', { description: 'Account connection is an owner handoff: it returns the CLI/dashboard step, because codes and passwords must never pass through model context.', inputSchema: { destination: z.string() }, annotations: read }, wrap(async (a) => ({ handoff: true, run: `submitfleet accounts connect ${a.destination} --email `, or: '/app#accounts' }))); + s.registerTool('submitfleet_account_disconnect', { description: 'Disconnect an account (owner only); queued work for it stops.', inputSchema: { account_id: z.string() }, annotations: write }, wrap(async (a) => { await fleet.accountDisconnect(actor, a.account_id); return { disconnected: a.account_id }; })); + + s.registerTool('submitfleet_submission_get', { description: 'One submission with its immutable event history.', inputSchema: { id: z.string() }, annotations: read }, wrap((a) => fleet.submissionGet(actor, a.id))); + s.registerTool('submitfleet_submission_reconcile', { description: 'Reconcile an uncertain submission, or check a submitted one for publication, using permitted reads only.', inputSchema: { id: z.string() }, annotations: { ...read, openWorldHint: true } }, wrap(async (a) => ({ state: await fleet.submissionReconcile(actor, a.id) }))); + s.registerTool('submitfleet_submission_update', { description: 'Update a remote listing if the destination supports it; otherwise unsupported_operation.', inputSchema: { id: z.string() }, annotations: write }, wrap((a) => fleet.submissionUpdate(actor, a.id))); + s.registerTool('submitfleet_submission_withdraw', { description: 'Withdraw a remote listing if the destination supports it; otherwise unsupported_operation.', inputSchema: { id: z.string() }, annotations: { ...write, destructiveHint: true } }, wrap((a) => fleet.submissionWithdraw(actor, a.id))); + s.registerTool('submitfleet_human_queue', { description: 'Jobs waiting for a person: challenges, payments, expired sessions, unresolved uncertainty.', inputSchema: {}, annotations: read }, wrap(() => fleet.humanQueue(actor))); + + s.registerTool('submitfleet_report_get', { description: 'Campaign report: original targets, exclusions, receipts, listing URLs, unresolved jobs, costs. Unknowns stay null.', inputSchema: { campaign_id: z.string() }, annotations: read }, wrap((a) => fleet.reportGet(actor, a.campaign_id))); + s.registerTool('submitfleet_report_export', { description: 'Export a campaign report as markdown, json or csv.', inputSchema: { campaign_id: z.string(), format: z.enum(['markdown', 'json', 'csv']) }, annotations: read }, wrap((a) => fleet.reportExport(actor, a.campaign_id, a.format))); + + addListCacheFields(s); + return s; +} + +/** + * Newer MCP clients validate tools/list strictly and reject a list without its cache + * metadata. Extra fields are ignored by older clients, so always send them. + */ +function addListCacheFields(s: McpServer): void { + const handlers = (s.server as unknown as { _requestHandlers: Map Promise>> })._requestHandlers; + const original = handlers?.get('tools/list'); + if (!original) return; + handlers.set('tools/list', async (req, extra) => ({ ...(await original(req, extra)), resultType: 'complete', ttlMs: 300_000, cacheScope: 'private' })); +} diff --git a/src/service.ts b/src/service.ts index 8aba2fc..3d5c1a6 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,5 +1,5 @@ import { readFile, stat } from 'node:fs/promises'; -import { isAbsolute, resolve, sep } from 'node:path'; +import { resolve, sep } from 'node:path'; import { builtinAdapters } from './adapters/index.js'; import type { Adapter } from './adapters/types.js'; import { tinyLaunchRequestCode, tinyLaunchVerify } from './adapters/tinylaunch.js'; @@ -55,7 +55,6 @@ export class SubmitFleet { readAsset = async (path: string): Promise => { const root = resolve(this.config.assetsDir); const full = resolve(root, path); - if (isAbsolute(path) && !full.startsWith(root + sep)) throw new SubmitFleetError('asset_outside_root', path); if (!full.startsWith(root + sep)) throw new SubmitFleetError('asset_outside_root', path); if (/\.(exe|sh|bat|cmd|js|mjs|html?|svg)$/i.test(full)) throw new SubmitFleetError('asset_type_not_allowed', path); const s = await stat(full); diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..7535ba4 --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,71 @@ +import { emitKeypressEvents } from 'node:readline'; +import type { SubmitFleet } from './service.js'; +import type { Actor } from './workspaces.js'; + +const ESC = '\x1b['; +const bold = (s: string) => `${ESC}1m${s}${ESC}0m`; +const dim = (s: string) => `${ESC}2m${s}${ESC}0m`; +const color = (n: number, s: string) => `${ESC}${n}m${s}${ESC}0m`; + +const TABS = ['coverage', 'campaigns', 'queue'] as const; + +/** Live terminal view: coverage, campaigns and the human-action queue. Keys: 1-3, r, p, q. */ +export async function runTui(fleet: SubmitFleet, actor: Actor): Promise { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + process.stderr.write('submitfleet tui needs an interactive terminal\n'); + return; + } + let tab: (typeof TABS)[number] = 'coverage'; + let note = ''; + const draw = async () => { + const lines: string[] = []; + const paused = await fleet.pausedState(actor); + lines.push(`${bold('SubmitFleet')} ${TABS.map((t, i) => (t === tab ? bold(`[${i + 1}] ${t}`) : dim(`[${i + 1}] ${t}`))).join(' ')} ${paused ? color(31, 'PAUSED') : color(32, 'running')}`); + lines.push(''); + if (tab === 'coverage') { + const c = await fleet.coverageReport(); + for (const k of ['discovered', 'source_labeled_free', 'verified_free', 'permission_reviewed', 'live_tested', 'release_qualifying'] as const) { + lines.push(`${k.replace(/_/g, ' ').padEnd(22)} ${String(c[k]).padStart(5)}`); + } + lines.push(''); + lines.push(c.status === 'release_ready' ? color(32, `release gate met (${c.release_qualifying}/${c.release_minimum})`) : color(33, `below_minimum: ${c.release_qualifying}/${c.release_minimum}`)); + const top = Object.entries(c.blocker_counts).sort((a, b) => b[1] - a[1]).slice(0, 8); + if (top.length) lines.push('', bold('top blockers'), ...top.map(([b, n]) => ` ${String(n).padStart(5)} ${b}`)); + } else if (tab === 'campaigns') { + const cs = await fleet.campaignList(actor); + if (!cs.length) lines.push(dim('no campaigns')); + for (const c of cs.slice(0, 20)) { + const st = await fleet.campaignStatus(actor, c.id); + lines.push(`${c.id} ${c.status.padEnd(22)} ${c.executable_count}/${c.min_executable} ${Object.entries(st.intents).map(([k, v]) => `${k}:${v}`).join(' ')}`); + } + } else { + const q = await fleet.humanQueue(actor); + if (!q.length) lines.push(dim('nothing waiting for a person')); + for (const i of q.slice(0, 30)) lines.push(`${String(i.destination).padEnd(24)} ${String(i.state).padEnd(12)} ${i.reason_code ?? ''}`); + } + lines.push('', dim('1-3 switch r refresh p pause all q quit') + (note ? ` ${note}` : '')); + process.stdout.write(`${ESC}2J${ESC}H${lines.join('\n')}\n`); + }; + + emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdout.write(`${ESC}?25l`); + const timer = setInterval(() => void draw(), 3000); + await draw(); + await new Promise((resolve) => { + process.stdin.on('keypress', async (_s, key: { name?: string; ctrl?: boolean }) => { + if (key.name === 'q' || (key.ctrl && key.name === 'c')) return resolve(); + const n = Number(key.name); + if (n >= 1 && n <= TABS.length) tab = TABS[n - 1]!; + if (key.name === 'p') { + await fleet.pauseAll(actor); + note = color(31, 'all outbound writes paused'); + } + await draw(); + }); + }); + clearInterval(timer); + process.stdin.setRawMode(false); + process.stdout.write(`${ESC}?25h${ESC}2J${ESC}H`); + process.stdin.pause(); +} diff --git a/src/util.ts b/src/util.ts index f7fd9e6..ed033b4 100644 --- a/src/util.ts +++ b/src/util.ts @@ -39,8 +39,13 @@ export function hashValue(v: unknown): string { * slash, no query or fragment. Tracking parameters must never create a new product. */ export function canonicalProductKey(url: string): string { - const u = new URL(url); - if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('invalid_url_scheme'); + let u: URL; + try { + u = new URL(url); + } catch { + throw new SubmitFleetError('invalid_request', 'url is not a valid URL'); + } + if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new SubmitFleetError('invalid_url_scheme', 'url must be http(s)'); const host = u.hostname.toLowerCase().replace(/^www\./, ''); const path = u.pathname.replace(/\/+$/, ''); return `${host}${path}`; diff --git a/test/http.test.ts b/test/http.test.ts new file mode 100644 index 0000000..6b8d8b7 --- /dev/null +++ b/test/http.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { buildApp } from '../src/http.js'; +import { createApiKey } from '../src/workspaces.js'; +import { approvedProduct, harness, seedDestinations, type Harness } from './helpers.js'; + +let h: Harness; +afterEach(() => h?.cleanup()); + +async function setup() { + h = await harness(); + const owner = await createApiKey(h.fleet.db, h.owner.workspaceId, 'owner', 'http owner'); + const agent = await createApiKey(h.fleet.db, h.owner.workspaceId, 'agent', 'http agent'); + const app = buildApp(h.fleet); + const call = (token: string | null, method: string, path: string, body?: unknown, headers: Record = {}) => + app.request(path, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}), ...headers }, body: body ? JSON.stringify(body) : undefined }); + return { app, call, owner: owner.token, agent: agent.token }; +} + +const rpc = (method: string, params: unknown = {}, id = 1) => ({ jsonrpc: '2.0', id, method, params }); +const mcpHeaders = { accept: 'application/json, text/event-stream' }; + +describe('HTTP API', () => { + it('requires a key and scopes everything to its workspace', async () => { + const { call, owner } = await setup(); + expect((await call(null, 'GET', '/api/v1/products')).status).toBe(401); + expect((await call('sf_own_bogus', 'GET', '/api/v1/products')).status).toBe(401); + const res = await call(owner, 'GET', '/api/v1/whoami'); + expect(await res.json()).toMatchObject({ role: 'owner', workspace_id: h.owner.workspaceId }); + }); + + it('runs plan -> approval request (agent) -> approve (owner only) -> run', async () => { + const { call, owner, agent } = await setup(); + await seedDestinations(h.fleet, h.owner, 2); + const p = await approvedProduct(h.fleet, h.owner); + const planRes = await call(agent, 'POST', '/api/v1/campaigns', { product_id: p.id, min_executable_destinations: 2 }); + expect(planRes.status).toBe(201); + const plan = (await planRes.json()) as { campaign: { id: string } }; + const id = plan.campaign.id; + const req = (await (await call(agent, 'POST', `/api/v1/campaigns/${id}/approval-request`)).json()) as { scope_hash: string }; + const denied = await call(agent, 'POST', `/api/v1/campaigns/${id}/approve`, { scope_hash: req.scope_hash }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ error: 'owner_required' }); + expect((await call(owner, 'POST', `/api/v1/campaigns/${id}/approve`, { scope_hash: req.scope_hash })).status).toBe(200); + expect(await (await call(agent, 'POST', `/api/v1/campaigns/${id}/run`)).json()).toMatchObject({ queued: 2 }); + await h.fleet.worker().tick(); + const md = await call(owner, 'GET', `/api/v1/reports/${id}?format=markdown`); + expect(md.headers.get('content-type')).toContain('text/markdown'); + expect(await md.text()).toContain('# SubmitFleet campaign report'); + }); + + it('a strict campaign below the minimum is a 409 with the real count', async () => { + const { call, agent } = await setup(); + await seedDestinations(h.fleet, h.owner, 3); + const p = await approvedProduct(h.fleet, h.owner); + const plan = (await (await call(agent, 'POST', '/api/v1/campaigns', { product_id: p.id })).json()) as { campaign: { id: string; status: string }; counts: { executable: number } }; + expect(plan.campaign.status).toBe('coverage_insufficient'); + expect(plan.counts.executable).toBe(3); + const r = await call(agent, 'POST', `/api/v1/campaigns/${plan.campaign.id}/approval-request`); + expect(r.status).toBe(409); + expect(await r.json()).toMatchObject({ error: 'coverage_insufficient', detail: { executable: 3, required: 100 } }); + }); + + it('rejects cross-origin browser calls and validates bodies', async () => { + const { call, owner } = await setup(); + expect((await call(owner, 'GET', '/api/v1/products', undefined, { origin: 'https://evil.example' })).status).toBe(403); + const bad = await call(owner, 'POST', '/api/v1/products', { url: 'javascript:alert(1)' }); + expect(bad.status).toBe(400); + }); + + it('serves the dashboard with a strict CSP, Copy Markdown and an honest clipboard fallback', async () => { + const { app } = await setup(); + const page = await app.request('/app'); + expect(page.headers.get('content-security-policy')).toContain("script-src 'self'"); + const js = await (await app.request('/app/app.js')).text(); + expect(js).toContain('Copy Markdown'); + expect(js).toContain('navigator.clipboard.writeText'); + expect(js).toContain('Clipboard is unavailable'); + // Success is only reported after the write resolves. + expect(js.indexOf("await navigator.clipboard.writeText(text)")).toBeLessThan(js.indexOf("toast('Copied Markdown')")); + const sh = await app.request('/install.sh'); + expect(sh.headers.get('content-type')).toContain('shellscript'); + expect(await sh.text()).toMatch(/^#!\/bin\/sh/); + }); +}); + +describe('MCP over Streamable HTTP', () => { + it('lists submitfleet_ tools with cache metadata and no approval-granting tool', async () => { + const { call, agent } = await setup(); + const init = await call(agent, 'POST', '/mcp', rpc('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 't', version: '1' } }), mcpHeaders); + expect(init.status).toBe(200); + const res = await call(agent, 'POST', '/mcp', rpc('tools/list', {}, 2), mcpHeaders); + const j = (await res.json()) as { result: { tools: { name: string }[]; ttlMs: number; cacheScope: string } }; + const names = j.result.tools.map((t) => t.name); + expect(names.every((n) => n.startsWith('submitfleet_'))).toBe(true); + for (const n of ['submitfleet_coverage_report', 'submitfleet_campaign_plan', 'submitfleet_approval_request', 'submitfleet_report_export']) expect(names).toContain(n); + expect(names.some((n) => /approve|grant/.test(n))).toBe(false); + expect(j.result.ttlMs).toBeGreaterThanOrEqual(0); + expect(['public', 'private']).toContain(j.result.cacheScope); + expect((await call(null, 'POST', '/mcp', rpc('tools/list'), mcpHeaders)).status).toBe(401); + }); + + it('tool errors carry reason codes and no external action', async () => { + const { call, agent } = await setup(); + const res = await call(agent, 'POST', '/mcp', rpc('tools/call', { name: 'submitfleet_campaign_status', arguments: { campaign_id: 'cmp_missing' } }, 3), mcpHeaders); + const j = (await res.json()) as { result: { isError: boolean; content: { text: string }[] } }; + expect(j.result.isError).toBe(true); + expect(JSON.parse(j.result.content[0]!.text)).toMatchObject({ error: 'not_found', external_action_performed: false }); + }); +}); diff --git a/test/tinylaunch.test.ts b/test/tinylaunch.test.ts new file mode 100644 index 0000000..5b88ab9 --- /dev/null +++ b/test/tinylaunch.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { tinyLaunchAdapter, toDescriptionHtml, TINYLAUNCH_BASE } from '../src/adapters/tinylaunch.js'; +import type { ExecContext } from '../src/adapters/types.js'; +import type { Destination } from '../src/catalog.js'; +import type { Product } from '../src/products.js'; + +// Contract tests against the request/response shapes in https://www.tinylaunch.com/llms.txt. +// These prove the adapter speaks the documented contract; they are not live evidence. + +const product: Product = { + id: 'prd_1', workspace_id: 'w', canonical_key: 'acme.example', url: 'https://acme.example/', name: 'Acme', tagline: 'The friendly to-do app', + description: 'A friendly to-do app.\n\nFree .', categories: [], contact_email: null, logo_path: 'logo.png', facts: {}, + destination_fields: { tinylaunch: { category_id: 9 } }, approved_at: 'x', approved_by: 'o', created_at: 'x', updated_at: 'x', +}; +const destination = { slug: 'tinylaunch', canonical_domain: 'tinylaunch.com' } as Destination; + +type Call = { method: string; url: string; body: Record | null; auth: string | null }; + +function mockFetch(routes: Record | null, n: number) => [number, unknown]>) { + const calls: Call[] = []; + const counts: Record = {}; + const f = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : null; + const headers = (init?.headers ?? {}) as Record; + calls.push({ method, url, body, auth: headers.authorization ?? null }); + const key = `${method} ${url.replace(TINYLAUNCH_BASE, '').replace(/\?.*$/, '')}`; + const h = routes[key]; + counts[key] = (counts[key] ?? 0) + 1; + if (!h) return new Response(JSON.stringify({ error: 'unmocked' }), { status: 599 }); + const [status, json] = h(body, counts[key]!); + return new Response(JSON.stringify(json), { status, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + return { f, calls }; +} + +async function prepared() { + const a = tinyLaunchAdapter(); + return a.prepare({ product, destination, earliestDate: '2026-11-01', latestDate: '2026-11-30', readAsset: async () => Buffer.from([0x89, 0x50, 0x4e, 0x47]) }); +} + +function ctx(f: typeof fetch, payload: Record, refs: Record = {}): ExecContext & { recorded: Record } { + const recorded: Record = { ...refs }; + return { + intentId: 'int_1', + payload, + destination, + remoteRefs: refs, + recorded, + recordRemote: async (r) => void Object.assign(recorded, r), + credentials: async () => ({ access_token: 'tok', expires_at_ms: String(Date.now() + 3_600_000) }), + fetch: f, + signal: new AbortController().signal, + now: Date.now, + }; +} + +const okRoutes = { + 'GET /maker': () => [200, { handle: 'acme_maker' }] as [number, unknown], + 'POST /startups': () => [201, { id: 16320, slug: 'acme' }] as [number, unknown], + 'GET /launch-dates': () => + [200, { windows: [ + { date: '2026-10-30', bookable_free: true }, + { date: '2026-11-02', bookable_free: false, bookable_premium: true }, + { date: '2026-11-03', bookable_free: true }, + { date: '2026-11-04', bookable_free: true }, + ], next_offset: null }] as [number, unknown], +}; + +describe('TinyLaunch adapter contract', () => { + it('validate asks for missing facts instead of guessing them', () => { + const issues = tinyLaunchAdapter().validate({ ...product, destination_fields: {}, tagline: null }, destination); + expect(issues.map((i) => i.field).sort()).toEqual(['destination_fields.tinylaunch.category_id', 'tagline']); + }); + + it('prepare builds the documented startup shape without any network call', async () => { + const p = (await prepared()) as { startup: Record }; + expect(Object.keys(p.startup).sort()).toEqual(['category_id', 'description', 'logo', 'name', 'tagline', 'url']); + expect(Object.keys(p.startup.logo as object).sort()).toEqual(['content_base64', 'filename']); + expect(p.startup.description).toBe('

A friendly to-do app.

Free <forever>.

'); + }); + + it('schedules a free launch: startup_id + date only, never premium or a paid tier', async () => { + const { f, calls } = mockFetch({ ...okRoutes, 'POST /launches': () => [201, { id: 15213, startup_id: 16320, date: '2026-11-03', premium: false }] }); + const c = ctx(f, await prepared()); + const r = await tinyLaunchAdapter().execute(c); + expect(r).toMatchObject({ kind: 'scheduled', remoteId: '15213', receiptId: 'tinylaunch:launch:15213', listingUrl: 'https://www.tinylaunch.com/launch/15213' }); + const launch = calls.find((x) => x.method === 'POST' && x.url.endsWith('/launches'))!; + expect(launch.body).toEqual({ startup_id: 16320, date: '2026-11-03' }); + expect(launch.auth).toBe('Bearer tok'); + expect(calls.find((x) => x.url.includes('/launch-dates'))!.auth).toBeNull(); + expect(c.recorded).toMatchObject({ startup_id: 16320, launch_id: '15213' }); + }); + + it('stops for a human if the response carries a payment link', async () => { + const { f } = mockFetch({ ...okRoutes, 'POST /launches': () => [201, { id: 1, paid: false, premium: true, payment_url: 'https://checkout.example' }] }); + const r = await tinyLaunchAdapter().execute(ctx(f, await prepared())); + expect(r).toMatchObject({ kind: 'needs_human', reasonCode: 'unexpected_payment:payment_url_returned', externalActionPerformed: true }); + }); + + it('moves to the next free date on date_full, within the approved window', async () => { + const { f, calls } = mockFetch({ ...okRoutes, 'POST /launches': (_b, n) => (n === 1 ? [409, { error: 'date_full' }] : [201, { id: 7 }]) }); + const r = await tinyLaunchAdapter().execute(ctx(f, await prepared())); + expect(r.kind).toBe('scheduled'); + expect(calls.filter((x) => x.url.endsWith('/launches')).map((x) => x.body!.date)).toEqual(['2026-11-03', '2026-11-04']); + }); + + it('reuses a startup created by an earlier attempt instead of creating a duplicate', async () => { + const { f, calls } = mockFetch({ ...okRoutes, 'POST /launches': () => [201, { id: 8 }] }); + await tinyLaunchAdapter().execute(ctx(f, await prepared(), { startup_id: 999 })); + expect(calls.some((x) => x.url.endsWith('/startups'))).toBe(false); + expect(calls.find((x) => x.url.endsWith('/launches'))!.body).toEqual({ startup_id: 999, date: '2026-11-03' }); + }); + + it('server errors after a write are uncertain; a missing maker profile is a handoff', async () => { + const e = mockFetch({ ...okRoutes, 'POST /launches': () => [502, {}] }); + expect((await tinyLaunchAdapter().execute(ctx(e.f, await prepared()))).kind).toBe('uncertain'); + const m = mockFetch({ ...okRoutes, 'GET /maker': () => [404, { error: 'not_found' }] }); + expect(await tinyLaunchAdapter().execute(ctx(m.f, await prepared()))).toMatchObject({ kind: 'needs_human', reasonCode: 'maker_profile_missing', externalActionPerformed: false }); + }); + + it('reconcile reports absence only when no startup exists for the URL', async () => { + const none = mockFetch({ 'GET /startups': () => [200, [{ id: 1, url: 'https://other.example/' }]] }); + expect((await tinyLaunchAdapter().reconcile(ctx(none.f, await prepared()))).status).toBe('absent'); + const some = mockFetch({ 'GET /startups': () => [200, [{ id: 2, url: 'https://acme.example' }]] }); + expect(await tinyLaunchAdapter().reconcile(ctx(some.f, await prepared()))).toMatchObject({ status: 'unknown', reasonCode: 'startup_found_launch_unknown' }); + }); + + it('description HTML escapes everything', () => { + expect(toDescriptionHtml('')).toBe('

<script>x</script>

'); + }); +}); From 607f996d006549e0f89c06e1d7d888d3120fa10e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 14 Oct 2026 23:40:25 +0000 Subject: [PATCH 3/3] Deploy to dev2: Dockerfile, deploy-dev2 workflow, seed on boot, proxy-aware origin check Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 7 ++++ .github/workflows/deploy-dev2.yml | 60 +++++++++++++++++++++++++++++++ Dockerfile | 30 ++++++++++++++++ src/cli.ts | 21 +++++++---- src/http.ts | 13 +++++-- test/http.test.ts | 4 +++ 6 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy-dev2.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..edce7a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.claude +coverage +*.db +*.db-* diff --git a/.github/workflows/deploy-dev2.yml b/.github/workflows/deploy-dev2.yml new file mode 100644 index 0000000..21252d5 --- /dev/null +++ b/.github/workflows/deploy-dev2.yml @@ -0,0 +1,60 @@ +# Ship every merge to dev2, where submitfleet.com runs (born there 2026-10-14, +# never on Railway). The box pulls the merged commit, rebuilds the +# image(s) from this repo and restarts the compose stack under +# /home/anthony/www/submitfleet.com. Generated by cli-tools/dev2/dev2-site scaffold. +name: Deploy to dev2 + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + ref: + description: Git ref to deploy (defaults to the pushed commit) + required: false + type: string + +concurrency: + group: deploy-dev2 + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + # ${{ }} values go through env, never straight into a run: body. + - name: Resolve target revision + id: rev + env: + REF: ${{ inputs.ref || github.sha }} + run: echo "sha=$REF" >> "$GITHUB_OUTPUT" + + - name: Set up ssh + env: + SSH_KEY: ${{ secrets.DEV2_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.DEV2_KNOWN_HOSTS }} + run: | + install -d -m 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + - name: Deploy + env: + DEV2_USER: ${{ secrets.DEV2_USER }} + DEV2_HOST: ${{ secrets.DEV2_HOST }} + SHA: ${{ steps.rev.outputs.sha }} + run: | + ssh -o BatchMode=yes "$DEV2_USER@$DEV2_HOST" \ + /home/anthony/www/submitfleet.com/deploy-app.sh "$SHA" + + - name: Verify the site answers + run: | + for i in $(seq 1 10); do + code=$(curl -s -o /dev/null -w '%{http_code}' "https://submitfleet.com/healthz" || true) + case "$code" in 2*|3*|401|403) echo "submitfleet.com $code"; exit 0;; esac + echo "attempt $i: $code"; sleep 10 + done + echo "submitfleet.com never answered"; exit 1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5d50044 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# SubmitFleet: API, MCP (/mcp), dashboard (/app) and the worker in one process. +FROM node:24-alpine AS build +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +RUN pnpm build + +FROM node:24-alpine AS deps +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --prod --ignore-scripts + +FROM node:24-alpine +WORKDIR /app +# SQLite lives in /data (a bind-mounted volume on dev2); SUBMITFLEET_SECRET_KEY comes from the vault. +ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 SUBMITFLEET_HOME=/data +# --chown: the deploy account's umask 007 leaves the checkout unreadable to other users. +COPY --chown=node:node package.json ./ +COPY --chown=node:node catalog ./catalog +COPY --from=deps --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/dist ./dist +RUN mkdir -p /data && chown node:node /data +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1 +CMD ["node", "dist/cli.js", "serve", "--port", "3000"] diff --git a/src/cli.ts b/src/cli.ts index 85d43fe..c1892dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -177,12 +177,7 @@ export async function main(argv = process.argv.slice(2)): Promise { return EXIT.ok; } case 'catalog': { - if (sub === 'seed') { - const seed = JSON.parse(readFileSync(join(packageRoot(), 'catalog', 'seed.json'), 'utf8')) as { destinations: DestinationInput[] }; - for (const d of seed.destinations) await fleet.catalogUpsert(owner, d); - out({ seeded: seed.destinations.length }); - return EXIT.ok; - } + if (sub === 'seed') return out({ seeded: await seedCatalog(fleet, owner, false) }), EXIT.ok; if (sub === 'search') { const ds = await fleet.catalogSearch(arg); out(o.json ? ds : ds.map((d) => `${d.slug}\t${d.kind}\t${d.fee_status}\t${d.automation_method}\t${d.adapter_health}`).join('\n') || 'no destinations'); @@ -330,6 +325,8 @@ export async function main(argv = process.argv.slice(2)): Promise { return EXIT.ok; } case 'serve': { + const added = await seedCatalog(fleet, owner, true); + if (added) log(`seeded ${added} missing catalog record(s)`); const port = num(o.port, Number(process.env.PORT ?? 8787))!; const app = buildApp(fleet, { allowedOrigins: (process.env.SUBMITFLEET_ALLOWED_ORIGINS ?? '').split(',').filter(Boolean) }); serve({ fetch: app.fetch, port, hostname: process.env.HOST ?? '127.0.0.1' }); @@ -369,6 +366,18 @@ export async function main(argv = process.argv.slice(2)): Promise { } } +/** Loads the reviewed seed records. With onlyMissing, never overwrites a record edited since. */ +async function seedCatalog(fleet: SubmitFleet, owner: Actor, onlyMissing: boolean): Promise { + const seed = JSON.parse(readFileSync(join(packageRoot(), 'catalog', 'seed.json'), 'utf8')) as { destinations: DestinationInput[] }; + let n = 0; + for (const d of seed.destinations) { + if (onlyMissing && (await fleet.catalogSearch(d.slug)).some((x) => x.slug === d.slug)) continue; + await fleet.catalogUpsert(owner, d); + n++; + } + return n; +} + async function loop(w: ReturnType, intervalMs: number): Promise { let stop = false; for (const sig of ['SIGINT', 'SIGTERM'] as const) process.once(sig, () => (stop = true)); diff --git a/src/http.ts b/src/http.ts index fca15e5..b90bfaf 100644 --- a/src/http.ts +++ b/src/http.ts @@ -107,9 +107,18 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[] } // Browsers only reach the API from our own origin; agents and the CLI send no Origin. const originGuard = async (c: Context, next: () => Promise) => { + // Compare hosts, not full origins: behind nginx the app sees http:// while the + // browser sends https://, and the proxy forwards the public host. const origin = c.req.header('origin'); - if (origin && origin !== new URL(c.req.url).origin && !allowed.has(origin)) { - return c.json({ error: 'origin_not_allowed' }, 403); + if (origin && !allowed.has(origin)) { + let originHost = ''; + try { + originHost = new URL(origin).host; + } catch { + // malformed Origin: refuse below + } + const host = c.req.header('x-forwarded-host') ?? c.req.header('host') ?? new URL(c.req.url).host; + if (originHost !== host) return c.json({ error: 'origin_not_allowed' }, 403); } await next(); }; diff --git a/test/http.test.ts b/test/http.test.ts index 6b8d8b7..6f132da 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -63,6 +63,10 @@ describe('HTTP API', () => { it('rejects cross-origin browser calls and validates bodies', async () => { const { call, owner } = await setup(); expect((await call(owner, 'GET', '/api/v1/products', undefined, { origin: 'https://evil.example' })).status).toBe(403); + // Behind nginx: the app sees http://127.0.0.1 but the browser's https origin matches the forwarded host. + const proxied = await call(owner, 'POST', '/api/v1/pause', undefined, { origin: 'https://submitfleet.com', 'x-forwarded-host': 'submitfleet.com' }); + expect(proxied.status).toBe(200); + expect((await call(owner, 'POST', '/api/v1/pause', undefined, { origin: 'https://evil.example', 'x-forwarded-host': 'submitfleet.com' })).status).toBe(403); const bad = await call(owner, 'POST', '/api/v1/products', { url: 'javascript:alert(1)' }); expect(bad.status).toBe(400); });