20 Commits
Author SHA1 Message Date
Tomáš Dvořák 5af23fbdc8 Merge pull request #6 from Dvorinka/fix/release-upload-files
ci: upload release assets by file, not glob
2026-09-21 14:41:58 +02:00
Tomas DvorakandDevin bac8f08782 ci: upload release assets by file, not glob
Desktop artifacts extract to nested dirs (appimage/, deb/, rpm/);
dist-release/* expanded to directories and gh release create died on
"is a directory" mid-upload. find -type f builds the file list.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 14:36:48 +02:00
Tomáš Dvořák f9cd641164 Merge pull request #5 from Dvorinka/fix/release-artifact-download
ci: exclude .dockerbuild artifacts from release download
2026-09-21 14:25:06 +02:00
Tomas DvorakandDevin c3321b86ae release: align desktop versions, harden tag check
tauri.conf.json and apps/desktop/package.json lagged at 0.5.0, so
v0.6.0 desktop bundles were labeled 0.5.0. Verify job now checks all
three version sources match the tag.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 14:20:02 +02:00
Tomas DvorakandDevin 00bf725ba7 ci: exclude .dockerbuild artifacts from release download
build-push-action uploads <repo>~<branch>~<sha>.dockerbuild metadata
blobs as artifacts. pattern:"*" pulled them into the release job and
extraction failed after 5 retries, killing the v0.6.0 release run.
Match only binaries-*, desktop-*, and npm-tarballs.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 14:06:18 +02:00
Tomáš Dvořák ec91deed46 Merge pull request #4 from Dvorinka/release/v0.6.0
release: v0.6.0
2026-09-21 13:49:49 +02:00
Tomas DvorakandDevin fede14b24f release: v0.6.0
Bump version to 0.6.0 and cut the changelog — phase 7 automation
(scheduled jobs, realtime SSE, automation UI, jobs CLI/MCP), security
hardening, and the new CI image pipeline + install.sh.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 13:45:17 +02:00
Tomáš Dvořák d39fc22594 Merge pull request #3 from Dvorinka/ci/docker-enhancements
ci: Docker image caching, Trivy scans, edge publishing, standalone installer
2026-09-21 13:44:16 +02:00
Tomas DvorakandDevin fc79c57f7e ci: run Trivy via container image, not the action
The trivy-action setup step downloads its binary through the
unauthenticated GitHub API and dies to rate limits on shared runners.
Running aquasec/trivy against the docker socket has no API dependency
and is one line shorter.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 13:35:25 +02:00
Tomas DvorakandDevin fe28902c4a ci: image caching, Trivy scans, edge publishing, standalone installer
- docker-build job is now a per-app matrix with GHA layer cache, a
  container contents smoke check, and a non-blocking Trivy scan.
- New docker-publish job pushes edge + sha tags to GHCR on master.
- Fixed release.yml tagging ghcr.io/<Owner>/... with a mixed-case
  owner — GHCR requires lowercase, so tagged releases would have
  failed at push time.
- compose files: image: keys name local builds after the GHCR repos.
- install.sh: curl|bash installer — fetches compose files, generates
  secrets, pulls prebuilt images (docker pull + up --no-build, since
  compose pull skips buildable services), falls back to a source
  tarball build when packages aren't public.
- README: install section covering the one-liner, clone, and prebuilt
  paths plus common compose commands.

Verified locally: all three images build, full dev-overlay stack
passes scripts/smoke-e2e.sh, and install.sh produces a healthy
stack in a standalone directory.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 13:22:25 +02:00
Tomáš Dvořák 438ee46016 Merge pull request #2 from Dvorinka/feat/phase-7-automation-realtime
CI / Backend Tests (push) Successful in 10m6s
CI / Frontend Tests (push) Failing after 4m54s
CI / Build All Services (push) Successful in 19m38s
CI / Docker Build Test (push) Successful in 1m45s
CI / Migration Up/Down (push) Failing after 5m46s
CI / Compose E2E Smoke (push) Failing after 3s
CI / Lint (push) Successful in 30m8s
feat: phase 7 automation & realtime — scheduled jobs, domain events, SSE
2026-09-18 15:37:34 +02:00
Tomas DvorakandDevin 1494bedbd5 lint: satisfy errcheck + ineffassign — masked while the linter couldn't run
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 15:23:49 +02:00
Tomas DvorakandDevin a215da452a ci: build golangci-lint with repo toolchain — prebuilt v1 binary can't parse go 1.26
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 15:19:35 +02:00
Tomas DvorakandDevin 9e9a3d5f98 feat: phase 7 automation & realtime — scheduled jobs, domain events, SSE
Scheduled jobs (migration 00009): cron expressions + @every descriptors,
in-process scheduler (15s scan, missed-run collapse), signed HTTP POST
delivery (X-Primora-Signature), AES-256-GCM sealed secrets returned once,
run history with status/duration/http-code, manual execution, retention
pruning. Viewer reads, admin/developer writes.

Domain events: publishEvent fans document/object/issue/deploy/job
mutations to realtime subscribers and matching webhooks through one
path. Webhook event filter extended to job.run, document.*, object.*.

Realtime: GET /projects/:id/realtime/stream SSE with 25s keepalives and
?token=/?api_key= auth for EventSource clients.

Automation dashboard page: Schedules tab (CRUD, run history, manual run,
secret-once banner) + Live events tab (live badge, pause/clear). Demo
mode parity. primora jobs:* CLI group, six primora_*_job* MCP tools,
AutomationService in the generated client.

Fixes folded in: gzip middleware buffered SSE flushes (broke EventSource
on both streams — */stream now bypasses compression); event payloads
double-marshaled json.RawMessage as base64; compose hardcoded localhost
trusted origins so non-default ports broke sign-in; frontend auth base
URL baked an absolute localhost instead of resolving /auth on origin.

Verified live: stack up on :8085, signed delivery HMAC-checked at sink,
@every 30s schedule firing, run history rendering, document.created
landing in the live events feed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 15:15:01 +02:00
Tomáš Dvořák 4b7fdf35ad Merge pull request #1 from Dvorinka/feat/phase-6-production-hardening
CI / Backend Tests (push) Successful in 10m22s
CI / Frontend Tests (push) Failing after 4m53s
CI / Build All Services (push) Successful in 19m40s
CI / Docker Build Test (push) Successful in 1m41s
CI / Migration Up/Down (push) Failing after 5m33s
CI / Compose E2E Smoke (push) Failing after 2s
CI / Lint (push) Failing after 14m38s
feat: phase 6 production hardening + DB-to-DB transfer
2026-09-17 20:39:42 +02:00
Tomas DvorakandDevin fffac52295 fix: address PR #1 security review — drop platform seeds, seal db passwords
- Remove managed connection seeding: platform-postgres/dragonfly carried
  the backend's own DATABASE_URL/DRAGONFLY_URL into tenant projects where
  any member could run arbitrary DBX queries against core tables
  (Codex P1). NewPlatformService loses the seeds param; firstPartyDBSeeds
  deleted.
- Seal db_connections passwords with the existing AES-256-GCM encryptor
  (enc:v1:<b64> marker); decrypted only in dbxArgsFor. Legacy plaintext
  values still read, but migration 00008 strips them (Codex P1).
- Migration 00008: delete is_managed rows, drop plaintext password keys.
- dbxConnectionName uses the full project UUID — the 8-hex prefix could
  collide and alias another project's credentials (Codex P2).
- ci.yml triggers on master, not the nonexistent main/develop branches.
- demo-mode stubs renamed to user-managed connections; CHANGELOG notes
  under Unreleased.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-17 20:39:31 +02:00
Tomas DvorakandDevin 55edbf8b56 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>
2026-09-17 09:15:42 +02:00
Tomas Dvorak 1b34dd43bb docs: phase 6 production-hardening plan + v1.0 bar in roadmap
Authored By: TDvorak <[email protected]>
2026-09-16 20:30:35 +02:00
Tomas Dvorak 61b29754d5 docs: mark phase 1 shipped in roadmap
Authored By: TDvorak <[email protected]>
2026-09-16 20:25:08 +02:00
Tomas Dvorak 88636e2c66 docs: track phase 5 follow-ups in roadmap, drop TODO.md
Authored By: TDvorak <[email protected]>
2026-09-16 20:22:38 +02:00
110 changed files with 5983 additions and 298 deletions

No files matched your search

+9
View File
@@ -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
+157 -18
View File
@@ -2,9 +2,12 @@ name: CI
on: on:
push: push:
branches: [main, develop] branches: [master]
pull_request: pull_request:
branches: [main, develop] branches: [master]
permissions:
contents: read
jobs: jobs:
backend-tests: backend-tests:
@@ -96,38 +99,171 @@ jobs:
run: go build -v ./cmd/server run: go build -v ./cmd/server
docker-build: docker-build:
name: Docker Build Test name: Docker Build (${{ matrix.app }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
app: [backend, auth, frontend]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build backend image - name: Build ${{ matrix.app }} image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: apps/backend/Dockerfile file: apps/${{ matrix.app }}/Dockerfile
push: false push: false
tags: primora-backend:test load: true
tags: primora-${{ matrix.app }}:test
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
- name: Build auth image - name: Smoke check ${{ matrix.app }} image contents
uses: docker/build-push-action@v6 run: |
with: case "${{ matrix.app }}" in
context: . backend) docker run --rm --entrypoint sh primora-backend:test -c 'test -x /usr/local/bin/primora-backend && test -x /usr/local/bin/dbx-mcp' ;;
file: apps/auth/Dockerfile auth) docker run --rm --entrypoint sh primora-auth:test -c 'test -f /workspace/apps/auth/dist/index.js' ;;
push: false frontend) docker run --rm --entrypoint sh primora-frontend:test -c 'test -f /usr/share/nginx/html/index.html' ;;
tags: primora-auth:test esac
- name: Build frontend image # Trivy via its container image — the action's binary installer is
# rate-limited by unauthenticated GitHub API calls on shared runners.
- name: Scan ${{ matrix.app }} image
run: >-
docker run --rm
-v /var/run/docker.sock:/var/run/docker.sock
aquasec/trivy:0.65.0
image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 0
--format table
primora-${{ matrix.app }}:test
docker-publish:
name: Publish edge images
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: [docker-build, e2e-smoke]
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
strategy:
fail-fast: false
matrix:
app: [backend, auth, frontend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
- name: Compute lowercase GHCR owner
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push edge + sha tags
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: apps/frontend/Dockerfile file: apps/${{ matrix.app }}/Dockerfile
push: false push: true
tags: primora-frontend:test tags: |
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:edge
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:sha-${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
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: Validate compose files
run: >-
docker compose
-f docker-compose.yml -f docker-compose.dev.yml
config -q
- 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
@@ -156,3 +292,6 @@ jobs:
with: with:
version: latest version: latest
working-directory: apps/backend working-directory: apps/backend
# Prebuilt binaries lag the repo's Go version — build the linter
# with the toolchain above so it understands go 1.26.
install-mode: goinstall
+30 -7
View File
@@ -19,11 +19,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Check tag matches package version - name: Check tag matches package versions
run: | run: |
PKG=$(node -p "require('./package.json').version") PKG=$(node -p "require('./package.json').version")
if [ "v$PKG" != "$VERSION" ]; then DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version")
echo "tag $VERSION does not match package.json version $PKG" TAURI=$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version")
if [ "v$PKG" != "$VERSION" ] || [ "$DESKTOP_PKG" != "$PKG" ] || [ "$TAURI" != "$PKG" ]; then
echo "tag $VERSION mismatch: root=$PKG desktop=$DESKTOP_PKG tauri=$TAURI"
exit 1 exit 1
fi fi
@@ -37,7 +39,11 @@ 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
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
- name: Compute lowercase GHCR owner
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
@@ -48,10 +54,11 @@ 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/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ env.VERSION }}
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:latest ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest
cache-from: type=gha,scope=${{ matrix.app }} cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }} cache-to: type=gha,mode=max,scope=${{ matrix.app }}
@@ -148,11 +155,22 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# build-push-action uploads "*.dockerbuild" metadata artifacts that
# download-artifact cannot extract — match only the real artifacts.
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
pattern: "*" pattern: "binaries-*"
merge-multiple: true merge-multiple: true
path: dist-release path: dist-release
- uses: actions/download-artifact@v4
with:
pattern: "desktop-*"
merge-multiple: true
path: dist-release
- uses: actions/download-artifact@v4
with:
name: npm-tarballs
path: dist-release
- name: Extract changelog section - name: Extract changelog section
run: | run: |
awk -v ver="${VERSION#v}" ' awk -v ver="${VERSION#v}" '
@@ -164,7 +182,12 @@ jobs:
- name: Create release - name: Create release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "$VERSION" --title "$VERSION" --notes-file notes.md dist-release/* # Desktop artifacts extract to nested dirs (appimage/, deb/, ...) —
# a bare glob hands gh a directory and the upload dies mid-flight.
run: |
mapfile -t files < <(find dist-release -type f)
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
npm-publish: npm-publish:
name: Publish to npm name: Publish to npm
+2
View File
@@ -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
+28 -1
View File
@@ -2,7 +2,34 @@
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
## [Unreleased] ## [0.6.0] - 2026-09-21
### Added
- **Scheduled jobs** — `core.scheduled_jobs` + `core.scheduled_job_runs` (migration 00009). Cron expressions and descriptors (`@hourly`, `@daily`, `@every 30m`, `*/15 * * * *`) parsed via robfig/cron; an in-process `JobScheduler` scans every 15 s, queues due jobs through a buffered channel, and collapses missed schedules into a single run. Endpoints under `/projects/:id/jobs`: list/create/update/delete, `GET …/jobs/:id/runs`, `POST …/jobs/:id/run` (manual execution). Jobs deliver a JSON envelope (event type, project/job/run IDs, trigger source, timestamp, static payload) via POST with `X-Primora-Signature: sha256=<hmac-sha256(body)>`, `X-Primora-Event`, `X-Primora-Job`, `X-Primora-Run` headers and a 10 s timeout. Secrets are AES-256-GCM sealed at rest; a generated secret is returned exactly once on create/rotate (`has_secret` thereafter). Run records carry status, trigger, HTTP status, duration, error text, and timestamps; history is pruned by retention. Reads require viewer+, writes admin/developer.
- **Domain events + realtime stream** — `PlatformService.publishEvent` fans project mutations out to realtime subscribers and matching webhooks through one path. New webhook event types: `job.run`, `document.created`/`updated`/`deleted`, `object.created`/`updated`/`deleted`. `GET …/projects/:projectID/realtime/stream` serves an SSE feed with 25 s keepalives and `?token=`/`?api_key=` query auth for `EventSource` clients.
- **Automation page** — SolidJS view with a Schedules tab (job table, create modal with schedule helper text, expandable run history, manual Run, enable/disable, delete, secret-shown-once banner) and a Live events tab (live/offline badge, pause, clear, auto-reconnect). Sidebar + command palette entries; demo-mode seeds and stubs included.
- **`primora jobs:*` CLI group** — `jobs:list`, `jobs:create <name> --schedule --url [--payload --secret --disabled]`, `jobs:run <job>`, `jobs:runs <job> --limit`, `jobs:rm <job> --yes`. Both `jobs list` and `jobs:list` spellings.
- **New `primora_*` MCP tools** — `primora_list_jobs`, `primora_create_job`, `primora_update_job`, `primora_delete_job`, `primora_list_job_runs`, `primora_run_job`. Webhook event schema extended to all new event types.
### Fixed
- **SSE broken by gzip middleware** — `Compression()` wrapped every `Accept-Encoding: gzip` response, and the gzip buffer swallowed each `Flush()`, so `EventSource` received headers then silence and closed. Paths ending in `/stream` now bypass compression; this repaired the pre-existing telemetry stream too.
- **Event payloads serialized as base64** — `document`/`object` fields inside domain-event envelopes were `json.RawMessage` placed into `map[string]any`, which marshals as base64. They're now wrapped so the raw document embeds in the event JSON.
- **Auth trusted origins ignored the configured public URL** — `docker-compose.yml` hardcoded `http://localhost` for `BETTER_AUTH_URL`, `AUTH_BASE_URL`, and `VITE_APP_URL`, so any non-default `NGINX_PORT` or domain broke sign-in with a 403. All three now read `.env` with localhost defaults.
- **Frontend auth base URL on non-default ports** — the bundled default baked `http://localhost`; `auth-client` now resolves `/auth` against `window.location.origin` (absolute, as better-auth requires) when `VITE_AUTH_BASE_URL` is unset.
### Security
- **Managed platform connections removed** — `platform-postgres`/`platform-dragonfly` seeds carried the backend's own `DATABASE_URL`/`DRAGONFLY_URL` into every project, where any member could run arbitrary queries through DBX against `core` tables. The seeds are gone and migration 00008 deletes existing managed rows. Operators keep direct `psql`/`redis-cli` access.
- **Connection passwords encrypted at rest** — `core.db_connections.config.password` is now AES-256-GCM sealed (`enc:v1:` marker) via `PRIMORA_ENCRYPTION_KEY`; decrypted only when registering the connection with DBX. Migration 00008 strips pre-existing plaintext passwords.
- **DBX naming** — connection names now prefix the full project UUID; the previous 8-hex prefix could collide across projects and alias the wrong credentials.
### Infrastructure
- **One-line installer** — `install.sh` (`curl | bash`) fetches the compose files, generates secrets, pulls prebuilt GHCR images, and falls back to a source-tarball build when packages aren't published.
- **CI image pipeline** — per-app buildx matrix with GHA layer cache, container-contents smoke checks, Trivy scans, `docker compose config` validation, and `edge`/`sha-*` GHCR publishing on master.
- **Fixed release image pushes** — tags were rendered with the mixed-case owner (`ghcr.io/Dvorinka/...`), which GHCR rejects; owner is now lowercased, unblocking tagged releases.
## [0.5.0] - 2026-09-16 ## [0.5.0] - 2026-09-16
+151
View File
@@ -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.
+85
View File
@@ -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
```
+40 -3
View File
@@ -21,11 +21,30 @@ Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
- **Generated client** — the TypeScript client is generated from `apps/backend/openapi/openapi.yaml`, so the API contract is the source of truth. - **Generated client** — the TypeScript client is generated from `apps/backend/openapi/openapi.yaml`, so the API contract is the source of truth.
- **Demo mode** — a fully client-side workspace (`?demo=true` or `VITE_DEMO_MODE=true`) for trying the UI without a backend. - **Demo mode** — a fully client-side workspace (`?demo=true` or `VITE_DEMO_MODE=true`) for trying the UI without a backend.
## Quick start (Docker) ## Install
Prerequisites: Docker with the Compose plugin. Prerequisites: Docker with the Compose plugin.
### One-liner (no clone)
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
```
Installs into `./primora`, generates secrets, and starts the stack on port 80.
Non-interactive — configure with env vars:
```bash
DOMAIN=example.com NGINX_PORT=8080 PRIMORA_DIR=/opt/primora bash install.sh
```
The installer pulls prebuilt images from GHCR when they are published;
otherwise it downloads the source tarball and builds locally.
### From a clone
```bash
git clone https://github.com/Dvorinka/Primora.git && cd Primora
./scripts/setup.sh # creates .env, generates secrets, starts the stack ./scripts/setup.sh # creates .env, generates secrets, starts the stack
``` ```
@@ -33,13 +52,31 @@ or manually:
```bash ```bash
cp .env.example .env # fill JWT_SECRET and BETTER_AUTH_SECRET cp .env.example .env # fill JWT_SECRET and BETTER_AUTH_SECRET
docker compose up -d --build docker compose up -d # builds the three app images from source
``` ```
Then open `http://localhost`. Mailpit is at `http://localhost/mailpit/` for local email. To run published images instead of building:
```bash
docker pull ghcr.io/dvorinka/primora-{backend,auth,frontend}:latest
docker compose up -d --no-build
```
Then open `http://localhost`. For local email capture, add the dev overlay
(`-f docker-compose.dev.yml`) and Mailpit is at `http://localhost/mailpit/`.
If ports `5432` or `6379` are already in use on your machine, set `POSTGRES_PORT` and `DRAGONFLY_PORT` in `.env` — only the host bindings change. If ports `5432` or `6379` are already in use on your machine, set `POSTGRES_PORT` and `DRAGONFLY_PORT` in `.env` — only the host bindings change.
### Useful commands
```bash
docker compose ps # service status + health
docker compose logs -f backend # follow one service
docker compose pull && docker compose up -d # update to latest release
docker compose down # stop (data persists in volumes)
docker compose down -v # stop and delete all data
```
## Verification ## Verification
| Check | Endpoint | | Check | Endpoint |
+182 -10
View File
@@ -17,7 +17,7 @@ Design docs: [project_backend.md](project_backend.md) · [project_frontend.md](p
- Docker Compose stack, nginx proxy, OpenAPI-generated client - Docker Compose stack, nginx proxy, OpenAPI-generated client
- MIT license, CONTRIBUTING, SECURITY, rewritten README/QUICK_START - MIT license, CONTRIBUTING, SECURITY, rewritten README/QUICK_START
## Phase 1 — Surface area & release plumbing ## Phase 1 — Surface area & release plumbing ✅
Everything an operator touches that isn't in the box yet. Everything an operator touches that isn't in the box yet.
@@ -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) ✅
@@ -111,18 +112,189 @@ IMS (`~/Desktop/PROG+HTML/IMS`) is discontinued and folded in — same stack
in-app install banner on `beforeinstallprompt`. Native shells via Capacitor in-app install banner on `beforeinstallprompt`. Native shells via Capacitor
remain deferred until push notifications are a real requirement. remain deferred until push notifications are a real requirement.
- ~~**Versioning**~~ — `v0.x` minor-per-phase until the API surface stabilizes. - ~~**Versioning**~~ — `v0.x` minor-per-phase until the API surface stabilizes.
Release pipeline (Phase 1) now also ships desktop bundles: tag → GitHub `v0.5.0` tagged and pushed; the release pipeline now also ships desktop
Release notes + ghcr.io images + CLI/MCP binaries + desktop installers. bundles: tag → GitHub Release notes + ghcr.io images + CLI/MCP binaries +
desktop installers.
- **Docs site** — deferred on purpose. README stays canonical; the OpenAPI - **Docs site** — deferred on purpose. README stays canonical; the OpenAPI
contract is the machine-readable source. The API still changes every phase — contract is the machine-readable source. The API still changes every phase —
a docs site now would document a moving target. Revisit when `v1.0.0` lands. a docs site now would document a moving target. Revisit when `v1.0.0` lands.
Follow-ups:
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
(host-verified on Linux only).
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when
distribution matures.
## Phase 6 — Production hardening (v0.6.0) ✅
The v1 feature surface is essentially complete; what remains is the
difference between "works" and "safe to run other people's data". Roughly
three-quarters of the distance is travelled — this phase is the rest.
Foundations already in place: liveness/readiness probes with dependency
checks, per-key and per-user rate limiting, CORS locked to `PUBLIC_URL`,
AES-256-GCM credential encryption (`PRIMORA_ENCRYPTION_KEY` enforced in
production), audit log on mutations, org/project roles, SMTP + Resend in
the auth service, migrations on boot, single-command compose deploy,
release pipeline with images + binaries + desktop bundles.
### Security
- ~~**Graceful shutdown**~~ ✅ — `http.Server` + `signal.NotifyContext`,
10 s drain; verified via SIGTERM on the running container.
- ~~**Security headers**~~ ✅ — `X-Frame-Options`, `X-Content-Type-Options`,
`Referrer-Policy`, `Permissions-Policy` on every response; CSP scoped to
the SPA location only (verified live).
- ~~**TLS story**~~ ✅ — documented Caddy/Traefik front-proxy setups in
DEPLOYMENT_GUIDE.md; no embedded cert manager.
- ~~**Scoped API keys**~~ ✅ — `scopes` column (migration 00006), enforced
in auth middleware (`requiredAPIKeyScope`), UI checkboxes in Settings;
keys without stored scopes behave as `admin` (legacy). Verified live: a
`read`-only key gets 403 on ingest.
- ~~**Dev-surface isolation**~~ ✅ — Mailpit moved to
`docker-compose.dev.yml`; the base compose file cannot expose it.
- ~~**Auth brute-force**~~ ✅ — Better Auth's built-in rate limiting is now
enabled unconditionally (it previously followed `NODE_ENV`, which the
shipped `.env` sets to development); the IP-level 60 req/min limiter on
`/auth/*` stays in front.
- ~~**Setup ergonomics**~~ ✅ — `setup.sh` generates
`PRIMORA_ENCRYPTION_KEY` via `openssl rand -hex 32` alongside the other
secrets.
### Reliability & operations
- ~~**Backups + restore runbook**~~ ✅ — `scripts/backup.sh` (pg_dump -Fc +
storage volume tar) and `scripts/restore.sh`; restore verified against a
scratch postgres + volume. Cron/systemd examples in the docs.
- ~~**Retention**~~ ✅ — per-project `retention_{events,audit,webhook}_days`
(migration 00007, 0 = disabled) + hourly `RetentionSweeper`; settings
exposed in the project edit dialog.
- ~~**Platform metrics export**~~ ✅ — `GET /api/v1/metrics` renders
Prometheus text exposition (requests, errors, active, duration sum).
- ~~**Production compose profile**~~ ✅ — healthchecks + `restart:
unless-stopped` on every service, `depends_on: service_healthy`,
Dragonfly pinned to `v2.0.0`.
- ~~**Upgrade runbook**~~ ✅ — DEPLOYMENT_GUIDE.md documents backup → pull →
migrate-on-boot → verify, plus rollback via `goose down`.
### Correctness
- ~~**Pagination audit**~~ ✅ — every `:many` query audited; time-series and
entity lists are naturally bounded; `ListComponents` capped at 500
(auto-registers from ingest).
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
dragonfly transfer, key templating, TTL).
- ~~**External object storage**~~ ✅ — constraint documented in
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred.
### Testing
- ~~**E2E smoke in CI**~~ ✅ — `scripts/smoke-e2e.sh` + `e2e-smoke` job:
health, signup, token, bootstrap/org+project, scoped key create, ingest,
read-only key rejection, metrics. Verified locally against the stack.
- ~~**Migration tests**~~ ✅ — `migrations` job runs goose up → reset → up
against scratch postgres; verified locally.
- ~~**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
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
QEMU + buildx.
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
verification, signing + auto-updater.
- Optional: SBOM + provenance attestation on release images.
### Docs
- ~~**Self-host guide**~~ ✅ — `DEPLOYMENT_GUIDE.md` (TLS, SMTP, backups,
upgrades, env reference, storage constraint) and
`PRODUCTION_READINESS.md` written; `verify-production-ready.sh` updated
to check them plus the Phase 6 artifacts.
**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
upgrade. Docs site revisited here, not before.
## Phase 7 — Automation & realtime (v0.7.0) ✅
The last Supabase/Appwrite-parity gaps: scheduled work and a live view of
what the platform is doing. One event fan-out drives webhooks and the
dashboard's live feed off the same stream.
- **Scheduled jobs** — `core.scheduled_jobs` + `core.scheduled_job_runs`
(migration 00009). Cron expressions and descriptors (`@hourly`,
`@daily`, `@every 30m`, `*/15 * * * *`) via robfig/cron. In-process
`JobScheduler`: 15 s due-scan, buffered execution queue, missed
schedules collapse into one run, run history pruned by retention.
- **Signed delivery** — jobs POST a JSON envelope (event, project/job/run
IDs, trigger, timestamp, static payload) with
`X-Primora-Signature: sha256=<hmac-sha256(body)>`, 10 s timeout. Secrets
are AES-256-GCM sealed at rest and the generated value is returned
exactly once (`has_secret` thereafter).
- **Run history** — status (running/success/failed), trigger source,
HTTP status, duration, error text, timestamps; `POST …/jobs/:id/run`
for manual execution. Role-gated: viewers read, admin/developer write.
- **Domain events** — `publishEvent` fans document/object/issue/deploy/
job mutations out to realtime subscribers and matching webhooks.
Webhook event filter extended to `job.run`, `document.*`, `object.*`.
- **Realtime stream** — `GET …/projects/:projectID/realtime/stream` SSE,
25 s keepalives, `?token=`/`?api_key=` query auth for `EventSource`.
- **Automation page** — Schedules tab (table, create modal, run history
expansion, manual run, enable/disable, delete, secret-shown-once) and
Live events tab (live badge, pause, clear, reconnect). Demo-mode seeds
and stubs included; command palette entry added.
- **Surfaces** — `primora jobs:*` CLI group (list/create/run/runs/rm),
six new `primora_*_job*` MCP tools, `AutomationService` in the
generated client.
### Fixes folded in
- **gzip swallowed SSE** — the compression middleware buffered every
flush, so `EventSource` saw headers then nothing and died. `*/stream`
paths now bypass compression (telemetry stream was equally broken).
- **Trusted origins ignored `PUBLIC_URL`** — compose hardcoded
`http://localhost` for `BETTER_AUTH_URL`/`AUTH_BASE_URL`/`VITE_APP_URL`;
they now read env so a non-default port/domain actually works.
- **Relative auth base URL** — better-auth rejects relative URLs;
`auth-client` now resolves `/auth` against `window.location.origin`,
preserving same-origin defaults without breaking split-origin dev.
## Future phases — candidates
Ordered loosely by leverage. None committed; each gets scoped when picked.
- **Phase 8 — Client realtime SDK** — `@primora/client` channel
subscriptions (`channel('documents').on('created', …)`), presence,
and broadcast over the Phase 7 stream. Supabase-realtime parity is a
protocol question, not a transport one.
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers.
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin.
The job runner already supplies the scheduling half.
- **Phase 10 — Storage backends** — S3-compatible object storage behind
the existing bucket abstraction (deferred from Phase 6), plus
presigned-URL uploads for large files.
- **Phase 11 — Generated REST per collection** — PostgREST-style
auto-CRUD + filtering over Collections documents; a clean read layer
on data that already exists.
- **Notifications** — alert rules on telemetry (error-rate thresholds,
heartbeat silence) fanning out through the webhook dispatcher.
- **Inbound webhooks** — project-scoped ingest endpoints that turn
external events into domain events/jobs.
- **HA mode** — leader-elected scheduler + distributed locks so two
backend replicas don't double-fire jobs; Postgres advisory locks are
enough at this scale.
- **Email surface** — transactional templates + send log in the
dashboard, backed by the auth service's existing SMTP/Resend wiring.
--- ---
## What is deliberately out of scope ## What is deliberately out of scope
- Managed/hosted tier — the product is the self-hosted thing. - Managed/hosted tier — the product is the self-hosted thing.
- Realtime subscriptions, edge functions, generated REST/GraphQL per-table - Per-request compute billing / hosted function runtime — if the
(Supabase PostgREST-style). Revisit after Phase 3 if wanted — Collections Functions phase lands it stays user-managed infrastructure.
already cover the common case.
- Embedding DBX's desktop UI — we use its MCP server as a library, not its app. - Embedding DBX's desktop UI — we use its MCP server as a library, not its app.
-28
View File
@@ -1,28 +0,0 @@
# Primora — Working list
## Phase 5 — Distribution (shipped, `deb56d9`)
- [x] PWA: vite-plugin-pwa + manifest + icons + install banner; SW precaches
the shell, `/api` `/auth` `/mailpit` stay NetworkOnly
- [x] `apps/desktop`: Tauri 2 thin shell — connect screen, tray
(Show / Change Server / Quit), close-to-tray, single-instance
- [x] `release.yml`: desktop bundle matrix (linux/macOS/windows) attached to
GitHub Releases
- [x] Workspace bumped to `0.5.0`, lockfile synced
- [x] CHANGELOG + ROADMAP + README updated
- [x] Gates: backend tests, frontend typecheck, workspace build, cargo check,
`check:generated` — all green
- [x] Smoke: desktop shell rendered the compose deployment's dashboard
(tauri-driver + WebKitWebDriver)
## Up next / future
- [x] Tag `v0.5.0` + push — release workflow fires on tag push
- [ ] Verify macOS/Windows desktop bundles from CI artifacts once the tag run
lands (host-verified on Linux only)
- [ ] Capacitor native shells — only if push notifications become a real
requirement
- [ ] Docs site / `docs/` folder — once the API surface stops moving per-phase
(revisit at `v1.0.0`)
- [ ] Desktop bundle signing + auto-updater — unsigned artifacts today; add
when distribution matures
+6
View File
@@ -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;
@@ -0,0 +1,11 @@
-- +goose Up
-- Managed seeds stored the platform's own DATABASE_URL/DRAGONFLY_URL in
-- tenant-visible rows; the feature is removed, so drop the leaked rows.
DELETE FROM core.db_connections WHERE is_managed;
-- Passwords are AES-GCM sealed app-side from now on; strip any plaintext
-- password left in existing configs (users re-enter on next edit).
UPDATE core.db_connections SET config = config - 'password' WHERE config ? 'password';
-- +goose Down
-- Deleted managed rows and stripped passwords are not recoverable.
SELECT 1;
@@ -0,0 +1,39 @@
-- +goose Up
CREATE TABLE core.scheduled_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
schedule TEXT NOT NULL,
url TEXT NOT NULL,
secret BYTEA NOT NULL DEFAULT ''::bytea,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
last_status TEXT NOT NULL DEFAULT '' CHECK (last_status IN ('', 'success', 'failed')),
next_run_at TIMESTAMPTZ,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
CREATE INDEX idx_scheduled_jobs_due ON core.scheduled_jobs(next_run_at)
WHERE enabled AND next_run_at IS NOT NULL;
CREATE TABLE core.scheduled_job_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'success', 'failed')),
triggered_by TEXT NOT NULL DEFAULT 'schedule' CHECK (triggered_by IN ('schedule', 'manual')),
status_code INT,
error TEXT NOT NULL DEFAULT '',
duration_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
CREATE INDEX idx_scheduled_job_runs_job ON core.scheduled_job_runs(job_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS core.scheduled_job_runs;
DROP TABLE IF EXISTS core.scheduled_jobs;
+2 -1
View File
@@ -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
+88
View File
@@ -0,0 +1,88 @@
-- name: ListScheduledJobs :many
SELECT * FROM core.scheduled_jobs
WHERE project_id = $1
ORDER BY created_at ASC;
-- name: GetScheduledJobByID :one
SELECT * FROM core.scheduled_jobs
WHERE id = $1;
-- name: CreateScheduledJob :one
INSERT INTO core.scheduled_jobs (
project_id,
name,
schedule,
url,
secret,
payload,
enabled,
next_run_at,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- name: UpdateScheduledJob :one
UPDATE core.scheduled_jobs
SET name = $3,
schedule = $4,
url = $5,
enabled = $6,
next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
updated_at = NOW()
WHERE id = $1
AND project_id = $2
RETURNING *;
-- name: DeleteScheduledJob :one
DELETE FROM core.scheduled_jobs
WHERE id = $1
AND project_id = $2
RETURNING id;
-- name: ListDueScheduledJobs :many
SELECT * FROM core.scheduled_jobs
WHERE enabled
AND next_run_at IS NOT NULL
AND next_run_at <= $1;
-- name: MarkScheduledJobRan :one
UPDATE core.scheduled_jobs
SET last_run_at = $2,
last_status = $3,
next_run_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: InsertScheduledJobRun :one
INSERT INTO core.scheduled_job_runs (job_id, triggered_by)
VALUES ($1, $2)
RETURNING *;
-- name: FinishScheduledJobRun :one
UPDATE core.scheduled_job_runs
SET status = $2,
status_code = $3,
error = $4,
duration_ms = $5,
finished_at = NOW()
WHERE id = $1
RETURNING *;
-- name: ListScheduledJobRuns :many
SELECT * FROM core.scheduled_job_runs
WHERE job_id = $1
ORDER BY created_at DESC
LIMIT $2;
-- name: PruneScheduledJobRuns :exec
DELETE FROM core.scheduled_job_runs r
WHERE r.job_id = $1
AND r.id NOT IN (
SELECT k.id FROM core.scheduled_job_runs k
WHERE k.job_id = $1
ORDER BY k.created_at DESC
LIMIT $2
);
+4 -1
View File
@@ -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 *;
+33
View File
@@ -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);
+2 -1
View File
@@ -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
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/pressly/goose/v3 v3.26.0 github.com/pressly/goose/v3 v3.26.0
github.com/redis/go-redis/v9 v9.16.0 github.com/redis/go-redis/v9 v9.16.0
github.com/resend/resend-go/v2 v2.15.0 github.com/resend/resend-go/v2 v2.15.0
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )
+2
View File
@@ -104,6 +104,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+48 -41
View File
@@ -2,12 +2,13 @@ package app
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/url" "net/http"
"os" "os"
"strconv" "os/signal"
"strings" "syscall"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -37,6 +38,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) {
@@ -88,7 +91,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("encryption key: %w", err) return nil, fmt.Errorf("encryption key: %w", err)
} }
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, firstPartyDBSeeds(cfg), encryptor, logger) platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
if cfg.Env == "production" { if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
@@ -157,55 +160,59 @@ 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,
Logger: logger, Logger: logger,
DB: dbPool, DB: dbPool,
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
a.Logger.Info("primora backend starting", "address", address) srv := &http.Server{Addr: address, Handler: a.Router}
return a.Router.Run(address)
}
// firstPartyDBSeeds turns the platform's own Postgres and Dragonfly URLs into ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
// managed connection seeds registered on every project. defer stop()
func firstPartyDBSeeds(cfg config.Config) []services.ManagedDBSeed {
var seeds []services.ManagedDBSeed errCh := make(chan error, 1)
if u, err := url.Parse(cfg.DatabaseURL); err == nil && u.Hostname() != "" { go func() {
cfg2 := services.DBConnectionConfig{ a.Logger.Info("primora backend starting", "address", address)
Host: u.Hostname(), errCh <- srv.ListenAndServe()
Database: strings.TrimPrefix(u.Path, "/"), }()
Username: u.User.Username(),
} select {
if p, ok := u.User.Password(); ok { case err := <-errCh:
cfg2.Password = p return err
} case <-ctx.Done():
if port, err := strconv.Atoi(u.Port()); err == nil {
cfg2.Port = &port
}
seeds = append(seeds, services.ManagedDBSeed{Name: "platform-postgres", DBType: "postgres", Config: cfg2})
} }
if u, err := url.Parse(cfg.DragonflyURL); err == nil && u.Hostname() != "" {
cfg2 := services.DBConnectionConfig{Host: u.Hostname()} a.Logger.Info("shutdown signal received, draining in-flight requests")
if p, ok := u.User.Password(); ok { shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
cfg2.Password = p defer cancel()
} if err := srv.Shutdown(shutdownCtx); err != nil {
if port, err := strconv.Atoi(u.Port()); err == nil { return fmt.Errorf("graceful shutdown: %w", err)
cfg2.Port = &port
}
seeds = append(seeds, services.ManagedDBSeed{Name: "platform-dragonfly", DBType: "redis", Config: cfg2})
} }
return seeds 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
} }
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)
@@ -0,0 +1,441 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: jobs.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createScheduledJob = `-- name: CreateScheduledJob :one
INSERT INTO core.scheduled_jobs (
project_id,
name,
schedule,
url,
secret,
payload,
enabled,
next_run_at,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type CreateScheduledJobParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Payload []byte `json:"payload"`
Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, createScheduledJob,
arg.ProjectID,
arg.Name,
arg.Schedule,
arg.Url,
arg.Secret,
arg.Payload,
arg.Enabled,
arg.NextRunAt,
arg.CreatedByUserID,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteScheduledJob = `-- name: DeleteScheduledJob :one
DELETE FROM core.scheduled_jobs
WHERE id = $1
AND project_id = $2
RETURNING id
`
type DeleteScheduledJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, deleteScheduledJob, arg.ID, arg.ProjectID)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const finishScheduledJobRun = `-- name: FinishScheduledJobRun :one
UPDATE core.scheduled_job_runs
SET status = $2,
status_code = $3,
error = $4,
duration_ms = $5,
finished_at = NOW()
WHERE id = $1
RETURNING id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at
`
type FinishScheduledJobRunParams struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
}
func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) {
row := q.db.QueryRow(ctx, finishScheduledJobRun,
arg.ID,
arg.Status,
arg.StatusCode,
arg.Error,
arg.DurationMs,
)
var i CoreScheduledJobRun
err := row.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
)
return i, err
}
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE id = $1
`
func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, getScheduledJobByID, id)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertScheduledJobRun = `-- name: InsertScheduledJobRun :one
INSERT INTO core.scheduled_job_runs (job_id, triggered_by)
VALUES ($1, $2)
RETURNING id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at
`
type InsertScheduledJobRunParams struct {
JobID uuid.UUID `json:"job_id"`
TriggeredBy string `json:"triggered_by"`
}
func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) {
row := q.db.QueryRow(ctx, insertScheduledJobRun, arg.JobID, arg.TriggeredBy)
var i CoreScheduledJobRun
err := row.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
)
return i, err
}
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE enabled
AND next_run_at IS NOT NULL
AND next_run_at <= $1
`
func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) {
rows, err := q.db.Query(ctx, listDueScheduledJobs, nextRunAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJob
for rows.Next() {
var i CoreScheduledJob
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listScheduledJobRuns = `-- name: ListScheduledJobRuns :many
SELECT id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at FROM core.scheduled_job_runs
WHERE job_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListScheduledJobRunsParams struct {
JobID uuid.UUID `json:"job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error) {
rows, err := q.db.Query(ctx, listScheduledJobRuns, arg.JobID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJobRun
for rows.Next() {
var i CoreScheduledJobRun
if err := rows.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listScheduledJobs = `-- name: ListScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE project_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error) {
rows, err := q.db.Query(ctx, listScheduledJobs, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJob
for rows.Next() {
var i CoreScheduledJob
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markScheduledJobRan = `-- name: MarkScheduledJobRan :one
UPDATE core.scheduled_jobs
SET last_run_at = $2,
last_status = $3,
next_run_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type MarkScheduledJobRanParams struct {
ID uuid.UUID `json:"id"`
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
LastStatus string `json:"last_status"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
}
func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobRanParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, markScheduledJobRan,
arg.ID,
arg.LastRunAt,
arg.LastStatus,
arg.NextRunAt,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const pruneScheduledJobRuns = `-- name: PruneScheduledJobRuns :exec
DELETE FROM core.scheduled_job_runs r
WHERE r.job_id = $1
AND r.id NOT IN (
SELECT k.id FROM core.scheduled_job_runs k
WHERE k.job_id = $1
ORDER BY k.created_at DESC
LIMIT $2
)
`
type PruneScheduledJobRunsParams struct {
JobID uuid.UUID `json:"job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error {
_, err := q.db.Exec(ctx, pruneScheduledJobRuns, arg.JobID, arg.Limit)
return err
}
const updateScheduledJob = `-- name: UpdateScheduledJob :one
UPDATE core.scheduled_jobs
SET name = $3,
schedule = $4,
url = $5,
enabled = $6,
next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
updated_at = NOW()
WHERE id = $1
AND project_id = $2
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type UpdateScheduledJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
Column8 bool `json:"column_8"`
Secret []byte `json:"secret"`
Column10 bool `json:"column_10"`
Payload []byte `json:"payload"`
}
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, updateScheduledJob,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Schedule,
arg.Url,
arg.Enabled,
arg.NextRunAt,
arg.Column8,
arg.Secret,
arg.Column10,
arg.Payload,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+39 -6
View File
@@ -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 {
@@ -273,12 +274,15 @@ type CoreOrganizationMember struct {
} }
type CoreProject struct { type CoreProject struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"` Slug string `json:"slug"`
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 {
@@ -303,6 +307,35 @@ type CoreProjectMember struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type CoreScheduledJob struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Payload []byte `json:"payload"`
Enabled bool `json:"enabled"`
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
LastStatus string `json:"last_status"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreScheduledJobRun struct {
ID uuid.UUID `json:"id"`
JobID uuid.UUID `json:"job_id"`
Status string `json:"status"`
TriggeredBy string `json:"triggered_by"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
}
type CoreUser struct { type CoreUser struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
AuthSubject string `json:"auth_subject"` AuthSubject string `json:"auth_subject"`
@@ -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
@@ -350,13 +359,16 @@ type ListProjectsForOrganizationParams struct {
} }
type ListProjectsForOrganizationRow struct { type ListProjectsForOrganizationRow struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"` Slug string `json:"slug"`
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"`
MembershipRole NullCoreProjectRole `json:"membership_role"` RetentionEventsDays int32 `json:"retention_events_days"`
RetentionAuditDays int32 `json:"retention_audit_days"`
RetentionWebhookDays int32 `json:"retention_webhook_days"`
MembershipRole NullCoreProjectRole `json:"membership_role"`
} }
func (q *Queries) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error) { func (q *Queries) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error) {
@@ -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,16 +431,22 @@ 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 {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
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
} }
@@ -8,6 +8,7 @@ import (
"context" "context"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
type Querier interface { type Querier interface {
@@ -32,6 +33,7 @@ type Querier interface {
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error) CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
@@ -43,9 +45,11 @@ type Querier interface {
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error) DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error) EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error) EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
@@ -63,12 +67,14 @@ type Querier interface {
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error) GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error) GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error) GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error) GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error) GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error) HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
@@ -79,6 +85,7 @@ type Querier interface {
ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error) ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error)
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
@@ -88,18 +95,31 @@ type Querier interface {
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error) ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error) ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error) ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error) ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error) ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error) MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error)
MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobRanParams) (CoreScheduledJob, error)
MarkWebhookDelivery(ctx context.Context, arg MarkWebhookDeliveryParams) error MarkWebhookDelivery(ctx context.Context, arg MarkWebhookDeliveryParams) error
MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error) MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error)
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error) MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error) MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
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)
@@ -109,6 +129,7 @@ type Querier interface {
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error) UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error) UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error)
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error) UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error) UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error) UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, 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) {
+1 -1
View File
@@ -86,7 +86,7 @@ func (d *Client) reset() {
d.session = nil d.session = nil
} }
if d.cmd != nil && d.cmd.Process != nil { if d.cmd != nil && d.cmd.Process != nil {
d.cmd.Process.Kill() _ = d.cmd.Process.Kill()
} }
d.cmd = nil d.cmd = nil
d.registered = map[string]bool{} d.registered = map[string]bool{}
@@ -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)
+15
View File
@@ -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)
@@ -103,6 +105,13 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.GET("/projects/:projectID/webhooks/:webhookID/deliveries", h.listWebhookDeliveries) api.GET("/projects/:projectID/webhooks/:webhookID/deliveries", h.listWebhookDeliveries)
api.POST("/projects/:projectID/webhooks/:webhookID/test", h.testWebhook) api.POST("/projects/:projectID/webhooks/:webhookID/test", h.testWebhook)
api.POST("/projects/:projectID/deploy-markers", h.createDeployMarker) api.POST("/projects/:projectID/deploy-markers", h.createDeployMarker)
api.GET("/projects/:projectID/jobs", h.listJobs)
api.POST("/projects/:projectID/jobs", h.createJob)
api.PATCH("/projects/:projectID/jobs/:jobID", h.updateJob)
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob)
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
api.GET("/buckets/:bucketID/objects", h.listObjects) api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject) api.POST("/buckets/:bucketID/objects", h.uploadObject)
api.POST("/buckets/:bucketID/object-copies", h.copyObject) api.POST("/buckets/:bucketID/object-copies", h.copyObject)
@@ -123,6 +132,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 {
+180
View File
@@ -0,0 +1,180 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/models"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
// actorProjectJob resolves the actor, :projectID and :jobID.
func (h *HTTPHandler) actorProjectJob(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
jobID, ok := parseUUIDParam(c, "jobID")
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
return actor, projectID, jobID, true
}
func (h *HTTPHandler) jobError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "job not found")
return
}
msg := err.Error()
if strings.Contains(msg, "already exists") {
apperrors.Abort(c, http.StatusConflict, "name_conflict", msg)
return
}
if strings.Contains(msg, "invalid schedule") || strings.Contains(msg, "schedule is required") || strings.Contains(msg, "https") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listJobs(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListScheduledJobs(c.Request.Context(), actor, projectID)
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createJob(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateScheduledJobInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateScheduledJob(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) updateJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
var body services.UpdateScheduledJobInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateScheduledJob(c.Request.Context(), actor, projectID, jobID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
if err := h.Platform.DeleteScheduledJob(c.Request.Context(), actor, projectID, jobID, middleware.RequestIDFromContext(c)); err != nil {
h.jobError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) listJobRuns(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
limit, ok := parsePaginationQuery(c, "limit", 50, 1, 200)
if !ok {
return
}
result, err := h.Platform.ListScheduledJobRuns(c.Request.Context(), actor, projectID, jobID, limit)
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) runJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
result, err := h.Platform.RunScheduledJob(c.Request.Context(), actor, projectID, jobID, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusAccepted, result)
}
// realtimeStream pushes project domain events (document/object/issue/job) to
// the dashboard over SSE — the same pattern as telemetryStream.
func (h *HTTPHandler) realtimeStream(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
ch, err := h.Platform.SubscribeRealtime(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
defer h.Platform.UnsubscribeRealtime(projectID, ch)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
if _, err := c.Writer.WriteString(": connected\n\n"); err == nil {
c.Writer.Flush()
}
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
c.Stream(func(w io.Writer) bool {
select {
case msg, ok := <-ch:
if !ok {
return false
}
c.SSEvent("event", json.RawMessage(msg))
return true
case <-keepalive.C:
c.SSEvent("ping", "keepalive")
return true
case <-c.Request.Context().Done():
return false
}
})
}
+29 -18
View File
@@ -17,12 +17,15 @@ type organizationMembershipResponse struct {
} }
type projectResponse struct { type projectResponse struct {
ID string `json:"id"` ID string `json:"id"`
OrganizationID string `json:"organization_id"` OrganizationID string `json:"organization_id"`
Slug string `json:"slug"` Slug string `json:"slug"`
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"`
} }
@@ -114,23 +118,29 @@ func toProjectFromRow(row db.ListProjectsForOrganizationRow) projectResponse {
membershipRole = &role membershipRole = &role
} }
return projectResponse{ return projectResponse{
ID: row.ID.String(), ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(), OrganizationID: row.OrganizationID.String(),
Slug: row.Slug, Slug: row.Slug,
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,
} }
} }
func toProjectFromCore(row db.CoreProject) projectResponse { func toProjectFromCore(row db.CoreProject) projectResponse {
return projectResponse{ return projectResponse{
ID: row.ID.String(), ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(), OrganizationID: row.OrganizationID.String(),
Slug: row.Slug, Slug: row.Slug,
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),
} }
@@ -35,8 +35,11 @@ func Compression() gin.HandlerFunc {
return return
} }
// Skip compression for small responses or streaming // Skip compression for small responses or streaming — gzip buffers
if c.Request.Method == "HEAD" || c.Request.URL.Path == "/api/v1/health/liveness" { // writes, which breaks SSE flush semantics.
if c.Request.Method == "HEAD" ||
c.Request.URL.Path == "/api/v1/health/liveness" ||
strings.HasSuffix(c.Request.URL.Path, "/stream") {
c.Next() c.Next()
return return
} }
+35 -3
View File
@@ -78,9 +78,11 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
apiKey = strings.TrimSpace(c.GetHeader("X-Primora-Key")) apiKey = strings.TrimSpace(c.GetHeader("X-Primora-Key"))
} }
authz := strings.TrimSpace(c.GetHeader("Authorization")) authz := strings.TrimSpace(c.GetHeader("Authorization"))
// EventSource cannot set headers — the SSE stream accepts credentials // EventSource cannot set headers — the SSE streams accept credentials
// as query params instead. // as query params instead.
if apiKey == "" && authz == "" && strings.HasSuffix(c.Request.URL.Path, "/telemetry/stream") { sseStream := strings.HasSuffix(c.Request.URL.Path, "/telemetry/stream") ||
strings.HasSuffix(c.Request.URL.Path, "/realtime/stream")
if apiKey == "" && authz == "" && sseStream {
apiKey = strings.TrimSpace(c.Query("api_key")) apiKey = strings.TrimSpace(c.Query("api_key"))
if apiKey == "" { if apiKey == "" {
if t := strings.TrimSpace(c.Query("token")); t != "" { if t := strings.TrimSpace(c.Query("token")); t != "" {
@@ -94,7 +96,6 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
token = strings.TrimSpace(strings.TrimPrefix(authz, "Bearer ")) token = strings.TrimSpace(strings.TrimPrefix(authz, "Bearer "))
} }
if strings.HasPrefix(strings.ToLower(authz), "bearer ") { if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
apiKey = ""
actor, err := m.resolveJWTActor(c.Request.Context(), token) actor, err := m.resolveJWTActor(c.Request.Context(), token)
if err != nil { if err != nil {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_token", err.Error()) apperrors.Abort(c, http.StatusUnauthorized, "invalid_token", err.Error())
@@ -125,6 +126,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 +269,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")
}
}
+33
View File
@@ -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())
}
+274 -46
View File
@@ -2,6 +2,7 @@ package services
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -9,7 +10,6 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db" db "github.com/tdvorak/primora/apps/backend/internal/database/db"
@@ -19,7 +19,8 @@ import (
// DBConnectionConfig holds the server-side credential payload for a saved // DBConnectionConfig holds the server-side credential payload for a saved
// connection. It is stored in core.db_connections.config and never serialized // connection. It is stored in core.db_connections.config and never serialized
// to the client — see DBConnectionSummary. // to the client — see DBConnectionSummary. Password is AES-GCM sealed
// (enc:v1:<base64>) before persisting; openPassword restores it.
type DBConnectionConfig struct { type DBConnectionConfig struct {
Host string `json:"host"` Host string `json:"host"`
Port *int `json:"port,omitempty"` Port *int `json:"port,omitempty"`
@@ -30,14 +31,6 @@ type DBConnectionConfig struct {
DriverProfile string `json:"driver_profile,omitempty"` DriverProfile string `json:"driver_profile,omitempty"`
} }
// ManagedDBSeed describes a first-party connection auto-registered on every
// project (the platform's own Postgres and Dragonfly).
type ManagedDBSeed struct {
Name string
DBType string
Config DBConnectionConfig
}
type CreateDBConnectionInput struct { type CreateDBConnectionInput struct {
Name string `json:"name" validate:"required,min=1"` Name string `json:"name" validate:"required,min=1"`
DBType string `json:"db_type" validate:"required,oneof=postgres mysql sqlite redis mongodb sqlserver duckdb clickhouse cassandra"` DBType string `json:"db_type" validate:"required,oneof=postgres mysql sqlite redis mongodb sqlserver duckdb clickhouse cassandra"`
@@ -88,15 +81,44 @@ func toDBConnectionSummary(row db.CoreDbConnection) DBConnectionSummary {
} }
} }
// dbxConnectionName derives the DBX-internal name. The project prefix keeps // dbxConnectionName derives the DBX-internal name. The full project UUID
// same-named connections in different projects from clobbering each other in // prefix keeps same-named connections in different projects from colliding
// DBX's shared storage. // in DBX's shared storage.
func dbxConnectionName(projectID uuid.UUID, name string) string { func dbxConnectionName(projectID uuid.UUID, name string) string {
short := strings.ReplaceAll(projectID.String(), "-", "")[:8] return "prj_" + strings.ReplaceAll(projectID.String(), "-", "") + "_" + name
return "prj_" + short + "_" + name
} }
func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any { // encPasswordPrefix marks a sealed password inside the JSONB config. Values
// without the prefix are legacy plaintext from pre-encryption rows.
const encPasswordPrefix = "enc:v1:"
func (s *PlatformService) sealPassword(plain string) (string, error) {
if plain == "" {
return "", nil
}
sealed, err := s.enc.Encrypt([]byte(plain))
if err != nil {
return "", fmt.Errorf("encrypt password: %w", err)
}
return encPasswordPrefix + base64.StdEncoding.EncodeToString(sealed), nil
}
func (s *PlatformService) openPassword(stored string) (string, error) {
if !strings.HasPrefix(stored, encPasswordPrefix) {
return stored, nil
}
sealed, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, encPasswordPrefix))
if err != nil {
return "", fmt.Errorf("decode sealed password: %w", err)
}
plain, err := s.enc.Decrypt(sealed)
if err != nil {
return "", fmt.Errorf("decrypt password: %w", err)
}
return string(plain), nil
}
func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) (map[string]any, error) {
cfg := parseDBConnectionConfig(row.Config) cfg := parseDBConnectionConfig(row.Config)
args := map[string]any{ args := map[string]any{
"name": dbxConnectionName(row.ProjectID, row.Name), "name": dbxConnectionName(row.ProjectID, row.Name),
@@ -113,7 +135,11 @@ func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any {
args["username"] = cfg.Username args["username"] = cfg.Username
} }
if cfg.Password != "" { if cfg.Password != "" {
args["password"] = cfg.Password plain, err := s.openPassword(cfg.Password)
if err != nil {
return nil, err
}
args["password"] = plain
} }
if cfg.SSL != nil { if cfg.SSL != nil {
args["ssl"] = *cfg.SSL args["ssl"] = *cfg.SSL
@@ -121,32 +147,7 @@ func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any {
if cfg.DriverProfile != "" { if cfg.DriverProfile != "" {
args["driver_profile"] = cfg.DriverProfile args["driver_profile"] = cfg.DriverProfile
} }
return args return args, nil
}
// seedManagedConnections registers the platform's own Postgres and Dragonfly
// on the project. Runs on list so existing projects pick them up too.
func (s *PlatformService) seedManagedConnections(ctx context.Context, projectID uuid.UUID) {
for _, seed := range s.managedDBSeeds {
_, err := s.repo.Queries().GetDBConnectionByName(ctx, db.GetDBConnectionByNameParams{
ProjectID: projectID,
Name: seed.Name,
})
if err == nil {
continue
}
if !errors.Is(err, pgx.ErrNoRows) {
continue
}
cfgBytes, _ := json.Marshal(seed.Config)
_, _ = s.repo.Queries().CreateDBConnection(ctx, db.CreateDBConnectionParams{
ProjectID: projectID,
Name: seed.Name,
DbType: seed.DBType,
Config: cfgBytes,
IsManaged: true,
})
}
} }
func (s *PlatformService) DBXStatus(ctx context.Context, actor *models.Actor) (map[string]any, error) { func (s *PlatformService) DBXStatus(ctx context.Context, actor *models.Actor) (map[string]any, error) {
@@ -163,7 +164,6 @@ func (s *PlatformService) ListDBConnections(ctx context.Context, actor *models.A
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err return nil, err
} }
s.seedManagedConnections(ctx, projectID)
rows, err := s.repo.Queries().ListDBConnections(ctx, projectID) rows, err := s.repo.Queries().ListDBConnections(ctx, projectID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -183,12 +183,16 @@ func (s *PlatformService) CreateDBConnection(ctx context.Context, actor *models.
if name == "" { if name == "" {
return DBConnectionSummary{}, errors.New("name must contain letters or digits") return DBConnectionSummary{}, errors.New("name must contain letters or digits")
} }
sealedPassword, err := s.sealPassword(input.Password)
if err != nil {
return DBConnectionSummary{}, err
}
cfg := DBConnectionConfig{ cfg := DBConnectionConfig{
Host: strings.TrimSpace(input.Host), Host: strings.TrimSpace(input.Host),
Port: input.Port, Port: input.Port,
Database: strings.TrimSpace(input.Database), Database: strings.TrimSpace(input.Database),
Username: strings.TrimSpace(input.Username), Username: strings.TrimSpace(input.Username),
Password: input.Password, Password: sealedPassword,
SSL: input.SSL, SSL: input.SSL,
DriverProfile: strings.TrimSpace(input.DriverProfile), DriverProfile: strings.TrimSpace(input.DriverProfile),
} }
@@ -269,7 +273,11 @@ func (s *PlatformService) connForTool(ctx context.Context, actor *models.Actor,
if s.dbx == nil { if s.dbx == nil {
return db.CoreDbConnection{}, errors.New("dbx not configured") return db.CoreDbConnection{}, errors.New("dbx not configured")
} }
if err := s.dbx.EnsureConnection(ctx, dbxConnectionName(projectID, row.Name), s.dbxArgsFor(row)); err != nil { args, err := s.dbxArgsFor(row)
if err != nil {
return db.CoreDbConnection{}, err
}
if err := s.dbx.EnsureConnection(ctx, dbxConnectionName(projectID, row.Name), args); err != nil {
return db.CoreDbConnection{}, err return db.CoreDbConnection{}, err
} }
return row, nil return row, nil
@@ -500,3 +508,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, `\`, `\\`), `"`, `\"`) + `"`
}
+591
View File
@@ -0,0 +1,591 @@
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/robfig/cron/v3"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// WebhookEventJobRun fires on every scheduled-job execution, successful or not.
const WebhookEventJobRun = "job.run"
// maxJobRunsPerJob bounds run history — older rows are pruned on each finish.
const maxJobRunsPerJob = 200
type ScheduledJobSummary struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
URL string `json:"url"`
Payload json.RawMessage `json:"payload"`
Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"`
LastRunAt *time.Time `json:"last_run_at"`
LastStatus *string `json:"last_status"`
NextRunAt *time.Time `json:"next_run_at"`
CreatedAt time.Time `json:"created_at"`
// Secret is populated only when Primora generated it — one chance to see it.
Secret string `json:"secret,omitempty"`
}
type ScheduledJobRunSummary struct {
ID uuid.UUID `json:"id"`
JobID uuid.UUID `json:"job_id"`
Status string `json:"status"`
TriggeredBy string `json:"triggered_by"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
type CreateScheduledJobInput struct {
Name string `json:"name" validate:"required,min=2"`
Schedule string `json:"schedule" validate:"required"`
URL string `json:"url" validate:"required,url"`
Secret string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
type UpdateScheduledJobInput struct {
Name *string `json:"name" validate:"omitempty,min=2"`
Schedule *string `json:"schedule"`
URL *string `json:"url" validate:"omitempty,url"`
Secret *string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
out := ScheduledJobSummary{
ID: row.ID,
ProjectID: row.ProjectID,
Name: row.Name,
Schedule: row.Schedule,
URL: row.Url,
Payload: row.Payload,
Enabled: row.Enabled,
HasSecret: len(row.Secret) > 0,
CreatedAt: row.CreatedAt.Time,
}
if row.LastRunAt.Valid {
t := row.LastRunAt.Time
out.LastRunAt = &t
}
if row.LastStatus != "" {
s := row.LastStatus
out.LastStatus = &s
}
if row.NextRunAt.Valid {
t := row.NextRunAt.Time
out.NextRunAt = &t
}
return out
}
func toJobRunSummary(row db.CoreScheduledJobRun) ScheduledJobRunSummary {
out := ScheduledJobRunSummary{
ID: row.ID,
JobID: row.JobID,
Status: row.Status,
TriggeredBy: row.TriggeredBy,
StatusCode: row.StatusCode,
Error: row.Error,
DurationMs: row.DurationMs,
StartedAt: row.CreatedAt.Time,
}
if row.FinishedAt.Valid {
t := row.FinishedAt.Time
out.FinishedAt = &t
}
return out
}
// parseSchedule accepts standard 5-field cron expressions plus robfig
// descriptors (@hourly, @every 30m, …).
func parseSchedule(expr string) (cron.Schedule, error) {
expr = strings.TrimSpace(expr)
if expr == "" {
return nil, errors.New("schedule is required")
}
sched, err := cron.ParseStandard(expr)
if err != nil {
return nil, fmt.Errorf("invalid schedule %q — expected a cron expression like \"*/15 * * * *\" or \"@every 1h\"", expr)
}
return sched, nil
}
func nextRunAt(sched cron.Schedule, from time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: sched.Next(from), Valid: true}
}
func (s *PlatformService) jobFor(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, write bool) (db.CoreScheduledJob, error) {
roles := []string{"admin", "developer", "viewer"}
if write {
roles = []string{"admin", "developer"}
}
if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil {
return db.CoreScheduledJob{}, err
}
row, err := s.repo.Queries().GetScheduledJobByID(ctx, jobID)
if err != nil {
return db.CoreScheduledJob{}, err
}
if row.ProjectID != projectID {
return db.CoreScheduledJob{}, fmt.Errorf("job access denied")
}
return row, nil
}
func (s *PlatformService) ListScheduledJobs(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ScheduledJobSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListScheduledJobs(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]ScheduledJobSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toJobSummary(row))
}
return out, nil
}
func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateScheduledJobInput, requestID string) (ScheduledJobSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ScheduledJobSummary{}, err
}
jobURL, err := normalizeWebhookURL(input.URL)
if err != nil {
return ScheduledJobSummary{}, err
}
sched, err := parseSchedule(input.Schedule)
if err != nil {
return ScheduledJobSummary{}, err
}
secret := strings.TrimSpace(input.Secret)
generated := false
if secret == "" {
if secret, err = randomToken(16); err != nil {
return ScheduledJobSummary{}, err
}
generated = true
}
secretBytes, err := s.enc.Encrypt([]byte(secret))
if err != nil {
return ScheduledJobSummary{}, err
}
payload := []byte("{}")
if input.Payload != nil {
payload, _ = json.Marshal(input.Payload)
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
var next pgtype.Timestamptz
if enabled {
next = nextRunAt(sched, time.Now())
}
row, err := s.repo.Queries().CreateScheduledJob(ctx, db.CreateScheduledJobParams{
ProjectID: projectID,
Name: strings.TrimSpace(input.Name),
Schedule: strings.TrimSpace(input.Schedule),
Url: jobURL,
Secret: secretBytes,
Payload: payload,
Enabled: enabled,
NextRunAt: next,
CreatedByUserID: createdBy,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
return ScheduledJobSummary{}, fmt.Errorf("a job named %q already exists", input.Name)
}
return ScheduledJobSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.created", "scheduled_job", row.ID.String(), map[string]any{
"name": row.Name, "schedule": row.Schedule, "url": row.Url,
}))
}
summary := toJobSummary(row)
if generated {
summary.Secret = secret
}
return summary, nil
}
func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, input UpdateScheduledJobInput, requestID string) (ScheduledJobSummary, error) {
row, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return ScheduledJobSummary{}, err
}
name := row.Name
if input.Name != nil {
name = strings.TrimSpace(*input.Name)
}
schedule := row.Schedule
if input.Schedule != nil {
schedule = strings.TrimSpace(*input.Schedule)
}
jobURL := row.Url
if input.URL != nil {
if jobURL, err = normalizeWebhookURL(*input.URL); err != nil {
return ScheduledJobSummary{}, err
}
}
enabled := row.Enabled
if input.Enabled != nil {
enabled = *input.Enabled
}
sched, err := parseSchedule(schedule)
if err != nil {
return ScheduledJobSummary{}, err
}
var next pgtype.Timestamptz
if enabled {
next = nextRunAt(sched, time.Now())
}
rotateSecret := input.Secret != nil
var secretBytes []byte
if rotateSecret {
secret := strings.TrimSpace(*input.Secret)
if secret == "" {
if secret, err = randomToken(16); err != nil {
return ScheduledJobSummary{}, err
}
}
if secretBytes, err = s.enc.Encrypt([]byte(secret)); err != nil {
return ScheduledJobSummary{}, err
}
}
rotatePayload := input.Payload != nil
var payload []byte
if rotatePayload {
payload, _ = json.Marshal(input.Payload)
}
updated, err := s.repo.Queries().UpdateScheduledJob(ctx, db.UpdateScheduledJobParams{
ID: jobID,
ProjectID: projectID,
Name: name,
Schedule: schedule,
Url: jobURL,
Enabled: enabled,
NextRunAt: next,
Column8: rotateSecret,
Secret: secretBytes,
Column10: rotatePayload,
Payload: payload,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
return ScheduledJobSummary{}, fmt.Errorf("a job named %q already exists", name)
}
return ScheduledJobSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.updated", "scheduled_job", jobID.String(), map[string]any{
"name": updated.Name, "schedule": updated.Schedule, "url": updated.Url, "enabled": updated.Enabled,
}))
summary := toJobSummary(updated)
if rotateSecret && strings.TrimSpace(*input.Secret) == "" {
if plain, decErr := s.enc.Decrypt(updated.Secret); decErr == nil {
summary.Secret = string(plain)
}
}
return summary, nil
}
func (s *PlatformService) DeleteScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, requestID string) error {
row, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return err
}
if _, err := s.repo.Queries().DeleteScheduledJob(ctx, db.DeleteScheduledJobParams{
ID: jobID,
ProjectID: projectID,
}); err != nil {
return err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.deleted", "scheduled_job", jobID.String(), map[string]any{
"name": row.Name,
}))
return nil
}
func (s *PlatformService) ListScheduledJobRuns(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, limit int32) ([]ScheduledJobRunSummary, error) {
if _, err := s.jobFor(ctx, actor, projectID, jobID, false); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListScheduledJobRuns(ctx, db.ListScheduledJobRunsParams{
JobID: jobID,
Limit: limit,
})
if err != nil {
return nil, err
}
out := make([]ScheduledJobRunSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toJobRunSummary(row))
}
return out, nil
}
// RunScheduledJob queues an out-of-band manual run. The run row is returned
// in "running" state; the result lands asynchronously.
func (s *PlatformService) RunScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, requestID string) (ScheduledJobRunSummary, error) {
job, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return ScheduledJobRunSummary{}, err
}
if s.scheduler == nil {
return ScheduledJobRunSummary{}, errors.New("scheduler unavailable")
}
run, err := s.scheduler.enqueue(ctx, job, "manual")
if err != nil {
return ScheduledJobRunSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.run", "scheduled_job", jobID.String(), map[string]any{
"name": job.Name, "run_id": run.ID.String(),
}))
return toJobRunSummary(run), nil
}
// ---------------------------------------------------------------------------
// JobScheduler — in-process ticker, signed POST per due job, run history.
// ---------------------------------------------------------------------------
// jobQueries is the slice of db.Queries the scheduler needs — narrow so tests
// can stub it.
type jobQueries interface {
ListDueScheduledJobs(ctx context.Context, now pgtype.Timestamptz) ([]db.CoreScheduledJob, error)
InsertScheduledJobRun(ctx context.Context, params db.InsertScheduledJobRunParams) (db.CoreScheduledJobRun, error)
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
}
type JobScheduler struct {
repo jobQueries
enc *secrets.Encryptor
logger *slog.Logger
http *http.Client
interval time.Duration
queue chan jobExec
quit chan struct{}
wg sync.WaitGroup
started sync.Once
// onEvent fans a finished run out to webhooks and the realtime stream.
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
}
type jobExec struct {
job db.CoreScheduledJob
run db.CoreScheduledJobRun
}
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler {
if logger == nil {
logger = slog.Default()
}
return &JobScheduler{
repo: q,
enc: enc,
logger: logger,
http: &http.Client{Timeout: 10 * time.Second},
interval: 15 * time.Second,
queue: make(chan jobExec, 256),
quit: make(chan struct{}),
onEvent: onEvent,
}
}
func (j *JobScheduler) Start(ctx context.Context) {
j.started.Do(func() {
j.wg.Add(1)
go j.run(ctx)
})
}
func (j *JobScheduler) Stop() {
close(j.quit)
j.wg.Wait()
}
func (j *JobScheduler) run(ctx context.Context) {
defer j.wg.Done()
ticker := time.NewTicker(j.interval)
defer ticker.Stop()
for {
select {
case exec := <-j.queue:
j.execute(exec.job, exec.run)
case <-ticker.C:
j.scan()
case <-j.quit:
return
case <-ctx.Done():
return
}
}
}
// scan picks up enabled jobs whose next_run_at has passed. Missed occurrences
// collapse into a single run — Primora fires once, not once per missed tick.
func (j *JobScheduler) scan() {
ctx := context.Background()
jobs, err := j.repo.ListDueScheduledJobs(ctx, pgtype.Timestamptz{Time: time.Now(), Valid: true})
if err != nil {
j.logger.Warn("scheduled job scan failed", "error", err)
return
}
for _, job := range jobs {
run, err := j.repo.InsertScheduledJobRun(ctx, db.InsertScheduledJobRunParams{JobID: job.ID, TriggeredBy: "schedule"})
if err != nil {
j.logger.Warn("job run insert failed", "job_id", job.ID, "error", err)
j.markRan(ctx, job, "failed")
continue
}
j.execute(job, run)
}
}
// enqueue inserts the run row synchronously so callers get a run ID, then
// hands the execution to the worker.
func (j *JobScheduler) enqueue(ctx context.Context, job db.CoreScheduledJob, triggeredBy string) (db.CoreScheduledJobRun, error) {
run, err := j.repo.InsertScheduledJobRun(ctx, db.InsertScheduledJobRunParams{
JobID: job.ID,
TriggeredBy: triggeredBy,
})
if err != nil {
return db.CoreScheduledJobRun{}, err
}
select {
case j.queue <- jobExec{job: job, run: run}:
case <-j.quit:
default:
j.logger.Warn("job queue full, run dropped", "job_id", job.ID, "run_id", run.ID)
}
return run, nil
}
func (j *JobScheduler) execute(job db.CoreScheduledJob, run db.CoreScheduledJobRun) {
ctx := context.Background()
start := time.Now()
statusCode, sendErr := j.deliver(ctx, job, run)
status := "success"
errText := ""
if sendErr != nil {
status = "failed"
errText = sendErr.Error()
}
duration := int32(time.Since(start).Milliseconds())
_, _ = j.repo.FinishScheduledJobRun(ctx, db.FinishScheduledJobRunParams{
ID: run.ID,
Status: status,
StatusCode: statusCode,
Error: errText,
DurationMs: &duration,
})
j.markRan(ctx, job, status)
_ = j.repo.PruneScheduledJobRuns(ctx, db.PruneScheduledJobRunsParams{JobID: job.ID, Limit: maxJobRunsPerJob})
if j.onEvent != nil {
j.onEvent(ctx, job.ProjectID, WebhookEventJobRun, map[string]any{
"job_id": job.ID.String(),
"job_name": job.Name,
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"status": status,
"status_code": statusCode,
"error": errText,
"duration_ms": duration,
})
}
}
func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, status string) {
var next pgtype.Timestamptz
if job.Enabled {
if sched, err := parseSchedule(job.Schedule); err == nil {
next = nextRunAt(sched, time.Now())
}
}
if _, err := j.repo.MarkScheduledJobRan(ctx, db.MarkScheduledJobRanParams{
ID: job.ID,
LastRunAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastStatus: status,
NextRunAt: next,
}); err != nil {
j.logger.Warn("job mark-ran failed", "job_id", job.ID, "error", err)
}
}
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
payload, _ := json.Marshal(map[string]any{
"event": WebhookEventJobRun,
"project_id": job.ProjectID.String(),
"job_id": job.ID.String(),
"job_name": job.Name,
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": json.RawMessage(job.Payload),
})
secret := ""
if len(job.Secret) > 0 && j.enc != nil {
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
secret = string(plain)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, job.Url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Primora-Event", WebhookEventJobRun)
req.Header.Set("X-Primora-Job", job.ID.String())
req.Header.Set("X-Primora-Run", run.ID.String())
req.Header.Set("X-Primora-Signature", "sha256="+signBody(secret, payload))
resp, err := j.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
code := int32(resp.StatusCode)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &code, fmt.Errorf("endpoint returned %d", resp.StatusCode)
}
return &code, nil
}
@@ -33,10 +33,11 @@ type PlatformService struct {
mailer *Mailer mailer *Mailer
publicURL string publicURL string
dbx *dbx.Client dbx *dbx.Client
managedDBSeeds []ManagedDBSeed
hub *EventHub hub *EventHub
realtime *EventHub
enc *secrets.Encryptor enc *secrets.Encryptor
dispatcher *WebhookDispatcher dispatcher *WebhookDispatcher
scheduler *JobScheduler
} }
type BootstrapInput struct { type BootstrapInput struct {
@@ -67,6 +68,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 {
@@ -78,7 +84,8 @@ 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 {
@@ -216,20 +223,51 @@ type InvitationSummary struct {
Status string `json:"status"` Status string `json:"status"`
} }
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService { func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub(), enc: enc} s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
if enc != nil { if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background()) s.dispatcher.Start(context.Background())
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
s.scheduler.Start(context.Background())
} }
return s return s
} }
// Close stops the webhook dispatcher. Idempotent. // Close stops the webhook dispatcher and job scheduler. Idempotent.
func (s *PlatformService) Close() { func (s *PlatformService) Close() {
if s.dispatcher != nil { if s.dispatcher != nil {
s.dispatcher.Stop() s.dispatcher.Stop()
} }
if s.scheduler != nil {
s.scheduler.Stop()
}
}
// publishEvent fans a domain event out to realtime SSE subscribers and to
// matching webhooks. Failures are logged by the callees — events never block
// the request that produced them.
func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
if s.realtime != nil {
s.realtime.Broadcast(projectID.String(), map[string]any{
"type": eventType,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
}
s.dispatchEvent(ctx, projectID, eventType, data)
}
// SubscribeRealtime registers an SSE consumer for project domain events.
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
return s.realtime.Subscribe(projectID.String()), nil
}
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
s.realtime.Unsubscribe(projectID.String(), ch)
} }
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) { func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
@@ -736,10 +774,13 @@ func (s *PlatformService) UpdateProject(ctx context.Context, actor *models.Actor
return db.CoreProject{}, err return db.CoreProject{}, err
} }
updated, err := s.repo.Queries().UpdateProjectByID(ctx, db.UpdateProjectByIDParams{ updated, err := s.repo.Queries().UpdateProjectByID(ctx, db.UpdateProjectByIDParams{
ID: projectID, ID: projectID,
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 +1079,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 +1088,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 +1105,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
@@ -1214,6 +1275,12 @@ func (s *PlatformService) UploadObject(ctx context.Context, actor *models.Actor,
"bucketId": bucket.ID.String(), "bucketId": bucket.ID.String(),
"key": objectKey, "key": objectKey,
})) }))
s.publishEvent(ctx, bucket.ProjectID, WebhookEventObjectCreated, map[string]any{
"bucket_id": bucket.ID.String(),
"object_key": objectKey,
"content_type": contentType,
"size_bytes": stored.SizeBytes,
})
return row, nil return row, nil
} }
@@ -1335,6 +1402,12 @@ func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor,
"oldObjectKey": oldObject.ObjectKey, "oldObjectKey": oldObject.ObjectKey,
"newObjectKey": updated.ObjectKey, "newObjectKey": updated.ObjectKey,
})) }))
s.publishEvent(ctx, sourceBucket.ProjectID, WebhookEventObjectUpdated, map[string]any{
"bucket_id": destinationBucket.ID.String(),
"object_key": updated.ObjectKey,
"old_bucket_id": sourceBucket.ID.String(),
"old_object_key": oldObject.ObjectKey,
})
return updated, nil return updated, nil
} }
@@ -1416,6 +1489,14 @@ func (s *PlatformService) CopyObject(ctx context.Context, actor *models.Actor, b
"sourceObjectKey": sourceObjectKey, "sourceObjectKey": sourceObjectKey,
"newObjectKey": created.ObjectKey, "newObjectKey": created.ObjectKey,
})) }))
s.publishEvent(ctx, sourceBucket.ProjectID, WebhookEventObjectCreated, map[string]any{
"bucket_id": destinationBucket.ID.String(),
"object_key": created.ObjectKey,
"content_type": created.ContentType,
"size_bytes": created.SizeBytes,
"source_bucket_id": sourceBucket.ID.String(),
"source_object_key": sourceObjectKey,
})
return created, nil return created, nil
} }
@@ -1441,6 +1522,10 @@ func (s *PlatformService) DeleteObject(ctx context.Context, actor *models.Actor,
"bucketId": bucketID.String(), "bucketId": bucketID.String(),
"key": objectKey, "key": objectKey,
})) }))
s.publishEvent(ctx, bucket.ProjectID, WebhookEventObjectDeleted, map[string]any{
"bucket_id": bucketID.String(),
"object_key": objectKey,
})
return nil return nil
} }
@@ -1664,6 +1749,11 @@ func (s *PlatformService) CreateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.created", "document", row.ID.String(), map[string]any{ _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.created", "document", row.ID.String(), map[string]any{
"collectionId": collectionID.String(), "collectionId": collectionID.String(),
})) }))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentCreated, map[string]any{
"collection_id": collectionID.String(),
"document_id": row.ID.String(),
"document": json.RawMessage(row.Data),
})
return row, nil return row, nil
} }
@@ -1688,6 +1778,11 @@ func (s *PlatformService) UpdateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.updated", "document", documentID.String(), map[string]any{ _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.updated", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(), "collectionId": collectionID.String(),
})) }))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentUpdated, map[string]any{
"collection_id": collectionID.String(),
"document_id": documentID.String(),
"document": json.RawMessage(row.Data),
})
return row, nil return row, nil
} }
@@ -1709,6 +1804,10 @@ func (s *PlatformService) DeleteDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.deleted", "document", documentID.String(), map[string]any{ _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.deleted", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(), "collectionId": collectionID.String(),
})) }))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentDeleted, map[string]any{
"collection_id": collectionID.String(),
"document_id": documentID.String(),
})
return nil return nil
} }
@@ -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)
}
}
}
+2 -2
View File
@@ -192,9 +192,9 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
for _, ev := range inserted { for _, ev := range inserted {
s.hub.Broadcast(ev.ProjectID.String(), ev) s.hub.Broadcast(ev.ProjectID.String(), ev)
} }
// A fresh error fingerprint is a new issue group — notify webhooks. // A fresh error fingerprint is a new issue group — notify webhooks + SSE.
for _, issue := range newIssues { for _, issue := range newIssues {
s.dispatchEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{ s.publishEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{
"fingerprint": issue.Fingerprint, "fingerprint": issue.Fingerprint,
"message": issue.Message, "message": issue.Message,
"severity": issue.Severity, "severity": issue.Severity,
+20 -7
View File
@@ -28,15 +28,28 @@ import (
) )
const ( const (
WebhookEventIssueCreated = "issue.created" WebhookEventIssueCreated = "issue.created"
WebhookEventDeployMarker = "deploy.marker" WebhookEventDeployMarker = "deploy.marker"
WebhookEventTest = "webhook.test" WebhookEventTest = "webhook.test"
WebhookEventDocumentCreated = "document.created"
WebhookEventDocumentUpdated = "document.updated"
WebhookEventDocumentDeleted = "document.deleted"
WebhookEventObjectCreated = "object.created"
WebhookEventObjectUpdated = "object.updated"
WebhookEventObjectDeleted = "object.deleted"
) )
var webhookEventTypes = map[string]bool{ var webhookEventTypes = map[string]bool{
WebhookEventIssueCreated: true, WebhookEventIssueCreated: true,
WebhookEventDeployMarker: true, WebhookEventDeployMarker: true,
WebhookEventTest: true, WebhookEventTest: true,
WebhookEventJobRun: true,
WebhookEventDocumentCreated: true,
WebhookEventDocumentUpdated: true,
WebhookEventDocumentDeleted: true,
WebhookEventObjectCreated: true,
WebhookEventObjectUpdated: true,
WebhookEventObjectDeleted: true,
} }
const webhookMaxAttempts = 3 const webhookMaxAttempts = 3
@@ -436,7 +449,7 @@ func (s *PlatformService) CreateDeployMarker(ctx context.Context, actor *models.
if _, err := s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "deploy.marker", "deploy_marker", marker.Ref+marker.Version, meta)); err != nil { if _, err := s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "deploy.marker", "deploy_marker", marker.Ref+marker.Version, meta)); err != nil {
return DeployMarker{}, err return DeployMarker{}, err
} }
s.dispatchEvent(ctx, projectID, WebhookEventDeployMarker, map[string]any{ s.publishEvent(ctx, projectID, WebhookEventDeployMarker, map[string]any{
"version": marker.Version, "version": marker.Version,
"ref": marker.Ref, "ref": marker.Ref,
"environment": marker.Environment, "environment": marker.Environment,
+429 -3
View File
@@ -14,6 +14,9 @@ tags:
- name: Collections - name: Collections
- name: Databases - name: Databases
- name: Telemetry - name: Telemetry
- name: Integrations
- name: Webhooks
- name: Automation
paths: paths:
/health/liveness: /health/liveness:
get: get:
@@ -37,6 +40,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 +1107,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]
@@ -1557,6 +1600,162 @@ paths:
$ref: "#/components/schemas/DeployMarker" $ref: "#/components/schemas/DeployMarker"
"401": "401":
$ref: "#/components/responses/ErrorResponse" $ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/jobs:
get:
tags: [Automation]
operationId: listScheduledJobs
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
responses:
"200":
description: Scheduled jobs for the project
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobListResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
post:
tags: [Automation]
operationId: createScheduledJob
summary: Runs a signed POST to the job URL on a cron schedule (standard 5-field cron or @every/@hourly descriptors). Payload is signed X-Primora-Signature like webhook deliveries; an empty secret generates one returned once. HTTPS required for public targets, HTTP allowed for private/self-hosted.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateScheduledJobRequest"
responses:
"201":
description: Job created
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobCreateResponse"
"400":
$ref: "#/components/responses/ErrorResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
"409":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/jobs/{jobID}:
patch:
tags: [Automation]
operationId: updateScheduledJob
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/JobID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateScheduledJobRequest"
responses:
"200":
description: Job updated
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobCreateResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
delete:
tags: [Automation]
operationId: deleteScheduledJob
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/JobID"
responses:
"204":
description: Job deleted
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/jobs/{jobID}/runs:
get:
tags: [Automation]
operationId: listScheduledJobRuns
summary: Recent executions, newest first. History is capped at 200 runs per job.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/JobID"
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
responses:
"200":
description: Job run history
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobRunListResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/jobs/{jobID}/run:
post:
tags: [Automation]
operationId: runScheduledJob
summary: Triggers an out-of-schedule manual run. Returns the run row in "running" state.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/JobID"
responses:
"202":
description: Manual run queued
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobRun"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/realtime/stream:
get:
tags: [Automation]
operationId: streamRealtimeEvents
summary: Server-sent events stream of project domain events — document/object mutations, new issues, deploy markers, job runs. EventSource clients pass `?api_key=` or `?token=` since headers cannot be set.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- name: api_key
in: query
schema:
type: string
- name: token
in: query
schema:
type: string
responses:
"200":
description: text/event-stream of {type, occurred_at, data} messages
"401":
$ref: "#/components/responses/ErrorResponse"
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
@@ -1690,6 +1889,13 @@ components:
schema: schema:
type: string type: string
format: uuid format: uuid
JobID:
name: jobID
in: path
required: true
schema:
type: string
format: uuid
DatabaseQuery: DatabaseQuery:
name: database name: database
in: query in: query
@@ -1920,6 +2126,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 +2161,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 +2351,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 +2374,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 +2400,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 +2837,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:
@@ -2980,7 +3272,7 @@ components:
type: array type: array
items: items:
type: string type: string
description: Event filter (issue.created, deploy.marker, webhook.test). Empty means all events. description: Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events.
enabled: enabled:
type: boolean type: boolean
has_secret: has_secret:
@@ -3002,7 +3294,7 @@ components:
type: array type: array
items: items:
type: string type: string
enum: [issue.created, deploy.marker, webhook.test] enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
enabled: enabled:
type: boolean type: boolean
required: [url] required: [url]
@@ -3019,7 +3311,7 @@ components:
type: array type: array
items: items:
type: string type: string
enum: [issue.created, deploy.marker, webhook.test] enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
enabled: enabled:
type: boolean type: boolean
WebhookCreateResponse: WebhookCreateResponse:
@@ -3108,3 +3400,137 @@ components:
type: string type: string
format: date-time format: date-time
required: [recorded_at] required: [recorded_at]
ScheduledJob:
type: object
properties:
id:
type: string
format: uuid
project_id:
type: string
format: uuid
name:
type: string
schedule:
type: string
description: Cron expression (5-field) or descriptor (@hourly, @every 30m).
url:
type: string
payload:
type: object
description: Static JSON payload merged into every delivery's data field.
enabled:
type: boolean
has_secret:
type: boolean
last_run_at:
type: string
format: date-time
nullable: true
last_status:
type: string
enum: [success, failed]
nullable: true
next_run_at:
type: string
format: date-time
nullable: true
created_at:
type: string
format: date-time
required: [id, project_id, name, schedule, url, payload, enabled, has_secret]
CreateScheduledJobRequest:
type: object
properties:
name:
type: string
minLength: 2
schedule:
type: string
description: 'Cron expression, e.g. "*/15 * * * *", or descriptor like "@every 1h".'
url:
type: string
format: uri
secret:
type: string
description: HMAC key for X-Primora-Signature. Generated when omitted.
payload:
type: object
description: Static JSON payload delivered as data on every run.
enabled:
type: boolean
default: true
required: [name, schedule, url]
UpdateScheduledJobRequest:
type: object
properties:
name:
type: string
minLength: 2
schedule:
type: string
url:
type: string
format: uri
secret:
type: string
description: Replace the signing secret. Omit to keep the current one.
payload:
type: object
enabled:
type: boolean
ScheduledJobCreateResponse:
allOf:
- $ref: "#/components/schemas/ScheduledJob"
- type: object
properties:
secret:
type: string
description: Plaintext secret — present only when Primora generated it. Store it; it cannot be recovered.
ScheduledJobListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/ScheduledJob"
required: [items]
ScheduledJobRun:
type: object
properties:
id:
type: string
format: uuid
job_id:
type: string
format: uuid
status:
type: string
enum: [running, success, failed]
triggered_by:
type: string
enum: [schedule, manual]
status_code:
type: integer
nullable: true
error:
type: string
duration_ms:
type: integer
nullable: true
started_at:
type: string
format: date-time
finished_at:
type: string
format: date-time
nullable: true
required: [id, job_id, status, triggered_by]
ScheduledJobRunListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/ScheduledJobRun"
required: [items]
+30 -1
View File
@@ -4,6 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js"; import { cmdAuditList } from "./commands/audit.js";
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js"; import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
import { cmdContext, cmdUse } from "./commands/context.js"; import { cmdContext, cmdUse } from "./commands/context.js";
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js"; import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
import { import {
cmdObjectsDownload, cmdObjectsDownload,
@@ -104,6 +105,34 @@ cli
.option("--yes", "Skip the confirmation prompt") .option("--yes", "Skip the confirmation prompt")
.action(cmdKeysRevoke); .action(cmdKeysRevoke);
cli
.command("jobs:list", "List scheduled jobs in the current project")
.option("--project <id>", "Project override")
.action(cmdJobsList);
cli
.command("jobs:create <name>", "Create a scheduled job (secret shown once if generated)")
.option("--project <id>", "Project override")
.option("--schedule <cron>", "Cron or descriptor, e.g. '*/15 * * * *' or '@every 1h'")
.option("--url <url>", "Target endpoint (HTTPS public, HTTP private)")
.option("--payload <json>", "Static JSON payload merged into every delivery")
.option("--secret <secret>", "HMAC signing secret (generated if omitted)")
.option("--disabled", "Create paused — enable via the dashboard or API")
.action(cmdJobsCreate);
cli
.command("jobs:run <job>", "Trigger a manual run now")
.option("--project <id>", "Project override")
.action(cmdJobsRun);
cli
.command("jobs:runs <job>", "List run history for a job")
.option("--project <id>", "Project override")
.option("--limit <n>", "Page size (default 50)")
.action(cmdJobsRuns);
cli
.command("jobs:rm <job>", "Delete a scheduled job")
.option("--project <id>", "Project override")
.option("--yes", "Skip the confirmation prompt")
.action(cmdJobsRemove);
cli cli
.command("audit:list", "List audit events for the current project") .command("audit:list", "List audit events for the current project")
.option("--project <id>", "Project override") .option("--project <id>", "Project override")
@@ -121,7 +150,7 @@ cli.version("0.4.0");
// cac only matches the first positional token, so "orgs list" cannot be a // cac only matches the first positional token, so "orgs list" cannot be a
// command name. Fold "group verb" pairs into "group:verb" before parsing — // command name. Fold "group verb" pairs into "group:verb" before parsing —
// `primora orgs list` and `primora orgs:list` both work. // `primora orgs list` and `primora orgs:list` both work.
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "audit"]); const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "jobs", "audit"]);
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) { if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv.splice(0, 2, `${argv[0]}:${argv[1]}`); argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
+149
View File
@@ -0,0 +1,149 @@
import type { ScheduledJob, ScheduledJobRun } from "@primora/api-client";
import { AutomationService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js";
import { formatDate, isJson, printJson, printTable, success } from "../out.js";
interface JobOptions {
project?: string;
json?: boolean;
}
function jobRow(job: ScheduledJob): string[] {
return [
job.name,
job.schedule,
job.url,
job.enabled ? "on" : "off",
job.last_status ?? "—",
job.last_run_at ? formatDate(job.last_run_at) : "never",
job.enabled && job.next_run_at ? formatDate(job.next_run_at) : "—",
job.id,
];
}
export async function cmdJobsList(options: JobOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const page = await AutomationService.listScheduledJobs({ projectId });
if (isJson(options)) {
printJson(page);
} else {
printTable(
["NAME", "SCHEDULE", "URL", "ENABLED", "LAST", "LAST RUN", "NEXT RUN", "ID"],
page.items.map(jobRow),
);
}
}
export async function cmdJobsCreate(
name: string,
options: JobOptions & {
schedule?: string;
url?: string;
payload?: string;
secret?: string;
disabled?: boolean;
},
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
if (!options.schedule) throw new Error("pass --schedule, e.g. '*/15 * * * *' or '@every 1h'");
if (!options.url) throw new Error("pass --url <https endpoint>");
let payload: Record<string, unknown> | undefined;
if (options.payload) {
try {
payload = JSON.parse(options.payload) as Record<string, unknown>;
} catch {
throw new Error("--payload must be valid JSON");
}
}
const projectId = requireProject(cfg, options.project);
const job = await AutomationService.createScheduledJob({
projectId,
requestBody: {
name,
schedule: options.schedule,
url: options.url,
secret: options.secret || undefined,
payload,
enabled: !options.disabled,
},
});
if (isJson(options)) {
printJson(job);
return;
}
success(`created ${job.name} (${job.id})`);
if (job.secret) {
process.stderr.write(`signing secret (shown once): ${job.secret}\n`);
}
}
export async function cmdJobsRemove(jobId: string, options: JobOptions & { yes?: boolean }): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
await AutomationService.deleteScheduledJob({ projectId, jobId });
if (isJson(options)) {
printJson({ deleted: jobId });
} else {
success(`deleted ${jobId}`);
}
}
export async function cmdJobsRun(jobId: string, options: JobOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const run = await AutomationService.runScheduledJob({ projectId, jobId });
if (isJson(options)) {
printJson(run);
} else {
success(`run queued (${run.id}) — status ${run.status}`);
}
}
function runRow(run: ScheduledJobRun): string[] {
return [
formatDate(run.started_at),
run.status,
run.triggered_by,
run.status_code != null ? String(run.status_code) : "—",
run.duration_ms != null ? `${run.duration_ms}ms` : "—",
run.error || "—",
];
}
export async function cmdJobsRuns(
jobId: string,
options: JobOptions & { limit?: string },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const page = await AutomationService.listScheduledJobRuns({
projectId,
jobId,
limit: options.limit ? Number(options.limit) : 50,
});
if (isJson(options)) {
printJson(page);
} else {
printTable(["STARTED", "STATUS", "TRIGGER", "HTTP", "DURATION", "ERROR"], page.items.map(runRow));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/desktop", "name": "@primora/desktop",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment", "description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Primora", "productName": "Primora",
"version": "0.5.0", "version": "0.6.0",
"identifier": "dev.tdvorak.primora", "identifier": "dev.tdvorak.primora",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+62 -6
View File
@@ -14,6 +14,7 @@ import {
UpdateOrganizationRequest, UpdateOrganizationRequest,
UpdateProjectMemberRoleRequest, UpdateProjectMemberRoleRequest,
UpdateProjectRequest, UpdateProjectRequest,
CreateApiKeyRequest,
CopyBucketObjectRequest, CopyBucketObjectRequest,
type Bucket, type Bucket,
type BucketObject, type BucketObject,
@@ -38,6 +39,7 @@ import {
import { import {
AuditPage, AuditPage,
AuthPage, AuthPage,
AutomationPage,
type AuthUser, type AuthUser,
CollectionsPage, CollectionsPage,
DatabasesPage, DatabasesPage,
@@ -57,6 +59,7 @@ import {
IconStorage, IconStorage,
IconDatabases, IconDatabases,
IconTelemetry, IconTelemetry,
IconZap,
IconCollections, IconCollections,
IconAudit, IconAudit,
IconAuth, IconAuth,
@@ -180,7 +183,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 +200,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 +269,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 +284,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 +567,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 +740,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 +755,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 +1037,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();
@@ -1416,6 +1459,7 @@ export default function App() {
{ id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") }, { id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") },
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") }, { id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") }, { id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") }, { id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") }, { id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") }, { id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
@@ -1646,6 +1690,14 @@ export default function App() {
/> />
</Show> </Show>
<Show when={activeView() === "automation"}>
<AutomationPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}> <Show when={activeView() === "storage"}>
<StoragePage <StoragePage
buckets={buckets()} buckets={buckets()}
@@ -1766,6 +1818,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 +1829,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 }))}
+5 -1
View File
@@ -10,6 +10,7 @@ import {
IconDatabases, IconDatabases,
IconTelemetry, IconTelemetry,
IconIntegrations, IconIntegrations,
IconZap,
IconAudit, IconAudit,
IconAuth, IconAuth,
IconSettings, IconSettings,
@@ -30,6 +31,7 @@ export type ViewType =
| "databases" | "databases"
| "telemetry" | "telemetry"
| "integrations" | "integrations"
| "automation"
| "auth" | "auth"
| "audit" | "audit"
| "settings"; | "settings";
@@ -46,6 +48,7 @@ const projectNav: NavEntry[] = [
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases }, { id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry }, { id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations }, { id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
{ id: "storage", label: "Storage", icon: IconStorage }, { id: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections }, { id: "collections", label: "Collections", icon: IconCollections },
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth }, { id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
@@ -53,7 +56,7 @@ const projectNav: NavEntry[] = [
]; ];
// Instagram-style bottom bar caps at five slots — the rest stay reachable via Menu. // Instagram-style bottom bar caps at five slots — the rest stay reachable via Menu.
const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations"].includes(e.id)); const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations", "automation"].includes(e.id));
const workspaceNav: NavEntry[] = [ const workspaceNav: NavEntry[] = [
{ id: "projects", label: "Projects", icon: IconProjects }, { id: "projects", label: "Projects", icon: IconProjects },
@@ -92,6 +95,7 @@ const viewTitle: Record<ViewType, string> = {
databases: "Databases", databases: "Databases",
telemetry: "Telemetry", telemetry: "Telemetry",
integrations: "Integrations", integrations: "Integrations",
automation: "Automation",
auth: "Authentication", auth: "Authentication",
audit: "Audit log", audit: "Audit log",
settings: "Settings", settings: "Settings",
+6 -2
View File
@@ -1,8 +1,12 @@
import { createAuthClient } from "better-auth/solid"; import { createAuthClient } from "better-auth/solid";
import { adminClient } from "better-auth/client/plugins"; import { adminClient } from "better-auth/client/plugins";
// Use full URL for dev mode, relative path for production // Same-origin default — nginx routes /auth to the auth service at whatever
const baseURL = import.meta.env.VITE_AUTH_BASE_URL ?? "http://localhost/auth"; // port or domain serves the app. better-auth requires an absolute baseURL, so
// resolve it against the runtime origin; VITE_AUTH_BASE_URL overrides for
// split-origin setups.
const baseURL =
import.meta.env.VITE_AUTH_BASE_URL ?? new URL("/auth", window.location.origin).href;
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL, baseURL,
+153 -9
View File
@@ -8,6 +8,8 @@ import {
OrganizationInvitation, OrganizationInvitation,
OrganizationMember, OrganizationMember,
ProjectMember, ProjectMember,
ScheduledJob,
ScheduledJobRun,
WebhookDelivery, WebhookDelivery,
} from "@primora/api-client"; } from "@primora/api-client";
import type { import type {
@@ -32,6 +34,7 @@ import type {
TelemetryStats, TelemetryStats,
Webhook, Webhook,
WebhookCreateResponse, WebhookCreateResponse,
ScheduledJobCreateResponse,
} from "@primora/api-client"; } from "@primora/api-client";
const STORAGE_KEY = "primora_demo_mode"; const STORAGE_KEY = "primora_demo_mode";
@@ -340,27 +343,27 @@ let dbConnections: DBConnection[] = [
{ {
id: "dbc-1", id: "dbc-1",
project_id: "proj-1", project_id: "proj-1",
name: "platform-postgres", name: "warehouse",
db_type: "postgres", db_type: "postgres",
host: "postgres", host: "db.example.internal",
port: 5432, port: 5432,
database: "primora", database: "analytics",
username: "primora", username: "primora_read",
ssl: null, ssl: true,
is_managed: true, is_managed: false,
has_password: true, has_password: true,
}, },
{ {
id: "dbc-2", id: "dbc-2",
project_id: "proj-1", project_id: "proj-1",
name: "platform-dragonfly", name: "cache",
db_type: "redis", db_type: "redis",
host: "dragonfly", host: "redis.example.internal",
port: 6379, port: 6379,
database: "", database: "",
username: "", username: "",
ssl: null, ssl: null,
is_managed: true, is_managed: false,
has_password: false, has_password: false,
}, },
]; ];
@@ -485,6 +488,74 @@ const demoDeliveries: WebhookDelivery[] = [
}, },
]; ];
const demoJobs: ScheduledJob[] = [
{
id: "demo-job-1",
project_id: "demo-project-1",
name: "nightly-rollup",
schedule: "0 3 * * *",
url: "https://api.example.com/jobs/rollup",
payload: { task: "rollup", window: "24h" },
enabled: true,
has_secret: true,
last_run_at: new Date(Date.now() - 7 * 3_600_000).toISOString(),
last_status: ScheduledJob.last_status.SUCCESS,
next_run_at: new Date(Date.now() + 17 * 3_600_000).toISOString(),
created_at: new Date(Date.now() - 14 * 86_400_000).toISOString(),
},
{
id: "demo-job-2",
project_id: "demo-project-1",
name: "healthcheck-ping",
schedule: "@every 15m",
url: "https://status.example.com/ping",
payload: {},
enabled: false,
has_secret: false,
last_run_at: new Date(Date.now() - 3 * 86_400_000).toISOString(),
last_status: ScheduledJob.last_status.FAILED,
created_at: new Date(Date.now() - 30 * 86_400_000).toISOString(),
},
];
const demoJobRuns: Record<string, ScheduledJobRun[]> = {
"demo-job-1": [
{
id: "demo-run-1",
job_id: "demo-job-1",
status: ScheduledJobRun.status.SUCCESS,
triggered_by: ScheduledJobRun.triggered_by.SCHEDULE,
status_code: 200,
duration_ms: 214,
started_at: new Date(Date.now() - 7 * 3_600_000).toISOString(),
finished_at: new Date(Date.now() - 7 * 3_600_000 + 214).toISOString(),
},
{
id: "demo-run-2",
job_id: "demo-job-1",
status: ScheduledJobRun.status.FAILED,
triggered_by: ScheduledJobRun.triggered_by.MANUAL,
status_code: 500,
error: "endpoint returned 500",
duration_ms: 1024,
started_at: new Date(Date.now() - 31 * 3_600_000).toISOString(),
finished_at: new Date(Date.now() - 31 * 3_600_000 + 1024).toISOString(),
},
],
"demo-job-2": [
{
id: "demo-run-3",
job_id: "demo-job-2",
status: ScheduledJobRun.status.FAILED,
triggered_by: ScheduledJobRun.triggered_by.SCHEDULE,
error: "connection refused",
duration_ms: 12,
started_at: new Date(Date.now() - 3 * 86_400_000).toISOString(),
finished_at: new Date(Date.now() - 3 * 86_400_000 + 12).toISOString(),
},
],
};
class DemoService { class DemoService {
private delay(ms = 250) { private delay(ms = 250) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@@ -531,6 +602,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; }
@@ -1230,6 +1304,76 @@ class DemoService {
return delivery; return delivery;
} }
async listScheduledJobs(): Promise<{ items: ScheduledJob[] }> {
await this.delay();
return { items: demoJobs };
}
async createScheduledJob(data: {
requestBody?: { name?: string; schedule?: string; url?: string; secret?: string; payload?: Record<string, unknown>; enabled?: boolean };
}): Promise<ScheduledJobCreateResponse> {
await this.delay();
const generated = !data.requestBody?.secret;
const job: ScheduledJob = {
id: `demo-job-${demoJobs.length + 1}`,
project_id: "demo-project-1",
name: data.requestBody?.name ?? "untitled",
schedule: data.requestBody?.schedule ?? "0 * * * *",
url: data.requestBody?.url ?? "https://example.com",
payload: (data.requestBody?.payload ?? {}) as Record<string, never>,
enabled: data.requestBody?.enabled ?? true,
has_secret: true,
last_status: null,
next_run_at: new Date(Date.now() + 3_600_000).toISOString(),
created_at: new Date().toISOString(),
};
demoJobs.push(job);
return { ...job, secret: generated ? "demo-secret-" + Math.random().toString(36).slice(2, 18) : undefined };
}
async updateScheduledJob(data: { jobId?: string; requestBody?: Partial<ScheduledJob> }): Promise<ScheduledJob> {
await this.delay();
const job = demoJobs.find((j) => j.id === data.jobId);
if (job && data.requestBody) {
Object.assign(job, data.requestBody);
}
return job as ScheduledJob;
}
async deleteScheduledJob(data: { jobId?: string }) {
await this.delay();
const idx = demoJobs.findIndex((j) => j.id === data.jobId);
if (idx >= 0) demoJobs.splice(idx, 1);
return {};
}
async listScheduledJobRuns(data: { jobId?: string; limit?: number }): Promise<{ items: ScheduledJobRun[] }> {
await this.delay();
return { items: (demoJobRuns[data.jobId ?? ""] ?? []).slice(0, data.limit ?? 50) };
}
async runScheduledJob(data: { jobId?: string }): Promise<ScheduledJobRun> {
await this.delay();
const jobID = data.jobId ?? "demo-job-1";
const run: ScheduledJobRun = {
id: `demo-run-${Date.now()}`,
job_id: jobID,
status: ScheduledJobRun.status.SUCCESS,
triggered_by: ScheduledJobRun.triggered_by.MANUAL,
status_code: 200,
duration_ms: Math.floor(Math.random() * 800) + 50,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
};
(demoJobRuns[jobID] ??= []).unshift(run);
const job = demoJobs.find((j) => j.id === jobID);
if (job) {
job.last_run_at = run.started_at;
job.last_status = ScheduledJob.last_status.SUCCESS;
}
return run;
}
async createDeployMarker(data: { async createDeployMarker(data: {
requestBody?: { version?: string; ref?: string; environment?: string; note?: string }; requestBody?: { version?: string; ref?: string; environment?: string; note?: string };
}): Promise<DeployMarker> { }): Promise<DeployMarker> {
+594
View File
@@ -0,0 +1,594 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
import {
AutomationService,
OpenAPI,
type ScheduledJob,
type ScheduledJobRun,
} from "@primora/api-client";
import { fetchApiToken } from "../lib/auth-client";
import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge";
import { Modal } from "../components/Modal";
import { Input, Textarea } from "../components/Input";
import {
IconPlus,
IconRefresh,
IconTrash,
IconPlay,
IconChevronRight,
IconCheck,
IconCopy,
IconZap,
} from "../components/Icons";
interface AutomationPageProps {
projectID?: string;
canManage: boolean;
demoMode: boolean;
}
type Tab = "schedules" | "events";
interface RealtimeEvent {
type: string;
occurred_at: string;
data: Record<string, unknown>;
}
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
const svc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof AutomationService) : AutomationService);
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
const statusVariant = (s?: string | null) =>
s === "success" ? "success" : s === "failed" ? "error" : s === "running" ? "warning" : "neutral";
const eventVariant = (t?: string) =>
t?.startsWith("document.")
? "primary"
: t?.startsWith("object.")
? "success"
: t === "job.run"
? "warning"
: t === "issue.created"
? "error"
: "neutral";
const DEMO_EVENT_TYPES = ["document.created", "object.created", "job.run", "deploy.marker", "issue.created"];
export function AutomationPage(props: AutomationPageProps) {
const [tab, setTab] = createSignal<Tab>("schedules");
const [jobs, setJobs] = createSignal<ScheduledJob[]>([]);
const [runs, setRuns] = createSignal<Record<string, ScheduledJobRun[]>>({});
const [expandedJob, setExpandedJob] = createSignal<string>();
const [showCreate, setShowCreate] = createSignal(false);
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
const [createdSecret, setCreatedSecret] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [busy, setBusy] = createSignal<string>();
const [message, setMessage] = createSignal("");
const [error, setError] = createSignal("");
const [events, setEvents] = createSignal<RealtimeEvent[]>([]);
const [live, setLive] = createSignal(false);
const [paused, setPaused] = createSignal(false);
const [typeFilter, setTypeFilter] = createSignal("");
const refresh = async () => {
if (!props.projectID) return;
setLoading(true);
setError("");
try {
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
setJobs(res.items ?? []);
} catch (e) {
setError(err(e));
} finally {
setLoading(false);
}
};
const loadRuns = async (jobID: string) => {
if (!props.projectID) return;
try {
const res = await svc(props.demoMode).listScheduledJobRuns({ projectId: props.projectID, jobId: jobID, limit: 50 });
setRuns((c) => ({ ...c, [jobID]: res.items ?? [] }));
} catch {
/* non-fatal */
}
};
const toggleRuns = (jobID: string) => {
if (expandedJob() === jobID) {
setExpandedJob(undefined);
return;
}
setExpandedJob(jobID);
void loadRuns(jobID);
};
createEffect(() => {
if (props.projectID) void refresh();
});
/* Live events — SSE against /realtime/stream; demo mode fakes a feed. */
createEffect(() => {
if (!props.projectID || tab() !== "events") return;
if (props.demoMode) {
let i = 0;
const timer = setInterval(() => {
if (paused()) return;
const type = DEMO_EVENT_TYPES[i++ % DEMO_EVENT_TYPES.length];
const data: Record<string, unknown> =
type === "job.run"
? { job_name: "nightly-rollup", status: "success", duration_ms: 42 + i }
: type.startsWith("document.")
? { collection_id: "demo-col-1", document_id: `doc-${i}`, document: { title: `Note ${i}` } }
: type.startsWith("object.")
? { bucket_id: "demo-bucket-1", object_key: `uploads/file-${i}.png`, size_bytes: 4096 * i }
: type === "issue.created"
? { fingerprint: `fp-${i}`, message: "TypeError: demo failure", severity: "error" }
: { version: `0.7.${i}`, environment: "production" };
setEvents((prev) => [{ type, occurred_at: new Date().toISOString(), data }, ...prev].slice(0, 200));
}, 2500);
setLive(true);
onCleanup(() => {
clearInterval(timer);
setLive(false);
});
return;
}
let es: EventSource | null = null;
let cancelled = false;
void fetchApiToken().then((token) => {
if (cancelled || !token || !props.projectID) return;
const url = `${OpenAPI.BASE}/projects/${props.projectID}/realtime/stream?token=${encodeURIComponent(token)}`;
es = new EventSource(url);
es.addEventListener("event", (m) => {
setLive(true);
if (paused()) return;
try {
const ev = JSON.parse((m as MessageEvent).data) as RealtimeEvent;
setEvents((prev) => [ev, ...prev].slice(0, 200));
} catch {
/* ignore malformed frames */
}
});
es.onerror = () => setLive(false);
es.onopen = () => setLive(true);
});
onCleanup(() => {
cancelled = true;
es?.close();
setLive(false);
});
});
const run = async (key: string, task: () => Promise<unknown>, done?: string) => {
setBusy(key);
setMessage("");
setError("");
try {
await task();
if (done) setMessage(done);
await refresh();
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const createJob = (e: Event) => {
e.preventDefault();
const form = jobForm();
let payload: Record<string, unknown> | undefined;
if (form.payload.trim()) {
try {
payload = JSON.parse(form.payload) as Record<string, unknown>;
} catch {
setError("Payload must be valid JSON");
return;
}
}
void run("create-job", async () => {
const created = await svc(props.demoMode).createScheduledJob({
projectId: props.projectID!,
requestBody: {
name: form.name.trim(),
schedule: form.schedule.trim(),
url: form.url.trim(),
secret: form.secret.trim() || undefined,
payload,
enabled: form.enabled,
},
});
if (created.secret) setCreatedSecret(created.secret);
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
setShowCreate(false);
}, "Schedule created");
};
const toggleEnabled = (job: ScheduledJob) =>
void run(`toggle-${job.id}`, () =>
svc(props.demoMode).updateScheduledJob({
projectId: props.projectID!,
jobId: job.id,
requestBody: { enabled: !job.enabled },
}),
);
const runNow = (job: ScheduledJob) =>
void run(
`run-${job.id}`,
() =>
svc(props.demoMode).runScheduledJob({ projectId: props.projectID!, jobId: job.id }),
`Run queued for ${job.name}`,
);
const deleteJob = (job: ScheduledJob) =>
void run(
`del-${job.id}`,
() => svc(props.demoMode).deleteScheduledJob({ projectId: props.projectID!, jobId: job.id }),
`Deleted ${job.name}`,
);
const filteredEvents = () => {
const f = typeFilter();
return f ? events().filter((e) => e.type === f) : events();
};
const eventTypes = () => [...new Set(events().map((e) => e.type))];
return (
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">Automation</h1>
<p class="page-description">Scheduled jobs and the live event stream for this project.</p>
</div>
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
<IconRefresh class="w-4 h-4" />
Refresh
</button>
</div>
<Show when={message()}>
<div class="message message-success mb-4">{message()}</div>
</Show>
<Show when={error()}>
<div class="message message-error mb-4">{error()}</div>
</Show>
<Show when={createdSecret()}>
<div class="message message-warning mb-4">
<div class="flex-1 min-w-0">
<strong>Job signing secret — shown once.</strong>
<code class="block mt-1" style="word-break: break-all">{createdSecret()}</code>
</div>
<button class="btn btn-ghost btn-sm" onClick={() => navigator.clipboard?.writeText(createdSecret())} title="Copy secret">
<IconCopy class="w-4 h-4" />
</button>
<button class="btn btn-ghost btn-sm" onClick={() => setCreatedSecret("")} title="Dismiss">
<IconCheck class="w-4 h-4" />
</button>
</div>
</Show>
<div class="tabs mb-4">
<button class={`tab ${tab() === "schedules" ? "active" : ""}`} onClick={() => setTab("schedules")}>
Schedules
</button>
<button class={`tab ${tab() === "events" ? "active" : ""}`} onClick={() => setTab("events")}>
Live events
</button>
</div>
<Show when={tab() === "schedules"}>
<div class="card card-flush">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Scheduled jobs</div>
<div class="card-header-description">
Cron-driven signed POSTs to your endpoints. Deliveries carry{" "}
<code>X-Primora-Signature</code> (HMAC-SHA256) — same scheme as webhooks. Missed
occurrences collapse into a single run.
</div>
</div>
<Show when={props.canManage}>
<button class="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}>
<IconPlus class="w-4 h-4" />
New schedule
</button>
</Show>
</div>
<Show
when={jobs().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
No schedules yet. Add one to ping an endpoint on a cron — backups, rollups, report
generation.
</p>
}
>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Schedule</th>
<th>Target</th>
<th class="hidden md:table-cell">Next run</th>
<th class="hidden md:table-cell">Last run</th>
<th>Enabled</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<For each={jobs()}>
{(job) => (
<>
<tr>
<td class="font-medium">{job.name}</td>
<td>
<code class="text-xs">{job.schedule}</code>
</td>
<td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">
{job.url}
</td>
<td class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td>
<td class="hidden md:table-cell">
<Show when={job.last_run_at} fallback={<span class="text-text-2">never</span>}>
<Badge variant={statusVariant(job.last_status)}>{job.last_status}</Badge>{" "}
<span class="text-text-2 text-xs">{fmtTime(job.last_run_at)}</span>
</Show>
</td>
<td>
<Show when={props.canManage} fallback={<Badge variant={job.enabled ? "success" : "neutral"}>{job.enabled ? "on" : "off"}</Badge>}>
<button
class={`badge ${job.enabled ? "badge-success" : ""} cursor-pointer border-0`}
onClick={() => toggleEnabled(job)}
disabled={busy() === `toggle-${job.id}`}
title={job.enabled ? "Disable" : "Enable"}
>
{job.enabled ? "on" : "off"}
</button>
</Show>
</td>
<td style="text-align:right;white-space:nowrap">
<button class="btn btn-ghost btn-sm" onClick={() => toggleRuns(job.id)}>
<IconChevronRight class={`w-4 h-4 ${expandedJob() === job.id ? "rotate-90" : ""}`} />
Runs
</button>
<Show when={props.canManage}>
<button
class="btn btn-ghost btn-sm"
onClick={() => runNow(job)}
disabled={busy() === `run-${job.id}`}
title="Trigger a manual run now"
>
<IconPlay class="w-4 h-4" />
Run
</button>
<button
class="btn btn-ghost btn-sm"
onClick={() => deleteJob(job)}
disabled={busy() === `del-${job.id}`}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</td>
</tr>
<Show when={expandedJob() === job.id}>
<tr>
<td colspan={7} style="padding:0">
<div class="p-4" style="background:var(--bg-subtle)">
<div class="text-xs text-text-2 mb-2 font-medium uppercase tracking-wide">
Run history — last {(runs()[job.id] ?? []).length} of 200 kept
</div>
<Show
when={(runs()[job.id] ?? []).length > 0}
fallback={<p class="text-text-2 text-sm">No runs yet.</p>}
>
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Trigger</th>
<th class="hidden md:table-cell">HTTP</th>
<th class="hidden md:table-cell">Duration</th>
<th>Started</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<For each={runs()[job.id] ?? []}>
{(r) => (
<tr>
<td>
<Badge variant={statusVariant(r.status)}>{r.status}</Badge>
</td>
<td class="text-text-2">{r.triggered_by}</td>
<td class="hidden md:table-cell text-text-2">{r.status_code ?? "—"}</td>
<td class="hidden md:table-cell text-text-2">
{r.duration_ms != null ? `${r.duration_ms} ms` : "—"}
</td>
<td class="text-text-2">{fmtTime(r.started_at)}</td>
<td class="text-text-2" style="max-width:16rem;overflow:hidden;text-overflow:ellipsis">
{r.error || "—"}
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</Show>
<Show when={tab() === "events"}>
<div class="card card-flush">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">
Live events{" "}
<Badge variant={live() ? "success" : "neutral"}>{live() ? "live" : "offline"}</Badge>
</div>
<div class="card-header-description">
Domain events as they happen — document and object mutations, new issues, deploy
markers, job runs. Webhooks subscribed to the same types fire in parallel.
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" onClick={() => setPaused(!paused())}>
{paused() ? "Resume" : "Pause"}
</button>
<button class="btn btn-secondary btn-sm" onClick={() => setEvents([])}>
Clear
</button>
</div>
</div>
<Show when={eventTypes().length > 1}>
<div class="flex gap-2 p-4 pb-0 flex-wrap">
<button
class={`badge cursor-pointer border-0 ${typeFilter() === "" ? "badge-primary" : ""}`}
onClick={() => setTypeFilter("")}
>
all
</button>
<For each={eventTypes()}>
{(t) => (
<button
class={`badge cursor-pointer border-0 ${typeFilter() === t ? "badge-primary" : ""}`}
onClick={() => setTypeFilter(typeFilter() === t ? "" : t)}
>
{t}
</button>
)}
</For>
</div>
</Show>
<Show
when={filteredEvents().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
{live()
? "Listening — create a document, upload a file, or run a job and it lands here."
: "Connecting to the event stream…"}
</p>
}
>
<div class="table-container">
<table class="table">
<tbody>
<For each={filteredEvents()}>
{(ev) => (
<tr>
<td style="width:11rem">
<Badge variant={eventVariant(ev.type)}>{ev.type}</Badge>
</td>
<td class="text-text-2 text-xs" style="width:11rem">
{fmtTime(ev.occurred_at)}
</td>
<td>
<code
class="text-xs block"
style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:36rem"
title={JSON.stringify(ev.data, null, 2)}
>
{JSON.stringify(ev.data)}
</code>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</Show>
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md">
<form onSubmit={createJob} class="space-y-4">
<Input
label="Name"
value={jobForm().name}
onInput={(e) => setJobForm((c) => ({ ...c, name: e.currentTarget.value }))}
placeholder="nightly-rollup"
required
/>
<div>
<Input
label="Schedule"
value={jobForm().schedule}
onInput={(e) => setJobForm((c) => ({ ...c, schedule: e.currentTarget.value }))}
placeholder="*/15 * * * * or @every 1h"
required
/>
<p class="label-hint">
Standard 5-field cron, or a descriptor: <code>@hourly</code>, <code>@daily</code>,{" "}
<code>@every 30m</code>.
</p>
</div>
<div>
<Input
label="Target URL"
value={jobForm().url}
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))}
placeholder="https://api.example.com/jobs/rollup"
required
/>
<p class="label-hint">
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
</p>
</div>
<Textarea
label="Payload (optional JSON)"
class="font-mono text-xs"
rows={3}
value={jobForm().payload}
onInput={(e) => setJobForm((c) => ({ ...c, payload: e.currentTarget.value }))}
placeholder='{"task": "rollup", "window": "24h"}'
/>
<Input
label="Signing secret (optional)"
value={jobForm().secret}
onInput={(e) => setJobForm((c) => ({ ...c, secret: e.currentTarget.value }))}
placeholder="generated if empty"
/>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jobForm().enabled}
onChange={(e) => setJobForm((c) => ({ ...c, enabled: e.currentTarget.checked }))}
/>
Enabled — start running on schedule immediately
</label>
<div class="flex gap-2 justify-end">
<button type="button" class="btn btn-secondary" onClick={() => setShowCreate(false)}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create-job"}>
<IconZap class="w-4 h-4" />
Create schedule
</button>
</div>
</form>
</Modal>
</div>
);
}
+40 -2
View File
@@ -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
+40 -1
View File
@@ -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>
+1
View File
@@ -7,6 +7,7 @@ export { CollectionsPage } from "./CollectionsPage";
export { DatabasesPage } from "./DatabasesPage"; export { DatabasesPage } from "./DatabasesPage";
export { TelemetryPage } from "./TelemetryPage"; export { TelemetryPage } from "./TelemetryPage";
export { IntegrationsPage } from "./IntegrationsPage"; export { IntegrationsPage } from "./IntegrationsPage";
export { AutomationPage } from "./AutomationPage";
export { SettingsPage } from "./SettingsPage"; export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage"; export { AuditPage } from "./AuditPage";
export { AuthPage, type AuthUser } from "./AuthPage"; export { AuthPage, type AuthUser } from "./AuthPage";
+149 -1
View File
@@ -3,6 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { z } from "zod"; import { z } from "zod";
import { import {
AutomationService,
CreateBucketRequest, CreateBucketRequest,
IntegrationsService, IntegrationsService,
OpenAPI, OpenAPI,
@@ -574,7 +575,20 @@ server.registerTool(
const webhookRef = z.string().describe("Webhook id (see primora_list_webhooks)"); const webhookRef = z.string().describe("Webhook id (see primora_list_webhooks)");
const webhookEvents = z const webhookEvents = z
.array(z.enum(["issue.created", "deploy.marker", "webhook.test"])) .array(
z.enum([
"issue.created",
"deploy.marker",
"webhook.test",
"job.run",
"document.created",
"document.updated",
"document.deleted",
"object.created",
"object.updated",
"object.deleted",
]),
)
.describe("Event filter — empty means every event"); .describe("Event filter — empty means every event");
server.registerTool( server.registerTool(
@@ -732,4 +746,138 @@ server.registerTool(
}, },
); );
/* ---------------------------------------------------------- */
/* automation — scheduled jobs */
/* ---------------------------------------------------------- */
const jobRef = z.string().describe("Scheduled job id (see primora_list_jobs)");
server.registerTool(
"primora_list_jobs",
{
description: "List scheduled jobs for a project — cron schedule, target URL, last/next run",
inputSchema: { projectId: z.string().optional() },
},
async ({ projectId: p }) => {
try {
return ok(await AutomationService.listScheduledJobs({ projectId: projectId(p) }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_job",
{
description:
"Create a scheduled job — cron expression or @descriptor, signed POST to url. Omit secret to generate one (returned once)",
inputSchema: {
name: z.string(),
schedule: z
.string()
.describe('Cron (5-field, e.g. "*/15 * * * *") or descriptor: @hourly, @daily, @every 30m'),
url: z.string().describe("HTTPS endpoint (http allowed only for private/self-hosted targets)"),
secret: z.string().optional(),
payload: z.record(z.unknown()).optional().describe("Static JSON payload merged into every delivery"),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ name, schedule, url, secret, payload, enabled, projectId: p }) => {
try {
return ok(
await AutomationService.createScheduledJob({
projectId: projectId(p),
requestBody: { name, schedule, url, secret, payload, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_update_job",
{
description: "Update a scheduled job — name, schedule, url, payload, enabled flag, or rotate the secret",
inputSchema: {
jobId: jobRef,
name: z.string().optional(),
schedule: z.string().optional(),
url: z.string().optional(),
secret: z.string().optional(),
payload: z.record(z.unknown()).optional(),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ jobId, name, schedule, url, secret, payload, enabled, projectId: p }) => {
try {
return ok(
await AutomationService.updateScheduledJob({
projectId: projectId(p),
jobId,
requestBody: { name, schedule, url, secret, payload, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_delete_job",
{
description: "Delete a scheduled job and stop future runs",
inputSchema: { jobId: jobRef, projectId: z.string().optional() },
},
async ({ jobId, projectId: p }) => {
try {
await AutomationService.deleteScheduledJob({ projectId: projectId(p), jobId });
return ok({ deleted: jobId });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_list_job_runs",
{
description: "Run history for a scheduled job — status, HTTP code, duration, errors",
inputSchema: {
jobId: jobRef,
limit: z.number().int().optional(),
projectId: z.string().optional(),
},
},
async ({ jobId, limit, projectId: p }) => {
try {
return ok(
await AutomationService.listScheduledJobRuns({ projectId: projectId(p), jobId, limit }),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_run_job",
{
description: "Trigger a manual run of a scheduled job now",
inputSchema: { jobId: jobRef, projectId: z.string().optional() },
},
async ({ jobId, projectId: p }) => {
try {
return ok(await AutomationService.runScheduledJob({ projectId: projectId(p), jobId }));
} catch (e) {
return fail(e);
}
},
);
await server.connect(new StdioServerTransport()); await server.connect(new StdioServerTransport());
+28
View File
@@ -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
+41 -18
View File
@@ -14,21 +14,22 @@ 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:
image: ghcr.io/dvorinka/primora-backend:latest
build: build:
context: . context: .
dockerfile: apps/backend/Dockerfile dockerfile: apps/backend/Dockerfile
@@ -47,9 +48,16 @@ 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:
image: ghcr.io/dvorinka/primora-auth:latest
build: build:
context: . context: .
dockerfile: apps/auth/Dockerfile dockerfile: apps/auth/Dockerfile
@@ -58,21 +66,24 @@ services:
environment: environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
DRAGONFLY_URL: redis://dragonfly:6379/0 DRAGONFLY_URL: redis://dragonfly:6379/0
BETTER_AUTH_URL: http://localhost/auth BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost/auth}
AUTH_BASE_URL: http://localhost/auth AUTH_BASE_URL: ${AUTH_BASE_URL:-http://localhost/auth}
VITE_APP_URL: http://localhost VITE_APP_URL: ${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:
image: ghcr.io/dvorinka/primora-frontend:latest
build: build:
context: . context: .
dockerfile: apps/frontend/Dockerfile dockerfile: apps/frontend/Dockerfile
@@ -88,19 +99,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
View File
@@ -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;
+12
View File
@@ -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;
}
Executable
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Primora one-line installer.
#
# curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
#
# Installs into ./primora (override with PRIMORA_DIR). Non-interactive:
# DOMAIN=example.com NGINX_PORT=8080 bash install.sh
#
# Pulls prebuilt GHCR images when available; otherwise downloads the source
# tarball and builds locally. Either way the result is the same compose stack.
set -euo pipefail
REPO="Dvorinka/Primora"
REF="${PRIMORA_REF:-master}"
DIR="${PRIMORA_DIR:-primora}"
DOMAIN="${DOMAIN:-localhost}"
NGINX_PORT="${NGINX_PORT:-80}"
info() { echo "==> $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 || die "docker is not installed"
docker compose version >/dev/null 2>&1 || die "docker compose plugin is not installed"
command -v curl >/dev/null 2>&1 || die "curl is not installed"
mkdir -p "$DIR/infra/nginx"
cd "$DIR"
# The compose stack needs exactly these three files from the repo.
info "Fetching compose files ($REF)"
for f in docker-compose.yml .env.example infra/nginx/default.conf; do
curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/$f" -o "$f"
done
if [ ! -f .env ]; then
info "Generating .env with random secrets"
cp .env.example .env
JWT_SECRET=$(openssl rand -base64 32)
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 16)
# '|' delimiter: base64 output can contain '/', '+', '='.
SED=(sed -i)
[[ "$OSTYPE" == darwin* ]] && SED=(sed -i '')
"${SED[@]}" "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$DB_PASSWORD|" .env
"${SED[@]}" "s|postgres://primora:primora@|postgres://primora:$DB_PASSWORD@|" .env
"${SED[@]}" "s|^JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env
"${SED[@]}" "s|^BETTER_AUTH_SECRET=.*|BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET|" .env
"${SED[@]}" "s|^PRIMORA_ENCRYPTION_KEY=.*|PRIMORA_ENCRYPTION_KEY=$ENCRYPTION_KEY|" .env
"${SED[@]}" "s|^NGINX_PORT=.*|NGINX_PORT=$NGINX_PORT|" .env
if [ "$DOMAIN" != "localhost" ]; then
"${SED[@]}" "s|http://localhost|http://$DOMAIN|g" .env
"${SED[@]}" "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=$DOMAIN|" .env
fi
else
info "Existing .env kept"
fi
# Compose v5 skips `pull` for services that declare build:, so fetch the
# prebuilt images directly. --no-build keeps `up` from rebuilding them.
GHCR_OWNER=$(echo "$REPO" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
if docker pull "ghcr.io/$GHCR_OWNER/primora-backend:latest" \
&& docker pull "ghcr.io/$GHCR_OWNER/primora-auth:latest" \
&& docker pull "ghcr.io/$GHCR_OWNER/primora-frontend:latest"; then
info "Starting prebuilt images"
docker compose up -d --no-build
else
# GHCR packages are private until first published release — build from source.
info "Prebuilt images unavailable; building from source"
curl -fsSL "https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" | tar xz --strip-components=1
docker compose up -d --build
fi
DISPLAY_URL="http://$DOMAIN"
[ "$NGINX_PORT" != "80" ] && DISPLAY_URL="$DISPLAY_URL:$NGINX_PORT"
info "Primora is starting"
echo " Dashboard: $DISPLAY_URL"
echo " Health: $DISPLAY_URL/api/v1/health/liveness"
echo " Logs: cd $DIR && docker compose logs -f"
echo " Stop: cd $DIR && docker compose down"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "primora", "name": "primora",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"packageManager": "[email protected]", "packageManager": "[email protected]",
"workspaces": [ "workspaces": [
@@ -38,6 +38,7 @@ export { CreateIntegrationRequest } from './models/CreateIntegrationRequest';
export { CreateInvitationRequest } from './models/CreateInvitationRequest'; export { CreateInvitationRequest } from './models/CreateInvitationRequest';
export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest'; export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest';
export type { CreateProjectRequest } from './models/CreateProjectRequest'; export type { CreateProjectRequest } from './models/CreateProjectRequest';
export type { CreateScheduledJobRequest } from './models/CreateScheduledJobRequest';
export type { CreateWebhookRequest } from './models/CreateWebhookRequest'; export type { CreateWebhookRequest } from './models/CreateWebhookRequest';
export type { DatabaseQuery } from './models/DatabaseQuery'; export type { DatabaseQuery } from './models/DatabaseQuery';
export type { DBConnection } from './models/DBConnection'; export type { DBConnection } from './models/DBConnection';
@@ -55,6 +56,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';
@@ -73,6 +76,7 @@ export type { IntegrationListResponse } from './models/IntegrationListResponse';
export type { IntegrationTestResult } from './models/IntegrationTestResult'; export type { IntegrationTestResult } from './models/IntegrationTestResult';
export type { Invitation } from './models/Invitation'; export type { Invitation } from './models/Invitation';
export type { InvitationID } from './models/InvitationID'; export type { InvitationID } from './models/InvitationID';
export type { JobID } from './models/JobID';
export type { MeResponse } from './models/MeResponse'; export type { MeResponse } from './models/MeResponse';
export type { MetricPoint } from './models/MetricPoint'; export type { MetricPoint } from './models/MetricPoint';
export type { MetricSeriesResponse } from './models/MetricSeriesResponse'; export type { MetricSeriesResponse } from './models/MetricSeriesResponse';
@@ -93,6 +97,11 @@ export type { ProjectSummary } from './models/ProjectSummary';
export type { ReadinessStatus } from './models/ReadinessStatus'; export type { ReadinessStatus } from './models/ReadinessStatus';
export type { RybbitMetricItem } from './models/RybbitMetricItem'; export type { RybbitMetricItem } from './models/RybbitMetricItem';
export type { RybbitSite } from './models/RybbitSite'; export type { RybbitSite } from './models/RybbitSite';
export { ScheduledJob } from './models/ScheduledJob';
export type { ScheduledJobCreateResponse } from './models/ScheduledJobCreateResponse';
export type { ScheduledJobListResponse } from './models/ScheduledJobListResponse';
export { ScheduledJobRun } from './models/ScheduledJobRun';
export type { ScheduledJobRunListResponse } from './models/ScheduledJobRunListResponse';
export type { SchemaQuery } from './models/SchemaQuery'; export type { SchemaQuery } from './models/SchemaQuery';
export type { TableQuery } from './models/TableQuery'; export type { TableQuery } from './models/TableQuery';
export type { TelemetryComponent } from './models/TelemetryComponent'; export type { TelemetryComponent } from './models/TelemetryComponent';
@@ -110,6 +119,7 @@ export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganization
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest'; export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest'; export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest';
export type { UpdateProjectRequest } from './models/UpdateProjectRequest'; export type { UpdateProjectRequest } from './models/UpdateProjectRequest';
export type { UpdateScheduledJobRequest } from './models/UpdateScheduledJobRequest';
export type { UpdateWebhookRequest } from './models/UpdateWebhookRequest'; export type { UpdateWebhookRequest } from './models/UpdateWebhookRequest';
export type { UserID } from './models/UserID'; export type { UserID } from './models/UserID';
export type { UserSummary } from './models/UserSummary'; export type { UserSummary } from './models/UserSummary';
@@ -151,6 +161,7 @@ export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest';
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest'; export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest'; export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest';
export { $CreateProjectRequest } from './schemas/$CreateProjectRequest'; export { $CreateProjectRequest } from './schemas/$CreateProjectRequest';
export { $CreateScheduledJobRequest } from './schemas/$CreateScheduledJobRequest';
export { $CreateWebhookRequest } from './schemas/$CreateWebhookRequest'; export { $CreateWebhookRequest } from './schemas/$CreateWebhookRequest';
export { $DatabaseQuery } from './schemas/$DatabaseQuery'; export { $DatabaseQuery } from './schemas/$DatabaseQuery';
export { $DBConnection } from './schemas/$DBConnection'; export { $DBConnection } from './schemas/$DBConnection';
@@ -168,6 +179,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';
@@ -186,6 +199,7 @@ export { $IntegrationListResponse } from './schemas/$IntegrationListResponse';
export { $IntegrationTestResult } from './schemas/$IntegrationTestResult'; export { $IntegrationTestResult } from './schemas/$IntegrationTestResult';
export { $Invitation } from './schemas/$Invitation'; export { $Invitation } from './schemas/$Invitation';
export { $InvitationID } from './schemas/$InvitationID'; export { $InvitationID } from './schemas/$InvitationID';
export { $JobID } from './schemas/$JobID';
export { $MeResponse } from './schemas/$MeResponse'; export { $MeResponse } from './schemas/$MeResponse';
export { $MetricPoint } from './schemas/$MetricPoint'; export { $MetricPoint } from './schemas/$MetricPoint';
export { $MetricSeriesResponse } from './schemas/$MetricSeriesResponse'; export { $MetricSeriesResponse } from './schemas/$MetricSeriesResponse';
@@ -206,6 +220,11 @@ export { $ProjectSummary } from './schemas/$ProjectSummary';
export { $ReadinessStatus } from './schemas/$ReadinessStatus'; export { $ReadinessStatus } from './schemas/$ReadinessStatus';
export { $RybbitMetricItem } from './schemas/$RybbitMetricItem'; export { $RybbitMetricItem } from './schemas/$RybbitMetricItem';
export { $RybbitSite } from './schemas/$RybbitSite'; export { $RybbitSite } from './schemas/$RybbitSite';
export { $ScheduledJob } from './schemas/$ScheduledJob';
export { $ScheduledJobCreateResponse } from './schemas/$ScheduledJobCreateResponse';
export { $ScheduledJobListResponse } from './schemas/$ScheduledJobListResponse';
export { $ScheduledJobRun } from './schemas/$ScheduledJobRun';
export { $ScheduledJobRunListResponse } from './schemas/$ScheduledJobRunListResponse';
export { $SchemaQuery } from './schemas/$SchemaQuery'; export { $SchemaQuery } from './schemas/$SchemaQuery';
export { $TableQuery } from './schemas/$TableQuery'; export { $TableQuery } from './schemas/$TableQuery';
export { $TelemetryComponent } from './schemas/$TelemetryComponent'; export { $TelemetryComponent } from './schemas/$TelemetryComponent';
@@ -223,6 +242,7 @@ export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizat
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest'; export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest'; export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest';
export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest'; export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest';
export { $UpdateScheduledJobRequest } from './schemas/$UpdateScheduledJobRequest';
export { $UpdateWebhookRequest } from './schemas/$UpdateWebhookRequest'; export { $UpdateWebhookRequest } from './schemas/$UpdateWebhookRequest';
export { $UserID } from './schemas/$UserID'; export { $UserID } from './schemas/$UserID';
export { $UserSummary } from './schemas/$UserSummary'; export { $UserSummary } from './schemas/$UserSummary';
@@ -233,6 +253,7 @@ export { $WebhookDeliveryListResponse } from './schemas/$WebhookDeliveryListResp
export { $WebhookID } from './schemas/$WebhookID'; export { $WebhookID } from './schemas/$WebhookID';
export { $WebhookListResponse } from './schemas/$WebhookListResponse'; export { $WebhookListResponse } from './schemas/$WebhookListResponse';
export { AutomationService } from './services/AutomationService';
export { CollectionsService } from './services/CollectionsService'; export { CollectionsService } from './services/CollectionsService';
export { DatabasesService } from './services/DatabasesService'; export { DatabasesService } from './services/DatabasesService';
export { HealthService } from './services/HealthService'; export { HealthService } from './services/HealthService';
@@ -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'>;
}; };
@@ -0,0 +1,22 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreateScheduledJobRequest = {
name: string;
/**
* Cron expression, e.g. "*15 * * * *", or descriptor like "@every 1h".
*/
schedule: string;
url: string;
/**
* HMAC key for X-Primora-Signature. Generated when omitted.
*/
secret?: string;
/**
* Static JSON payload delivered as data on every run.
*/
payload?: Record<string, any>;
enabled?: boolean;
};
@@ -8,7 +8,7 @@ export type CreateWebhookRequest = {
* HMAC key for X-Primora-Signature. Generated when omitted. * HMAC key for X-Primora-Signature. Generated when omitted.
*/ */
secret?: string; secret?: string;
events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test'>; events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>;
enabled?: boolean; enabled?: boolean;
}; };
@@ -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;
};
@@ -0,0 +1,5 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type JobID = 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;
}; };
@@ -0,0 +1,31 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ScheduledJob = {
id: string;
project_id: string;
name: string;
/**
* Cron expression (5-field) or descriptor (@hourly, @every 30m).
*/
schedule: string;
url: string;
/**
* Static JSON payload merged into every delivery's data field.
*/
payload: Record<string, any>;
enabled: boolean;
has_secret: boolean;
last_run_at?: string | null;
last_status?: ScheduledJob.last_status | null;
next_run_at?: string | null;
created_at?: string;
};
export namespace ScheduledJob {
export enum last_status {
SUCCESS = 'success',
FAILED = 'failed',
}
}
@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ScheduledJob } from './ScheduledJob';
export type ScheduledJobCreateResponse = (ScheduledJob & {
/**
* Plaintext secret — present only when Primora generated it. Store it; it cannot be recovered.
*/
secret?: string;
});
@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ScheduledJob } from './ScheduledJob';
export type ScheduledJobListResponse = {
items: Array<ScheduledJob>;
};
@@ -0,0 +1,27 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ScheduledJobRun = {
id: string;
job_id: string;
status: ScheduledJobRun.status;
triggered_by: ScheduledJobRun.triggered_by;
status_code?: number | null;
error?: string;
duration_ms?: number | null;
started_at?: string;
finished_at?: string | null;
};
export namespace ScheduledJobRun {
export enum status {
RUNNING = 'running',
SUCCESS = 'success',
FAILED = 'failed',
}
export enum triggered_by {
SCHEDULE = 'schedule',
MANUAL = 'manual',
}
}
@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ScheduledJobRun } from './ScheduledJobRun';
export type ScheduledJobRunListResponse = {
items: Array<ScheduledJobRun>;
};
@@ -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;
}; };
@@ -0,0 +1,16 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdateScheduledJobRequest = {
name?: string;
schedule?: string;
url?: string;
/**
* Replace the signing secret. Omit to keep the current one.
*/
secret?: string;
payload?: Record<string, any>;
enabled?: boolean;
};
@@ -8,7 +8,7 @@ export type UpdateWebhookRequest = {
* Replace the signing secret. Omit to keep the current one. * Replace the signing secret. Omit to keep the current one.
*/ */
secret?: string; secret?: string;
events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test'>; events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>;
enabled?: boolean; enabled?: boolean;
}; };
@@ -7,7 +7,7 @@ export type Webhook = {
project_id: string; project_id: string;
url: string; url: string;
/** /**
* Event filter (issue.created, deploy.marker, webhook.test). Empty means all events. * Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events.
*/ */
events: Array<string>; events: Array<string>;
enabled: boolean; enabled: boolean;
@@ -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;
@@ -0,0 +1,37 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $CreateScheduledJobRequest = {
properties: {
name: {
type: 'string',
isRequired: true,
minLength: 2,
},
schedule: {
type: 'string',
description: `Cron expression, e.g. "*/15 * * * *", or descriptor like "@every 1h".`,
isRequired: true,
},
url: {
type: 'string',
isRequired: true,
format: 'uri',
},
secret: {
type: 'string',
description: `HMAC key for X-Primora-Signature. Generated when omitted.`,
},
payload: {
type: 'dictionary',
contains: {
properties: {
},
},
},
enabled: {
type: 'boolean',
},
},
} 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;
@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $JobID = {
type: 'string',
format: 'uuid',
} 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;
@@ -0,0 +1,65 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ScheduledJob = {
properties: {
id: {
type: 'string',
isRequired: true,
format: 'uuid',
},
project_id: {
type: 'string',
isRequired: true,
format: 'uuid',
},
name: {
type: 'string',
isRequired: true,
},
schedule: {
type: 'string',
description: `Cron expression (5-field) or descriptor (@hourly, @every 30m).`,
isRequired: true,
},
url: {
type: 'string',
isRequired: true,
},
payload: {
type: 'dictionary',
contains: {
properties: {
},
},
isRequired: true,
},
enabled: {
type: 'boolean',
isRequired: true,
},
has_secret: {
type: 'boolean',
isRequired: true,
},
last_run_at: {
type: 'string',
isNullable: true,
format: 'date-time',
},
last_status: {
type: 'Enum',
isNullable: true,
},
next_run_at: {
type: 'string',
isNullable: true,
format: 'date-time',
},
created_at: {
type: 'string',
format: 'date-time',
},
},
} as const;
@@ -0,0 +1,17 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ScheduledJobCreateResponse = {
type: 'all-of',
contains: [{
type: 'ScheduledJob',
}, {
properties: {
secret: {
type: 'string',
description: `Plaintext secret — present only when Primora generated it. Store it; it cannot be recovered.`,
},
},
}],
} as const;
@@ -0,0 +1,15 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ScheduledJobListResponse = {
properties: {
items: {
type: 'array',
contains: {
type: 'ScheduledJob',
},
isRequired: true,
},
},
} as const;
@@ -0,0 +1,46 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ScheduledJobRun = {
properties: {
id: {
type: 'string',
isRequired: true,
format: 'uuid',
},
job_id: {
type: 'string',
isRequired: true,
format: 'uuid',
},
status: {
type: 'Enum',
isRequired: true,
},
triggered_by: {
type: 'Enum',
isRequired: true,
},
status_code: {
type: 'number',
isNullable: true,
},
error: {
type: 'string',
},
duration_ms: {
type: 'number',
isNullable: true,
},
started_at: {
type: 'string',
format: 'date-time',
},
finished_at: {
type: 'string',
isNullable: true,
format: 'date-time',
},
},
} as const;
@@ -0,0 +1,15 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ScheduledJobRunListResponse = {
properties: {
items: {
type: 'array',
contains: {
type: 'ScheduledJobRun',
},
isRequired: true,
},
},
} 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;
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more