diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec0e537 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Port to listen on (default: 3000) +PORT=3000 + +# Path to the SQLite database file. +# On first run (file does not exist), the database is initialized. +# On subsequent runs, the existing database is opened. +DB_PATH=./stack.db + +# Owner entity ID. Required only on first run when the DB is being initialized. +# Generate a stable ID before first launch and keep it consistent. +ENTITY_ID= + +# IANA timezone string. Used only on first run. Default: UTC +TIMEZONE=UTC + +# Master bearer token for the stack owner. Used to bootstrap the server and +# manage other tokens via POST /tokens. Keep this secret and treat it like a +# root password. All other tokens are issued via the API and stored in the DB. +OWNER_TOKEN= + +# Allowed CORS origins. Default: * (all origins) +# Use a comma-separated list to restrict: https://app.example.com,https://admin.example.com +CORS_ORIGINS=* + +# Canonical base URL of this server (optional). +# Used in responses that reference the server's own URL. +# Auto-detected from the request if not set. +# Example: https://stack.example.com +BASE_URL= + +# Maximum upload size for attachments in bytes (default: 52428800 = 50 MB). +MAX_ATTACHMENT_BYTES=52428800 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5cc744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.db +*.db-wal +*.db-shm +attachments/ +.env +.env.local diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..29c69b2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4cbc711 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..2f5af49 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettierConfig from 'eslint-config-prettier'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + { + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, + { + ignores: ['dist/**', 'node_modules/**'], + }, +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5437b1d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2257 @@ +{ + "name": "@haverstack/server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@haverstack/server", + "version": "0.1.0", + "dependencies": { + "@haverstack/adapter-sqlite": "^0.1.0", + "@haverstack/core": "file:../core/packages/core", + "@hono/node-server": "^1.13.7", + "hono": "^4.6.0", + "pino": "^9.5.0", + "pino-pretty": "^13.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^10.0.0", + "prettier": "^3.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "typescript-eslint": "^8.0.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "../core/packages/core": { + "name": "@haverstack/core", + "version": "0.1.0", + "license": "CC0-1.0", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@haverstack/adapter-sqlite": { + "version": "0.1.0", + "license": "CC0-1.0", + "dependencies": { + "@haverstack/core": "0.1.0", + "sql.js": "^1.14.0" + } + }, + "node_modules/@haverstack/core": { + "resolved": "../core/packages/core", + "link": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.21", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.0.3", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.25", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.15", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql.js": { + "version": "1.14.1", + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..647af1b --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "@haverstack/server", + "version": "0.1.0", + "description": "Reference server implementation for Haverstack", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/index.js", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "eslint src tests" + }, + "dependencies": { + "@haverstack/adapter-sqlite": "^0.1.0", + "@haverstack/core": "file:../core/packages/core", + "@hono/node-server": "^1.13.7", + "hono": "^4.6.0", + "pino": "^9.5.0", + "pino-pretty": "^13.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^10.0.0", + "prettier": "^3.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "typescript-eslint": "^8.0.0", + "vitest": "^2.0.0" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..de16497 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,51 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import type { Logger } from 'pino'; +import type { StackContext } from './stack.js'; +import type { Config } from './config.js'; +import type { AppEnv } from './types.js'; +import { authMiddleware } from './middleware/auth.js'; +import { errorMiddleware } from './middleware/errors.js'; +import { wellknownRoutes } from './routes/wellknown.js'; +import { healthRoutes } from './routes/health.js'; +import { recordRoutes } from './routes/records.js'; +import { typeRoutes } from './routes/types.js'; +import { attachmentRoutes } from './routes/attachments.js'; +import { entityRoutes } from './routes/entity.js'; +import { tokenRoutes } from './routes/tokens.js'; + +export type { AppEnv }; + +export function createApp(ctx: StackContext, config: Config, logger: Logger): Hono { + const app = new Hono(); + + // Assign a unique request ID to every request for log correlation. + app.use(async (c, next) => { + c.set('requestId', crypto.randomUUID()); + await next(); + }); + + app.use( + cors({ + origin: + config.corsOrigins === '*' ? '*' : config.corsOrigins.split(',').map((s) => s.trim()), + allowMethods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], + allowHeaders: ['Authorization', 'Content-Type', 'Content-Disposition'], + exposeHeaders: ['X-Request-Id', 'Content-Disposition'], + }), + ); + app.onError(errorMiddleware(logger)); + app.use(authMiddleware(config.ownerToken, ctx)); + + app.route('/.well-known', wellknownRoutes(ctx)); + app.route('/health', healthRoutes()); + app.route('/records', recordRoutes(ctx)); + app.route('/types', typeRoutes(ctx)); + app.route('/attachments', attachmentRoutes(ctx, config.maxAttachmentBytes)); + app.route('/entity', entityRoutes(ctx)); + app.route('/tokens', tokenRoutes(ctx)); + + app.notFound((c) => c.json({ error: 'Not found' }, 404)); + + return app; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..5fcfc12 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,52 @@ +import { existsSync } from 'node:fs'; + +function required(name: string): string { + const val = process.env[name]; + if (!val) throw new Error(`Missing required environment variable: ${name}`); + return val; +} + +function optional(name: string, fallback: string): string { + return process.env[name] ?? fallback; +} + +export type Config = { + port: number; + dbPath: string; + entityId: string | null; + timezone: string; + ownerToken: string; + corsOrigins: string; + baseUrl: string | null; + isNewDb: boolean; + maxAttachmentBytes: number; +}; + +export function loadConfig(): Config { + const dbPath = required('DB_PATH'); + const isNewDb = !existsSync(dbPath); + + const entityId = process.env['ENTITY_ID'] ?? null; + const timezone = optional('TIMEZONE', 'UTC'); + + if (isNewDb && !entityId) { + throw new Error( + 'ENTITY_ID is required when initializing a new database (DB_PATH does not exist yet)', + ); + } + + return { + port: parseInt(optional('PORT', '3000'), 10), + dbPath, + entityId, + timezone, + ownerToken: required('OWNER_TOKEN'), + corsOrigins: optional('CORS_ORIGINS', '*'), + baseUrl: process.env['BASE_URL'] ?? null, + isNewDb, + maxAttachmentBytes: parseInt( + optional('MAX_ATTACHMENT_BYTES', String(50 * 1024 * 1024)), + 10, + ), + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e8b9f5e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,46 @@ +import { serve } from '@hono/node-server'; +import pino from 'pino'; +import { loadConfig } from './config.js'; +import { initStack } from './stack.js'; +import { createApp } from './app.js'; + +const logger = pino({ + level: process.env['LOG_LEVEL'] ?? 'info', + transport: + process.env['NODE_ENV'] !== 'production' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +async function main() { + const config = loadConfig(); + const ctx = await initStack(config); + const app = createApp(ctx, config, logger); + + logger.info( + { dbPath: config.dbPath, isNewDb: config.isNewDb }, + 'Stack initialized', + ); + + const server = serve({ fetch: app.fetch, port: config.port }, (info) => { + logger.info({ port: info.port }, 'Server listening'); + }); + + const shutdown = async (signal: string) => { + logger.info({ signal }, 'Shutting down'); + server.close(async () => { + await ctx.stack.flush(); + await ctx.stack.close(); + logger.info('Clean shutdown complete'); + process.exit(0); + }); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +main().catch((err) => { + logger.error({ err }, 'Fatal startup error'); + process.exit(1); +}); diff --git a/src/lib/serialize.ts b/src/lib/serialize.ts new file mode 100644 index 0000000..16a1076 --- /dev/null +++ b/src/lib/serialize.ts @@ -0,0 +1,83 @@ +import type { StackRecord, StackType, RecordVersion, Association, Permission } from '@haverstack/core'; + +export type WireRecord = { + id: string; + typeId: string; + createdAt: string; + updatedAt: string; + content: Record; + version: number; + parentId?: string; + entityId?: string; + appId?: string; + deletedAt?: string; + permissions?: Permission[]; + associations?: Association[]; +}; + +export type WireType = { + id: string; + baseId: string; + version: number; + name: string; + schema: Record; + schemaHash: string; + migratesFrom?: string; + createdAt: string; +}; + +export type WireVersion = { + version: number; + content: Record; + updatedAt: string; + entityId?: string; +}; + +export function serializeRecord(r: StackRecord): WireRecord { + const w: WireRecord = { + id: r.id, + typeId: r.typeId, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + content: r.content, + version: r.version, + }; + if (r.parentId !== undefined) w.parentId = r.parentId; + if (r.entityId !== undefined) w.entityId = r.entityId; + if (r.appId !== undefined) w.appId = r.appId; + if (r.deletedAt !== undefined) w.deletedAt = r.deletedAt.toISOString(); + if (r.permissions !== undefined) w.permissions = r.permissions; + if (r.associations !== undefined) w.associations = r.associations; + return w; +} + +export function serializeType(t: StackType): WireType { + const w: WireType = { + id: t.id, + baseId: t.baseId, + version: t.version, + name: t.name, + schema: t.schema as Record, + schemaHash: t.schemaHash, + createdAt: t.createdAt.toISOString(), + }; + if (t.migratesFrom !== undefined) w.migratesFrom = t.migratesFrom; + return w; +} + +export function serializeVersion(v: RecordVersion): WireVersion { + const w: WireVersion = { + version: v.version, + content: v.content, + updatedAt: v.updatedAt.toISOString(), + }; + if (v.entityId !== undefined) w.entityId = v.entityId; + return w; +} + +/** Parse an ISO date string from a wire body, returns undefined if absent or invalid. */ +export function parseDate(val: unknown): Date | undefined { + if (typeof val !== 'string') return undefined; + const d = new Date(val); + return isNaN(d.getTime()) ? undefined : d; +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..8c27e77 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,40 @@ +import type { MiddlewareHandler } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; + +export function authMiddleware(ownerToken: string, ctx: StackContext): MiddlewareHandler { + const ownerEntityId = ctx.stack.ownerEntityId; + + return async (c, next) => { + const header = c.req.header('Authorization'); + if (header?.startsWith('Bearer ')) { + const token = header.slice(7); + if (token === ownerToken) { + c.set('auth', ownerEntityId ? { entityId: ownerEntityId } : null); + } else { + const result = await ctx.adapter.lookupToken(token); + c.set('auth', result ? { entityId: result.entityId } : null); + } + } else { + c.set('auth', null); + } + await next(); + }; +} + +export function requireAuth(): MiddlewareHandler { + return async (c, next) => { + if (!c.get('auth')) return c.json({ error: 'Unauthorized' }, 401); + await next(); + }; +} + +export function requireOwner(ownerEntityId: string | null): MiddlewareHandler { + return async (c, next) => { + const auth = c.get('auth'); + if (!auth) return c.json({ error: 'Unauthorized' }, 401); + if (!ownerEntityId || auth.entityId !== ownerEntityId) + return c.json({ error: 'Forbidden' }, 403); + await next(); + }; +} diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts new file mode 100644 index 0000000..d8522bf --- /dev/null +++ b/src/middleware/errors.ts @@ -0,0 +1,14 @@ +import type { ErrorHandler } from 'hono'; +import type { Logger } from 'pino'; +import type { AppEnv } from '../types.js'; +import { StackPermissionError, StackValidationError } from '@haverstack/core'; + +export function errorMiddleware(logger: Logger): ErrorHandler { + return (err, c) => { + if (err instanceof StackPermissionError) return c.json({ error: 'Forbidden' }, 403); + if (err instanceof StackValidationError) + return c.json({ error: err.message, details: err.errors }, 422); + logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error'); + return c.json({ error: 'Internal server error' }, 500); + }; +} diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts new file mode 100644 index 0000000..79a4d64 --- /dev/null +++ b/src/routes/attachments.ts @@ -0,0 +1,146 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth, requireOwner } from '../middleware/auth.js'; + +export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + // POST /attachments — upload raw binary, Content-Type = MIME type + app.post('/', requireAuth(), async (c) => { + const contentLength = Number(c.req.header('Content-Length') ?? 0); + if (contentLength > maxAttachmentBytes) { + return c.json({ error: 'Attachment too large' }, 413); + } + + const data = new Uint8Array(await c.req.arrayBuffer()); + if (data.byteLength > maxAttachmentBytes) { + return c.json({ error: 'Attachment too large' }, 413); + } + + const filename = parseFilename(c.req.header('Content-Disposition')); + const mimeType = resolveMimeType( + c.req.header('Content-Type') ?? 'application/octet-stream', + filename, + ); + + const fileId = await adapter.putAttachment(data, mimeType, filename); + return c.json({ fileId }, 201); + }); + + // GET /attachments/:fileId — download + app.get('/:fileId', async (c) => { + const fileId = c.req.param('fileId'); + const auth = c.get('auth'); + + const accessible = await isAttachmentAccessible(fileId, auth?.entityId ?? null, ctx); + if (!accessible) return c.json({ error: 'Unauthorized' }, 401); + + const meta = await adapter.getAttachmentMeta(fileId); + if (!meta) return c.json({ error: 'Attachment not found' }, 404); + + let data: Uint8Array; + try { + data = await adapter.getAttachment(fileId); + } catch { + return c.json({ error: 'Attachment not found' }, 404); + } + + const headers: Record = { + 'Content-Type': meta.mimeType, + 'Content-Length': String(meta.size), + }; + if (meta.filename) { + headers['Content-Disposition'] = `attachment; filename="${meta.filename}"`; + } + + return c.newResponse(data as unknown as Uint8Array, 200, headers); + }); + + // DELETE /attachments/:fileId + app.delete('/:fileId', requireOwner(ownerEntityId), async (c) => { + const fileId = c.req.param('fileId'); + + const meta = await adapter.getAttachmentMeta(fileId); + if (!meta) return c.json({ error: 'Attachment not found' }, 404); + + await adapter.deleteAttachment(fileId); + return c.body(null, 204); + }); + + return app; +} + +function parseFilename(disposition: string | undefined): string | undefined { + if (!disposition) return undefined; + const match = disposition.match(/filename="([^"]+)"/); + return match?.[1]; +} + +// Map of common file extensions to MIME types. Used to upgrade +// application/octet-stream when the client omits a specific Content-Type +// but provided a filename with a recognisable extension. +const EXTENSION_MIME: Record = { + // Images + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + // Documents + pdf: 'application/pdf', + // Text + txt: 'text/plain', + md: 'text/markdown', + csv: 'text/csv', + html: 'text/html', + htm: 'text/html', + css: 'text/css', + js: 'text/javascript', + json: 'application/json', + xml: 'application/xml', + // Video + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + // Audio + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + m4a: 'audio/mp4', + // Archives + zip: 'application/zip', + gz: 'application/gzip', +}; + +function resolveMimeType(declared: string, filename: string | undefined): string { + if (declared !== 'application/octet-stream' || !filename) return declared; + const ext = filename.split('.').pop()?.toLowerCase(); + return (ext && EXTENSION_MIME[ext]) || declared; +} + +/** + * An attachment is accessible if the requester can read at least one of the + * Records that reference it (per spec: permissions are governed by the + * referencing Record(s), not the attachment itself). Owners always pass, + * even for attachments not yet referenced by any Record (e.g. just uploaded). + */ +async function isAttachmentAccessible( + fileId: string, + requesterEntityId: string | null, + ctx: StackContext, +): Promise { + const { stack } = ctx; + // Owner bypass: always accessible even if no record references it yet. + if (requesterEntityId && requesterEntityId === stack.ownerEntityId) return true; + + const result = await stack.asEntity(requesterEntityId).query({ + filter: { attachmentFileId: fileId }, + limit: 1, + }); + return result.records.length > 0; +} diff --git a/src/routes/entity.ts b/src/routes/entity.ts new file mode 100644 index 0000000..b008be1 --- /dev/null +++ b/src/routes/entity.ts @@ -0,0 +1,38 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { serializeRecord } from '../lib/serialize.js'; + +export function entityRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + app.get('/', requireAuth(), async (c) => { + if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); + const auth = c.get('auth')!; + const record = await stack.asEntity(auth.entityId).get(ownerEntityId); + if (!record) return c.json({ error: 'Entity record not found' }, 404); + return c.json(serializeRecord(record)); + }); + + app.patch('/', requireAuth(), async (c) => { + if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); + const auth = c.get('auth')!; + const body = await c.req.json>(); + try { + const updated = await stack.asEntity(auth.entityId).update( + ownerEntityId, + (body.content ?? {}) as Record, + ); + return c.json(serializeRecord(updated)); + } catch (err) { + if (err instanceof Error && err.message.startsWith('Record not found')) + return c.json({ error: 'Entity record not found' }, 404); + throw err; + } + }); + + return app; +} diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..218f216 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,8 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; + +export function healthRoutes(): Hono { + const app = new Hono(); + app.get('/', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); + return app; +} diff --git a/src/routes/records.ts b/src/routes/records.ts new file mode 100644 index 0000000..16697bf --- /dev/null +++ b/src/routes/records.ts @@ -0,0 +1,360 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { serializeRecord, serializeVersion } from '../lib/serialize.js'; +import type { StackQuery, RecordFilter, Association, Permission, TypeId } from '@haverstack/core'; + +// --------------------------------------------------------------------------- +// Query parsing helpers +// --------------------------------------------------------------------------- + +function getAll(url: URL, key: string): string[] { + return url.searchParams.getAll(key); +} + +function getOne(url: URL, key: string): string | null { + return url.searchParams.get(key); +} + +/** Convert wire ISO strings back to Date objects inside a StackQuery body. */ +function parseQueryBody(raw: unknown): StackQuery { + if (!raw || typeof raw !== 'object') return {}; + const body = raw as Record; + const query: StackQuery = {}; + + if (body.filter) { + const f = body.filter as Record; + const filter: RecordFilter = {}; + + if (f.typeId !== undefined) filter.typeId = f.typeId as string | string[]; + if (f.parentId !== undefined) + filter.parentId = f.parentId === null ? null : (f.parentId as string); + if (f.appId !== undefined) filter.appId = f.appId as string | string[]; + if (f.entityId !== undefined) filter.entityId = f.entityId as string | string[]; + if (f.tags !== undefined) filter.tags = f.tags as string[]; + if (f.hasAttachment !== undefined) filter.hasAttachment = f.hasAttachment as string; + if (f.attachmentFileId !== undefined) filter.attachmentFileId = f.attachmentFileId as string; + if (f.relatedTo !== undefined) + filter.relatedTo = f.relatedTo as { recordId: string; label?: string }; + if (f.content !== undefined) filter.content = f.content as Record; + if (f.search !== undefined) filter.search = f.search as string; + if (f.includeDeleted) filter.includeDeleted = true; + + if (f.createdAt) { + const r = f.createdAt as Record; + filter.createdAt = { + ...(r.before && { before: new Date(r.before) }), + ...(r.after && { after: new Date(r.after) }), + }; + } + if (f.updatedAt) { + const r = f.updatedAt as Record; + filter.updatedAt = { + ...(r.before && { before: new Date(r.before) }), + ...(r.after && { after: new Date(r.after) }), + }; + } + + query.filter = filter; + } + + if (body.sort) { + const s = body.sort as Record; + query.sort = { + field: s.field as 'createdAt' | 'updatedAt' | 'version', + ...(s.direction && { direction: s.direction as 'asc' | 'desc' }), + }; + } + + if (typeof body.limit === 'number') query.limit = body.limit; + if (typeof body.cursor === 'string') query.cursor = body.cursor; + + return query; +} + +/** Build a StackQuery from GET /records URL search params. */ +function parseQueryParams(url: URL): StackQuery { + const filter: RecordFilter = {}; + + const typeIds = getAll(url, 'typeId'); + if (typeIds.length) filter.typeId = typeIds.length === 1 ? typeIds[0] : typeIds; + + const parentId = getOne(url, 'parentId'); + if (parentId !== null) filter.parentId = parentId === 'null' ? null : parentId; + + const appIds = getAll(url, 'appId'); + if (appIds.length) filter.appId = appIds.length === 1 ? appIds[0] : appIds; + + const entityIds = getAll(url, 'entityId'); + if (entityIds.length) filter.entityId = entityIds.length === 1 ? entityIds[0] : entityIds; + + const tags = getAll(url, 'tag'); + if (tags.length) filter.tags = tags; + + const hasAttachment = getOne(url, 'hasAttachment'); + if (hasAttachment) filter.hasAttachment = hasAttachment; + + const attachmentFileId = getOne(url, 'attachmentFileId'); + if (attachmentFileId) filter.attachmentFileId = attachmentFileId; + + const relatedTo = getOne(url, 'relatedTo'); + if (relatedTo) { + const label = getOne(url, 'relatedLabel'); + filter.relatedTo = { recordId: relatedTo, ...(label && { label }) }; + } + + const search = getOne(url, 'search'); + if (search) filter.search = search; + + const createdBefore = getOne(url, 'createdBefore'); + const createdAfter = getOne(url, 'createdAfter'); + if (createdBefore || createdAfter) { + filter.createdAt = { + ...(createdBefore && { before: new Date(createdBefore) }), + ...(createdAfter && { after: new Date(createdAfter) }), + }; + } + + const updatedBefore = getOne(url, 'updatedBefore'); + const updatedAfter = getOne(url, 'updatedAfter'); + if (updatedBefore || updatedAfter) { + filter.updatedAt = { + ...(updatedBefore && { before: new Date(updatedBefore) }), + ...(updatedAfter && { after: new Date(updatedAfter) }), + }; + } + + if (getOne(url, 'includeDeleted') === 'true') filter.includeDeleted = true; + + const query: StackQuery = {}; + if (Object.keys(filter).length) query.filter = filter; + + const sort = getOne(url, 'sort') as 'createdAt' | 'updatedAt' | 'version' | null; + const direction = getOne(url, 'direction') as 'asc' | 'desc' | null; + if (sort) query.sort = { field: sort, ...(direction && { direction }) }; + + const limit = getOne(url, 'limit'); + if (limit) query.limit = parseInt(limit, 10); + + const cursor = getOne(url, 'cursor'); + if (cursor) query.cursor = cursor; + + return query; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** True when ScopedStack threw because the record doesn't exist (not a permission error). */ +function isRecordNotFound(err: unknown): boolean { + return err instanceof Error && err.message.startsWith('Record not found'); +} + +// --------------------------------------------------------------------------- +// Route factory +// --------------------------------------------------------------------------- + +export function recordRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { stack } = ctx; + + // POST /records/query — full query with content-field filters + // Registered before /:id patterns to avoid param capture on the literal "query" segment. + app.post('/query', requireAuth(), async (c) => { + const auth = c.get('auth'); + const query = parseQueryBody(await c.req.json()); + const result = await stack.asEntity(auth?.entityId ?? null).query(query); + return c.json({ records: result.records.map(serializeRecord), cursor: result.cursor, total: result.total }); + }); + + // GET /records — query by native fields via URL params + app.get('/', async (c) => { + const auth = c.get('auth'); + const query = parseQueryParams(new URL(c.req.url)); + const result = await stack.asEntity(auth?.entityId ?? null).query(query); + return c.json({ records: result.records.map(serializeRecord), cursor: result.cursor, total: result.total }); + }); + + // POST /records — create (server generates the ID) + app.post('/', requireAuth(), async (c) => { + const body = await c.req.json>(); + if (!body.typeId || typeof body.typeId !== 'string') + return c.json({ error: 'typeId is required' }, 400); + if (!body.content || typeof body.content !== 'object') + return c.json({ error: 'content is required' }, 400); + + const created = await stack.create(body.typeId as TypeId, body.content as Record, { + parentId: typeof body.parentId === 'string' ? body.parentId : undefined, + entityId: typeof body.entityId === 'string' ? body.entityId : undefined, + appId: typeof body.appId === 'string' ? body.appId : undefined, + permissions: Array.isArray(body.permissions) ? (body.permissions as Permission[]) : undefined, + associations: Array.isArray(body.associations) ? (body.associations as Association[]) : undefined, + }); + return c.json(serializeRecord(created), 201); + }); + + // GET /records/:id + app.get('/:id', async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth'); + const record = await stack.asEntity(auth?.entityId ?? null).get(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + return c.json(serializeRecord(record)); + }); + + // PATCH /records/:id — merges content patch (RFC 7396); null field values remove the field + app.patch('/:id', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const body = await c.req.json>(); + try { + const updated = await stack.asEntity(auth.entityId).update( + id, + (body.content ?? {}) as Record, + ); + return c.json(serializeRecord(updated)); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } + }); + + // DELETE /records/:id (?hard=true for permanent) + app.delete('/:id', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const hard = new URL(c.req.url).searchParams.get('hard') === 'true'; + if (hard && auth.entityId !== stack.ownerEntityId) + return c.json({ error: 'Forbidden' }, 403); + try { + await stack.asEntity(auth.entityId).delete(id, { hard }); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } + return c.body(null, 204); + }); + + // ------------------------------------------------------------------ + // Permissions + // ------------------------------------------------------------------ + + app.get('/:id/permissions', async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth'); + const record = await stack.asEntity(auth?.entityId ?? null).get(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + return c.json({ permissions: record.permissions ?? [] }); + }); + + app.put('/:id/permissions', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const body = await c.req.json<{ permissions: Permission[] }>(); + if (!Array.isArray(body.permissions)) + return c.json({ error: 'permissions must be an array' }, 400); + try { + await stack.asEntity(auth.entityId).setPermissions(id, body.permissions); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } + return c.json({ permissions: body.permissions }); + }); + + // ------------------------------------------------------------------ + // Associations + // ------------------------------------------------------------------ + + app.get('/:id/associations', async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth'); + const record = await stack.asEntity(auth?.entityId ?? null).get(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + let assocs = record.associations ?? []; + const kind = c.req.query('kind'); + if (kind) assocs = assocs.filter((a) => a.kind === kind); + const label = c.req.query('label'); + if (label) assocs = assocs.filter((a) => a.label === label); + return c.json({ associations: assocs }); + }); + + app.post('/:id/associations', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const body = await c.req.json(); + if (!body.kind || !body.label) return c.json({ error: 'kind and label are required' }, 400); + try { + await stack.asEntity(auth.entityId).associate(id, body); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } + return c.body(null, 204); + }); + + app.delete('/:id/associations', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const body = await c.req.json(); + try { + await stack.asEntity(auth.entityId).dissociate(id, body); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } + return c.body(null, 204); + }); + + // ------------------------------------------------------------------ + // Versions + // ------------------------------------------------------------------ + + app.get('/:id/versions', async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth'); + try { + const versions = await stack.asEntity(auth?.entityId ?? null).getVersions(id); + return c.json(versions.map(serializeVersion)); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } + }); + + app.get('/:id/versions/:version', async (c) => { + const id = c.req.param('id'); + const vNum = parseInt(c.req.param('version'), 10); + if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); + const auth = c.get('auth'); + try { + const version = await stack.asEntity(auth?.entityId ?? null).getVersion(id, vNum); + if (!version) return c.json({ error: 'Version not found' }, 404); + return c.json(serializeVersion(version)); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } + }); + + // POST /records/:id/restore/:version — creates new version, does not rewrite history + app.post('/:id/restore/:version', requireAuth(), async (c) => { + const id = c.req.param('id'); + const vNum = parseInt(c.req.param('version'), 10); + if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); + const auth = c.get('auth')!; + try { + const restored = await stack.asEntity(auth.entityId).restoreVersion(id, vNum); + return c.json(serializeRecord(restored)); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + if (err instanceof Error && err.message.startsWith('Version')) + return c.json({ error: 'Version not found' }, 404); + throw err; + } + }); + + return app; +} diff --git a/src/routes/tokens.ts b/src/routes/tokens.ts new file mode 100644 index 0000000..c716c5d --- /dev/null +++ b/src/routes/tokens.ts @@ -0,0 +1,65 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import type { TokenInfo } from '@haverstack/adapter-sqlite'; + +export function tokenRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + // POST /tokens — issue a new token (owner only) + app.post('/', requireAuth(), async (c) => { + const auth = c.get('auth')!; + if (auth.entityId !== ownerEntityId) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json<{ entityId?: string; label?: string; expiresAt?: string }>(); + const entityId = body.entityId ?? ownerEntityId; + const expiresAt = body.expiresAt ? new Date(body.expiresAt) : undefined; + + const { id, token } = await adapter.createToken(entityId, { label: body.label, expiresAt }); + + return c.json( + { + id, + token, + entityId, + label: body.label ?? null, + createdAt: new Date().toISOString(), + expiresAt: expiresAt?.toISOString() ?? null, + }, + 201, + ); + }); + + // GET /tokens — list all DB-managed tokens; never returns token values + app.get('/', requireAuth(), async (c) => { + const auth = c.get('auth')!; + if (auth.entityId !== ownerEntityId) return c.json({ error: 'Forbidden' }, 403); + + const tokens = await adapter.listTokens(); + return c.json({ tokens: tokens.map(serializeToken) }); + }); + + // DELETE /tokens/:id — revoke a token by its ID + app.delete('/:id', requireAuth(), async (c) => { + const auth = c.get('auth')!; + if (auth.entityId !== ownerEntityId) return c.json({ error: 'Forbidden' }, 403); + + await adapter.revokeToken(c.req.param('id')); + return c.body(null, 204); + }); + + return app; +} + +function serializeToken(t: TokenInfo) { + return { + id: t.id, + entityId: t.entityId, + label: t.label ?? null, + createdAt: t.createdAt.toISOString(), + expiresAt: t.expiresAt?.toISOString() ?? null, + }; +} diff --git a/src/routes/types.ts b/src/routes/types.ts new file mode 100644 index 0000000..5074c4e --- /dev/null +++ b/src/routes/types.ts @@ -0,0 +1,52 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireOwner } from '../middleware/auth.js'; +import { serializeType } from '../lib/serialize.js'; +import type { StackType, TypeSchema } from '@haverstack/core'; + +export function typeRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + + app.get('/', async (c) => { + const types = await adapter.listTypes(); + return c.json(types.map(serializeType)); + }); + + app.get('/:id', async (c) => { + const id = decodeURIComponent(c.req.param('id')); + const type = await adapter.getType(id); + if (!type) return c.json({ error: 'Type not found' }, 404); + return c.json(serializeType(type)); + }); + + app.post('/', requireOwner(stack.ownerEntityId), async (c) => { + const body = await c.req.json>(); + if (!body.id || typeof body.id !== 'string') return c.json({ error: 'id is required' }, 400); + if (!body.baseId || typeof body.baseId !== 'string') + return c.json({ error: 'baseId is required' }, 400); + if (typeof body.version !== 'number') return c.json({ error: 'version must be a number' }, 400); + if (!body.name || typeof body.name !== 'string') return c.json({ error: 'name is required' }, 400); + if (!body.schema || typeof body.schema !== 'object') + return c.json({ error: 'schema is required' }, 400); + if (!body.schemaHash || typeof body.schemaHash !== 'string') + return c.json({ error: 'schemaHash is required' }, 400); + + const type: StackType = { + id: body.id, + baseId: body.baseId, + version: body.version, + name: body.name, + schema: body.schema as TypeSchema, + schemaHash: body.schemaHash, + createdAt: body.createdAt ? new Date(body.createdAt as string) : new Date(), + ...(body.migratesFrom ? { migratesFrom: body.migratesFrom as string } : {}), + }; + + await adapter.saveType(type); + return c.json(serializeType(type), 201); + }); + + return app; +} diff --git a/src/routes/wellknown.ts b/src/routes/wellknown.ts new file mode 100644 index 0000000..fc8afc3 --- /dev/null +++ b/src/routes/wellknown.ts @@ -0,0 +1,18 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; + +export function wellknownRoutes(ctx: StackContext): Hono { + const app = new Hono(); + + app.get('/stack', (c) => { + return c.json({ + version: '1.0', + entityId: ctx.stack.ownerEntityId ?? '', + timezone: ctx.stack.timezone, + capabilities: ctx.stack.capabilities, + }); + }); + + return app; +} diff --git a/src/stack.ts b/src/stack.ts new file mode 100644 index 0000000..0361556 --- /dev/null +++ b/src/stack.ts @@ -0,0 +1,25 @@ +import { SQLiteAdapter } from '@haverstack/adapter-sqlite'; +import { Stack } from '@haverstack/core'; +import type { Config } from './config.js'; + +export type StackContext = { + adapter: SQLiteAdapter; + stack: Stack; +}; + +export async function initStack(config: Config): Promise { + let adapter: SQLiteAdapter; + + if (config.isNewDb) { + adapter = await SQLiteAdapter.initialize({ + path: config.dbPath, + entityId: config.entityId!, + timezone: config.timezone, + }); + } else { + adapter = await SQLiteAdapter.open({ path: config.dbPath }); + } + + const stack = await Stack.create(adapter); + return { adapter, stack }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0e2ca0a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,7 @@ +/** Hono context variable map shared across all route files. */ +export type AppEnv = { + Variables: { + auth: { entityId: string } | null; + requestId: string; + }; +}; diff --git a/tests/routes/associations.test.ts b/tests/routes/associations.test.ts new file mode 100644 index 0000000..cb3cff1 --- /dev/null +++ b/tests/routes/associations.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js'; + +const TYPE_ID = 'com.example.test/post@1'; + +describe('Associations', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await t.ctx.stack.defineType(TYPE_ID, 'Post', { + text: { kind: 'text' as const, required: true as const }, + }); + }); + afterEach(async () => { await t.cleanup(); }); + + async function seedRecord() { + return t.ctx.stack.create(TYPE_ID, { text: 'Hello' }); + } + + it('POST adds a tag association', async () => { + const record = await seedRecord(); + const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { kind: 'tag', label: 'starred' }, + }); + expect(status).toBe(204); + }); + + it('GET returns all associations', async () => { + const record = await seedRecord(); + await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'archived' }); + const { status, data } = await req( + t.app, 'GET', `/records/${record.id}/associations`, { token: TEST_TOKEN }, + ); + expect(status).toBe(200); + expect((data as { associations: unknown[] }).associations).toHaveLength(2); + }); + + it('GET ?kind=tag filters by kind', async () => { + const record = await seedRecord(); + await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + const { status, data } = await req( + t.app, 'GET', `/records/${record.id}/associations?kind=tag`, { token: TEST_TOKEN }, + ); + expect(status).toBe(200); + const assocs = (data as { associations: Array<{ kind: string }> }).associations; + expect(assocs.every((a) => a.kind === 'tag')).toBe(true); + }); + + it('DELETE removes an association', async () => { + const record = await seedRecord(); + await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { kind: 'tag', label: 'starred' }, + }); + expect(status).toBe(204); + const after = await t.ctx.adapter.getRecord(record.id); + expect(after?.associations?.some((a) => a.label === 'starred')).toBeFalsy(); + }); +}); diff --git a/tests/routes/attachments.test.ts b/tests/routes/attachments.test.ts new file mode 100644 index 0000000..19ac8d4 --- /dev/null +++ b/tests/routes/attachments.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; + +const NOTE_TYPE_ID = 'com.example.test/note@1'; + +async function seedType(ctx: TestApp['ctx']) { + return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { + body: { kind: 'text' as const, required: true as const }, + }); +} + +async function putFile(ctx: TestApp['ctx'], content = 'hello') { + return ctx.adapter.putAttachment(new TextEncoder().encode(content), 'text/plain'); +} + +describe('GET /attachments/:fileId', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await seedType(t.ctx); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it('allows the owner to read an unattached file', async () => { + const fileId = await putFile(t.ctx); + const { status, data } = await req(t.app, 'GET', `/attachments/${fileId}`, { + token: TEST_TOKEN, + }); + expect(status).toBe(200); + expect(data).toBe('hello'); + }); + + it('rejects an anonymous request for an unattached file', async () => { + const fileId = await putFile(t.ctx); + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`); + expect(status).toBe(401); + }); + + it('rejects a non-owner authenticated request for an unattached file', async () => { + const fileId = await putFile(t.ctx); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); + expect(status).toBe(401); + }); + + it('allows anonymous access when the referencing record is public', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'public note' }, + { + permissions: [{ access: 'public' }], + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`); + expect(status).toBe(200); + }); + + it('rejects anonymous access when the referencing record is private', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'private note' }, + { + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`); + expect(status).toBe(401); + }); + + it('grants access if ANY referencing record is accessible, not just the first', async () => { + const fileId = await putFile(t.ctx); + // Private record references the file first... + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'private note' }, + { + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + // ...a second, public record also references it. + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'public note' }, + { + permissions: [{ access: 'public' }], + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`); + expect(status).toBe(200); + }); + + it('grants a non-owner entity access via an entity-scoped read grant', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'shared note' }, + { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); + expect(status).toBe(200); + }); + + it('rejects a non-owner entity without a matching read grant', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'private note' }, + { + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); + expect(status).toBe(401); + }); +}); + +describe('DELETE /attachments/:fileId', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await seedType(t.ctx); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it('allows the owner to delete', async () => { + const fileId = await putFile(t.ctx); + const { status } = await req(t.app, 'DELETE', `/attachments/${fileId}`, { + token: TEST_TOKEN, + }); + expect(status).toBe(204); + }); + + it('rejects a non-owner entity even with a write grant on a referencing record', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'shared note' }, + { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + + const { status } = await req(t.app, 'DELETE', `/attachments/${fileId}`, { token }); + expect(status).toBe(403); + }); +}); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts new file mode 100644 index 0000000..cb4609e --- /dev/null +++ b/tests/routes/records.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, TEST_ENTITY_ID, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; + +const NOTE_TYPE_ID = 'com.example.test/note@1'; + +async function seedType(ctx: TestApp['ctx']) { + return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { + title: { kind: 'string' as const }, + body: { kind: 'text' as const, required: true as const }, + }); +} + +async function seedRecord(ctx: TestApp['ctx'], overrides: Record = {}) { + return ctx.stack.create(NOTE_TYPE_ID, { body: 'Hello world', ...overrides }); +} + +describe('Records', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await seedType(t.ctx); + }); + afterEach(async () => { await t.cleanup(); }); + + describe('POST /records', () => { + it('creates a record and returns a server-generated id', async () => { + const { status, data } = await req(t.app, 'POST', '/records', { + token: TEST_TOKEN, + body: { + typeId: NOTE_TYPE_ID, + content: { body: 'Test note' }, + entityId: TEST_ENTITY_ID, + }, + }); + expect(status).toBe(201); + const d = data as Record; + expect(typeof d.id).toBe('string'); + expect(d.typeId).toBe(NOTE_TYPE_ID); + expect((d.content as Record).body).toBe('Test note'); + }); + + it('returns 400 when typeId is missing', async () => { + const { status } = await req(t.app, 'POST', '/records', { + token: TEST_TOKEN, + body: { content: { body: 'x' } }, + }); + expect(status).toBe(400); + }); + + it('returns 401 without auth', async () => { + const { status } = await req(t.app, 'POST', '/records', { + body: { typeId: NOTE_TYPE_ID, content: { body: 'x' } }, + }); + expect(status).toBe(401); + }); + }); + + describe('GET /records/:id', () => { + it('returns a record by id', async () => { + const record = await seedRecord(t.ctx); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN }); + expect(status).toBe(200); + expect((data as Record).id).toBe(record.id); + }); + + it('returns 404 for unknown id', async () => { + const { status } = await req(t.app, 'GET', '/records/nonexistent', { token: TEST_TOKEN }); + expect(status).toBe(404); + }); + + it('anonymous gets 403 for a private record', async () => { + const record = await seedRecord(t.ctx); + const { status } = await req(t.app, 'GET', `/records/${record.id}`); + expect(status).toBe(403); + }); + + it('anonymous can read a public record', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'public content' }, { + permissions: [{ access: 'public' }], + }); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}`); + expect(status).toBe(200); + expect((data as Record).id).toBe(record.id); + }); + + it('entity with a read grant can read the record', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'shared content' }, { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + }); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); + expect(status).toBe(200); + }); + + it('entity without a grant gets 403', async () => { + const record = await seedRecord(t.ctx); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); + expect(status).toBe(403); + }); + }); + + describe('GET /records', () => { + it('returns records list', async () => { + await seedRecord(t.ctx, { body: 'Note 1' }); + await seedRecord(t.ctx, { body: 'Note 2' }); + const { status, data } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN }); + expect(status).toBe(200); + const d = data as { records: unknown[]; total: null }; + expect(d.total).toBeNull(); + expect(d.records).toHaveLength(2); + }); + + it('filters by typeId query param', async () => { + await seedRecord(t.ctx); + const { data } = await req(t.app, 'GET', `/records?typeId=${encodeURIComponent(NOTE_TYPE_ID)}`, { token: TEST_TOKEN }); + expect((data as { records: unknown[] }).records).toHaveLength(1); + }); + + it('anonymous query returns only public records', async () => { + await seedRecord(t.ctx, { body: 'private' }); + await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'public' }, { permissions: [{ access: 'public' }] }); + const { data } = await req(t.app, 'GET', '/records'); + const d = data as { records: Array<{ content: { body: string } }>; total: null }; + expect(d.total).toBeNull(); + expect(d.records).toHaveLength(1); + expect(d.records[0].content.body).toBe('public'); + }); + }); + + describe('POST /records/query', () => { + it('accepts a full StackQuery body', async () => { + await seedRecord(t.ctx); + const { status, data } = await req(t.app, 'POST', '/records/query', { + token: TEST_TOKEN, + body: { + filter: { typeId: NOTE_TYPE_ID }, + sort: { field: 'createdAt', direction: 'desc' }, + limit: 10, + }, + }); + expect(status).toBe(200); + const d = data as { records: unknown[]; total: null }; + expect(d.records).toHaveLength(1); + expect(d.total).toBeNull(); + }); + }); + + describe('PATCH /records/:id', () => { + it('merges content (does not overwrite unmentioned fields)', async () => { + const record = await seedRecord(t.ctx, { body: 'original', title: 'My title' }); + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { content: { body: 'Updated body' } }, + }); + expect(status).toBe(200); + const d = data as Record; + const content = d.content as Record; + expect(content.body).toBe('Updated body'); + expect(content.title).toBe('My title'); + expect(d.version).toBe(2); + }); + + it('snapshots the previous version on update', async () => { + const record = await seedRecord(t.ctx); + await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { content: { body: 'v2' } }, + }); + const versions = await t.ctx.adapter.getVersions(record.id); + expect(versions).toHaveLength(1); + expect(versions[0].version).toBe(1); + }); + + it('returns 403 when requester lacks write access', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'hi' }, { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + }); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + token, + body: { content: { body: 'hacked' } }, + }); + expect(status).toBe(403); + }); + }); + + describe('DELETE /records/:id', () => { + it('soft-deletes by default', async () => { + const record = await seedRecord(t.ctx); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); + expect(status).toBe(204); + const after = await t.ctx.adapter.getRecord(record.id); + expect(after?.deletedAt).toBeDefined(); + }); + + it('hard-deletes with ?hard=true (owner)', async () => { + const record = await seedRecord(t.ctx); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN }); + expect(status).toBe(204); + expect(await t.ctx.adapter.getRecord(record.id)).toBeNull(); + }); + + it('non-owner with write access can soft-delete', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'shared' }, { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + }); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token }); + expect(status).toBe(204); + const after = await t.ctx.adapter.getRecord(record.id); + expect(after?.deletedAt).toBeDefined(); + }); + + it('non-owner gets 403 on hard delete even with write access', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'shared' }, { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + }); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token }); + expect(status).toBe(403); + expect(await t.ctx.adapter.getRecord(record.id)).not.toBeNull(); + }); + }); +}); diff --git a/tests/routes/types.test.ts b/tests/routes/types.test.ts new file mode 100644 index 0000000..079bc24 --- /dev/null +++ b/tests/routes/types.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { hashSchema } from '@haverstack/core'; +import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; + +describe('Types', () => { + let t: TestApp; + beforeEach(async () => { t = await buildTestApp(); }); + afterEach(async () => { await t.cleanup(); }); + + const typeId = 'com.example.test/item@1'; + const schema = { name: { kind: 'string' as const, required: true as const } }; + + describe('POST /types', () => { + it('registers a type as owner', async () => { + const schemaHash = await hashSchema(schema); + const { status, data } = await req(t.app, 'POST', '/types', { + token: TEST_TOKEN, + body: { + id: typeId, + baseId: 'com.example.test/item', + version: 1, + name: 'Item', + schema, + schemaHash, + createdAt: new Date().toISOString(), + }, + }); + expect(status).toBe(201); + expect((data as Record).id).toBe(typeId); + }); + + it('returns 401 without auth', async () => { + const { status } = await req(t.app, 'POST', '/types', { + body: { id: typeId, baseId: 'x', version: 1, name: 'x', schema: {}, schemaHash: 'x' }, + }); + expect(status).toBe(401); + }); + + it('returns 403 for a non-owner entity', async () => { + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'POST', '/types', { + token, + body: { id: typeId, baseId: 'x', version: 1, name: 'x', schema: {}, schemaHash: 'x' }, + }); + expect(status).toBe(403); + }); + }); + + describe('GET /types', () => { + it('returns all registered types (no auth required)', async () => { + await t.ctx.stack.defineType(typeId, 'Item', schema); + const { status, data } = await req(t.app, 'GET', '/types'); + expect(status).toBe(200); + expect((data as unknown[]).length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('GET /types/:id', () => { + it('returns one type (URL-encoded, no auth required)', async () => { + await t.ctx.stack.defineType(typeId, 'Item', schema); + const { status, data } = await req(t.app, 'GET', `/types/${encodeURIComponent(typeId)}`); + expect(status).toBe(200); + expect((data as Record).id).toBe(typeId); + }); + + it('returns 404 for unknown type', async () => { + const { status } = await req(t.app, 'GET', '/types/unknown%40999'); + expect(status).toBe(404); + }); + }); +}); diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts new file mode 100644 index 0000000..f88e784 --- /dev/null +++ b/tests/routes/versions.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js'; + +const TYPE_ID = 'com.example.test/doc@1'; + +describe('Versions', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await t.ctx.stack.defineType(TYPE_ID, 'Doc', { + body: { kind: 'text' as const, required: true as const }, + }); + }); + afterEach(async () => { await t.cleanup(); }); + + async function createAndPatch() { + const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' }); + await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }, + }); + return record; + } + + it('GET /records/:id/versions returns history', async () => { + const record = await createAndPatch(); + const { status, data } = await req( + t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN }, + ); + expect(status).toBe(200); + expect((data as unknown[]).length).toBe(1); + }); + + it('GET /records/:id/versions/:version returns one version', async () => { + const record = await createAndPatch(); + const { status, data } = await req( + t.app, 'GET', `/records/${record.id}/versions/1`, { token: TEST_TOKEN }, + ); + expect(status).toBe(200); + expect((data as Record).version).toBe(1); + }); + + it('POST /records/:id/restore/:version restores content without rewriting history', async () => { + const record = await createAndPatch(); + const { status, data } = await req( + t.app, 'POST', `/records/${record.id}/restore/1`, { token: TEST_TOKEN }, + ); + expect(status).toBe(200); + const d = data as Record; + expect((d.content as Record).body).toBe('v1'); + // version 1 was snapshotted, then v2 was applied, then v2 was snapshotted for restore → new version is 3 + expect(d.version).toBe(3); + }); +}); diff --git a/tests/routes/wellknown.test.ts b/tests/routes/wellknown.test.ts new file mode 100644 index 0000000..86baf0f --- /dev/null +++ b/tests/routes/wellknown.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_ENTITY_ID, type TestApp } from '../setup.js'; + +describe('GET /.well-known/stack', () => { + let t: TestApp; + beforeEach(async () => { t = await buildTestApp(); }); + afterEach(async () => { await t.cleanup(); }); + + it('returns the discovery document', async () => { + const { status, data } = await req(t.app, 'GET', '/.well-known/stack'); + expect(status).toBe(200); + const d = data as Record; + expect(d.version).toBe('1.0'); + expect(d.entityId).toBe(TEST_ENTITY_ID); + expect(d.timezone).toBe('UTC'); + expect(d.capabilities).toBeDefined(); + }); + + it('does not require authentication', async () => { + const { status } = await req(t.app, 'GET', '/.well-known/stack'); + expect(status).toBe(200); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..719db67 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,114 @@ +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import { mkdirSync } from 'node:fs'; +import { SQLiteAdapter } from '@haverstack/adapter-sqlite'; +import { Stack } from '@haverstack/core'; +import pino from 'pino'; +import { createApp } from '../src/app.js'; +import type { Config } from '../src/config.js'; +import type { StackContext } from '../src/stack.js'; +import type { Hono } from 'hono'; +import type { AppEnv } from '../src/app.js'; + +export const TEST_ENTITY_ID = 'test-entity-id-00000001'; +export const TEST_TOKEN = 'test-bearer-token'; +export const OTHER_ENTITY_ID = 'other-entity-id-00000002'; + +export const logger = pino({ level: 'silent' }); + +/** + * Each test gets its own isolated temp directory so the SQLiteAdapter's + * sibling `attachments/` folder never collides between parallel test runs. + */ +export function tempDbPath(): string { + const dir = join(tmpdir(), `haverstack-test-${randomBytes(8).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return join(dir, 'stack.db'); +} + +export async function createTestContext(dbPath: string): Promise { + const adapter = await SQLiteAdapter.initialize({ + path: dbPath, + entityId: TEST_ENTITY_ID, + timezone: 'UTC', + }); + const stack = await Stack.create(adapter); + return { adapter, stack }; +} + +export function testConfig(dbPath: string): Config { + return { + port: 3000, + dbPath, + entityId: TEST_ENTITY_ID, + timezone: 'UTC', + ownerToken: TEST_TOKEN, + corsOrigins: '*', + baseUrl: null, + isNewDb: true, + maxAttachmentBytes: 50 * 1024 * 1024, + }; +} + +export type TestApp = { + app: Hono; + ctx: StackContext; + dbPath: string; + cleanup: () => Promise; +}; + +export async function buildTestApp(): Promise { + const dbPath = tempDbPath(); + const ctx = await createTestContext(dbPath); + const config = testConfig(dbPath); + const app = createApp(ctx, config, logger); + + const cleanup = async () => { + await ctx.stack.close(); + // Remove the whole temp directory (includes the .db file and attachments/). + await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {}); + }; + + return { app, ctx, dbPath, cleanup }; +} + +export type ReqOpts = { + /** Adds Authorization: Bearer header. */ + token?: string; + /** JSON-serialised as the request body with Content-Type: application/json. */ + body?: unknown; + /** Additional headers merged after auth/content-type. */ + headers?: Record; +}; + +/** + * Fire a request at the Hono test app and return status + parsed JSON body. + */ +export async function req( + app: Hono, + method: string, + path: string, + opts: ReqOpts = {}, +): Promise<{ status: number; data: unknown }> { + const headers: Record = {}; + if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`; + if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; + Object.assign(headers, opts.headers); + + const res = await app.request(path, { + method, + headers, + ...(opts.body !== undefined && { body: JSON.stringify(opts.body) }), + }); + + const text = await res.text(); + let data: unknown; + try { + data = JSON.parse(text); + } catch { + data = text; + } + return { status: res.status, data }; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..9ebb053 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..85e20d4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..001cbcb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + }, + }, +});