mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: phase 6 production hardening + DB-to-DB transfer
Security:
- Graceful shutdown: http.Server + signal.NotifyContext, 10s drain,
retention sweeper cancelled before pool close
- Scoped API keys (migration 00006): ingest/read/write/admin enforced in
auth middleware; scope picker + badges in Settings UI; scopeless legacy
keys act as admin. Read-only keys now get 403 on ingest (verified live)
- nginx: baseline security headers on all responses, CSP scoped to SPA;
fixed add_header inheritance shadowing
- better-auth rate limit enabled unconditionally (was gated on NODE_ENV,
which the shipped .env sets to development)
- Mailpit moved to docker-compose.dev.yml; prod compose cannot expose it
- setup.sh generates PRIMORA_ENCRYPTION_KEY
Reliability/ops:
- Retention: per-project retention_{events,audit,webhook}_days
(migration 00007, 0=off) + hourly RetentionSweeper; edit UI in Projects
- GET /api/v1/metrics Prometheus exposition
- compose: healthchecks + restart policies everywhere, dragonfly pinned
to v2.0.0, depends_on service_healthy
- backup.sh/restore.sh (pg_dump -Fc + storage volume); restore verified
against scratch postgres+volume
- .dockerignore: exclude target dirs (build context was 4.7GB)
Correctness/testing:
- DBX transfer endpoint: POST /db-connections/:id/transfer — SQL or Redis
target, 1000-row bound, server-side creds (verified live pg→dragonfly)
- Pagination audit: ListComponents capped at 500 (auto-registers)
- CI: goose up/reset/up job + compose e2e-smoke job (smoke-e2e.sh
verified locally: signup, bootstrap, scoped keys, ingest, metrics)
- k6 load sanity script: 6650 reqs, ~217/s, 0 real failures, p95 221ms
(429s = configured rate limiter working)
Docs: DEPLOYMENT_GUIDE.md, PRODUCTION_READINESS.md; verify script
repaired (stale refs + set -e arithmetic abort). ROADMAP: phase 2 +
phase 6 marked complete.
Authored By: TDvorak <[email protected]>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1b34dd43bb
commit
55edbf8b56
64 files changed
+2008
-115
No files matched your search
@@ -7,3 +7,12 @@ coverage
|
|||||||
.env
|
.env
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|
||||||
|
# build artifacts + local data — 4.5G+ of Rust target dir otherwise
|
||||||
|
**/target
|
||||||
|
apps/desktop/src-tauri/gen
|
||||||
|
backups
|
||||||
|
tmp
|
||||||
|
**/tmp
|
||||||
|
graphify-out
|
||||||
|
.playwright-mcp
|
||||||
|
|
||||||
@@ -129,6 +129,86 @@ jobs:
|
|||||||
push: false
|
push: false
|
||||||
tags: primora-frontend:test
|
tags: primora-frontend:test
|
||||||
|
|
||||||
|
migrations:
|
||||||
|
name: Migration Up/Down
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: primora_test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: primora_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26'
|
||||||
|
cache-dependency-path: apps/backend/go.sum
|
||||||
|
|
||||||
|
- name: Install goose
|
||||||
|
run: go install github.com/pressly/goose/v3/cmd/[email protected]
|
||||||
|
|
||||||
|
- name: Migrations up
|
||||||
|
run: |
|
||||||
|
goose -dir apps/backend/db/migrations \
|
||||||
|
postgres "postgres://primora_test:test@localhost:5432/primora_test?sslmode=disable" up
|
||||||
|
|
||||||
|
- name: Migrations reset (all downs)
|
||||||
|
run: |
|
||||||
|
goose -dir apps/backend/db/migrations \
|
||||||
|
postgres "postgres://primora_test:test@localhost:5432/primora_test?sslmode=disable" reset
|
||||||
|
|
||||||
|
- name: Migrations up again (idempotent re-apply)
|
||||||
|
run: |
|
||||||
|
goose -dir apps/backend/db/migrations \
|
||||||
|
postgres "postgres://primora_test:test@localhost:5432/primora_test?sslmode=disable" up
|
||||||
|
|
||||||
|
e2e-smoke:
|
||||||
|
name: Compose E2E Smoke
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Prepare env
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Start stack
|
||||||
|
run: >-
|
||||||
|
docker compose
|
||||||
|
-f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
up -d --build
|
||||||
|
|
||||||
|
- name: Run smoke checks
|
||||||
|
run: ./scripts/smoke-e2e.sh
|
||||||
|
|
||||||
|
- name: Dump logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: >-
|
||||||
|
docker compose
|
||||||
|
-f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
logs --tail=200
|
||||||
|
|
||||||
|
- name: Tear down
|
||||||
|
if: always()
|
||||||
|
run: >-
|
||||||
|
docker compose
|
||||||
|
-f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
down -v
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ jobs:
|
|||||||
app: [backend, auth, frontend]
|
app: [backend, auth, frontend]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -48,6 +49,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: apps/${{ matrix.app }}/Dockerfile
|
file: apps/${{ matrix.app }}/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:${{ env.VERSION }}
|
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:${{ env.VERSION }}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ apps/desktop/src-tauri/gen/schemas
|
|||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
infra/data
|
infra/data
|
||||||
|
backups
|
||||||
|
.restore-tmp.*
|
||||||
infra/postgres
|
infra/postgres
|
||||||
infra/dragonfly
|
infra/dragonfly
|
||||||
infra/mailpit
|
infra/mailpit
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Primora Deployment Guide
|
||||||
|
|
||||||
|
Primora ships as a Docker Compose stack: nginx (edge), frontend (static SPA),
|
||||||
|
auth (Better Auth / Hono), backend (Go API), postgres, and dragonfly
|
||||||
|
(Redis-compatible cache). The base `docker-compose.yml` is the production
|
||||||
|
surface — dev-only services like Mailpit live in `docker-compose.dev.yml`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine 24+ with the Compose plugin
|
||||||
|
- A host with 2 GB free RAM minimum (postgres + dragonfly + services)
|
||||||
|
- A domain pointing at the host if you want TLS
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> && cd Primora
|
||||||
|
./scripts/setup.sh # writes .env with generated secrets
|
||||||
|
docker compose up -d # production surface only
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development (includes Mailpit at `/mailpit/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup.sh` generates `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
|
||||||
|
`PRIMORA_ENCRYPTION_KEY`. Do not reuse the example values anywhere public.
|
||||||
|
|
||||||
|
## Environment reference
|
||||||
|
|
||||||
|
| Variable | Purpose | Required |
|
||||||
|
|---|---|---|
|
||||||
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | database credentials | yes |
|
||||||
|
| `DATABASE_URL` | backend → postgres DSN | yes |
|
||||||
|
| `DRAGONFLY_URL` | backend → cache DSN | yes |
|
||||||
|
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
|
||||||
|
| `BETTER_AUTH_SECRET` | auth session/signing secret | yes |
|
||||||
|
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for integration credentials + webhook secrets at rest | production |
|
||||||
|
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
||||||
|
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
||||||
|
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
||||||
|
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes |
|
||||||
|
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
||||||
|
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
||||||
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
||||||
|
| `RESEND_API_KEY` | alternative to SMTP — Resend API | no |
|
||||||
|
| `*_CLIENT_ID` / `*_CLIENT_SECRET` | GitHub / Google / Discord / Microsoft OAuth | no |
|
||||||
|
| `NGINX_PORT` | host port for the edge (default 80) | no |
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
Primora does not terminate TLS itself — put a reverse proxy in front of
|
||||||
|
nginx's port 80. The nginx config sets `X-Forwarded-*`-aware headers; pass
|
||||||
|
them through.
|
||||||
|
|
||||||
|
**Caddy** (`Caddyfile`):
|
||||||
|
|
||||||
|
```
|
||||||
|
primora.example.com {
|
||||||
|
reverse_proxy localhost:80
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Traefik** (compose labels on the nginx service):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.primora.rule=Host(`primora.example.com`)
|
||||||
|
- traefik.http.routers.primora.entrypoints=websecure
|
||||||
|
- traefik.http.routers.primora.tls.certresolver=letsencrypt
|
||||||
|
```
|
||||||
|
|
||||||
|
Once TLS is live, update `VITE_APP_URL`, `VITE_AUTH_BASE_URL`,
|
||||||
|
`AUTH_BASE_URL`, `BETTER_AUTH_URL`, and `COOKIE_DOMAIN` to the https origin
|
||||||
|
and rebuild the frontend image (`docker compose build frontend`).
|
||||||
|
|
||||||
|
## SMTP / email
|
||||||
|
|
||||||
|
Auth sends verification and password-reset email through SMTP
|
||||||
|
(`SMTP_*` + `MAIL_FROM`) or Resend (`RESEND_API_KEY`). In development the
|
||||||
|
`docker-compose.dev.yml` overlay runs Mailpit; mail is captured at
|
||||||
|
`http://localhost/mailpit/` and nothing leaves the host. **The production
|
||||||
|
compose file contains no mail sink — configure real SMTP before inviting
|
||||||
|
users.**
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
`scripts/backup.sh` produces a tarball with a `pg_dump -Fc` of the database
|
||||||
|
and a tar of the `backend_storage` volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh # → backups/primora-<stamp>.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore (destructive — stops writes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop backend auth
|
||||||
|
./scripts/restore.sh backups/primora-<stamp>.tar.gz
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Cron example (nightly, keep 14):
|
||||||
|
|
||||||
|
```cron
|
||||||
|
17 3 * * * cd /opt/primora && ./scripts/backup.sh && \
|
||||||
|
ls -1t backups/primora-*.tar.gz | tail -n +15 | xargs -r rm
|
||||||
|
```
|
||||||
|
|
||||||
|
systemd timer example is in `PRODUCTION_READINESS.md`.
|
||||||
|
|
||||||
|
## Upgrades
|
||||||
|
|
||||||
|
Migrations run automatically on backend boot (goose). Safe order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh # 1. snapshot first
|
||||||
|
docker compose pull && docker compose build # 2. new images
|
||||||
|
docker compose up -d # 3. rolling recreate; backend migrates on boot
|
||||||
|
./scripts/smoke-e2e.sh # 4. verify health, auth, ingest
|
||||||
|
```
|
||||||
|
|
||||||
|
`goose` migrations include `Down` sections; `goose -dir apps/backend/db/migrations postgres "$DATABASE_URL" down` rolls back one version if a release misbehaves.
|
||||||
|
|
||||||
|
## Storage constraint
|
||||||
|
|
||||||
|
Object storage is the **local filesystem** (`backend_storage` volume). There
|
||||||
|
is no S3-compatible backend yet — the volume must be on durable, backed-up
|
||||||
|
disk, and a single host serves all objects. Plan capacity accordingly;
|
||||||
|
multi-node deployments need an external object store first.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
- `GET /api/v1/metrics` — Prometheus exposition (unauthenticated; restrict at
|
||||||
|
the edge if the instance is public — see the commented rule in
|
||||||
|
`infra/nginx/default.conf`).
|
||||||
|
- `GET /api/v1/health/liveness` / `readiness` — compose healthchecks use the
|
||||||
|
same endpoints.
|
||||||
|
- Structured JSON logs on stdout for every service — ship with your normal
|
||||||
|
docker log driver.
|
||||||
|
|
||||||
|
## API key scopes
|
||||||
|
|
||||||
|
Keys are created with scopes: `ingest`, `read`, `write`, `admin`.
|
||||||
|
`admin` unlocks everything; `write` unlocks read + ingest + non-admin
|
||||||
|
mutations; `read`/`ingest` are single-purpose. Keys without stored scopes
|
||||||
|
(pre-scope keys) behave as `admin` for backwards compatibility — rotate them
|
||||||
|
into scoped keys at your leisure.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Primora Production Readiness
|
||||||
|
|
||||||
|
Status checklist for self-hosted production use. Everything listed under
|
||||||
|
"Hardened" is implemented and verified; "Known limits" is the honest
|
||||||
|
remainder.
|
||||||
|
|
||||||
|
## Hardened
|
||||||
|
|
||||||
|
- **Graceful shutdown** — the backend drains in-flight requests for up to
|
||||||
|
10 s on SIGTERM/SIGINT (`internal/app`).
|
||||||
|
- **Scoped API keys** — `ingest` / `read` / `write` / `admin`, enforced in
|
||||||
|
auth middleware before handlers run. Legacy keys without stored scopes act
|
||||||
|
as `admin`.
|
||||||
|
- **Brute-force protection** — Better Auth rate limiting is enabled
|
||||||
|
unconditionally (3 attempts / 10 s on sign-in, sign-up, password change);
|
||||||
|
an IP-level limiter caps `/auth/*` at 60 req/min, and per-identity API
|
||||||
|
rate limits apply via `USER_RATE_LIMIT_PER_MINUTE` /
|
||||||
|
`API_KEY_RATE_LIMIT_PER_MINUTE` in Dragonfly.
|
||||||
|
- **Metrics** — `GET /api/v1/metrics` in Prometheus text format.
|
||||||
|
- **Retention** — per-project sweeper for telemetry events, audit log rows,
|
||||||
|
and webhook deliveries (`retention_*_days`, 0 = disabled, default 90).
|
||||||
|
- **Edge security** — nginx emits `X-Frame-Options`, `X-Content-Type-Options`,
|
||||||
|
`Referrer-Policy`, `Permissions-Policy`, and a CSP scoped to the SPA only.
|
||||||
|
- **Compose hygiene** — all services have healthchecks and
|
||||||
|
`restart: unless-stopped`; Dragonfly is pinned to `v2.0.0`; Mailpit lives
|
||||||
|
only in `docker-compose.dev.yml`.
|
||||||
|
- **Secrets** — `setup.sh` generates `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
|
||||||
|
`PRIMORA_ENCRYPTION_KEY`; credentials and webhook secrets are AES-256
|
||||||
|
encrypted at rest.
|
||||||
|
- **Release images** — GitHub release builds `linux/amd64` + `linux/arm64`
|
||||||
|
via buildx/QEMU.
|
||||||
|
- **CI coverage** — Go tests, frontend tests + typecheck, all three Docker
|
||||||
|
builds, golangci-lint, migration up/reset/up round-trip, and a compose E2E
|
||||||
|
smoke (`scripts/smoke-e2e.sh`).
|
||||||
|
- **Backups** — `scripts/backup.sh` / `scripts/restore.sh` cover the database
|
||||||
|
(custom-format dump) and the storage volume.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- **Storage is local filesystem only.** No S3 backend — the
|
||||||
|
`backend_storage` volume is the durability boundary.
|
||||||
|
- **Single host.** Compose topology is single-node; postgres and dragonfly
|
||||||
|
are not clustered. HA is out of scope for v0.x.
|
||||||
|
- **No TLS inside the stack.** Terminate at a front proxy (Caddy/Traefik);
|
||||||
|
see DEPLOYMENT_GUIDE.md.
|
||||||
|
- **Metrics are in-memory counters.** `/api/v1/metrics` covers request
|
||||||
|
totals/errors/duration — not per-route histograms or DB pool stats yet.
|
||||||
|
- **`/api/v1/metrics` is unauthenticated.** Scrape-only design; block it at
|
||||||
|
the edge for public instances (commented rule in `infra/nginx/default.conf`).
|
||||||
|
|
||||||
|
## systemd timer for backups
|
||||||
|
|
||||||
|
`/etc/systemd/system/primora-backup.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Primora backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/primora
|
||||||
|
ExecStart=/opt/primora/scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/primora-backup.timer`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Nightly Primora backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:17:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/verify-production-ready.sh # structural check
|
||||||
|
./scripts/smoke-e2e.sh # live E2E against a running stack
|
||||||
|
npm run check # typecheck + tests + generated-code drift
|
||||||
|
```
|
||||||
+61
-55
@@ -39,7 +39,7 @@ Everything an operator touches that isn't in the box yet.
|
|||||||
- **Responsive pass** — sidebar already collapses; audit every page at
|
- **Responsive pass** — sidebar already collapses; audit every page at
|
||||||
390px for table/card overflow.
|
390px for table/card overflow.
|
||||||
|
|
||||||
## Phase 2 — Databases (DBX)
|
## Phase 2 — Databases (DBX) ✅
|
||||||
|
|
||||||
The "any database" story, via [DBX](https://github.com/t8y2/dbx) — 90+ drivers
|
The "any database" story, via [DBX](https://github.com/t8y2/dbx) — 90+ drivers
|
||||||
in a 25 MB MCP server. Primora does not embed DBX; the Go backend keeps
|
in a 25 MB MCP server. Primora does not embed DBX; the Go backend keeps
|
||||||
@@ -58,9 +58,10 @@ as REST (pattern already proven in IMS's `internal/app/dbx.go`).
|
|||||||
- **First-party connections** ✅ — the platform's own Postgres and Dragonfly
|
- **First-party connections** ✅ — the platform's own Postgres and Dragonfly
|
||||||
auto-register as connections on every project, so "put Postgres in it and
|
auto-register as connections on every project, so "put Postgres in it and
|
||||||
it runs Postgres" works out of the box.
|
it runs Postgres" works out of the box.
|
||||||
- **DB-to-DB links** — DBX data transfer between two saved connections
|
- **DB-to-DB links** ✅ — `POST …/db-connections/:id/transfer` runs a query
|
||||||
(e.g. Postgres → Dragonfly cache warm), plus documented patterns for
|
on the source connection and writes rows to a SQL target (`target_table`)
|
||||||
pairing them.
|
or a Redis target (`key_pattern` + `value_column`, optional TTL). Bounded
|
||||||
|
at 1000 rows; credentials stay server-side.
|
||||||
|
|
||||||
## Phase 3 — Observability (IMS merge) ✅
|
## Phase 3 — Observability (IMS merge) ✅
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ Follow-ups:
|
|||||||
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when
|
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when
|
||||||
distribution matures.
|
distribution matures.
|
||||||
|
|
||||||
## Phase 6 — Production hardening (v0.6.0)
|
## Phase 6 — Production hardening (v0.6.0) ✅
|
||||||
|
|
||||||
The v1 feature surface is essentially complete; what remains is the
|
The v1 feature surface is essentially complete; what remains is the
|
||||||
difference between "works" and "safe to run other people's data". Roughly
|
difference between "works" and "safe to run other people's data". Roughly
|
||||||
@@ -140,74 +141,79 @@ release pipeline with images + binaries + desktop bundles.
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- **Graceful shutdown** — `router.Run()` drops in-flight requests on every
|
- ~~**Graceful shutdown**~~ ✅ — `http.Server` + `signal.NotifyContext`,
|
||||||
deploy. Switch to `http.Server` + `signal.NotifyContext` with a ~10s
|
10 s drain; verified via SIGTERM on the running container.
|
||||||
drain.
|
- ~~**Security headers**~~ ✅ — `X-Frame-Options`, `X-Content-Type-Options`,
|
||||||
- **Security headers** — add `X-Frame-Options`, `Referrer-Policy`,
|
`Referrer-Policy`, `Permissions-Policy` on every response; CSP scoped to
|
||||||
`Permissions-Policy`, and a baseline CSP at the nginx/frontend layer.
|
the SPA location only (verified live).
|
||||||
- **TLS story** — shipped nginx vhost is HTTP-only. Document a reverse
|
- ~~**TLS story**~~ ✅ — documented Caddy/Traefik front-proxy setups in
|
||||||
proxy (Caddy/Traefik) in front for production; optionally ship a
|
DEPLOYMENT_GUIDE.md; no embedded cert manager.
|
||||||
TLS-ready vhost with cert volume mounts. Don't embed a cert manager.
|
- ~~**Scoped API keys**~~ ✅ — `scopes` column (migration 00006), enforced
|
||||||
- **Scoped API keys** — `prm_…` keys are full-project-access today. Add a
|
in auth middleware (`requiredAPIKeyScope`), UI checkboxes in Settings;
|
||||||
`scopes` column (`ingest`, `read`, `write`, `admin`) and enforce in the
|
keys without stored scopes behave as `admin` (legacy). Verified live: a
|
||||||
auth middleware.
|
`read`-only key gets 403 on ingest.
|
||||||
- **Dev-surface isolation** — mailpit is proxied at `/mailpit/` in the
|
- ~~**Dev-surface isolation**~~ ✅ — Mailpit moved to
|
||||||
single compose file. Move dev services to `docker-compose.dev.yml` so a
|
`docker-compose.dev.yml`; the base compose file cannot expose it.
|
||||||
production deploy cannot expose it.
|
- ~~**Auth brute-force**~~ ✅ — Better Auth's built-in rate limiting is now
|
||||||
- **Auth brute-force** — verify Better Auth's built-in throttling covers
|
enabled unconditionally (it previously followed `NODE_ENV`, which the
|
||||||
login/signup; add IP backoff if it doesn't.
|
shipped `.env` sets to development); the IP-level 60 req/min limiter on
|
||||||
- **Setup ergonomics** — `setup.sh` should generate
|
`/auth/*` stays in front.
|
||||||
`PRIMORA_ENCRYPTION_KEY` (`openssl rand -hex 32`) rather than leaving it
|
- ~~**Setup ergonomics**~~ ✅ — `setup.sh` generates
|
||||||
to the operator.
|
`PRIMORA_ENCRYPTION_KEY` via `openssl rand -hex 32` alongside the other
|
||||||
|
secrets.
|
||||||
|
|
||||||
### Reliability & operations
|
### Reliability & operations
|
||||||
|
|
||||||
- **Backups + restore runbook** — `pg_dump` of `core.*`, storage-root
|
- ~~**Backups + restore runbook**~~ ✅ — `scripts/backup.sh` (pg_dump -Fc +
|
||||||
archive, cron/timer example, and a documented restore path. The restore
|
storage volume tar) and `scripts/restore.sh`; restore verified against a
|
||||||
must be tested, not just the dump.
|
scratch postgres + volume. Cron/systemd examples in the docs.
|
||||||
- **Retention** — telemetry events, audit log, and webhook deliveries grow
|
- ~~**Retention**~~ ✅ — per-project `retention_{events,audit,webhook}_days`
|
||||||
unbounded. Per-project retention settings + a sweeper job.
|
(migration 00007, 0 = disabled) + hourly `RetentionSweeper`; settings
|
||||||
- **Platform metrics export** — internal counters exist (surfaced in
|
exposed in the project edit dialog.
|
||||||
readiness); expose `/metrics` for Prometheus scraping.
|
- ~~**Platform metrics export**~~ ✅ — `GET /api/v1/metrics` renders
|
||||||
- **Production compose profile** — healthchecks on every service, restart
|
Prometheus text exposition (requests, errors, active, duration sum).
|
||||||
policies, pinned versions for dependencies (dragonfly is on `latest`).
|
- ~~**Production compose profile**~~ ✅ — healthchecks + `restart:
|
||||||
- **Upgrade runbook** — today it's `git pull && compose up -d`. Document
|
unless-stopped` on every service, `depends_on: service_healthy`,
|
||||||
the safe order (backup → pull → migrate-on-boot → verify) and rollback.
|
Dragonfly pinned to `v2.0.0`.
|
||||||
|
- ~~**Upgrade runbook**~~ ✅ — DEPLOYMENT_GUIDE.md documents backup → pull →
|
||||||
|
migrate-on-boot → verify, plus rollback via `goose down`.
|
||||||
|
|
||||||
### Correctness
|
### Correctness
|
||||||
|
|
||||||
- **Pagination audit** — every list endpoint must paginate; verify the
|
- ~~**Pagination audit**~~ ✅ — every `:many` query audited; time-series and
|
||||||
heaviest (audit, events, webhook deliveries) can't return unbounded
|
entity lists are naturally bounded; `ListComponents` capped at 500
|
||||||
rows.
|
(auto-registers from ingest).
|
||||||
- **DB-to-DB links** — the one open Phase 2 item: DBX transfer between two
|
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
|
||||||
saved connections.
|
dragonfly transfer, key templating, TTL).
|
||||||
- **External object storage** — files live under `BACKEND_STORAGE_ROOT`
|
- ~~**External object storage**~~ ✅ — constraint documented in
|
||||||
on local disk. Acceptable for single-node self-hosting; document the
|
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred.
|
||||||
constraint or add an S3-compatible backend behind the storage service.
|
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- **E2E smoke in CI** — `ci.yml` runs unit tests; add a compose-up job
|
- ~~**E2E smoke in CI**~~ ✅ — `scripts/smoke-e2e.sh` + `e2e-smoke` job:
|
||||||
exercising health, signup/login, project create, and ingest → issue
|
health, signup, token, bootstrap/org+project, scoped key create, ingest,
|
||||||
created. Six Go test files + one frontend file is thin for a platform.
|
read-only key rejection, metrics. Verified locally against the stack.
|
||||||
- **Migration tests** — goose up/down against a scratch database in CI.
|
- ~~**Migration tests**~~ ✅ — `migrations` job runs goose up → reset → up
|
||||||
- **Load sanity** — one `k6`/`vegeta` script against ingest + a hot read
|
against scratch postgres; verified locally.
|
||||||
path; publish numbers, don't promise scale.
|
- ~~**Load sanity**~~ ✅ — `scripts/load-sanity.js` (k6): ingest + events
|
||||||
|
read, 15 VUs / 30 s. Local run: 6650 requests, ~217 req/s, 0 real
|
||||||
|
failures, p95 221 ms — most requests beyond the configured 600/min cap
|
||||||
|
correctly returned 429.
|
||||||
|
|
||||||
### Distribution
|
### Distribution
|
||||||
|
|
||||||
- **arm64 images** — releases build amd64 only; ARM VPS/Pi can't pull. Add
|
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
|
||||||
`platforms: linux/amd64,linux/arm64` to the buildx steps.
|
QEMU + buildx.
|
||||||
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
|
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
|
||||||
verification, signing + auto-updater.
|
verification, signing + auto-updater.
|
||||||
- Optional: SBOM + provenance attestation on release images.
|
- Optional: SBOM + provenance attestation on release images.
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
- **Self-host guide** — TLS, SMTP, backups, upgrade, and env reference in
|
- ~~**Self-host guide**~~ ✅ — `DEPLOYMENT_GUIDE.md` (TLS, SMTP, backups,
|
||||||
one place. `scripts/verify-production-ready.sh` already points at
|
upgrades, env reference, storage constraint) and
|
||||||
`DEPLOYMENT_GUIDE.md` / `PRODUCTION_READINESS.md`, which don't exist —
|
`PRODUCTION_READINESS.md` written; `verify-production-ready.sh` updated
|
||||||
write them or fix the script.
|
to check them plus the Phase 6 artifacts.
|
||||||
|
|
||||||
**v1.0 bar:** every Security item done, a restore test actually run, E2E
|
**v1.0 bar:** every Security item done, a restore test actually run, E2E
|
||||||
smoke green in CI, and the upgrade runbook followed once on a real
|
smoke green in CI, and the upgrade runbook followed once on a real
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export const auth = betterAuth({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Enabled unconditionally — better-auth only turns this on when NODE_ENV is
|
||||||
|
// production, but the shipped .env defaults to development. Credential
|
||||||
|
// endpoints get 3 req/10s; the IP-level limiter in index.ts sits in front.
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
socialProviders,
|
socialProviders,
|
||||||
plugins: [
|
plugins: [
|
||||||
jwt({
|
jwt({
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE core.api_keys
|
||||||
|
ADD COLUMN scopes TEXT[] NOT NULL DEFAULT '{admin}';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.api_keys DROP COLUMN IF EXISTS scopes;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE core.projects
|
||||||
|
ADD COLUMN retention_events_days INT NOT NULL DEFAULT 90,
|
||||||
|
ADD COLUMN retention_audit_days INT NOT NULL DEFAULT 365,
|
||||||
|
ADD COLUMN retention_webhook_days INT NOT NULL DEFAULT 30;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.projects
|
||||||
|
DROP COLUMN IF EXISTS retention_events_days,
|
||||||
|
DROP COLUMN IF EXISTS retention_audit_days,
|
||||||
|
DROP COLUMN IF EXISTS retention_webhook_days;
|
||||||
@@ -4,8 +4,9 @@ INSERT INTO core.api_keys (
|
|||||||
name,
|
name,
|
||||||
prefix,
|
prefix,
|
||||||
secret_hash,
|
secret_hash,
|
||||||
|
scopes,
|
||||||
created_by_user_id
|
created_by_user_id
|
||||||
) VALUES ($1, $2, $3, $4, $5)
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ListAPIKeysForProject :many
|
-- name: ListAPIKeysForProject :many
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ RETURNING *;
|
|||||||
UPDATE core.projects
|
UPDATE core.projects
|
||||||
SET slug = $2,
|
SET slug = $2,
|
||||||
name = $3,
|
name = $3,
|
||||||
description = $4
|
description = $4,
|
||||||
|
retention_events_days = COALESCE(sqlc.narg('retention_events_days'), retention_events_days),
|
||||||
|
retention_audit_days = COALESCE(sqlc.narg('retention_audit_days'), retention_audit_days),
|
||||||
|
retention_webhook_days = COALESCE(sqlc.narg('retention_webhook_days'), retention_webhook_days)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Per-project retention sweeps. A retention of 0 days disables the sweep for
|
||||||
|
-- that stream. Pending webhook deliveries are never swept — they may still be
|
||||||
|
-- retrying.
|
||||||
|
|
||||||
|
-- name: SweepExpiredEvents :execrows
|
||||||
|
DELETE FROM core.events e
|
||||||
|
USING core.projects p
|
||||||
|
WHERE e.project_id = p.id
|
||||||
|
AND p.retention_events_days > 0
|
||||||
|
AND e.ts < NOW() - make_interval(days => p.retention_events_days);
|
||||||
|
|
||||||
|
-- name: SweepExpiredAuditLogs :execrows
|
||||||
|
DELETE FROM core.audit_logs a
|
||||||
|
USING core.projects p
|
||||||
|
WHERE a.project_id = p.id
|
||||||
|
AND p.retention_audit_days > 0
|
||||||
|
AND a.created_at < NOW() - make_interval(days => p.retention_audit_days);
|
||||||
|
|
||||||
|
-- name: SweepExpiredOrgAuditLogs :execrows
|
||||||
|
-- Org-scoped audit rows (project_id NULL) have no project retention — sweep
|
||||||
|
-- them on a fixed 365-day floor so they cannot grow unbounded.
|
||||||
|
DELETE FROM core.audit_logs a
|
||||||
|
WHERE a.project_id IS NULL
|
||||||
|
AND a.created_at < NOW() - INTERVAL '365 days';
|
||||||
|
|
||||||
|
-- name: SweepExpiredWebhookDeliveries :execrows
|
||||||
|
DELETE FROM core.webhook_deliveries d
|
||||||
|
USING core.webhooks w
|
||||||
|
JOIN core.projects p ON p.id = w.project_id
|
||||||
|
WHERE d.webhook_id = w.id
|
||||||
|
AND p.retention_webhook_days > 0
|
||||||
|
AND d.status <> 'pending'
|
||||||
|
AND d.created_at < NOW() - make_interval(days => p.retention_webhook_days);
|
||||||
@@ -11,7 +11,8 @@ WHERE project_id = $1 AND name = $2;
|
|||||||
-- name: ListComponents :many
|
-- name: ListComponents :many
|
||||||
SELECT * FROM core.components
|
SELECT * FROM core.components
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY name;
|
ORDER BY name
|
||||||
|
LIMIT 500;
|
||||||
|
|
||||||
-- name: DeleteComponent :exec
|
-- name: DeleteComponent :exec
|
||||||
DELETE FROM core.components
|
DELETE FROM core.components
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -37,6 +41,8 @@ type App struct {
|
|||||||
Redis *redis.Client
|
Redis *redis.Client
|
||||||
DBX *dbx.Client
|
DBX *dbx.Client
|
||||||
Platform *services.PlatformService
|
Platform *services.PlatformService
|
||||||
|
|
||||||
|
sweepCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func Bootstrap(ctx context.Context) (*App, error) {
|
func Bootstrap(ctx context.Context) (*App, error) {
|
||||||
@@ -157,6 +163,10 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
}
|
}
|
||||||
handler.Register(router)
|
handler.Register(router)
|
||||||
|
|
||||||
|
sweepCtx, sweepCancel := context.WithCancel(context.Background())
|
||||||
|
sweeper := services.NewRetentionSweeper(repo, logger)
|
||||||
|
go sweeper.Run(sweepCtx)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Router: router,
|
Router: router,
|
||||||
@@ -165,13 +175,41 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
Redis: redisClient,
|
Redis: redisClient,
|
||||||
DBX: dbxClient,
|
DBX: dbxClient,
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
|
sweepCancel: sweepCancel,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Run() error {
|
func (a *App) Run() error {
|
||||||
address := ":" + a.Config.ServerPort
|
address := ":" + a.Config.ServerPort
|
||||||
|
srv := &http.Server{Addr: address, Handler: a.Router}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
a.Logger.Info("primora backend starting", "address", address)
|
a.Logger.Info("primora backend starting", "address", address)
|
||||||
return a.Router.Run(address)
|
errCh <- srv.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Logger.Info("shutdown signal received, draining in-flight requests")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
return fmt.Errorf("graceful shutdown: %w", err)
|
||||||
|
}
|
||||||
|
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Stop the retention sweeper before main closes the DB pool.
|
||||||
|
a.sweepCancel()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstPartyDBSeeds turns the platform's own Postgres and Dragonfly URLs into
|
// firstPartyDBSeeds turns the platform's own Postgres and Dragonfly URLs into
|
||||||
@@ -206,6 +244,9 @@ func firstPartyDBSeeds(cfg config.Config) []services.ManagedDBSeed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
|
if a.sweepCancel != nil {
|
||||||
|
a.sweepCancel()
|
||||||
|
}
|
||||||
if a.Platform != nil {
|
if a.Platform != nil {
|
||||||
a.Platform.Close()
|
a.Platform.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ INSERT INTO core.api_keys (
|
|||||||
name,
|
name,
|
||||||
prefix,
|
prefix,
|
||||||
secret_hash,
|
secret_hash,
|
||||||
|
scopes,
|
||||||
created_by_user_id
|
created_by_user_id
|
||||||
) VALUES ($1, $2, $3, $4, $5)
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at
|
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at, scopes
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAPIKeyParams struct {
|
type CreateAPIKeyParams struct {
|
||||||
@@ -28,6 +29,7 @@ type CreateAPIKeyParams struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
SecretHash []byte `json:"secret_hash"`
|
SecretHash []byte `json:"secret_hash"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ func (q *Queries) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (Cor
|
|||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Prefix,
|
arg.Prefix,
|
||||||
arg.SecretHash,
|
arg.SecretHash,
|
||||||
|
arg.Scopes,
|
||||||
arg.CreatedByUserID,
|
arg.CreatedByUserID,
|
||||||
)
|
)
|
||||||
var i CoreApiKey
|
var i CoreApiKey
|
||||||
@@ -50,12 +53,13 @@ func (q *Queries) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (Cor
|
|||||||
&i.LastUsedAt,
|
&i.LastUsedAt,
|
||||||
&i.RevokedAt,
|
&i.RevokedAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.Scopes,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAPIKeyByIDForProject = `-- name: GetAPIKeyByIDForProject :one
|
const getAPIKeyByIDForProject = `-- name: GetAPIKeyByIDForProject :one
|
||||||
SELECT id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at FROM core.api_keys
|
SELECT id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at, scopes FROM core.api_keys
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND id = $2
|
AND id = $2
|
||||||
`
|
`
|
||||||
@@ -78,13 +82,14 @@ func (q *Queries) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByID
|
|||||||
&i.LastUsedAt,
|
&i.LastUsedAt,
|
||||||
&i.RevokedAt,
|
&i.RevokedAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.Scopes,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAPIKeyByPrefix = `-- name: GetAPIKeyByPrefix :one
|
const getAPIKeyByPrefix = `-- name: GetAPIKeyByPrefix :one
|
||||||
SELECT
|
SELECT
|
||||||
ak.id, ak.project_id, ak.name, ak.prefix, ak.secret_hash, ak.created_by_user_id, ak.last_used_at, ak.revoked_at, ak.created_at,
|
ak.id, ak.project_id, ak.name, ak.prefix, ak.secret_hash, ak.created_by_user_id, ak.last_used_at, ak.revoked_at, ak.created_at, ak.scopes,
|
||||||
p.organization_id
|
p.organization_id
|
||||||
FROM core.api_keys ak
|
FROM core.api_keys ak
|
||||||
JOIN core.projects p ON p.id = ak.project_id
|
JOIN core.projects p ON p.id = ak.project_id
|
||||||
@@ -101,6 +106,7 @@ type GetAPIKeyByPrefixRow struct {
|
|||||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||||
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
|
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,13 +123,14 @@ func (q *Queries) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIK
|
|||||||
&i.LastUsedAt,
|
&i.LastUsedAt,
|
||||||
&i.RevokedAt,
|
&i.RevokedAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.Scopes,
|
||||||
&i.OrganizationID,
|
&i.OrganizationID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listAPIKeysForProject = `-- name: ListAPIKeysForProject :many
|
const listAPIKeysForProject = `-- name: ListAPIKeysForProject :many
|
||||||
SELECT id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at FROM core.api_keys
|
SELECT id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at, scopes FROM core.api_keys
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -147,6 +154,7 @@ func (q *Queries) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID
|
|||||||
&i.LastUsedAt,
|
&i.LastUsedAt,
|
||||||
&i.RevokedAt,
|
&i.RevokedAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.Scopes,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -164,7 +172,7 @@ SET revoked_at = NOW()
|
|||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND id = $2
|
AND id = $2
|
||||||
AND revoked_at IS NULL
|
AND revoked_at IS NULL
|
||||||
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at
|
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at, scopes
|
||||||
`
|
`
|
||||||
|
|
||||||
type RevokeAPIKeyParams struct {
|
type RevokeAPIKeyParams struct {
|
||||||
@@ -185,6 +193,7 @@ func (q *Queries) RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (Cor
|
|||||||
&i.LastUsedAt,
|
&i.LastUsedAt,
|
||||||
&i.RevokedAt,
|
&i.RevokedAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.Scopes,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ new_org_member AS (
|
|||||||
new_project AS (
|
new_project AS (
|
||||||
INSERT INTO core.projects (organization_id, slug, name, description)
|
INSERT INTO core.projects (organization_id, slug, name, description)
|
||||||
SELECT id, $4, $5, $6 FROM new_org
|
SELECT id, $4, $5, $6 FROM new_org
|
||||||
RETURNING id, organization_id, slug, name, description, created_at
|
RETURNING id, organization_id, slug, name, description, created_at, retention_events_days, retention_audit_days, retention_webhook_days
|
||||||
),
|
),
|
||||||
new_project_member AS (
|
new_project_member AS (
|
||||||
INSERT INTO core.project_members (project_id, user_id, role)
|
INSERT INTO core.project_members (project_id, user_id, role)
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ type CoreApiKey struct {
|
|||||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||||
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
|
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoreAuditLog struct {
|
type CoreAuditLog struct {
|
||||||
@@ -279,6 +280,9 @@ type CoreProject struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
RetentionEventsDays int32 `json:"retention_events_days"`
|
||||||
|
RetentionAuditDays int32 `json:"retention_audit_days"`
|
||||||
|
RetentionWebhookDays int32 `json:"retention_webhook_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoreProjectInvitation struct {
|
type CoreProjectInvitation struct {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ INSERT INTO core.projects (
|
|||||||
name,
|
name,
|
||||||
description
|
description
|
||||||
) VALUES ($1, $2, $3, $4)
|
) VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, organization_id, slug, name, description, created_at
|
RETURNING id, organization_id, slug, name, description, created_at, retention_events_days, retention_audit_days, retention_webhook_days
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateProjectParams struct {
|
type CreateProjectParams struct {
|
||||||
@@ -84,6 +84,9 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (C
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.RetentionEventsDays,
|
||||||
|
&i.RetentionAuditDays,
|
||||||
|
&i.RetentionWebhookDays,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (C
|
|||||||
const deleteProjectByID = `-- name: DeleteProjectByID :one
|
const deleteProjectByID = `-- name: DeleteProjectByID :one
|
||||||
DELETE FROM core.projects
|
DELETE FROM core.projects
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, organization_id, slug, name, description, created_at
|
RETURNING id, organization_id, slug, name, description, created_at, retention_events_days, retention_audit_days, retention_webhook_days
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) {
|
func (q *Queries) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) {
|
||||||
@@ -104,12 +107,15 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProj
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.RetentionEventsDays,
|
||||||
|
&i.RetentionAuditDays,
|
||||||
|
&i.RetentionWebhookDays,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProjectByID = `-- name: GetProjectByID :one
|
const getProjectByID = `-- name: GetProjectByID :one
|
||||||
SELECT id, organization_id, slug, name, description, created_at FROM core.projects
|
SELECT id, organization_id, slug, name, description, created_at, retention_events_days, retention_audit_days, retention_webhook_days FROM core.projects
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -123,6 +129,9 @@ func (q *Queries) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.RetentionEventsDays,
|
||||||
|
&i.RetentionAuditDays,
|
||||||
|
&i.RetentionWebhookDays,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -327,7 +336,7 @@ func (q *Queries) ListProjectMembers(ctx context.Context, projectID uuid.UUID) (
|
|||||||
|
|
||||||
const listProjectsForOrganization = `-- name: ListProjectsForOrganization :many
|
const listProjectsForOrganization = `-- name: ListProjectsForOrganization :many
|
||||||
SELECT
|
SELECT
|
||||||
p.id, p.organization_id, p.slug, p.name, p.description, p.created_at,
|
p.id, p.organization_id, p.slug, p.name, p.description, p.created_at, p.retention_events_days, p.retention_audit_days, p.retention_webhook_days,
|
||||||
pm.role AS membership_role
|
pm.role AS membership_role
|
||||||
FROM core.projects p
|
FROM core.projects p
|
||||||
LEFT JOIN core.project_members pm
|
LEFT JOIN core.project_members pm
|
||||||
@@ -356,6 +365,9 @@ type ListProjectsForOrganizationRow struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
RetentionEventsDays int32 `json:"retention_events_days"`
|
||||||
|
RetentionAuditDays int32 `json:"retention_audit_days"`
|
||||||
|
RetentionWebhookDays int32 `json:"retention_webhook_days"`
|
||||||
MembershipRole NullCoreProjectRole `json:"membership_role"`
|
MembershipRole NullCoreProjectRole `json:"membership_role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +387,9 @@ func (q *Queries) ListProjectsForOrganization(ctx context.Context, arg ListProje
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.RetentionEventsDays,
|
||||||
|
&i.RetentionAuditDays,
|
||||||
|
&i.RetentionWebhookDays,
|
||||||
&i.MembershipRole,
|
&i.MembershipRole,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -416,9 +431,12 @@ const updateProjectByID = `-- name: UpdateProjectByID :one
|
|||||||
UPDATE core.projects
|
UPDATE core.projects
|
||||||
SET slug = $2,
|
SET slug = $2,
|
||||||
name = $3,
|
name = $3,
|
||||||
description = $4
|
description = $4,
|
||||||
|
retention_events_days = COALESCE($5, retention_events_days),
|
||||||
|
retention_audit_days = COALESCE($6, retention_audit_days),
|
||||||
|
retention_webhook_days = COALESCE($7, retention_webhook_days)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, organization_id, slug, name, description, created_at
|
RETURNING id, organization_id, slug, name, description, created_at, retention_events_days, retention_audit_days, retention_webhook_days
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateProjectByIDParams struct {
|
type UpdateProjectByIDParams struct {
|
||||||
@@ -426,6 +444,9 @@ type UpdateProjectByIDParams struct {
|
|||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
|
RetentionEventsDays *int32 `json:"retention_events_days"`
|
||||||
|
RetentionAuditDays *int32 `json:"retention_audit_days"`
|
||||||
|
RetentionWebhookDays *int32 `json:"retention_webhook_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error) {
|
func (q *Queries) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error) {
|
||||||
@@ -434,6 +455,9 @@ func (q *Queries) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDPa
|
|||||||
arg.Slug,
|
arg.Slug,
|
||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Description,
|
arg.Description,
|
||||||
|
arg.RetentionEventsDays,
|
||||||
|
arg.RetentionAuditDays,
|
||||||
|
arg.RetentionWebhookDays,
|
||||||
)
|
)
|
||||||
var i CoreProject
|
var i CoreProject
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -443,6 +467,9 @@ func (q *Queries) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDPa
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.RetentionEventsDays,
|
||||||
|
&i.RetentionAuditDays,
|
||||||
|
&i.RetentionWebhookDays,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ type Querier interface {
|
|||||||
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
||||||
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
||||||
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
||||||
|
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
||||||
|
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
||||||
|
// that stream. Pending webhook deliveries are never swept — they may still be
|
||||||
|
// retrying.
|
||||||
|
SweepExpiredEvents(ctx context.Context) (int64, error)
|
||||||
|
// Org-scoped audit rows (project_id NULL) have no project retention — sweep
|
||||||
|
// them on a fixed 365-day floor so they cannot grow unbounded.
|
||||||
|
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
||||||
|
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
||||||
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
||||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: retention.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sweepExpiredAuditLogs = `-- name: SweepExpiredAuditLogs :execrows
|
||||||
|
DELETE FROM core.audit_logs a
|
||||||
|
USING core.projects p
|
||||||
|
WHERE a.project_id = p.id
|
||||||
|
AND p.retention_audit_days > 0
|
||||||
|
AND a.created_at < NOW() - make_interval(days => p.retention_audit_days)
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SweepExpiredAuditLogs(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, sweepExpiredAuditLogs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweepExpiredEvents = `-- name: SweepExpiredEvents :execrows
|
||||||
|
|
||||||
|
DELETE FROM core.events e
|
||||||
|
USING core.projects p
|
||||||
|
WHERE e.project_id = p.id
|
||||||
|
AND p.retention_events_days > 0
|
||||||
|
AND e.ts < NOW() - make_interval(days => p.retention_events_days)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
||||||
|
// that stream. Pending webhook deliveries are never swept — they may still be
|
||||||
|
// retrying.
|
||||||
|
func (q *Queries) SweepExpiredEvents(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, sweepExpiredEvents)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweepExpiredOrgAuditLogs = `-- name: SweepExpiredOrgAuditLogs :execrows
|
||||||
|
DELETE FROM core.audit_logs a
|
||||||
|
WHERE a.project_id IS NULL
|
||||||
|
AND a.created_at < NOW() - INTERVAL '365 days'
|
||||||
|
`
|
||||||
|
|
||||||
|
// Org-scoped audit rows (project_id NULL) have no project retention — sweep
|
||||||
|
// them on a fixed 365-day floor so they cannot grow unbounded.
|
||||||
|
func (q *Queries) SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, sweepExpiredOrgAuditLogs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweepExpiredWebhookDeliveries = `-- name: SweepExpiredWebhookDeliveries :execrows
|
||||||
|
DELETE FROM core.webhook_deliveries d
|
||||||
|
USING core.webhooks w
|
||||||
|
JOIN core.projects p ON p.id = w.project_id
|
||||||
|
WHERE d.webhook_id = w.id
|
||||||
|
AND p.retention_webhook_days > 0
|
||||||
|
AND d.status <> 'pending'
|
||||||
|
AND d.created_at < NOW() - make_interval(days => p.retention_webhook_days)
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, sweepExpiredWebhookDeliveries)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
@@ -239,6 +239,7 @@ const listComponents = `-- name: ListComponents :many
|
|||||||
SELECT id, project_id, name, kind, meta, created_at FROM core.components
|
SELECT id, project_id, name, kind, meta, created_at FROM core.components
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
|
LIMIT 500
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error) {
|
func (q *Queries) ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error) {
|
||||||
|
|||||||
@@ -204,6 +204,23 @@ func (h *HTTPHandler) dbxRedis(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) dbxTransfer(c *gin.Context) {
|
||||||
|
actor, projectID, connectionID, ok := h.actorProjectConn(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.TransferInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.TransferDBRows(c.Request.Context(), actor, projectID, connectionID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.dbxError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
// actorAndProject resolves the actor and :projectID param.
|
// actorAndProject resolves the actor and :projectID param.
|
||||||
func (h *HTTPHandler) actorAndProject(c *gin.Context) (*models.Actor, uuid.UUID, bool) {
|
func (h *HTTPHandler) actorAndProject(c *gin.Context) (*models.Actor, uuid.UUID, bool) {
|
||||||
actor, ok := middleware.RequireActor(c)
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
router.GET("/api/v1/health/liveness", h.liveness)
|
router.GET("/api/v1/health/liveness", h.liveness)
|
||||||
router.GET("/api/v1/health/readiness", h.readiness)
|
router.GET("/api/v1/health/readiness", h.readiness)
|
||||||
router.GET("/api/v1/openapi.yaml", h.openapi)
|
router.GET("/api/v1/openapi.yaml", h.openapi)
|
||||||
|
router.GET("/api/v1/metrics", h.metrics)
|
||||||
|
|
||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
api.GET("/me", h.me)
|
api.GET("/me", h.me)
|
||||||
@@ -91,6 +92,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.GET("/projects/:projectID/db-connections/:connectionID/foreign-keys", h.dbxForeignKeys)
|
api.GET("/projects/:projectID/db-connections/:connectionID/foreign-keys", h.dbxForeignKeys)
|
||||||
api.POST("/projects/:projectID/db-connections/:connectionID/query", h.dbxQuery)
|
api.POST("/projects/:projectID/db-connections/:connectionID/query", h.dbxQuery)
|
||||||
api.POST("/projects/:projectID/db-connections/:connectionID/redis", h.dbxRedis)
|
api.POST("/projects/:projectID/db-connections/:connectionID/redis", h.dbxRedis)
|
||||||
|
api.POST("/projects/:projectID/db-connections/:connectionID/transfer", h.dbxTransfer)
|
||||||
api.GET("/projects/:projectID/integrations", h.listIntegrations)
|
api.GET("/projects/:projectID/integrations", h.listIntegrations)
|
||||||
api.POST("/projects/:projectID/integrations", h.createIntegration)
|
api.POST("/projects/:projectID/integrations", h.createIntegration)
|
||||||
api.DELETE("/projects/:projectID/integrations/:integrationID", h.deleteIntegration)
|
api.DELETE("/projects/:projectID/integrations/:integrationID", h.deleteIntegration)
|
||||||
@@ -123,6 +125,12 @@ func (h *HTTPHandler) openapi(c *gin.Context) {
|
|||||||
c.File("openapi/openapi.yaml")
|
c.File("openapi/openapi.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metrics is intentionally unauthenticated — Prometheus scrapes it. Restrict
|
||||||
|
// it at the edge if the deployment is public-facing.
|
||||||
|
func (h *HTTPHandler) metrics(c *gin.Context) {
|
||||||
|
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(h.Metrics.RenderPrometheus()))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) me(c *gin.Context) {
|
func (h *HTTPHandler) me(c *gin.Context) {
|
||||||
actor, ok := middleware.RequireActor(c)
|
actor, ok := middleware.RequireActor(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type projectResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
MembershipRole *string `json:"membership_role"`
|
MembershipRole *string `json:"membership_role"`
|
||||||
|
RetentionEventsDays int32 `json:"retention_events_days"`
|
||||||
|
RetentionAuditDays int32 `json:"retention_audit_days"`
|
||||||
|
RetentionWebhookDays int32 `json:"retention_webhook_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiKeyResponse struct {
|
type apiKeyResponse struct {
|
||||||
@@ -30,6 +33,7 @@ type apiKeyResponse struct {
|
|||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
LastUsedAt *time.Time `json:"last_used_at"`
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
RevokedAt *time.Time `json:"revoked_at"`
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
}
|
}
|
||||||
@@ -120,6 +124,9 @@ func toProjectFromRow(row db.ListProjectsForOrganizationRow) projectResponse {
|
|||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Description: row.Description,
|
Description: row.Description,
|
||||||
MembershipRole: membershipRole,
|
MembershipRole: membershipRole,
|
||||||
|
RetentionEventsDays: row.RetentionEventsDays,
|
||||||
|
RetentionAuditDays: row.RetentionAuditDays,
|
||||||
|
RetentionWebhookDays: row.RetentionWebhookDays,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +138,9 @@ func toProjectFromCore(row db.CoreProject) projectResponse {
|
|||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Description: row.Description,
|
Description: row.Description,
|
||||||
MembershipRole: nil,
|
MembershipRole: nil,
|
||||||
|
RetentionEventsDays: row.RetentionEventsDays,
|
||||||
|
RetentionAuditDays: row.RetentionAuditDays,
|
||||||
|
RetentionWebhookDays: row.RetentionWebhookDays,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +158,7 @@ func toAPIKeyResponse(row db.CoreApiKey) apiKeyResponse {
|
|||||||
ProjectID: row.ProjectID.String(),
|
ProjectID: row.ProjectID.String(),
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Prefix: row.Prefix,
|
Prefix: row.Prefix,
|
||||||
|
Scopes: row.Scopes,
|
||||||
LastUsedAt: timestamptzPtr(row.LastUsedAt),
|
LastUsedAt: timestamptzPtr(row.LastUsedAt),
|
||||||
RevokedAt: timestamptzPtr(row.RevokedAt),
|
RevokedAt: timestamptzPtr(row.RevokedAt),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
|||||||
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") {
|
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if need := requiredAPIKeyScope(c.Request.Method, c.Request.URL.Path); !actor.HasScope(need) {
|
||||||
|
apperrors.Abort(c, http.StatusForbidden, "insufficient_scope", "api key lacks the required scope: "+need)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Set(actorKey, actor)
|
c.Set(actorKey, actor)
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -264,9 +268,36 @@ func (m AuthMiddleware) resolveAPIKeyActor(ctx context.Context, rawKey string) (
|
|||||||
OrganizationID: &orgID,
|
OrganizationID: &orgID,
|
||||||
APIKeyID: &apiKeyID,
|
APIKeyID: &apiKeyID,
|
||||||
APIKeyPrefix: prefix,
|
APIKeyPrefix: prefix,
|
||||||
|
Scopes: row.Scopes,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requiredAPIKeyScope maps a request to the scope an API key must hold.
|
||||||
|
// admin-only surfaces: org management, invitations, bootstrap, project
|
||||||
|
// settings/members, and API key minting (a write key must never mint keys).
|
||||||
|
func requiredAPIKeyScope(method, path string) string {
|
||||||
|
switch method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||||
|
return "read"
|
||||||
|
}
|
||||||
|
if path == "/api/v1/ingest" {
|
||||||
|
return "ingest"
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(path, "/api/v1/")
|
||||||
|
segs := strings.SplitN(rest, "/", 3)
|
||||||
|
switch segs[0] {
|
||||||
|
case "organizations", "invitations", "bootstrap":
|
||||||
|
return "admin"
|
||||||
|
case "projects":
|
||||||
|
if len(segs) < 3 ||
|
||||||
|
strings.HasPrefix(segs[2], "api-keys") ||
|
||||||
|
strings.HasPrefix(segs[2], "members") {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "write"
|
||||||
|
}
|
||||||
|
|
||||||
func PgUUID(id uuid.UUID) pgtype.UUID {
|
func PgUUID(id uuid.UUID) pgtype.UUID {
|
||||||
return pgtype.UUID{Bytes: id, Valid: true}
|
return pgtype.UUID{Bytes: id, Valid: true}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequiredAPIKeyScope(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
method, path, want string
|
||||||
|
}{
|
||||||
|
{http.MethodPost, "/api/v1/ingest", "ingest"},
|
||||||
|
{http.MethodOptions, "/api/v1/ingest", "read"}, // preflight passes through CORS
|
||||||
|
{http.MethodGet, "/api/v1/projects/x/events", "read"},
|
||||||
|
{http.MethodGet, "/api/v1/projects/x/api-keys", "read"},
|
||||||
|
{http.MethodPost, "/api/v1/projects/x/api-keys", "admin"},
|
||||||
|
{http.MethodDelete, "/api/v1/projects/x/api-keys/y", "admin"},
|
||||||
|
{http.MethodPatch, "/api/v1/projects/x/members/y", "admin"},
|
||||||
|
{http.MethodPatch, "/api/v1/projects/x", "admin"},
|
||||||
|
{http.MethodDelete, "/api/v1/projects/x", "admin"},
|
||||||
|
{http.MethodPatch, "/api/v1/organizations/x", "admin"},
|
||||||
|
{http.MethodDelete, "/api/v1/organizations/x", "admin"},
|
||||||
|
{http.MethodPost, "/api/v1/organizations/x/invitations", "admin"},
|
||||||
|
{http.MethodPost, "/api/v1/bootstrap", "admin"},
|
||||||
|
{http.MethodPost, "/api/v1/projects/x/buckets", "write"},
|
||||||
|
{http.MethodPost, "/api/v1/projects/x/db-connections/y/transfer", "write"},
|
||||||
|
{http.MethodDelete, "/api/v1/buckets/x", "write"},
|
||||||
|
{http.MethodPost, "/api/v1/projects/x/deploy-markers", "write"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := requiredAPIKeyScope(tc.method, tc.path); got != tc.want {
|
||||||
|
t.Errorf("%s %s: got %q, want %q", tc.method, tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActorHasScope(t *testing.T) {
|
||||||
|
key := func(scopes ...string) *models.Actor {
|
||||||
|
return &models.Actor{Type: models.ActorTypeAPIKey, Scopes: scopes}
|
||||||
|
}
|
||||||
|
user := &models.Actor{Type: models.ActorTypeUser}
|
||||||
|
|
||||||
|
if !user.HasScope("admin") {
|
||||||
|
t.Error("user actors bypass scope checks")
|
||||||
|
}
|
||||||
|
if !key("admin").HasScope("read") {
|
||||||
|
t.Error("admin should satisfy every requirement")
|
||||||
|
}
|
||||||
|
if !key("write").HasScope("read") || !key("write").HasScope("ingest") {
|
||||||
|
t.Error("write should imply read and ingest")
|
||||||
|
}
|
||||||
|
if key("write").HasScope("admin") {
|
||||||
|
t.Error("write must not imply admin")
|
||||||
|
}
|
||||||
|
if key("read").HasScope("write") || key("read").HasScope("ingest") {
|
||||||
|
t.Error("read must not imply write or ingest")
|
||||||
|
}
|
||||||
|
if !key("ingest").HasScope("ingest") || key("ingest").HasScope("read") {
|
||||||
|
t.Error("ingest keys only reach /ingest")
|
||||||
|
}
|
||||||
|
if key().HasScope("read") {
|
||||||
|
t.Error("scopeless key has no access")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type Actor struct {
|
|||||||
OrganizationID *uuid.UUID
|
OrganizationID *uuid.UUID
|
||||||
APIKeyID *uuid.UUID
|
APIKeyID *uuid.UUID
|
||||||
APIKeyPrefix string
|
APIKeyPrefix string
|
||||||
|
Scopes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Actor) IsUser() bool {
|
func (a *Actor) IsUser() bool {
|
||||||
@@ -30,3 +31,35 @@ func (a *Actor) IsUser() bool {
|
|||||||
func (a *Actor) IsAPIKey() bool {
|
func (a *Actor) IsAPIKey() bool {
|
||||||
return a != nil && a.Type == ActorTypeAPIKey
|
return a != nil && a.Type == ActorTypeAPIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasScope reports whether the actor may perform work requiring `need`.
|
||||||
|
// User actors are always allowed — scopes constrain API keys only.
|
||||||
|
// Scope semantics: admin unlocks everything; write unlocks read + ingest +
|
||||||
|
// non-admin mutations; read and ingest unlock only themselves.
|
||||||
|
func (a *Actor) HasScope(need string) bool {
|
||||||
|
if a == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.IsUser() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, s := range a.Scopes {
|
||||||
|
switch s {
|
||||||
|
case "admin":
|
||||||
|
return true
|
||||||
|
case "write":
|
||||||
|
if need == "read" || need == "write" || need == "ingest" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "read":
|
||||||
|
if need == "read" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "ingest":
|
||||||
|
if need == "ingest" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package observability
|
package observability
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -56,3 +57,24 @@ func (m *Metrics) GetStats() map[string]any {
|
|||||||
"avg_response_time_ms": avgResponseTime,
|
"avg_response_time_ms": avgResponseTime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderPrometheus emits the collector in Prometheus text exposition format.
|
||||||
|
// Duration is exposed as sum+count so scrapers can compute real averages.
|
||||||
|
func (m *Metrics) RenderPrometheus() string {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return fmt.Sprintf(`# HELP primora_http_requests_total Total HTTP requests handled.
|
||||||
|
# TYPE primora_http_requests_total counter
|
||||||
|
primora_http_requests_total %d
|
||||||
|
# HELP primora_http_request_errors_total HTTP requests that ended in a 5xx response.
|
||||||
|
# TYPE primora_http_request_errors_total counter
|
||||||
|
primora_http_request_errors_total %d
|
||||||
|
# HELP primora_http_active_requests Requests currently in flight.
|
||||||
|
# TYPE primora_http_active_requests gauge
|
||||||
|
primora_http_active_requests %d
|
||||||
|
# HELP primora_http_request_duration_ms_sum Sum of request durations in milliseconds.
|
||||||
|
# TYPE primora_http_request_duration_ms_sum counter
|
||||||
|
primora_http_request_duration_ms_sum %d
|
||||||
|
`,
|
||||||
|
m.requestCount, m.errorCount, m.activeRequests, m.totalResponseTime.Milliseconds())
|
||||||
|
}
|
||||||
@@ -500,3 +500,223 @@ func (s *PlatformService) ExecuteDBXRedis(ctx context.Context, actor *models.Act
|
|||||||
}
|
}
|
||||||
return map[string]any{"output": text}, nil
|
return map[string]any{"output": text}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransferInput describes a DB-to-DB link: rows read from the source
|
||||||
|
// connection are written to the target connection.
|
||||||
|
type TransferInput struct {
|
||||||
|
TargetConnectionID string `json:"target_connection_id" validate:"required,uuid"`
|
||||||
|
Query string `json:"query" validate:"required"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
// SQL targets: destination table (schema-qualified allowed).
|
||||||
|
TargetTable string `json:"target_table"`
|
||||||
|
// Redis targets: SET key template with {column} placeholders; the value is
|
||||||
|
// value_column's cell, or the whole row as JSON when omitted.
|
||||||
|
KeyPattern string `json:"key_pattern"`
|
||||||
|
ValueColumn string `json:"value_column"`
|
||||||
|
// Optional TTL in seconds applied to each Redis SET.
|
||||||
|
TTLSeconds *int `json:"ttl_seconds" validate:"omitempty,min=0"`
|
||||||
|
Limit int `json:"limit" validate:"omitempty,min=1,max=1000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferMaxRows = 1000
|
||||||
|
|
||||||
|
// TransferDBRows moves data between two saved connections of the same
|
||||||
|
// project. Both connections must accept write-level access.
|
||||||
|
func (s *PlatformService) TransferDBRows(ctx context.Context, actor *models.Actor, projectID, sourceID uuid.UUID, input TransferInput, requestID string) (map[string]any, error) {
|
||||||
|
source, err := s.connForTool(ctx, actor, projectID, sourceID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
targetID, err := uuid.Parse(input.TargetConnectionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("target_connection_id must be a uuid")
|
||||||
|
}
|
||||||
|
target, err := s.connForTool(ctx, actor, projectID, targetID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := input.Limit
|
||||||
|
if limit <= 0 || limit > transferMaxRows {
|
||||||
|
limit = transferMaxRows
|
||||||
|
}
|
||||||
|
args := map[string]any{"connection_name": dbxConnectionName(projectID, source.Name), "sql": input.Query}
|
||||||
|
if input.Database != "" {
|
||||||
|
args["database"] = input.Database
|
||||||
|
}
|
||||||
|
text, err := s.dbx.Call(ctx, "dbx_execute_query", args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
table, _ := dbx.ParseMDTable(text)
|
||||||
|
if table == nil || len(table.Columns) == 0 {
|
||||||
|
return nil, errors.New("source query did not return a result table")
|
||||||
|
}
|
||||||
|
rows := table.Rows
|
||||||
|
truncated := false
|
||||||
|
if len(rows) > limit {
|
||||||
|
rows = rows[:limit]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var transferred int
|
||||||
|
if target.DbType == "redis" {
|
||||||
|
transferred, err = s.transferToRedis(ctx, projectID, target, table.Columns, rows, input)
|
||||||
|
} else {
|
||||||
|
transferred, err = s.transferToSQL(ctx, projectID, target, table.Columns, rows, input)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
project, perr := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||||
|
if perr == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "db_connection.transferred", "db_connection", source.ID.String(), map[string]any{
|
||||||
|
"source": source.Name,
|
||||||
|
"target": target.Name,
|
||||||
|
"transferred": transferred,
|
||||||
|
"truncated": truncated,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"transferred": transferred,
|
||||||
|
"truncated": truncated,
|
||||||
|
"source": source.Name,
|
||||||
|
"target": target.Name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transferToSQL INSERTs rows into target_table in batches of 100. Cell values
|
||||||
|
// arrive as text from DBX's markdown output; literal "NULL" maps to SQL NULL.
|
||||||
|
func (s *PlatformService) transferToSQL(ctx context.Context, projectID uuid.UUID, target db.CoreDbConnection, cols []string, rows [][]string, input TransferInput) (int, error) {
|
||||||
|
tableIdent, ok := safeDottedIdent(input.TargetTable)
|
||||||
|
if !ok {
|
||||||
|
return 0, errors.New("target_table must be a plain identifier or schema.table")
|
||||||
|
}
|
||||||
|
colIdents := make([]string, len(cols))
|
||||||
|
for i, col := range cols {
|
||||||
|
ident, ok := safeIdent(col)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("source column %q is not a usable identifier", col)
|
||||||
|
}
|
||||||
|
colIdents[i] = `"` + ident + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
transferred := 0
|
||||||
|
const batch = 100
|
||||||
|
for start := 0; start < len(rows); start += batch {
|
||||||
|
end := min(start+batch, len(rows))
|
||||||
|
var values strings.Builder
|
||||||
|
values.WriteString("INSERT INTO " + tableIdent + " (" + strings.Join(colIdents, ", ") + ") VALUES ")
|
||||||
|
for i, row := range rows[start:end] {
|
||||||
|
if i > 0 {
|
||||||
|
values.WriteString(", ")
|
||||||
|
}
|
||||||
|
values.WriteString("(")
|
||||||
|
for j := range cols {
|
||||||
|
if j > 0 {
|
||||||
|
values.WriteString(", ")
|
||||||
|
}
|
||||||
|
cell := ""
|
||||||
|
if j < len(row) {
|
||||||
|
cell = row[j]
|
||||||
|
}
|
||||||
|
if cell == "NULL" {
|
||||||
|
values.WriteString("NULL")
|
||||||
|
} else {
|
||||||
|
values.WriteString(sqlQuote(cell))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.WriteString(")")
|
||||||
|
}
|
||||||
|
if _, err := s.dbx.Call(ctx, "dbx_execute_query", map[string]any{
|
||||||
|
"connection_name": dbxConnectionName(projectID, target.Name),
|
||||||
|
"sql": values.String(),
|
||||||
|
}); err != nil {
|
||||||
|
return transferred, fmt.Errorf("insert batch at row %d: %w", start, err)
|
||||||
|
}
|
||||||
|
transferred += end - start
|
||||||
|
}
|
||||||
|
return transferred, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transferToRedis SETs one key per row. Placeholders like {email} in
|
||||||
|
// key_pattern are replaced with that row's cell value.
|
||||||
|
var keyPlaceholder = regexp.MustCompile(`\{([A-Za-z0-9_]+)\}`)
|
||||||
|
|
||||||
|
func (s *PlatformService) transferToRedis(ctx context.Context, projectID uuid.UUID, target db.CoreDbConnection, cols []string, rows [][]string, input TransferInput) (int, error) {
|
||||||
|
if input.KeyPattern == "" {
|
||||||
|
return 0, errors.New("key_pattern is required for redis targets")
|
||||||
|
}
|
||||||
|
colIndex := map[string]int{}
|
||||||
|
for i, col := range cols {
|
||||||
|
colIndex[col] = i
|
||||||
|
}
|
||||||
|
for _, ph := range keyPlaceholder.FindAllStringSubmatch(input.KeyPattern, -1) {
|
||||||
|
if _, ok := colIndex[ph[1]]; !ok {
|
||||||
|
return 0, fmt.Errorf("key_pattern references unknown column %q", ph[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.ValueColumn != "" {
|
||||||
|
if _, ok := colIndex[input.ValueColumn]; !ok {
|
||||||
|
return 0, fmt.Errorf("value_column %q not in source columns", input.ValueColumn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transferred := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
key := keyPlaceholder.ReplaceAllStringFunc(input.KeyPattern, func(m string) string {
|
||||||
|
col := keyPlaceholder.FindStringSubmatch(m)[1]
|
||||||
|
if i, ok := colIndex[col]; ok && i < len(row) {
|
||||||
|
return row[i]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
var value string
|
||||||
|
if input.ValueColumn != "" {
|
||||||
|
value = row[colIndex[input.ValueColumn]]
|
||||||
|
} else {
|
||||||
|
obj := map[string]string{}
|
||||||
|
for i, col := range cols {
|
||||||
|
if i < len(row) {
|
||||||
|
obj[col] = row[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(obj)
|
||||||
|
value = string(raw)
|
||||||
|
}
|
||||||
|
cmd := "SET " + redisQuote(key) + " " + redisQuote(value)
|
||||||
|
if input.TTLSeconds != nil && *input.TTLSeconds > 0 {
|
||||||
|
cmd += fmt.Sprintf(" EX %d", *input.TTLSeconds)
|
||||||
|
}
|
||||||
|
if _, err := s.dbx.Call(ctx, "dbx_execute_redis_command", map[string]any{
|
||||||
|
"connection_name": dbxConnectionName(projectID, target.Name),
|
||||||
|
"command": cmd,
|
||||||
|
}); err != nil {
|
||||||
|
return transferred, fmt.Errorf("SET %q: %w", key, err)
|
||||||
|
}
|
||||||
|
transferred++
|
||||||
|
}
|
||||||
|
return transferred, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var dottedIdent = regexp.MustCompile(`^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)?$`)
|
||||||
|
|
||||||
|
func safeDottedIdent(s string) (string, bool) {
|
||||||
|
if !dottedIdent.MatchString(s) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = `"` + parts[i] + `"`
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "."), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqlQuote(v string) string {
|
||||||
|
return "'" + strings.ReplaceAll(v, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func redisQuote(v string) string {
|
||||||
|
return `"` + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `"`, `\"`) + `"`
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@ type UpdateProjectInput struct {
|
|||||||
Name string `json:"name" validate:"required,min=2"`
|
Name string `json:"name" validate:"required,min=2"`
|
||||||
Slug string `json:"slug" validate:"required,min=2"`
|
Slug string `json:"slug" validate:"required,min=2"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
|
// Retention windows in days; nil leaves the current value, 0 disables
|
||||||
|
// the sweeper for that stream.
|
||||||
|
RetentionEventsDays *int32 `json:"retention_events_days" validate:"omitempty,min=0,max=3650"`
|
||||||
|
RetentionAuditDays *int32 `json:"retention_audit_days" validate:"omitempty,min=0,max=3650"`
|
||||||
|
RetentionWebhookDays *int32 `json:"retention_webhook_days" validate:"omitempty,min=0,max=3650"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateInvitationInput struct {
|
type CreateInvitationInput struct {
|
||||||
@@ -79,6 +84,7 @@ type CreateInvitationInput struct {
|
|||||||
|
|
||||||
type CreateAPIKeyInput struct {
|
type CreateAPIKeyInput struct {
|
||||||
Name string `json:"name" validate:"required,min=2"`
|
Name string `json:"name" validate:"required,min=2"`
|
||||||
|
Scopes []string `json:"scopes" validate:"omitempty,dive,oneof=ingest read write admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBucketInput struct {
|
type CreateBucketInput struct {
|
||||||
@@ -740,6 +746,9 @@ func (s *PlatformService) UpdateProject(ctx context.Context, actor *models.Actor
|
|||||||
Slug: normalizeSlug(input.Slug),
|
Slug: normalizeSlug(input.Slug),
|
||||||
Name: strings.TrimSpace(input.Name),
|
Name: strings.TrimSpace(input.Name),
|
||||||
Description: input.Description,
|
Description: input.Description,
|
||||||
|
RetentionEventsDays: input.RetentionEventsDays,
|
||||||
|
RetentionAuditDays: input.RetentionAuditDays,
|
||||||
|
RetentionWebhookDays: input.RetentionWebhookDays,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return db.CoreProject{}, err
|
return db.CoreProject{}, err
|
||||||
@@ -1038,6 +1047,7 @@ func (s *PlatformService) CreateAPIKey(ctx context.Context, actor *models.Actor,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generate api key secret: %w", err)
|
return nil, fmt.Errorf("generate api key secret: %w", err)
|
||||||
}
|
}
|
||||||
|
scopes := normalizeAPIKeyScopes(input.Scopes)
|
||||||
prefix := "prm_" + prefixSuffix
|
prefix := "prm_" + prefixSuffix
|
||||||
rawKey := prefix + "_" + keySuffix
|
rawKey := prefix + "_" + keySuffix
|
||||||
secretHash := sha256.Sum256([]byte(rawKey))
|
secretHash := sha256.Sum256([]byte(rawKey))
|
||||||
@@ -1046,6 +1056,7 @@ func (s *PlatformService) CreateAPIKey(ctx context.Context, actor *models.Actor,
|
|||||||
Name: strings.TrimSpace(input.Name),
|
Name: strings.TrimSpace(input.Name),
|
||||||
Prefix: prefix,
|
Prefix: prefix,
|
||||||
SecretHash: secretHash[:],
|
SecretHash: secretHash[:],
|
||||||
|
Scopes: scopes,
|
||||||
CreatedByUserID: pgtype.UUID{Bytes: *actor.UserID, Valid: true},
|
CreatedByUserID: pgtype.UUID{Bytes: *actor.UserID, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1062,9 +1073,27 @@ func (s *PlatformService) CreateAPIKey(ctx context.Context, actor *models.Actor,
|
|||||||
"prefix": row.Prefix,
|
"prefix": row.Prefix,
|
||||||
"secret": rawKey,
|
"secret": rawKey,
|
||||||
"name": row.Name,
|
"name": row.Name,
|
||||||
|
"scopes": row.Scopes,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeAPIKeyScopes dedupes the requested scopes. An omitted or empty
|
||||||
|
// list means a full-access key — the pre-scopes behaviour.
|
||||||
|
func normalizeAPIKeyScopes(input []string) []string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return []string{"admin"}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(input))
|
||||||
|
for _, s := range input {
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PlatformService) RevokeAPIKey(ctx context.Context, actor *models.Actor, projectID, apiKeyID uuid.UUID, requestID string) error {
|
func (s *PlatformService) RevokeAPIKey(ctx context.Context, actor *models.Actor, projectID, apiKeyID uuid.UUID, requestID string) error {
|
||||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RetentionSweeper deletes telemetry events, audit rows, and webhook
|
||||||
|
// deliveries that are past each project's retention window. It runs once
|
||||||
|
// shortly after boot and then hourly — a missed sweep is not a problem, the
|
||||||
|
// next tick catches it.
|
||||||
|
type RetentionSweeper struct {
|
||||||
|
repo *repositories.CoreRepository
|
||||||
|
logger *slog.Logger
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRetentionSweeper(repo *repositories.CoreRepository, logger *slog.Logger) *RetentionSweeper {
|
||||||
|
return &RetentionSweeper{repo: repo, logger: logger, interval: time.Hour}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RetentionSweeper) Run(ctx context.Context) {
|
||||||
|
// jarvis: initial delay — let migrations and first-party seeds settle before sweeping.
|
||||||
|
timer := time.NewTimer(time.Minute)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
s.Sweep(ctx)
|
||||||
|
timer.Reset(s.interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RetentionSweeper) Sweep(ctx context.Context) {
|
||||||
|
q := s.repo.Queries()
|
||||||
|
for _, sweep := range []struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context) (int64, error)
|
||||||
|
}{
|
||||||
|
{"events", q.SweepExpiredEvents},
|
||||||
|
{"audit_logs", q.SweepExpiredAuditLogs},
|
||||||
|
{"audit_logs_org", q.SweepExpiredOrgAuditLogs},
|
||||||
|
{"webhook_deliveries", q.SweepExpiredWebhookDeliveries},
|
||||||
|
} {
|
||||||
|
rows, err := sweep.fn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Warn("retention sweep failed", "stream", sweep.name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rows > 0 {
|
||||||
|
s.logger.Info("retention sweep removed rows", "stream", sweep.name, "rows", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,18 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ReadinessStatus"
|
$ref: "#/components/schemas/ReadinessStatus"
|
||||||
|
/metrics:
|
||||||
|
get:
|
||||||
|
tags: [Health]
|
||||||
|
operationId: getMetrics
|
||||||
|
summary: Prometheus text exposition of platform HTTP metrics
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Prometheus metrics
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
/me:
|
/me:
|
||||||
get:
|
get:
|
||||||
tags: [Platform]
|
tags: [Platform]
|
||||||
@@ -1092,6 +1104,34 @@ paths:
|
|||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
"502":
|
"502":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/db-connections/{connectionID}/transfer:
|
||||||
|
post:
|
||||||
|
tags: [Databases]
|
||||||
|
operationId: transferDbRows
|
||||||
|
summary: Move query results from this connection to another saved connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/ConnectionID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/DBTransferRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Transfer result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/DBTransferResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
/ingest:
|
/ingest:
|
||||||
post:
|
post:
|
||||||
tags: [Telemetry]
|
tags: [Telemetry]
|
||||||
@@ -1920,6 +1960,21 @@ components:
|
|||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
retention_events_days:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 3650
|
||||||
|
description: Telemetry event retention; 0 disables the sweep.
|
||||||
|
retention_audit_days:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 3650
|
||||||
|
description: Audit log retention; 0 disables the sweep.
|
||||||
|
retention_webhook_days:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 3650
|
||||||
|
description: Webhook delivery retention; 0 disables the sweep.
|
||||||
required: [name, slug]
|
required: [name, slug]
|
||||||
Project:
|
Project:
|
||||||
type: object
|
type: object
|
||||||
@@ -1940,6 +1995,12 @@ components:
|
|||||||
membership_role:
|
membership_role:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
retention_events_days:
|
||||||
|
type: integer
|
||||||
|
retention_audit_days:
|
||||||
|
type: integer
|
||||||
|
retention_webhook_days:
|
||||||
|
type: integer
|
||||||
required: [id, organization_id, slug, name]
|
required: [id, organization_id, slug, name]
|
||||||
ProjectOverview:
|
ProjectOverview:
|
||||||
type: object
|
type: object
|
||||||
@@ -2124,6 +2185,15 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
scopes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum: [ingest, read, write, admin]
|
||||||
|
description: >-
|
||||||
|
Access scopes for the key. admin unlocks everything; write unlocks
|
||||||
|
read + ingest + non-admin mutations; read and ingest unlock only
|
||||||
|
themselves. Omitted means ["admin"] (full access, legacy behaviour).
|
||||||
required: [name]
|
required: [name]
|
||||||
ApiKey:
|
ApiKey:
|
||||||
type: object
|
type: object
|
||||||
@@ -2138,6 +2208,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
prefix:
|
prefix:
|
||||||
type: string
|
type: string
|
||||||
|
scopes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum: [ingest, read, write, admin]
|
||||||
last_used_at:
|
last_used_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -2159,6 +2234,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
scopes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum: [ingest, read, write, admin]
|
||||||
required: [id, prefix, secret, name]
|
required: [id, prefix, secret, name]
|
||||||
CreateBucketRequest:
|
CreateBucketRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -2591,6 +2671,52 @@ components:
|
|||||||
output:
|
output:
|
||||||
type: string
|
type: string
|
||||||
required: [output]
|
required: [output]
|
||||||
|
DBTransferRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
target_connection_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
description: SELECT run against the source connection.
|
||||||
|
database:
|
||||||
|
type: string
|
||||||
|
target_table:
|
||||||
|
type: string
|
||||||
|
description: Destination table for SQL targets (schema-qualified allowed).
|
||||||
|
key_pattern:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
Redis targets: SET key template with {column} placeholders, e.g.
|
||||||
|
"user:{id}".
|
||||||
|
value_column:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
Redis targets: column whose cell becomes the value. Omitted stores
|
||||||
|
the whole row as JSON.
|
||||||
|
ttl_seconds:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Optional EX TTL applied to each Redis SET.
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 1000
|
||||||
|
default: 1000
|
||||||
|
required: [target_connection_id, query]
|
||||||
|
DBTransferResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
transferred:
|
||||||
|
type: integer
|
||||||
|
truncated:
|
||||||
|
type: boolean
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
target:
|
||||||
|
type: string
|
||||||
|
required: [transferred, truncated, source, target]
|
||||||
IngestEvent:
|
IngestEvent:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
UpdateOrganizationRequest,
|
UpdateOrganizationRequest,
|
||||||
UpdateProjectMemberRoleRequest,
|
UpdateProjectMemberRoleRequest,
|
||||||
UpdateProjectRequest,
|
UpdateProjectRequest,
|
||||||
|
CreateApiKeyRequest,
|
||||||
CopyBucketObjectRequest,
|
CopyBucketObjectRequest,
|
||||||
type Bucket,
|
type Bucket,
|
||||||
type BucketObject,
|
type BucketObject,
|
||||||
@@ -180,7 +181,14 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [projectInput, setProjectInput] = createSignal({ name: "", slug: "", description: "" });
|
const [projectInput, setProjectInput] = createSignal({ name: "", slug: "", description: "" });
|
||||||
const [projectEditInput, setProjectEditInput] = createSignal({ name: "", slug: "", description: "" });
|
const [projectEditInput, setProjectEditInput] = createSignal({
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
description: "",
|
||||||
|
retentionEventsDays: "",
|
||||||
|
retentionAuditDays: "",
|
||||||
|
retentionWebhookDays: "",
|
||||||
|
});
|
||||||
const [organizationInput, setOrganizationInput] = createSignal({ name: "", slug: "" });
|
const [organizationInput, setOrganizationInput] = createSignal({ name: "", slug: "" });
|
||||||
const [organizationEditInput, setOrganizationEditInput] = createSignal({ name: "", slug: "" });
|
const [organizationEditInput, setOrganizationEditInput] = createSignal({ name: "", slug: "" });
|
||||||
const [invitationInput, setInvitationInput] = createSignal({
|
const [invitationInput, setInvitationInput] = createSignal({
|
||||||
@@ -190,6 +198,7 @@ export default function App() {
|
|||||||
projectRole: CreateInvitationRequest.projectRole.DEVELOPER as string,
|
projectRole: CreateInvitationRequest.projectRole.DEVELOPER as string,
|
||||||
});
|
});
|
||||||
const [apiKeyName, setApiKeyName] = createSignal("Frontend key");
|
const [apiKeyName, setApiKeyName] = createSignal("Frontend key");
|
||||||
|
const [apiKeyScopes, setApiKeyScopes] = createSignal<string[]>(["admin"]);
|
||||||
const [bucketInput, setBucketInput] = createSignal({
|
const [bucketInput, setBucketInput] = createSignal({
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
@@ -258,7 +267,13 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableProjects = createMemo<ProjectSummary[]>(() => {
|
type ProjectListItem = ProjectSummary & {
|
||||||
|
retentionEventsDays?: number;
|
||||||
|
retentionAuditDays?: number;
|
||||||
|
retentionWebhookDays?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableProjects = createMemo<ProjectListItem[]>(() => {
|
||||||
const items = organizationProjects();
|
const items = organizationProjects();
|
||||||
if (!items) return activeOrganization()?.projects ?? [];
|
if (!items) return activeOrganization()?.projects ?? [];
|
||||||
return items.map((project) => ({
|
return items.map((project) => ({
|
||||||
@@ -267,10 +282,13 @@ export default function App() {
|
|||||||
slug: project.slug,
|
slug: project.slug,
|
||||||
description: project.description ?? undefined,
|
description: project.description ?? undefined,
|
||||||
membershipRole: project.membership_role ?? undefined,
|
membershipRole: project.membership_role ?? undefined,
|
||||||
|
retentionEventsDays: project.retention_events_days,
|
||||||
|
retentionAuditDays: project.retention_audit_days,
|
||||||
|
retentionWebhookDays: project.retention_webhook_days,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeProject = createMemo<ProjectSummary | undefined>(() =>
|
const activeProject = createMemo<ProjectListItem | undefined>(() =>
|
||||||
availableProjects().find((item) => item.id === selectedProjectID()),
|
availableProjects().find((item) => item.id === selectedProjectID()),
|
||||||
);
|
);
|
||||||
const canUpdateOrganization = createMemo(() => {
|
const canUpdateOrganization = createMemo(() => {
|
||||||
@@ -547,8 +565,15 @@ export default function App() {
|
|||||||
const project = activeProject();
|
const project = activeProject();
|
||||||
setProjectEditInput(
|
setProjectEditInput(
|
||||||
project
|
project
|
||||||
? { name: project.name, slug: project.slug, description: project.description ?? "" }
|
? {
|
||||||
: { name: "", slug: "", description: "" },
|
name: project.name,
|
||||||
|
slug: project.slug,
|
||||||
|
description: project.description ?? "",
|
||||||
|
retentionEventsDays: project.retentionEventsDays?.toString() ?? "",
|
||||||
|
retentionAuditDays: project.retentionAuditDays?.toString() ?? "",
|
||||||
|
retentionWebhookDays: project.retentionWebhookDays?.toString() ?? "",
|
||||||
|
}
|
||||||
|
: { name: "", slug: "", description: "", retentionEventsDays: "", retentionAuditDays: "", retentionWebhookDays: "" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -713,6 +738,12 @@ export default function App() {
|
|||||||
setProjectMessage("");
|
setProjectMessage("");
|
||||||
try {
|
try {
|
||||||
const trimmed = projectEditInput().description.trim();
|
const trimmed = projectEditInput().description.trim();
|
||||||
|
const parseRetention = (value: string): number | undefined => {
|
||||||
|
const raw = value.trim();
|
||||||
|
if (raw === "") return undefined;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? Math.min(3650, Math.max(0, Math.trunc(n))) : undefined;
|
||||||
|
};
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
await demoService.updateProject({ requestBody: { ...projectEditInput(), description: trimmed || null } });
|
await demoService.updateProject({ requestBody: { ...projectEditInput(), description: trimmed || null } });
|
||||||
} else {
|
} else {
|
||||||
@@ -722,6 +753,9 @@ export default function App() {
|
|||||||
name: projectEditInput().name,
|
name: projectEditInput().name,
|
||||||
slug: projectEditInput().slug,
|
slug: projectEditInput().slug,
|
||||||
description: trimmed.length > 0 ? trimmed : null,
|
description: trimmed.length > 0 ? trimmed : null,
|
||||||
|
retention_events_days: parseRetention(projectEditInput().retentionEventsDays),
|
||||||
|
retention_audit_days: parseRetention(projectEditInput().retentionAuditDays),
|
||||||
|
retention_webhook_days: parseRetention(projectEditInput().retentionWebhookDays),
|
||||||
} as UpdateProjectRequest,
|
} as UpdateProjectRequest,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1001,7 +1035,14 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const result = isDemo
|
const result = isDemo
|
||||||
? await demoService.createApiKey({ requestBody: { name: apiKeyName() } })
|
? await demoService.createApiKey({ requestBody: { name: apiKeyName() } })
|
||||||
: await ProjectsService.createApiKey({ projectId: projectID, requestBody: { name: apiKeyName() } });
|
: await ProjectsService.createApiKey({
|
||||||
|
projectId: projectID,
|
||||||
|
requestBody: {
|
||||||
|
name: apiKeyName(),
|
||||||
|
scopes: apiKeyScopes() as CreateApiKeyRequest["scopes"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setApiKeyScopes(["admin"]);
|
||||||
await refetchAPIKeys();
|
await refetchAPIKeys();
|
||||||
await refetchAuditLogs();
|
await refetchAuditLogs();
|
||||||
await refreshProjectOverviewSnapshot();
|
await refreshProjectOverviewSnapshot();
|
||||||
@@ -1766,6 +1807,7 @@ export default function App() {
|
|||||||
<SettingsPage
|
<SettingsPage
|
||||||
apiKeys={apiKeys()}
|
apiKeys={apiKeys()}
|
||||||
apiKeyName={apiKeyName()}
|
apiKeyName={apiKeyName()}
|
||||||
|
apiKeyScopes={apiKeyScopes()}
|
||||||
apiKeySecret={apiKeySecret()}
|
apiKeySecret={apiKeySecret()}
|
||||||
apiKeyMessage={apiKeyMessage()}
|
apiKeyMessage={apiKeyMessage()}
|
||||||
apiKeyPending={apiKeyPending()}
|
apiKeyPending={apiKeyPending()}
|
||||||
@@ -1776,6 +1818,9 @@ export default function App() {
|
|||||||
workspacePending={workspacePending()}
|
workspacePending={workspacePending()}
|
||||||
hasActiveOrganization={!!activeOrganization()}
|
hasActiveOrganization={!!activeOrganization()}
|
||||||
onApiKeyNameChange={setApiKeyName}
|
onApiKeyNameChange={setApiKeyName}
|
||||||
|
onApiKeyScopeToggle={(scope) =>
|
||||||
|
setApiKeyScopes((c) => (c.includes(scope) ? c.filter((s) => s !== scope) : [...c, scope]))
|
||||||
|
}
|
||||||
onCreateApiKey={createApiKey}
|
onCreateApiKey={createApiKey}
|
||||||
onDeleteApiKey={revokeApiKey}
|
onDeleteApiKey={revokeApiKey}
|
||||||
onOrganizationInputChange={(field, value) => setOrganizationInput((c) => ({ ...c, [field]: value }))}
|
onOrganizationInputChange={(field, value) => setOrganizationInput((c) => ({ ...c, [field]: value }))}
|
||||||
|
|||||||
@@ -531,6 +531,9 @@ class DemoService {
|
|||||||
name: p.name,
|
name: p.name,
|
||||||
description: p.description ?? null,
|
description: p.description ?? null,
|
||||||
membership_role: p.membershipRole ?? null,
|
membership_role: p.membershipRole ?? null,
|
||||||
|
retention_events_days: 90,
|
||||||
|
retention_audit_days: 90,
|
||||||
|
retention_webhook_days: 90,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
async listAuthUsers() { await this.delay(); return authUsers; }
|
async listAuthUsers() { await this.delay(); return authUsers; }
|
||||||
|
|||||||
@@ -11,16 +11,22 @@ interface ProjectInput {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectEditInput extends ProjectInput {
|
||||||
|
retentionEventsDays: string;
|
||||||
|
retentionAuditDays: string;
|
||||||
|
retentionWebhookDays: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectsPageProps {
|
interface ProjectsPageProps {
|
||||||
projects: ProjectSummary[];
|
projects: ProjectSummary[];
|
||||||
selectedProjectID?: string;
|
selectedProjectID?: string;
|
||||||
projectInput: ProjectInput;
|
projectInput: ProjectInput;
|
||||||
projectEditInput: ProjectInput;
|
projectEditInput: ProjectEditInput;
|
||||||
projectMessage: string;
|
projectMessage: string;
|
||||||
projectPending: boolean;
|
projectPending: boolean;
|
||||||
canUpdateProject: boolean;
|
canUpdateProject: boolean;
|
||||||
onProjectInputChange: (field: keyof ProjectInput, value: string) => void;
|
onProjectInputChange: (field: keyof ProjectInput, value: string) => void;
|
||||||
onProjectEditInputChange: (field: keyof ProjectInput, value: string) => void;
|
onProjectEditInputChange: (field: keyof ProjectEditInput, value: string) => void;
|
||||||
onCreateProject: (event: SubmitEvent) => void;
|
onCreateProject: (event: SubmitEvent) => void;
|
||||||
onUpdateProject: (event: SubmitEvent) => void;
|
onUpdateProject: (event: SubmitEvent) => void;
|
||||||
onDeleteProject: () => void;
|
onDeleteProject: () => void;
|
||||||
@@ -252,6 +258,38 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
|||||||
value={props.projectEditInput.description}
|
value={props.projectEditInput.description}
|
||||||
onInput={(e) => props.onProjectEditInputChange("description", e.currentTarget.value)}
|
onInput={(e) => props.onProjectEditInputChange("description", e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="label">Data retention (days)</span>
|
||||||
|
<p class="label-hint mb-2">
|
||||||
|
Rows older than this are swept hourly. 0 disables a category. Max 3650.
|
||||||
|
</p>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Input
|
||||||
|
label="Telemetry events"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={3650}
|
||||||
|
value={props.projectEditInput.retentionEventsDays}
|
||||||
|
onInput={(e) => props.onProjectEditInputChange("retentionEventsDays", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Audit log"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={3650}
|
||||||
|
value={props.projectEditInput.retentionAuditDays}
|
||||||
|
onInput={(e) => props.onProjectEditInputChange("retentionAuditDays", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Webhook deliveries"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={3650}
|
||||||
|
value={props.projectEditInput.retentionWebhookDays}
|
||||||
|
onInput={(e) => props.onProjectEditInputChange("retentionWebhookDays", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<button type="button" class="btn btn-ghost" onClick={() => setSettingsOpen(false)}>
|
<button type="button" class="btn btn-ghost" onClick={() => setSettingsOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -17,9 +17,17 @@ interface OrgInput {
|
|||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const API_KEY_SCOPES: Array<{ value: string; label: string; hint: string }> = [
|
||||||
|
{ value: "ingest", label: "ingest", hint: "telemetry ingest only" },
|
||||||
|
{ value: "read", label: "read", hint: "read endpoints" },
|
||||||
|
{ value: "write", label: "write", hint: "read + ingest + mutations" },
|
||||||
|
{ value: "admin", label: "admin", hint: "full access" },
|
||||||
|
];
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
apiKeys?: ApiKey[];
|
apiKeys?: ApiKey[];
|
||||||
apiKeyName: string;
|
apiKeyName: string;
|
||||||
|
apiKeyScopes: string[];
|
||||||
apiKeySecret: string;
|
apiKeySecret: string;
|
||||||
apiKeyMessage: string;
|
apiKeyMessage: string;
|
||||||
apiKeyPending: boolean;
|
apiKeyPending: boolean;
|
||||||
@@ -30,6 +38,7 @@ interface SettingsPageProps {
|
|||||||
workspacePending: boolean;
|
workspacePending: boolean;
|
||||||
hasActiveOrganization: boolean;
|
hasActiveOrganization: boolean;
|
||||||
onApiKeyNameChange: (name: string) => void;
|
onApiKeyNameChange: (name: string) => void;
|
||||||
|
onApiKeyScopeToggle: (scope: string) => void;
|
||||||
onCreateApiKey: () => void;
|
onCreateApiKey: () => void;
|
||||||
onDeleteApiKey: (id: string) => void;
|
onDeleteApiKey: (id: string) => void;
|
||||||
onOrganizationInputChange: (field: keyof OrgInput, value: string) => void;
|
onOrganizationInputChange: (field: keyof OrgInput, value: string) => void;
|
||||||
@@ -112,12 +121,34 @@ export function SettingsPage(props: SettingsPageProps) {
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
onClick={props.onCreateApiKey}
|
onClick={props.onCreateApiKey}
|
||||||
disabled={props.apiKeyPending || !props.apiKeyName.trim()}
|
disabled={props.apiKeyPending || !props.apiKeyName.trim() || props.apiKeyScopes.length === 0}
|
||||||
>
|
>
|
||||||
<IconPlus class="w-4 h-4" />
|
<IconPlus class="w-4 h-4" />
|
||||||
Create key
|
Create key
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<span class="label">Scopes</span>
|
||||||
|
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||||
|
<For each={API_KEY_SCOPES}>
|
||||||
|
{(scope) => (
|
||||||
|
<label class="flex items-center gap-2 text-sm text-text-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-[var(--accent)]"
|
||||||
|
checked={props.apiKeyScopes.includes(scope.value)}
|
||||||
|
disabled={props.apiKeyScopes.length === 1 && props.apiKeyScopes.includes(scope.value)}
|
||||||
|
onChange={() => props.onApiKeyScopeToggle(scope.value)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<code>{scope.label}</code>
|
||||||
|
<span class="text-text-3"> — {scope.hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-flush">
|
<div class="card card-flush">
|
||||||
@@ -141,6 +172,7 @@ export function SettingsPage(props: SettingsPageProps) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Prefix</th>
|
<th>Prefix</th>
|
||||||
|
<th>Scopes</th>
|
||||||
<th>Last used</th>
|
<th>Last used</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th style="width:1%" />
|
<th style="width:1%" />
|
||||||
@@ -154,6 +186,13 @@ export function SettingsPage(props: SettingsPageProps) {
|
|||||||
<td>
|
<td>
|
||||||
<code>{key.prefix}…</code>
|
<code>{key.prefix}…</code>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<For each={key.scopes && key.scopes.length > 0 ? key.scopes : ["admin"]}>
|
||||||
|
{(scope) => <Badge variant="neutral">{scope}</Badge>}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="text-text-3 text-xs">
|
<td class="text-text-3 text-xs">
|
||||||
{key.last_used_at ? props.formatDate(key.last_used_at) : "Never"}
|
{key.last_used_at ? props.formatDate(key.last_used_at) : "Never"}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Development overlay — services that must never ship to production.
|
||||||
|
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
services:
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.27.9
|
||||||
|
ports:
|
||||||
|
- "${MAILPIT_HTTP_PORT}:8025"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8025/ >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
auth:
|
||||||
|
environment:
|
||||||
|
SMTP_HOST: mailpit
|
||||||
|
SMTP_PORT: 1025
|
||||||
|
depends_on:
|
||||||
|
mailpit:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
volumes:
|
||||||
|
- ./infra/nginx/mailpit.conf:/etc/nginx/primora-extra/mailpit.conf:ro
|
||||||
|
depends_on:
|
||||||
|
mailpit:
|
||||||
|
condition: service_started
|
||||||
+35
-15
@@ -14,18 +14,18 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
dragonfly:
|
dragonfly:
|
||||||
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:v2.0.0
|
||||||
command: ["--logtostderr", "--proactor_threads=4"]
|
command: ["--logtostderr", "--proactor_threads=4"]
|
||||||
ports:
|
ports:
|
||||||
- "${DRAGONFLY_PORT:-6379}:6379"
|
- "${DRAGONFLY_PORT:-6379}:6379"
|
||||||
restart: unless-stopped
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli -p 6379 ping | grep -q PONG"]
|
||||||
mailpit:
|
interval: 10s
|
||||||
image: axllent/mailpit:latest
|
timeout: 3s
|
||||||
ports:
|
retries: 10
|
||||||
- "${MAILPIT_HTTP_PORT}:8025"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
@@ -47,6 +47,12 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- backend_storage:/data/storage
|
- backend_storage:/data/storage
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
@@ -61,15 +67,17 @@ services:
|
|||||||
BETTER_AUTH_URL: http://localhost/auth
|
BETTER_AUTH_URL: http://localhost/auth
|
||||||
AUTH_BASE_URL: http://localhost/auth
|
AUTH_BASE_URL: http://localhost/auth
|
||||||
VITE_APP_URL: http://localhost
|
VITE_APP_URL: http://localhost
|
||||||
SMTP_HOST: mailpit
|
|
||||||
SMTP_PORT: 1025
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
dragonfly:
|
dragonfly:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
mailpit:
|
healthcheck:
|
||||||
condition: service_started
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
@@ -88,19 +96,31 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
auth:
|
auth:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.27-alpine
|
image: nginx:1.27-alpine
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
- auth
|
condition: service_started
|
||||||
- frontend
|
auth:
|
||||||
- mailpit
|
condition: service_started
|
||||||
|
frontend:
|
||||||
|
condition: service_started
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_PORT}:80"
|
- "${NGINX_PORT}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost/nginx-health >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+21
-10
@@ -9,6 +9,16 @@ server {
|
|||||||
# startup-time resolution forever (stale upstream → 502).
|
# startup-time resolution forever (stale upstream → 502).
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
||||||
|
# Baseline security headers on every response, API included.
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), usb=()" always;
|
||||||
|
|
||||||
|
location = /nginx-health {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
set $upstream http://backend:8080;
|
set $upstream http://backend:8080;
|
||||||
proxy_pass $upstream;
|
proxy_pass $upstream;
|
||||||
@@ -40,18 +50,19 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /mailpit/ {
|
# Dev-only location blocks (mailpit) are dropped in here by
|
||||||
set $upstream http://mailpit:8025;
|
# docker-compose.dev.yml. A glob with no matches is legal, so production
|
||||||
rewrite ^/mailpit/?(.*)$ /$1 break;
|
# simply includes nothing.
|
||||||
proxy_pass $upstream;
|
include /etc/nginx/primora-extra/*.conf;
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# Any add_header inside a location replaces ALL inherited add_headers —
|
||||||
|
# so the baseline four must be repeated here next to the SPA-only CSP.
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), usb=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
set $upstream http://frontend:80;
|
set $upstream http://frontend:80;
|
||||||
proxy_pass $upstream;
|
proxy_pass $upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Dev-only: mounted into /etc/nginx/primora-extra/ by docker-compose.dev.yml.
|
||||||
|
# Included inside the default server block — do not add a server{} here.
|
||||||
|
location /mailpit/ {
|
||||||
|
set $upstream http://mailpit:8025;
|
||||||
|
rewrite ^/mailpit/?(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ export type { DBRedisRequest } from './models/DBRedisRequest';
|
|||||||
export type { DBRedisResponse } from './models/DBRedisResponse';
|
export type { DBRedisResponse } from './models/DBRedisResponse';
|
||||||
export type { DBSchemaContextResponse } from './models/DBSchemaContextResponse';
|
export type { DBSchemaContextResponse } from './models/DBSchemaContextResponse';
|
||||||
export type { DBTableListResponse } from './models/DBTableListResponse';
|
export type { DBTableListResponse } from './models/DBTableListResponse';
|
||||||
|
export type { DBTransferRequest } from './models/DBTransferRequest';
|
||||||
|
export type { DBTransferResponse } from './models/DBTransferResponse';
|
||||||
export type { DBXStatus } from './models/DBXStatus';
|
export type { DBXStatus } from './models/DBXStatus';
|
||||||
export type { DeployMarker } from './models/DeployMarker';
|
export type { DeployMarker } from './models/DeployMarker';
|
||||||
export type { Document } from './models/Document';
|
export type { Document } from './models/Document';
|
||||||
@@ -168,6 +170,8 @@ export { $DBRedisRequest } from './schemas/$DBRedisRequest';
|
|||||||
export { $DBRedisResponse } from './schemas/$DBRedisResponse';
|
export { $DBRedisResponse } from './schemas/$DBRedisResponse';
|
||||||
export { $DBSchemaContextResponse } from './schemas/$DBSchemaContextResponse';
|
export { $DBSchemaContextResponse } from './schemas/$DBSchemaContextResponse';
|
||||||
export { $DBTableListResponse } from './schemas/$DBTableListResponse';
|
export { $DBTableListResponse } from './schemas/$DBTableListResponse';
|
||||||
|
export { $DBTransferRequest } from './schemas/$DBTransferRequest';
|
||||||
|
export { $DBTransferResponse } from './schemas/$DBTransferResponse';
|
||||||
export { $DBXStatus } from './schemas/$DBXStatus';
|
export { $DBXStatus } from './schemas/$DBXStatus';
|
||||||
export { $DeployMarker } from './schemas/$DeployMarker';
|
export { $DeployMarker } from './schemas/$DeployMarker';
|
||||||
export { $Document } from './schemas/$Document';
|
export { $Document } from './schemas/$Document';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type ApiKey = {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
|
scopes?: Array<'ingest' | 'read' | 'write' | 'admin'>;
|
||||||
last_used_at?: string | null;
|
last_used_at?: string | null;
|
||||||
revoked_at?: string | null;
|
revoked_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,5 +4,9 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export type CreateApiKeyRequest = {
|
export type CreateApiKeyRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Access scopes for the key. admin unlocks everything; write unlocks read + ingest + non-admin mutations; read and ingest unlock only themselves. Omitted means ["admin"] (full access, legacy behaviour).
|
||||||
|
*/
|
||||||
|
scopes?: Array<'ingest' | 'read' | 'write' | 'admin'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -7,5 +7,6 @@ export type CreatedApiKey = {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
scopes?: Array<'ingest' | 'read' | 'write' | 'admin'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type DBTransferRequest = {
|
||||||
|
target_connection_id: string;
|
||||||
|
/**
|
||||||
|
* SELECT run against the source connection.
|
||||||
|
*/
|
||||||
|
query: string;
|
||||||
|
database?: string;
|
||||||
|
/**
|
||||||
|
* Destination table for SQL targets (schema-qualified allowed).
|
||||||
|
*/
|
||||||
|
target_table?: string;
|
||||||
|
/**
|
||||||
|
* Redis targets: SET key template with {column} placeholders, e.g. "user:{id}".
|
||||||
|
*/
|
||||||
|
key_pattern?: string;
|
||||||
|
/**
|
||||||
|
* Redis targets: column whose cell becomes the value. Omitted stores the whole row as JSON.
|
||||||
|
*/
|
||||||
|
value_column?: string;
|
||||||
|
/**
|
||||||
|
* Optional EX TTL applied to each Redis SET.
|
||||||
|
*/
|
||||||
|
ttl_seconds?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type DBTransferResponse = {
|
||||||
|
transferred: number;
|
||||||
|
truncated: boolean;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -9,5 +9,8 @@ export type Project = {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
membership_role?: string | null;
|
membership_role?: string | null;
|
||||||
|
retention_events_days?: number;
|
||||||
|
retention_audit_days?: number;
|
||||||
|
retention_webhook_days?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -6,5 +6,17 @@ export type UpdateProjectRequest = {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
/**
|
||||||
|
* Telemetry event retention; 0 disables the sweep.
|
||||||
|
*/
|
||||||
|
retention_events_days?: number;
|
||||||
|
/**
|
||||||
|
* Audit log retention; 0 disables the sweep.
|
||||||
|
*/
|
||||||
|
retention_audit_days?: number;
|
||||||
|
/**
|
||||||
|
* Webhook delivery retention; 0 disables the sweep.
|
||||||
|
*/
|
||||||
|
retention_webhook_days?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,6 +22,12 @@ export const $ApiKey = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
},
|
||||||
|
scopes: {
|
||||||
|
type: 'array',
|
||||||
|
contains: {
|
||||||
|
type: 'Enum',
|
||||||
|
},
|
||||||
|
},
|
||||||
last_used_at: {
|
last_used_at: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isNullable: true,
|
isNullable: true,
|
||||||
|
|||||||
@@ -8,5 +8,11 @@ export const $CreateApiKeyRequest = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
},
|
||||||
|
scopes: {
|
||||||
|
type: 'array',
|
||||||
|
contains: {
|
||||||
|
type: 'Enum',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -21,5 +21,11 @@ export const $CreatedApiKey = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
},
|
||||||
|
scopes: {
|
||||||
|
type: 'array',
|
||||||
|
contains: {
|
||||||
|
type: 'Enum',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $DBTransferRequest = {
|
||||||
|
properties: {
|
||||||
|
target_connection_id: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'uuid',
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: `SELECT run against the source connection.`,
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
target_table: {
|
||||||
|
type: 'string',
|
||||||
|
description: `Destination table for SQL targets (schema-qualified allowed).`,
|
||||||
|
},
|
||||||
|
key_pattern: {
|
||||||
|
type: 'string',
|
||||||
|
description: `Redis targets: SET key template with {column} placeholders, e.g. "user:{id}".`,
|
||||||
|
},
|
||||||
|
value_column: {
|
||||||
|
type: 'string',
|
||||||
|
description: `Redis targets: column whose cell becomes the value. Omitted stores the whole row as JSON.`,
|
||||||
|
},
|
||||||
|
ttl_seconds: {
|
||||||
|
type: 'number',
|
||||||
|
description: `Optional EX TTL applied to each Redis SET.`,
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
maximum: 1000,
|
||||||
|
minimum: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const $DBTransferResponse = {
|
||||||
|
properties: {
|
||||||
|
transferred: {
|
||||||
|
type: 'number',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
truncated: {
|
||||||
|
type: 'boolean',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -30,5 +30,14 @@ export const $Project = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
isNullable: true,
|
isNullable: true,
|
||||||
},
|
},
|
||||||
|
retention_events_days: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
retention_audit_days: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
retention_webhook_days: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -16,5 +16,20 @@ export const $UpdateProjectRequest = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
isNullable: true,
|
isNullable: true,
|
||||||
},
|
},
|
||||||
|
retention_events_days: {
|
||||||
|
type: 'number',
|
||||||
|
description: `Telemetry event retention; 0 disables the sweep.`,
|
||||||
|
maximum: 3650,
|
||||||
|
},
|
||||||
|
retention_audit_days: {
|
||||||
|
type: 'number',
|
||||||
|
description: `Audit log retention; 0 disables the sweep.`,
|
||||||
|
maximum: 3650,
|
||||||
|
},
|
||||||
|
retention_webhook_days: {
|
||||||
|
type: 'number',
|
||||||
|
description: `Webhook delivery retention; 0 disables the sweep.`,
|
||||||
|
maximum: 3650,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -15,6 +15,8 @@ import type { DBRedisRequest } from '../models/DBRedisRequest';
|
|||||||
import type { DBRedisResponse } from '../models/DBRedisResponse';
|
import type { DBRedisResponse } from '../models/DBRedisResponse';
|
||||||
import type { DBSchemaContextResponse } from '../models/DBSchemaContextResponse';
|
import type { DBSchemaContextResponse } from '../models/DBSchemaContextResponse';
|
||||||
import type { DBTableListResponse } from '../models/DBTableListResponse';
|
import type { DBTableListResponse } from '../models/DBTableListResponse';
|
||||||
|
import type { DBTransferRequest } from '../models/DBTransferRequest';
|
||||||
|
import type { DBTransferResponse } from '../models/DBTransferResponse';
|
||||||
import type { DBXStatus } from '../models/DBXStatus';
|
import type { DBXStatus } from '../models/DBXStatus';
|
||||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
import type { CancelablePromise } from '../core/CancelablePromise';
|
||||||
import { OpenAPI } from '../core/OpenAPI';
|
import { OpenAPI } from '../core/OpenAPI';
|
||||||
@@ -333,4 +335,33 @@ export class DatabasesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Move query results from this connection to another saved connection
|
||||||
|
* @returns DBTransferResponse Transfer result
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static transferDbRows({
|
||||||
|
projectId,
|
||||||
|
connectionId,
|
||||||
|
requestBody,
|
||||||
|
}: {
|
||||||
|
projectId: string,
|
||||||
|
connectionId: string,
|
||||||
|
requestBody: DBTransferRequest,
|
||||||
|
}): CancelablePromise<DBTransferResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/projects/{projectID}/db-connections/{connectionID}/transfer',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
'connectionID': connectionId,
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
mediaType: 'application/json',
|
||||||
|
errors: {
|
||||||
|
404: `Error response`,
|
||||||
|
502: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,4 +28,15 @@ export class HealthService {
|
|||||||
url: '/health/readiness',
|
url: '/health/readiness',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Prometheus text exposition of platform HTTP metrics
|
||||||
|
* @returns string Prometheus metrics
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static getMetrics(): CancelablePromise<string> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/metrics',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Primora backup: pg_dump of the database + archive of the object-storage root.
|
||||||
|
#
|
||||||
|
# Produces backups/primora-YYYYMMDD-HHMMSS.tar.gz containing:
|
||||||
|
# db.dump postgres custom-format dump (all schemas, incl. core + auth)
|
||||||
|
# storage/ contents of the backend_storage volume
|
||||||
|
#
|
||||||
|
# Usage (from the repo root, against the compose stack):
|
||||||
|
# ./scripts/backup.sh
|
||||||
|
#
|
||||||
|
# Optional env:
|
||||||
|
# BACKUP_DIR output directory (default: ./backups)
|
||||||
|
# POSTGRES_CONTAINER compose container name (default: primora-postgres-1)
|
||||||
|
# POSTGRES_USER/DB dump credentials (default: primora/primora)
|
||||||
|
# STORAGE_VOLUME docker volume for objects (default: primora_backend_storage)
|
||||||
|
#
|
||||||
|
# Restore with scripts/restore.sh. Schedule via cron or a systemd timer —
|
||||||
|
# see DEPLOYMENT_GUIDE.md#backups.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
||||||
|
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-primora-postgres-1}"
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-primora}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-primora}"
|
||||||
|
STORAGE_VOLUME="${STORAGE_VOLUME:-primora_backend_storage}"
|
||||||
|
|
||||||
|
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
BACKUP_DIR="$(cd "$BACKUP_DIR" && pwd)"
|
||||||
|
# Workdir lives under BACKUP_DIR — /tmp is not bind-mountable on every
|
||||||
|
# docker setup, and the storage archive is written through a container.
|
||||||
|
work="$(mktemp -d "$BACKUP_DIR/.tmp.XXXXXX")"
|
||||||
|
out="$BACKUP_DIR/primora-$stamp.tar.gz"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
echo "==> dumping database from $POSTGRES_CONTAINER"
|
||||||
|
docker exec "$POSTGRES_CONTAINER" \
|
||||||
|
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc \
|
||||||
|
> "$work/db.dump"
|
||||||
|
[ -s "$work/db.dump" ] || { echo "pg_dump produced an empty file" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "==> archiving storage volume $STORAGE_VOLUME"
|
||||||
|
# Read the volume through a throwaway container — no host mount assumptions.
|
||||||
|
docker run --rm -v "$STORAGE_VOLUME:/data:ro" -v "$work:/backup" \
|
||||||
|
alpine:3 tar -C /data -cf /backup/storage.tar .
|
||||||
|
[ -s "$work/storage.tar" ] || echo "warning: storage volume appears empty" >&2
|
||||||
|
|
||||||
|
tar -C "$work" -czf "$out" db.dump storage.tar
|
||||||
|
echo "==> wrote $out ($(du -h "$out" | cut -f1))"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Primora load sanity — NOT a benchmark. Verifies the ingest path and a hot
|
||||||
|
// read endpoint hold up under modest concurrency without errors.
|
||||||
|
//
|
||||||
|
// k6 run scripts/load-sanity.js \
|
||||||
|
// -e PRIMORA_API_KEY=prm_xxx_xxx \
|
||||||
|
// -e PRIMORA_PROJECT_ID=<uuid> \
|
||||||
|
// -e BASE_URL=http://localhost
|
||||||
|
//
|
||||||
|
// The key needs the ingest + read scopes. Create one in Settings → API keys.
|
||||||
|
|
||||||
|
import http from "k6/http";
|
||||||
|
import { check } from "k6";
|
||||||
|
|
||||||
|
const BASE = __ENV.BASE_URL || "http://localhost";
|
||||||
|
const API_KEY = __ENV.PRIMORA_API_KEY || "";
|
||||||
|
const PROJECT = __ENV.PRIMORA_PROJECT_ID || "";
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
ingest: {
|
||||||
|
exec: "ingest",
|
||||||
|
executor: "constant-vus",
|
||||||
|
vus: 5,
|
||||||
|
duration: "30s",
|
||||||
|
},
|
||||||
|
hot_read: {
|
||||||
|
exec: "hotRead",
|
||||||
|
executor: "constant-vus",
|
||||||
|
vus: 10,
|
||||||
|
duration: "30s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 429s are the rate limiter doing its job (API_KEY_RATE_LIMIT_PER_MINUTE,
|
||||||
|
// default 600/min) — counted as expected via responseCallback below. The
|
||||||
|
// sanity bar is: no real errors, no hangs. Raise the env limit or lower
|
||||||
|
// VUs to exercise unthrottled throughput.
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ["rate<0.01"],
|
||||||
|
http_req_duration: ["p(95)<500"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ingestBody = JSON.stringify({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "metric",
|
||||||
|
severity: "info",
|
||||||
|
component: "load-sanity",
|
||||||
|
message: "k6 sanity metric",
|
||||||
|
payload: { name: "k6.sanity", value: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ingest() {
|
||||||
|
const res = http.post(`${BASE}/api/v1/ingest`, ingestBody, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"X-Primora-Key": API_KEY,
|
||||||
|
},
|
||||||
|
responseCallback: http.expectedStatuses(202, 429),
|
||||||
|
});
|
||||||
|
check(res, { "ingest 202 or throttled": (r) => r.status === 202 || r.status === 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hotRead() {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE}/api/v1/projects/${PROJECT}/events?limit=25`,
|
||||||
|
{
|
||||||
|
headers: { "X-Primora-Key": API_KEY },
|
||||||
|
responseCallback: http.expectedStatuses(200, 429),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
check(res, { "events 200 or throttled": (r) => r.status === 200 || r.status === 429 });
|
||||||
|
}
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Primora restore: rebuilds the database and storage volume from a backup
|
||||||
|
# tarball produced by scripts/backup.sh.
|
||||||
|
#
|
||||||
|
# ./scripts/restore.sh backups/primora-20260101-120000.tar.gz
|
||||||
|
#
|
||||||
|
# DESTRUCTIVE: drops and recreates the target database's objects and
|
||||||
|
# overwrites the storage volume. Stop the stack first:
|
||||||
|
# docker compose stop backend auth
|
||||||
|
# Then restore, then `docker compose up -d`.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ARCHIVE="${1:?usage: restore.sh <backup.tar.gz>}"
|
||||||
|
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-primora-postgres-1}"
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-primora}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-primora}"
|
||||||
|
STORAGE_VOLUME="${STORAGE_VOLUME:-primora_backend_storage}"
|
||||||
|
|
||||||
|
[ -f "$ARCHIVE" ] || { echo "no such archive: $ARCHIVE" >&2; exit 1; }
|
||||||
|
docker exec "$POSTGRES_CONTAINER" true 2>/dev/null \
|
||||||
|
|| { echo "postgres container $POSTGRES_CONTAINER is not running" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "This will overwrite database '$POSTGRES_DB' in $POSTGRES_CONTAINER"
|
||||||
|
echo "and the contents of volume $STORAGE_VOLUME."
|
||||||
|
read -r -p "Continue? [y/N] " confirm
|
||||||
|
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || { echo "aborted"; exit 1; }
|
||||||
|
|
||||||
|
# Workdir under cwd — /tmp is not bind-mountable on every docker setup.
|
||||||
|
work="$(mktemp -d "$(pwd)/.restore-tmp.XXXXXX")"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
tar -xzf "$ARCHIVE" -C "$work"
|
||||||
|
[ -f "$work/db.dump" ] || { echo "archive has no db.dump" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "==> restoring database"
|
||||||
|
cat "$work/db.dump" | docker exec -i "$POSTGRES_CONTAINER" \
|
||||||
|
pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner
|
||||||
|
|
||||||
|
if [ -f "$work/storage.tar" ]; then
|
||||||
|
echo "==> restoring storage volume $STORAGE_VOLUME"
|
||||||
|
docker run --rm -v "$STORAGE_VOLUME:/data" -v "$work:/backup" \
|
||||||
|
alpine:3 sh -c 'rm -rf /data/* /data/..?* /data/.[!.]* 2>/dev/null; tar -C /data -xf /backup/storage.tar'
|
||||||
|
else
|
||||||
|
echo "warning: archive has no storage.tar — skipping volume" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> restore complete; restart the stack with: docker compose up -d"
|
||||||
+7
-3
@@ -26,15 +26,18 @@ if [ ! -f .env ]; then
|
|||||||
echo "🔐 Generating secure secrets..."
|
echo "🔐 Generating secure secrets..."
|
||||||
JWT_SECRET=$(openssl rand -base64 32)
|
JWT_SECRET=$(openssl rand -base64 32)
|
||||||
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
|
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
|
||||||
|
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||||
|
|
||||||
# Update .env with secrets
|
# Update .env with secrets
|
||||||
# Use different sed approach for better compatibility
|
# Use different sed approach for better compatibility
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
sed -i '' "s/JWT_SECRET=change-me-super-long-jwt-secret/JWT_SECRET=$JWT_SECRET/g" .env
|
sed -i '' "s/JWT_SECRET=change-me-super-long-jwt-secret/JWT_SECRET=$JWT_SECRET/g" .env
|
||||||
sed -i '' "s/BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret/BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET/g" .env
|
sed -i '' "s/BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret/BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET/g" .env
|
||||||
|
sed -i '' "s/^PRIMORA_ENCRYPTION_KEY=.*/PRIMORA_ENCRYPTION_KEY=$ENCRYPTION_KEY/" .env
|
||||||
else
|
else
|
||||||
sed -i "s/JWT_SECRET=change-me-super-long-jwt-secret/JWT_SECRET=$JWT_SECRET/g" .env
|
sed -i "s/JWT_SECRET=change-me-super-long-jwt-secret/JWT_SECRET=$JWT_SECRET/g" .env
|
||||||
sed -i "s/BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret/BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET/g" .env
|
sed -i "s/BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret/BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET/g" .env
|
||||||
|
sed -i "s/^PRIMORA_ENCRYPTION_KEY=.*/PRIMORA_ENCRYPTION_KEY=$ENCRYPTION_KEY/" .env
|
||||||
fi
|
fi
|
||||||
echo "✅ .env file created and secured."
|
echo "✅ .env file created and secured."
|
||||||
else
|
else
|
||||||
@@ -55,12 +58,13 @@ else
|
|||||||
sed -i "s/VITE_APP_URL=http:\/\/localhost/VITE_APP_URL=http:\/\/$DOMAIN/g" .env
|
sed -i "s/VITE_APP_URL=http:\/\/localhost/VITE_APP_URL=http:\/\/$DOMAIN/g" .env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start services
|
# Start services — the dev overlay adds mailpit for local email capture.
|
||||||
|
# Production deploys use the base file alone: docker compose up -d
|
||||||
echo "📦 Pulling images and starting services..."
|
echo "📦 Pulling images and starting services..."
|
||||||
if command -v docker-compose &> /dev/null; then
|
if command -v docker-compose &> /dev/null; then
|
||||||
docker-compose up -d
|
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
else
|
else
|
||||||
docker compose up -d
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end smoke test against a running Primora stack.
|
||||||
|
# Expects the compose stack on BASE_URL (nginx :80 by default).
|
||||||
|
#
|
||||||
|
# ./scripts/smoke-e2e.sh
|
||||||
|
# BASE_URL=http://localhost:8080 ./scripts/smoke-e2e.sh
|
||||||
|
#
|
||||||
|
# Covers: health, signup/login, bootstrap, scoped API keys, ingest,
|
||||||
|
# telemetry read, and the Prometheus endpoint. Exits non-zero on failure.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${BASE_URL:-http://localhost}"
|
||||||
|
EMAIL="smoke-$(date +%s)@example.com"
|
||||||
|
PASSWORD="smoke-password-$(date +%s)"
|
||||||
|
JAR="$(mktemp)"
|
||||||
|
trap 'rm -f "$JAR"' EXIT
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
step() { echo "==> $*"; }
|
||||||
|
|
||||||
|
wait_for() {
|
||||||
|
local url="$1" name="$2" tries=60
|
||||||
|
while (( tries-- > 0 )); do
|
||||||
|
if curl -fsS -o /dev/null "$url" 2>/dev/null; then
|
||||||
|
echo " $name ready"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fail "$name did not become ready ($url)"
|
||||||
|
}
|
||||||
|
|
||||||
|
step "waiting for services"
|
||||||
|
wait_for "$BASE/api/v1/health/liveness" "backend liveness"
|
||||||
|
wait_for "$BASE/api/v1/health/readiness" "backend readiness"
|
||||||
|
wait_for "$BASE/auth-health" "auth"
|
||||||
|
|
||||||
|
step "signup $EMAIL"
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -c "$JAR" \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\",\"name\":\"Smoke Test\"}" \
|
||||||
|
"$BASE/auth/sign-up/email")
|
||||||
|
[ "$code" = "200" ] || [ "$code" = "201" ] || fail "sign-up returned $code"
|
||||||
|
|
||||||
|
step "fetch api token"
|
||||||
|
token=$(curl -fsS -b "$JAR" "$BASE/auth/token" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
||||||
|
[ -n "$token" ] || fail "no token in /auth/token response"
|
||||||
|
|
||||||
|
step "bootstrap organization + project"
|
||||||
|
code=$(curl -sS -o /tmp/smoke-bootstrap.json -w '%{http_code}' -H "authorization: Bearer $token" -H 'content-type: application/json' \
|
||||||
|
-d '{"organizationName":"Smoke Org","organizationSlug":"smoke-org","projectName":"Smoke Project","projectSlug":"smoke-project"}' \
|
||||||
|
"$BASE/api/v1/bootstrap")
|
||||||
|
if [ "$code" = "409" ]; then
|
||||||
|
# Instance already bootstrapped — create a fresh org + project for this user.
|
||||||
|
org_id=$(curl -fsS -H "authorization: Bearer $token" -H 'content-type: application/json' \
|
||||||
|
-d "{\"name\":\"Smoke Org $(date +%s)\",\"slug\":\"smoke-$(date +%s)\"}" \
|
||||||
|
"$BASE/api/v1/organizations" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
|
||||||
|
[ -n "$org_id" ] || fail "could not create organization"
|
||||||
|
project_id=$(curl -fsS -H "authorization: Bearer $token" -H 'content-type: application/json' \
|
||||||
|
-d "{\"name\":\"Smoke Project\",\"slug\":\"smoke-$(date +%s)-p\"}" \
|
||||||
|
"$BASE/api/v1/organizations/$org_id/projects" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
|
||||||
|
else
|
||||||
|
[ "$code" = "200" ] || [ "$code" = "201" ] || { cat /tmp/smoke-bootstrap.json >&2; fail "bootstrap returned $code"; }
|
||||||
|
project_id=$(sed -n 's/.*"project_id":"\([^"]*\)".*/\1/p' /tmp/smoke-bootstrap.json)
|
||||||
|
fi
|
||||||
|
[ -n "$project_id" ] || fail "no project_id available"
|
||||||
|
echo " project $project_id"
|
||||||
|
|
||||||
|
step "create ingest+read API key"
|
||||||
|
key_json=$(curl -fsS -H "authorization: Bearer $token" -H 'content-type: application/json' \
|
||||||
|
-d '{"name":"smoke-ingest","scopes":["ingest","read"]}' \
|
||||||
|
"$BASE/api/v1/projects/$project_id/api-keys")
|
||||||
|
api_key=$(echo "$key_json" | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p')
|
||||||
|
[ -n "$api_key" ] || fail "no secret in api key response"
|
||||||
|
echo "$key_json" | grep -q '"ingest"' || fail "scopes missing from api key response"
|
||||||
|
|
||||||
|
step "ingest event via X-Primora-Key"
|
||||||
|
curl -fsS -o /dev/null -H "X-Primora-Key: $api_key" -H 'content-type: application/json' \
|
||||||
|
-d '{"events":[{"type":"metric","severity":"info","component":"smoke","message":"smoke metric","payload":{"name":"smoke.count","value":1}}]}' \
|
||||||
|
"$BASE/api/v1/ingest"
|
||||||
|
|
||||||
|
step "negative: read-only key cannot ingest"
|
||||||
|
ro_json=$(curl -fsS -H "authorization: Bearer $token" -H 'content-type: application/json' \
|
||||||
|
-d '{"name":"smoke-readonly","scopes":["read"]}' \
|
||||||
|
"$BASE/api/v1/projects/$project_id/api-keys")
|
||||||
|
ro_key=$(echo "$ro_json" | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p')
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -H "X-Primora-Key: $ro_key" -H 'content-type: application/json' \
|
||||||
|
-d '{"type":"metric","severity":"info","message":"should be rejected"}' \
|
||||||
|
"$BASE/api/v1/ingest")
|
||||||
|
[ "$code" = "403" ] || fail "read-only key ingest returned $code, expected 403"
|
||||||
|
|
||||||
|
step "read back events"
|
||||||
|
curl -fsS -o /dev/null -H "authorization: Bearer $token" \
|
||||||
|
"$BASE/api/v1/projects/$project_id/events?limit=5"
|
||||||
|
|
||||||
|
step "prometheus metrics"
|
||||||
|
curl -fsS "$BASE/api/v1/metrics" | grep -q 'primora_http_requests_total' \
|
||||||
|
|| fail "metrics endpoint missing primora_http_requests_total"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "smoke: all checks passed"
|
||||||
@@ -20,20 +20,20 @@ FAILED=0
|
|||||||
check_file() {
|
check_file() {
|
||||||
if [ -f "$1" ]; then
|
if [ -f "$1" ]; then
|
||||||
echo -e "${GREEN}✓${NC} $2"
|
echo -e "${GREEN}✓${NC} $2"
|
||||||
((PASSED++))
|
((++PASSED))
|
||||||
else
|
else
|
||||||
echo -e "${RED}✗${NC} $2 (missing: $1)"
|
echo -e "${RED}✗${NC} $2 (missing: $1)"
|
||||||
((FAILED++))
|
((++FAILED))
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
check_dir() {
|
check_dir() {
|
||||||
if [ -d "$1" ]; then
|
if [ -d "$1" ]; then
|
||||||
echo -e "${GREEN}✓${NC} $2"
|
echo -e "${GREEN}✓${NC} $2"
|
||||||
((PASSED++))
|
((++PASSED))
|
||||||
else
|
else
|
||||||
echo -e "${RED}✗${NC} $2 (missing: $1)"
|
echo -e "${RED}✗${NC} $2 (missing: $1)"
|
||||||
((FAILED++))
|
((++FAILED))
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +58,18 @@ echo ""
|
|||||||
echo "📚 Documentation:"
|
echo "📚 Documentation:"
|
||||||
check_file "PRODUCTION_READINESS.md" "Production readiness checklist"
|
check_file "PRODUCTION_READINESS.md" "Production readiness checklist"
|
||||||
check_file "DEPLOYMENT_GUIDE.md" "Deployment guide"
|
check_file "DEPLOYMENT_GUIDE.md" "Deployment guide"
|
||||||
check_file "PRODUCTION_IMPROVEMENTS.md" "Improvements summary"
|
check_file "ROADMAP.md" "Roadmap"
|
||||||
check_file "PRODUCTION_READY_SUMMARY.md" "Production ready summary"
|
echo ""
|
||||||
|
|
||||||
|
echo "🛡️ Phase 6 hardening:"
|
||||||
|
check_file "apps/backend/db/migrations/00006_api_key_scopes.sql" "API key scopes migration"
|
||||||
|
check_file "apps/backend/db/migrations/00007_retention.sql" "Retention migration"
|
||||||
|
check_file "apps/backend/internal/services/retention.go" "Retention sweeper"
|
||||||
|
check_file "docker-compose.dev.yml" "Dev overlay (Mailpit isolation)"
|
||||||
|
check_file "scripts/smoke-e2e.sh" "E2E smoke script"
|
||||||
|
check_file "scripts/backup.sh" "Backup script"
|
||||||
|
check_file "scripts/restore.sh" "Restore script"
|
||||||
|
check_file "scripts/load-sanity.js" "k6 load sanity script"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "🐳 Infrastructure:"
|
echo "🐳 Infrastructure:"
|
||||||
|
|||||||
Reference in new issue
Block a user