From 994040e00401b969e9dcc8093ac22dfd09464f76 Mon Sep 17 00:00:00 2001 From: Noah AI Date: Mon, 29 Jun 2026 13:20:41 +0000 Subject: [PATCH] AI Update: Started backend platform server on port 8080 --- backend/.dockerignore | 3 + backend/.env | 13 ++ backend/.env.example | 14 ++ backend/.gitignore | 2 + backend/Dockerfile | 15 ++ backend/README.md | 59 ++++++ backend/bun.lock | 187 ++++++++++++++++++ backend/package.json | 22 +++ backend/src/config.ts | 81 ++++++++ backend/src/index.ts | 37 ++++ backend/src/lib/db.ts | 158 +++++++++++++++ backend/src/lib/storage.ts | 139 ++++++++++++++ backend/src/lib/todos-schema.ts | 74 +++++++ backend/src/routes/health.ts | 24 +++ backend/src/routes/todos.ts | 331 ++++++++++++++++++++++++++++++++ backend/src/types/todo.ts | 25 +++ backend/tsconfig.json | 18 ++ 17 files changed, 1202 insertions(+) create mode 100644 backend/.dockerignore create mode 100644 backend/.env create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/bun.lock create mode 100644 backend/package.json create mode 100644 backend/src/config.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/lib/db.ts create mode 100644 backend/src/lib/storage.ts create mode 100644 backend/src/lib/todos-schema.ts create mode 100644 backend/src/routes/health.ts create mode 100644 backend/src/routes/todos.ts create mode 100644 backend/src/types/todo.ts create mode 100644 backend/tsconfig.json diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..f75af19 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.git +.DS_Store diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..f9c9ed5 --- /dev/null +++ b/backend/.env @@ -0,0 +1,13 @@ +PORT=8080 +NODE_ENV=development +DATABASE_URL=libsql://db-dev-63adec08f5fd35eadac3-sumit22.aws-ap-south-1.turso.io +DATABASE_AUTH_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhIjoicnciLCJpYXQiOjE3ODI3Mzg5MjcsImlkIjoiMDE5ZjEzODUtMzUwMS03ODA0LWExZTEtY2JlN2E4NDlhM2I2Iiwia2lkIjoiczltT1N1V3ZwX2pib21leUZZNzR3RVNoN1NOak12RmVDc3EtZk5YdHY1NCIsInJpZCI6IjU5OTg0M2RkLTc4NWItNDhhZS04MDI0LTgxODM2YmQzZDE0MSJ9.TsozKx4lkITuiGiQ7FQDR_xl1LiZtzExx6nDkrZgefNXI7QPWiKYrSTgapqNMlO9QiGzE_cPbcClHvCPnmgMDg +S3_ENDPOINT=https://t3.storage.dev +S3_BUCKET=plena-staging +S3_PREFIX=backends/6892099c9a4be35629c49e8c/6a426fea762485cd9a634857/dev/ +S3_ACCESS_KEY_ID=tid_VozbolUlzrxAAhDfPdanwUzAMqsO_E_EroKpPZnCcwOuXlHUKd +S3_SECRET_ACCESS_KEY=tsec_C6sLdJDir0Kz+4abePDcJIdah+zA7dni1Z5nNY3lH5f4-dd+Nr+GohZuXgZwetr1Hv-270 +S3_SESSION_TOKEN= +PROJECT_ID=6a426fea762485cd9a634857 +CORS_ORIGIN=* +LOG_LEVEL=debug diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..51409de --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +PORT=8080 +NODE_ENV=development +API_PREFIX= +CORS_ORIGIN=* +LOG_LEVEL=debug +PROJECT_ID=template-project +DATABASE_URL=libsql://your-db.turso.io +DATABASE_AUTH_TOKEN=replace-me +S3_ENDPOINT=https://t3.storage.dev +S3_BUCKET=replace-me +S3_PREFIX=backends/template-project/ +S3_ACCESS_KEY_ID=replace-me +S3_SECRET_ACCESS_KEY=replace-me +S3_SESSION_TOKEN= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fd4f2b0 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,2 @@ +node_modules +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..34de1cb --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM oven/bun:1.2.18 AS base + +WORKDIR /app + +COPY package.json bun.lock tsconfig.json ./ +RUN bun install --frozen-lockfile + +COPY src ./src + +ENV NODE_ENV=production +ENV PORT=8080 + +EXPOSE 8080 + +CMD ["bun", "src/index.ts"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7c1b3e8 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,59 @@ +# Backend Todo App Template + +Backend-only Bun + Express template intended to be snapshotted directly and mounted at `/mnt/backend`. + +## What it provides + +- `GET /health` +- `GET /todos` +- `POST /todos` +- `GET /todos/:id` +- `PUT /todos/:id` +- `DELETE /todos/:id` +- `POST /todos/:id/attachment/presign` +- `POST /todos/:id/attachment/complete` + +Todos are stored in Turso/libSQL through the HTTP pipeline API. Attachments use Tigris or any S3-compatible endpoint through presigned `PUT` URLs. + +## Runtime contract + +- app root is this folder +- start command: `bun run dev` +- listens on `PORT`, default `8080` +- compatible with a direct snapshot mounted to `/mnt/backend` + +## Setup + +```bash +cp .env.example .env +bun install +bun run dev +``` + +## Required env + +`.env` and `.env.example` contain the full key set expected by the app: + +- `PORT` +- `NODE_ENV` +- `API_PREFIX` +- `CORS_ORIGIN` +- `LOG_LEVEL` +- `PROJECT_ID` +- `DATABASE_URL` +- `DATABASE_AUTH_TOKEN` +- `S3_ENDPOINT` +- `S3_BUCKET` +- `S3_PREFIX` +- `S3_ACCESS_KEY_ID` +- `S3_SECRET_ACCESS_KEY` +- `S3_SESSION_TOKEN` + +## Attachment flow + +1. Create a todo with `POST /todos`. +2. Request a presigned upload target with `POST /todos/:id/attachment/presign`. +3. Upload the file directly to the returned `url` using the returned `method` and `headers`. +4. Finalize the attachment metadata with `POST /todos/:id/attachment/complete`. + +Deleting a todo only removes the database record in v1. It does not delete the object from storage. diff --git a/backend/bun.lock b/backend/bun.lock new file mode 100644 index 0000000..e722e1a --- /dev/null +++ b/backend/bun.lock @@ -0,0 +1,187 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "backend-todo-app-template", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^17.2.0", + "express": "^5.1.0", + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/node": "^24.0.7", + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + + "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..ff65b52 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "backend-todo-app-template", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^17.2.0", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/node": "^24.0.7", + "typescript": "^5.8.3" + } +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..0c2b134 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,81 @@ +const REQUIRED_ENV_KEYS = [ + 'PROJECT_ID', + 'DATABASE_URL', + 'DATABASE_AUTH_TOKEN', + 'S3_ENDPOINT', + 'S3_BUCKET', + 'S3_PREFIX', + 'S3_ACCESS_KEY_ID', + 'S3_SECRET_ACCESS_KEY', +] as const; + +const PLACEHOLDER_PATTERNS = [/^replace-me$/i, /^libsql:\/\/your-db\.turso\.io$/i]; + +function normalizeOptional(value: string | undefined): string { + return (value ?? '').trim(); +} + +function normalizePrefix(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + + return trimmed.startsWith('/') ? trimmed.replace(/\/+$/, '') : `/${trimmed.replace(/\/+$/, '')}`; +} + +function isPlaceholder(value: string): boolean { + return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value)); +} + +function buildMissingEnv(env: Record): string[] { + return REQUIRED_ENV_KEYS.filter((key) => { + const value = normalizeOptional(env[key]); + return !value || isPlaceholder(value); + }); +} + +export type AppConfig = { + port: number; + nodeEnv: string; + apiPrefix: string; + corsOrigin: string; + logLevel: string; + projectId: string; + databaseUrl: string; + databaseAuthToken: string; + s3Endpoint: string; + s3Bucket: string; + s3Prefix: string; + s3AccessKeyId: string; + s3SecretAccessKey: string; + s3SessionToken: string; + missingEnv: string[]; +}; + +export function getConfig(env: Record = process.env): AppConfig { + const port = Number(env.PORT || '8080'); + + return { + port: Number.isFinite(port) ? port : 8080, + nodeEnv: env.NODE_ENV || 'development', + apiPrefix: normalizePrefix(env.API_PREFIX || ''), + corsOrigin: env.CORS_ORIGIN || '*', + logLevel: env.LOG_LEVEL || 'debug', + projectId: env.PROJECT_ID || 'template-project', + databaseUrl: normalizeOptional(env.DATABASE_URL), + databaseAuthToken: normalizeOptional(env.DATABASE_AUTH_TOKEN), + s3Endpoint: env.S3_ENDPOINT || 'https://t3.storage.dev', + s3Bucket: normalizeOptional(env.S3_BUCKET), + s3Prefix: env.S3_PREFIX || '', + s3AccessKeyId: normalizeOptional(env.S3_ACCESS_KEY_ID), + s3SecretAccessKey: normalizeOptional(env.S3_SECRET_ACCESS_KEY), + s3SessionToken: normalizeOptional(env.S3_SESSION_TOKEN), + missingEnv: buildMissingEnv(env), + }; +} + +export function buildApiPath(config: AppConfig, path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${config.apiPrefix}${normalizedPath}` || normalizedPath; +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..8fd6617 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,37 @@ +import 'dotenv/config'; +import cors from 'cors'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import { buildApiPath, getConfig } from './config'; +import { TursoClient } from './lib/db'; +import { createHealthRouter } from './routes/health'; +import { createTodosRouter } from './routes/todos'; + +const config = getConfig(); +const app = express(); +const db = new TursoClient(config); + +app.disable('x-powered-by'); +app.use(cors({ origin: config.corsOrigin === '*' ? true : config.corsOrigin })); +app.use(express.json({ limit: '1mb' })); + +const apiRoot = config.apiPrefix || '/'; +app.use(apiRoot, createHealthRouter(config)); +app.use(apiRoot, createTodosRouter(config, db)); + +app.use((_req, res) => { + res.status(404).json({ success: false, error: 'Not found' }); +}); + +app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => { + const status = /required|invalid|missing/i.test(error.message) ? 400 : 500; + res.status(status).json({ + success: false, + error: error.message || 'Unhandled error', + }); +}); + +app.listen(config.port, '0.0.0.0', () => { + const healthPath = buildApiPath(config, '/health'); + console.log(`Backend todo template listening on port ${config.port}`); + console.log(`Health check available at ${healthPath}`); +}); diff --git a/backend/src/lib/db.ts b/backend/src/lib/db.ts new file mode 100644 index 0000000..9ece18f --- /dev/null +++ b/backend/src/lib/db.ts @@ -0,0 +1,158 @@ +import type { AppConfig } from '../config'; +import { TODOS_TABLE_SQL } from './todos-schema'; + +type TursoPipelineResponse = { + results?: Array<{ + type?: string; + response?: { + result?: { + cols?: Array<{ name?: string } | string>; + rows?: unknown[][]; + affected_row_count?: number; + }; + error?: { + message?: string; + }; + }; + }>; +}; + +function getTursoHttpBase(databaseUrl: string): string { + if (!databaseUrl) { + return ''; + } + + if (databaseUrl.startsWith('libsql://')) { + return `https://${databaseUrl.slice('libsql://'.length)}`; + } + + return databaseUrl; +} + +function extractCellValue(cell: unknown): unknown { + if (cell == null) { + return null; + } + + if (typeof cell === 'object') { + const cellObj = cell as Record; + if (cellObj.type === 'null') { + return null; + } + if ('value' in cellObj) { + return cellObj.value; + } + if ('base64' in cellObj) { + return cellObj.base64; + } + } + + return cell; +} + +function formatSqlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +export function sqlValue(value: string | number | boolean | null | undefined): string { + if (value == null) { + return 'NULL'; + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('Invalid numeric SQL value'); + } + return String(value); + } + + if (typeof value === 'boolean') { + return value ? '1' : '0'; + } + + return formatSqlString(value); +} + +export class TursoClient { + private readonly baseUrl: string; + + constructor(private readonly config: AppConfig) { + this.baseUrl = getTursoHttpBase(config.databaseUrl); + } + + private assertConfigured() { + if (!this.baseUrl || !this.config.databaseAuthToken) { + throw new Error('Database env missing'); + } + } + + async execute(sql: string): Promise { + this.assertConfigured(); + + const response = await fetch(`${this.baseUrl}/v2/pipeline`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.databaseAuthToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + requests: [ + { + type: 'execute', + stmt: { sql }, + }, + ], + }), + }); + + const text = await response.text(); + let payload: TursoPipelineResponse | { raw: string }; + + try { + payload = JSON.parse(text) as TursoPipelineResponse; + } catch { + payload = { raw: text }; + } + + if (!response.ok) { + const details = typeof payload === 'object' ? JSON.stringify(payload) : text; + throw new Error(`Turso query failed: ${details}`); + } + + const typedPayload = payload as TursoPipelineResponse; + const resultObj = typedPayload.results?.[0]; + const errorMessage = (resultObj as any)?.response?.error?.message || (resultObj as any)?.error?.message; + if (errorMessage) { + throw new Error(`Turso query failed: ${errorMessage}`); + } + + return typedPayload; + } + + async rows(sql: string): Promise[]> { + const response = await this.execute(sql); + const result = response.results?.[0]?.response?.result; + const cols = result?.cols ?? []; + const rows = result?.rows ?? []; + const columnNames = cols.map((col) => (typeof col === 'string' ? col : col.name || '')); + + return rows.map((row) => { + const record: Record = {}; + + row.forEach((cell, index) => { + record[columnNames[index] || String(index)] = extractCellValue(cell); + }); + + return record; + }); + } + + async first(sql: string): Promise | null> { + const rows = await this.rows(sql); + return rows[0] ?? null; + } + + async ensureTodosTable(): Promise { + await this.execute(TODOS_TABLE_SQL); + } +} diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts new file mode 100644 index 0000000..ea49aa1 --- /dev/null +++ b/backend/src/lib/storage.ts @@ -0,0 +1,139 @@ +import { createHash, createHmac } from 'node:crypto'; +import type { AppConfig } from '../config'; +import type { PresignedUpload } from '../types/todo'; + +function hashHex(data: string): string { + return createHash('sha256').update(data).digest('hex'); +} + +function hmac(key: Buffer | string, data: string, encoding?: 'hex'): Buffer | string { + const digest = createHmac('sha256', key).update(data).digest(); + return encoding ? digest.toString(encoding) : digest; +} + +function encodeRfc3986(value: string): string { + return encodeURIComponent(value).replace(/[!'()*]/g, (char) => + `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +function toAmzDate(date: Date): string { + return date.toISOString().replace(/[:-]|\.\d{3}/g, ''); +} + +function buildScope(shortDate: string, region: string, service: string): string { + return `${shortDate}/${region}/${service}/aws4_request`; +} + +function signString(secretAccessKey: string, shortDate: string, region: string, service: string, stringToSign: string): string { + const kDate = hmac(`AWS4${secretAccessKey}`, shortDate) as Buffer; + const kRegion = hmac(kDate, region) as Buffer; + const kService = hmac(kRegion, service) as Buffer; + const kSigning = hmac(kService, 'aws4_request') as Buffer; + return hmac(kSigning, stringToSign, 'hex') as string; +} + +function sanitizeFilename(filename: string): string { + const trimmed = filename.trim(); + const base = trimmed || 'attachment.bin'; + return base.replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +function normalizePrefix(prefix: string): string { + const trimmed = prefix.trim(); + if (!trimmed) { + return ''; + } + + return trimmed.replace(/^\/+/, '').replace(/\/+$/, ''); +} + +function joinKey(...parts: string[]): string { + return parts + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => part.replace(/^\/+/, '').replace(/\/+$/, '')) + .join('/'); +} + +function encodeObjectPath(bucket: string, key: string): string { + const encodedKey = key + .split('/') + .filter(Boolean) + .map((segment) => encodeRfc3986(segment)) + .join('/'); + + return `/${encodeRfc3986(bucket)}${encodedKey ? `/${encodedKey}` : ''}`; +} + +export function buildAttachmentUrl(config: AppConfig, key: string): string { + const endpoint = new URL(config.s3Endpoint); + const path = encodeObjectPath(config.s3Bucket, key); + return `${endpoint.origin}${path}`; +} + +export function createAttachmentKey(config: AppConfig, todoId: number, filename: string): string { + const prefix = normalizePrefix(config.s3Prefix); + const safeFilename = sanitizeFilename(filename); + return joinKey(prefix, 'todos', String(todoId), `${Date.now()}-${safeFilename}`); +} + +export function presignTodoAttachmentUpload( + config: AppConfig, + todoId: number, + filename: string, + contentType?: string, +): PresignedUpload { + if (!config.s3Bucket || !config.s3AccessKeyId || !config.s3SecretAccessKey) { + throw new Error('Storage env missing'); + } + + const endpoint = new URL(config.s3Endpoint); + const region = 'auto'; + const service = 's3'; + const now = new Date(); + const amzDate = toAmzDate(now); + const shortDate = amzDate.slice(0, 8); + const key = createAttachmentKey(config, todoId, filename); + const canonicalUri = encodeObjectPath(config.s3Bucket, key); + const scope = buildScope(shortDate, region, service); + const params: Record = { + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': `${config.s3AccessKeyId}/${scope}`, + 'X-Amz-Date': amzDate, + 'X-Amz-Expires': '900', + 'X-Amz-SignedHeaders': 'host', + }; + + if (config.s3SessionToken) { + params['X-Amz-Security-Token'] = config.s3SessionToken; + } + + const canonicalQuery = Object.keys(params) + .sort() + .map((name) => `${encodeRfc3986(name)}=${encodeRfc3986(params[name])}`) + .join('&'); + + const canonicalRequest = [ + 'PUT', + canonicalUri, + canonicalQuery, + `host:${endpoint.host}\n`, + 'host', + 'UNSIGNED-PAYLOAD', + ].join('\n'); + + const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, hashHex(canonicalRequest)].join('\n'); + const signature = signString(config.s3SecretAccessKey, shortDate, region, service, stringToSign); + const url = `${endpoint.origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`; + + return { + url, + method: 'PUT', + headers: { + 'content-type': contentType || 'application/octet-stream', + }, + key, + attachmentUrl: buildAttachmentUrl(config, key), + }; +} diff --git a/backend/src/lib/todos-schema.ts b/backend/src/lib/todos-schema.ts new file mode 100644 index 0000000..a027e8c --- /dev/null +++ b/backend/src/lib/todos-schema.ts @@ -0,0 +1,74 @@ +import type { Todo, TodoAttachment } from '../types/todo'; + +export const TODOS_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + completed INTEGER NOT NULL DEFAULT 0, + attachment_key TEXT, + attachment_filename TEXT, + attachment_content_type TEXT, + attachment_size_bytes INTEGER, + attachment_url TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +) +`; + +export const TODO_SELECT_COLUMNS = [ + 'id', + 'title', + 'description', + 'completed', + 'attachment_key', + 'attachment_filename', + 'attachment_content_type', + 'attachment_size_bytes', + 'attachment_url', + 'created_at', + 'updated_at', +].join(', '); + +function toNullableString(value: unknown): string | null { + return value == null ? null : String(value); +} + +function toNullableNumber(value: unknown): number | null { + if (value == null || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function toAttachment(row: Record): TodoAttachment | null { + const key = toNullableString(row.attachment_key); + const filename = toNullableString(row.attachment_filename); + const url = toNullableString(row.attachment_url); + + if (!key || !filename || !url) { + return null; + } + + return { + key, + filename, + contentType: toNullableString(row.attachment_content_type), + sizeBytes: toNullableNumber(row.attachment_size_bytes), + url, + }; +} + +export function mapTodoRow(row: Record): Todo { + return { + id: Number(row.id), + title: String(row.title ?? ''), + description: toNullableString(row.description), + completed: Number(row.completed ?? 0) === 1, + attachment: toAttachment(row), + createdAt: String(row.created_at ?? ''), + updatedAt: String(row.updated_at ?? ''), + }; +} diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 0000000..a1d4d61 --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,24 @@ +import { Router } from 'express'; +import type { AppConfig } from '../config'; + +export function createHealthRouter(config: AppConfig): Router { + const router = Router(); + + router.get('/health', (_req, res) => { + const ready = config.missingEnv.length === 0; + + res.status(200).json({ + status: 'ok', + projectId: config.projectId, + ready, + config: { + apiPrefix: config.apiPrefix, + databaseConfigured: !!config.databaseUrl && !!config.databaseAuthToken, + storageConfigured: !!config.s3Bucket && !!config.s3AccessKeyId && !!config.s3SecretAccessKey, + missingEnv: config.missingEnv, + }, + }); + }); + + return router; +} diff --git a/backend/src/routes/todos.ts b/backend/src/routes/todos.ts new file mode 100644 index 0000000..bd5e39f --- /dev/null +++ b/backend/src/routes/todos.ts @@ -0,0 +1,331 @@ +import { Router, type Request, type Response, type NextFunction } from 'express'; +import type { AppConfig } from '../config'; +import { TursoClient, sqlValue } from '../lib/db'; +import { TODO_SELECT_COLUMNS, mapTodoRow } from '../lib/todos-schema'; +import { presignTodoAttachmentUpload } from '../lib/storage'; +import type { TodoAttachment } from '../types/todo'; + +type AttachmentUpdateInput = + | { mode: 'keep' } + | { mode: 'clear' } + | { mode: 'replace'; attachment: TodoAttachment }; + +function asyncHandler(handler: (req: Request, res: Response, next: NextFunction) => Promise) { + return (req: Request, res: Response, next: NextFunction) => { + void handler(req, res, next).catch(next); + }; +} + +function normalizeTodoId(rawId: string | string[] | undefined): number | null { + if (Array.isArray(rawId)) { + return null; + } + + const id = Number(rawId); + return Number.isInteger(id) && id > 0 ? id : null; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function parseAttachmentUpdate(body: Record): AttachmentUpdateInput { + if (!Object.prototype.hasOwnProperty.call(body, 'attachment')) { + return { mode: 'keep' }; + } + + if (body.attachment == null) { + return { mode: 'clear' }; + } + + if (typeof body.attachment !== 'object') { + throw new Error('attachment must be an object or null'); + } + + const attachment = body.attachment as Record; + const key = readString(attachment.key)?.trim(); + const filename = readString(attachment.filename)?.trim(); + const url = readString(attachment.url)?.trim(); + const contentType = readString(attachment.contentType)?.trim() || null; + const sizeBytes = attachment.sizeBytes == null ? null : Number(attachment.sizeBytes); + + if (!key || !filename || !url) { + throw new Error('attachment.key, attachment.filename, and attachment.url are required'); + } + + if (sizeBytes != null && !Number.isFinite(sizeBytes)) { + throw new Error('attachment.sizeBytes must be a number'); + } + + return { + mode: 'replace', + attachment: { + key, + filename, + contentType, + sizeBytes, + url, + }, + }; +} + +function applyAttachmentUpdate(attachmentUpdate: AttachmentUpdateInput, updates: string[]) { + if (attachmentUpdate.mode === 'keep') { + return; + } + + if (attachmentUpdate.mode === 'clear') { + updates.push( + 'attachment_key = NULL', + 'attachment_filename = NULL', + 'attachment_content_type = NULL', + 'attachment_size_bytes = NULL', + 'attachment_url = NULL', + ); + return; + } + + updates.push( + `attachment_key = ${sqlValue(attachmentUpdate.attachment.key)}`, + `attachment_filename = ${sqlValue(attachmentUpdate.attachment.filename)}`, + `attachment_content_type = ${sqlValue(attachmentUpdate.attachment.contentType)}`, + `attachment_size_bytes = ${sqlValue(attachmentUpdate.attachment.sizeBytes)}`, + `attachment_url = ${sqlValue(attachmentUpdate.attachment.url)}`, + ); +} + +async function requireTodo(db: TursoClient, id: number) { + const row = await db.first(`SELECT ${TODO_SELECT_COLUMNS} FROM todos WHERE id = ${id} LIMIT 1`); + return row ? mapTodoRow(row) : null; +} + +export function createTodosRouter(config: AppConfig, db: TursoClient): Router { + const router = Router(); + + router.use( + asyncHandler(async (_req, _res, next) => { + await db.ensureTodosTable(); + next(); + }), + ); + + router.get( + '/todos', + asyncHandler(async (_req, res) => { + const rows = await db.rows(`SELECT ${TODO_SELECT_COLUMNS} FROM todos ORDER BY id DESC`); + res.status(200).json({ + success: true, + todos: rows.map(mapTodoRow), + }); + }), + ); + + router.post( + '/todos', + asyncHandler(async (req, res) => { + const title = readString(req.body?.title)?.trim() || ''; + const description = readString(req.body?.description); + + if (!title) { + res.status(400).json({ success: false, error: 'title is required' }); + return; + } + + const row = await db.first( + ` + INSERT INTO todos (title, description, completed, updated_at) + VALUES (${sqlValue(title)}, ${sqlValue(description ?? null)}, 0, CURRENT_TIMESTAMP) + RETURNING ${TODO_SELECT_COLUMNS} + `, + ); + + res.status(201).json({ + success: true, + todo: row ? mapTodoRow(row) : null, + }); + }), + ); + + router.get( + '/todos/:id', + asyncHandler(async (req, res) => { + const id = normalizeTodoId(req.params.id); + if (!id) { + res.status(400).json({ success: false, error: 'Invalid todo id' }); + return; + } + + const todo = await requireTodo(db, id); + if (!todo) { + res.status(404).json({ success: false, error: 'Todo not found' }); + return; + } + + res.status(200).json({ success: true, todo }); + }), + ); + + router.put( + '/todos/:id', + asyncHandler(async (req, res) => { + const id = normalizeTodoId(req.params.id); + if (!id) { + res.status(400).json({ success: false, error: 'Invalid todo id' }); + return; + } + + const body = (req.body ?? {}) as Record; + const updates: string[] = []; + const attachmentUpdate = parseAttachmentUpdate(body); + + if (Object.prototype.hasOwnProperty.call(body, 'title')) { + const title = readString(body.title)?.trim() || ''; + if (!title) { + res.status(400).json({ success: false, error: 'title is required' }); + return; + } + updates.push(`title = ${sqlValue(title)}`); + } + + if (Object.prototype.hasOwnProperty.call(body, 'description')) { + const description = body.description == null ? null : readString(body.description); + if (body.description != null && description == null) { + res.status(400).json({ success: false, error: 'description must be a string or null' }); + return; + } + updates.push(`description = ${sqlValue(description)}`); + } + + if (Object.prototype.hasOwnProperty.call(body, 'completed')) { + if (typeof body.completed !== 'boolean') { + res.status(400).json({ success: false, error: 'completed must be a boolean' }); + return; + } + updates.push(`completed = ${sqlValue(body.completed)}`); + } + + applyAttachmentUpdate(attachmentUpdate, updates); + + if (updates.length === 0) { + res.status(400).json({ success: false, error: 'No updatable fields provided' }); + return; + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + const row = await db.first( + `UPDATE todos SET ${updates.join(', ')} WHERE id = ${id} RETURNING ${TODO_SELECT_COLUMNS}`, + ); + + if (!row) { + res.status(404).json({ success: false, error: 'Todo not found' }); + return; + } + + res.status(200).json({ success: true, todo: mapTodoRow(row) }); + }), + ); + + router.delete( + '/todos/:id', + asyncHandler(async (req, res) => { + const id = normalizeTodoId(req.params.id); + if (!id) { + res.status(400).json({ success: false, error: 'Invalid todo id' }); + return; + } + + const row = await db.first(`DELETE FROM todos WHERE id = ${id} RETURNING id`); + if (!row) { + res.status(404).json({ success: false, error: 'Todo not found' }); + return; + } + + res.status(200).json({ success: true, deletedId: id }); + }), + ); + + router.post( + '/todos/:id/attachment/presign', + asyncHandler(async (req, res) => { + const id = normalizeTodoId(req.params.id); + if (!id) { + res.status(400).json({ success: false, error: 'Invalid todo id' }); + return; + } + + const todo = await requireTodo(db, id); + if (!todo) { + res.status(404).json({ success: false, error: 'Todo not found' }); + return; + } + + const filename = readString(req.body?.filename)?.trim() || ''; + const contentType = readString(req.body?.contentType)?.trim() || undefined; + if (!filename) { + res.status(400).json({ success: false, error: 'filename is required' }); + return; + } + + const upload = presignTodoAttachmentUpload(config, id, filename, contentType); + res.status(200).json({ + success: true, + ...upload, + }); + }), + ); + + router.post( + '/todos/:id/attachment/complete', + asyncHandler(async (req, res) => { + const id = normalizeTodoId(req.params.id); + if (!id) { + res.status(400).json({ success: false, error: 'Invalid todo id' }); + return; + } + + const key = readString(req.body?.key)?.trim() || ''; + const filename = readString(req.body?.filename)?.trim() || ''; + const attachmentUrl = readString(req.body?.attachmentUrl)?.trim() || ''; + const contentType = readString(req.body?.contentType)?.trim() || null; + const rawSizeBytes = req.body?.sizeBytes; + const sizeBytes = rawSizeBytes == null ? null : Number(rawSizeBytes); + + if (!key || !filename || !attachmentUrl) { + res.status(400).json({ + success: false, + error: 'key, filename, and attachmentUrl are required', + }); + return; + } + + if (sizeBytes != null && (!Number.isFinite(sizeBytes) || sizeBytes < 0)) { + res.status(400).json({ success: false, error: 'sizeBytes must be a non-negative number' }); + return; + } + + const row = await db.first( + ` + UPDATE todos + SET + attachment_key = ${sqlValue(key)}, + attachment_filename = ${sqlValue(filename)}, + attachment_content_type = ${sqlValue(contentType)}, + attachment_size_bytes = ${sqlValue(sizeBytes)}, + attachment_url = ${sqlValue(attachmentUrl)}, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${id} + RETURNING ${TODO_SELECT_COLUMNS} + `, + ); + + if (!row) { + res.status(404).json({ success: false, error: 'Todo not found' }); + return; + } + + res.status(200).json({ success: true, todo: mapTodoRow(row) }); + }), + ); + + return router; +} diff --git a/backend/src/types/todo.ts b/backend/src/types/todo.ts new file mode 100644 index 0000000..786ea02 --- /dev/null +++ b/backend/src/types/todo.ts @@ -0,0 +1,25 @@ +export type TodoAttachment = { + key: string; + filename: string; + contentType: string | null; + sizeBytes: number | null; + url: string; +}; + +export type Todo = { + id: number; + title: string; + description: string | null; + completed: boolean; + createdAt: string; + updatedAt: string; + attachment: TodoAttachment | null; +}; + +export type PresignedUpload = { + url: string; + method: 'PUT'; + headers: Record; + key: string; + attachmentUrl: string; +}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..d53e940 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "node" + ], + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ] +}