feat: tasks app, security hardening, mobile fixes, iOS app shell
- Custom SQLite task manager replacing TickTick wrapper - 73 tasks migrated from TickTick across 15 projects - RRULE recurrence engine with lazy materialization - Dashboard tasks widget (desktop sidebar + mobile card) - Tasks page with project tabs, add/edit/complete/delete - Security: locked ports to localhost, removed old containers - Gitea Actions runner configured and all 3 CI jobs passing - Fixed mobile overflow on dashboard cards - iOS Capacitor app shell (Second Brain) - Frontend/backend guide docs for adding new services - TickTick Google Calendar sync re-authorized Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
281
docs/new-service-guide.md
Normal file
281
docs/new-service-guide.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Adding a New Backend Service
|
||||
|
||||
Step-by-step for adding a new backend service to the platform.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create the service directory
|
||||
|
||||
```
|
||||
services/yourservice/
|
||||
server.py # or server.js
|
||||
Dockerfile
|
||||
.env # (optional, for local secrets)
|
||||
data/ # (created at runtime, for SQLite services)
|
||||
```
|
||||
|
||||
## 2. Python service template
|
||||
|
||||
```python
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
PORT = int(os.environ.get("PORT", 8099))
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
|
||||
DB_PATH = DATA_DIR / "yourservice.db"
|
||||
|
||||
# --- Database ---
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# --- Migrations (add columns safely) ---
|
||||
# try:
|
||||
# c.execute("ALTER TABLE items ADD COLUMN description TEXT DEFAULT ''")
|
||||
# except: pass
|
||||
|
||||
# --- Handler ---
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
return json.loads(self.rfile.read(length)) if length else {}
|
||||
|
||||
def _send_json(self, data, status=200):
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
|
||||
# Health check (before auth, unauthenticated)
|
||||
if path == "/api/health":
|
||||
self._send_json({"status": "ok"})
|
||||
return
|
||||
|
||||
# --- Your routes ---
|
||||
if path == "/api/items":
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM items ORDER BY created_at DESC").fetchall()
|
||||
conn.close()
|
||||
self._send_json({"items": [dict(r) for r in rows]})
|
||||
return
|
||||
|
||||
self._send_json({"error": "Not found"}, 404)
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
body = self._read_body()
|
||||
|
||||
if path == "/api/items":
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute("INSERT INTO items (name) VALUES (?)", (body.get("name", ""),))
|
||||
conn.commit()
|
||||
item_id = c.lastrowid
|
||||
conn.close()
|
||||
self._send_json({"id": item_id}, 201)
|
||||
return
|
||||
|
||||
self._send_json({"error": "Not found"}, 404)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress request logs (gateway logs instead)
|
||||
|
||||
# --- Start ---
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
from http.server import ThreadingHTTPServer
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
print(f"Service listening on port {PORT}")
|
||||
server.serve_forever()
|
||||
```
|
||||
|
||||
## 3. Node/Express service template
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3099;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Health (before auth middleware)
|
||||
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
// API key auth middleware
|
||||
const SERVICE_API_KEY = process.env.SERVICE_API_KEY || '';
|
||||
if (SERVICE_API_KEY) {
|
||||
app.use((req, res, next) => {
|
||||
const key = req.headers['x-api-key'] || req.query.api_key;
|
||||
if (key !== SERVICE_API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Routes
|
||||
app.get('/items', async (req, res) => {
|
||||
// ...
|
||||
});
|
||||
|
||||
app.listen(port, () => console.log(`Listening on ${port}`));
|
||||
```
|
||||
|
||||
## 4. Dockerfile (Python)
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies (add as needed)
|
||||
RUN pip install --no-cache-dir bcrypt
|
||||
|
||||
# Non-root user
|
||||
RUN adduser --disabled-password --no-create-home appuser
|
||||
RUN mkdir -p /app/data && chown -R appuser /app/data
|
||||
|
||||
COPY --chown=appuser server.py .
|
||||
|
||||
EXPOSE 8099
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/api/health', timeout=3)" || exit 1
|
||||
|
||||
USER appuser
|
||||
CMD ["python3", "server.py"]
|
||||
```
|
||||
|
||||
## 5. Dockerfile (Node)
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY server.js ./
|
||||
|
||||
EXPOSE 3099
|
||||
ENV NODE_ENV=production
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3099/health || exit 1
|
||||
|
||||
USER node
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
## 6. Docker Compose entry
|
||||
|
||||
Add to `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
yourservice:
|
||||
build:
|
||||
context: ./services/yourservice
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform-yourservice
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./services/yourservice/data:/app/data
|
||||
environment:
|
||||
- PORT=8099
|
||||
- SERVICE_API_KEY=${YOURSERVICE_API_KEY} # for API-key auth
|
||||
- TZ=${TZ:-America/Chicago}
|
||||
```
|
||||
|
||||
Add to the `gateway` service:
|
||||
```yaml
|
||||
gateway:
|
||||
environment:
|
||||
- YOURSERVICE_BACKEND_URL=http://yourservice:8099
|
||||
- YOURSERVICE_API_KEY=${YOURSERVICE_API_KEY}
|
||||
depends_on:
|
||||
- yourservice
|
||||
```
|
||||
|
||||
## 7. Gateway integration
|
||||
|
||||
### a) Register the app in `gateway/database.py`
|
||||
|
||||
Add a migration block in `_seed_apps()`:
|
||||
|
||||
```python
|
||||
try:
|
||||
c.execute("""INSERT INTO apps (id, name, icon, route_prefix, proxy_target, sort_order, enabled, dashboard_widget)
|
||||
VALUES ('yourservice', 'Your Service', 'icon-name', '/yourservice', ?, 8, 1, NULL)""",
|
||||
(YOURSERVICE_BACKEND_URL,))
|
||||
except: pass
|
||||
```
|
||||
|
||||
### b) Add URL to `gateway/config.py`
|
||||
|
||||
```python
|
||||
YOURSERVICE_BACKEND_URL = os.environ.get("YOURSERVICE_BACKEND_URL", "http://yourservice:8099")
|
||||
```
|
||||
|
||||
### c) Add auth injection in `gateway/server.py`
|
||||
|
||||
In the `_proxy()` method, add to the credential injection block:
|
||||
|
||||
```python
|
||||
elif service_id == "yourservice":
|
||||
headers["X-API-Key"] = YOURSERVICE_API_KEY
|
||||
```
|
||||
|
||||
### d) If Node/Express: skip `/api` prefix
|
||||
|
||||
In `gateway/proxy.py`, add to `NO_API_PREFIX_SERVICES`:
|
||||
|
||||
```python
|
||||
NO_API_PREFIX_SERVICES = {"inventory", "budget", "yourservice"}
|
||||
```
|
||||
|
||||
This makes `/api/yourservice/items` proxy to `http://yourservice:8099/items`
|
||||
instead of `http://yourservice:8099/api/items`.
|
||||
|
||||
Python services typically expect the `/api` prefix, so don't add them here.
|
||||
|
||||
## 8. Checklist
|
||||
|
||||
- [ ] `services/yourservice/server.py` (or `.js`) with `/health` endpoint
|
||||
- [ ] `services/yourservice/Dockerfile` — non-root user, healthcheck, minimal copy
|
||||
- [ ] `docker-compose.yml` — service entry + gateway env vars + depends_on
|
||||
- [ ] `gateway/config.py` — backend URL from env
|
||||
- [ ] `gateway/database.py` — app registration in seed
|
||||
- [ ] `gateway/server.py` — auth header injection in `_proxy()`
|
||||
- [ ] `gateway/proxy.py` — add to `NO_API_PREFIX_SERVICES` if Node
|
||||
- [ ] `frontend-v2/src/routes/(app)/yourservice/+page.svelte` — UI page
|
||||
- [ ] Nav registration in `+layout.server.ts`, `Navbar.svelte`, `MobileTabBar.svelte`
|
||||
- [ ] `.env` — add `YOURSERVICE_API_KEY` (or other secrets)
|
||||
- [ ] `docker compose build yourservice gateway && docker compose up -d`
|
||||
Reference in New Issue
Block a user