Files
platform/docs/new-service-guide.md
Yusuf Suleman 6023ebf9d0 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>
2026-03-30 15:35:57 -05:00

7.3 KiB

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

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

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)

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)

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:

  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:

  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():

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

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:

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:

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