diff --git a/.changeset/orpc-contracts.md b/.changeset/orpc-contracts.md new file mode 100644 index 0000000..4c88711 --- /dev/null +++ b/.changeset/orpc-contracts.md @@ -0,0 +1,13 @@ +--- +"@codelitdev/platform": minor +"@codelitdev/platform-conformance": minor +"@codelitdev/platform-cli": minor +--- + +REST contracts move from ts-rest to oRPC, and every product commits its OpenAPI document (ADR 0017). + +- `@codelitdev/platform/rest` (new): `platformErrors`, `encodePlatformErrorBody` and `platformErrorResponseBodySchema` keep oRPC error bodies and the generated OpenAPI in the platform `{ code, message, details? }` shape. Also adds `listOperationIds`, `normalizeOpenApiDocument`, and `writeOpenApiDocument`/`runOpenApiScript` for a product's `generate:openapi --check` script. It has no runtime dependency on oRPC. +- `@codelitdev/platform-conformance`: **breaking.** `openapiDocument()` must also return `committedDocumentCurrent`. The `openapi` suite fails when the committed `openapi.json` does not match the contract. +- `@codelitdev/platform-cli`: the template defines its contract with oRPC on Express, commits `apps/api/openapi.json`, and adds `generate:openapi`/`check:openapi` (`check:openapi` is in `check:drift` and CI). The preset pins `@orpc/*` 1.16.1 instead of `@ts-rest/*`. The `@anatine/zod-openapi` patch and the `ts-deepmerge` override are removed. `doctor` now checks the lowest version when several manifests declare the same package, so a vulnerable pin can no longer be hidden by directory read order. + +Existing products: follow ADR 0017. Add `generate:openapi` to your release version step so each release commits the document hosted docs serve. diff --git a/biome.json b/biome.json index 1ff2602..9d9e0bb 100644 --- a/biome.json +++ b/biome.json @@ -15,6 +15,7 @@ "!**/migrations", "!**/node_modules", "!**/*.generated.ts", + "!**/apps/api/openapi.json", "!**/*.tgz", "!packages/design-system/public/r" ] diff --git a/bun.lock b/bun.lock index 240555e..a830c19 100644 --- a/bun.lock +++ b/bun.lock @@ -26,9 +26,11 @@ "@codelitdev/observability": "workspace:*", "@codelitdev/platform": "workspace:*", "@codelitdev/platform-conformance": "workspace:*", + "@orpc/contract": "1.16.1", + "@orpc/openapi": "1.16.1", + "@orpc/server": "1.16.1", + "@orpc/zod": "1.16.1", "@reference-product/api-contract": "workspace:*", - "@ts-rest/express": "3.52.1", - "@ts-rest/open-api": "^3.52.1", "@types/pg": "^8.23.1", "better-auth": "1.7.2", "better-call": "1.4.0", @@ -71,7 +73,8 @@ "name": "@reference-product/api-contract", "version": "0.0.0", "dependencies": { - "@ts-rest/core": "^3.52.1", + "@codelitdev/platform": "workspace:*", + "@orpc/contract": "1.16.1", "zod": "^3.25.76", }, "devDependencies": { @@ -80,7 +83,7 @@ }, "packages/billing": { "name": "@codelitdev/billing", - "version": "0.8.1", + "version": "0.9.0", "bin": { "codelit-billing": "./bin/codelit-billing.js", }, @@ -106,7 +109,7 @@ }, "packages/design-system": { "name": "@codelitdev/design-system", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { "@types/react": "^19.2.17", "@types/react-dom": "^19.2.5", @@ -124,7 +127,7 @@ }, "packages/mcp-server-kit": { "name": "@codelitdev/mcp-server-kit", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", @@ -138,12 +141,12 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/platform": ">=0.4.2", + "@codelitdev/platform": ">=0.9.0", }, }, "packages/oauth-server-kit": { "name": "@codelitdev/oauth-server-kit", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { "@better-auth/core": "1.7.2", "@better-auth/oauth-provider": "1.7.2", @@ -167,7 +170,7 @@ }, "packages/observability": { "name": "@codelitdev/observability", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "pino": "^9.6.0", }, @@ -192,7 +195,7 @@ }, "packages/platform": { "name": "@codelitdev/platform", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { "@codelitdev/billing": "workspace:*", "@electric-sql/pglite": "^0.2.17", @@ -202,7 +205,7 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/billing": ">=0.4.2", + "@codelitdev/billing": ">=0.9.0", "drizzle-orm": "^0.45.2", }, "optionalPeers": [ @@ -212,7 +215,7 @@ }, "packages/platform-cli": { "name": "@codelitdev/platform-cli", - "version": "0.8.1", + "version": "0.9.0", "bin": { "codelit-platform": "./dist/bin.js", }, @@ -224,7 +227,7 @@ }, "packages/platform-conformance": { "name": "@codelitdev/platform-conformance", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { "@codelitdev/platform": "workspace:*", "@types/bun": "^1.4.1", @@ -232,25 +235,19 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/platform": ">=0.1.0-alpha.0", + "@codelitdev/platform": ">=0.9.0", }, }, }, "trustedDependencies": [ "esbuild", ], - "patchedDependencies": { - "@anatine/zod-openapi@1.14.2": "patches/@anatine__zod-openapi@1.14.2.patch", - }, "overrides": { "esbuild": "0.28.2", "postcss": "8.5.28", "qs": "6.16.0", - "ts-deepmerge": "8.0.0", }, "packages": { - "@anatine/zod-openapi": ["@anatine/zod-openapi@1.14.2", "", { "dependencies": { "ts-deepmerge": "^6.0.3" }, "peerDependencies": { "openapi3-ts": "^2.0.0 || ^3.0.0", "zod": "^3.20.0" } }, "sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg=="], - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -575,6 +572,36 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@orpc/client": ["@orpc/client@1.16.1", "", { "dependencies": { "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1", "@orpc/standard-server-fetch": "1.16.1", "@orpc/standard-server-peer": "1.16.1" } }, "sha512-y2aEfZqD6QKAmTObFbzG0yCiMC1iP3PmCy8t+8y/u3P9iaIcB8Ur09OZdVSg+sbTWiqXfbm5smCukDaN/nmwBw=="], + + "@orpc/contract": ["@orpc/contract@1.16.1", "", { "dependencies": { "@orpc/client": "1.16.1", "@orpc/shared": "1.16.1", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-DXpRqxZxDPQ/ijMQi/PNdSskzP6CFBWJF7JC/DrBC1GeSWhSq6Ib5bS6iAq23ZwZuCaRyq7ApH6cxlIfcEJDWA=="], + + "@orpc/interop": ["@orpc/interop@1.16.1", "", {}, "sha512-wj+8oB/Mx/cVKyrnHOrU+cwYGPVG/0TuK3RGJH49TxHDtBcnvnIf85E+aQO6GYI9coHeyAs0uZxFVO56qZrVlw=="], + + "@orpc/json-schema": ["@orpc/json-schema@1.16.1", "", { "dependencies": { "@orpc/contract": "1.16.1", "@orpc/interop": "1.16.1", "@orpc/openapi": "1.16.1", "@orpc/server": "1.16.1", "@orpc/shared": "1.16.1", "json-schema-typed": "^8.0.2" } }, "sha512-O8FRBFy0rHQ0/EY1APihyFXZuAhV6Aoj9wOt67k1Nzp98frp43tKCakcE7sCY+VFKtTcGHVu7pbU5gEl85dN2A=="], + + "@orpc/openapi": ["@orpc/openapi@1.16.1", "", { "dependencies": { "@orpc/client": "1.16.1", "@orpc/contract": "1.16.1", "@orpc/interop": "1.16.1", "@orpc/openapi-client": "1.16.1", "@orpc/server": "1.16.1", "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-R6MCUYJlms/pUEp71sKp9FnenDhpJb9g3TdL9bHqhNISBrl8skIvQZ3Po+cxcSmg43XKYrle9TrXyje6Upc6dg=="], + + "@orpc/openapi-client": ["@orpc/openapi-client@1.16.1", "", { "dependencies": { "@orpc/client": "1.16.1", "@orpc/contract": "1.16.1", "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1" } }, "sha512-aHK6SjG878M/PjY49eW50OSDcqlEDwgnv2UKmEnu2ol2NcRLcn6Wt1p+7gjFy7W53lZ55u6SnV/LFQXlpmuihg=="], + + "@orpc/server": ["@orpc/server@1.16.1", "", { "dependencies": { "@orpc/client": "1.16.1", "@orpc/contract": "1.16.1", "@orpc/interop": "1.16.1", "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1", "@orpc/standard-server-aws-lambda": "1.16.1", "@orpc/standard-server-fastify": "1.16.1", "@orpc/standard-server-fetch": "1.16.1", "@orpc/standard-server-node": "1.16.1", "@orpc/standard-server-peer": "1.16.1", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-Jnfu1a8eMsovjSnROZQKQ4UVPsE/P6ZjMRo85K0hBvLrqZceQOct2HHSiTnVuuj0AFQosHKmK+QR4UG6DX1SoA=="], + + "@orpc/shared": ["@orpc/shared@1.16.1", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-O8WIhgMXuQXMLVbPEmfeyJgXfNGPGLFRp/2vHFmoYgXlxgTZtMmybi8OWn17tt2uT7xQt4z/vWHpn1yD7EQ65g=="], + + "@orpc/standard-server": ["@orpc/standard-server@1.16.1", "", { "dependencies": { "@orpc/shared": "1.16.1" } }, "sha512-f8Ejzey83F/EXA1kugCyuPcE0TFJyUtxopEZ3vr/aqAOFdr45tIFlQl/v1VkpZAKmt3RP9KiAZGyesv5rhJltA=="], + + "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.16.1", "", { "dependencies": { "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1", "@orpc/standard-server-fetch": "1.16.1", "@orpc/standard-server-node": "1.16.1" } }, "sha512-XRP2KX8FKHZqUxf3fRbUvw6WzuFBnWvj08EpiX+5P861iOHOlqycIUTK4D2C4BAkxBx2wUYmmw7wii0RMIUq7A=="], + + "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.16.1", "", { "dependencies": { "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1", "@orpc/standard-server-node": "1.16.1" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-EU+XO6H7YP9Q39ldPNrMSIb+Fvemtkp+O0WwBAmuUGuoP+thnZkg7RoLm61bOfsmFt+RtIskMy9XeU8BvB29iA=="], + + "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.16.1", "", { "dependencies": { "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1" } }, "sha512-UoIVjrCbQ3R8+W6fArVpX18A66wTdty6W2L/aFegCsGDd3vq7rlCeVIo3R3hRDY0lV9iY392kAHe/9ul/ProOg=="], + + "@orpc/standard-server-node": ["@orpc/standard-server-node@1.16.1", "", { "dependencies": { "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1", "@orpc/standard-server-fetch": "1.16.1" } }, "sha512-0HnD8E4s6YBT5nAauevmntj5E3yrcW/LbloVLD3lWRY/6dWNiVGKJGtsBLDlR416/eOLjljW8CvAnCOaP6Llug=="], + + "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.16.1", "", { "dependencies": { "@orpc/shared": "1.16.1", "@orpc/standard-server": "1.16.1" } }, "sha512-AL9g2RxdM17APwkHbgqmqYv/9IIkuanpNSVuXw4+R27zQWx+4O2aq7ovfpmgGy3TJnoU5ZpZYh8ZBKt2fJPnUg=="], + + "@orpc/zod": ["@orpc/zod@1.16.1", "", { "dependencies": { "@orpc/json-schema": "1.16.1", "@orpc/openapi": "1.16.1", "@orpc/shared": "1.16.1", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.16.1", "@orpc/server": "1.16.1", "zod": ">=3.25.0" } }, "sha512-Gsit6JpQZRFhBHjL5r45KvBKJtFjAZU7KwO4S3fte5B/3VSVx042avOahPb9+cngmfMo03NDLztuLBjNq+Jw9w=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -657,12 +684,6 @@ "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], - "@ts-rest/core": ["@ts-rest/core@3.52.1", "", { "peerDependencies": { "@types/node": "^18.18.7 || >=20.8.4", "zod": "^3.22.3" }, "optionalPeers": ["@types/node", "zod"] }, "sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g=="], - - "@ts-rest/express": ["@ts-rest/express@3.52.1", "", { "peerDependencies": { "@ts-rest/core": "~3.52.0", "express": "^4.0.0", "zod": "^3.22.3" }, "optionalPeers": ["zod"] }, "sha512-ttVynyJSWYnVzn7FazSFvShD5j3Lq1JsIhSKereV2Z51zxWEQwo35CpCO3Q8rELzkfD9CYoT6eNSBJRlJLogIw=="], - - "@ts-rest/open-api": ["@ts-rest/open-api@3.52.1", "", { "dependencies": { "@anatine/zod-openapi": "^1.12.0", "openapi3-ts": "^2.0.2" }, "peerDependencies": { "@ts-rest/core": "~3.52.0", "zod": "^3.22.3" } }, "sha512-VY91g6HFzCe1/fNgX4Y3HzaJbIdztCGHTg34drkh7bKgtNOizLJLXV0wwIkMwLGvjhRHojJxnwYxHjGMIkScwg=="], - "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], @@ -879,6 +900,8 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -1133,7 +1156,7 @@ "open": ["open@11.0.2", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q=="], - "openapi3-ts": ["openapi3-ts@2.0.2", "", { "dependencies": { "yaml": "^1.10.2" } }, "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], @@ -1237,6 +1260,8 @@ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1357,6 +1382,8 @@ "systeminformation": ["systeminformation@5.33.8", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-v4F6OGYGh7wDvV68YmjOmZwGixV9A/GQ7d2b84t0UF4CaOy9jipNWIJDkHqYDYiTPuiojqlwVQd0hfUKOUN7tQ=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -1375,8 +1402,6 @@ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - "ts-deepmerge": ["ts-deepmerge@8.0.0", "", {}, "sha512-133O+10nJmVI8w5xeVZPEv5PIrv7iaUae07wv1aH8XJH95Ur6YIhWAPhPyP1YPlbPS9fCVcNIZTu7m8urRVF0A=="], - "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], @@ -1389,6 +1414,8 @@ "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + "type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="], + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1421,6 +1448,8 @@ "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "wildcard-match": ["wildcard-match@5.1.6", "", {}, "sha512-Dh5N543my42ynFsbT0v6iPpaqJt95MOK1n/lhojVTe/hS3gpP6e938bbzsfaS0goLlJbhEOCIJPe/Tc3hckoaQ=="], + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1433,7 +1462,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], @@ -1457,8 +1486,6 @@ "@better-auth/oauth-provider/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], - "@changesets/parse/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1467,8 +1494,6 @@ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "@manypkg/tools/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "@modelcontextprotocol/client/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "@modelcontextprotocol/core/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], @@ -1477,6 +1502,10 @@ "@modelcontextprotocol/server/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@orpc/openapi/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + + "@orpc/server/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "@reference-product/api/drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="], "@reference-product/web/@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], diff --git a/docs/architecture.md b/docs/architecture.md index 6c66c01..26e475d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,7 @@ Package manager and runtime: Bun CodeLit Platform is the reusable application kernel and bootstrap system for CodeLit SaaS products. It lets products share a proven implementation of: - TypeScript monorepo and build conventions. -- Express and ts-rest APIs with generated OpenAPI. +- Express APIs defined by an oRPC contract, with a generated and committed OpenAPI document (ADR 0017). - Better Auth and @codelitdev/oauth-server-kit. - Tenant selection, membership, API-key, and authorization conventions. - REST and MCP parity over common application services. @@ -74,7 +74,7 @@ Auth, MCP, observability, and billing improvements arrive as package releases. M REST routes and MCP tools call the same framework-independent application service: - web/API client -> ts-rest adapter --+ + web/API client -> oRPC handler -----+ +-> application service -> authorization MCP client -> MCP adapter -----+ -> repositories -> audit/outbox @@ -367,7 +367,7 @@ The extraction must not copy product event names, context allowlists, environmen The preset ships inside @codelitdev/platform-cli as `src/preset.json` (ADR 0006). It is a tested compatibility bill of materials pinning: - Exact compatible releases of the independently versioned Platform workspace packages and selected external peers such as dodopayments. -- Express, ts-rest, Zod, Better Auth, Drizzle, MCP SDK, Pino, Bun test, and PGlite. +- Express, oRPC, Zod, Better Auth, Drizzle, MCP SDK, Pino, Bun test, and PGlite. - Supported Bun, TypeScript, Next.js, and React lines. Products depend on individual packages; CI verifies that resolved versions match one preset. The preset has no runtime code. @@ -473,9 +473,9 @@ A managed file may be replaced only when its current hash matches the hash recor The completed reference product is the executable specification: -- apps/api: Express, ts-rest, OpenAPI, Better Auth, Drizzle/PostgreSQL, REST, MCP, Pino, optional PostHog, and worker entry point. +- apps/api: Express, oRPC, OpenAPI, Better Auth, Drizzle/PostgreSQL, REST, MCP, Pino, optional PostHog, and worker entry point. - apps/web: Next.js admin shell, hosted sign-in redirect, session validation against the API, tenant switcher, and one resource screen. -- packages/api-contract: shared Zod and ts-rest contract. +- packages/api-contract: shared Zod schemas and the oRPC contract. - Tenant, membership, invitation, API-key, audit-event, and example-resource models. - Official @codelitdev/billing example with billing.config.ts, committed billing.generated.ts, application-owned Drizzle migration, createBilling composition, fake provider, action-grant adapter, audit/product-effect hooks, and worker maintenance entry point. - PGlite integration tests and Docker Compose for PostgreSQL. @@ -583,7 +583,7 @@ Standard server order: 3. Security headers, body limits, and rate limiting. 4. Authentication. 5. Tenant resolution and membership. -6. ts-rest adapter or MCP transport. +6. oRPC handler or MCP transport. 7. Product service and transaction. 8. Sanitized error capture. 9. Final error response. @@ -707,7 +707,7 @@ Exit: repository history disposition is recorded, blocking ADRs are accepted, an - Implement @codelitdev/platform's context, error, lifecycle, audit, ID, and safe transport-mapping contracts. - Create `examples/reference-product/apps/api`, `apps/web`, and `packages/api-contract` without creating the distributable template yet. - Implement Better Auth schema generation, OAuth composition, cross-subdomain session cookies, tenant selection, memberships, invitations, API keys, audit events, and the credential-precedence rules in section 8. -- Add one tenant-owned example resource through ts-rest/OpenAPI and a Pino-stdout-only baseline. +- Add one tenant-owned example resource through the oRPC contract and OpenAPI and a Pino-stdout-only baseline. - Integrate @codelitdev/billing through packed public exports, a committed generated schema, an application-owned migration, fake provider, action grants, audit/product-effect hooks, and bounded maintenance invocation. - Add PGlite tests, Docker Compose PostgreSQL, migration drift checks, and public-API reports. @@ -779,7 +779,7 @@ Required tests: - Ambiguous/invalid credential rejection with no fallback, and proof that an API key cannot select another tenant. - API-key one-time display, digest verification, expiry, rotation overlap, immediate revocation, permission subsets, and log redaction. - Invitation expiry, resend invalidation, email mismatch, consume-once concurrency, membership idempotency, auditing, and last-owner protection. -- ts-rest runtime/OpenAPI consistency. +- oRPC runtime/OpenAPI consistency, and a committed `openapi.json` that matches the contract. - MCP session, auth, cancellation, and parity. - PostHog absent/configured/failing, idempotent initialization, and timeout-bounded flush/shutdown. - Exception redaction, product property allowlists, bounded dedupe state, per-source rate caps, and manual-versus-SDK double-capture prevention. @@ -812,7 +812,7 @@ Security requirements: - Every credential kind passes tenant-isolation conformance. - Better Auth and billing schema generation are deterministic and application migrations remain application-owned. - Credential precedence, API-key lifecycle, invitations, tenant selection, and last-owner invariants pass security conformance. -- OpenAPI comes from the runtime ts-rest contract. +- OpenAPI comes from the oRPC contract and is committed as `apps/api/openapi.json`; CI and conformance fail when it is stale. - Optional PostHog is redacted, bounded, and failure-isolated. - The reference app consumes the preset-pinned official billing package using public exports, a checked-in generated schema, and application-owned migration. - Billing action grants, audit/product-effect hooks, fake-provider Cloud/OSS behavior, and maintenance invocation pass composition conformance. diff --git a/docs/decisions/0017-orpc-contract-and-committed-openapi.md b/docs/decisions/0017-orpc-contract-and-committed-openapi.md new file mode 100644 index 0000000..aaf5beb --- /dev/null +++ b/docs/decisions/0017-orpc-contract-and-committed-openapi.md @@ -0,0 +1,89 @@ +# ADR 0017: oRPC contracts and a committed OpenAPI document + +Status: accepted +Date: 2026-11-13 + +## Context + +The template, the reference product, SendLit, FrontLit, and CourseLit define +their REST APIs with ts-rest and generate OpenAPI with `@ts-rest/open-api`. +ts-rest has had no release since `3.53.0-rc.1` (June 2025). Its OpenAPI +generator depends on `@anatine/zod-openapi`, which needed a local patch, and +it supports Zod 3 only. MediaLit uses Express with Joi and swagger-autogen, so +its document comes from hand-written comments and does not match the code. + +A survey of the four products (2026-11-13) found: + +- Every product runs Express 4. The Platform packages also assume Express: + `oauth-server-kit` exports Express routers, and `observability` lists Express + as a peer. Products also depend on Express for raw-body webhooks, rate + limiters, SSE, WebSockets, tus, multipart uploads, and about 50 test files + that build an Express app. +- SendLit and FrontLit use the contract for typed clients. SendLit's public + SDK is built on ts-rest types, and both web apps call `initClient`. +- Contract keys are used as operation ids by authorization policies and MCP + parity in SendLit, FrontLit, and CourseLit. +- Hosted FrontLit serves each product's committed `openapi.json` at + `/openapi.json`. Only SendLit and CourseLit commit the + file, and neither regenerates it as part of a release. + +## Decision + +- **Contracts are oRPC contracts** (`@orpc/contract`) built with Zod. They + stay in the product's `packages/api-contract`. Routes keep their keys as + operation ids, and nested routers use dot paths (`settings.esps.list`). +- **Express stays.** oRPC's `OpenAPIHandler` from `@orpc/openapi/node` runs as + Express middleware. Requests it does not match fall through to the rest of + the app, so webhooks, Better Auth, the kit routers, and uploads are + unchanged. We rejected Hono and Fastify: replacing Express would ripple + through the Platform kits and every product without improving the API + documentation. +- **Errors keep the platform shape.** `@codelitdev/platform/rest` adds: + - `platformErrors(...codes)`, which declares platform error codes on a route. + - `encodePlatformErrorBody`, which is the handler's + `customErrorResponseBodyEncoder`. + - `platformErrorResponseBodySchema`, which is the generator's + `customErrorResponseBodySchema`. + + The runtime body and the documented body are therefore both + `{ code, message, details? }`. A route can declare a code that is not a + platform code, with its own `data` schema (for example readiness on 503). + Its body is then that data unchanged. This is how products with their own + error shapes migrate. Input validation failures and thrown exceptions + become platform bodies, and internal messages are never exposed. +- **The OpenAPI document is generated from the contract alone.** Generation + uses `OpenAPIGenerator` with `@orpc/zod`'s Zod 3 converter. It does not + start the server or need a database. The output is OpenAPI 3.1. +- **Every product commits `apps/api/openapi.json`:** + - `generate:openapi` writes the file. `info.version` is the API package + version, and the server URL is the production API URL. + - `check:openapi`, which is `generate:openapi --check`, fails CI when the + file is stale. It is part of `check:drift`. + - The release version step runs `generate:openapi`, so the release commit + carries the document for that release. This repository does it in + `version-packages`. Products add it to their own version step. + - `platform-conformance` fails a product that declares `openapi` when its + committed document does not match the contract + (`committedDocumentCurrent`). +- **Versions:** oRPC is pinned to exact versions in the preset (`1.16.1`), and + Zod stays on 3.25. oRPC accepts Zod 4 through `@orpc/zod/zod4`, so moving + to Zod 4 is a separate decision. + +## Consequences + +- ts-rest, `@anatine/zod-openapi`, its patch, and the `ts-deepmerge` override + are gone from this repository and the template. +- Paths use `{param}` instead of `:param`. Code that reads contract paths + directly must change, for example CourseLit's policy resolver and its + generated MCP tools. +- oRPC's typed client throws on error responses. ts-rest returned a + `{ status, body }` union instead. SendLit's and FrontLit's client wrappers + and SendLit's SDK change shape when they migrate. +- Each committed `openapi.json` changes once in a large diff: from OpenAPI 3.0 + to 3.1, a different schema converter, and fuller error responses. Docs + renderers must accept 3.1. +- oRPC 2.0 is in beta. A future major upgrade is a contract-builder migration + across products, so we pin exact versions and upgrade deliberately. +- Product migration order: CourseLit (no typed client), MediaLit (adopt a + contract for its public routes and replace swagger-autogen), FrontLit, and + then SendLit (its public SDK is affected). diff --git a/examples/reference-product/README.md b/examples/reference-product/README.md index f662d15..4618a00 100644 --- a/examples/reference-product/README.md +++ b/examples/reference-product/README.md @@ -27,6 +27,11 @@ not enable it for a production deployment. Open `http://127.0.0.1:4000/docs` for the interactive Swagger UI, or fetch the generated OpenAPI document from `http://127.0.0.1:4000/openapi.json`. +The REST API is an oRPC contract in `packages/api-contract`. The committed +`apps/api/openapi.json` is generated from it with +`bun run --filter @reference-product/api generate:openapi`. CI fails when the +file is stale, and the release version step regenerates it (ADR 0017). + In a second terminal, start the Next.js web app: ```bash diff --git a/examples/reference-product/apps/api/openapi.json b/examples/reference-product/apps/api/openapi.json new file mode 100644 index 0000000..418c3f0 --- /dev/null +++ b/examples/reference-product/apps/api/openapi.json @@ -0,0 +1,3563 @@ +{ + "info": { + "title": "Reference Product API", + "version": "0.0.0" + }, + "servers": [ + { + "url": "https://api.reference-product.example" + } + ], + "openapi": "3.1.1", + "paths": { + "/health": { + "get": { + "operationId": "health", + "summary": "Liveness", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "const": "ok" + }, + "service": { + "type": "string" + }, + "time": { + "type": "string" + } + }, + "required": [ + "status", + "service", + "time" + ] + } + } + } + } + } + } + }, + "/ready": { + "get": { + "operationId": "ready", + "summary": "Readiness", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "enum": [ + "ready", + "not_ready" + ], + "type": "string" + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "required": [ + "status", + "checks" + ] + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "enum": [ + "ready", + "not_ready" + ], + "type": "string" + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "required": [ + "status", + "checks" + ] + } + } + } + } + } + } + }, + "/v1/notes": { + "get": { + "operationId": "listNotes", + "summary": "List notes", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "title", + "body", + "createdAt", + "updatedAt" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + }, + "post": { + "operationId": "createNote", + "summary": "Create a note", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "body": { + "default": "", + "type": "string", + "maxLength": 8000 + } + }, + "required": [ + "title" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "title", + "body", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/notes/{noteId}": { + "patch": { + "operationId": "updateNote", + "summary": "Update a note", + "parameters": [ + { + "name": "noteId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "body": { + "type": "string", + "maxLength": 8000 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "title", + "body", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + }, + "delete": { + "operationId": "deleteNote", + "summary": "Delete a note", + "parameters": [ + { + "name": "noteId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/billing/entitlement": { + "get": { + "operationId": "getEntitlement", + "summary": "Billing entitlement", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenantId": { + "type": "string" + }, + "activePaidPlan": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entitled": { + "type": "boolean" + }, + "subscriptionStatus": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "tenantId", + "activePaidPlan", + "entitled", + "subscriptionStatus" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/tenants": { + "get": { + "operationId": "listTenants", + "summary": "List tenants", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + }, + "required": [ + "id", + "name" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + }, + "post": { + "operationId": "createTenant", + "summary": "Create a tenant", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + }, + "required": [ + "id", + "name" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/api/tenant/select": { + "post": { + "operationId": "selectTenant", + "summary": "Select the session tenant", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenantId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tenantId" + ] + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/api-keys": { + "get": { + "operationId": "listApiKeys", + "summary": "List API keys", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "publicId": { + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "revokedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "publicId" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + }, + "post": { + "operationId": "createApiKey", + "summary": "Create an API key", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permissions": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "publicId": { + "type": "string" + }, + "raw": { + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "revokedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "publicId", + "raw" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/api-keys/{publicId}": { + "delete": { + "operationId": "revokeApiKey", + "summary": "Revoke an API key", + "parameters": [ + { + "name": "publicId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/invitations": { + "post": { + "operationId": "createInvitation", + "summary": "Invite a member", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "permissions": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "email", + "role" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "const": true + }, + "id": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "ok", + "id" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/invitations/accept": { + "post": { + "operationId": "acceptInvitation", + "summary": "Accept an invitation", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 1 + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "token", + "email" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "const": true + }, + "tenantId": { + "type": "string" + } + }, + "required": [ + "ok", + "tenantId" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/invitations/{invitationId}": { + "delete": { + "operationId": "revokeInvitation", + "summary": "Revoke an invitation", + "parameters": [ + { + "name": "invitationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + } + } +} diff --git a/examples/reference-product/apps/api/package.json b/examples/reference-product/apps/api/package.json index 511515c..d3d42b7 100644 --- a/examples/reference-product/apps/api/package.json +++ b/examples/reference-product/apps/api/package.json @@ -16,7 +16,9 @@ "generate:auth-schema": "bun scripts/generate-auth-schema.mjs", "check:billing-schema": "codelit-billing generate --check --config billing.config.ts", "check:auth-schema": "bun scripts/generate-auth-schema.mjs --check", - "check:drift": "bun run check:billing-schema && bun run check:auth-schema", + "generate:openapi": "bun scripts/generate-openapi.ts", + "check:openapi": "bun scripts/generate-openapi.ts --check", + "check:drift": "bun run check:billing-schema && bun run check:auth-schema && bun run check:openapi", "migrate": "bun scripts/migrate.mjs", "pretest": "bun run check:auth-schema" }, @@ -31,12 +33,14 @@ "@codelitdev/observability": "workspace:*", "@codelitdev/platform": "workspace:*", "@codelitdev/platform-conformance": "workspace:*", + "@orpc/contract": "1.16.1", + "@orpc/openapi": "1.16.1", + "@orpc/server": "1.16.1", + "@orpc/zod": "1.16.1", "@reference-product/api-contract": "workspace:*", - "@ts-rest/express": "3.52.1", - "@ts-rest/open-api": "^3.52.1", "@types/pg": "^8.23.1", - "better-call": "1.4.0", "better-auth": "1.7.2", + "better-call": "1.4.0", "drizzle-orm": "^0.45.2", "express": "^4.21.2", "pg": "^8.23.0", diff --git a/examples/reference-product/apps/api/scripts/generate-openapi.ts b/examples/reference-product/apps/api/scripts/generate-openapi.ts new file mode 100644 index 0000000..ffaba13 --- /dev/null +++ b/examples/reference-product/apps/api/scripts/generate-openapi.ts @@ -0,0 +1,12 @@ +// Writes the committed apps/api/openapi.json from the API contract. +// `--check` fails instead of writing; CI runs it on every change, and the +// release version step runs it without `--check` so each release commits the +// document hosted docs serve. +import path from "node:path"; +import { runOpenApiScript } from "@codelitdev/platform/rest"; +import { createOpenApiDocument } from "../src/openapi.js"; + +await runOpenApiScript({ + document: await createOpenApiDocument(), + file: path.resolve(import.meta.dirname, "../openapi.json"), +}); diff --git a/examples/reference-product/apps/api/src/app.test.ts b/examples/reference-product/apps/api/src/app.test.ts index 19e709c..bcb2585 100644 --- a/examples/reference-product/apps/api/src/app.test.ts +++ b/examples/reference-product/apps/api/src/app.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { listOperationIds } from "@codelitdev/platform/rest"; import { contract } from "@reference-product/api-contract"; import { eq } from "drizzle-orm"; import { createReferenceAuth } from "./auth/better-auth.js"; @@ -111,6 +112,72 @@ describe.serial("reference API adapters", () => { await runtime.close(); }); + it("serves success statuses, 204 bodies, and validated output over HTTP", async () => { + const clock = freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")); + const runtime = await createPgliteRuntime({ clock }); + const world = await seedWorld(runtime, clock); + const app = createExpressApp(runtime); + const server = await new Promise>((resolve) => { + const listening = app.listen(0, () => resolve(listening)); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("listen_failed"); + const base = `http://127.0.0.1:${address.port}`; + const headers = { + "content-type": "application/json", + cookie: world.owner.sessionCookie, + "x-tenant-id": world.tenantA.publicId, + }; + try { + const created = await fetch(`${base}/v1/notes`, { + method: "POST", + headers, + body: JSON.stringify({ title: "Over HTTP" }), + }); + expect(created.status).toBe(201); + const note = (await created.json()) as { id: string; body: string }; + expect(note.body).toBe(""); + + const updated = await fetch(`${base}/v1/notes/${note.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ title: "Renamed" }), + }); + expect(updated.status).toBe(200); + expect(await updated.json()).toMatchObject({ id: note.id, title: "Renamed" }); + + const key = await fetch(`${base}/v1/api-keys`, { + method: "POST", + headers, + body: JSON.stringify({ permissions: [] }), + }); + expect(key.status).toBe(201); + const { publicId } = (await key.json()) as { publicId: string }; + const revoked = await fetch(`${base}/v1/api-keys/${publicId}`, { + method: "DELETE", + headers, + }); + expect(revoked.status).toBe(204); + expect(await revoked.text()).toBe(""); + + const missing = await fetch(`${base}/v1/notes/does-not-exist`, { + method: "DELETE", + headers, + }); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ + code: "not_found", + message: "The requested resource was not found.", + }); + + const unknownRoute = await fetch(`${base}/v1/unknown`, { headers }); + expect(unknownRoute.status).toBe(404); + } finally { + await new Promise((resolve) => server.close(resolve)); + await runtime.close(); + } + }); + it("supports one-time API-key creation, metadata listing, scoped revocation, and expiry", async () => { const clock = freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")); const runtime = await createPgliteRuntime({ clock }); @@ -304,7 +371,7 @@ describe.serial("reference API adapters", () => { const { createReferenceMcp } = await import("./mcp.js"); const mcp = createReferenceMcp(runtime); const issues = validateParityManifest([...mcpParityManifest], { - restOperationIds: new Set(Object.keys(contract)), + restOperationIds: new Set(listOperationIds(contract)), mcpToolNames: new Set(mcp.listTools().map((tool) => tool.name)), mcpToolRisks: new Map( mcp.listTools().map((tool) => [tool.name, tool.risk] as const), @@ -497,7 +564,7 @@ describe.serial("reference API adapters", () => { await runtime.close(); }); - it("mounts the shared contract through the Express ts-rest adapter", async () => { + it("serves the oRPC contract through Express with platform error bodies", async () => { const runtime = await createPgliteRuntime({ clock: freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")), }); @@ -522,7 +589,27 @@ describe.serial("reference API adapters", () => { `http://127.0.0.1:${address.port}/docs/swagger-ui-init.js`, ); expect(docsInitializer.status).toBe(200); - expect(await docsInitializer.text()).toContain('"/v1/notes"'); + expect(await docsInitializer.text()).toContain('"/openapi.json"'); + const base = `http://127.0.0.1:${address.port}`; + const unauthenticated = await fetch(`${base}/v1/notes`); + expect(unauthenticated.status).toBe(401); + expect(await unauthenticated.json()).toEqual({ + code: "unauthenticated", + message: "Authentication is required.", + }); + const invalid = await fetch(`${base}/v1/notes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "" }), + }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toEqual({ + code: "validation_failed", + message: "The request is invalid.", + }); + const ready = await fetch(`${base}/ready`); + expect(ready.status).toBe(200); + expect(await ready.json()).toMatchObject({ status: "ready" }); const protectedResource = await fetch( `http://127.0.0.1:${address.port}/.well-known/oauth-protected-resource/mcp`, ); diff --git a/examples/reference-product/apps/api/src/conformance-adapter.ts b/examples/reference-product/apps/api/src/conformance-adapter.ts index d2872e0..12a2126 100644 --- a/examples/reference-product/apps/api/src/conformance-adapter.ts +++ b/examples/reference-product/apps/api/src/conformance-adapter.ts @@ -1,10 +1,13 @@ +import { readFile } from "node:fs/promises"; import { createObservability, redactText } from "@codelitdev/observability"; +import { listOperationIds, serializeOpenApiDocument } from "@codelitdev/platform/rest"; import { runPlatformConformance } from "@codelitdev/platform-conformance"; import { contract } from "@reference-product/api-contract"; import { eq } from "drizzle-orm"; import { runBoundedMaintenance } from "./billing.js"; import { mcpFor } from "./dispatch.js"; import { createExpressApp } from "./express-app.js"; +import { createOpenApiDocument } from "./openapi.js"; import type { Runtime } from "./runtime.js"; import type { SeededWorld } from "./seed.js"; @@ -156,8 +159,16 @@ export function createReferenceConformanceAdapter( headers: {}, }); const document = response.body as { paths: Record }; - const contractOperationIds = Object.keys(contract); - return { paths: document.paths, contractOperationIds }; + const generated = await createOpenApiDocument(); + const committed = await readFile( + new URL("../openapi.json", import.meta.url), + "utf8", + ).catch(() => undefined); + return { + paths: document.paths, + contractOperationIds: listOperationIds(contract), + committedDocumentCurrent: committed === serializeOpenApiDocument(generated), + }; }, observability: { async redact(secret: string) { diff --git a/examples/reference-product/apps/api/src/dispatch.ts b/examples/reference-product/apps/api/src/dispatch.ts index 72a5da1..f8dd24f 100644 --- a/examples/reference-product/apps/api/src/dispatch.ts +++ b/examples/reference-product/apps/api/src/dispatch.ts @@ -75,7 +75,7 @@ export async function dispatch( if (request.method === "GET" && path === "/openapi.json") { return { status: 200, - body: createOpenApiDocument(deps.auth.publicApiUrl), + body: await createOpenApiDocument(deps.auth.publicApiUrl), }; } diff --git a/examples/reference-product/apps/api/src/express-app.ts b/examples/reference-product/apps/api/src/express-app.ts index 59404ab..92785a7 100644 --- a/examples/reference-product/apps/api/src/express-app.ts +++ b/examples/reference-product/apps/api/src/express-app.ts @@ -1,15 +1,24 @@ import { createOAuthPagesRouter } from "@codelitdev/oauth-server-kit/express"; import { createMcpOAuthDiscoveryRoutes } from "@codelitdev/oauth-server-kit/mcp"; -import { readOrCreateRequestId } from "@codelitdev/platform"; +import { + healthReport, + readinessReport, + readOrCreateRequestId, +} from "@codelitdev/platform"; +import { + encodePlatformErrorBody, + isPublicErrorBody, + listOperationIds, +} from "@codelitdev/platform/rest"; +import { OpenAPIHandler } from "@orpc/openapi/node"; +import { implement, ORPCError, onError } from "@orpc/server"; import { contract } from "@reference-product/api-contract"; -import { createExpressEndpoints, initServer } from "@ts-rest/express"; import { toNodeHandler } from "better-auth/node"; import express, { type Express, type RequestHandler } from "express"; import swaggerUi from "swagger-ui-express"; import { AUTH_BASE_PATH, MCP_SCOPES_SUPPORTED } from "./auth/options.js"; import type { DispatchDeps } from "./deps.js"; import { dispatch } from "./dispatch.js"; -import { createOpenApiDocument } from "./openapi.js"; export function createExpressApp(deps: DispatchDeps): Express { const app = express(); @@ -26,7 +35,7 @@ export function createExpressApp(deps: DispatchDeps): Express { deps.clock, ); // Keep the generated correlation ID on the request as well as the - // response so ts-rest and the fallback dispatcher audit the same value. + // response so the oRPC handler and the fallback dispatcher audit the same value. req.headers["x-request-id"] = requestId; res.setHeader("X-Request-ID", requestId); const now = Date.now(); @@ -71,8 +80,9 @@ export function createExpressApp(deps: DispatchDeps): Express { app.use( "/docs", ...(swaggerUi.serve as unknown as RequestHandler[]), - swaggerUi.setup(createOpenApiDocument(deps.auth.publicApiUrl), { + swaggerUi.setup(undefined, { explorer: true, + swaggerUrl: "/openapi.json", swaggerOptions: { persistAuthorization: true, displayRequestDuration: true, @@ -83,55 +93,7 @@ export function createExpressApp(deps: DispatchDeps): Express { ); app.use(express.json({ limit: "32kb" })); - const server = initServer(); - const forward = async (input: { - req: { - method: string; - path: string; - headers: Record; - body?: unknown; - }; - res: { setHeader(name: string, value: string): void }; - }) => { - const response = await dispatch(deps, { - method: input.req.method, - path: input.req.path, - headers: input.req.headers, - body: input.req.body, - }); - for (const [name, value] of Object.entries(response.headers ?? {})) { - input.res.setHeader(name, value); - } - return { status: response.status, body: response.body } as never; - }; - const router = server.router(contract, { - health: forward, - ready: forward, - listNotes: forward, - createNote: forward, - updateNote: forward, - deleteNote: forward, - getEntitlement: forward, - listTenants: forward, - createTenant: forward, - selectTenant: forward, - listApiKeys: forward, - createApiKey: forward, - revokeApiKey: forward, - createInvitation: forward, - acceptInvitation: forward, - revokeInvitation: forward, - } as never); - createExpressEndpoints(contract, router, app, { - logInitialization: false, - responseValidation: true, - requestValidationErrorHandler: (_error, _req, res) => { - res.status(400).json({ - code: "validation_failed", - message: "The request body is invalid.", - }); - }, - }); + app.use(createRestHandler(deps)); app.use(async (req, res, next) => { try { const requestId = readOrCreateRequestId( @@ -186,3 +148,71 @@ export function createExpressApp(deps: DispatchDeps): Express { ); return app; } + +type RestContext = { req: express.Request; res: express.Response }; + +/** + * Serves the oRPC contract. oRPC validates input and output against the + * contract; the handlers forward to `dispatch`, which owns authentication, + * tenancy and the product logic for both REST and the fallback routes. + * Requests the contract does not match fall through to the next middleware. + */ +function createRestHandler(deps: DispatchDeps): RequestHandler { + const os = implement(contract).$context(); + const forward = async ({ context }: { context: RestContext }) => { + const { req, res } = context; + const response = await dispatch(deps, { + method: req.method, + path: req.path, + headers: req.headers as Record, + body: req.body, + }); + for (const [name, value] of Object.entries(response.headers ?? {})) { + res.setHeader(name, value); + } + if (response.status >= 400) { + if (!isPublicErrorBody(response.body)) { + throw new Error(`dispatch returned a non-platform error (${response.status})`); + } + throw new ORPCError(response.body.code, { + status: response.status, + data: { details: response.body.details }, + }); + } + return (response.body ?? undefined) as never; + }; + const handlers: Record = {}; + for (const id of listOperationIds(contract)) { + handlers[id] = os[id as keyof typeof contract].handler(forward as never); + } + handlers.health = os.health.handler(() => healthReport(deps.serviceName, deps.clock)); + handlers.ready = os.ready.handler(() => { + const report = readinessReport([{ name: "database", ready: deps.databaseReady }]); + if (report.status !== "ready") { + throw new ORPCError("not_ready", { status: 503, data: report }); + } + return report; + }); + const router = os.router(handlers as never); + const handler = new OpenAPIHandler(router, { + customErrorResponseBodyEncoder: encodePlatformErrorBody, + interceptors: [ + // Expected 4xx results are thrown as ORPCError; report only failures, + // including output that does not match the contract. + onError((error) => { + if (error instanceof ORPCError && error.status < 500) return; + deps.observability?.captureException({ error, source: "rest" }); + }), + ], + }); + return async (req, res, next) => { + try { + const { matched } = await handler.handle(req, res, { + context: { req, res }, + }); + if (!matched) next(); + } catch (error) { + next(error); + } + }; +} diff --git a/examples/reference-product/apps/api/src/openapi.ts b/examples/reference-product/apps/api/src/openapi.ts index 6cff5a2..226fd9f 100644 --- a/examples/reference-product/apps/api/src/openapi.ts +++ b/examples/reference-product/apps/api/src/openapi.ts @@ -1,12 +1,36 @@ +import { + normalizeOpenApiDocument, + platformErrorResponseBodySchema, +} from "@codelitdev/platform/rest"; +import { OpenAPIGenerator } from "@orpc/openapi"; +import { ZodToJsonSchemaConverter } from "@orpc/zod"; import { contract } from "@reference-product/api-contract"; -import { generateOpenApi } from "@ts-rest/open-api"; +import packageJson from "../package.json" with { type: "json" }; -export function createOpenApiDocument(publicApiUrl: string) { - return generateOpenApi(contract, { +/** + * The server URL written into the committed `openapi.json`. Hosted docs serve + * that file, so it names the production API, not the local one. + */ +export const OPENAPI_PUBLIC_SERVER_URL = "https://api.reference-product.example"; + +const generator = new OpenAPIGenerator({ + schemaConverters: [new ZodToJsonSchemaConverter()], +}); + +/** + * Builds the OpenAPI document from the contract alone, without starting the + * server, so release scripts can generate it. + */ +export async function createOpenApiDocument( + serverUrl: string = OPENAPI_PUBLIC_SERVER_URL, +) { + const document = await generator.generate(contract, { info: { title: "Reference Product API", - version: "1.0.0", + version: packageJson.version, }, - servers: [{ url: publicApiUrl }], + servers: [{ url: serverUrl }], + customErrorResponseBodySchema: platformErrorResponseBodySchema, }); + return normalizeOpenApiDocument(document); } diff --git a/examples/reference-product/apps/api/tsconfig.json b/examples/reference-product/apps/api/tsconfig.json index 9212c92..967b4ce 100644 --- a/examples/reference-product/apps/api/tsconfig.json +++ b/examples/reference-product/apps/api/tsconfig.json @@ -6,9 +6,10 @@ "strict": true, "skipLibCheck": true, "esModuleInterop": true, + "resolveJsonModule": true, "noEmit": true, "rootDir": ".", "types": ["node", "bun"] }, - "include": ["src", "billing.config.ts"] + "include": ["src", "scripts/*.ts", "billing.config.ts"] } diff --git a/examples/reference-product/packages/api-contract/package.json b/examples/reference-product/packages/api-contract/package.json index c88e767..613c1b1 100644 --- a/examples/reference-product/packages/api-contract/package.json +++ b/examples/reference-product/packages/api-contract/package.json @@ -14,7 +14,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@ts-rest/core": "^3.52.1", + "@codelitdev/platform": "workspace:*", + "@orpc/contract": "1.16.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/examples/reference-product/packages/api-contract/src/index.ts b/examples/reference-product/packages/api-contract/src/index.ts index a2f4a25..e8b57c7 100644 --- a/examples/reference-product/packages/api-contract/src/index.ts +++ b/examples/reference-product/packages/api-contract/src/index.ts @@ -1,8 +1,7 @@ -import { initContract } from "@ts-rest/core"; +import { platformErrors } from "@codelitdev/platform/rest"; +import { oc } from "@orpc/contract"; import { z } from "zod"; -const c = initContract(); - export const platformErrorSchema = z.object({ code: z.enum([ "unauthenticated", @@ -79,178 +78,149 @@ export const invitationSchema = z.object({ token: z.string().optional(), }); -export const contract = c.router({ - health: { - method: "GET", - path: "/health", - responses: { 200: healthResponseSchema }, - summary: "Liveness", - }, - ready: { - method: "GET", - path: "/ready", - responses: { 200: readinessResponseSchema, 503: readinessResponseSchema }, - summary: "Readiness", - }, - listNotes: { - method: "GET", - path: "/v1/notes", - responses: { - 200: z.object({ items: z.array(noteSchema) }), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - createNote: { - method: "POST", - path: "/v1/notes", - body: createNoteBodySchema, - responses: { - 201: noteSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - updateNote: { - method: "PATCH", - path: "/v1/notes/:noteId", - pathParams: z.object({ noteId: z.string() }), - body: updateNoteBodySchema, - responses: { - 200: noteSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, - deleteNote: { - method: "DELETE", - path: "/v1/notes/:noteId", - pathParams: z.object({ noteId: z.string() }), - responses: { - 200: z.object({ id: z.string() }), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, - getEntitlement: { - method: "GET", - path: "/v1/billing/entitlement", - responses: { - 200: entitlementSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - listTenants: { - method: "GET", - path: "/v1/tenants", - responses: { - 200: z.object({ items: z.array(tenantSchema) }), - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - createTenant: { - method: "POST", - path: "/v1/tenants", - body: z.object({ name: z.string().min(1).max(200) }), - responses: { - 201: tenantSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - }, - }, - selectTenant: { - method: "POST", - path: "/api/tenant/select", - body: z.object({ tenantId: z.string().min(1) }), - responses: { - 204: z.undefined(), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - listApiKeys: { - method: "GET", - path: "/v1/api-keys", - responses: { - 200: z.object({ items: z.array(apiKeySchema.omit({ raw: true })) }), - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - createApiKey: { - method: "POST", - path: "/v1/api-keys", - body: z.object({ - permissions: z.array(z.string()).default([]), - expiresAt: z.string().datetime().optional(), - }), - responses: { - 201: apiKeySchema.required({ raw: true }), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - revokeApiKey: { - method: "DELETE", - path: "/v1/api-keys/:publicId", - pathParams: z.object({ publicId: z.string() }), - responses: { - 204: z.undefined(), - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, - createInvitation: { - method: "POST", - path: "/v1/invitations", - body: z.object({ - email: z.string().email(), - role: z.string().min(1), - permissions: z.array(z.string()).default([]), - }), - responses: { - 201: invitationSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - acceptInvitation: { - method: "POST", - path: "/v1/invitations/accept", - body: z.object({ token: z.string().min(1), email: z.string().email() }), - responses: { - 200: z.object({ ok: z.literal(true), tenantId: z.string() }), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, - revokeInvitation: { - method: "DELETE", - path: "/v1/invitations/:invitationId", - pathParams: z.object({ invitationId: z.string() }), - responses: { - 204: z.undefined(), - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, -}); +// Errors every authenticated route can return. Platform error codes keep the +// public `{ code, message, details? }` body; see @codelitdev/platform/rest. +const authenticated = platformErrors( + "unauthenticated", + "credential_ambiguous", + "forbidden", + "rate_limited", +); +const tenantScoped = { + ...authenticated, + ...platformErrors("tenant_required", "tenant_forbidden"), +}; + +export const contract = { + health: oc + .route({ method: "GET", path: "/health", summary: "Liveness" }) + .output(healthResponseSchema), + ready: oc + .route({ method: "GET", path: "/ready", summary: "Readiness" }) + // Readiness reports keep their own body on 503 instead of a platform error. + .errors({ not_ready: { status: 503, data: readinessResponseSchema } }) + .output(readinessResponseSchema), + listNotes: oc + .route({ method: "GET", path: "/v1/notes", summary: "List notes" }) + .errors(tenantScoped) + .output(z.object({ items: z.array(noteSchema) })), + createNote: oc + .route({ + method: "POST", + path: "/v1/notes", + successStatus: 201, + summary: "Create a note", + }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed") }) + .input(createNoteBodySchema) + .output(noteSchema), + updateNote: oc + .route({ method: "PATCH", path: "/v1/notes/{noteId}", summary: "Update a note" }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed", "not_found") }) + .input(updateNoteBodySchema.extend({ noteId: z.string() })) + .output(noteSchema), + deleteNote: oc + .route({ method: "DELETE", path: "/v1/notes/{noteId}", summary: "Delete a note" }) + .errors({ ...tenantScoped, ...platformErrors("not_found") }) + .input(z.object({ noteId: z.string() })) + .output(z.object({ id: z.string() })), + getEntitlement: oc + .route({ + method: "GET", + path: "/v1/billing/entitlement", + summary: "Billing entitlement", + }) + .errors(tenantScoped) + .output(entitlementSchema), + listTenants: oc + .route({ method: "GET", path: "/v1/tenants", summary: "List tenants" }) + .errors(authenticated) + .output(z.object({ items: z.array(tenantSchema) })), + createTenant: oc + .route({ + method: "POST", + path: "/v1/tenants", + successStatus: 201, + summary: "Create a tenant", + }) + .errors({ ...authenticated, ...platformErrors("validation_failed") }) + .input(z.object({ name: z.string().min(1).max(200) })) + .output(tenantSchema), + selectTenant: oc + .route({ + method: "POST", + path: "/api/tenant/select", + successStatus: 204, + summary: "Select the session tenant", + }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed") }) + .input(z.object({ tenantId: z.string().min(1) })) + .output(z.void()), + listApiKeys: oc + .route({ method: "GET", path: "/v1/api-keys", summary: "List API keys" }) + .errors(tenantScoped) + .output(z.object({ items: z.array(apiKeySchema.omit({ raw: true })) })), + createApiKey: oc + .route({ + method: "POST", + path: "/v1/api-keys", + successStatus: 201, + summary: "Create an API key", + }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed") }) + .input( + z.object({ + permissions: z.array(z.string()).default([]), + expiresAt: z.string().datetime().optional(), + }), + ) + .output(apiKeySchema.required({ raw: true })), + revokeApiKey: oc + .route({ + method: "DELETE", + path: "/v1/api-keys/{publicId}", + successStatus: 204, + summary: "Revoke an API key", + }) + .errors({ ...tenantScoped, ...platformErrors("not_found") }) + .input(z.object({ publicId: z.string() })) + .output(z.void()), + createInvitation: oc + .route({ + method: "POST", + path: "/v1/invitations", + successStatus: 201, + summary: "Invite a member", + }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed") }) + .input( + z.object({ + email: z.string().email(), + role: z.string().min(1), + permissions: z.array(z.string()).default([]), + }), + ) + .output(invitationSchema), + acceptInvitation: oc + .route({ + method: "POST", + path: "/v1/invitations/accept", + summary: "Accept an invitation", + }) + .errors({ ...authenticated, ...platformErrors("validation_failed", "not_found") }) + .input(z.object({ token: z.string().min(1), email: z.string().email() })) + .output(z.object({ ok: z.literal(true), tenantId: z.string() })), + revokeInvitation: oc + .route({ + method: "DELETE", + path: "/v1/invitations/{invitationId}", + successStatus: 204, + summary: "Revoke an invitation", + }) + .errors({ ...tenantScoped, ...platformErrors("not_found") }) + .input(z.object({ invitationId: z.string() })) + .output(z.void()), +}; export type Contract = typeof contract; export { mcpParityManifest } from "./mcp-parity.js"; diff --git a/package.json b/package.json index ce61a8f..181c83e 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,7 @@ "overrides": { "esbuild": "0.28.2", "postcss": "8.5.28", - "qs": "6.16.0", - "ts-deepmerge": "8.0.0" - }, - "patchedDependencies": { - "@anatine/zod-openapi@1.14.2": "patches/@anatine__zod-openapi@1.14.2.patch" + "qs": "6.16.0" }, "engines": { "bun": ">=1.4.4" @@ -38,7 +34,7 @@ "test:packed": "bun run --filter @codelitdev/billing test:packed && bun run --filter @codelitdev/platform test:packed && bun run --filter @codelitdev/observability test:packed && bun run --filter @codelitdev/mcp-server-kit test:packed && bun run --filter @codelitdev/platform-conformance test:packed && bun run --filter @codelitdev/platform-cli test:packed && bun scripts/check-package-contents.mjs", "template:smoke": "bun scripts/template-smoke.mjs", "changeset": "changeset", - "version-packages": "changeset version && bun scripts/sync-preset.mjs", + "version-packages": "changeset version && bun scripts/sync-preset.mjs && bun run prepare:dist && bun run --filter @reference-product/api generate:openapi", "release": "bun run verify && node scripts/resolve-workspace-ranges.mjs && changeset publish", "prepare": "husky" }, diff --git a/packages/oauth-server-kit/docs/integration.md b/packages/oauth-server-kit/docs/integration.md index cbc1fcc..87b3daa 100644 --- a/packages/oauth-server-kit/docs/integration.md +++ b/packages/oauth-server-kit/docs/integration.md @@ -237,11 +237,11 @@ Exchange the code with the original verifier and no client secret. Store refresh tokens in the OS secure credential store. Reject callback state or issuer mismatches, and never accept an unregistered redirect URI. -## 4. REST API, including ts-rest +## 4. REST API, including oRPC -Mount the bearer middleware immediately before the protected router. ts-rest -needs no package-specific adapter because it consumes ordinary Express -middleware. +Mount the bearer middleware immediately before the protected router. The +oRPC handler needs no package-specific adapter because it runs as ordinary +Express middleware after it. ```ts import { createOAuthBearerMiddleware } from "@codelitdev/oauth-server-kit/express"; @@ -252,7 +252,7 @@ const requireOAuth = createOAuthBearerMiddleware({ audiences: [restResource], }); -app.use("/api", requireOAuth, tsRestRouter); +app.use("/api", requireOAuth, orpcMiddleware); ``` `request.auth` is a neutral session/OAuth identity only after successful diff --git a/packages/platform-cli/src/cli.test.ts b/packages/platform-cli/src/cli.test.ts index fff92d3..f40acf4 100644 --- a/packages/platform-cli/src/cli.test.ts +++ b/packages/platform-cli/src/cli.test.ts @@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import path from "node:path"; import { createProduct } from "./create.js"; -import { doctor } from "./doctor.js"; +import { doctor, resolveCodelitVersions } from "./doctor.js"; import { sha256File } from "./hash.js"; import { readManifest, writeManifest } from "./manifest.js"; import { syncProduct } from "./sync.js"; @@ -224,6 +224,25 @@ describe("platform-cli", () => { expect(Object.keys(migrated.managedFiles)).not.toContain("legacy/managed.txt"); }); + it("reports the lowest version when several manifests declare a package", () => { + const parent = mkdtempSync(path.join(tmpdir(), "cli-doctor-")); + const target = path.join(parent, "acme"); + createProduct({ targetDir: target, productName: "Acme" }); + // apps/api still declares the recommended version; only the contract is low. + writeFileSync( + path.join(target, "packages", "api-contract", "package.json"), + JSON.stringify( + { name: "acme-contract", dependencies: { "@codelitdev/platform": "0.0.1" } }, + null, + 2, + ), + ); + expect(resolveCodelitVersions(target)["@codelitdev/platform"]).toBe("0.0.1"); + expect(doctor(target).issues).toContain( + "below_minimum_secure:@codelitdev/platform", + ); + }); + it("fails doctor when a resolved @codelitdev package is below minimumSecure", () => { const parent = mkdtempSync(path.join(tmpdir(), "cli-doctor-")); const target = path.join(parent, "acme"); diff --git a/packages/platform-cli/src/doctor.ts b/packages/platform-cli/src/doctor.ts index a0e4dcc..792ac05 100644 --- a/packages/platform-cli/src/doctor.ts +++ b/packages/platform-cli/src/doctor.ts @@ -2,7 +2,11 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { sha256File } from "./hash.js"; import { readManifest } from "./manifest.js"; -import { loadPresetManifest, validateResolvedVersions } from "./preset.js"; +import { + compareVersions, + loadPresetManifest, + validateResolvedVersions, +} from "./preset.js"; export type DoctorReport = { ok: boolean; @@ -95,6 +99,15 @@ export function resolveCodelitVersions( ): Record { const resolved: Record = {}; const names = managedNames ? new Set(managedNames) : undefined; + // Several manifests can declare the same package (for example the API and + // the contract both depend on @codelitdev/platform). Keep the lowest + // version so a vulnerable one is never hidden by directory read order. + const record = (name: string, version: string) => { + const current = resolved[name]; + if (current === undefined || compareVersions(version, current) < 0) { + resolved[name] = version; + } + }; for (const pkgPath of collectPackageJsonFiles(root)) { const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { dependencies?: Record; @@ -108,11 +121,11 @@ export function resolveCodelitVersions( const version = ( JSON.parse(readFileSync(installed, "utf8")) as { version: string } ).version; - resolved[name] = version; + record(name, version); continue; } if (spec.startsWith("workspace:")) continue; - resolved[name] = spec.replace(/^[\^~]/, ""); + record(name, spec.replace(/^[\^~]/, "")); } } return resolved; diff --git a/packages/platform-cli/src/preset.json b/packages/platform-cli/src/preset.json index bef0e89..7c59266 100644 --- a/packages/platform-cli/src/preset.json +++ b/packages/platform-cli/src/preset.json @@ -48,9 +48,10 @@ "external": { "typescript": "5.9.3", "express": "4.21.2", - "@ts-rest/core": "3.52.1", - "@ts-rest/express": "3.52.1", - "@ts-rest/open-api": "3.52.1", + "@orpc/contract": "1.16.1", + "@orpc/openapi": "1.16.1", + "@orpc/server": "1.16.1", + "@orpc/zod": "1.16.1", "pg": "8.23.0", "@types/pg": "8.23.1", "zod": "3.25.76", diff --git a/packages/platform-cli/src/preset.ts b/packages/platform-cli/src/preset.ts index 9a3e82c..7445798 100644 --- a/packages/platform-cli/src/preset.ts +++ b/packages/platform-cli/src/preset.ts @@ -45,7 +45,8 @@ function parseVersion(value: string): { }; } -function cmp(a: string, b: string): number { +/** Orders two versions by SemVer precedence (negative when `a` is lower). */ +export function compareVersions(a: string, b: string): number { const pa = parseVersion(a); const pb = parseVersion(b); if (!pa || !pb) return a.localeCompare(b); @@ -76,7 +77,7 @@ function satisfies(version: string, range: string): boolean { const match = /^(>=|<=|>|<|=)?(.+)$/.exec(clause); if (!match) return false; const relation = match[1] ?? "="; - const result = cmp(version, match[2]!); + const result = compareVersions(version, match[2]!); if (relation === ">=") return result >= 0; if (relation === "<=") return result <= 0; if (relation === ">") return result > 0; @@ -93,10 +94,10 @@ function satisfies(version: string, range: string): boolean { function satisfiesCaret(version: string, pin: string): boolean { const v = parseVersion(version); const p = parseVersion(pin); - if (!v || !p) return cmp(version, pin) === 0; + if (!v || !p) return compareVersions(version, pin) === 0; if (v.major !== p.major) return false; if (p.major === 0 && v.minor !== p.minor) return false; - return cmp(version, pin) >= 0; + return compareVersions(version, pin) >= 0; } export type PresetIssue = { @@ -134,7 +135,7 @@ export function validateResolvedVersions( }); continue; } - if (cmp(version, pin.minimumSecure) < 0) { + if (compareVersions(version, pin.minimumSecure) < 0) { issues.push({ packageName: name, reason: "below_minimum_secure", diff --git a/packages/platform-conformance/README.md b/packages/platform-conformance/README.md index 31a4496..a2f2ec5 100644 --- a/packages/platform-conformance/README.md +++ b/packages/platform-conformance/README.md @@ -20,7 +20,7 @@ the reference runner is not yet a general adapter for every product. | Credentials | Sessions, OAuth tokens, and API keys resolve to the right principal; ambiguous or invalid credentials fail without fallback; transport clients cannot present a trusted system identity. | Some cases in the reference runner. | | OAuth integration | Advertised issuer, authorization and token endpoints, resource metadata, and declared DCR/CIMD support match the running product. | MCP protected-resource discovery has an independent suite; authorization-server and client-registration checks still need one. | | Tenancy and identity lifecycle | Membership is rechecked, API keys stay fixed to one tenant, cross-tenant access is denied, and key revocation, invitations, and last-owner safeguards work. | Basic isolation in the reference runner; lifecycle cases remain product tests. | -| REST and OpenAPI | Runtime validation, stable safe errors, and generated OpenAPI agree with the product contract. | Reference-specific checks for `/v1/notes` and its operations. | +| REST and OpenAPI | Runtime validation, stable safe errors, and generated OpenAPI agree with the product contract; the committed `openapi.json` matches the contract. | Reference-specific checks for `/v1/notes` and its operations; the committed-document check applies to every product that declares `openapi`. | | MCP transport and parity | Anonymous requests receive an OAuth challenge; authenticated tools follow product policy and match equivalent REST capabilities; destructive actions require explicit proof. | Independent discovery suite and reference-specific tool checks. Static parity validation lives in `@codelitdev/mcp-server-kit`. | | Observability | Disabled telemetry is safe, secrets are redacted, and a failed optional sink does not fail requests or jobs. | Reference runner hooks; independent composition suite still needed. | | Billing composition | Product schema, action grants, audit hooks, fake-provider flow, and maintenance are wired correctly. | Reference runner hooks. Provider and workflow contracts live in `@codelitdev/billing/testing`. | diff --git a/packages/platform-conformance/src/adapter.ts b/packages/platform-conformance/src/adapter.ts index 0d28530..e20b643 100644 --- a/packages/platform-conformance/src/adapter.ts +++ b/packages/platform-conformance/src/adapter.ts @@ -34,8 +34,16 @@ export type PlatformConformanceAdapter< mcp?(input: McpInput): Promise; readAuditEvents(): Promise; openapiDocument?(): Promise<{ + /** Paths of the document the running API serves. */ paths: Record; + /** Operation ids from the contract, e.g. via `listOperationIds`. */ contractOperationIds: readonly string[]; + /** + * Whether the committed `openapi.json` equals the document generated from + * the contract (`generate:openapi --check` passes). Hosted docs serve the + * committed file, so a stale one publishes the wrong API. + */ + committedDocumentCurrent: boolean; }>; observability?: { redact(secret: string): Promise; diff --git a/packages/platform-conformance/src/run.test.ts b/packages/platform-conformance/src/run.test.ts index ec64293..b1e2338 100644 --- a/packages/platform-conformance/src/run.test.ts +++ b/packages/platform-conformance/src/run.test.ts @@ -8,6 +8,7 @@ type Tenant = { id: string; publicId: string }; function memoryAdapter(options: { mcp?: boolean; challenge?: string | null; + committedOpenApiCurrent?: boolean; }): PlatformConformanceAdapter< Principal, Tenant, @@ -153,6 +154,7 @@ function memoryAdapter(options: { return { paths: { "/v1/notes": { get: { operationId: "listNotes" } } }, contractOperationIds: ["listNotes", "createNote", "updateNote", "deleteNote"], + committedDocumentCurrent: options.committedOpenApiCurrent ?? true, }; }, observability: { @@ -217,6 +219,18 @@ describe("runPlatformConformance", () => { expect(result.failures).toEqual([]); }); + it("fails OpenAPI conformance when the committed document is stale", async () => { + const result = await runPlatformConformance( + memoryAdapter({ committedOpenApiCurrent: false }), + { openapi: true }, + ); + expect(result.failures).toContainEqual({ + suite: "openapi", + message: + "committed openapi.json does not match the contract; run generate:openapi", + }); + }); + it("fails MCP conformance when the unauthenticated response omits the bearer challenge", async () => { const result = await runPlatformConformance( memoryAdapter({ mcp: true, challenge: null }), diff --git a/packages/platform-conformance/src/run.ts b/packages/platform-conformance/src/run.ts index c7b7bf7..41a4192 100644 --- a/packages/platform-conformance/src/run.ts +++ b/packages/platform-conformance/src/run.ts @@ -292,16 +292,19 @@ export async function runPlatformConformance( if (capabilities.openapi) { const document = await adapter.openapiDocument!(); if (!document.paths["/v1/notes"]) { - fail("openapi", "ts-rest contract is missing /v1/notes"); + fail("openapi", "OpenAPI document is missing /v1/notes"); } - if (!document.contractOperationIds.includes("listNotes")) { - fail("openapi", "ts-rest contract is missing listNotes"); - } - for (const operationId of ["createNote", "updateNote", "deleteNote"]) { + for (const operationId of ["listNotes", "createNote", "updateNote", "deleteNote"]) { if (!document.contractOperationIds.includes(operationId)) { - fail("openapi", `ts-rest contract is missing ${operationId}`); + fail("openapi", `API contract is missing ${operationId}`); } } + if (document.committedDocumentCurrent !== true) { + fail( + "openapi", + "committed openapi.json does not match the contract; run generate:openapi", + ); + } } if (capabilities.observability) { diff --git a/packages/platform/package.json b/packages/platform/package.json index 5cdb1d3..fa1a42d 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -29,6 +29,11 @@ "types": "./dist/billing/drizzle.d.ts", "import": "./dist/billing/drizzle.js", "default": "./dist/billing/drizzle.js" + }, + "./rest": { + "types": "./dist/rest/index.d.ts", + "import": "./dist/rest/index.js", + "default": "./dist/rest/index.js" } }, "files": [ diff --git a/packages/platform/src/exports.test.ts b/packages/platform/src/exports.test.ts index a310c88..f69c973 100644 --- a/packages/platform/src/exports.test.ts +++ b/packages/platform/src/exports.test.ts @@ -9,7 +9,13 @@ describe("public exports", () => { ) as { exports: Record; }; - expect(Object.keys(pkg.exports)).toEqual([".", "./billing", "./billing/drizzle"]); + expect(Object.keys(pkg.exports)).toEqual([ + ".", + "./billing", + "./billing/drizzle", + "./rest", + ]); + expect(pkg.exports["./rest"].import).toBe("./dist/rest/index.js"); expect(pkg.exports["."].import).toBe("./dist/index.js"); expect(pkg.exports["."].types).toBe("./dist/index.d.ts"); expect(pkg.exports["./billing"].import).toBe("./dist/billing/index.js"); diff --git a/packages/platform/src/rest/index.ts b/packages/platform/src/rest/index.ts new file mode 100644 index 0000000..f12e7a1 --- /dev/null +++ b/packages/platform/src/rest/index.ts @@ -0,0 +1,286 @@ +/** + * REST helpers for products that define their HTTP API as an oRPC contract. + * + * This module has no runtime dependency on oRPC. It works on the plain shapes + * oRPC passes to its hooks, so `@codelitdev/platform` does not pin an oRPC + * version for products. + * + * - `platformErrors()` declares platform error codes on a contract route. + * - `encodePlatformErrorBody()` is the handler's `customErrorResponseBodyEncoder`. + * - `platformErrorResponseBodySchema()` is the generator's + * `customErrorResponseBodySchema`, so the document matches the runtime body. + * - `listOperationIds()` walks a contract for MCP parity and policy tables. + * - `writeOpenApiDocument()` writes or checks the committed `openapi.json`. + */ +import { + PLATFORM_ERROR_CODES, + PLATFORM_ERROR_HTTP_STATUS, + PLATFORM_ERROR_MESSAGES, + type PlatformErrorCode, + type PlatformSafeDetails, + type PublicErrorBody, +} from "../errors.js"; + +export type PlatformErrorMap = { + [K in C]: { status: number; message: string }; +}; + +/** Error map entries for `oc.errors(...)`, keyed by platform error code. */ +export function platformErrors( + ...codes: C[] +): PlatformErrorMap { + const map = {} as PlatformErrorMap; + for (const code of codes) { + map[code] = { + status: PLATFORM_ERROR_HTTP_STATUS[code], + message: PLATFORM_ERROR_MESSAGES[code], + }; + } + return map; +} + +export function isPlatformErrorCode(value: unknown): value is PlatformErrorCode { + return ( + typeof value === "string" && + (PLATFORM_ERROR_CODES as readonly string[]).includes(value) + ); +} + +/** True when `value` is a `{ code, message, details? }` platform error body. */ +export function isPublicErrorBody(value: unknown): value is PublicErrorBody { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { code?: unknown; message?: unknown }; + return isPlatformErrorCode(candidate.code) && typeof candidate.message === "string"; +} + +/** The parts of an oRPC `ORPCError` that error encoding reads. */ +export type RestErrorLike = { + code: string; + status: number; + defined: boolean; + data?: unknown; +}; + +const STATUS_FALLBACK_CODES: Record = { + 400: "validation_failed", + 401: "unauthenticated", + 403: "forbidden", + 404: "not_found", + 409: "conflict", + 413: "validation_failed", + 415: "validation_failed", + 422: "validation_failed", + 429: "rate_limited", +}; + +function readDetails(data: unknown): PlatformSafeDetails | undefined { + if (typeof data !== "object" || data === null) return undefined; + const details = (data as { details?: unknown }).details; + if (typeof details !== "object" || details === null) return undefined; + return details as PlatformSafeDetails; +} + +/** + * Builds the HTTP error body for an oRPC error. + * + * - Platform codes become `{ code, message, details? }`, with the fixed public + * message. `data.details` is passed through as `details`. + * - Other codes declared on the route (`defined: true`) return their `data` + * unchanged. Products use this for their own error bodies, for example a + * readiness report on 503. + * - Anything else, such as oRPC input validation failures or thrown + * exceptions, becomes a platform body chosen from the status. It never + * includes internal messages. + */ +export function encodePlatformErrorBody(error: RestErrorLike): unknown { + if (isPlatformErrorCode(error.code)) { + const body: PublicErrorBody = { + code: error.code, + message: PLATFORM_ERROR_MESSAGES[error.code], + }; + const details = readDetails(error.data); + if (details) body.details = details; + return body; + } + if (error.defined) return error.data; + const code = STATUS_FALLBACK_CODES[error.status] ?? "internal_error"; + return { code, message: PLATFORM_ERROR_MESSAGES[code] } satisfies PublicErrorBody; +} + +type JsonSchema = Record; + +/** oRPC passes `[code, defaultMessage, dataRequired, dataJsonSchema]`. */ +export type DefinedErrorDefinition = [ + code: string, + defaultMessage: string, + dataRequired: boolean, + dataSchema: unknown, +]; + +export function platformErrorBodyJsonSchema( + codes: readonly PlatformErrorCode[] = PLATFORM_ERROR_CODES, +): JsonSchema { + return { + type: "object", + properties: { + code: { type: "string", enum: [...codes] }, + message: { type: "string" }, + details: { + type: "object", + additionalProperties: { + anyOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + { type: "null" }, + ], + }, + }, + }, + required: ["code", "message"], + }; +} + +/** + * Documents the body `encodePlatformErrorBody` sends for one status. Platform + * codes share one schema with a `code` enum; other declared codes use their + * data schema. + */ +export function platformErrorResponseBodySchema( + definitions: readonly DefinedErrorDefinition[], + _status: number, +): JsonSchema { + const platformCodes = definitions.map(([code]) => code).filter(isPlatformErrorCode); + const variants: JsonSchema[] = []; + if (platformCodes.length > 0) { + variants.push(platformErrorBodyJsonSchema(platformCodes)); + } + for (const [code, , , dataSchema] of definitions) { + if (isPlatformErrorCode(code)) continue; + variants.push( + typeof dataSchema === "object" && dataSchema !== null + ? (dataSchema as JsonSchema) + : {}, + ); + } + if (variants.length === 1) return variants[0] as JsonSchema; + return { oneOf: variants }; +} + +/** Walks a nested contract and returns dot-path ids, e.g. `notes.list`. */ +export function listOperationIds( + router: unknown, + prefix: readonly string[] = [], +): string[] { + if (typeof router !== "object" || router === null) return []; + if ("~orpc" in router) return [prefix.join(".")]; + const ids: string[] = []; + for (const [key, value] of Object.entries(router)) { + ids.push(...listOperationIds(value, [...prefix, key])); + } + return ids; +} + +const NO_BODY_STATUSES = new Set(["204", "205", "304"]); +const STATUS_DESCRIPTIONS: Record = { + "200": "OK", + "201": "Created", + "202": "Accepted", + "204": "No Content", + "400": "Bad Request", + "401": "Unauthorized", + "403": "Forbidden", + "404": "Not Found", + "409": "Conflict", + "413": "Content Too Large", + "422": "Unprocessable Content", + "429": "Too Many Requests", + "500": "Internal Server Error", + "503": "Service Unavailable", +}; + +/** + * Tidies a generated document in place and returns it: responses that cannot + * carry a body (204, 205, 304) lose their content, and generic descriptions + * ("OK" on a 201, "404" on a 404) become the HTTP status text. + */ +export function normalizeOpenApiDocument(document: T): T { + const paths = (document as { paths?: Record> }).paths; + for (const operations of Object.values(paths ?? {})) { + for (const operation of Object.values(operations)) { + const responses = (operation as { responses?: Record }) + ?.responses; + for (const [status, response] of Object.entries(responses ?? {})) { + if (NO_BODY_STATUSES.has(status)) delete response.content; + const text = STATUS_DESCRIPTIONS[status]; + if ( + text && + (response.description === status || response.description === "OK") + ) { + response.description = text; + } + } + } + } + return document; +} + +/** Stable text for a committed OpenAPI document: 2-space JSON plus newline. */ +export function serializeOpenApiDocument(document: unknown): string { + return `${JSON.stringify(document, null, 2)}\n`; +} + +export type OpenApiWriteResult = "written" | "current" | "stale" | "missing"; + +/** + * Writes `document` to `file`, or with `check: true` compares without + * writing. Check mode returns "stale" or "missing" so the caller can fail CI. + */ +export async function writeOpenApiDocument(input: { + document: unknown; + file: string; + check?: boolean; +}): Promise { + // Imported lazily so contract packages that browsers bundle can import + // this module without pulling in node:fs. + const { readFile, writeFile } = await import("node:fs/promises"); + const next = serializeOpenApiDocument(input.document); + let current: string | undefined; + try { + current = await readFile(input.file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (current === next) return "current"; + if (input.check) return current === undefined ? "missing" : "stale"; + await writeFile(input.file, next); + return "written"; +} + +/** + * Body of a product's `generate:openapi` script. Pass `--check` to fail + * (exit code 1) when the committed file differs from the contract. + */ +export async function runOpenApiScript(input: { + document: unknown; + file: string; + argv?: readonly string[]; + log?: (line: string) => void; +}): Promise { + const argv = input.argv ?? process.argv.slice(2); + const log = input.log ?? ((line: string) => console.log(line)); + const result = await writeOpenApiDocument({ + document: input.document, + file: input.file, + check: argv.includes("--check"), + }); + if (result === "stale" || result === "missing") { + log( + `${input.file} is ${result === "missing" ? "missing" : "out of date"}. Run \`bun run generate:openapi\` and commit the result.`, + ); + process.exitCode = 1; + } else { + log(`${input.file} is ${result === "written" ? "updated" : "current"}.`); + } + return result; +} diff --git a/packages/platform/src/rest/rest.test.ts b/packages/platform/src/rest/rest.test.ts new file mode 100644 index 0000000..19276ca --- /dev/null +++ b/packages/platform/src/rest/rest.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + encodePlatformErrorBody, + isPublicErrorBody, + listOperationIds, + normalizeOpenApiDocument, + platformErrorResponseBodySchema, + platformErrors, + writeOpenApiDocument, +} from "./index.js"; + +describe("platformErrors", () => { + it("maps codes to their platform status and public message", () => { + expect(platformErrors("unauthenticated", "not_found")).toEqual({ + unauthenticated: { status: 401, message: "Authentication is required." }, + not_found: { status: 404, message: "The requested resource was not found." }, + }); + }); +}); + +describe("encodePlatformErrorBody", () => { + it("uses the fixed public message and passes safe details", () => { + expect( + encodePlatformErrorBody({ + code: "conflict", + status: 409, + defined: true, + data: { details: { field: "title" } }, + }), + ).toEqual({ + code: "conflict", + message: "The request conflicts with current state.", + details: { field: "title" }, + }); + }); + + it("returns data unchanged for product-defined codes", () => { + const report = { status: "not_ready", checks: { database: false } }; + expect( + encodePlatformErrorBody({ + code: "not_ready", + status: 503, + defined: true, + data: report, + }), + ).toBe(report); + }); + + it("maps undefined oRPC errors by status without leaking internals", () => { + expect( + encodePlatformErrorBody({ + code: "BAD_REQUEST", + status: 400, + defined: false, + data: { issues: ["secret"] }, + }), + ).toEqual({ code: "validation_failed", message: "The request is invalid." }); + expect( + encodePlatformErrorBody({ + code: "INTERNAL_SERVER_ERROR", + status: 500, + defined: false, + }), + ).toEqual({ code: "internal_error", message: "An internal error occurred." }); + expect( + encodePlatformErrorBody({ + code: "not_ready", + status: 503, + defined: false, + data: {}, + }), + ).toEqual({ code: "internal_error", message: "An internal error occurred." }); + }); +}); + +describe("platformErrorResponseBodySchema", () => { + it("documents platform codes as one schema with a code enum", () => { + const schema = platformErrorResponseBodySchema( + [ + ["forbidden", "x", false, undefined], + ["tenant_forbidden", "x", false, undefined], + ], + 403, + ); + expect((schema.properties as { code: { enum: string[] } }).code.enum).toEqual([ + "forbidden", + "tenant_forbidden", + ]); + }); + + it("uses the data schema for product-defined codes", () => { + const data = { type: "object", properties: { status: { const: "not_ready" } } }; + expect(platformErrorResponseBodySchema([["not_ready", "x", true, data]], 503)).toBe( + data, + ); + }); +}); + +describe("listOperationIds", () => { + it("returns dot paths for nested contract procedures", () => { + const procedure = { "~orpc": {} }; + expect( + listOperationIds({ health: procedure, settings: { esps: { list: procedure } } }), + ).toEqual(["health", "settings.esps.list"]); + }); +}); + +describe("isPublicErrorBody", () => { + it("accepts platform bodies only", () => { + expect(isPublicErrorBody({ code: "not_found", message: "m" })).toBe(true); + expect(isPublicErrorBody({ code: "nope", message: "m" })).toBe(false); + expect(isPublicErrorBody({ error: "x" })).toBe(false); + }); +}); + +describe("normalizeOpenApiDocument", () => { + it("drops 204 bodies and replaces generic descriptions", () => { + const document = normalizeOpenApiDocument({ + paths: { + "/x": { + post: { + responses: { + "201": { description: "OK", content: {} }, + "204": { description: "OK", content: { "application/json": {} } }, + "404": { description: "404" }, + "503": { description: "Readiness report" }, + }, + }, + }, + }, + }); + expect(document.paths["/x"].post.responses).toEqual({ + "201": { description: "Created", content: {} }, + "204": { description: "No Content" }, + "404": { description: "Not Found" }, + "503": { description: "Readiness report" }, + } as never); + }); +}); + +describe("writeOpenApiDocument", () => { + it("writes, then reports current, and check mode reports stale or missing", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "openapi-")); + const file = path.join(dir, "openapi.json"); + expect(await writeOpenApiDocument({ document: { a: 1 }, file, check: true })).toBe( + "missing", + ); + expect(await writeOpenApiDocument({ document: { a: 1 }, file })).toBe("written"); + expect(await readFile(file, "utf8")).toBe('{\n "a": 1\n}\n'); + expect(await writeOpenApiDocument({ document: { a: 1 }, file, check: true })).toBe( + "current", + ); + await writeFile(file, "{}\n"); + expect(await writeOpenApiDocument({ document: { a: 1 }, file, check: true })).toBe( + "stale", + ); + expect(await readFile(file, "utf8")).toBe("{}\n"); + }); +}); diff --git a/patches/@anatine__zod-openapi@1.14.2.patch b/patches/@anatine__zod-openapi@1.14.2.patch deleted file mode 100644 index 091b135..0000000 --- a/patches/@anatine__zod-openapi@1.14.2.patch +++ /dev/null @@ -1,14 +0,0 @@ -diff --git a/src/lib/zod-openapi.js b/src/lib/zod-openapi.js -index cc71afb4d872b755908f03b9cf441112240405de..20b60b6e9a2f586f4d527eeecfff982909c46428 100644 ---- a/src/lib/zod-openapi.js -+++ b/src/lib/zod-openapi.js -@@ -2,6 +2,9 @@ - Object.defineProperty(exports, "__esModule", { value: true }); - exports.generateSchema = exports.extendApi = void 0; - const ts_deepmerge_1 = require("ts-deepmerge"); -+// ts-deepmerge 8 exposes its CommonJS API as a named `merge` export. -+// Preserve compatibility with this package's generated default-import calls. -+ts_deepmerge_1.default ??= ts_deepmerge_1.merge; - const zod_1 = require("zod"); - function extendApi(schema, SchemaObject = {}) { - schema.metaOpenApi = Object.assign(schema.metaOpenApi || {}, SchemaObject); diff --git a/templates/saas-product/.github/workflows/code-quality.yml b/templates/saas-product/.github/workflows/code-quality.yml index 0639e69..0813489 100644 --- a/templates/saas-product/.github/workflows/code-quality.yml +++ b/templates/saas-product/.github/workflows/code-quality.yml @@ -26,6 +26,9 @@ jobs: if: ${{ !hashFiles('bun.lock') }} - run: bun install --frozen-lockfile - run: bun run lint + # Hosted docs serve the committed apps/api/openapi.json; fail when the + # contract changed without regenerating it (`bun run generate:openapi`). + - run: bun run check:openapi - run: bun run typecheck - run: bun run test - run: bun run build diff --git a/templates/saas-product/apps/api/README.md b/templates/saas-product/apps/api/README.md index 5865411..a5fab01 100644 --- a/templates/saas-product/apps/api/README.md +++ b/templates/saas-product/apps/api/README.md @@ -2,5 +2,31 @@ Product-owned application services live here. -Open the interactive Swagger UI at `GET /docs`, or fetch the generated OpenAPI -document directly from `GET /openapi.json`. +## REST contract and OpenAPI + +The REST API is defined as an oRPC contract in `packages/api-contract`. The +contract decides validation, status codes, and the OpenAPI document. Declare +platform errors on each route with `platformErrors(...)` from +`@codelitdev/platform/rest` so the runtime body and the documented body are +both `{ code, message, details? }`. + +Open the interactive Swagger UI at `GET /docs`, or fetch the running API's +document from `GET /openapi.json`. + +### Committed `openapi.json` + +`apps/api/openapi.json` is committed. Hosted docs serve this file, not the +running API, so it must match the contract of the release it ships with. + +- `bun run generate:openapi` (here or at the repository root) rewrites it from + the contract. It does not start the server or need a database. +- `bun run check:openapi` fails when the file is stale. It is part of + `check:drift` and runs in CI, so a contract change without the regenerated + file cannot merge. +- Your release version step must run `bun run generate:openapi` so the release + commit carries the document for that version. With changesets, for example: + `"version-packages": "changeset version && bun run generate:openapi"`. + +`info.version` comes from this package's `version`, and the server URL comes +from `OPENAPI_PUBLIC_SERVER_URL` in `src/openapi.ts`. Set it to your +production API URL. diff --git a/templates/saas-product/apps/api/openapi.json b/templates/saas-product/apps/api/openapi.json new file mode 100644 index 0000000..81d3e30 --- /dev/null +++ b/templates/saas-product/apps/api/openapi.json @@ -0,0 +1,3563 @@ +{ + "info": { + "title": "__PRODUCT_NAME__ API", + "version": "0.0.0" + }, + "servers": [ + { + "url": "https://api.__PRODUCT_SLUG__.example" + } + ], + "openapi": "3.1.1", + "paths": { + "/health": { + "get": { + "operationId": "health", + "summary": "Liveness", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "const": "ok" + }, + "service": { + "type": "string" + }, + "time": { + "type": "string" + } + }, + "required": [ + "status", + "service", + "time" + ] + } + } + } + } + } + } + }, + "/ready": { + "get": { + "operationId": "ready", + "summary": "Readiness", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "enum": [ + "ready", + "not_ready" + ], + "type": "string" + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "required": [ + "status", + "checks" + ] + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "enum": [ + "ready", + "not_ready" + ], + "type": "string" + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "required": [ + "status", + "checks" + ] + } + } + } + } + } + } + }, + "/v1/notes": { + "get": { + "operationId": "listNotes", + "summary": "List notes", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "title", + "body", + "createdAt", + "updatedAt" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + }, + "post": { + "operationId": "createNote", + "summary": "Create a note", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "body": { + "default": "", + "type": "string", + "maxLength": 8000 + } + }, + "required": [ + "title" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "title", + "body", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/notes/{noteId}": { + "patch": { + "operationId": "updateNote", + "summary": "Update a note", + "parameters": [ + { + "name": "noteId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "body": { + "type": "string", + "maxLength": 8000 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "title", + "body", + "createdAt", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + }, + "delete": { + "operationId": "deleteNote", + "summary": "Delete a note", + "parameters": [ + { + "name": "noteId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/billing/entitlement": { + "get": { + "operationId": "getEntitlement", + "summary": "Billing entitlement", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenantId": { + "type": "string" + }, + "activePaidPlan": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entitled": { + "type": "boolean" + }, + "subscriptionStatus": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "tenantId", + "activePaidPlan", + "entitled", + "subscriptionStatus" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/tenants": { + "get": { + "operationId": "listTenants", + "summary": "List tenants", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + }, + "required": [ + "id", + "name" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + }, + "post": { + "operationId": "createTenant", + "summary": "Create a tenant", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + }, + "required": [ + "id", + "name" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/api/tenant/select": { + "post": { + "operationId": "selectTenant", + "summary": "Select the session tenant", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenantId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "tenantId" + ] + } + } + } + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/api-keys": { + "get": { + "operationId": "listApiKeys", + "summary": "List API keys", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "publicId": { + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "revokedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "publicId" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + }, + "post": { + "operationId": "createApiKey", + "summary": "Create an API key", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permissions": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "publicId": { + "type": "string" + }, + "raw": { + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "revokedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "publicId", + "raw" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/api-keys/{publicId}": { + "delete": { + "operationId": "revokeApiKey", + "summary": "Revoke an API key", + "parameters": [ + { + "name": "publicId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/invitations": { + "post": { + "operationId": "createInvitation", + "summary": "Invite a member", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "role": { + "type": "string", + "minLength": 1 + }, + "permissions": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "email", + "role" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "const": true + }, + "id": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "ok", + "id" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/invitations/accept": { + "post": { + "operationId": "acceptInvitation", + "summary": "Accept an invitation", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 1 + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "token", + "email" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "const": true + }, + "tenantId": { + "type": "string" + } + }, + "required": [ + "ok", + "tenantId" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "validation_failed" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + }, + "/v1/invitations/{invitationId}": { + "delete": { + "operationId": "revokeInvitation", + "summary": "Revoke an invitation", + "parameters": [ + { + "name": "invitationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "credential_ambiguous", + "tenant_required" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "unauthenticated" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "forbidden", + "tenant_forbidden" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "rate_limited" + ] + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "code", + "message" + ] + } + } + } + } + } + } + } + } +} diff --git a/templates/saas-product/apps/api/package.json b/templates/saas-product/apps/api/package.json index 3d020ad..e7c343d 100644 --- a/templates/saas-product/apps/api/package.json +++ b/templates/saas-product/apps/api/package.json @@ -16,27 +16,31 @@ "generate:auth-schema": "bun scripts/generate-auth-schema.mjs", "check:billing-schema": "codelit-billing generate --check --config billing.config.ts", "check:auth-schema": "bun scripts/generate-auth-schema.mjs --check", - "check:drift": "bun run check:billing-schema && bun run check:auth-schema", + "generate:openapi": "bun scripts/generate-openapi.ts", + "check:openapi": "bun scripts/generate-openapi.ts --check", + "check:drift": "bun run check:billing-schema && bun run check:auth-schema && bun run check:openapi", "migrate": "bun scripts/migrate.mjs", "pretest": "bun run check:auth-schema" }, "dependencies": { + "@__PRODUCT_SLUG__/api-contract": "workspace:*", "@better-auth/core": "1.7.7", - "@codelitdev/billing": "workspace:*", - "@codelitdev/oauth-server-kit": "workspace:*", - "@codelitdev/mcp-server-kit": "workspace:*", - "@codelitdev/platform-conformance": "workspace:*", - "@codelitdev/observability": "workspace:*", "@better-auth/oauth-provider": "1.7.7", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", - "@ts-rest/express": "3.52.1", - "@ts-rest/open-api": "3.52.1", + "@codelitdev/billing": "workspace:*", + "@codelitdev/mcp-server-kit": "workspace:*", + "@codelitdev/oauth-server-kit": "workspace:*", + "@codelitdev/observability": "workspace:*", "@codelitdev/platform": "workspace:*", - "@__PRODUCT_SLUG__/api-contract": "workspace:*", + "@codelitdev/platform-conformance": "workspace:*", + "@orpc/contract": "1.16.1", + "@orpc/openapi": "1.16.1", + "@orpc/server": "1.16.1", + "@orpc/zod": "1.16.1", "@types/pg": "8.23.1", - "better-call": "1.4.0", "better-auth": "1.7.7", + "better-call": "1.4.0", "drizzle-orm": "0.45.2", "express": "4.21.2", "pg": "8.23.0", diff --git a/templates/saas-product/apps/api/scripts/generate-openapi.ts b/templates/saas-product/apps/api/scripts/generate-openapi.ts new file mode 100644 index 0000000..ffaba13 --- /dev/null +++ b/templates/saas-product/apps/api/scripts/generate-openapi.ts @@ -0,0 +1,12 @@ +// Writes the committed apps/api/openapi.json from the API contract. +// `--check` fails instead of writing; CI runs it on every change, and the +// release version step runs it without `--check` so each release commits the +// document hosted docs serve. +import path from "node:path"; +import { runOpenApiScript } from "@codelitdev/platform/rest"; +import { createOpenApiDocument } from "../src/openapi.js"; + +await runOpenApiScript({ + document: await createOpenApiDocument(), + file: path.resolve(import.meta.dirname, "../openapi.json"), +}); diff --git a/templates/saas-product/apps/api/src/app.test.ts b/templates/saas-product/apps/api/src/app.test.ts index e82c760..a9a6319 100644 --- a/templates/saas-product/apps/api/src/app.test.ts +++ b/templates/saas-product/apps/api/src/app.test.ts @@ -1,5 +1,6 @@ import { contract } from "@__PRODUCT_SLUG__/api-contract"; import { describe, expect, it } from "bun:test"; +import { listOperationIds } from "@codelitdev/platform/rest"; import { eq } from "drizzle-orm"; import { createReferenceAuth } from "./auth/better-auth.js"; import { composeOAuthProviderOptions } from "./auth/oauth.js"; @@ -111,6 +112,72 @@ describe.serial("reference API adapters", () => { await runtime.close(); }); + it("serves success statuses, 204 bodies, and validated output over HTTP", async () => { + const clock = freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")); + const runtime = await createPgliteRuntime({ clock }); + const world = await seedWorld(runtime, clock); + const app = createExpressApp(runtime); + const server = await new Promise>((resolve) => { + const listening = app.listen(0, () => resolve(listening)); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("listen_failed"); + const base = `http://127.0.0.1:${address.port}`; + const headers = { + "content-type": "application/json", + cookie: world.owner.sessionCookie, + "x-tenant-id": world.tenantA.publicId, + }; + try { + const created = await fetch(`${base}/v1/notes`, { + method: "POST", + headers, + body: JSON.stringify({ title: "Over HTTP" }), + }); + expect(created.status).toBe(201); + const note = (await created.json()) as { id: string; body: string }; + expect(note.body).toBe(""); + + const updated = await fetch(`${base}/v1/notes/${note.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ title: "Renamed" }), + }); + expect(updated.status).toBe(200); + expect(await updated.json()).toMatchObject({ id: note.id, title: "Renamed" }); + + const key = await fetch(`${base}/v1/api-keys`, { + method: "POST", + headers, + body: JSON.stringify({ permissions: [] }), + }); + expect(key.status).toBe(201); + const { publicId } = (await key.json()) as { publicId: string }; + const revoked = await fetch(`${base}/v1/api-keys/${publicId}`, { + method: "DELETE", + headers, + }); + expect(revoked.status).toBe(204); + expect(await revoked.text()).toBe(""); + + const missing = await fetch(`${base}/v1/notes/does-not-exist`, { + method: "DELETE", + headers, + }); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ + code: "not_found", + message: "The requested resource was not found.", + }); + + const unknownRoute = await fetch(`${base}/v1/unknown`, { headers }); + expect(unknownRoute.status).toBe(404); + } finally { + await new Promise((resolve) => server.close(resolve)); + await runtime.close(); + } + }); + it("supports one-time API-key creation, metadata listing, scoped revocation, and expiry", async () => { const clock = freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")); const runtime = await createPgliteRuntime({ clock }); @@ -304,7 +371,7 @@ describe.serial("reference API adapters", () => { const { createReferenceMcp } = await import("./mcp.js"); const mcp = createReferenceMcp(runtime); const issues = validateParityManifest([...mcpParityManifest], { - restOperationIds: new Set(Object.keys(contract)), + restOperationIds: new Set(listOperationIds(contract)), mcpToolNames: new Set(mcp.listTools().map((tool) => tool.name)), mcpToolRisks: new Map( mcp.listTools().map((tool) => [tool.name, tool.risk] as const), @@ -497,7 +564,7 @@ describe.serial("reference API adapters", () => { await runtime.close(); }); - it("mounts the shared contract through the Express ts-rest adapter", async () => { + it("serves the oRPC contract through Express with platform error bodies", async () => { const runtime = await createPgliteRuntime({ clock: freezeRuntimeClock(new Date("2026-03-01T00:00:00.000Z")), }); @@ -522,7 +589,27 @@ describe.serial("reference API adapters", () => { `http://127.0.0.1:${address.port}/docs/swagger-ui-init.js`, ); expect(docsInitializer.status).toBe(200); - expect(await docsInitializer.text()).toContain('"/v1/notes"'); + expect(await docsInitializer.text()).toContain('"/openapi.json"'); + const base = `http://127.0.0.1:${address.port}`; + const unauthenticated = await fetch(`${base}/v1/notes`); + expect(unauthenticated.status).toBe(401); + expect(await unauthenticated.json()).toEqual({ + code: "unauthenticated", + message: "Authentication is required.", + }); + const invalid = await fetch(`${base}/v1/notes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "" }), + }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toEqual({ + code: "validation_failed", + message: "The request is invalid.", + }); + const ready = await fetch(`${base}/ready`); + expect(ready.status).toBe(200); + expect(await ready.json()).toMatchObject({ status: "ready" }); const protectedResource = await fetch( `http://127.0.0.1:${address.port}/.well-known/oauth-protected-resource/mcp`, ); diff --git a/templates/saas-product/apps/api/src/conformance-adapter.ts b/templates/saas-product/apps/api/src/conformance-adapter.ts index 33181e3..201d39f 100644 --- a/templates/saas-product/apps/api/src/conformance-adapter.ts +++ b/templates/saas-product/apps/api/src/conformance-adapter.ts @@ -1,10 +1,13 @@ import { contract } from "@__PRODUCT_SLUG__/api-contract"; +import { readFile } from "node:fs/promises"; import { createObservability, redactText } from "@codelitdev/observability"; +import { listOperationIds, serializeOpenApiDocument } from "@codelitdev/platform/rest"; import { runPlatformConformance } from "@codelitdev/platform-conformance"; import { eq } from "drizzle-orm"; import { runBoundedMaintenance } from "./billing.js"; import { mcpFor } from "./dispatch.js"; import { createExpressApp } from "./express-app.js"; +import { createOpenApiDocument } from "./openapi.js"; import type { Runtime } from "./runtime.js"; import type { SeededWorld } from "./seed.js"; @@ -156,8 +159,16 @@ export function createReferenceConformanceAdapter( headers: {}, }); const document = response.body as { paths: Record }; - const contractOperationIds = Object.keys(contract); - return { paths: document.paths, contractOperationIds }; + const generated = await createOpenApiDocument(); + const committed = await readFile( + new URL("../openapi.json", import.meta.url), + "utf8", + ).catch(() => undefined); + return { + paths: document.paths, + contractOperationIds: listOperationIds(contract), + committedDocumentCurrent: committed === serializeOpenApiDocument(generated), + }; }, observability: { async redact(secret: string) { diff --git a/templates/saas-product/apps/api/src/dispatch.ts b/templates/saas-product/apps/api/src/dispatch.ts index 882b453..cec7115 100644 --- a/templates/saas-product/apps/api/src/dispatch.ts +++ b/templates/saas-product/apps/api/src/dispatch.ts @@ -75,7 +75,7 @@ export async function dispatch( if (request.method === "GET" && path === "/openapi.json") { return { status: 200, - body: createOpenApiDocument(deps.auth.publicApiUrl), + body: await createOpenApiDocument(deps.auth.publicApiUrl), }; } diff --git a/templates/saas-product/apps/api/src/express-app.ts b/templates/saas-product/apps/api/src/express-app.ts index 0a5c1b4..2a33f81 100644 --- a/templates/saas-product/apps/api/src/express-app.ts +++ b/templates/saas-product/apps/api/src/express-app.ts @@ -1,15 +1,24 @@ import { contract } from "@__PRODUCT_SLUG__/api-contract"; import { createOAuthPagesRouter } from "@codelitdev/oauth-server-kit/express"; import { createMcpOAuthDiscoveryRoutes } from "@codelitdev/oauth-server-kit/mcp"; -import { readOrCreateRequestId } from "@codelitdev/platform"; -import { createExpressEndpoints, initServer } from "@ts-rest/express"; +import { + healthReport, + readinessReport, + readOrCreateRequestId, +} from "@codelitdev/platform"; +import { + encodePlatformErrorBody, + isPublicErrorBody, + listOperationIds, +} from "@codelitdev/platform/rest"; +import { OpenAPIHandler } from "@orpc/openapi/node"; +import { implement, ORPCError, onError } from "@orpc/server"; import { toNodeHandler } from "better-auth/node"; import express, { type Express, type RequestHandler } from "express"; import swaggerUi from "swagger-ui-express"; import { AUTH_BASE_PATH, MCP_SCOPES_SUPPORTED } from "./auth/options.js"; import type { DispatchDeps } from "./deps.js"; import { dispatch } from "./dispatch.js"; -import { createOpenApiDocument } from "./openapi.js"; export function createExpressApp(deps: DispatchDeps): Express { const app = express(); @@ -26,7 +35,7 @@ export function createExpressApp(deps: DispatchDeps): Express { deps.clock, ); // Keep the generated correlation ID on the request as well as the - // response so ts-rest and the fallback dispatcher audit the same value. + // response so the oRPC handler and the fallback dispatcher audit the same value. req.headers["x-request-id"] = requestId; res.setHeader("X-Request-ID", requestId); const now = Date.now(); @@ -71,8 +80,9 @@ export function createExpressApp(deps: DispatchDeps): Express { app.use( "/docs", ...(swaggerUi.serve as unknown as RequestHandler[]), - swaggerUi.setup(createOpenApiDocument(deps.auth.publicApiUrl), { + swaggerUi.setup(undefined, { explorer: true, + swaggerUrl: "/openapi.json", swaggerOptions: { persistAuthorization: true, displayRequestDuration: true, @@ -83,55 +93,7 @@ export function createExpressApp(deps: DispatchDeps): Express { ); app.use(express.json({ limit: "32kb" })); - const server = initServer(); - const forward = async (input: { - req: { - method: string; - path: string; - headers: Record; - body?: unknown; - }; - res: { setHeader(name: string, value: string): void }; - }) => { - const response = await dispatch(deps, { - method: input.req.method, - path: input.req.path, - headers: input.req.headers, - body: input.req.body, - }); - for (const [name, value] of Object.entries(response.headers ?? {})) { - input.res.setHeader(name, value); - } - return { status: response.status, body: response.body } as never; - }; - const router = server.router(contract, { - health: forward, - ready: forward, - listNotes: forward, - createNote: forward, - updateNote: forward, - deleteNote: forward, - getEntitlement: forward, - listTenants: forward, - createTenant: forward, - selectTenant: forward, - listApiKeys: forward, - createApiKey: forward, - revokeApiKey: forward, - createInvitation: forward, - acceptInvitation: forward, - revokeInvitation: forward, - } as never); - createExpressEndpoints(contract, router, app, { - logInitialization: false, - responseValidation: true, - requestValidationErrorHandler: (_error, _req, res) => { - res.status(400).json({ - code: "validation_failed", - message: "The request body is invalid.", - }); - }, - }); + app.use(createRestHandler(deps)); app.use(async (req, res, next) => { try { const requestId = readOrCreateRequestId( @@ -186,3 +148,71 @@ export function createExpressApp(deps: DispatchDeps): Express { ); return app; } + +type RestContext = { req: express.Request; res: express.Response }; + +/** + * Serves the oRPC contract. oRPC validates input and output against the + * contract; the handlers forward to `dispatch`, which owns authentication, + * tenancy and the product logic for both REST and the fallback routes. + * Requests the contract does not match fall through to the next middleware. + */ +function createRestHandler(deps: DispatchDeps): RequestHandler { + const os = implement(contract).$context(); + const forward = async ({ context }: { context: RestContext }) => { + const { req, res } = context; + const response = await dispatch(deps, { + method: req.method, + path: req.path, + headers: req.headers as Record, + body: req.body, + }); + for (const [name, value] of Object.entries(response.headers ?? {})) { + res.setHeader(name, value); + } + if (response.status >= 400) { + if (!isPublicErrorBody(response.body)) { + throw new Error(`dispatch returned a non-platform error (${response.status})`); + } + throw new ORPCError(response.body.code, { + status: response.status, + data: { details: response.body.details }, + }); + } + return (response.body ?? undefined) as never; + }; + const handlers: Record = {}; + for (const id of listOperationIds(contract)) { + handlers[id] = os[id as keyof typeof contract].handler(forward as never); + } + handlers.health = os.health.handler(() => healthReport(deps.serviceName, deps.clock)); + handlers.ready = os.ready.handler(() => { + const report = readinessReport([{ name: "database", ready: deps.databaseReady }]); + if (report.status !== "ready") { + throw new ORPCError("not_ready", { status: 503, data: report }); + } + return report; + }); + const router = os.router(handlers as never); + const handler = new OpenAPIHandler(router, { + customErrorResponseBodyEncoder: encodePlatformErrorBody, + interceptors: [ + // Expected 4xx results are thrown as ORPCError; report only failures, + // including output that does not match the contract. + onError((error) => { + if (error instanceof ORPCError && error.status < 500) return; + deps.observability?.captureException({ error, source: "rest" }); + }), + ], + }); + return async (req, res, next) => { + try { + const { matched } = await handler.handle(req, res, { + context: { req, res }, + }); + if (!matched) next(); + } catch (error) { + next(error); + } + }; +} diff --git a/templates/saas-product/apps/api/src/openapi.ts b/templates/saas-product/apps/api/src/openapi.ts index 7179400..a14d1da 100644 --- a/templates/saas-product/apps/api/src/openapi.ts +++ b/templates/saas-product/apps/api/src/openapi.ts @@ -1,12 +1,36 @@ import { contract } from "@__PRODUCT_SLUG__/api-contract"; -import { generateOpenApi } from "@ts-rest/open-api"; +import { + normalizeOpenApiDocument, + platformErrorResponseBodySchema, +} from "@codelitdev/platform/rest"; +import { OpenAPIGenerator } from "@orpc/openapi"; +import { ZodToJsonSchemaConverter } from "@orpc/zod"; +import packageJson from "../package.json" with { type: "json" }; -export function createOpenApiDocument(publicApiUrl: string) { - return generateOpenApi(contract, { +/** + * The server URL written into the committed `openapi.json`. Hosted docs serve + * that file, so it names the production API, not the local one. + */ +export const OPENAPI_PUBLIC_SERVER_URL = "https://api.__PRODUCT_SLUG__.example"; + +const generator = new OpenAPIGenerator({ + schemaConverters: [new ZodToJsonSchemaConverter()], +}); + +/** + * Builds the OpenAPI document from the contract alone, without starting the + * server, so release scripts can generate it. + */ +export async function createOpenApiDocument( + serverUrl: string = OPENAPI_PUBLIC_SERVER_URL, +) { + const document = await generator.generate(contract, { info: { title: "__PRODUCT_NAME__ API", - version: "1.0.0", + version: packageJson.version, }, - servers: [{ url: publicApiUrl }], + servers: [{ url: serverUrl }], + customErrorResponseBodySchema: platformErrorResponseBodySchema, }); + return normalizeOpenApiDocument(document); } diff --git a/templates/saas-product/apps/api/tsconfig.json b/templates/saas-product/apps/api/tsconfig.json index 9212c92..967b4ce 100644 --- a/templates/saas-product/apps/api/tsconfig.json +++ b/templates/saas-product/apps/api/tsconfig.json @@ -6,9 +6,10 @@ "strict": true, "skipLibCheck": true, "esModuleInterop": true, + "resolveJsonModule": true, "noEmit": true, "rootDir": ".", "types": ["node", "bun"] }, - "include": ["src", "billing.config.ts"] + "include": ["src", "scripts/*.ts", "billing.config.ts"] } diff --git a/templates/saas-product/biome.template.json b/templates/saas-product/biome.template.json index 19f6631..36a7b25 100644 --- a/templates/saas-product/biome.template.json +++ b/templates/saas-product/biome.template.json @@ -13,7 +13,8 @@ "!**/dist/**", "!**/migrations/**", "!**/node_modules/**", - "!**/*.generated.ts" + "!**/*.generated.ts", + "!**/apps/api/openapi.json" ] }, "formatter": { diff --git a/templates/saas-product/package.json b/templates/saas-product/package.json index 5ccba4a..e46d98b 100644 --- a/templates/saas-product/package.json +++ b/templates/saas-product/package.json @@ -17,11 +17,7 @@ "overrides": { "esbuild": "0.28.2", "postcss": "8.5.28", - "qs": "6.16.0", - "ts-deepmerge": "8.0.0" - }, - "patchedDependencies": { - "@anatine/zod-openapi@1.14.2": "patches/@anatine__zod-openapi@1.14.2.patch" + "qs": "6.16.0" }, "devDependencies": { "@biomejs/biome": "2.5.12", @@ -34,6 +30,8 @@ "typecheck": "bun run --workspaces --if-present typecheck", "test": "bun run --workspaces --if-present test", "build": "bun run --workspaces --if-present build", - "migrate": "bun apps/api/scripts/migrate.mjs" + "migrate": "bun apps/api/scripts/migrate.mjs", + "generate:openapi": "bun run --filter '*/api' generate:openapi", + "check:openapi": "bun run --filter '*/api' check:openapi" } } diff --git a/templates/saas-product/packages/api-contract/package.json b/templates/saas-product/packages/api-contract/package.json index bc8dc98..ed72c72 100644 --- a/templates/saas-product/packages/api-contract/package.json +++ b/templates/saas-product/packages/api-contract/package.json @@ -14,7 +14,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@ts-rest/core": "3.52.1", + "@codelitdev/platform": "workspace:*", + "@orpc/contract": "1.16.1", "zod": "3.25.76" }, "devDependencies": { diff --git a/templates/saas-product/packages/api-contract/src/index.ts b/templates/saas-product/packages/api-contract/src/index.ts index a2f4a25..e8b57c7 100644 --- a/templates/saas-product/packages/api-contract/src/index.ts +++ b/templates/saas-product/packages/api-contract/src/index.ts @@ -1,8 +1,7 @@ -import { initContract } from "@ts-rest/core"; +import { platformErrors } from "@codelitdev/platform/rest"; +import { oc } from "@orpc/contract"; import { z } from "zod"; -const c = initContract(); - export const platformErrorSchema = z.object({ code: z.enum([ "unauthenticated", @@ -79,178 +78,149 @@ export const invitationSchema = z.object({ token: z.string().optional(), }); -export const contract = c.router({ - health: { - method: "GET", - path: "/health", - responses: { 200: healthResponseSchema }, - summary: "Liveness", - }, - ready: { - method: "GET", - path: "/ready", - responses: { 200: readinessResponseSchema, 503: readinessResponseSchema }, - summary: "Readiness", - }, - listNotes: { - method: "GET", - path: "/v1/notes", - responses: { - 200: z.object({ items: z.array(noteSchema) }), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - createNote: { - method: "POST", - path: "/v1/notes", - body: createNoteBodySchema, - responses: { - 201: noteSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - updateNote: { - method: "PATCH", - path: "/v1/notes/:noteId", - pathParams: z.object({ noteId: z.string() }), - body: updateNoteBodySchema, - responses: { - 200: noteSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, - deleteNote: { - method: "DELETE", - path: "/v1/notes/:noteId", - pathParams: z.object({ noteId: z.string() }), - responses: { - 200: z.object({ id: z.string() }), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, - getEntitlement: { - method: "GET", - path: "/v1/billing/entitlement", - responses: { - 200: entitlementSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - listTenants: { - method: "GET", - path: "/v1/tenants", - responses: { - 200: z.object({ items: z.array(tenantSchema) }), - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - createTenant: { - method: "POST", - path: "/v1/tenants", - body: z.object({ name: z.string().min(1).max(200) }), - responses: { - 201: tenantSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - }, - }, - selectTenant: { - method: "POST", - path: "/api/tenant/select", - body: z.object({ tenantId: z.string().min(1) }), - responses: { - 204: z.undefined(), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - listApiKeys: { - method: "GET", - path: "/v1/api-keys", - responses: { - 200: z.object({ items: z.array(apiKeySchema.omit({ raw: true })) }), - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - createApiKey: { - method: "POST", - path: "/v1/api-keys", - body: z.object({ - permissions: z.array(z.string()).default([]), - expiresAt: z.string().datetime().optional(), - }), - responses: { - 201: apiKeySchema.required({ raw: true }), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - revokeApiKey: { - method: "DELETE", - path: "/v1/api-keys/:publicId", - pathParams: z.object({ publicId: z.string() }), - responses: { - 204: z.undefined(), - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, - createInvitation: { - method: "POST", - path: "/v1/invitations", - body: z.object({ - email: z.string().email(), - role: z.string().min(1), - permissions: z.array(z.string()).default([]), - }), - responses: { - 201: invitationSchema, - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - }, - }, - acceptInvitation: { - method: "POST", - path: "/v1/invitations/accept", - body: z.object({ token: z.string().min(1), email: z.string().email() }), - responses: { - 200: z.object({ ok: z.literal(true), tenantId: z.string() }), - 400: platformErrorSchema, - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, - revokeInvitation: { - method: "DELETE", - path: "/v1/invitations/:invitationId", - pathParams: z.object({ invitationId: z.string() }), - responses: { - 204: z.undefined(), - 401: platformErrorSchema, - 403: platformErrorSchema, - 404: platformErrorSchema, - }, - }, -}); +// Errors every authenticated route can return. Platform error codes keep the +// public `{ code, message, details? }` body; see @codelitdev/platform/rest. +const authenticated = platformErrors( + "unauthenticated", + "credential_ambiguous", + "forbidden", + "rate_limited", +); +const tenantScoped = { + ...authenticated, + ...platformErrors("tenant_required", "tenant_forbidden"), +}; + +export const contract = { + health: oc + .route({ method: "GET", path: "/health", summary: "Liveness" }) + .output(healthResponseSchema), + ready: oc + .route({ method: "GET", path: "/ready", summary: "Readiness" }) + // Readiness reports keep their own body on 503 instead of a platform error. + .errors({ not_ready: { status: 503, data: readinessResponseSchema } }) + .output(readinessResponseSchema), + listNotes: oc + .route({ method: "GET", path: "/v1/notes", summary: "List notes" }) + .errors(tenantScoped) + .output(z.object({ items: z.array(noteSchema) })), + createNote: oc + .route({ + method: "POST", + path: "/v1/notes", + successStatus: 201, + summary: "Create a note", + }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed") }) + .input(createNoteBodySchema) + .output(noteSchema), + updateNote: oc + .route({ method: "PATCH", path: "/v1/notes/{noteId}", summary: "Update a note" }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed", "not_found") }) + .input(updateNoteBodySchema.extend({ noteId: z.string() })) + .output(noteSchema), + deleteNote: oc + .route({ method: "DELETE", path: "/v1/notes/{noteId}", summary: "Delete a note" }) + .errors({ ...tenantScoped, ...platformErrors("not_found") }) + .input(z.object({ noteId: z.string() })) + .output(z.object({ id: z.string() })), + getEntitlement: oc + .route({ + method: "GET", + path: "/v1/billing/entitlement", + summary: "Billing entitlement", + }) + .errors(tenantScoped) + .output(entitlementSchema), + listTenants: oc + .route({ method: "GET", path: "/v1/tenants", summary: "List tenants" }) + .errors(authenticated) + .output(z.object({ items: z.array(tenantSchema) })), + createTenant: oc + .route({ + method: "POST", + path: "/v1/tenants", + successStatus: 201, + summary: "Create a tenant", + }) + .errors({ ...authenticated, ...platformErrors("validation_failed") }) + .input(z.object({ name: z.string().min(1).max(200) })) + .output(tenantSchema), + selectTenant: oc + .route({ + method: "POST", + path: "/api/tenant/select", + successStatus: 204, + summary: "Select the session tenant", + }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed") }) + .input(z.object({ tenantId: z.string().min(1) })) + .output(z.void()), + listApiKeys: oc + .route({ method: "GET", path: "/v1/api-keys", summary: "List API keys" }) + .errors(tenantScoped) + .output(z.object({ items: z.array(apiKeySchema.omit({ raw: true })) })), + createApiKey: oc + .route({ + method: "POST", + path: "/v1/api-keys", + successStatus: 201, + summary: "Create an API key", + }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed") }) + .input( + z.object({ + permissions: z.array(z.string()).default([]), + expiresAt: z.string().datetime().optional(), + }), + ) + .output(apiKeySchema.required({ raw: true })), + revokeApiKey: oc + .route({ + method: "DELETE", + path: "/v1/api-keys/{publicId}", + successStatus: 204, + summary: "Revoke an API key", + }) + .errors({ ...tenantScoped, ...platformErrors("not_found") }) + .input(z.object({ publicId: z.string() })) + .output(z.void()), + createInvitation: oc + .route({ + method: "POST", + path: "/v1/invitations", + successStatus: 201, + summary: "Invite a member", + }) + .errors({ ...tenantScoped, ...platformErrors("validation_failed") }) + .input( + z.object({ + email: z.string().email(), + role: z.string().min(1), + permissions: z.array(z.string()).default([]), + }), + ) + .output(invitationSchema), + acceptInvitation: oc + .route({ + method: "POST", + path: "/v1/invitations/accept", + summary: "Accept an invitation", + }) + .errors({ ...authenticated, ...platformErrors("validation_failed", "not_found") }) + .input(z.object({ token: z.string().min(1), email: z.string().email() })) + .output(z.object({ ok: z.literal(true), tenantId: z.string() })), + revokeInvitation: oc + .route({ + method: "DELETE", + path: "/v1/invitations/{invitationId}", + successStatus: 204, + summary: "Revoke an invitation", + }) + .errors({ ...tenantScoped, ...platformErrors("not_found") }) + .input(z.object({ invitationId: z.string() })) + .output(z.void()), +}; export type Contract = typeof contract; export { mcpParityManifest } from "./mcp-parity.js"; diff --git a/templates/saas-product/patches/@anatine__zod-openapi@1.14.2.patch b/templates/saas-product/patches/@anatine__zod-openapi@1.14.2.patch deleted file mode 100644 index 091b135..0000000 --- a/templates/saas-product/patches/@anatine__zod-openapi@1.14.2.patch +++ /dev/null @@ -1,14 +0,0 @@ -diff --git a/src/lib/zod-openapi.js b/src/lib/zod-openapi.js -index cc71afb4d872b755908f03b9cf441112240405de..20b60b6e9a2f586f4d527eeecfff982909c46428 100644 ---- a/src/lib/zod-openapi.js -+++ b/src/lib/zod-openapi.js -@@ -2,6 +2,9 @@ - Object.defineProperty(exports, "__esModule", { value: true }); - exports.generateSchema = exports.extendApi = void 0; - const ts_deepmerge_1 = require("ts-deepmerge"); -+// ts-deepmerge 8 exposes its CommonJS API as a named `merge` export. -+// Preserve compatibility with this package's generated default-import calls. -+ts_deepmerge_1.default ??= ts_deepmerge_1.merge; - const zod_1 = require("zod"); - function extendApi(schema, SchemaObject = {}) { - schema.metaOpenApi = Object.assign(schema.metaOpenApi || {}, SchemaObject);