Skip to content

Latest commit

 

History

History
517 lines (368 loc) · 22 KB

File metadata and controls

517 lines (368 loc) · 22 KB

Deployment

taskTimer is a Linux desktop application. There is no separate HTTP API service in this repository; distribution targets are the standalone AppImage, the GNOME Shell extension zip, and running from source with GJS.

For code layout and what is not in this repo (Go main.go, handlers_test.go, etc.), see architecture.md.

End users

Channel Notes
AppImage Built with make appimage or downloaded from GitHub Releases; see BUILD.md.
GNOME extension make pack → install the .zip per README.md and BUILD.md.
From source gjs main.js after installing GObject Introspection deps (README.md).

Release automation (tags, changelog notes, pre-releases) is described in CHANGELOG.md and .github/workflows/release.yml.

Release workflow artifacts (tag push)

On a version tag push, .github/workflows/release.yml builds and publishes the following GitHub Release assets:

  • AppImage (Linux “binary”): packaging/appimage/dist/*.AppImage
  • Checksums: packaging/appimage/dist/SHA256SUMS (SHA-256 of the AppImage and SBOM JSON files)
  • SBOM (npm dev tooling): dist/sbom/tasktimer-cyclonedx.json (CycloneDX), dist/sbom/tasktimer-spdx.json (SPDX)

Frontend dist/ and Go binaries (checklist note)

This repository currently has no Go module and no frontend build output directory (there is no go build binary and no frontend/dist/).

If a future version of this repo adds a Go CLI and/or a built web frontend, the release workflow should additionally attach:

  • A Linux binary built by go build
  • Any frontend dist/ bundle(s)
  • Corresponding SHA256 sums for each artifact

SBOM (Task 65)

SBOMs describe npm dev tooling only (package-lock.json — ESLint, Playwright, webpack budget, etc.). The shipped GJS/GTK app has no npm runtime dependencies. If a go.mod is added later, extend bin/generate-sbom.sh (e.g. go version -m or Syft) and attach additional SBOM assets on release.

Generate locally (same as release workflow):

npm ci
npm run sbom

Outputs:

File Format
dist/sbom/tasktimer-cyclonedx.json CycloneDX (JSON) via @cyclonedx/cyclonedx-npm
dist/sbom/tasktimer-spdx.json SPDX via npx npm@10.9.2 sbom (npm 10 sbom subcommand)

Release: .github/workflows/release.yml runs npm run sbom before uploading GitHub Release assets (see Release workflow artifacts above).

Docker (Dockerfile.api)

The multi-stage Dockerfile.api is not a REST API container. It provides:

  1. builder — installs GJS/GTK/GStreamer + xvfb/dbus, copies the tree, and runs a smoke check (gjs import probe + gjs main.js --version under xvfb + dbus-run-session). The full make test / make lint pipeline runs in .github/workflows/ci.yml; some tests assume a normal user home and can fail in arbitrary containers.
  2. runtime — slimmer image with GJS + GTK + GStreamer typelibs and the checked-out tree; default CMD runs gjs main.js --version (no GUI; validates that the app loads far enough to print the version).

Build

From the repository root:

docker build -f Dockerfile.api -t tasktimer:dev .

Run (version smoke)

docker run --rm tasktimer:dev

Interactive shell (optional)

docker run --rm -it --entrypoint bash tasktimer:dev

A full windowed app in Docker requires X11/Wayland forwarding and is out of scope here; use a normal desktop install or AppImage for UI testing.

Relation to CI

.github/workflows/ci.yml runs the same make lint / make test steps on GitHub-hosted runners. The Docker image is optional: use it when you want a reproducible, local environment close to CI without installing all packages on the host.

CSP + security headers (Task 66)

Status in this repository: there is no HTTP server or securityHeadersMiddleware on the shipped GTK/Shell app. Policy lives in tooling/security_headers_middleware.mjs as the source of truth for a future static UI or API gateway.

What the middleware sets

Header Value (default options)
Content-Security-Policy default-src 'self'; script-src 'self' (bundled assets under same origin, e.g. /static/main.js or dist/webpack-budget/main.js); connect-src 'self' http://localhost/mock (MSW/E2E API today); frame-ancestors 'none'; upgrade-insecure-requests
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Resource-Policy same-origin

Tune CSP for production: pass apiOrigin: 'https://api.example.com' (and optionally staticOrigin: "'self'") when calling securityHeaders() / securityHeadersMiddleware. Keep script-src on 'self' only if the SPA is built with hashed bundles (no inline scripts). Set allowInlineStyles: false when CSS is fully external.

Node (reference):

import { createServer } from 'node:http';
import { securityHeadersMiddleware } from '../tooling/security_headers_middleware.mjs';

createServer((req, res) => {
    securityHeadersMiddleware(req, res, () => {
        // serve static files from dist/ or frontend/dist/
    }, { apiOrigin: 'https://api.example.com' });
}).listen(8080);

nginx reverse proxy (must match middleware defaults)

Use the same CSP string as buildContentSecurityPolicy() with default options (verify after changing the .mjs file):

# /etc/nginx/snippets/tasktimer-security-headers.conf
# Mirror tooling/security_headers_middleware.mjs (default apiOrigin + staticOrigin).

add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' http://localhost/mock; upgrade-insecure-requests" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
server {
    listen 443 ssl http2;
    server_name app.example.com;

    include snippets/tasktimer-security-headers.conf;

    root /var/www/tasktimer/frontend/dist;
    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass https://api.example.com/;
        proxy_set_header Host $host;
        # Re-apply snippet on upstream responses if this vhost terminates TLS for the browser.
    }
}

For production, replace http://localhost/mock in connect-src with your real API origin (same value as apiOrigin in the middleware). Static assets must be served from the same origin as the HTML ('self') or update staticOrigin / CSP accordingly.

CORS + cookie flags (Task 67)

Status: N/A for the shipped GTK app (no session cookies over HTTP). Policy for a production SPA is defined in tooling/cors_cookie_policy.mjs and implemented in packaging/caddy/Caddyfile / Dockerfile.caddy.

Recommended production model (same origin)

Serve the SPA and reverse-proxy /api/* on one HTTPS host (e.g. https://app.example.com). The browser sees a single origin — no CORS preflight for typical fetch('/api/...') calls.

Concern Policy
Cookies Set-Cookie: session=…; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax (see SESSION_COOKIE_SAME_ORIGIN in cors_cookie_policy.mjs)
SPA fetch Same-origin requests; use credentials: 'include' only if the API sets cookies on that host
CORS Not required for browser calls to /api on the same host
CSP connect-src 'self' only (see packaging/caddy/Caddyfile — matches same-origin proxy)

Build/run reference proxy:

docker build -f Dockerfile.caddy -t tasktimer:caddy .
docker run --rm -p 8080:8080 -e API_UPSTREAM=host.docker.internal:3000 tasktimer:caddy

Open http://127.0.0.1:8080/ (placeholder static root under packaging/caddy/www/).

Cross-origin API (separate subdomain)

If the SPA stays on https://app.example.com and the API on https://api.example.com:

Concern Policy
CORS Access-Control-Allow-Origin: https://app.example.com (exact SPA origin, not *), Access-Control-Allow-Credentials: true, methods/headers per corsHeadersForCredentialedSpa()
Cookies SameSite=None; Secure; HttpOnly on the API host (SESSION_COOKIE_CROSS_ORIGIN)
SPA fetch fetch('https://api.example.com/...', { credentials: 'include' })

nginx (same-origin SPA + /api proxy)

Matches Dockerfile.caddy / Caddyfile behavior (security headers from Task 66; cookies set by upstream API):

# Upstream API should send Set-Cookie consistent with SESSION_COOKIE_SAME_ORIGIN:
#   HttpOnly; Secure; SameSite=Lax; Path=/

server {
    listen 443 ssl http2;
    server_name app.example.com;

    include snippets/tasktimer-security-headers.conf;
    # Same-origin CSP: connect-src 'self' (see packaging/caddy/Caddyfile)
    add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; upgrade-insecure-requests" always;

    root /var/www/tasktimer/frontend/dist;
    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass https://api-internal:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        # Pass Set-Cookie from API; do not strip Secure/HttpOnly/SameSite=Lax
        proxy_pass_header Set-Cookie;
    }
}

nginx (cross-origin API — credentialed CORS)

On api.example.com (API vhost), mirror corsHeadersForCredentialedSpa('https://app.example.com'):

# /etc/nginx/snippets/tasktimer-cors-credentialed.conf
set $cors_origin "https://app.example.com";

add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age "86400" always;
add_header Vary "Origin" always;

if ($request_method = OPTIONS) {
    return 204;
}

Upstream login/session handlers should emit:

Set-Cookie: session=…; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=None

Caddy (cross-origin API snippet)

When not using Dockerfile.caddy same-origin handle /api/*, configure the API site explicitly:

api.example.com {
    @preflight method OPTIONS
    handle @preflight {
        header Access-Control-Allow-Origin "https://app.example.com"
        header Access-Control-Allow-Credentials "true"
        header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE, OPTIONS"
        header Access-Control-Allow-Headers "Content-Type, Authorization"
        header Access-Control-Max-Age "86400"
        respond 204
    }
    reverse_proxy api:3000
    header Access-Control-Allow-Origin "https://app.example.com"
    header Access-Control-Allow-Credentials "true"
    header Vary "Origin"
}

Keep packaging/caddy/Caddyfile (same-origin reference) and this cross-origin block aligned with tooling/cors_cookie_policy.mjs when you change origins or cookie SameSite mode.

security.txt (Task 70)

Canonical file in the repository: .well-known/security.txt (RFC 9116). The shipped GTK app does not serve HTTP; operators expose this path on a project website or API gateway when one exists.

Verify locally

docker build -f Dockerfile.caddy -t tasktimer:caddy .

# File present in image (no port mapping required):
docker run --rm tasktimer:caddy cat /srv/www/.well-known/security.txt

# HTTP via Caddy inside the container:
docker run --rm -d -p 8080:8080 --name tasktimer-caddy tasktimer:caddy
docker exec tasktimer-caddy wget -qO- http://localhost:8080/.well-known/security.txt
docker stop tasktimer-caddy

Host curl http://127.0.0.1:8080/... depends on Docker publishing ports correctly on your machine; if it hangs or resets, use the docker exec check above.

Renew Expires at least annually (edit .well-known/security.txt and redeploy).

nginx

location = /.well-known/security.txt {
    alias /var/www/tasktimer/.well-known/security.txt;
    default_type text/plain;
    add_header Cache-Control "public, max-age=3600";
}

Deploy the repo file to /var/www/tasktimer/.well-known/security.txt (or symlink from your checkout). For HTTPS sites, also set:

Canonical: https://app.example.com/.well-known/security.txt

as an additional line in the served file (keep the repo copy in sync).

Caddy

Dockerfile.caddy copies .well-known/ to /srv/www/.well-known/. packaging/caddy/Caddyfile serves /.well-known/security.txt before the SPA try_files fallback.

Production vhost example:

app.example.com {
    root * /var/www/tasktimer
    handle /.well-known/security.txt {
        file_server
    }
    handle {
        try_files {path} /index.html
        file_server
    }
}

GitHub-only hosting (no server)

Publish via GitHub Pages (branch main, folder / or docs/) so the file is available at
https://cryptod.github.io/tasksTimer/.well-known/security.txt
—or rely on private vulnerability reporting via GitHub Security Advisories (listed as Contact in the file).

OpenAPI (Task 73)

Initial OpenAPI 3 slice for a future backend:

Resource Paths
Auth POST /auth/login, POST /auth/logout, GET /auth/me
Tasks GET/POST /tasks, GET/PATCH/DELETE /tasks/{taskId}
Projects GET/POST /projects, GET/PATCH/DELETE /projects/{projectId}
Users (admin) GET/POST /users, GET/PATCH/DELETE /users/{userId}, PUT /users/{userId}/password
Errors ErrorResponse + ErrorCode components

File: docs/api/openapi.yaml. Verify: gjs tests/test20_openapi_spec.js.

API versioning (Task 75)

Path prefix /api/v1 (not unversioned). Policy: docs/api/versioning-policy.md. SPA default: frontend/config.js. Verify: gjs tests/test22_api_versioning_policy.js.

External security review (Task 72)

Review type: structured maintainer self-assessment (external penetration test deferred until a public HTTP API ships).

Artifact Location
Public summary docs/plan/security-review-summary.md
Plan index docs/plan/README.md
Checklist + remediation log docs/dev/security-self-assessment.md

Re-run annually or before major surface changes; update finding counts in the public summary.

Verify: gjs tests/test19_security_plan_links.js

Audit log (Task 71)

No HTTP admin API in the shipped desktop app — there is no audit trail table or middleware.

Spot-check (this repo): admin password actions, user delete, and integration CRUD are not implemented and therefore not logged. Correlation IDs were absent before the Task 71 reference policy.

When a backend is added: every sensitive handler must persist an append-only row with correlation_id (from X-Correlation-ID) for:

Category Actions
Admin password admin.password.set, admin.password.reset
User delete user.delete
Integrations integration.create, integration.update, integration.delete

Full review + example row: docs/dev/audit-log-review.md. Policy: src/api/audit_log_policy.js, tooling/audit_log_policy.mjs. Test: gjs tests/test18_audit_log_policy.js.

Account lockout (Task 69)

No HTTP login exists in the shipped desktop app. This project does not implement per-account lockout after failed logins.

Policy: rate limit only on future auth endpoints (no lockout flag on users). Rationale, abuse model, and operator accepted risk: ADR 0002. Reference limits: tooling/auth_abuse_policy.mjs. API clients should handle 429 / RATE_LIMITED per docs/api/errors.md.

When login is implemented, add rate-limit tests; lockout tests are not required unless ADR 0002 is superseded.

File upload threat model (Task 68)

Today: the shipped product has no end-user file upload API. Local timer/settings data is user-owned JSON on disk (trusted workstation model).

If you add HTTP uploads later, read docs/dev/file-upload-threat-model.md and choose an explicit operator stance:

Stance When Virus scanning
Trusted users Internal/single-tenant; authenticated operators Not required; accept parser/abuse risk with size/type limits
Untrusted users Public or multi-tenant Required before files are served; quarantine until clean

Each stance includes an accepted risk statement to copy into your runbook so operators know what is (and is not) covered.

Load balancer and probes (Task 80)

When a future HTTP API sits behind nginx, Caddy, or a cloud load balancer, configure two probe paths on the backend upstream (not the static SPA):

Probe Path Expect Use
Liveness GET /health 200 JSON {"status":"ok"} Restart/unhealthy only if process dead
Readiness GET /readyz 200 when DB reachable; 503 when not LB pool membership, Kubernetes readinessProbe

Contract: docs/api/health-probes.md. Reference server: tooling/reference_api_server.mjs.

Verify locally:

bin/verify-health-probes.sh

Simulate DB failure on reference server: REFERENCE_API_DB_OK=0 node tooling/reference_api_server.mjs/readyz returns 503, /health still 200.

nginx (upstream health)

upstream tasktimer_api {
    server 127.0.0.1:3000;
    # Optional active check (nginx Plus / commercial) — path must be /readyz
    # health_check uri=/readyz passes=2 fails=3 interval=5s;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    location /health {
        proxy_pass http://tasktimer_api/health;
        proxy_http_version 1.1;
        access_log off;
    }

    location /readyz {
        proxy_pass http://tasktimer_api/readyz;
        proxy_http_version 1.1;
        access_log off;
    }

    location /api/ {
        proxy_pass http://tasktimer_api;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        root /var/www/tasktimer/frontend/dist;
        try_files $uri /index.html;
    }
}

LB rule: point health checks at /readyz, not /health. Use /health only for process liveness (e.g. systemd Restart=, k8s livenessProbe with low cost).

Caddy (reference)

Extend packaging/caddy/Caddyfile when the API process serves probes on the upstream:

:8080 {
    handle /health {
        reverse_proxy {$API_UPSTREAM:127.0.0.1:3000}
    }
    handle /readyz {
        reverse_proxy {$API_UPSTREAM:127.0.0.1:3000}
    }
    handle /api/* {
        reverse_proxy {$API_UPSTREAM:127.0.0.1:3000}
    }
    # … SPA static handlers …
}

Cloud load balancers (AWS ALB, GCP HTTP(S) LB, etc.): set health check path to /readyz, success codes 200 only; treat 503 as unhealthy.

Kubernetes

livenessProbe:
  httpGet:
    path: /health
    port: 3000
  periodSeconds: 10
  timeoutSeconds: 2
readinessProbe:
  httpGet:
    path: /readyz
    port: 3000
  periodSeconds: 5
  timeoutSeconds: 3
  failureThreshold: 2

Do not use /health for readiness — a live process with a down database must stop receiving traffic (503 on /readyz).

Metrics scrape (Task 81)

Scrape GET /metrics from Prometheus on the API upstream (same host/port as probes). Do not route /metrics through the static SPA. See docs/dev/observability.md (RED metrics + sample Grafana JSON).

Server / Kubernetes (summary)

There is nothing to deploy as a scalable HTTP API today. When a web UI is added, terminate TLS at nginx or Caddy and apply the Task 66/67 snippets so proxy behavior matches tooling/security_headers_middleware.mjs, tooling/cors_cookie_policy.mjs, and Dockerfile.caddy. Use Task 80 probe paths above for load balancers and orchestrators.