mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5af23fbdc8 | ||
|
|
bac8f08782 | ||
|
|
f9cd641164 | ||
|
|
c3321b86ae | ||
|
|
00bf725ba7 | ||
|
|
ec91deed46 | ||
|
|
fede14b24f | ||
|
|
d39fc22594 | ||
|
|
fc79c57f7e | ||
|
|
fe28902c4a | ||
|
|
438ee46016 | ||
|
|
1494bedbd5 | ||
|
|
a215da452a | ||
|
|
9e9a3d5f98 | ||
|
|
4b7fdf35ad | ||
|
|
fffac52295 | ||
|
|
55edbf8b56 | ||
|
|
1b34dd43bb | ||
|
|
61b29754d5 | ||
|
|
88636e2c66 |
No files matched your search
@@ -7,3 +7,12 @@ coverage
|
|||||||
.env
|
.env
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|
||||||
|
# build artifacts + local data — 4.5G+ of Rust target dir otherwise
|
||||||
|
**/target
|
||||||
|
apps/desktop/src-tauri/gen
|
||||||
|
backups
|
||||||
|
tmp
|
||||||
|
**/tmp
|
||||||
|
graphify-out
|
||||||
|
.playwright-mcp
|
||||||
|
|
||||||
+157
-18
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Primora Deployment Guide
|
||||||
|
|
||||||
|
Primora ships as a Docker Compose stack: nginx (edge), frontend (static SPA),
|
||||||
|
auth (Better Auth / Hono), backend (Go API), postgres, and dragonfly
|
||||||
|
(Redis-compatible cache). The base `docker-compose.yml` is the production
|
||||||
|
surface — dev-only services like Mailpit live in `docker-compose.dev.yml`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine 24+ with the Compose plugin
|
||||||
|
- A host with 2 GB free RAM minimum (postgres + dragonfly + services)
|
||||||
|
- A domain pointing at the host if you want TLS
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> && cd Primora
|
||||||
|
./scripts/setup.sh # writes .env with generated secrets
|
||||||
|
docker compose up -d # production surface only
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development (includes Mailpit at `/mailpit/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup.sh` generates `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
|
||||||
|
`PRIMORA_ENCRYPTION_KEY`. Do not reuse the example values anywhere public.
|
||||||
|
|
||||||
|
## Environment reference
|
||||||
|
|
||||||
|
| Variable | Purpose | Required |
|
||||||
|
|---|---|---|
|
||||||
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | database credentials | yes |
|
||||||
|
| `DATABASE_URL` | backend → postgres DSN | yes |
|
||||||
|
| `DRAGONFLY_URL` | backend → cache DSN | yes |
|
||||||
|
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
|
||||||
|
| `BETTER_AUTH_SECRET` | auth session/signing secret | yes |
|
||||||
|
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for integration credentials + webhook secrets at rest | production |
|
||||||
|
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
||||||
|
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
||||||
|
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
||||||
|
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes |
|
||||||
|
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
||||||
|
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
||||||
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
||||||
|
| `RESEND_API_KEY` | alternative to SMTP — Resend API | no |
|
||||||
|
| `*_CLIENT_ID` / `*_CLIENT_SECRET` | GitHub / Google / Discord / Microsoft OAuth | no |
|
||||||
|
| `NGINX_PORT` | host port for the edge (default 80) | no |
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
Primora does not terminate TLS itself — put a reverse proxy in front of
|
||||||
|
nginx's port 80. The nginx config sets `X-Forwarded-*`-aware headers; pass
|
||||||
|
them through.
|
||||||
|
|
||||||
|
**Caddy** (`Caddyfile`):
|
||||||
|
|
||||||
|
```
|
||||||
|
primora.example.com {
|
||||||
|
reverse_proxy localhost:80
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Traefik** (compose labels on the nginx service):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.primora.rule=Host(`primora.example.com`)
|
||||||
|
- traefik.http.routers.primora.entrypoints=websecure
|
||||||
|
- traefik.http.routers.primora.tls.certresolver=letsencrypt
|
||||||
|
```
|
||||||
|
|
||||||
|
Once TLS is live, update `VITE_APP_URL`, `VITE_AUTH_BASE_URL`,
|
||||||
|
`AUTH_BASE_URL`, `BETTER_AUTH_URL`, and `COOKIE_DOMAIN` to the https origin
|
||||||
|
and rebuild the frontend image (`docker compose build frontend`).
|
||||||
|
|
||||||
|
## SMTP / email
|
||||||
|
|
||||||
|
Auth sends verification and password-reset email through SMTP
|
||||||
|
(`SMTP_*` + `MAIL_FROM`) or Resend (`RESEND_API_KEY`). In development the
|
||||||
|
`docker-compose.dev.yml` overlay runs Mailpit; mail is captured at
|
||||||
|
`http://localhost/mailpit/` and nothing leaves the host. **The production
|
||||||
|
compose file contains no mail sink — configure real SMTP before inviting
|
||||||
|
users.**
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
`scripts/backup.sh` produces a tarball with a `pg_dump -Fc` of the database
|
||||||
|
and a tar of the `backend_storage` volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh # → backups/primora-<stamp>.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore (destructive — stops writes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop backend auth
|
||||||
|
./scripts/restore.sh backups/primora-<stamp>.tar.gz
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Cron example (nightly, keep 14):
|
||||||
|
|
||||||
|
```cron
|
||||||
|
17 3 * * * cd /opt/primora && ./scripts/backup.sh && \
|
||||||
|
ls -1t backups/primora-*.tar.gz | tail -n +15 | xargs -r rm
|
||||||
|
```
|
||||||
|
|
||||||
|
systemd timer example is in `PRODUCTION_READINESS.md`.
|
||||||
|
|
||||||
|
## Upgrades
|
||||||
|
|
||||||
|
Migrations run automatically on backend boot (goose). Safe order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh # 1. snapshot first
|
||||||
|
docker compose pull && docker compose build # 2. new images
|
||||||
|
docker compose up -d # 3. rolling recreate; backend migrates on boot
|
||||||
|
./scripts/smoke-e2e.sh # 4. verify health, auth, ingest
|
||||||
|
```
|
||||||
|
|
||||||
|
`goose` migrations include `Down` sections; `goose -dir apps/backend/db/migrations postgres "$DATABASE_URL" down` rolls back one version if a release misbehaves.
|
||||||
|
|
||||||
|
## Storage constraint
|
||||||
|
|
||||||
|
Object storage is the **local filesystem** (`backend_storage` volume). There
|
||||||
|
is no S3-compatible backend yet — the volume must be on durable, backed-up
|
||||||
|
disk, and a single host serves all objects. Plan capacity accordingly;
|
||||||
|
multi-node deployments need an external object store first.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
- `GET /api/v1/metrics` — Prometheus exposition (unauthenticated; restrict at
|
||||||
|
the edge if the instance is public — see the commented rule in
|
||||||
|
`infra/nginx/default.conf`).
|
||||||
|
- `GET /api/v1/health/liveness` / `readiness` — compose healthchecks use the
|
||||||
|
same endpoints.
|
||||||
|
- Structured JSON logs on stdout for every service — ship with your normal
|
||||||
|
docker log driver.
|
||||||
|
|
||||||
|
## API key scopes
|
||||||
|
|
||||||
|
Keys are created with scopes: `ingest`, `read`, `write`, `admin`.
|
||||||
|
`admin` unlocks everything; `write` unlocks read + ingest + non-admin
|
||||||
|
mutations; `read`/`ingest` are single-purpose. Keys without stored scopes
|
||||||
|
(pre-scope keys) behave as `admin` for backwards compatibility — rotate them
|
||||||
|
into scoped keys at your leisure.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Primora Production Readiness
|
||||||
|
|
||||||
|
Status checklist for self-hosted production use. Everything listed under
|
||||||
|
"Hardened" is implemented and verified; "Known limits" is the honest
|
||||||
|
remainder.
|
||||||
|
|
||||||
|
## Hardened
|
||||||
|
|
||||||
|
- **Graceful shutdown** — the backend drains in-flight requests for up to
|
||||||
|
10 s on SIGTERM/SIGINT (`internal/app`).
|
||||||
|
- **Scoped API keys** — `ingest` / `read` / `write` / `admin`, enforced in
|
||||||
|
auth middleware before handlers run. Legacy keys without stored scopes act
|
||||||
|
as `admin`.
|
||||||
|
- **Brute-force protection** — Better Auth rate limiting is enabled
|
||||||
|
unconditionally (3 attempts / 10 s on sign-in, sign-up, password change);
|
||||||
|
an IP-level limiter caps `/auth/*` at 60 req/min, and per-identity API
|
||||||
|
rate limits apply via `USER_RATE_LIMIT_PER_MINUTE` /
|
||||||
|
`API_KEY_RATE_LIMIT_PER_MINUTE` in Dragonfly.
|
||||||
|
- **Metrics** — `GET /api/v1/metrics` in Prometheus text format.
|
||||||
|
- **Retention** — per-project sweeper for telemetry events, audit log rows,
|
||||||
|
and webhook deliveries (`retention_*_days`, 0 = disabled, default 90).
|
||||||
|
- **Edge security** — nginx emits `X-Frame-Options`, `X-Content-Type-Options`,
|
||||||
|
`Referrer-Policy`, `Permissions-Policy`, and a CSP scoped to the SPA only.
|
||||||
|
- **Compose hygiene** — all services have healthchecks and
|
||||||
|
`restart: unless-stopped`; Dragonfly is pinned to `v2.0.0`; Mailpit lives
|
||||||
|
only in `docker-compose.dev.yml`.
|
||||||
|
- **Secrets** — `setup.sh` generates `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
|
||||||
|
`PRIMORA_ENCRYPTION_KEY`; credentials and webhook secrets are AES-256
|
||||||
|
encrypted at rest.
|
||||||
|
- **Release images** — GitHub release builds `linux/amd64` + `linux/arm64`
|
||||||
|
via buildx/QEMU.
|
||||||
|
- **CI coverage** — Go tests, frontend tests + typecheck, all three Docker
|
||||||
|
builds, golangci-lint, migration up/reset/up round-trip, and a compose E2E
|
||||||
|
smoke (`scripts/smoke-e2e.sh`).
|
||||||
|
- **Backups** — `scripts/backup.sh` / `scripts/restore.sh` cover the database
|
||||||
|
(custom-format dump) and the storage volume.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- **Storage is local filesystem only.** No S3 backend — the
|
||||||
|
`backend_storage` volume is the durability boundary.
|
||||||
|
- **Single host.** Compose topology is single-node; postgres and dragonfly
|
||||||
|
are not clustered. HA is out of scope for v0.x.
|
||||||
|
- **No TLS inside the stack.** Terminate at a front proxy (Caddy/Traefik);
|
||||||
|
see DEPLOYMENT_GUIDE.md.
|
||||||
|
- **Metrics are in-memory counters.** `/api/v1/metrics` covers request
|
||||||
|
totals/errors/duration — not per-route histograms or DB pool stats yet.
|
||||||
|
- **`/api/v1/metrics` is unauthenticated.** Scrape-only design; block it at
|
||||||
|
the edge for public instances (commented rule in `infra/nginx/default.conf`).
|
||||||
|
|
||||||
|
## systemd timer for backups
|
||||||
|
|
||||||
|
`/etc/systemd/system/primora-backup.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Primora backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/primora
|
||||||
|
ExecStart=/opt/primora/scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/primora-backup.timer`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Nightly Primora backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:17:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/verify-production-ready.sh # structural check
|
||||||
|
./scripts/smoke-e2e.sh # live E2E against a running stack
|
||||||
|
npm run check # typecheck + tests + generated-code drift
|
||||||
|
```
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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
|
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -28,7 +28,10 @@ RETURNING *;
|
|||||||
UPDATE core.projects
|
UPDATE core.projects
|
||||||
SET slug = $2,
|
SET slug = $2,
|
||||||
name = $3,
|
name = $3,
|
||||||
description = $4
|
description = $4,
|
||||||
|
retention_events_days = COALESCE(sqlc.narg('retention_events_days'), retention_events_days),
|
||||||
|
retention_audit_days = COALESCE(sqlc.narg('retention_audit_days'), retention_audit_days),
|
||||||
|
retention_webhook_days = COALESCE(sqlc.narg('retention_webhook_days'), retention_webhook_days)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Per-project retention sweeps. A retention of 0 days disables the sweep for
|
||||||
|
-- that stream. Pending webhook deliveries are never swept — they may still be
|
||||||
|
-- retrying.
|
||||||
|
|
||||||
|
-- name: SweepExpiredEvents :execrows
|
||||||
|
DELETE FROM core.events e
|
||||||
|
USING core.projects p
|
||||||
|
WHERE e.project_id = p.id
|
||||||
|
AND p.retention_events_days > 0
|
||||||
|
AND e.ts < NOW() - make_interval(days => p.retention_events_days);
|
||||||
|
|
||||||
|
-- name: SweepExpiredAuditLogs :execrows
|
||||||
|
DELETE FROM core.audit_logs a
|
||||||
|
USING core.projects p
|
||||||
|
WHERE a.project_id = p.id
|
||||||
|
AND p.retention_audit_days > 0
|
||||||
|
AND a.created_at < NOW() - make_interval(days => p.retention_audit_days);
|
||||||
|
|
||||||
|
-- name: SweepExpiredOrgAuditLogs :execrows
|
||||||
|
-- Org-scoped audit rows (project_id NULL) have no project retention — sweep
|
||||||
|
-- them on a fixed 365-day floor so they cannot grow unbounded.
|
||||||
|
DELETE FROM core.audit_logs a
|
||||||
|
WHERE a.project_id IS NULL
|
||||||
|
AND a.created_at < NOW() - INTERVAL '365 days';
|
||||||
|
|
||||||
|
-- name: SweepExpiredWebhookDeliveries :execrows
|
||||||
|
DELETE FROM core.webhook_deliveries d
|
||||||
|
USING core.webhooks w
|
||||||
|
JOIN core.projects p ON p.id = w.project_id
|
||||||
|
WHERE d.webhook_id = w.id
|
||||||
|
AND p.retention_webhook_days > 0
|
||||||
|
AND d.status <> 'pending'
|
||||||
|
AND d.created_at < NOW() - make_interval(days => p.retention_webhook_days);
|
||||||
@@ -11,7 +11,8 @@ WHERE project_id = $1 AND name = $2;
|
|||||||
-- name: ListComponents :many
|
-- name: ListComponents :many
|
||||||
SELECT * FROM core.components
|
SELECT * FROM core.components
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY name;
|
ORDER BY name
|
||||||
|
LIMIT 500;
|
||||||
|
|
||||||
-- name: DeleteComponent :exec
|
-- name: DeleteComponent :exec
|
||||||
DELETE FROM core.components
|
DELETE FROM core.components
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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, `\`, `\\`), `"`, `\"`) + `"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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]}`);
|
||||||
|
|||||||
@@ -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,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,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"
|
||||||
|
|||||||
@@ -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 }))}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,16 +11,22 @@ interface ProjectInput {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectEditInput extends ProjectInput {
|
||||||
|
retentionEventsDays: string;
|
||||||
|
retentionAuditDays: string;
|
||||||
|
retentionWebhookDays: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectsPageProps {
|
interface ProjectsPageProps {
|
||||||
projects: ProjectSummary[];
|
projects: ProjectSummary[];
|
||||||
selectedProjectID?: string;
|
selectedProjectID?: string;
|
||||||
projectInput: ProjectInput;
|
projectInput: ProjectInput;
|
||||||
projectEditInput: ProjectInput;
|
projectEditInput: ProjectEditInput;
|
||||||
projectMessage: string;
|
projectMessage: string;
|
||||||
projectPending: boolean;
|
projectPending: boolean;
|
||||||
canUpdateProject: boolean;
|
canUpdateProject: boolean;
|
||||||
onProjectInputChange: (field: keyof ProjectInput, value: string) => void;
|
onProjectInputChange: (field: keyof ProjectInput, value: string) => void;
|
||||||
onProjectEditInputChange: (field: keyof ProjectInput, value: string) => void;
|
onProjectEditInputChange: (field: keyof ProjectEditInput, value: string) => void;
|
||||||
onCreateProject: (event: SubmitEvent) => void;
|
onCreateProject: (event: SubmitEvent) => void;
|
||||||
onUpdateProject: (event: SubmitEvent) => void;
|
onUpdateProject: (event: SubmitEvent) => void;
|
||||||
onDeleteProject: () => void;
|
onDeleteProject: () => void;
|
||||||
@@ -252,6 +258,38 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
|||||||
value={props.projectEditInput.description}
|
value={props.projectEditInput.description}
|
||||||
onInput={(e) => props.onProjectEditInputChange("description", e.currentTarget.value)}
|
onInput={(e) => props.onProjectEditInputChange("description", e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="label">Data retention (days)</span>
|
||||||
|
<p class="label-hint mb-2">
|
||||||
|
Rows older than this are swept hourly. 0 disables a category. Max 3650.
|
||||||
|
</p>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Input
|
||||||
|
label="Telemetry events"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={3650}
|
||||||
|
value={props.projectEditInput.retentionEventsDays}
|
||||||
|
onInput={(e) => props.onProjectEditInputChange("retentionEventsDays", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Audit log"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={3650}
|
||||||
|
value={props.projectEditInput.retentionAuditDays}
|
||||||
|
onInput={(e) => props.onProjectEditInputChange("retentionAuditDays", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Webhook deliveries"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={3650}
|
||||||
|
value={props.projectEditInput.retentionWebhookDays}
|
||||||
|
onInput={(e) => props.onProjectEditInputChange("retentionWebhookDays", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<button type="button" class="btn btn-ghost" onClick={() => setSettingsOpen(false)}>
|
<button type="button" class="btn btn-ghost" onClick={() => setSettingsOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -17,9 +17,17 @@ interface OrgInput {
|
|||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const API_KEY_SCOPES: Array<{ value: string; label: string; hint: string }> = [
|
||||||
|
{ value: "ingest", label: "ingest", hint: "telemetry ingest only" },
|
||||||
|
{ value: "read", label: "read", hint: "read endpoints" },
|
||||||
|
{ value: "write", label: "write", hint: "read + ingest + mutations" },
|
||||||
|
{ value: "admin", label: "admin", hint: "full access" },
|
||||||
|
];
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
apiKeys?: ApiKey[];
|
apiKeys?: ApiKey[];
|
||||||
apiKeyName: string;
|
apiKeyName: string;
|
||||||
|
apiKeyScopes: string[];
|
||||||
apiKeySecret: string;
|
apiKeySecret: string;
|
||||||
apiKeyMessage: string;
|
apiKeyMessage: string;
|
||||||
apiKeyPending: boolean;
|
apiKeyPending: boolean;
|
||||||
@@ -30,6 +38,7 @@ interface SettingsPageProps {
|
|||||||
workspacePending: boolean;
|
workspacePending: boolean;
|
||||||
hasActiveOrganization: boolean;
|
hasActiveOrganization: boolean;
|
||||||
onApiKeyNameChange: (name: string) => void;
|
onApiKeyNameChange: (name: string) => void;
|
||||||
|
onApiKeyScopeToggle: (scope: string) => void;
|
||||||
onCreateApiKey: () => void;
|
onCreateApiKey: () => void;
|
||||||
onDeleteApiKey: (id: string) => void;
|
onDeleteApiKey: (id: string) => void;
|
||||||
onOrganizationInputChange: (field: keyof OrgInput, value: string) => void;
|
onOrganizationInputChange: (field: keyof OrgInput, value: string) => void;
|
||||||
@@ -112,12 +121,34 @@ export function SettingsPage(props: SettingsPageProps) {
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
onClick={props.onCreateApiKey}
|
onClick={props.onCreateApiKey}
|
||||||
disabled={props.apiKeyPending || !props.apiKeyName.trim()}
|
disabled={props.apiKeyPending || !props.apiKeyName.trim() || props.apiKeyScopes.length === 0}
|
||||||
>
|
>
|
||||||
<IconPlus class="w-4 h-4" />
|
<IconPlus class="w-4 h-4" />
|
||||||
Create key
|
Create key
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<span class="label">Scopes</span>
|
||||||
|
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||||
|
<For each={API_KEY_SCOPES}>
|
||||||
|
{(scope) => (
|
||||||
|
<label class="flex items-center gap-2 text-sm text-text-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-[var(--accent)]"
|
||||||
|
checked={props.apiKeyScopes.includes(scope.value)}
|
||||||
|
disabled={props.apiKeyScopes.length === 1 && props.apiKeyScopes.includes(scope.value)}
|
||||||
|
onChange={() => props.onApiKeyScopeToggle(scope.value)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<code>{scope.label}</code>
|
||||||
|
<span class="text-text-3"> — {scope.hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-flush">
|
<div class="card card-flush">
|
||||||
@@ -141,6 +172,7 @@ export function SettingsPage(props: SettingsPageProps) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Prefix</th>
|
<th>Prefix</th>
|
||||||
|
<th>Scopes</th>
|
||||||
<th>Last used</th>
|
<th>Last used</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th style="width:1%" />
|
<th style="width:1%" />
|
||||||
@@ -154,6 +186,13 @@ export function SettingsPage(props: SettingsPageProps) {
|
|||||||
<td>
|
<td>
|
||||||
<code>{key.prefix}…</code>
|
<code>{key.prefix}…</code>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<For each={key.scopes && key.scopes.length > 0 ? key.scopes : ["admin"]}>
|
||||||
|
{(scope) => <Badge variant="neutral">{scope}</Badge>}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="text-text-3 text-xs">
|
<td class="text-text-3 text-xs">
|
||||||
{key.last_used_at ? props.formatDate(key.last_used_at) : "Never"}
|
{key.last_used_at ? props.formatDate(key.last_used_at) : "Never"}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
@@ -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
@@ -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
@@ -9,6 +9,16 @@ server {
|
|||||||
# startup-time resolution forever (stale upstream → 502).
|
# startup-time resolution forever (stale upstream → 502).
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
||||||
|
# Baseline security headers on every response, API included.
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), usb=()" always;
|
||||||
|
|
||||||
|
location = /nginx-health {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
set $upstream http://backend:8080;
|
set $upstream http://backend:8080;
|
||||||
proxy_pass $upstream;
|
proxy_pass $upstream;
|
||||||
@@ -40,18 +50,19 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /mailpit/ {
|
# Dev-only location blocks (mailpit) are dropped in here by
|
||||||
set $upstream http://mailpit:8025;
|
# docker-compose.dev.yml. A glob with no matches is legal, so production
|
||||||
rewrite ^/mailpit/?(.*)$ /$1 break;
|
# simply includes nothing.
|
||||||
proxy_pass $upstream;
|
include /etc/nginx/primora-extra/*.conf;
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# Any add_header inside a location replaces ALL inherited add_headers —
|
||||||
|
# so the baseline four must be repeated here next to the SPA-only CSP.
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), usb=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
set $upstream http://frontend:80;
|
set $upstream http://frontend:80;
|
||||||
proxy_pass $upstream;
|
proxy_pass $upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Dev-only: mounted into /etc/nginx/primora-extra/ by docker-compose.dev.yml.
|
||||||
|
# Included inside the default server block — do not add a server{} here.
|
||||||
|
location /mailpit/ {
|
||||||
|
set $upstream http://mailpit:8025;
|
||||||
|
rewrite ^/mailpit/?(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
Executable
+86
@@ -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
@@ -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
Reference in new issue
Block a user