#5 Gateway Trust Model: - Removed inventory /test endpoint - Updated docs/trust-model.md with accurate description: - Per-user services (trips, fitness) vs gateway-key services clearly separated - Known limitations documented (no per-user isolation on shared services) - No false claims about per-user auth where it doesn't exist #8 Dependency Security: - Workflow reviewed and confirmed sane - Added .gitea/README.md documenting runner requirement - Status: repo-side complete, operationally blocked on runner setup #9 Performance Hardening: - Budget /transactions/recent: 30s cache (1.1s→41ms on repeat) - Budget /uncategorized-count: 2min cache (1.3s→42ms on repeat) - Both endpoints document Actual Budget per-account API constraint - Budget buildLookups: 2min cache (already in place) - All inventory full scans already eliminated (prior commit)
This commit is contained in:
21
.gitea/README.md
Normal file
21
.gitea/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Gitea CI Workflows
|
||||||
|
|
||||||
|
## security.yml
|
||||||
|
|
||||||
|
Runs on push/PR to `master`. Three jobs:
|
||||||
|
|
||||||
|
1. **dependency-audit** — `npm audit --audit-level=high` for budget and frontend
|
||||||
|
2. **secret-scanning** — checks for tracked .env/.db files and hardcoded secret patterns
|
||||||
|
3. **dockerfile-lint** — verifies all Dockerfiles have `USER` (non-root) and `HEALTHCHECK`
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
These workflows require a **Gitea Actions runner** to be configured.
|
||||||
|
Without a runner, the workflows are committed but will not execute.
|
||||||
|
|
||||||
|
To set up a runner:
|
||||||
|
1. Go to Gitea → Site Administration → Runners
|
||||||
|
2. Register a runner (Docker-based or shell-based)
|
||||||
|
3. The workflows will automatically execute on the next push
|
||||||
|
|
||||||
|
See: https://docs.gitea.com/usage/actions/overview
|
||||||
@@ -10,19 +10,30 @@ All frontend requests go through: Browser → Pangolin → frontend-v2 (SvelteKi
|
|||||||
- Users authenticate via `/api/auth/login` with username/password (bcrypt)
|
- Users authenticate via `/api/auth/login` with username/password (bcrypt)
|
||||||
- Session stored as `platform_session` cookie (HttpOnly, Secure, SameSite=Lax)
|
- Session stored as `platform_session` cookie (HttpOnly, Secure, SameSite=Lax)
|
||||||
- All `(app)` routes require valid session (checked in `+layout.server.ts`)
|
- All `(app)` routes require valid session (checked in `+layout.server.ts`)
|
||||||
|
- Registration is disabled (returns 403)
|
||||||
|
|
||||||
### Service-level auth
|
### Service-level auth
|
||||||
Each backend service has its own auth mechanism. The gateway injects credentials when proxying:
|
|
||||||
|
|
||||||
| Service | Auth Type | Injected By Gateway | Validated Against |
|
Services fall into two categories:
|
||||||
|---------|-----------|--------------------|--------------------|
|
|
||||||
| Trips | Bearer token | `Authorization: Bearer {token}` | `/api/trips` (protected endpoint) |
|
**Per-user token services** — each platform user has their own service credential:
|
||||||
| Fitness | Bearer token | `Authorization: Bearer {token}` | `/api/user` (protected endpoint) |
|
|
||||||
| Reader | API key | `X-Auth-Token: {key}` | `/v1/feeds/counters` |
|
| Service | Auth Type | How Injected | Per-User Data? |
|
||||||
| Inventory | API key | `X-API-Key: {key}` | `/summary` |
|
|---------|-----------|-------------|----------------|
|
||||||
| Budget | API key | `X-API-Key: {key}` | `/summary` |
|
| Trips | Bearer token | `Authorization: Bearer {token}` | No — all users see all trips |
|
||||||
| Books (Shelfmark) | None (proxied) | — | Gateway auth only |
|
| Fitness | Bearer token | `Authorization: Bearer {token}` | **Yes** — each user has own entries, goals, favorites |
|
||||||
| Music (Spotizerr) | None (proxied) | — | Gateway auth only |
|
|
||||||
|
**Gateway-key services** — a single API key shared by all platform users:
|
||||||
|
|
||||||
|
| Service | Auth Type | How Injected | Per-User Data? |
|
||||||
|
|---------|-----------|-------------|----------------|
|
||||||
|
| Inventory | API key | `X-API-Key: {key}` | No — single shared inventory |
|
||||||
|
| Budget | API key | `X-API-Key: {key}` | No — single shared budget |
|
||||||
|
| Reader | API key | `X-Auth-Token: {key}` | No — single shared feed reader |
|
||||||
|
| Books (Shelfmark) | None | Proxied through gateway | No — single shared download manager |
|
||||||
|
| Music (Spotizerr) | None | Proxied through gateway | No — single shared music downloader |
|
||||||
|
|
||||||
|
**Important**: Gateway-key services do NOT have per-user data isolation. Any authenticated platform user can access all data in these services. This is by design — the household shares a single budget, inventory, reader, and media library.
|
||||||
|
|
||||||
### Frontend hooks auth (SvelteKit)
|
### Frontend hooks auth (SvelteKit)
|
||||||
- Immich proxy: validates `platform_session` cookie before proxying
|
- Immich proxy: validates `platform_session` cookie before proxying
|
||||||
@@ -30,23 +41,36 @@ Each backend service has its own auth mechanism. The gateway injects credentials
|
|||||||
- Legacy trips Immich: validates `platform_session` cookie before proxying
|
- Legacy trips Immich: validates `platform_session` cookie before proxying
|
||||||
|
|
||||||
## Service Connections
|
## Service Connections
|
||||||
- Users connect services via Settings page
|
- Users connect per-user services (trips, fitness) via Settings page
|
||||||
- Token validation uses a **protected endpoint**, not health checks
|
- Token validation uses a **protected endpoint** per service type — not health checks
|
||||||
- Unknown services cannot be connected (rejected with 400)
|
- Unknown services cannot be connected (rejected with 400)
|
||||||
- Tokens stored in `service_connections` table, per-user
|
- Tokens stored in `service_connections` table, per-user
|
||||||
|
|
||||||
|
## Per-User Navigation
|
||||||
|
- Each user sees only their configured apps in the navbar
|
||||||
|
- Configured via `hiddenByUser` map in `+layout.server.ts`
|
||||||
|
- Apps not in nav are still accessible via direct URL (not blocked)
|
||||||
|
|
||||||
## Internal Network
|
## Internal Network
|
||||||
- All services communicate on Docker internal network
|
- All services communicate on Docker internal network via plain HTTP
|
||||||
- No service port is exposed to the host (except frontend-v2 via Pangolin)
|
- No service port is exposed to the host (except frontend-v2 via Pangolin)
|
||||||
- Gateway is the single entry point for all API traffic
|
- Gateway is the single entry point for all API traffic
|
||||||
|
- No custom SSL context — all internal calls are plain HTTP
|
||||||
|
|
||||||
## TLS
|
## TLS
|
||||||
- External HTTPS: default TLS verification (certificate + hostname)
|
- External HTTPS (OpenAI, SMTP2GO, Open Library): default TLS verification
|
||||||
- Internal services: `_internal_ssl_ctx` with verification disabled (Docker services don't have valid certs)
|
- Internal services: plain HTTP (Docker network, no TLS needed)
|
||||||
- Image proxy: default TLS verification + domain allowlist
|
- Image proxy: default TLS verification + domain allowlist + content-type validation
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
- All secrets loaded from environment variables
|
- All secrets loaded from environment variables
|
||||||
- No hardcoded credentials in code
|
- No hardcoded credentials in code
|
||||||
- `.env` files excluded from git
|
- `.env` files excluded from git
|
||||||
- Admin credentials required via `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars
|
- Admin credentials required via `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars
|
||||||
|
- Service API keys generated per service, stored in `.env`
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
- Gateway-key services are shared — no per-user access control
|
||||||
|
- Books and Music services have no auth at all (rely on gateway session only)
|
||||||
|
- Shelfmark and Spotizerr accept any request from the Docker network
|
||||||
|
- Per-user nav hiding is cosmetic — direct URL access is not blocked
|
||||||
|
|||||||
@@ -201,11 +201,21 @@ app.get('/transactions', requireReady, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Recent transactions across all accounts ------------------------------
|
// ---- Recent transactions across all accounts (cached 30s) -----------------
|
||||||
|
// NOTE: Actual Budget API requires per-account queries. There is no cross-account
|
||||||
|
// transaction endpoint. Fan-out across accounts is unavoidable. Cache mitigates
|
||||||
|
// repeated calls from dashboard + page load.
|
||||||
|
|
||||||
|
let recentTxnCache = { data: null, limit: 0, expiresAt: 0 };
|
||||||
|
|
||||||
app.get('/transactions/recent', requireReady, async (_req, res) => {
|
app.get('/transactions/recent', requireReady, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = parseInt(_req.query.limit, 10) || 20;
|
const limit = parseInt(_req.query.limit, 10) || 20;
|
||||||
|
|
||||||
|
if (recentTxnCache.data && recentTxnCache.limit >= limit && Date.now() < recentTxnCache.expiresAt) {
|
||||||
|
return res.json(recentTxnCache.data.slice(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
const accounts = await api.getAccounts();
|
const accounts = await api.getAccounts();
|
||||||
const { payeeMap, accountMap, categoryMap } = await buildLookups();
|
const { payeeMap, accountMap, categoryMap } = await buildLookups();
|
||||||
|
|
||||||
@@ -221,9 +231,10 @@ app.get('/transactions/recent', requireReady, async (_req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
all.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
|
all.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
|
||||||
all = all.slice(0, limit);
|
const enriched = all.slice(0, Math.max(limit, 100)).map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap));
|
||||||
|
|
||||||
res.json(all.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)));
|
recentTxnCache = { data: enriched, limit: Math.max(limit, 100), expiresAt: Date.now() + 30000 };
|
||||||
|
res.json(enriched.slice(0, limit));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[budget] GET /transactions/recent error:', err);
|
console.error('[budget] GET /transactions/recent error:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -359,10 +370,17 @@ app.post('/make-transfer', requireReady, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Uncategorized count (total across all accounts) ----------------------
|
// ---- Uncategorized count (cached 2 min) ------------------------------------
|
||||||
|
// NOTE: Fans out across all accounts — Actual API constraint.
|
||||||
|
|
||||||
|
let uncatCache = { count: 0, expiresAt: 0 };
|
||||||
|
|
||||||
app.get('/uncategorized-count', requireReady, async (_req, res) => {
|
app.get('/uncategorized-count', requireReady, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (Date.now() < uncatCache.expiresAt) {
|
||||||
|
return res.json({ count: uncatCache.count });
|
||||||
|
}
|
||||||
|
|
||||||
const accounts = await api.getAccounts();
|
const accounts = await api.getAccounts();
|
||||||
const startDate = '2000-01-01';
|
const startDate = '2000-01-01';
|
||||||
const endDate = new Date().toISOString().slice(0, 10);
|
const endDate = new Date().toISOString().slice(0, 10);
|
||||||
@@ -373,6 +391,7 @@ app.get('/uncategorized-count', requireReady, async (_req, res) => {
|
|||||||
const txns = await api.getTransactions(acct.id, startDate, endDate);
|
const txns = await api.getTransactions(acct.id, startDate, endDate);
|
||||||
total += txns.filter((t) => !t.category && !t.transfer_id && t.amount !== 0).length;
|
total += txns.filter((t) => !t.category && !t.transfer_id && t.amount !== 0).length;
|
||||||
}
|
}
|
||||||
|
uncatCache = { count: total, expiresAt: Date.now() + 120000 };
|
||||||
res.json({ count: total });
|
res.json({ count: total });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[budget] GET /uncategorized-count error:', err);
|
console.error('[budget] GET /uncategorized-count error:', err);
|
||||||
|
|||||||
@@ -190,10 +190,6 @@ app.get('/search-records', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test endpoint
|
// Test endpoint
|
||||||
app.get('/test', (req, res) => {
|
|
||||||
res.json({ message: 'Server is working!', timestamp: new Date() });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get single item details
|
// Get single item details
|
||||||
app.get('/item-details/:id', async (req, res) => {
|
app.get('/item-details/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user