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.
| 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.
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)
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
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 sbomOutputs:
| 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).
The multi-stage Dockerfile.api is not a REST API container. It provides:
builder— installs GJS/GTK/GStreamer +xvfb/dbus, copies the tree, and runs a smoke check (gjsimport probe +gjs main.js --versionunderxvfb+dbus-run-session). The fullmake test/make lintpipeline runs in .github/workflows/ci.yml; some tests assume a normal user home and can fail in arbitrary containers.runtime— slimmer image with GJS + GTK + GStreamer typelibs and the checked-out tree; defaultCMDrunsgjs main.js --version(no GUI; validates that the app loads far enough to print the version).
From the repository root:
docker build -f Dockerfile.api -t tasktimer:dev .docker run --rm tasktimer:devdocker run --rm -it --entrypoint bash tasktimer:devA 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.
.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.
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.
| 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);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.
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.
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:caddyOpen http://127.0.0.1:8080/ (placeholder static root under packaging/caddy/www/).
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' }) |
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;
}
}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
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.
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.
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-caddyHost 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).
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).
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
}
}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).
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.
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.
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
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.
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.
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.
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.shSimulate DB failure on reference server: REFERENCE_API_DB_OK=0 node tooling/reference_api_server.mjs → /readyz returns 503, /health still 200.
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).
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.
livenessProbe:
httpGet:
path: /health
port: 3000
periodSeconds: 10
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /readyz
port: 3000
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2Do not use /health for readiness — a live process with a down database must stop receiving traffic (503 on /readyz).
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).
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.